zele 0.2.0 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -1
- package/bin/zele +27 -0
- package/dist/api-utils.d.ts +51 -2
- package/dist/api-utils.js +89 -3
- package/dist/api-utils.js.map +1 -1
- package/dist/auth.d.ts +27 -6
- package/dist/auth.js +185 -129
- package/dist/auth.js.map +1 -1
- package/dist/calendar-client.d.ts +16 -9
- package/dist/calendar-client.js +163 -59
- package/dist/calendar-client.js.map +1 -1
- package/dist/cli.js +28 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/attachment.js +17 -15
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.js +20 -9
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.js +67 -78
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.js +25 -18
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/label.js +33 -45
- package/dist/commands/label.js.map +1 -1
- package/dist/commands/mail-actions.js +11 -13
- package/dist/commands/mail-actions.js.map +1 -1
- package/dist/commands/mail.js +114 -128
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/profile.js +18 -21
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/watch.d.ts +2 -0
- package/dist/commands/watch.js +73 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/db.js +12 -13
- package/dist/db.js.map +1 -1
- package/dist/generated/browser.d.ts +12 -27
- package/dist/generated/client.d.ts +13 -28
- package/dist/generated/client.js +1 -1
- package/dist/generated/commonInputTypes.d.ts +90 -26
- package/dist/generated/enums.d.ts +0 -4
- package/dist/generated/enums.js +0 -3
- package/dist/generated/enums.js.map +1 -1
- package/dist/generated/internal/class.d.ts +22 -55
- package/dist/generated/internal/class.js +12 -4
- package/dist/generated/internal/class.js.map +1 -1
- package/dist/generated/internal/prismaNamespace.d.ts +272 -511
- package/dist/generated/internal/prismaNamespace.js +54 -66
- package/dist/generated/internal/prismaNamespace.js.map +1 -1
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +60 -74
- package/dist/generated/internal/prismaNamespaceBrowser.js +50 -62
- package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
- package/dist/generated/models/Account.d.ts +1637 -0
- package/dist/generated/models/Account.js +2 -0
- package/dist/generated/models/Account.js.map +1 -0
- package/dist/generated/models/CalendarList.d.ts +1161 -0
- package/dist/generated/models/CalendarList.js +2 -0
- package/dist/generated/models/CalendarList.js.map +1 -0
- package/dist/generated/models/Label.d.ts +1161 -0
- package/dist/generated/models/Label.js +2 -0
- package/dist/generated/models/Label.js.map +1 -0
- package/dist/generated/models/Profile.d.ts +1269 -0
- package/dist/generated/models/Profile.js +2 -0
- package/dist/generated/models/Profile.js.map +1 -0
- package/dist/generated/models/SyncState.d.ts +1130 -0
- package/dist/generated/models/SyncState.js +2 -0
- package/dist/generated/models/SyncState.js.map +1 -0
- package/dist/generated/models/Thread.d.ts +1608 -0
- package/dist/generated/models/Thread.js +2 -0
- package/dist/generated/models/Thread.js.map +1 -0
- package/dist/generated/models.d.ts +6 -9
- package/dist/gmail-client.d.ts +119 -94
- package/dist/gmail-client.js +862 -315
- package/dist/gmail-client.js.map +1 -1
- package/dist/mail-tui.d.ts +1 -0
- package/dist/mail-tui.js +517 -0
- package/dist/mail-tui.js.map +1 -0
- package/dist/output.d.ts +6 -4
- package/dist/output.js +124 -17
- package/dist/output.js.map +1 -1
- package/package.json +39 -11
- package/schema.prisma +81 -113
- package/src/api-utils.ts +103 -5
- package/src/auth.ts +224 -143
- package/src/calendar-client.ts +196 -89
- package/src/cli.ts +32 -1
- package/src/commands/attachment.ts +18 -19
- package/src/commands/auth-cmd.ts +19 -9
- package/src/commands/calendar.ts +42 -85
- package/src/commands/draft.ts +19 -22
- package/src/commands/label.ts +21 -57
- package/src/commands/mail-actions.ts +11 -19
- package/src/commands/mail.ts +104 -149
- package/src/commands/profile.ts +12 -28
- package/src/commands/watch.ts +88 -0
- package/src/db.ts +13 -16
- package/src/generated/browser.ts +49 -0
- package/src/generated/client.ts +71 -0
- package/src/generated/commonInputTypes.ts +332 -0
- package/src/generated/enums.ts +17 -0
- package/src/generated/internal/class.ts +250 -0
- package/src/generated/internal/prismaNamespace.ts +1198 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +169 -0
- package/src/generated/models/Account.ts +1848 -0
- package/src/generated/models/CalendarList.ts +1331 -0
- package/src/generated/models/Label.ts +1331 -0
- package/src/generated/models/Profile.ts +1439 -0
- package/src/generated/models/SyncState.ts +1300 -0
- package/src/generated/models/Thread.ts +1787 -0
- package/src/generated/models.ts +17 -0
- package/src/gmail-client.test.ts +59 -0
- package/src/gmail-client.ts +1034 -422
- package/src/mail-tui.tsx +1061 -0
- package/src/output.test.ts +1093 -0
- package/src/output.ts +128 -20
- package/src/schema.sql +58 -68
- package/src/test-fixtures/email-html/safe-claude-event.html +28 -0
- package/src/test-fixtures/email-html/safe-product-announcement.html +25 -0
- package/src/test-fixtures/email-html/safe-tracked-links.html +27 -0
- package/src/test-fixtures/email-html-snapshots/safe-claude-event.html.md +9 -0
- package/src/test-fixtures/email-html-snapshots/safe-product-announcement.html.md +13 -0
- package/src/test-fixtures/email-html-snapshots/safe-tracked-links.html.md +7 -0
- package/AGENTS.md +0 -26
- package/CHANGELOG.md +0 -36
- package/dist/generated/models/accounts.d.ts +0 -2000
- package/dist/generated/models/accounts.js +0 -2
- package/dist/generated/models/accounts.js.map +0 -1
- package/dist/generated/models/calendar_events.d.ts +0 -1433
- package/dist/generated/models/calendar_events.js +0 -2
- package/dist/generated/models/calendar_events.js.map +0 -1
- package/dist/generated/models/calendar_lists.d.ts +0 -1131
- package/dist/generated/models/calendar_lists.js +0 -2
- package/dist/generated/models/calendar_lists.js.map +0 -1
- package/dist/generated/models/label_counts.d.ts +0 -1131
- package/dist/generated/models/label_counts.js +0 -2
- package/dist/generated/models/label_counts.js.map +0 -1
- package/dist/generated/models/labels.d.ts +0 -1131
- package/dist/generated/models/labels.js +0 -2
- package/dist/generated/models/labels.js.map +0 -1
- package/dist/generated/models/profiles.d.ts +0 -1131
- package/dist/generated/models/profiles.js +0 -2
- package/dist/generated/models/profiles.js.map +0 -1
- package/dist/generated/models/sync_states.d.ts +0 -1107
- package/dist/generated/models/sync_states.js +0 -2
- package/dist/generated/models/sync_states.js.map +0 -1
- package/dist/generated/models/thread_lists.d.ts +0 -1404
- package/dist/generated/models/thread_lists.js +0 -2
- package/dist/generated/models/thread_lists.js.map +0 -1
- package/dist/generated/models/threads.d.ts +0 -1247
- package/dist/generated/models/threads.js +0 -2
- package/dist/generated/models/threads.js.map +0 -1
- package/dist/gmail-cache.d.ts +0 -60
- package/dist/gmail-cache.js +0 -264
- package/dist/gmail-cache.js.map +0 -1
- package/docs/gogcli-gmail-implementation.md +0 -599
- package/scripts/test-device-code-clients.ts +0 -186
- package/scripts/test-micropython-scopes.ts +0 -72
- package/scripts/test-oauth-clients.ts +0 -257
- package/src/gmail-cache.ts +0 -339
- package/tsconfig.json +0 -16
|
@@ -1,599 +0,0 @@
|
|
|
1
|
-
# gogcli Gmail Implementation Reference
|
|
2
|
-
|
|
3
|
-
Reference for reimplementing gogcli's Gmail commands in TypeScript using Google JS APIs.
|
|
4
|
-
|
|
5
|
-
Source: `github.com/steipete/gogcli` (Go, v0.9.0)
|
|
6
|
-
JS equivalent: `googleapis` npm package (`google.gmail('v1')`)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
## Architecture Overview
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
CLI args
|
|
13
|
-
|
|
|
14
|
-
v
|
|
15
|
-
Command struct (flags/args via kong parser)
|
|
16
|
-
|
|
|
17
|
-
v
|
|
18
|
-
cmd.Run(ctx, flags)
|
|
19
|
-
|
|
|
20
|
-
v
|
|
21
|
-
newGmailService(ctx, account)
|
|
22
|
-
| reads OAuth creds from keyring
|
|
23
|
-
| creates oauth2.TokenSource (auto-refresh)
|
|
24
|
-
| wraps HTTP transport with RetryTransport
|
|
25
|
-
| returns *gmail.Service
|
|
26
|
-
v
|
|
27
|
-
svc.Users.{Resource}.{Method}("me", ...).Do()
|
|
28
|
-
| (official Google Go API client)
|
|
29
|
-
v
|
|
30
|
-
Output: --json -> JSON to stdout | text -> tab-separated table
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Key design: every command follows the same pattern:
|
|
34
|
-
1. Validate args/flags
|
|
35
|
-
2. Get authenticated Gmail service
|
|
36
|
-
3. Call Google API
|
|
37
|
-
4. Format output (JSON or text)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
## Auth & Service Creation
|
|
41
|
-
|
|
42
|
-
**Go implementation** (`googleapi/gmail.go`, `googleapi/client.go`):
|
|
43
|
-
|
|
44
|
-
```
|
|
45
|
-
NewGmail(ctx, email)
|
|
46
|
-
-> optionsForAccount(ctx, ServiceGmail, email)
|
|
47
|
-
-> resolve OAuth client name for email
|
|
48
|
-
-> read client credentials (clientID, clientSecret) from config
|
|
49
|
-
-> load refresh token from keyring/secrets store
|
|
50
|
-
-> create oauth2.TokenSource with auto-refresh
|
|
51
|
-
-> wrap in RetryTransport (handles 429 + 5xx)
|
|
52
|
-
-> return []option.ClientOption with authenticated HTTP client
|
|
53
|
-
-> gmail.NewService(ctx, opts...)
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
**JS equivalent**:
|
|
57
|
-
```ts
|
|
58
|
-
import { google } from 'googleapis';
|
|
59
|
-
|
|
60
|
-
const oauth2Client = new google.auth.OAuth2(clientId, clientSecret);
|
|
61
|
-
oauth2Client.setCredentials({ refresh_token: refreshToken });
|
|
62
|
-
const gmail = google.gmail({ version: 'v1', auth: oauth2Client });
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
The Go version uses a package-level `var newGmailService` that can be swapped in tests.
|
|
66
|
-
In JS, use dependency injection or a factory function.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
## Retry & Error Handling
|
|
70
|
-
|
|
71
|
-
**RetryTransport** (`googleapi/transport.go`):
|
|
72
|
-
- Wraps HTTP transport at the `RoundTrip` level
|
|
73
|
-
- **429 (rate limit)**: up to 3 retries, exponential backoff (1s base) with jitter, respects `Retry-After` header
|
|
74
|
-
- **5xx (server error)**: up to 1 retry, 1s delay
|
|
75
|
-
- **Circuit breaker**: stops all requests if too many consecutive failures
|
|
76
|
-
- Request bodies are buffered for replay on retry
|
|
77
|
-
|
|
78
|
-
**JS equivalent**: use `axios-retry` or `gaxios` retry config, or implement manually:
|
|
79
|
-
```ts
|
|
80
|
-
const backoff = (attempt: number) => {
|
|
81
|
-
const base = 1000 * Math.pow(2, attempt);
|
|
82
|
-
const jitter = Math.random() * base / 2;
|
|
83
|
-
return base + jitter;
|
|
84
|
-
};
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
**Error types** (`googleapi/errors.go`):
|
|
88
|
-
- `AuthRequiredError` - no token found, need to re-auth
|
|
89
|
-
- `RateLimitError` - 429 exceeded after retries
|
|
90
|
-
- `CircuitBreakerError` - too many failures
|
|
91
|
-
- `QuotaExceededError` - API quota hit
|
|
92
|
-
- `NotFoundError` - resource not found
|
|
93
|
-
- `PermissionDeniedError` - insufficient scopes
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
## Output System
|
|
97
|
-
|
|
98
|
-
**Two modes** (`outfmt/outfmt.go`):
|
|
99
|
-
- `--json`: `outfmt.WriteJSON(os.Stdout, payload)` - pretty-printed JSON, no HTML escaping
|
|
100
|
-
- `--plain`: raw TSV output (no alignment)
|
|
101
|
-
- Default: aligned tab-separated table via `tabwriter`
|
|
102
|
-
|
|
103
|
-
**Pattern in every command**:
|
|
104
|
-
```go
|
|
105
|
-
if outfmt.IsJSON(ctx) {
|
|
106
|
-
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
|
107
|
-
"threads": items,
|
|
108
|
-
"nextPageToken": resp.NextPageToken,
|
|
109
|
-
})
|
|
110
|
-
}
|
|
111
|
-
// else: print table
|
|
112
|
-
w, flush := tableWriter(ctx)
|
|
113
|
-
defer flush()
|
|
114
|
-
fmt.Fprintln(w, "ID\tDATE\tFROM\tSUBJECT")
|
|
115
|
-
for _, it := range items {
|
|
116
|
-
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", it.ID, it.Date, it.From, it.Subject)
|
|
117
|
-
}
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
## Pagination
|
|
122
|
-
|
|
123
|
-
**Pattern used everywhere**:
|
|
124
|
-
- `--max` (alias `--limit`): passed as `MaxResults(n)` to the API call
|
|
125
|
-
- `--page`: passed as `PageToken(token)` to the API call
|
|
126
|
-
- Response includes `NextPageToken`
|
|
127
|
-
- In JSON mode: `nextPageToken` field in output
|
|
128
|
-
- In text mode: hint printed to stderr: `# Next page: --page <token>`
|
|
129
|
-
|
|
130
|
-
**Go implementation** (`output_helpers.go`):
|
|
131
|
-
```go
|
|
132
|
-
func printNextPageHint(u *ui.UI, nextPageToken string) {
|
|
133
|
-
if nextPageToken == "" { return }
|
|
134
|
-
u.Err().Printf("# Next page: --page %s", nextPageToken)
|
|
135
|
-
}
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
**JS equivalent**: The Google JS API supports the same pattern:
|
|
139
|
-
```ts
|
|
140
|
-
const res = await gmail.users.threads.list({
|
|
141
|
-
userId: 'me', q: query, maxResults: max, pageToken: page
|
|
142
|
-
});
|
|
143
|
-
// res.data.nextPageToken
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
The CLI does NOT auto-paginate. It returns one page and tells the user how to get the next one.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
## Concurrent Fetching
|
|
150
|
-
|
|
151
|
-
Search endpoints only return IDs. The CLI fetches details concurrently.
|
|
152
|
-
|
|
153
|
-
**Pattern** (`gmail.go:fetchThreadDetails`, `gmail_messages.go:fetchMessageDetails`):
|
|
154
|
-
```go
|
|
155
|
-
const maxConcurrency = 10
|
|
156
|
-
sem := make(chan struct{}, maxConcurrency)
|
|
157
|
-
|
|
158
|
-
for i, t := range threads {
|
|
159
|
-
go func(idx int, threadID string) {
|
|
160
|
-
sem <- struct{}{} // acquire
|
|
161
|
-
defer func() { <-sem }() // release
|
|
162
|
-
|
|
163
|
-
thread, err := svc.Users.Threads.Get("me", threadID).
|
|
164
|
-
Format("metadata").
|
|
165
|
-
MetadataHeaders("From", "Subject", "Date").
|
|
166
|
-
Do()
|
|
167
|
-
// ...
|
|
168
|
-
}(i, t.Id)
|
|
169
|
-
}
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
- Bounded to 10 concurrent requests to avoid rate limiting
|
|
173
|
-
- Results collected in order (indexed array)
|
|
174
|
-
- On error: re-runs sequentially to find first error
|
|
175
|
-
|
|
176
|
-
**JS equivalent**: use `Promise.all` with a concurrency limiter like `p-limit`:
|
|
177
|
-
```ts
|
|
178
|
-
import pLimit from 'p-limit';
|
|
179
|
-
const limit = pLimit(10);
|
|
180
|
-
const details = await Promise.all(
|
|
181
|
-
threadIds.map(id => limit(() => gmail.users.threads.get({
|
|
182
|
-
userId: 'me', id, format: 'metadata',
|
|
183
|
-
metadataHeaders: ['From', 'Subject', 'Date']
|
|
184
|
-
})))
|
|
185
|
-
);
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
## Label Resolution
|
|
190
|
-
|
|
191
|
-
Labels are referenced by name or ID. The CLI resolves names to IDs before API calls.
|
|
192
|
-
|
|
193
|
-
**Two helper maps**:
|
|
194
|
-
- `fetchLabelNameToID(svc)` - for modifying labels (name -> ID)
|
|
195
|
-
- `fetchLabelIDToName(svc)` - for display (ID -> name)
|
|
196
|
-
|
|
197
|
-
Both call `svc.Users.Labels.List("me")` and build maps.
|
|
198
|
-
Name lookups are case-insensitive (`strings.ToLower`).
|
|
199
|
-
|
|
200
|
-
**JS equivalent**:
|
|
201
|
-
```ts
|
|
202
|
-
const labelsRes = await gmail.users.labels.list({ userId: 'me' });
|
|
203
|
-
const nameToId = new Map(
|
|
204
|
-
labelsRes.data.labels.map(l => [l.name.toLowerCase(), l.id])
|
|
205
|
-
);
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
## Command-by-Command Breakdown
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
### gmail search
|
|
213
|
-
|
|
214
|
-
**API**: `GET /gmail/v1/users/me/threads?q={query}&maxResults={max}&pageToken={page}`
|
|
215
|
-
|
|
216
|
-
**Go call**: `svc.Users.Threads.List("me").Q(query).MaxResults(max).PageToken(page).Do()`
|
|
217
|
-
|
|
218
|
-
**Then**: For each thread in response, concurrently fetch:
|
|
219
|
-
`svc.Users.Threads.Get("me", threadID).Format("metadata").MetadataHeaders("From", "Subject", "Date").Do()`
|
|
220
|
-
|
|
221
|
-
**Output fields**: `id`, `date`, `from`, `subject`, `labels`, `messageCount`
|
|
222
|
-
|
|
223
|
-
**Flags**:
|
|
224
|
-
| Flag | Type | Default | Description |
|
|
225
|
-
|------|------|---------|-------------|
|
|
226
|
-
| `query` | arg[] | required | Gmail search query (joined with spaces) |
|
|
227
|
-
| `--max` | int | 10 | Max results per page |
|
|
228
|
-
| `--page` | string | - | Page token for next page |
|
|
229
|
-
| `--oldest` | bool | false | Show first message date instead of last |
|
|
230
|
-
| `--timezone` | string | local | IANA timezone for dates |
|
|
231
|
-
| `--local` | bool | false | Force local timezone |
|
|
232
|
-
|
|
233
|
-
**Date handling**: Dates parsed with `net/mail.ParseDate()`, formatted as `2006-01-02 15:04` in the output timezone.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
### gmail messages search
|
|
237
|
-
|
|
238
|
-
**API**: `GET /gmail/v1/users/me/messages?q={query}&maxResults={max}&pageToken={page}`
|
|
239
|
-
|
|
240
|
-
**Go call**: `svc.Users.Messages.List("me").Q(query).MaxResults(max).PageToken(page).Fields("messages(id,threadId),nextPageToken").Do()`
|
|
241
|
-
|
|
242
|
-
**Then**: For each message, concurrently fetch:
|
|
243
|
-
- Without body: `svc.Users.Messages.Get("me", id).Format("metadata").MetadataHeaders("From", "Subject", "Date").Fields("id,threadId,labelIds,payload(headers)").Do()`
|
|
244
|
-
- With body (`--include-body`): `svc.Users.Messages.Get("me", id).Format("full").Do()`
|
|
245
|
-
|
|
246
|
-
**Output fields**: `id`, `threadId`, `date`, `from`, `subject`, `labels`, `body` (optional)
|
|
247
|
-
|
|
248
|
-
**Extra flags**:
|
|
249
|
-
| Flag | Description |
|
|
250
|
-
|------|-------------|
|
|
251
|
-
| `--include-body` | Include decoded message body (truncated to 200 chars in text mode) |
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
### gmail get
|
|
255
|
-
|
|
256
|
-
**API**: `GET /gmail/v1/users/me/messages/{messageId}?format={format}`
|
|
257
|
-
|
|
258
|
-
**Go call**: `svc.Users.Messages.Get("me", messageID).Format(format).Do()`
|
|
259
|
-
|
|
260
|
-
**Formats**:
|
|
261
|
-
- `full` (default): full message with body + attachments
|
|
262
|
-
- `metadata`: headers only (default headers: From, To, Subject, Date, List-Unsubscribe)
|
|
263
|
-
- `raw`: base64url-encoded RFC822
|
|
264
|
-
|
|
265
|
-
**JSON output includes**:
|
|
266
|
-
- `message` - raw API response
|
|
267
|
-
- `headers` - flattened map: `{from, to, cc, bcc, subject, date}`
|
|
268
|
-
- `body` - decoded body text (full format only)
|
|
269
|
-
- `attachments` - list of `{filename, size, sizeHuman, mimeType, attachmentId}`
|
|
270
|
-
- `unsubscribe` - best unsubscribe link from `List-Unsubscribe` header
|
|
271
|
-
|
|
272
|
-
**Body extraction** (`gmail_thread.go`): walks MIME tree, prefers `text/plain` over `text/html`, decodes base64/quoted-printable, handles charset conversion.
|
|
273
|
-
|
|
274
|
-
**Unsubscribe link parsing**: extracts `<url>` from `List-Unsubscribe` header, prefers HTTPS > HTTP > mailto.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
### gmail send
|
|
278
|
-
|
|
279
|
-
**API**: `POST /gmail/v1/users/me/messages/send` with `{raw: base64url(RFC822)}`
|
|
280
|
-
|
|
281
|
-
**Go call**: `svc.Users.Messages.Send("me", &gmail.Message{Raw: encoded, ThreadId: threadID}).Do()`
|
|
282
|
-
|
|
283
|
-
**RFC822 construction** (`gmail_mime.go`):
|
|
284
|
-
- Built from scratch, no external mail library
|
|
285
|
-
- Handles: From, To, Cc, Bcc, Reply-To, Subject, Date, Message-ID, MIME-Version
|
|
286
|
-
- Reply headers: In-Reply-To, References (from `fetchReplyInfo`)
|
|
287
|
-
- Body: `text/plain` | `text/html` | `multipart/alternative` (both)
|
|
288
|
-
- Attachments: `multipart/mixed` wrapping `multipart/alternative` + attachment parts
|
|
289
|
-
- Encoding: 7bit for text, base64 for attachments
|
|
290
|
-
- Subject encoding: RFC 2047 `=?UTF-8?B?...?=` when non-ASCII
|
|
291
|
-
|
|
292
|
-
**Reply flow**:
|
|
293
|
-
1. If `--reply-to-message-id`: fetch original message metadata (Message-ID, References, From, To, Cc, Reply-To)
|
|
294
|
-
2. If `--thread-id`: fetch thread, pick latest message
|
|
295
|
-
3. Set `In-Reply-To` and `References` headers
|
|
296
|
-
4. Set `ThreadId` on the sent message
|
|
297
|
-
5. If `--reply-all`: auto-populate To/Cc from original (RFC 5322: Reply-To > From)
|
|
298
|
-
|
|
299
|
-
**Send-as alias validation**: `svc.Users.Settings.SendAs.Get("me", fromEmail).Do()` - checks `verificationStatus == "accepted"`
|
|
300
|
-
|
|
301
|
-
**Tracking**: injects 1x1 pixel `<img>` before `</body>` in HTML body, generates encrypted tracking ID
|
|
302
|
-
|
|
303
|
-
**Flags**:
|
|
304
|
-
| Flag | Type | Description |
|
|
305
|
-
|------|------|-------------|
|
|
306
|
-
| `--to` | string | Recipients (comma-separated) |
|
|
307
|
-
| `--cc` | string | CC recipients |
|
|
308
|
-
| `--bcc` | string | BCC recipients |
|
|
309
|
-
| `--subject` | string | Subject (required) |
|
|
310
|
-
| `--body` | string | Plain text body |
|
|
311
|
-
| `--body-file` | string | Body from file (`-` for stdin) |
|
|
312
|
-
| `--body-html` | string | HTML body |
|
|
313
|
-
| `--reply-to-message-id` | string | Reply to message ID |
|
|
314
|
-
| `--thread-id` | string | Reply within thread |
|
|
315
|
-
| `--reply-all` | bool | Auto-populate recipients from original |
|
|
316
|
-
| `--reply-to` | string | Reply-To header |
|
|
317
|
-
| `--attach` | string[] | File paths (repeatable) |
|
|
318
|
-
| `--from` | string | Send-as alias |
|
|
319
|
-
| `--track` | bool | Enable open tracking |
|
|
320
|
-
| `--track-split` | bool | Separate sends per recipient for tracking |
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
### gmail thread get
|
|
324
|
-
|
|
325
|
-
**API**: `GET /gmail/v1/users/me/threads/{threadId}?format=full`
|
|
326
|
-
|
|
327
|
-
**Go call**: `svc.Users.Threads.Get("me", threadID).Format("full").Do()`
|
|
328
|
-
|
|
329
|
-
**Displays**: all messages in thread with headers, body (truncated to 500 chars unless `--full`), attachments
|
|
330
|
-
|
|
331
|
-
**Optional download**: for each message with attachments, calls:
|
|
332
|
-
`svc.Users.Messages.Attachments.Get("me", messageId, attachmentId).Do()`
|
|
333
|
-
|
|
334
|
-
**Attachment caching**: checks if file already exists at destination with matching size before downloading.
|
|
335
|
-
|
|
336
|
-
**Flags**:
|
|
337
|
-
| Flag | Description |
|
|
338
|
-
|------|-------------|
|
|
339
|
-
| `--download` | Download all attachments |
|
|
340
|
-
| `--full` | Show full message bodies (no truncation) |
|
|
341
|
-
| `--out-dir` | Output directory for attachments (default: current dir) |
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
### gmail thread modify
|
|
345
|
-
|
|
346
|
-
**API**: `POST /gmail/v1/users/me/threads/{threadId}/modify` with `{addLabelIds, removeLabelIds}`
|
|
347
|
-
|
|
348
|
-
**Go call**: `svc.Users.Threads.Modify("me", tid, &gmail.ModifyThreadRequest{AddLabelIds: addIDs, RemoveLabelIds: removeIDs}).Do()`
|
|
349
|
-
|
|
350
|
-
Labels are resolved from names to IDs first via `fetchLabelNameToID`.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
### gmail labels list
|
|
354
|
-
|
|
355
|
-
**API**: `GET /gmail/v1/users/me/labels`
|
|
356
|
-
|
|
357
|
-
**Go call**: `svc.Users.Labels.List("me").Do()`
|
|
358
|
-
|
|
359
|
-
**Output**: `id`, `name`, `type` for each label. No pagination (returns all labels).
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
### gmail labels get
|
|
363
|
-
|
|
364
|
-
**API**: `GET /gmail/v1/users/me/labels/{labelId}`
|
|
365
|
-
|
|
366
|
-
**Go call**: `svc.Users.Labels.Get("me", id).Do()`
|
|
367
|
-
|
|
368
|
-
Resolves name to ID first if needed. Output includes message/thread counts.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
### gmail labels create
|
|
372
|
-
|
|
373
|
-
**API**: `POST /gmail/v1/users/me/labels` with `{name, labelListVisibility: "labelShow", messageListVisibility: "show"}`
|
|
374
|
-
|
|
375
|
-
**Go call**: `svc.Users.Labels.Create("me", &gmail.Label{...}).Do()`
|
|
376
|
-
|
|
377
|
-
Pre-checks for duplicate names. Maps 409 Conflict errors to user-friendly messages.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
### gmail labels modify
|
|
381
|
-
|
|
382
|
-
**API**: `POST /gmail/v1/users/me/threads/{threadId}/modify` (per thread)
|
|
383
|
-
|
|
384
|
-
Iterates over multiple thread IDs, modifying labels on each. Reports per-thread success/failure.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
### gmail attachment
|
|
388
|
-
|
|
389
|
-
**API**: `GET /gmail/v1/users/me/messages/{messageId}/attachments/{attachmentId}`
|
|
390
|
-
|
|
391
|
-
**Go call**: `svc.Users.Messages.Attachments.Get("me", messageID, attachmentID).Do()`
|
|
392
|
-
|
|
393
|
-
Returns base64url-encoded data. Decoded and written to file.
|
|
394
|
-
Caches: skips download if file exists with matching size.
|
|
395
|
-
|
|
396
|
-
**Filename**: `{messageId}_{attachmentId[:8]}_{filename}` in attachments dir.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
### gmail history
|
|
400
|
-
|
|
401
|
-
**API**: `GET /gmail/v1/users/me/history?startHistoryId={id}&maxResults={max}&historyTypes=messageAdded`
|
|
402
|
-
|
|
403
|
-
**Go call**: `svc.Users.History.List("me").StartHistoryId(id).MaxResults(max).HistoryTypes("messageAdded").Do()`
|
|
404
|
-
|
|
405
|
-
Returns message IDs that were added since the given history ID.
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
### gmail batch delete
|
|
409
|
-
|
|
410
|
-
**API**: `POST /gmail/v1/users/me/messages/batchDelete` with `{ids: [...]}`
|
|
411
|
-
|
|
412
|
-
**Go call**: `svc.Users.Messages.BatchDelete("me", &gmail.BatchDeleteMessagesRequest{Ids: ids}).Do()`
|
|
413
|
-
|
|
414
|
-
Single API call for multiple message IDs. Permanently deletes (not trash).
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
### gmail batch modify
|
|
418
|
-
|
|
419
|
-
**API**: `POST /gmail/v1/users/me/messages/batchModify` with `{ids, addLabelIds, removeLabelIds}`
|
|
420
|
-
|
|
421
|
-
**Go call**: `svc.Users.Messages.BatchModify("me", &gmail.BatchModifyMessagesRequest{...}).Do()`
|
|
422
|
-
|
|
423
|
-
Single API call. Resolves label names to IDs first.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
### gmail drafts list
|
|
427
|
-
|
|
428
|
-
**API**: `GET /gmail/v1/users/me/drafts?maxResults={max}&pageToken={page}`
|
|
429
|
-
|
|
430
|
-
**Go call**: `svc.Users.Drafts.List("me").MaxResults(max).PageToken(page).Do()`
|
|
431
|
-
|
|
432
|
-
**Output**: `id`, `messageId`, `threadId` per draft. Supports pagination.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
### gmail drafts get
|
|
436
|
-
|
|
437
|
-
**API**: `GET /gmail/v1/users/me/drafts/{draftId}?format=full`
|
|
438
|
-
|
|
439
|
-
**Go call**: `svc.Users.Drafts.Get("me", draftID).Format("full").Do()`
|
|
440
|
-
|
|
441
|
-
Shows headers, body, attachments. Optional `--download` for attachments.
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
### gmail drafts create
|
|
445
|
-
|
|
446
|
-
**API**: `POST /gmail/v1/users/me/drafts` with `{message: {raw: base64url(RFC822)}}`
|
|
447
|
-
|
|
448
|
-
**Go call**: `svc.Users.Drafts.Create("me", &gmail.Draft{Message: msg}).Do()`
|
|
449
|
-
|
|
450
|
-
Same RFC822 building as `send`, but `To` is optional (`allowMissingTo: true`).
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
### gmail drafts update
|
|
454
|
-
|
|
455
|
-
**API**: `PUT /gmail/v1/users/me/drafts/{draftId}` with `{id, message: {raw: base64url(RFC822)}}`
|
|
456
|
-
|
|
457
|
-
**Go call**: `svc.Users.Drafts.Update("me", draftID, &gmail.Draft{Id: draftID, Message: msg}).Do()`
|
|
458
|
-
|
|
459
|
-
Fetches existing draft first to preserve thread ID and To if not explicitly set.
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
### gmail drafts delete
|
|
463
|
-
|
|
464
|
-
**API**: `DELETE /gmail/v1/users/me/drafts/{draftId}`
|
|
465
|
-
|
|
466
|
-
**Go call**: `svc.Users.Drafts.Delete("me", draftID).Do()`
|
|
467
|
-
|
|
468
|
-
Requires confirmation (unless `--force`).
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
### gmail drafts send
|
|
472
|
-
|
|
473
|
-
**API**: `POST /gmail/v1/users/me/drafts/send` with `{id: draftId}`
|
|
474
|
-
|
|
475
|
-
**Go call**: `svc.Users.Drafts.Send("me", &gmail.Draft{Id: draftID}).Do()`
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
### gmail url
|
|
479
|
-
|
|
480
|
-
**API**: none (computed locally)
|
|
481
|
-
|
|
482
|
-
**Format**: `https://mail.google.com/mail/u/0/#inbox/{threadId}`
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
### gmail settings filters list/get/create/delete
|
|
486
|
-
|
|
487
|
-
**APIs**:
|
|
488
|
-
- `GET /gmail/v1/users/me/settings/filters`
|
|
489
|
-
- `GET /gmail/v1/users/me/settings/filters/{filterId}`
|
|
490
|
-
- `POST /gmail/v1/users/me/settings/filters`
|
|
491
|
-
- `DELETE /gmail/v1/users/me/settings/filters/{filterId}`
|
|
492
|
-
|
|
493
|
-
Create takes criteria (from, to, subject, query, hasAttachment) and actions (addLabel, removeLabel, archive, markRead, star, forward, trash, neverSpam, important).
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
### gmail settings delegates list/get/add/remove
|
|
497
|
-
|
|
498
|
-
**APIs**:
|
|
499
|
-
- `GET /gmail/v1/users/me/settings/delegates`
|
|
500
|
-
- `GET /gmail/v1/users/me/settings/delegates/{delegateEmail}`
|
|
501
|
-
- `POST /gmail/v1/users/me/settings/delegates`
|
|
502
|
-
- `DELETE /gmail/v1/users/me/settings/delegates/{delegateEmail}`
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
### gmail settings forwarding list/get/create/delete
|
|
506
|
-
|
|
507
|
-
**APIs**:
|
|
508
|
-
- `GET /gmail/v1/users/me/settings/forwardingAddresses`
|
|
509
|
-
- `GET /gmail/v1/users/me/settings/forwardingAddresses/{email}`
|
|
510
|
-
- `POST /gmail/v1/users/me/settings/forwardingAddresses`
|
|
511
|
-
- `DELETE /gmail/v1/users/me/settings/forwardingAddresses/{email}`
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
### gmail settings autoforward get/update
|
|
515
|
-
|
|
516
|
-
**APIs**:
|
|
517
|
-
- `GET /gmail/v1/users/me/settings/autoForwarding`
|
|
518
|
-
- `PUT /gmail/v1/users/me/settings/autoForwarding`
|
|
519
|
-
|
|
520
|
-
Update takes: enabled, emailAddress, disposition (leaveInInbox, archive, trash, markRead).
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
### gmail settings sendas list/get/create/verify/delete/update
|
|
524
|
-
|
|
525
|
-
**APIs**:
|
|
526
|
-
- `GET /gmail/v1/users/me/settings/sendAs`
|
|
527
|
-
- `GET /gmail/v1/users/me/settings/sendAs/{email}`
|
|
528
|
-
- `POST /gmail/v1/users/me/settings/sendAs`
|
|
529
|
-
- `POST /gmail/v1/users/me/settings/sendAs/{email}/verify`
|
|
530
|
-
- `DELETE /gmail/v1/users/me/settings/sendAs/{email}`
|
|
531
|
-
- `PUT /gmail/v1/users/me/settings/sendAs/{email}`
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
### gmail settings vacation get/update
|
|
535
|
-
|
|
536
|
-
**APIs**:
|
|
537
|
-
- `GET /gmail/v1/users/me/settings/vacation`
|
|
538
|
-
- `PUT /gmail/v1/users/me/settings/vacation`
|
|
539
|
-
|
|
540
|
-
Update takes: enableAutoReply, responseSubject, responseBodyHtml/PlainText, startTime, endTime, restrictToContacts, restrictToDomain.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
### gmail settings watch start/status/renew/stop/serve
|
|
544
|
-
|
|
545
|
-
**APIs**:
|
|
546
|
-
- `POST /gmail/v1/users/me/watch` (start)
|
|
547
|
-
- `POST /gmail/v1/users/me/stop` (stop)
|
|
548
|
-
- Watch status stored locally in config file
|
|
549
|
-
|
|
550
|
-
**Serve** starts a local HTTP server that receives Pub/Sub push notifications. Decodes the notification, fetches new messages via History API, and optionally forwards to a webhook URL.
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
## Helpers to Reimplement
|
|
554
|
-
|
|
555
|
-
### Body extraction
|
|
556
|
-
Walk the MIME part tree recursively. Prefer `text/plain` > `text/html`. Decode `base64` / `quoted-printable`. Handle charset via `Content-Type; charset=...`.
|
|
557
|
-
|
|
558
|
-
### Attachment collection
|
|
559
|
-
Walk MIME parts. Any part with `body.attachmentId` is an attachment.
|
|
560
|
-
|
|
561
|
-
### Date formatting
|
|
562
|
-
Parse RFC 2822 dates, convert to target timezone, format as `YYYY-MM-DD HH:mm`.
|
|
563
|
-
|
|
564
|
-
### Email address parsing
|
|
565
|
-
Use `mail.ParseAddressList()` (Go) / `email-addresses` npm package. Fallback: manual comma-split + `<email>` extraction.
|
|
566
|
-
|
|
567
|
-
### CSV splitting
|
|
568
|
-
`splitCSV(s)`: split on comma, trim whitespace, filter empty strings.
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
## Testing Strategy
|
|
572
|
-
|
|
573
|
-
The Go tests use:
|
|
574
|
-
1. `httptest.NewServer` - fake HTTP server returning canned JSON
|
|
575
|
-
2. Real Google API client pointed at fake server (`option.WithEndpoint(srv.URL)`)
|
|
576
|
-
3. Service constructor swapped via package-level var
|
|
577
|
-
4. `captureStdout(t, fn)` to capture output
|
|
578
|
-
5. No mock libraries
|
|
579
|
-
|
|
580
|
-
**JS equivalent**:
|
|
581
|
-
```ts
|
|
582
|
-
// Use nock or msw to intercept HTTP requests
|
|
583
|
-
import nock from 'nock';
|
|
584
|
-
nock('https://gmail.googleapis.com')
|
|
585
|
-
.get('/gmail/v1/users/me/threads')
|
|
586
|
-
.query({ q: 'test', maxResults: '10' })
|
|
587
|
-
.reply(200, { threads: [...], nextPageToken: 'abc' });
|
|
588
|
-
```
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
## Key Differences for JS Implementation
|
|
592
|
-
|
|
593
|
-
1. **No goroutines**: Use `Promise.all` + `p-limit` for concurrent fetching
|
|
594
|
-
2. **No tabwriter**: Use a table formatting library or simple string padding
|
|
595
|
-
3. **RFC822 building**: Use `nodemailer` or `mailcomposer` instead of building manually
|
|
596
|
-
4. **OAuth**: `googleapis` handles token refresh automatically with `OAuth2Client`
|
|
597
|
-
5. **Retry**: Use `gaxios` retry config or wrap with custom retry logic
|
|
598
|
-
6. **Body parsing**: `gmail` API returns base64url in JS too; use `Buffer.from(data, 'base64url')`
|
|
599
|
-
7. **Keyring**: Use `keytar` or OS-specific credential storage
|