omni-dev 0.41.0

AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, Datadog, Gmail, and Drive.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
# Gmail Integration

omni-dev exposes read access (and, opt-in, label mutation) to the Gmail v1
API through the `omni-dev gmail` command tree, with a matching `gmail_*` MCP
tool for every read-only subcommand. Authentication and output formats are
identical across both surfaces; the MCP tools simply return YAML matching the
CLI's `-o yaml` output. For the MCP-tool reference (parameters only), see
[docs/mcp.md](mcp.md#gmail-6-tools).

New to this integration? Follow the
[Gmail Quickstart](gmail-quickstart.md) for a linear, zero-to-synced-archive
walkthrough — this page is the topic-by-topic reference.

## Table of Contents

1. [Prerequisites]#prerequisites
2. [Authentication]#authentication
3. [Multiple accounts]#multiple-accounts
4. [Output formats]#output-formats
5. [Search]#search
6. [Messages]#messages
7. [Threads]#threads
8. [Labels]#labels
9. [Sync]#sync
10. [Sync all accounts]#sync-all-accounts
11. [Extract attachments]#extract-attachments
12. [Render]#render
13. [Rate limits and retry behaviour]#rate-limits-and-retry-behaviour
14. [Troubleshooting]#troubleshooting
15. [See also]#see-also

## Prerequisites

Gmail read scopes are Google **restricted scopes** — an application
distributed to third parties that requests them must pass a Google CASA
security assessment with annual recertification. omni-dev doesn't carry that
burden, so **each user creates their own Google Cloud OAuth2 client**:

1. Create (or reuse) a project in the [Google Cloud console].
2. Enable the **Gmail API** for that project.
3. Create an OAuth2 client of type **Desktop app** (not "Web application" —
   the loopback-redirect flow below requires it).
4. Note the client's **Client ID** and **Client secret**.
5. When you run `gmail auth login` below, Google's consent screen lists
   Gmail as its **own separate permission tick-box**, distinct from the
   basic profile/email checkboxes it also requests. **Explicitly tick
   it.** Leaving it unticked makes login fail immediately with an error
   naming the scopes Google actually granted (e.g. `openid`, `email`,
   `profile` — no Gmail scope at all) instead of writing an unusable
   refresh token to `settings.json`. See
   [Troubleshooting]#no-gmail-scope-was-granted for the exact error.

**Prominent callout:** a freshly created OAuth2 client's consent screen
defaults to **Testing** publishing status. In that status, Google expires
issued refresh tokens after **7 days**, so `omni-dev gmail auth login` will
need to be re-run weekly until you push the project to **In production**
(no Google verification review is required below 100 test users for a
self-scoped read/label-modify request). See
[Troubleshooting](#invalid_grant) for the error this produces.

[Google Cloud console]: https://console.cloud.google.com/

## Authentication

### Environment variables

| Variable               | Purpose                                                        | Default |
|-------------------------|-----------------------------------------------------------------|---------|
| `GMAIL_CLIENT_ID`       | OAuth2 client id from your own Google Cloud project (required). | _none_  |
| `GMAIL_CLIENT_SECRET`   | OAuth2 client secret for the same client (required).            | _none_  |
| `GMAIL_REFRESH_TOKEN`   | Written by `gmail auth login`; not meant to be hand-set.        | _none_  |
| `GMAIL_SCOPE`           | Written by `gmail auth login`; records the granted scope (`gmail.readonly` or `gmail.modify`) so `auth status` can report it without a network call. | _none_ |
| `GMAIL_API_URL`         | Explicit API base URL; overrides the real `gmail.googleapis.com` host entirely. Use for a proxy or a forced egress gateway. | _unset_ |

`GMAIL_CLIENT_ID`/`GMAIL_CLIENT_SECRET` can reach `gmail auth login` three
ways: run `omni-dev gmail auth import [PATH]` first to read them straight
out of the `client_secret.json` Google Cloud Console hands out (the
secret never transits a shell, an env var, or an agent's context — see
[below](#interactive-setup)); set them by hand (in your shell profile, or
in `~/.omni-dev/settings.json`'s `env` map); or leave them unset and
`gmail auth login` prompts for them interactively — the client id echoes
normally, the secret does not.

### Interactive setup

If you downloaded the OAuth client's `client_secret.json` from the Cloud
console, import it directly — the client id/secret are saved to
`settings.json` without ever passing through your shell:

```bash
$ omni-dev gmail auth import
Found ~/Downloads/client_secret_1234.apps.googleusercontent.com.json (Desktop app client)
Client id/secret saved to ~/.omni-dev/settings.json

Run `omni-dev gmail auth login` to authorize.
```

`PATH` is optional: discovery tries `$GMAIL_CLIENT_SECRET_FILE`, then
`~/.config/gws/client_secret.json`, then the most-recently-modified
`~/Downloads/client_secret_*.apps.googleusercontent.com.json` (the Cloud
console's default download name).

Then run `auth login` — if `auth import` wasn't run and the client
id/secret aren't in the environment or `settings.json` either, it prompts
for them instead:

```bash
$ omni-dev gmail auth login

Credentials saved to ~/.omni-dev/settings.json
  Granted scope: https://www.googleapis.com/auth/gmail.readonly

Run `omni-dev gmail auth status` to verify.
```

This opens a browser to Google's consent screen via a loopback OAuth2
authorization-code + PKCE flow (see [ADR-0063](adrs/adr-0063.md)); once you
approve, the refresh token is written to `~/.omni-dev/settings.json`. Pass
`--modify` to additionally request the `gmail.modify` scope, needed for
`gmail label add`/`remove`:

```bash
$ omni-dev gmail auth login --modify
```

### Verifying credentials

```bash
$ omni-dev gmail auth status
Checking Gmail authentication...
Authenticated as: user@example.com
Messages in mailbox: 5842
Granted scope: gmail.readonly
```

This calls `users.getProfile`, a live network call. The matching MCP tool,
`gmail_auth_status`, returns boolean presence flags and the granted scope
only — it never calls the Gmail API, so it can't confirm the refresh token
is still accepted.

Pass `--all` to report every configured named account (see
[Multiple accounts](#multiple-accounts)) in one call instead of just the
resolved one:

```bash
$ omni-dev gmail auth status --all

== work ==
Checking Gmail authentication...
Authenticated as: alice@work.com
Messages in mailbox: 12034
Granted scope: gmail.readonly, gmail.modify

== personal ==
Checking Gmail authentication...
Authenticated as: alice@gmail.com
Messages in mailbox: 5842
Granted scope: gmail.readonly
```

`--all` degenerates to the single-account output above when no named
accounts are configured. Each successful check also backfills that
account's cached `email_address` in `settings.json` if it isn't already
set (never used for authentication itself — only for the browser-profile
targeting below) — an explicit value, whether you set it by hand or a
previous check backfilled it, is never overwritten.

### Removing credentials

```bash
$ omni-dev gmail auth logout
Gmail credentials removed from ~/.omni-dev/settings.json
```

Idempotent: if no credentials are configured, it prints
`No Gmail credentials were configured.` and exits successfully. Removes
the resolved account (see [Multiple accounts](#multiple-accounts) below) —
pass `--account NAME` to target a specific named account.

## Multiple accounts

`--profile` (see [Prerequisites](#prerequisites) and
[ADR-0045](adrs/adr-0045.md)) selects a whole credential bundle — Atlassian,
Datadog, the Claude API key, *and* Gmail all at once. That's the wrong tool
for "I just want a second mailbox while everything else about my
environment stays the same," so Gmail accounts are a second, independent
axis: named entries in a `gmail` block of `~/.omni-dev/settings.json`,
selected per invocation via an `--account NAME` flag or the
`OMNI_DEV_GMAIL_ACCOUNT` environment variable (AWS-CLI style, mirroring
`--profile`). `--account` is scoped to the `gmail` command tree — usable
either right after `gmail` or after the leaf subcommand
(`gmail --account work search ...` or `gmail search --account work ...`),
but not before `gmail` itself, since it isn't a CLI-wide flag. See
[ADR-0066](adrs/adr-0066.md) for the full design rationale.

**Zero-migration guarantee:** an installation that never configures a named
account behaves exactly as before — every command in this guide works
identically whether or not you ever touch `--account`.

### Configuring accounts

Create a second (or subsequent) account the same way you configured the
first, adding `--account NAME`:

```bash
$ omni-dev gmail auth import --account personal
$ omni-dev gmail auth login --account personal
```

`--account` need not already exist — `auth login`/`auth import` are how an
account comes into existence. Every other Gmail command (`search`, `read`,
`thread`, `label`, `sync`, `auth status`, `auth logout`) also accepts
`--account NAME` to target a specific mailbox, and the MCP tools accept the
equivalent `account` parameter.

If you already have a single-account setup and want to migrate it into a
named account instead of starting over:

```bash
$ omni-dev gmail account import-legacy --name work
Legacy Gmail credentials migrated to account 'work'. Legacy credentials left
in place — pass --remove-legacy to delete them.
```

Non-destructive by default; pass `--remove-legacy` to delete the old
credentials once you've confirmed the migration worked. `import-legacy`
takes `--name`, not `--account` — `--account` is inherited by every `gmail`
subcommand (including `import-legacy`) and selects an *existing* account,
while this one names the account being *created*, and clap doesn't allow a
subcommand to redefine an inherited flag. `--name` defaults to the literal
name `default` if omitted.

**One sharp edge:** the moment a first named account is created — via
`auth login --account NAME` or `account import-legacy` — while legacy
credentials still exist, those legacy credentials become **shadowed**: a
no-`--account` invocation from then on resolves through the named-account
rules below and no longer falls back to them. omni-dev prints a one-time
stderr notice at that exact transition, pointing at `gmail account
import-legacy` (to migrate any other legacy account) or `gmail auth logout`
(to remove the now-unreachable legacy credentials).

### Managing accounts

```bash
$ omni-dev gmail account list
NAME      EMAIL              SCOPE                          DEFAULT
personal  alice@gmail.com    gmail.readonly                 
work      alice@work.com     gmail.readonly, gmail.modify   *

$ omni-dev gmail account set-default work
Default Gmail account set to 'work'.
```

`gmail account list` reads only `settings.json` — no network call, no
secret ever rendered. The matching MCP tool is `gmail_account_list`; call
it before passing an `account` parameter to any other Gmail tool, since an
unknown name is a hard error rather than a silent fallback.

### Resolution order

When a command runs, the account it uses is resolved in this order:

1. A literal `GMAIL_CLIENT_ID`/`GMAIL_CLIENT_SECRET`/`GMAIL_REFRESH_TOKEN`
   set directly in the process environment bypasses account resolution
   entirely — today's exact single-account behaviour, unchanged.
2. `--account NAME` / `OMNI_DEV_GMAIL_ACCOUNT`, if set, selects that named
   account. An unknown name is a hard error listing the accounts that
   *are* configured — never a silent fallback to the wrong mailbox.
3. No explicit account, with one or more named accounts configured: the
   configured default (`gmail account set-default`) if it still names a
   real account, else the sole account if exactly one is configured, else
   a hard error naming both remedies.
4. No named accounts configured at all: falls through unchanged to the
   pre-multi-account resolution (process env → the active `--profile`'s
   `env` map → the base `env` map) — the zero-migration path.

### Browser profile targeting

With several named accounts, `gmail auth login` opening whatever profile
your default browser happens to be on means you have to switch Google
identities by hand on the consent screen — easy to get wrong, and it can
land the refresh token on the wrong mailbox entirely. Two escape hatches,
both configured per account in `settings.json`'s `gmail.accounts.<name>`
and both opt-in — neither changes behaviour for an account that sets
neither:

**Manual — `browser_command`.** An explicit launch command, with `{url}`
substituted for the authorization URL (or appended, if no `{url}`
placeholder is present). Takes precedence over automatic resolution below.
Works for any browser, not just Chrome:

```json
"gmail": {
  "accounts": {
    "jky.greens": {
      "browser_command": "open -na \"Google Chrome\" --args --profile-directory=\"Profile 7\" {url}"
    }
  }
}
```

**Automatic — `chrome_profile_from_email`.** Set this `true` alongside
`email_address` (see [Verifying credentials](#verifying-credentials) above
— set it by hand, or let `gmail auth status --all` backfill it after a
first login) and `gmail auth login` looks up which local Chrome profile is
signed into that address, launching the authorization URL targeting it
instead of the OS default browser:

```json
"gmail": {
  "accounts": {
    "jky.greens": {
      "email_address": "jky.greens@example.com",
      "chrome_profile_from_email": true
    }
  }
}
```

Chrome-only for now (no Chromium/Brave/Edge support yet — use
`browser_command` for those). Resolution reads Chrome's own `Local State`
file and never guesses: zero matching profiles or more than one profile
signed into the same address both fall back to the OS default browser
rather than picking one, same as Chrome not being installed or the file
being unreadable — resolution failure is always a fallback, never a login
failure. See [ADR-0067](adrs/adr-0067.md) for the full design rationale.

## Output formats

Every subcommand that renders a list or record (`search`, `read`, `thread`,
`label list`, `sync`, `sync-all`, `extract-attachments`, `render`, `account
list`) accepts `-o <format>` (`table` / `json` / `yaml` / `yamls` / `jsonl`,
default `table`) — the same convention as every other `omni-dev` domain
(see [ADR-0046](adrs/adr-0046.md)). `auth login`/`auth logout`/`auth
status`, `label add`/`label remove`, and `account set-default`/`account
import-legacy` print a fixed human-readable status line instead and have no
`-o` flag. `--out-file` exists only on `gmail read`, the one command with a
naturally file-shaped payload (a message body/attachment source worth
writing to disk); no other Gmail leaf has a use for it.

`gmail read` additionally accepts `-o markdown` — a human-readable
Markdown rendering of the message rather than a machine-readable format;
see [Messages](#messages) and [Render](#render).

## Search

```bash
$ omni-dev gmail search --query 'label:finance after:2026/01/01' --limit 50
$ omni-dev gmail search --query 'label:finance' --limit 50 --enrich --concurrency 4
```

`--query` uses [Gmail's own search syntax] (the same operators as the Gmail
search box: `from:`, `label:`, `after:`, `has:attachment`, etc.) — omni-dev
does not reinterpret it. `--limit 0` fetches every match up to a 10,000
hard cap, auto-paginating underneath.

By default `search` returns only `id`/`threadId` per hit — `messages.list`
itself never returns more than that, and it's the quota-safe choice. Pass
`--enrich` to add From/Subject/Date/snippet, at the cost of one extra
`messages.get` request **per hit**. `--concurrency` (default 4) bounds how
many of those hydration requests run at once; see
[Rate limits and retry behaviour](#rate-limits-and-retry-behaviour) for the
quota math before raising it or combining `--enrich` with a large `--limit`.

[Gmail's own search syntax]: https://support.google.com/mail/answer/7190

### MCP equivalent(s)

`gmail_search` — same ids-only default; pass `enrich: true` (and optionally
`concurrency`) for the enriched rows.

## Messages

```bash
$ omni-dev gmail read <message-id>
$ omni-dev gmail read <message-id> --detail minimal
$ omni-dev gmail read <message-id> --detail metadata
$ omni-dev gmail read <message-id> --detail raw --out-file message.eml
$ omni-dev gmail read <message-id> -o markdown
$ omni-dev gmail read <message-id> -o markdown --out-file message.md
```

`--detail` controls how much of the message is fetched — named `--detail`,
not `--format`, since `-o/--output` already owns that word for this
project's rendering axis (see [ADR-0046](adrs/adr-0046.md)); the values
match Gmail's own wire values verbatim: `minimal` (only
`id`/`threadId`/`labelIds`/`sizeEstimate` — no headers or body), `metadata`
(headers + snippet only), `full` (default; parsed MIME structure), or `raw`
(the RFC 2822 source, base64url-encoded over the wire — the cheapest way to
get a byte-for-byte copy). `--out-file` writes a flat text rendering to disk
instead of stdout for `minimal`/`metadata`/`full`; for `raw` it decodes the
base64url payload first and writes the literal RFC 2822 bytes, so
`--detail raw --out-file message.eml` produces a genuine `.eml` rather than
still-encoded text.

**`-o markdown`** renders the message as human-readable Markdown: a header
block (Subject/From/To/Cc/Date/Message-Id/In-Reply-To/References, RFC
2047-decoded — unlike the raw wire encoding [Sync](#sync)'s manifest
fields keep), the body (`text/plain` preferred, `text/html` converted to
Markdown otherwise), and an attachment filename list. It always fetches the
complete raw MIME message regardless of `--detail` (rendering needs the
full structure), so `--detail` is ignored when combined with `-o markdown`.
The same rendering function backs [`gmail render`](#render) for already-
archived `.eml` files — `-o markdown` is the live-fetch equivalent, useful
when you want readable text for one message without archiving the whole
mailbox first.

**`--fold-quotes`** (only relevant with `-o markdown`; ignored otherwise,
the reverse of `--detail`'s asymmetry) collapses `>`-quoted reply history
nested more than one level deep into a one-line `*(N quoted lines
omitted)*` marker, so a thread with 10-20+ levels of quoting doesn't drown
its new content in repeated older quotes. The immediately-preceding
reply's quote (depth 1) always stays visible for context; only deeper
nesting folds. Off by default — verbatim rendering is fully
information-preserving, and the full text is one re-render away without
the flag.

### MCP equivalent(s)

`gmail_message_read` — takes the same `format` values (`minimal` /
`metadata` / `full` / `raw`), plus `output_file` (writes to disk and
returns a short YAML summary instead of the inline body — for large
messages/attachments that would exceed the response size limit).

## Threads

```bash
$ omni-dev gmail thread <thread-id>
```

Fetches the whole conversation (`format=full` always — a thread's point is
showing every message in it). No `--format` or `--out-file` flag.

### MCP equivalent(s)

`gmail_thread_read`. Always truncation-guarded — a thread's N messages,
each potentially carrying attachments, is the single highest payload-size
risk on the whole Gmail surface.

## Labels

```bash
$ omni-dev gmail label list
$ omni-dev gmail label add <message-id...> --label IMPORTANT
$ omni-dev gmail label remove <message-id...> --label UNREAD
```

`label add`/`remove` require the `gmail.modify` scope (`gmail auth login
--modify`) — a `gmail.readonly`-only token gets a 403
`insufficientPermissions` error. `label add` is unconditional; `label
remove` prompts for confirmation by default (per [ADR-0027](adrs/adr-0027.md)),
accepting `--force` to skip the prompt and `--dry-run` to preview without
calling the API (`--dry-run` wins if both are set).

### MCP equivalent(s)

`gmail_label_list` ships in this release. A mutating `gmail_label_modify`
tool (add/remove) is planned as a fast-follow — until then, label mutation
is CLI-only.

## Sync

```bash
$ omni-dev gmail sync --output-dir ~/mail-archive
$ omni-dev gmail sync --output-dir ~/mail-archive --query 'label:finance'
$ omni-dev gmail sync --output-dir ~/mail-archive --full
$ omni-dev gmail sync --output-dir ~/mail-archive --dry-run
$ omni-dev gmail sync --output-dir ~/mail-archive --extract-attachments
```

Maintains a durable, greppable local archive of a mailbox — full-fidelity
`.eml` files plus a JSONL manifest — incrementally updated on each run.
Unlike every other Gmail command, `sync` is a genuinely long-running bulk
operation: **a first sync of a several-thousand-message mailbox takes
minutes, not seconds**. A 50k-message mailbox is roughly 15-20 minutes at
Gmail's theoretical 50 msg/s quota ceiling, but real-world throughput
depends on message sizes and network too — a measured run against a
5,824-message mailbox sustained 36.4 msg/s, which extrapolates to roughly
23 minutes for 50k. Either figure is bounded by Gmail's per-second quota
(see
[Rate limits](#rate-limits-and-retry-behaviour) below). A re-run against an
already-synced mailbox with no new mail is fast — typically a single
`history.list` call.

On a terminal, a backfill/`--full`/reconciliation run shows two live
progress indicators on stderr — a listing spinner (pages fetched, ids
discovered so far) and a fetch bar (messages fetched so far out of the
currently-known total, plus a running error count) — updated as the
mailbox is listed and fetched *concurrently*, rather than only printing a
report once the entire run finishes. Total wall-clock time is unchanged
(still bounded by the same per-second quota above); what changes is that
fetching now begins as soon as the first listing page arrives, instead of
waiting for the whole mailbox to be listed first. Pass `--quiet` to
suppress the bars; they're also disabled automatically when stderr isn't a
terminal or when `-o json`/`-o yaml`/`-o yamls`/`-o jsonl` is selected.
Whenever the bars ran (or `--quiet` was passed), the final text report
skips the per-action listing too (see **Report summary** below), since
bars already showed every fetch/delete live and repeating them as text
would just be a second, redundant dump.

**Archive layout:**

```
<output-dir>/
  state.json                  # watermark (historyId) + account identity
  manifest.jsonl               # one record per message: id, thread_id, label_ids,
                                #   internal_date, subject, from, to, rfc822_msgid,
                                #   in_reply_to, references, attachment_count,
                                #   attachment_filenames, path, size, history_id,
                                #   deleted_at (soft-deleted messages only)
  messages/<year>/<month>/<day>/<id>.eml   # sharded by the message's internal_date
  messages/<year>/<month>/<day>/<id>/attachments/<filename>  # only with --extract-attachments
```

`.eml` files are **immutable** once written — Gmail labels aren't part of
the RFC 2822 body, so a label change updates only the manifest record, never
the message file. The manifest is *not* a derived index that could be
regenerated from the `.eml` files; it is the sole record of each message's
Gmail-side metadata (labels, thread, watermark).

**Backfill vs. incremental:** the first run (or `--full`) lists the whole
mailbox and fetches whatever's missing on disk — listing and fetching are
pipelined, so the fetch fan-out for early-listed messages starts
immediately rather than waiting for the whole mailbox to be listed first.
Presence-on-disk is the real idempotence mechanism, so an interrupted
backfill simply picks up where it left off on the next run, no cursor
required. The manifest itself is checkpointed to disk every 200 fetched
messages during a large backfill (not only once at the end), so a crash
loses at most that many messages' worth of already-completed work, not the
whole run. Subsequent runs use `history.list` from the stored watermark,
applying `messagesAdded`/`messagesDeleted`/`labelsAdded`/`labelsRemoved`
events. Google does not guarantee history availability past roughly **one
week**; a `startHistoryId` older than that gets a 404, which `sync` treats
as a signal to fall back to the same full-listing pass as a backfill (not a
silent gap, and not a blind re-download of everything) — the `historyId`
watermark is purely an optimisation over that fallback, never a
correctness requirement. (An incremental run's own `history.list` pass is
not pipelined — it's typically a single page already, so there's little to
overlap; only the full-listing path above gains concurrent
listing+fetching.) A run that hits a per-item error never advances the
watermark, so the next run safely re-examines the same range (already
-archived messages are skipped for free). One exception: a message that
vanishes from the server in the window between being listed and being
fetched (`messages.get` returns a 404 with reason `notFound`) is not an
error — it's recorded as a `Vanished` action instead, since Gmail's
`history.list` and `messages.get` aren't perfectly consistent and retrying
that particular id can never succeed. Withholding the watermark for it
would only wedge the account, re-discovering and re-failing on the same
stale history event on every run for up to a week; see the Troubleshooting
section below.

**`--query` and incremental sync (a known limitation):** `--query` scopes a
backfill/`--full`/reconciliation pass, but `history.list` has no query
filter, so an incremental run cannot re-apply it — newly-arrived mail that
would match your `--query` is only picked up by a later `--full` re-run. If
you sync a query-scoped subset of your mailbox regularly, plan on an
occasional `--full` pass.

**Header fields:** `subject`/`from`/`to`/`rfc822_msgid`/`in_reply_to`/
`references` in the manifest are parsed directly from the already-fetched
raw message bytes (no second network request), and are stored as their raw
wire encoding — non-ASCII subjects encoded per RFC 2047
(`=?UTF-8?B?...?=`) are **not** decoded to human-readable text in this
release. `in_reply_to`/`references` are what let a conversation be
reconstructed from the manifest alone, without re-parsing every `.eml`. To
read an individual archived message with its headers properly decoded, use
[`gmail render`](#render) against its `.eml` file (or `gmail read -o
markdown` for a live, not-yet-archived message) rather than the manifest.

**Attachments:** `attachment_count` and `attachment_filenames` record how
many MIME parts are marked `Content-Disposition: attachment` and whichever
filenames could be parsed from them (including RFC 2231 percent-encoded
filenames), scanned from the same already-decoded bytes — no second fetch.
This is metadata only, computed the same way regardless of
`--extract-attachments` (see [ADR-0065](adrs/adr-0065.md)): attachments
always stay inline inside the `.eml` too (lossless, since `format=raw`
preserves them).

**`--extract-attachments`** additionally writes each message's
`Content-Disposition: attachment` MIME parts to disk as separate files
under `messages/<year>/<month>/<day>/<id>/attachments/<filename>` — a
sibling directory of the message's own `.eml`. Off by default: it's extra
I/O and disk usage per message, and the `.eml` remains the lossless source
of truth either way, so this is purely a convenience projection, never a
new archive contract. Filenames are sanitised against path traversal; a
second attachment in one message that sanitises to an already-used name
gets a `-N` suffix (`image.png` -> `image-1.png`); an attachment with no
usable filename gets a synthesised one. A message that fails to parse as
MIME simply yields no attachment files — it never fails the `.eml` fetch
itself. Because `sync` only ever fetches messages missing on disk
(presence-on-disk is the archive's idempotence mechanism — see above),
turning this flag on does **not** retroactively extract attachments for
messages already archived by an earlier run, even under `--full`; run
[`gmail extract-attachments`](#extract-attachments) with `--archive-dir`
pointed at the same directory instead — it extracts from the `.eml` files
already on disk, no re-fetch required.

**Report summary:** every report — table/text and `-o json`/`-o yaml`/
`-o yamls`/`-o jsonl` alike — ends with an at-a-glance tally, e.g.
`5,794 fetched, 30 deleted, 0 errors` in text output, or an explicit
`summary` field (`fetched`/`would_fetch`/`labels_updated`/`deleted`/
`undeleted`/`would_delete`/`would_undelete`/`errors` counts) in the
structured formats. `-o json`/`-o yaml`/`-o yamls`/`-o jsonl` always
include the full per-action listing alongside `summary` too — the
authoritative, complete record. Text output includes the per-action
listing only when nothing else already showed it: if the live progress
bars ran, or `--quiet` was passed, text output shows just `Note`s, errors,
and the summary — a large sync's per-action listing can run into the
thousands of lines, and printing it again once bars already rendered it
live would just be a second, redundant dump. A non-interactive `stderr`
(no bars possible) still gets the full per-action listing in text, since
it's the only record of what happened in that case.

**`--dry-run`** reports every action sync would take without writing any
file — not `state.json`, not `manifest.jsonl`, not a single `.eml`.

No MCP equivalent — a bulk, potentially long-running filesystem operation
is a poor fit for a synchronous MCP tool call (the same reasoning that kept
label mutation CLI-only above).

## Sync all accounts

```bash
$ omni-dev gmail sync-all
$ omni-dev gmail sync-all --concurrency 10
$ omni-dev gmail sync-all --full --dry-run
$ omni-dev gmail sync-all -o json
```

Runs [`sync`](#sync) for every account listed in `.omni-dev/gmail-sync.yaml`,
concurrently, replacing a wrapper script that loops `gmail sync --account
...` over each mailbox one at a time. Each account keeps its own archive
and its own [rate limit](#rate-limits-and-retry-behaviour) budget — nothing
about a single account's sync changes, only that several now run at once.
See [ADR-0068](adrs/adr-0068.md) for the full design rationale.

**Config file:** `.omni-dev/gmail-sync.yaml`, discovered the same way as
every other `.omni-dev/` file (see
[docs/omni-dev-directory.md](omni-dev-directory.md#gmail-syncyaml)) — walk-up
from the current directory, a `local/` override, `--context-dir`/
`OMNI_DEV_CONFIG_DIR`:

```yaml
concurrency: 20
accounts:
  - account: jky.greens
    output_dir: emails/jky.greens/
  - account: newhoggy
    output_dir: emails/newhoggy/
    query: "-in:spam"
    extract_attachments: true
```

`account` must name an account already configured under
[Multiple accounts](#multiple-accounts) — `gmail-sync.yaml` says only
*which* accounts to sync and *where*, never a second credential store.
`output_dir` resolves relative to the project root (the parent of the
discovered `.omni-dev/`) unless absolute. Unlike every other `.omni-dev/`
config file, a missing, empty, or malformed `gmail-sync.yaml`, or one
naming an account `gmail account list` doesn't know about, is a hard error
before any network call is made — see
[docs/omni-dev-directory.md's Validation behaviour](omni-dev-directory.md#gmail-syncyaml-1).

**`--account` is incompatible with `sync-all`:** the global `--account`/
`OMNI_DEV_GMAIL_ACCOUNT` selector picks one mailbox; `sync-all` always
targets the whole `gmail-sync.yaml` list, so passing both is a hard error
rather than a silent no-op or an ignored flag.

**Concurrency:** two independent caps compose. Each account's own fetch
fan-out is still bounded by the same local concurrency `gmail sync` itself
uses — unaffected by this command. A second, *shared* cap —
`sync-all --concurrency` if given, else `gmail-sync.yaml`'s top-level
`concurrency`, else the same default as `gmail sync --concurrency` — bounds
how many fetch requests are in flight *across every account combined* at
once, so one account can never claim the whole shared budget for itself.
Each account still paces its own requests against its own Gmail quota
independently (see [Rate limits](#rate-limits-and-retry-behaviour) below) —
the shared cap is a purely local resource limit, unrelated to quota
compliance.

**Progress and output:** on the same interactive-terminal condition a single
`gmail sync` uses (`-o table`, not `--quiet`, a `stderr` that's actually a
tty), every account gets its own listing spinner + fetch bar, all registered
on one shared `MultiProgress` — a single shared renderer, rather than each
account's bars fighting another's over the same terminal, is what lets them
all advance concurrently and stay legible. Independent of the bars, a
one-line summary still prints per account as soon as that account finishes
(not only once every account is done), e.g. `jky.greens: 42 fetched, 0
errors`, followed by a trailing `combined: ...` total once every account has
finished — that line prints through the bars (via `suspend`) rather than
racing their redraw. `--quiet` suppresses both the live bars and the
per-account summary lines; the combined total and any per-message error
lines always print regardless. `-o json`/`-o yaml`/`-o yamls`/`-o jsonl`
instead emit one structured record per account (`account`, `actions`,
`errors`, `summary`, and — only for an account whose task failed before
producing a report at all, e.g. bad credentials or a rejected output
directory — `account_error`) plus a `combined_summary`, once every account
has finished — the same `summary` shape [Sync](#sync)'s own **Report
summary** describes.

**Exit code:** non-zero if *any* configured account either failed outright
(bad credentials, a rejected output directory, …) or reported one or more
per-message errors — one account's failure is never silently swallowed
because the others succeeded. Every other account still runs to completion
regardless of an earlier one's failure.

No MCP equivalent — same reasoning as `sync` itself, doubled: a bulk,
potentially long-running filesystem operation across several mailboxes at
once is an even poorer fit for a synchronous MCP tool call.

## Extract attachments

```bash
$ omni-dev gmail extract-attachments --archive-dir ~/mail-archive
$ omni-dev gmail extract-attachments --archive-dir ~/mail-archive --dry-run
$ omni-dev gmail extract-attachments --archive-dir ~/mail-archive -o json
```

Retroactively extracts attachments for messages [`sync`](#sync)/[`sync-all`](#sync-all-accounts)
already archived, without contacting Gmail at all — the fix for
[`--extract-attachments`](#sync)'s "no retroactive backfill" limitation
(see [ADR-0065](adrs/adr-0065.md)). Purely local and fast: it reads the
manifest and `.eml` files already under `--archive-dir` and never resolves
a client, so no credentials, `--account`, or network access are needed —
unlike every other `gmail` subcommand. Named `--archive-dir` rather than
`sync`'s `--output-dir` since this command's primary interaction with the
directory is reading an existing archive, not producing one — it happens
to also write new `attachments/` subdirectories into it, but that's
incidental to what the flag names.

For each message in the manifest, it trusts `attachment_count > 0` (the
same cheap heuristic scan `sync` always runs, regardless of whether
`--extract-attachments` was ever passed — see [Sync](#sync)'s Attachments
paragraph) as a fast-path filter, skipping the rest without opening their
`.eml`. A candidate whose `messages/<year>/<month>/<day>/<id>/attachments/`
directory already exists is skipped too — the same presence-on-disk
idempotence `sync` itself relies on, which is what makes this command safe
to re-run at any time to pick up whatever an earlier run missed (including
a partial/interrupted one). Everything else is read from disk, parsed with
the same real MIME parser `sync --extract-attachments` uses, and written
out identically. A message whose real parse finds nothing — the rare
heuristic/parser disagreement ADR-0065 documents — is silently skipped,
not an error; a missing or unreadable `.eml` is recorded as a per-message
error and the run continues with the rest of the archive.

**`--dry-run`** parses every candidate `.eml` (so its reported counts are
accurate, not just an echo of the heuristic) and reports what it would
extract without writing any file.

**Report summary:** mirrors [Sync](#sync)'s — a trailing `N extracted, N
would extract, N errors` tally in text output, or a `summary` field in the
structured formats, with the full per-action listing always included in
`-o json`/`-o yaml`/`-o yamls`/`-o jsonl`.

No MCP equivalent — a bulk filesystem operation is as poor a fit here as
it is for `sync` itself.

## Render

```bash
$ omni-dev gmail render message.eml
$ omni-dev gmail render messages/2026/01/*/*/*.eml
$ omni-dev gmail render message.eml --out-dir rendered/
$ omni-dev gmail render *.eml -o json
$ omni-dev gmail render message.eml --fold-quotes
$ omni-dev gmail render --archive-dir archive/ --all --out-dir rendered/
```

Renders one or more `.eml` files as human-readable Markdown: a header
block (Subject/From/To/Cc/Date/Message-Id/In-Reply-To/References, RFC
2047-decoded), the body (`text/plain` preferred, `text/html` converted to
Markdown otherwise), and an attachment filename list (listed, never
embedded — this is a readable rendering, not an export). Purely local and
fast, like [Extract attachments](#extract-attachments): it never resolves
a client, so no credentials, `--account`, or network access are needed.

By default `render` takes bare file paths, with no dependency on the
mailbox having been synced by this tool at all — this works equally well
piped a glob from a `gmail sync` archive
(`messages/<year>/<month>/<day>/*.eml`) or any other `.eml` file, from
anywhere. The same rendering function backs `gmail read -o markdown`; see
[Messages](#messages).

**`--archive-dir PATH --all`** is the alternative for rendering an entire
synced archive: it reads `PATH`'s `manifest.jsonl` and renders every
non-deleted message, in place of gathering paths yourself. `--all` is
required alongside `--archive-dir` (rather than `--archive-dir` alone
implying it), reserving room for a future non-`--all` selector; the two
are mutually exclusive with positional `PATH` arguments. Paired with
`--out-dir`, a message whose `.md` file already exists there is silently
skipped, mirroring [`extract-attachments`](#extract-attachments)'s own
presence-on-disk idempotence for `attachments/` dirs — so re-running
against a growing archive only renders what's new.

By default (no `--out-dir`), each input's rendered Markdown is printed
directly to stdout — with more than one input, successive renderings are
separated by a `---` thematic break — so `omni-dev gmail render *.eml >
combined.md` produces clean, redirectable Markdown as long as every input
renders successfully. **`--out-dir DIR`** instead writes one `.md` file
per input into `DIR` (named after the input's stem, e.g. `abc123.eml` ->
`abc123.md`; `DIR` is created if missing), printing a `Saved to:` line per
file instead.

A per-file read/parse/write failure (a missing path, a permission error)
is recorded against that file rather than aborting the run — the rest of
the batch still renders — but the command still exits non-zero if any
file failed. An unparseable message degrades to a short placeholder rather
than failing outright, the same posture
[`extract-attachments`](#extract-attachments) takes for a message whose
real MIME parse disagrees with the cheap heuristic.

`-o json`/`-o yaml`/`-o yamls`/`-o jsonl` emit one structured record per
input (`path`, and either `markdown` or `saved_to`, plus `error` for a
failed file) instead of the Table view above.

**`--fold-quotes`** collapses deeply-nested `>`-quoted reply history in
each rendered body — see [Messages](#messages)'s `-o markdown` section for
the full description; the behavior is identical since both call the same
rendering function.

No MCP equivalent — same reasoning as
[Extract attachments](#extract-attachments).

## Rate limits and retry behaviour

Gmail enforces a **per-user quota of 250 units/second**; `messages.get` and
`messages.list` each cost 5 units, `messages.batchModify` costs 50 units
for up to 1000 ids. `gmail search`'s ids-only default costs a flat 5 units
regardless of `--limit` (auto-pagination is still one `messages.list` call
per page). `--enrich` adds one `messages.get` (5 units) **per hit**, so
`--enrich --limit 50` can cost up to 255 units — nearly the entire
per-second budget in one command — and `--limit 0 --enrich` against a large
mailbox can cost tens of thousands of units, spread across as many seconds
as `--concurrency` allows. `--concurrency` (default 4) bounds how many of
those `messages.get` calls are in flight at once; it does not itself pace
requests against the per-second budget, so a large `--limit --enrich`
combination should be sized deliberately, not left at defaults.

Gmail signals quota exhaustion as **HTTP 403** with `reason:
rateLimitExceeded` / `userRateLimitExceeded`, not HTTP 429 — the Gmail
client's requests retry both `429` and this specific 403 shape through the
shared retry driver (`retry_if`/`retry_429`, `src/utils/http.rs`), with the
same `Retry-After`-then-exponential-backoff schedule; any other 403 (e.g.
`insufficientPermissions`) is never retried. `gmail sync` additionally
paces its own `messages.get` requests against the 250-units/second budget
with a proactive token-bucket limiter, rather than relying on this reactive
retry — see [Sync](#sync) above. `search --enrich`/`thread` still rely on
`--concurrency` alone (a concurrency bound, not a rate limiter) plus this
retry driver as their only quota protection.

The list endpoints (`search`, `thread`'s underlying calls) auto-paginate
when `--limit 0` is passed, capped at **10,000 records** per invocation.
Any non-zero `--limit` is upper-bounded by the same cap.

## Troubleshooting

### Credentials not configured

```
Error: Gmail credentials not configured. Run `omni-dev gmail auth login`
```

Means `GMAIL_CLIENT_ID`, `GMAIL_CLIENT_SECRET`, or `GMAIL_REFRESH_TOKEN` is
missing from both the environment and `settings.json`. Run
`omni-dev gmail auth import` or just `omni-dev gmail auth login` — it
prompts for the first two if they're still absent — to fix the first two;
the third is written by `auth login` itself.

### `invalid_grant`

```
Error: Failed to obtain a Gmail access token
  Caused by: Google rejected the request (invalid_grant): this almost always means either (1) your Gmail OAuth client is in "Testing" publishing status, where refresh tokens expire after 7 days — publish it to "In production" in Google Cloud Console to avoid this, or (2) access was revoked. Run `omni-dev gmail auth login` again to re-authenticate.
```

The most common cause by far is the 7-day testing-mode refresh-token
expiry described in [Prerequisites](#prerequisites). Re-run
`omni-dev gmail auth login`, or push your OAuth client to "In production"
in Google Cloud Console to stop it recurring.

### `access_denied`

```
Error: Google denied the authorization request: access_denied
```

You (or another user) clicked "Cancel" on Google's consent screen, or your
OAuth client's test-user allowlist doesn't include the account you tried to
authorize (a Testing-mode consent screen only allows explicitly added test
users). Re-run `omni-dev gmail auth login` and either approve the prompt or
add the account under **OAuth consent screen → Test users** in Google Cloud
Console.

### Could not start the local OAuth callback listener

```
Error: Failed to start the local OAuth callback listener
```

The loopback listener binds an OS-assigned ephemeral port
(`127.0.0.1:0`), so this should be rare. The one common cause is a stale
process from a previously interrupted `gmail auth login` holding a socket
resource open — retry, and if it persists, check for a leftover `omni-dev`
process.

### Browser did not open

`gmail auth login` opens your default browser automatically. If it fails
to open (e.g. over SSH, or in a headless environment), the authorization
URL is printed to the terminal for you to open manually — no CLI flag is
needed to force this fallback; it's the same code path.

If it opens the *wrong* browser profile (mixing up which named account
lands on which Google identity), see [Browser profile
targeting](#browser-profile-targeting) above.

### No Gmail scope was granted

```
Error: Google did not grant a Gmail scope (received: openid, email, profile).
  On the consent screen, tick the Gmail permission — restricted scopes are
  not granted by default. Re-run `omni-dev gmail auth login`.
```

Cause: the consent screen's Gmail permission tick-box (see
[Prerequisites](#prerequisites)) was left unticked, so Google granted only
`openid`/`email`/`profile` — no Gmail scope at all. `auth login` rejects
this immediately, naming the scopes Google actually granted, and writes
nothing to `settings.json`. Fix: re-run `omni-dev gmail auth login` and
tick the Gmail permission this time — `--modify` does not help here,
since the problem isn't *which* Gmail scope was granted, it's that none
was.

### `insufficientPermissions`

```
Error: Gmail API request failed: HTTP 403: Insufficient Permission (reason: insufficientPermissions)
```

`gmail.readonly` was granted, but `label add`/`remove` fails — read
commands (`search`, `read`, `thread`, `auth status`) all work fine; only
label mutation 403s. Fix is `omni-dev gmail auth login --modify`
(re-consent with the write scope), not a retry.

### MCP server cannot see credentials

Same as every other domain: environment variables exported in your
interactive shell are not inherited by an MCP client unless it launched
the server from that same shell. Run `omni-dev gmail auth login` once —
this persists the refresh token (plus client id/secret) to
`~/.omni-dev/settings.json`, read by every invocation regardless of how
the process started.

### `operation timed out` fetching a message during `sync`

```
Error: <id> failed: Failed to parse messages.get response: error decoding response body for url (...): request or response body error: operation timed out
```

`messages.get?format=raw` returns the whole message (headers, body, and
every attachment, base64-encoded) in one response. The Gmail client (like
the Atlassian and Datadog clients) sets two independent timeouts, not one:
a 10-second connect timeout (DNS + TCP + TLS handshake) and a 120-second
**read** timeout that covers each individual read of the response body and
resets on every successful one — it's a stall detector, not a fixed total
deadline, so a download that's slow-but-still-progressing keeps extending
it rather than getting cut off partway through. A handful of large
messages (tens of MB — attachment-heavy mail) downloading concurrently
under `--concurrency` divide the available bandwidth, so each read can
individually stall long enough to trip the read timeout even though
nothing is actually stuck. This is more likely the more of `--concurrency`
is spent on large messages at once, not a sign of a broken connection.

`sync` is safe to just re-run: a run with errors never advances the
watermark, and presence-on-disk means already-archived messages are
skipped, so a re-run only retries what failed. Two ways to make it
succeed:

- Lower `--concurrency` (even down to `1`) so each large download gets
  more of the available bandwidth to itself.
- Raise the read timeout instead via `OMNI_DEV_HTTP_READ_TIMEOUT_SECS`
  (whole seconds; a missing, non-numeric, or non-positive value falls back
  to the 120-second default) — shared by the Gmail, Atlassian, and Datadog
  REST clients, e.g.
  `OMNI_DEV_HTTP_READ_TIMEOUT_SECS=300 omni-dev gmail sync ...`. The
  connect timeout has its own override, `OMNI_DEV_HTTP_CONNECT_TIMEOUT_SECS`
  (default 10s), for the unrelated case of a slow-to-establish connection.

### `Vanished <id>` in a `sync` report

```
Vanished m1a2b3c4 (message no longer existed on the server; skipped, not an error)
```

This is expected and benign, not something to fix by re-running: Gmail's
`history.list` and `messages.get` aren't perfectly consistent, so a message
can be permanently deleted from the server in the window between being
listed and being fetched — auto-filtered mail, a sent message recalled
immediately, and similar routine churn. Unlike every other per-item
failure, this can never succeed on retry, so it is not counted as an error
(`report.errors` stays empty), does not withhold the watermark, and does
not fail `sync`'s or `sync-all`'s exit code — only a message vanishing for
any *other* reason (a 404 with a different `reason`, or any non-404
failure) still surfaces as an ordinary error and still withholds the
watermark. See [ADR-0064](adrs/adr-0064.md)'s 2026-08-06 amendment for
#1509.

## See also

- [Gmail Quickstart]gmail-quickstart.md — a linear, zero-to-synced-archive
  walkthrough for first-time setup.
- [Drive Integration]drive.md — the sibling Google integration; shares
  the same named-account/OAuth2 storage pattern.
- [User Guide]user-guide.md#gmail-integration — short reference; primary
  content lives here.
- [MCP Reference — Gmail]mcp.md#gmail-6-tools — parameter-only listing of
  all 6 `gmail_*` MCP tools.
- [ADR-0063]adrs/adr-0063.md — OAuth2 authorization-code + PKCE design,
  refresh-token-only persistence, and the bring-your-own Google Cloud
  project rationale.
- [ADR-0066]adrs/adr-0066.md — the named-account store behind
  [Multiple accounts]#multiple-accounts, and why it's orthogonal to
  `--profile`.
- [ADR-0068]adrs/adr-0068.md — the `gmail-sync.yaml` config file and
  shared-semaphore concurrency model behind
  [Sync all accounts]#sync-all-accounts.
- [Gmail API documentation]https://developers.google.com/workspace/gmail/api/reference/rest — upstream reference.