omni-dev 0.46.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
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
# 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-8-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. [Drafts]#drafts
10. [Sync]#sync
11. [Sync all accounts]#sync-all-accounts
12. [Extract attachments]#extract-attachments
13. [Render]#render
14. [Insert]#insert
15. [Rate limits and retry behaviour]#rate-limits-and-retry-behaviour
16. [Troubleshooting]#troubleshooting
17. [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.

To go to **In production**: OAuth consent screen → **Publish App**. This
by itself does not trigger a verification review — the next time you (or
any of your up-to-100 test users) sign in, Google shows an "unverified
app" interstitial; click **Advanced → Go to `<your project>` (unsafe)**
to proceed. That warning is expected and permanent for a project like
this one — it's not a sign anything is misconfigured, and it's the
tradeoff for not taking on CASA. **Don't upload a logo** on the Branding
page: Google requires a full verification review (including CASA for
restricted scopes like this one) before it will display a logo, so
uploading one moves your project onto that track even though you never
asked for a review. Branding fields otherwise (app name, support email)
don't trigger it.

[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. Also accepts a `GMAIL_REFRESH_TOKEN_FILE` companion (below). | _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.

`GMAIL_REFRESH_TOKEN` also accepts a `GMAIL_REFRESH_TOKEN_FILE` companion
naming an absolute path to a file holding the token, instead of the value
itself — the Docker/Kubernetes secrets convention described in
[ADR-0089](adrs/adr-0089.md). `GMAIL_CLIENT_SECRET` is deliberately **not**
part of this convention: `GMAIL_CLIENT_SECRET_FILE` already means something
else (above) — the path to a Google `client_secret.json` for `gmail auth
import` — and an installed-app OAuth client secret isn't confidential per
Google in the first place, so `gmail auth import` already covers the
file-based case.

`GMAIL_REFRESH_TOKEN_COMMAND` fetches the token on demand from a password
manager or keychain; see [secret-commands.md](secret-commands.md).
`gmail auth login` refuses, before opening a browser, when the map it would
write to holds one.

### 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 by
every command that changes the mailbox — `gmail label add`/`remove`,
[`draft create`](#creating-drafts)/[`update`](#updating-drafts) and
[`insert`](#insert):

```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.

### Keeping an account's secrets in files

A named account's `client_secret` and `refresh_token` can live in files
instead of in `settings.json`. Replace either field with its `_file`
companion, which holds an absolute path to a file whose contents are the
secret ([#2008](https://github.com/rust-works/omni-dev/issues/2008)):

```json
"gmail": {
  "accounts": {
    "work": {
      "client_id": "123456789-abc.apps.googleusercontent.com",
      "client_secret_file": "/Users/me/.secrets/google-oauth-client-secret",
      "refresh_token_file": "/Users/me/.secrets/gmail-work-refresh-token"
    }
  }
}
```

The file rules are the ones `GMAIL_REFRESH_TOKEN_FILE` uses
([ADR-0089](adrs/adr-0089.md)). The path must be absolute. The file must be
a regular file, and either yours and owner-only (`chmod 600`) or owned by
root and not writable by others. One trailing newline is ignored. Setting a
field and its `_file` on the same account is an error that names both keys.

The account field holds the **raw** client secret. It is not a
`client_secret.json`, which is what the flat `GMAIL_CLIENT_SECRET_FILE`
names (above). Put only the `client_secret` value from that JSON in the
file. An account whose `client_secret_file` holds JSON fails to load with an
error saying so.

When `gmail auth login`, `gmail auth import` and `gmail account
import-legacy` write a new secret to an account whose entry already has the
`_file` field, the value goes **into that file** and the plain field is
removed from `settings.json`. Accounts without a `_file` field are written
exactly as before. The file rules are:

- A file that already holds the same value is left alone, so one shared file
  or a root-owned read-only mount keeps working.
- Any other file is replaced atomically with a new owner-only file, but only
  if the file-reading rules above would accept it (an empty file is also
  fine). A file with loose permissions, a file owned by someone else, a
  dangling symlink or a file holding JSON is refused and left untouched. The
  file's directory must already exist.
- A `client_secret_file` holding another value is not replaced when the
  login's client id differs from the account's `client_id`. That file most
  likely belongs to another OAuth client's accounts.
- Every file is checked before any is written, and `settings.json` is
  written last, so a refused write changes nothing.

Several accounts that use one Google Cloud OAuth client can point their
`client_secret_file` at the **same** file, so the secret is kept in one
place. To rotate it, write the new secret to that file. Then, for each
account, run `gmail auth login --account <name>` with the client secret
supplied through `GMAIL_CLIENT_SECRET` or pasted at the prompt. Each login
writes the account's new refresh token into its own `refresh_token_file`,
and leaves the shared file alone, since it already holds that value.

`gmail auth logout` removes the account's entry from `settings.json`. It
does not delete the files the entry named, because they may be shared or
not yours to delete.

### 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`, `draft list`, `draft show`, `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, avoiding per-hit read costs. 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.

## Drafts

```bash
$ omni-dev gmail draft list
$ omni-dev gmail draft list --query 'to:alice subject:report' --limit 10
$ omni-dev gmail --account work draft list -o yaml
```

`draft list` lists the mailbox's drafts. Each row shows the **draft id**,
the id of the draft's current message, its thread id, the `To` header,
the subject, the date and a snippet. `-o yaml`/`json` also carry `cc` and
`bcc`.

The draft id and the message id are different things, and the output
labels them `DRAFT_ID` / `MESSAGE_ID` (`draft_id` / `message_id` in machine
output) so they can't be confused. Every drafts endpoint is addressed by the
draft id. The message id changes each time a draft is saved. `gmail search
--query in:drafts` finds draft *messages*, but it cannot return their draft
ids. That is what this command is for.

`--query` takes the same [Gmail search syntax][Gmail's own search syntax]
as `gmail search`. `--limit` also works the same way: the default is 50,
and `0` fetches every draft up to the 10,000 hard cap, auto-paginating
underneath.

`drafts.list` returns only ids, so each row costs one extra `messages.get`
(20 quota units). That is the same cost as `gmail search --enrich`, and
these calls run at the same fixed concurrency (4); see
[Rate limits and retry behaviour](#rate-limits-and-retry-behaviour).
`draft list` has no `--concurrency` flag. Saving a draft replaces its
message. If a draft is saved while `draft list` runs, the old message it
was about to fetch is gone. That row still appears, with its ids but blank
headers, and running the command again shows it in full.

### Showing a draft

```bash
$ omni-dev gmail draft show r-1234567890
$ omni-dev gmail draft show r-1234567890 -o markdown
$ omni-dev gmail draft show r-1234567890 --detail raw --out-file draft.eml
```

`draft show` fetches one draft by its **draft id** (the `DRAFT_ID` column of
`draft list`). `gmail read <message-id>` can read a draft's message too, but
that id goes stale the next time the draft is saved. The draft id does not.

It takes `gmail read`'s flags and shares its output code, so the two
render a message the same way:

- `--detail minimal|metadata|full|raw` (default `full`) picks how much of
  the message Gmail returns.
- The default table view prints `Draft-Id`, `Message-Id`, `Thread-Id`,
  labels and the snippet. `-o markdown` renders a `Draft-Id` line, the
  headers and the body. Unlike `gmail read`, it includes `Bcc`, which a
  draft keeps. `read` and `render` leave `Bcc` out because Gmail keeps it
  on Sent mail too, and hiding those recipients is the point of `Bcc`. `-o json`/`yaml`/`yamls`/`jsonl`
  emit Gmail's `drafts.get` response, `{id, message}`, so machine output
  carries the draft id beside the message.
- `--detail raw --out-file PATH` writes the draft's exact stored RFC 2822
  bytes: an `.eml` file you can edit and hand back to
  [`draft update --raw`]#updating-drafts.

A draft id that doesn't exist fails with `No draft with id "…"`. The usual
cause is passing a message id from `gmail search` or `gmail read`, which no
drafts endpoint accepts.

**Read-only scope is enough.** `drafts.list`, `drafts.get` and
`messages.get` all accept `gmail.readonly`, so `draft list` and `draft show`
work for an account authorised without `--modify`.

### Creating drafts

```bash
$ omni-dev gmail draft create --to alice@example.com --subject 'Quarterly report' \
    --body 'Figures attached.' --attach q3.pdf
$ omni-dev gmail draft create --to 'Zoë Ångström <zoe@example.com>' --cc bob@example.com \
    --subject 'Grüße' --body-file note.txt
$ git log -1 --format=%B | omni-dev gmail draft create --to team@example.com --subject 'Release notes'
$ omni-dev gmail draft create --to alice@example.com --subject 'Quarterly report' \
    --html-body-file note.html --attach q3.pdf
$ omni-dev gmail draft create --to alice@example.com --reply-to 18c2f0a1b2c3d4e5 --body 'Thanks!'
$ omni-dev gmail draft create --reply-to 18c2f0a1b2c3d4e5 --reply-all --body 'Thanks, all!'
$ omni-dev gmail draft create --from 'Sales Team <sales@example.com>' --to alice@example.com \
    --subject 'Your order' --body 'It shipped today.'
$ omni-dev gmail draft create --raw message.eml
```

`draft create` stages a new draft and prints its `DRAFT_ID`, `MESSAGE_ID`
and `THREAD_ID` (`-o yaml`/`json` give `draft_id`/`message_id`/`thread_id`).
Nothing is sent. The draft waits in Gmail's Drafts folder for a person to
review and send.

- **Recipients.** `--to` is required unless `--reply-to` is given, which
  defaults it (see *Reply recipients* below). `--cc` and `--bcc` are
  optional. Each value is one mailbox, either `addr@example.com` or
  `Name <addr@example.com>`.
  Repeat the flag, or list several values after it, for more recipients.
  Values are never split on commas, so `"Doe, Jane" <jane@example.com>`
  works. Non-ASCII names and subjects are sent as RFC 2047 encoded words.
  A line break in any header value is rejected.
- **Body.** The plain-text body is taken from `--body TEXT`,
  `--body-file PATH` or, failing both, standard input. When standard input
  is a terminal, the command errors instead of waiting for typed input. A
  script that runs it with an open but silent stdin must pass `--body` or
  `--body-file`, or redirect `</dev/null` for an empty body. The body must be
  UTF-8.
- **HTML body.** `--html-body HTML` or `--html-body-file PATH` adds an HTML
  version. The message is then `multipart/alternative`, with the plain-text
  part first and the HTML part second (nested inside `multipart/mixed` when
  there are attachments), so Gmail and other HTML-capable clients show the
  HTML. With `--body` or `--body-file` as well, that text is the plain-text
  part, used as given. Without either, the plain-text part is the HTML
  converted to Markdown with the converter `gmail render` uses, so links
  survive as `[text](url)`, leaving out a full document's `<head>`,
  `<style>` and `<script>` content; standard input is never read in that
  case. The
  HTML must be UTF-8 and is sent as given, not sanitised. Inline images
  aren't supported: HTML that refers to one with `cid:` gets a warning,
  because nothing attaches the image and it would show as broken.
- **Attachments.** Each `--attach PATH` becomes a base64 part of a
  `multipart/mixed` message. Its type is guessed from the file extension,
  defaulting to `application/octet-stream`.
- **From.** Not set by default, so Gmail fills in the account's own
  address. `--from ADDR` (`addr@example.com` or `Name <addr@example.com>`)
  sends as one of the account's **send-as addresses** instead: the primary
  address or an alias added under Gmail's *Settings → Accounts → Send mail
  as*. `draft create` checks it first with one `users.settings.sendAs.list`
  call and refuses, before anything is created, an address that isn't one
  of them or an alias still awaiting verification. The error lists the
  addresses the account can use. The check is there because Gmail is
  reported to send from the primary address instead when a draft's `From`
  isn't a verified alias, so the draft would look right and go out wrong.
  The address is matched case-insensitively and written as Gmail stores it.
  Without a name, the name is the one Gmail has for that address, else the
  primary address's, as Gmail itself does for a nameless alias. When that
  leaves the primary address with no name at all, `From` is left out, so
  Gmail fills in the address and your account name rather than a bare
  address. The uploaded `Message-ID` then ends in the `From` address's
  domain. No new scope is needed: `gmail.modify` (and even
  `gmail.readonly`) allows the call. `--from` doesn't change who a
  `--reply-to` draft is addressed to.
- **Message-ID.** The uploaded id ends in the `--from` address's domain,
  or else the account's own domain (for example `<…@gmail.com>`), looked
  up with one `users.getProfile` call, which `gmail.readonly` allows,
  alongside any `--reply-to` lookup. If that call fails, the id ends in
  `@localhost` instead and a warning is printed once the draft has been
  created. **Gmail doesn't keep it:** every save, by `draft create` or
  [`draft update`]#updating-drafts, `--raw` included, gives the stored
  draft a `Message-ID` of Gmail's own (`<…@mail.gmail.com>`) and a new
  `Date`, so `draft show` returns those rather than the uploaded values.
  What id the message carries once it is sent from the Gmail UI is
  unverified ([#1953]https://github.com/rust-works/omni-dev/issues/1953),
  which is why the uploaded one is still made valid.
- **Replies.** `--reply-to` takes the **Gmail message id** of the message
  being answered, as `gmail search`/`read` print it, not its `Message-ID`
  header. Gmail only files a reply into the original's thread when the
  draft's `threadId`, its `In-Reply-To`/`References` headers and its
  subject all line up. `draft create` fetches the original's headers (one
  `messages.get`) and sets all three. The subject defaults to
  `Re: <original subject>`, without doubling an existing `Re:`. An explicit
  `--subject` is compared with the original's once leading `Re:` prefixes
  are removed from both. If they differ, a warning is printed, because
  Gmail may start a new thread for it.
- **Reply recipients.** With `--reply-to` and no `--to`, the draft is
  addressed the way a mail client's Reply addresses it. The same
  `messages.get` also reads the original's `From`, `Reply-To`, `To` and
  `Cc`, so this costs no extra request.

  | Original message                      | Draft `To`                  |
  |---------------------------------------|-----------------------------|
  | From someone else, has `Reply-To`     | the `Reply-To` mailbox(es)  |
  | From someone else, no `Reply-To`      | `From`                      |
  | Sent by you (Gmail's `SENT` label)    | the original's `To`         |

  `--reply-all` (only with `--reply-to`) also adds the original's `To` to the
  draft's `To` and its `Cc` to the draft's `Cc`, keeping each recipient in
  the header they were in, as Gmail's web UI does. `Bcc` is never carried
  over.

  Your own address is left out of every defaulted header. The primary
  address comes from the `users.getProfile` call `draft create` already
  makes. `--reply-all` also leaves out your send-as aliases, found with one
  `users.settings.sendAs.list` call. If that call fails, the command fails
  rather than risk putting an alias in its own reply. A plain reply doesn't
  use that call, so it only recognises the primary address.

  An explicit `--to` or `--cc` **replaces** that header's default rather than
  adding to it, so you can drop someone. A defaulted header also leaves out
  anyone you already named in `--to`, `--cc` or `--bcc`, and repeats, both
  compared case-insensitively. So naming the original's sender in `--cc` or
  `--bcc` keeps them out of the defaulted `To`, and the draft can end up with
  no `To` at all; pass `--to` as well to keep them there. If leaving yourself
  out empties `To`, a defaulted `Cc` moves up into `To`, as mail clients do:
  replying to a message you sent yourself, copying others, goes to them. A
  reply to a note to self goes back to you, as in Gmail, without
  `--reply-all`'s extras. The command fails before creating anything, asking
  for `--to`, only when the draft would have no recipient at all. Once the
  draft exists, a `note: replying to …; cc …` line on stderr says who was
  defaulted. Standard output and `-o` output are unchanged.

  Addresses are decoded from the original's headers (RFC 2047 names,
  groups, quoted names, headers repeated or folded). One that can't be used,
  such as a name that decodes to a line break, is skipped, with a warning
  when it was in a header the reply drew on. Your addresses are matched as
  whole addresses, so Gmail's dot and `+tag` variants of an `@gmail.com`
  address (`j.doe+x@gmail.com` for `jdoe@gmail.com`) are **not** recognised
  as yours. `Mail-Followup-To`/`Mail-Reply-To` are ignored.
- **`--raw FILE`** uploads a complete RFC 5322 message byte for byte, with
  no parsing or line-ending changes (Gmail still replaces its `Message-ID`
  and `Date` on save). It can't be combined with any of the composing
  flags, `--from` included: the file's own `From` is uploaded unchecked.
- **Size.** Messages over Gmail's 35 MB per-message limit are refused
  before any request is sent. This is the same limit as [Insert]#insert.
  Attachments are checked on disk before they're read, with base64's growth
  of about a third counted along with the body. The built message is
  checked again, exactly, before the upload.
  The upload uses the same `/upload/` multipart transport as `gmail insert`.

**Needs `gmail.modify`.** `drafts.create` isn't allowed with
`gmail.readonly`. A read-only account gets an error telling it to re-run
`omni-dev gmail auth login --modify` (see
[`insufficientPermissions`](#insufficientpermissions)).

**Drafts are never sent or deleted.** omni-dev can only stage a draft for a
person to review. It deliberately has no `draft send` and no `draft delete`:
Gmail's `drafts.send` delivers mail that can't be recalled, and
`drafts.delete` skips Trash, so a deleted draft can't be recovered. Send
or discard drafts in Gmail itself. A unit test fails the build if either
endpoint is ever added to the drafts client (#1920).

### Updating drafts

```bash
$ omni-dev gmail draft update r-1234567890 --subject 'Quarterly report (final)'
$ omni-dev gmail draft update r-1234567890 --cc bob@example.com --cc carol@example.com
$ omni-dev gmail draft update r-1234567890 --body-file revised.txt --remove-attachment q3-draft.pdf \
    --attach q3.pdf
$ omni-dev gmail draft update r-1234567890 --html-body-file revised.html
$ omni-dev gmail draft update r-1234567890 --from sales@example.com
$ omni-dev gmail draft update r-1234567890 --raw draft.eml --if-message-id 18c2f0a1b2c3d4e5
```

`draft update` revises a staged draft and prints its `DRAFT_ID` (unchanged),
its new `MESSAGE_ID`, the `PREVIOUS_MESSAGE_ID` it replaced, and its
`THREAD_ID`. `-o yaml`/`json` give the same ids in snake case. At least one
edit is required. Nothing is sent.

Gmail's `drafts.update` has no partial form: every update replaces the whole
message. So `draft update` reads the stored message
(`drafts.get?format=raw`), changes only what the flags name, and uploads the result.
**Everything else is uploaded byte for byte**: `From` (unless `--from` is
given), `In-Reply-To`/`References`, any other header, the body, and every
attachment you didn't name. The upload also carries the stored `Date` and
`Message-ID` unchanged, but Gmail replaces both on every save, as it does
for `draft create`, so after an update `draft show` returns a new
`<…@mail.gmail.com>` `Message-ID` and the time of the update as `Date`.

- **Recipients and subject.** `--to`, `--cc`, `--bcc` and `--subject`
  replace just that header, encoded the way `draft create` encodes it. Each
  recipient flag replaces that header's **whole** list, so pass every
  recipient you want to keep, repeating the flag for each one (unlike
  `draft create`, one flag takes one value, so the draft id can follow it).
  `--subject ''` removes the subject. A recipient header can't be cleared
  yet.
- **From.** `--from ADDR` replaces `From` with one of the account's send-as
  addresses, checked and named exactly as in `draft create`. The check runs
  before the draft is read, so a refused address changes nothing and costs
  no draft reads. `--from` with your primary address and no name known for
  it removes `From`, so Gmail fills it in with your account name; otherwise
  there's no way to remove `From`.
- **Body.** `--body TEXT` or `--body-file PATH` replaces the body with plain
  text. There's no standard-input fallback, unlike `draft create`, because
  the body is optional here. A draft written in the Gmail UI also has an
  HTML version (and possibly inline images). That version is **dropped**,
  with a warning, rather than left contradicting the new text, since Gmail
  shows the HTML version when there is one.
- **HTML body.** `--html-body HTML` or `--html-body-file PATH` replaces the
  body with a `multipart/alternative` of plain text and that HTML, as in
  `draft create`. The plain-text part is `--body`/`--body-file` when given,
  and otherwise derived from the new HTML. It is never kept from the old
  body, where it could say something different. Replacing a body that had
  inline images drops them, with a warning. New HTML that refers to a
  `cid:` image gets the same warning as in `draft create`.
- **Attachments.** `--attach PATH` adds files after the existing
  attachments, with the same on-disk size check as `draft create`.
  `--remove-attachment NAME` removes one attachment: the one
  `draft show -o markdown` lists under `NAME` (sanitised, with `-1`, `-2`… added to repeated
  names and `attachment-N` for an unnamed part), or else the one stored
  under that exact filename, if only one is. Two attachments both called
  `image.png` are removed as `image.png` and `image-1.png`. A name the draft
  doesn't have is an error that lists the names it does have.
- **`--raw FILE`** replaces the whole message with an `.eml` file, uploaded
  byte for byte (Gmail then replaces its `Message-ID` and `Date`, as on
  every save). `draft show --detail raw --out-file` writes such a file.
  Use this for anything the flags can't express, such as inline images. It
  can't be combined with the editing flags.
- **Threads.** The draft's `threadId` is copied from the stored draft into
  every update, `--raw` included. Without it, a reply draft silently falls
  out of its thread. Gmail also threads by subject, so changing a reply
  draft's subject prints a warning. If Gmail files the result in a different
  thread anyway, a second warning names both thread ids.

**Concurrent edits.** Drafts have no ETag or precondition, so an update
always overwrites whatever is stored, including an edit made in the Gmail UI
after omni-dev read the draft. The draft's message id changes on every save,
which gives a cheap check. `draft update` reads the draft again
(`format=minimal`) just before the upload and refuses with `draft changed since it
was read` if the message id moved. `--if-message-id ID` extends the check
back to an earlier read: pass the `MESSAGE_ID` that `draft show` or `draft
list` printed, and the update is refused unless the draft still holds that
message. The window is **smaller, not closed**: a save that lands between
the final read and the upload is still overwritten.

**Needs `gmail.modify`**, like `draft create`, with the same actionable error
for a read-only account.

### MCP equivalent(s)

- `gmail_draft_list` mirrors `draft list`. It takes the same `query` and
  `limit` (default 50, `0` for every draft up to the 10,000 hard cap) and
  returns the same rows as `-o yaml`, with `draft_id`, `message_id` and
  `thread_id` named apart. Hydration concurrency is fixed at the CLI's
  default of 4. Neither surface has a knob for it.
- `gmail_draft_show` mirrors `draft show`. It takes a `draft_id` and the
  same `format` values as `gmail_message_read` (`minimal` / `metadata` /
  `full` / `raw`) and returns `drafts.get`'s `{id, message}` as YAML. A
  message id fails with the same `No draft with id` hint. `output_file`
  writes that YAML to disk and returns a short summary. Unlike
  `--detail raw --out-file draft.eml`, it never decodes the message to an
  `.eml`.

Both need only `gmail.readonly`. `draft create` and `draft update` stay
CLI-only: they would be the first Gmail MCP tools that need `gmail.modify`,
and their file inputs (`--attach`, `--body-file`, `--raw`) would let an MCP
client make the server read local files into the mailbox, so they get their
own review. `send` and `delete` are not offered on either surface.

## 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
$ omni-dev gmail sync --output-dir ~/mail-archive --exclude-label SPAM --exclude-label TRASH
```

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**. With the current pacing budget, get-only throughput
is at most roughly five messages/second: 50k messages take about 2 hours
47 minutes before listing costs, network delays, or retries. Older measurements
using the previous quota baseline do not predict current throughput. Sync's
configured concurrency defaults to 20 but is clamped to five by the shared
cap; the token bucket paces requests independently (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 +
                                #   pending_fetch (ids that failed, with their retry
                                #   backoff — see Sync below)
  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),
                                #   excluded_labels (--exclude-label reason, only
                                #   for a record soft-deleted by run_incremental's
                                #   own precise tracking — see Sync below)
  messages/<year>/<month>/<day>/<id>.eml   # sharded by the message's internal_date
  messages/<year>/<month>/<day>/<id>/attachments/<filename>  # only with --extract-attachments
  insert-ledger.jsonl          # only after `gmail insert` has run — see Insert below
```

`.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 per-item errors still advances the
watermark, but records the failed message ids in `state.json`'s
`pending_fetch`; the next incremental run retries them directly, alongside
the new history window, and drops any that are by then archived, deleted,
or vanished. This means a mailbox that keeps hitting errors (for example a
sustained `rateLimitExceeded`) never lets its watermark age past the
one-week limit and fall back to a full listing (#1784). 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 and never added
to `pending_fetch`, since Gmail's `history.list` and `messages.get` aren't
perfectly consistent and retrying that particular id can never succeed;
see the Troubleshooting section below.

**Retry backoff for failing ids (#1790):** an id that keeps failing is not
retried on every run. After its *n*th consecutive failure it is deferred for
`5 min * 2^(n-1)`, capped at 24 hours (5, 10, 20, 40 minutes, … up to once a
day), and `state.json` records its failure count, first-failure time, next
retry time and last error beside it. The watermark is independent of this
and still advances on every run, so a bad id never delays new mail. This
spares a rate-limited account from re-spending quota it doesn't have, and
caps a permanently failing id (a decode error, a message too large to
download inside the read timeout) at about one request a day. Backoff also
applies to `--full` and to a reconciliation after an expired watermark, so
the most expensive pass doesn't retry known-bad ids either.

A deferred id costs no request and is **not** an error: it is reported as a
warning (and in the `deferred` array of `-o json`/`yaml` output, plus a
`deferred` count in the summary), and does not change `sync`'s or
`sync-all`'s exit code. Only an id that was actually attempted and failed
again is an error.

```
Warning: 2 message(s) deferred by retry backoff after repeated failures; pass --retry-pending to retry them now
  18f3a…: 4 consecutive failure(s), next retry 2026-10-01T04:40:00Z; last error: Gmail API request failed: HTTP 403 …
```

After fixing the cause (raising the read timeout, waiting out a quota
reset), pass **`--retry-pending`** — `gmail sync --retry-pending`, or
`gmail sync-all --retry-pending` for every account — to attempt every
pending id now. A forced retry that fails again still counts as a further
failure and restarts the delay from that moment. `state.json` written by an
earlier version (a plain list of ids) still loads, with every id due
immediately.

**`--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.

**`--exclude-label` (label-based exclusion, #1780):** for the common case of
excluding spam/trash (or any other label) from the archive, prefer
`--exclude-label LABEL_ID` (repeatable, e.g. `--exclude-label SPAM
--exclude-label TRASH`) over `--query` — unlike `--query`, it's fully
general and takes effect on **incremental** syncs too, since it works off
the label data `history.list` events already carry for free rather than a
server-side query string: a new message with an excluded label is never
fetched, and a `labelsAdded`/`labelsRemoved` event that later moves an
already-archived message across the excluded boundary soft-deletes/
undeletes it. That undelete is deliberately conservative: the manifest
records *precisely which* configured label(s) caused a soft-delete only
when `run_incremental` itself made that call (from the message's real,
event-driven current label set) — a record soft-deleted for any other
reason (a real message deletion, or simply falling out of a `--full`
listing) never gets tagged, so an unrelated later label change can never be
mistaken for that exclusion having lifted and resurrect it.

On backfill/`--full`/reconciliation passes, only labels with a well-known
`-in:` query translation take effect this way — currently just
`SPAM`/`TRASH`, since Gmail's `label:`/`in:` search operators match a
label's *display name*, not its internal id, and most other system labels
use `is:` rather than `in:`; folding an arbitrary label id in reliably would
need an extra `labels.list` lookup. Any other `--exclude-label` value still
filters incremental runs (fully generally, by id), and a full/backfill pass
prints a `Note` explaining it wasn't excluded on that pass — add it to
`--query` yourself if you also want it gone from a `--full` re-run. A
message a full/backfill pass excludes by a custom label (tracked only via a
prior incremental run) is not casually resurrected just because it still
appears in a listing that couldn't have filtered that label out — only an
actual `labelsRemoved` event (via a later incremental run) or a
`--query`-assisted `--full` pass can undo it. Combining `--query` with
`--exclude-label` is safe with either operator: any of your own query's
`OR`-joined clauses is parenthesized before an exclusion clause is
appended, so the exclusion always applies to the query as a whole rather
than binding only to its last term.

One edge case, not solved: a message that arrives *already* carrying an
excluded label is never archived, so if that label is later removed
there's no manifest record for the `labelsRemoved` event to act on — it
self-heals on the next `--full`/reconciliation pass, same as this
section's other races.

**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`/`vanished`/`excluded`/
`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 --retry-pending   # retry ids deferred by backoff now
$ 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"
    exclude_labels: [SPAM, TRASH]
    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` — printed through the bars (via `suspend`) rather than racing their
redraw, and only once that account's own bars have been cleared away. Once
every account has finished, and so every bar is gone, a trailing
`combined: ...` total follows: what remains on screen is one summary line
per account plus that total. `--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/
$ omni-dev gmail render --archive-dir archive/ --since 2026-01-01 --until 2026-01-31 --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`** is the alternative for rendering an entire (or
selected part of a) synced archive: it reads `PATH`'s `manifest.jsonl` and
renders every matching non-deleted message, in place of gathering paths
yourself. It shares [`gmail insert`](#insert)'s **Selection** — one of
`--all`, `--since DATE`/`--until DATE` (`YYYY-MM-DD`, inclusive), `--id ID`
(repeatable), `--ids-from FILE`, or `--source-label LABEL_ID` — and is
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).

## Insert

```bash
$ omni-dev gmail insert --archive-dir ~/mail-archive --all --label RESTORED --dry-run
$ omni-dev gmail insert --archive-dir ~/mail-archive --all --label RESTORED
$ omni-dev gmail insert --archive-dir ~/mail-archive --since 2020-01-01 --until 2020-12-31 --label RESTORED
$ omni-dev gmail insert --archive-dir ~/mail-archive --all --label RESTORED --drop-label INBOX --drop-label UNREAD
$ omni-dev gmail insert --archive-dir ~/mail-archive --all --label RESTORED --verify-remote
```

Restores archived `.eml` messages into a mailbox via `messages.insert`,
closing the loop the rest of this page describes: [`sync`](#sync)/
[`sync-all`](#sync-all-accounts) capture, [`render`](#render)/
[`extract-attachments`](#extract-attachments) read the archive back, and
`insert` restores it — making the archive a genuine backup rather than a
read-only copy. Unlike `extract-attachments`/`render`, this contacts Gmail
and needs a client, so `--account` works normally.

Uses `messages.insert`, not `messages.import` — `import` runs a message
through Gmail's normal spam/classification pipeline, which is actively
wrong for mail that may be years old (it can land in spam, or be caught by
filters written long after the mail actually arrived). `insert` places the
message exactly where instructed, with `internalDateSource=dateHeader` (not
Gmail's default) so the archived `Date:` header — not the moment of
insertion — sets Gmail's sort order. **Needs `gmail.modify`**
(`gmail auth login --modify`), the scope `label add`/`remove` and
`draft create`/`update` already use; a `gmail.readonly`-only account gets a
403 at its first insert, and the run stops there with an error naming the
fix (see [`insufficientPermissions`](#insufficientpermissions)).

**Selection** — one of `--all`, `--since DATE`/`--until DATE`
(`YYYY-MM-DD`, inclusive), `--id ID` (repeatable), `--ids-from FILE` (one id
per line, `-` for stdin, blank lines and `#` comments skipped — the same
shape a `--dry-run` report's ids can be piped back into), or
`--source-label LABEL_ID` (a raw archived label id, distinct from the
destination `--label` below) is required — a bare `gmail insert
--archive-dir DIR` is rejected rather than silently restoring the whole
archive. Combining selectors narrows rather than widens the match set
(`--id` plus `--since` requires both); `--all` combined with anything else
is rejected outright, since it already means "everything." This selection
logic is local-only, filtering the already-synced `manifest.jsonl` — it is
**not** Gmail's search query syntax.

**`--label NAME`** tags every inserted message with an existing destination
label, resolved by name — never auto-created, so a typo fails before any
write rather than silently creating a stray label. It matters more here
than it might sound: inserted mail's raw headers still name the *original*
recipient, so `to:` searches on the destination account won't match it, and
this tag becomes the only reliable handle for "what came from the archive."

**Label replay:** an archived message's Gmail system labels
(`INBOX`/`SENT`/`UNREAD`/`STARRED`/`IMPORTANT`/`SPAM`/`TRASH`/`CATEGORY_*`)
replay onto the inserted copy automatically — this is what preserves
**sent mail** for free: `messages.batchModify` ([Labels](#labels)) forbids
adding `SENT`, so `insert` is the only mechanism able to restore mail the
source mailbox sent rather than received. `DRAFT` never replays (it would
create a Drafts row with no backing Draft resource), and any *user* label
from the source mailbox is dropped (it's foreign to the destination, or
worse, collides with an unrelated label there). `--drop-label LABEL_ID`
(repeatable) strips a label after that filter — most commonly
`--drop-label INBOX --drop-label UNREAD`, which restores mail as
already-read and archived instead of dumping it into a live Inbox. Before
the first request (including under `--dry-run`), the run reports how many
selected messages will land in INBOX/UNREAD, and separately how many will
land in TRASH/SPAM — Gmail auto-purges Trash after 30 days, so a "restore"
that silently lands 400 messages there quietly destroys them a month later.

**Idempotency:** a local `insert-ledger.jsonl` (a sibling of
`manifest.jsonl` — see [Sync](#sync)'s Archive layout) records every
message this command has already accounted for against a destination
mailbox, keyed by **(destination address, archived Message-ID)** — Gmail
assigns a brand-new id on every insert, so unlike `sync`/
`extract-attachments`, the source manifest's own id can't serve as the
presence check. The destination address is part of the key, not incidental
metadata: consolidating a legacy mailbox into a *different* current one is
the primary use case, and a ledger scoped only by Message-ID would make a
completed restore into account A silently suppress every insert into
account B. Re-running the same selection inserts nothing the second time;
an interrupted run resumes and only inserts what the ledger doesn't already
have. **`--verify-remote`** adds a pre-insert `rfc822msgid:` probe against
the destination itself — useful for a first run into a mailbox that may
already hold some of this mail, or as a recovery path after losing the
ledger — but is a supplement to the ledger, not a substitute: the probe
costs extra quota and a round-trip per message, and Gmail's search index
can lag a real insert by seconds to minutes.

**`--dry-run`** does two read-only preflight calls (destination identity,
label resolution) and reports exactly what would be inserted, with the
INBOX/TRASH counts above — no `messages.insert` call is made and no ledger
file is written.

**Concurrency and rate limits:** default `--concurrency` is **4**, well
below `sync`'s default of 20 — `messages.insert` costs 5x a `messages.get`
against the same quota bucket, so a wider fan-out buys no extra throughput
here and only widens how many messages could be inserted-but-unledgered if
the process crashes mid-run (see the note below). See
[Rate limits and retry behaviour](#rate-limits-and-retry-behaviour) for the
shared retry/backoff mechanics.

**Report summary:** mirrors [Sync](#sync)'s — a trailing `N inserted, N
skipped, N errors` tally in text output (`N would insert` under
`--dry-run`), or a `summary` field in the structured formats, with the full
per-action listing (including informational `Note`s) always included in
`-o json`/`-o yaml`/`-o yamls`/`-o jsonl`. A per-message failure (a
malformed `.eml`, a transient API error) is recorded and the run continues
with the rest of the batch; the command exits non-zero if any message
failed.

**Known limitation:** the ledger is written only after each message's
successful response, so a crash mid-batch can leave a handful of messages
inserted on Gmail's side but not yet recorded locally — a following run
would then insert them again. The alternative (recording *before* sending)
trades a rare, visible, deletable duplicate for a silent, undetectable gap
in restored mail, which is strictly worse for a backup-restore tool.
Recovery is re-running with `--verify-remote`.

**Cleaning up a test/live-verification pass:** the ledger's `inserted_id`
field doubles as an undo list — since `messages.batchModify` has no
client-side scope guard and `TRASH` is addable (only `SENT`/`DRAFT` are
rejected by the API), the *existing* [`gmail label add`](#labels) command
is enough to trash everything a run inserted into a given destination:

```bash
$ jq -r 'select(.destination=="throwaway@example.com") | .inserted_id' \
    ~/mail-archive/insert-ledger.jsonl | xargs omni-dev gmail label add --label TRASH
```

No MCP equivalent — a bulk, mutating, potentially long-running operation is
as poor a fit here as it is for `sync`/`extract-attachments`.

## Rate limits and retry behaviour

[Google's current quota reference](https://developers.google.com/workspace/gmail/api/reference/quota)
lists **6,000 units/minute per user per project**. Sync and insert use a
**100-unit/second continuous pacing budget** derived from that minute quota.
Some projects using the API between November 2025 and April 2026 retain their
previous quotas; the client uses the current documented baseline.

`messages.get` costs **20 units**, `messages.list` costs **5**, `history.list`
costs **2**, and `messages.insert` costs **25**. `messages.batchModify` costs
50 units for up to 1000 ids. `drafts.create` costs 10 units; `draft create`
also makes one `users.getProfile` (1 unit), plus one `messages.get` with
`--reply-to` and one `users.settings.sendAs.list` (1 unit) with `--reply-all`
or `--from`. `draft update` makes one `drafts.update` (15 units) plus two
`drafts.get` calls (20 units each), or one with `--raw`, and one
`users.settings.sendAs.list` with `--from`.

`gmail search`'s ids-only default costs 5 units **per page**. `--enrich` adds
one 20-unit `messages.get` **per hit**, so 50 enriched hits on one page cost
1,005 units. `--limit 0 --enrich` against a large mailbox can consume substantial
quota. `--concurrency` (default 4, clamped to 1 through 5) bounds in-flight gets;
it does not pace request rate. Size large enrichment requests deliberately.

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 100-units/second budget
with a proactive token-bucket limiter, in addition to reactive
retry — see [Sync](#sync) above. `search --enrich`/`thread`/`draft list` still rely on
`--concurrency` (or `draft list`'s fixed bound of 4) alone (a concurrency bound, not a rate limiter) plus this
retry driver as their only quota protection.

Sync and insert buckets start with one second's budget and refill continuously;
they do not enforce an exact minute window or coordinate quota across processes.
Retries can also consume additional quota.

The list endpoints (`search`, `draft list`, `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`, `insert`,
`draft create` or `draft update` fails — read commands (`search`, `read`, `thread`,
`draft list`, `auth status`) all work fine; only mailbox writes 403. Fix is
`omni-dev gmail auth login --modify` (re-consent with the write scope), not
a retry. `draft create`, `draft update`, `label add`/`remove` and `insert` say so
themselves:

```
Error: This Gmail account is authorised read-only, and this command needs the `gmail.modify` scope. Re-run `omni-dev gmail auth login --modify` (adding `--account NAME` for a named account) to grant it.
  Caused by: Gmail API request failed: HTTP 403: …
```

`insert` stops at the first such 403 instead of trying every message, since
each one would fail the same way. It still prints the run's report (including
any other per-message errors), then fails once with the error above, saying how
many inserts Gmail refused and how many were never sent. Inserts already in
flight when the 403 arrives still finish, so the ledger misses nothing.

### 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 records the failed ids in
`state.json`'s `pending_fetch`, 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), is not added to `pending_fetch`, and does
not fail `sync`'s or `sync-all`'s exit code. A message missing for any
*other* reason (a 404 with a different `reason`, or any non-404 failure)
still surfaces as an ordinary error and is retried on the next run. 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-8-tools — parameter-only listing of
  all 8 `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.