Interactive visualization of Gmail and Google Calendar communication networks as a force-directed graph
TypeScript
12
310 commits
updated Sep 30, 2026
Quick start · Run without Docker · Using the app · Privacy · Getting your Google data · Configuration · Troubleshooting · Project structure · Contributing
Visualize your Gmail and Google Calendar communication network as an interactive graph. Import your Google Takeout export, and the app extracts your contacts and meeting co-attendees, filters out spam and automated senders, and lays everyone out in a D3.js force-directed graph with you at the centre. Everything runs locally.
See it running → — a write-up about the project, with a video walkthrough of the graph.
See the graph in a few minutes on a synthetic mailbox that ships with the repo — nineteen invented messages, no personal data involved. All you need is Docker with Compose v2.27+.
git clone https://github.com/yegormishchuk/gmail-contact-graph.git
cd gmail-contact-graph
mkdir -p data/Email && cp gmail-mbox-parser/tests/fixtures/sample.mbox data/Email/
docker compose up -d webapp
git clone https://github.com/yegormishchuk/gmail-contact-graph.git
cd gmail-contact-graph
New-Item -ItemType Directory -Force data/Email
Copy-Item gmail-mbox-parser/tests/fixtures/sample.mbox data/Email/
docker compose up -d webapp
The first start builds the image, which takes several minutes — the parsers compile SQLite from source. Later starts reuse the cache.
Open http://127.0.0.1:5000. On the import screen pick
sample.mbox, enter you@example.com as your address (every message in the
sample is to or from it), and press Import. The result is a graph of seven
contacts.
Your own mail next. Request your export now — it can take hours to arrive;
see Getting your Google data. When it does, put
the .mbox in data/Email/, click Re-import in the header, pick your file
and enter your own address. The new graph replaces the sample one.
Stop with docker compose stop webapp, follow the logs with
docker compose logs -f webapp. After a git pull, rebuild with
docker compose up -d --build webapp — otherwise Compose keeps running the old
image without saying so.
Needs Rust 1.87+ and Node.js 20.19+; on Linux also pkg-config and the
OpenSSL headers. make is optional. Install commands for every platform are in
docs/install.md.
git clone https://github.com/yegormishchuk/gmail-contact-graph.git
cd gmail-contact-graph/gmail-contact-graph
make setup
make run
make setup installs the webapp's dependencies, builds it, and builds the two
parsers the import screen runs. Open
http://127.0.0.1:5000 and import from the screen that
greets you, as in the Quick start: put the .mbox in
data/Email/ at the repository root, and the calendar .ics files, if you
have them, in data/Calendar/.
To try it on the sample first, keep it in its own data directory so it never mixes with your real one:
mkdir -p ../data/demo/Email && cp ../gmail-mbox-parser/tests/fixtures/sample.mbox ../data/demo/Email/
make run DATA_DIR=../data/demo
makecd gmail-mbox-parser && cargo build --release --bin fill_db && cd ..
cd calendar-parser && cargo build --release --bin fill_events && cd ..
cd gmail-contact-graph/webapp
npm install
npm run build
npm start
For development with hot reload, make dev starts the API server on port 5000
and the Vite client on 3000.
The parsers can also be run by hand from the command line — for scripting, or
for the ranking .txt files. That path is documented separately in
docs/cli.md.
Importing. The import screen lists the .mbox files in data/Email/
(Refresh list after adding one), asks for your Gmail address — the centre
of the graph — and optionally your name. If there are .ics files in
data/Calendar/, the Calendar box is ticked and they are imported too. A
mailbox of a gigabyte or so takes a few minutes; the graph opens by itself.
Re-import in the header runs it again later, for a newer export, while the
current graph stays up.
Views. The filter panel switches what the graph shows:
| Mode | What it shows |
|---|---|
| Overall | Top contacts across mail and calendar, ranked by a combined score |
| Gmail | Email contacts only, ranked by a composite email score |
| Calendar | Meeting co-attendees, ranked by shared meetings |
| Message Groups | People who appear together on the same emails |
| Organizations | Contacts grouped by email domain |
| Event Groups | Clusters per recurring event |
Calendar, Overall and Event Groups need a calendar import; without one only the mail-based views are populated. Statistics in the header summarizes the mailbox.
Correcting the filter. Spam filtering is heuristic, so it gets some contacts wrong. The 🗑 button next to a contact marks it as not human and removes it; the Spam tab lists everything that was filtered out, with a not spam button to bring a contact back. These edits are kept across a re-import of the same mailbox.
Everything runs on your machine. The parsers read your local Takeout export and
write SQLite files into data/; the webapp server binds to 127.0.0.1 only (and
under Docker, the port is published on 127.0.0.1 only), so it is reachable
from your own machine and not from the rest of your network. Nothing is
uploaded, and data/ is gitignored so your mail can't be committed by
accident.
The one exception is opt-in. If you set HF_API_KEY, contact names and email
addresses — never subjects or message bodies — are sent to the Hugging Face
API to classify them as human or automated. Leave HF_API_KEY empty and the
project makes no outbound network requests at all.
.zip,
and the largest file size, so the mailbox is not split across archives.Takeout/Mail/All mail Including Spam and Trash.mbox; the calendars are
.ics files under Takeout/Calendar/..mbox into data/Email/ and the .ics files into
data/Calendar/. Any file name works — you pick the mailbox on the import
screen.Nothing is required: the import screen asks for your address. Optional settings
live in one project-root .env, copied from the template:
cp .env.example .env
That file is read by the webapp, the parsers and Docker Compose. A value passed
on the make command line (make run PORT=5055) overrides it.
| Variable | Default | Meaning |
|---|---|---|
USER_EMAIL | — | Your Gmail address, pre-filled on the import screen. |
PORT | 5000 | Server port (under Docker, also the published host port). |
HOST | 127.0.0.1 | Bind address. Loopback keeps your mailbox off the network. |
DATA_DIR | ../data | Where exports and generated files live. |
CONTACTS_DB_FILE | $DATA_DIR/contacts.db | The database the webapp serves and imports into. |
MBOX_DIR | $DATA_DIR/Email | Where .mbox files are looked for. |
CALENDAR_DIR | $DATA_DIR/Calendar | Where .ics files are looked for. |
FILL_DB_BIN, FILL_EVENTS_BIN | the target/release builds | Parser binaries the import screen runs. |
ALLOWED_ORIGINS | empty | Comma-separated origins allowed cross-origin requests; only for a deliberate cross-origin setup. |
UID, GID | 1000 | Docker only: owner of the files containers write — see Troubleshooting. |
HF_API_KEY, HF_MODEL, HF_BATCH_SIZE, HF_TIMEOUT | — | The AI spam filter, below. |
Relative paths resolve against gmail-contact-graph/ for a native run. Under
Docker, DATA_DIR is instead the host directory mounted into the container,
relative to the repository root (default ./data) — so pass a native value on
the command line (make run DATA_DIR=../data/demo) rather than putting it in
.env. The variables only the command-line parsers use are listed in
docs/cli.md.
With HF_API_KEY set, the mbox parser additionally verifies borderline contacts
against a hosted LLM (HF_MODEL, default meta-llama/Llama-3.1-8B-Instruct;
HF_BATCH_SIZE contacts per request, HF_TIMEOUT seconds per request). It
takes effect during an import, so set it before importing; the import screen
shows whether it is on. Only contact names and email addresses are sent, never
subjects or bodies (see Privacy).
Beta. This step is not deterministic. Which contacts survive depends entirely on the model you point
HF_MODELat, and the same model can return different verdicts on different runs — hosted models are also updated and retired without notice. Treat the result as a suggestion, not a stable classification; the manual corrections let you fix it. Everything else in the pipeline is deterministic and unaffected by this setting.
Leave it empty for the sample mailbox: its invented addresses are exactly the kind of input the classifier judges unpredictably.
An import failed. The graph you had is untouched: an import writes
contacts.db.new and swaps it in only on success. The error is shown in the
app; under Docker, docker compose logs webapp has the details. The
database from before the last successful import is kept as
data/contacts.db.prev for manual recovery.
The import screen says a parser is not built. Run make build-parsers in
gmail-contact-graph/ (needs Rust), then Refresh. The Docker image always
includes them.
Several .mbox files. An import
reads exactly one mailbox and replaces the previous data, so importing two
files one after the other leaves you with only the second. Import the export
that covers everything — Takeout's All mail file.
No calendar views. Calendar, Overall and Event Groups stay empty without a
calendar import: put the .ics files in data/Calendar/ and Re-import
with the Calendar box ticked.
Docker on Linux or macOS — permission errors in data/. Containers run as
UID 1000 by default. If that is not you, tell Compose your IDs:
echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
Docker on Windows — slow imports. data/ runs to a couple of gigabytes,
and Docker Desktop reaches Windows paths (C:\...) through a translation layer
that makes large reads and SQLite noticeably slower. Clone into the WSL2
filesystem (\\wsl$\...) rather than working under /mnt/c/....
Docker is running old code. docker compose up reuses whatever image is
already built. After a git pull or a source edit:
docker compose up -d --build webapp.
gmail-contact-graph/
├── gmail-mbox-parser/ # Rust: fill_db parses an .mbox into SQLite, spam filter,
│ # generate_rankings (tools/)
├── calendar-parser/ # Rust: fill_events parses .ics into the same database
├── gmail-contact-graph/ # Node.js webapp
│ └── webapp/packages/
│ ├── shared/ # TypeScript types
│ ├── server/ # Express API, runs the parsers on import
│ └── client/ # React + D3.js graph
├── docker/ # Dockerfiles and entrypoints
├── docs/ # install guide, command-line pipeline
├── scripts/ # helpers for generating synthetic data
└── data/ # your exports and the database (gitignored)
Inside data/:
data/
├── Email/ # input: *.mbox
├── Calendar/ # input: *.ics
├── contacts.db # the database the webapp serves: contacts, mails, events
├── contacts.db.prev # the database before the last import
├── benchmarks/ # timings.jsonl — how long each stage of each import took
└── rankings/ # *_ranking.txt, written only by the command-line pipeline
Pull requests are welcome. A few things specific to this repository:
You do not need a mailbox of your own. The parser ships with a synthetic
19-message mbox in gmail-mbox-parser/tests/fixtures/, and cargo test --test e2e
runs the entire pipeline against it. To see the result in the browser instead,
follow the Quick start.
Never commit a real export. *.mbox is gitignored so that a Takeout file
cannot be added by accident. The fixtures directory is the single exception,
and every address in it is invented.
Run what CI runs, before pushing. In each of the three crates:
cargo fmt --check
cargo clippy --all-targets -- -D warnings # warnings fail the build
cargo test
and for the webapp, from gmail-contact-graph/webapp:
npm run lint && npm run build && npm test
main is protected. Push a branch and open a pull request: direct pushes
are rejected, and the six required CI checks (the five rust jobs and
webapp) must pass; the docker job runs too but does not block a merge. No
review approval is required.
Commit messages follow Conventional Commits:
feat:, fix:, docs:, chore:, ci:, test:, optionally scoped as
fix(server): ....
Looking for somewhere to start? The two address-parsing defects under Known
limitations in CHANGELOG.md each have a failing test waiting in
gmail-mbox-parser/src/email.rs — remove the #[ignore] and make it pass.
Copyright 2026 Yegor Mishchuk
Licensed under the Apache License, Version 2.0. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. See LICENSE for the full text and NOTICE for attribution requirements.
TypeScript
54.0%
Rust
35.3%
CSS
5.7%
Dockerfile
1.8%
Makefile
1.7%
Shell
1.1%
Interactive visualization of Gmail and Google Calendar communication networks as a force-directed graph
TypeScript
12
310 commits
updated Sep 30, 2026
Quick start · Run without Docker · Using the app · Privacy · Getting your Google data · Configuration · Troubleshooting · Project structure · Contributing
Visualize your Gmail and Google Calendar communication network as an interactive graph. Import your Google Takeout export, and the app extracts your contacts and meeting co-attendees, filters out spam and automated senders, and lays everyone out in a D3.js force-directed graph with you at the centre. Everything runs locally.
See it running → — a write-up about the project, with a video walkthrough of the graph.
See the graph in a few minutes on a synthetic mailbox that ships with the repo — nineteen invented messages, no personal data involved. All you need is Docker with Compose v2.27+.
git clone https://github.com/yegormishchuk/gmail-contact-graph.git
cd gmail-contact-graph
mkdir -p data/Email && cp gmail-mbox-parser/tests/fixtures/sample.mbox data/Email/
docker compose up -d webapp
git clone https://github.com/yegormishchuk/gmail-contact-graph.git
cd gmail-contact-graph
New-Item -ItemType Directory -Force data/Email
Copy-Item gmail-mbox-parser/tests/fixtures/sample.mbox data/Email/
docker compose up -d webapp
The first start builds the image, which takes several minutes — the parsers compile SQLite from source. Later starts reuse the cache.
Open http://127.0.0.1:5000. On the import screen pick
sample.mbox, enter you@example.com as your address (every message in the
sample is to or from it), and press Import. The result is a graph of seven
contacts.
Your own mail next. Request your export now — it can take hours to arrive;
see Getting your Google data. When it does, put
the .mbox in data/Email/, click Re-import in the header, pick your file
and enter your own address. The new graph replaces the sample one.
Stop with docker compose stop webapp, follow the logs with
docker compose logs -f webapp. After a git pull, rebuild with
docker compose up -d --build webapp — otherwise Compose keeps running the old
image without saying so.
Needs Rust 1.87+ and Node.js 20.19+; on Linux also pkg-config and the
OpenSSL headers. make is optional. Install commands for every platform are in
docs/install.md.
git clone https://github.com/yegormishchuk/gmail-contact-graph.git
cd gmail-contact-graph/gmail-contact-graph
make setup
make run
make setup installs the webapp's dependencies, builds it, and builds the two
parsers the import screen runs. Open
http://127.0.0.1:5000 and import from the screen that
greets you, as in the Quick start: put the .mbox in
data/Email/ at the repository root, and the calendar .ics files, if you
have them, in data/Calendar/.
To try it on the sample first, keep it in its own data directory so it never mixes with your real one:
mkdir -p ../data/demo/Email && cp ../gmail-mbox-parser/tests/fixtures/sample.mbox ../data/demo/Email/
make run DATA_DIR=../data/demo
makecd gmail-mbox-parser && cargo build --release --bin fill_db && cd ..
cd calendar-parser && cargo build --release --bin fill_events && cd ..
cd gmail-contact-graph/webapp
npm install
npm run build
npm start
For development with hot reload, make dev starts the API server on port 5000
and the Vite client on 3000.
The parsers can also be run by hand from the command line — for scripting, or
for the ranking .txt files. That path is documented separately in
docs/cli.md.
Importing. The import screen lists the .mbox files in data/Email/
(Refresh list after adding one), asks for your Gmail address — the centre
of the graph — and optionally your name. If there are .ics files in
data/Calendar/, the Calendar box is ticked and they are imported too. A
mailbox of a gigabyte or so takes a few minutes; the graph opens by itself.
Re-import in the header runs it again later, for a newer export, while the
current graph stays up.
Views. The filter panel switches what the graph shows:
| Mode | What it shows |
|---|---|
| Overall | Top contacts across mail and calendar, ranked by a combined score |
| Gmail | Email contacts only, ranked by a composite email score |
| Calendar | Meeting co-attendees, ranked by shared meetings |
| Message Groups | People who appear together on the same emails |
| Organizations | Contacts grouped by email domain |
| Event Groups | Clusters per recurring event |
Calendar, Overall and Event Groups need a calendar import; without one only the mail-based views are populated. Statistics in the header summarizes the mailbox.
Correcting the filter. Spam filtering is heuristic, so it gets some contacts wrong. The 🗑 button next to a contact marks it as not human and removes it; the Spam tab lists everything that was filtered out, with a not spam button to bring a contact back. These edits are kept across a re-import of the same mailbox.
Everything runs on your machine. The parsers read your local Takeout export and
write SQLite files into data/; the webapp server binds to 127.0.0.1 only (and
under Docker, the port is published on 127.0.0.1 only), so it is reachable
from your own machine and not from the rest of your network. Nothing is
uploaded, and data/ is gitignored so your mail can't be committed by
accident.
The one exception is opt-in. If you set HF_API_KEY, contact names and email
addresses — never subjects or message bodies — are sent to the Hugging Face
API to classify them as human or automated. Leave HF_API_KEY empty and the
project makes no outbound network requests at all.
.zip,
and the largest file size, so the mailbox is not split across archives.Takeout/Mail/All mail Including Spam and Trash.mbox; the calendars are
.ics files under Takeout/Calendar/..mbox into data/Email/ and the .ics files into
data/Calendar/. Any file name works — you pick the mailbox on the import
screen.Nothing is required: the import screen asks for your address. Optional settings
live in one project-root .env, copied from the template:
cp .env.example .env
That file is read by the webapp, the parsers and Docker Compose. A value passed
on the make command line (make run PORT=5055) overrides it.
| Variable | Default | Meaning |
|---|---|---|
USER_EMAIL | — | Your Gmail address, pre-filled on the import screen. |
PORT | 5000 | Server port (under Docker, also the published host port). |
HOST | 127.0.0.1 | Bind address. Loopback keeps your mailbox off the network. |
DATA_DIR | ../data | Where exports and generated files live. |
CONTACTS_DB_FILE | $DATA_DIR/contacts.db | The database the webapp serves and imports into. |
MBOX_DIR | $DATA_DIR/Email | Where .mbox files are looked for. |
CALENDAR_DIR | $DATA_DIR/Calendar | Where .ics files are looked for. |
FILL_DB_BIN, FILL_EVENTS_BIN | the target/release builds | Parser binaries the import screen runs. |
ALLOWED_ORIGINS | empty | Comma-separated origins allowed cross-origin requests; only for a deliberate cross-origin setup. |
UID, GID | 1000 | Docker only: owner of the files containers write — see Troubleshooting. |
HF_API_KEY, HF_MODEL, HF_BATCH_SIZE, HF_TIMEOUT | — | The AI spam filter, below. |
Relative paths resolve against gmail-contact-graph/ for a native run. Under
Docker, DATA_DIR is instead the host directory mounted into the container,
relative to the repository root (default ./data) — so pass a native value on
the command line (make run DATA_DIR=../data/demo) rather than putting it in
.env. The variables only the command-line parsers use are listed in
docs/cli.md.
With HF_API_KEY set, the mbox parser additionally verifies borderline contacts
against a hosted LLM (HF_MODEL, default meta-llama/Llama-3.1-8B-Instruct;
HF_BATCH_SIZE contacts per request, HF_TIMEOUT seconds per request). It
takes effect during an import, so set it before importing; the import screen
shows whether it is on. Only contact names and email addresses are sent, never
subjects or bodies (see Privacy).
Beta. This step is not deterministic. Which contacts survive depends entirely on the model you point
HF_MODELat, and the same model can return different verdicts on different runs — hosted models are also updated and retired without notice. Treat the result as a suggestion, not a stable classification; the manual corrections let you fix it. Everything else in the pipeline is deterministic and unaffected by this setting.
Leave it empty for the sample mailbox: its invented addresses are exactly the kind of input the classifier judges unpredictably.
An import failed. The graph you had is untouched: an import writes
contacts.db.new and swaps it in only on success. The error is shown in the
app; under Docker, docker compose logs webapp has the details. The
database from before the last successful import is kept as
data/contacts.db.prev for manual recovery.
The import screen says a parser is not built. Run make build-parsers in
gmail-contact-graph/ (needs Rust), then Refresh. The Docker image always
includes them.
Several .mbox files. An import
reads exactly one mailbox and replaces the previous data, so importing two
files one after the other leaves you with only the second. Import the export
that covers everything — Takeout's All mail file.
No calendar views. Calendar, Overall and Event Groups stay empty without a
calendar import: put the .ics files in data/Calendar/ and Re-import
with the Calendar box ticked.
Docker on Linux or macOS — permission errors in data/. Containers run as
UID 1000 by default. If that is not you, tell Compose your IDs:
echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
Docker on Windows — slow imports. data/ runs to a couple of gigabytes,
and Docker Desktop reaches Windows paths (C:\...) through a translation layer
that makes large reads and SQLite noticeably slower. Clone into the WSL2
filesystem (\\wsl$\...) rather than working under /mnt/c/....
Docker is running old code. docker compose up reuses whatever image is
already built. After a git pull or a source edit:
docker compose up -d --build webapp.
gmail-contact-graph/
├── gmail-mbox-parser/ # Rust: fill_db parses an .mbox into SQLite, spam filter,
│ # generate_rankings (tools/)
├── calendar-parser/ # Rust: fill_events parses .ics into the same database
├── gmail-contact-graph/ # Node.js webapp
│ └── webapp/packages/
│ ├── shared/ # TypeScript types
│ ├── server/ # Express API, runs the parsers on import
│ └── client/ # React + D3.js graph
├── docker/ # Dockerfiles and entrypoints
├── docs/ # install guide, command-line pipeline
├── scripts/ # helpers for generating synthetic data
└── data/ # your exports and the database (gitignored)
Inside data/:
data/
├── Email/ # input: *.mbox
├── Calendar/ # input: *.ics
├── contacts.db # the database the webapp serves: contacts, mails, events
├── contacts.db.prev # the database before the last import
├── benchmarks/ # timings.jsonl — how long each stage of each import took
└── rankings/ # *_ranking.txt, written only by the command-line pipeline
Pull requests are welcome. A few things specific to this repository:
You do not need a mailbox of your own. The parser ships with a synthetic
19-message mbox in gmail-mbox-parser/tests/fixtures/, and cargo test --test e2e
runs the entire pipeline against it. To see the result in the browser instead,
follow the Quick start.
Never commit a real export. *.mbox is gitignored so that a Takeout file
cannot be added by accident. The fixtures directory is the single exception,
and every address in it is invented.
Run what CI runs, before pushing. In each of the three crates:
cargo fmt --check
cargo clippy --all-targets -- -D warnings # warnings fail the build
cargo test
and for the webapp, from gmail-contact-graph/webapp:
npm run lint && npm run build && npm test
main is protected. Push a branch and open a pull request: direct pushes
are rejected, and the six required CI checks (the five rust jobs and
webapp) must pass; the docker job runs too but does not block a merge. No
review approval is required.
Commit messages follow Conventional Commits:
feat:, fix:, docs:, chore:, ci:, test:, optionally scoped as
fix(server): ....
Looking for somewhere to start? The two address-parsing defects under Known
limitations in CHANGELOG.md each have a failing test waiting in
gmail-mbox-parser/src/email.rs — remove the #[ignore] and make it pass.
Copyright 2026 Yegor Mishchuk
Licensed under the Apache License, Version 2.0. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. See LICENSE for the full text and NOTICE for attribution requirements.
TypeScript
54.0%
Rust
35.3%
CSS
5.7%
Dockerfile
1.8%
Makefile
1.7%
Shell
1.1%