@hartewired/gmail-cli 1.0.0
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/LICENSE +21 -0
- package/README.md +130 -0
- package/bin/gmail.js +409 -0
- package/lib/api.js +33 -0
- package/lib/args.js +22 -0
- package/lib/auth.js +43 -0
- package/lib/config.js +87 -0
- package/lib/mime.js +195 -0
- package/lib/oauth.js +126 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matt Harte
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# gmail-cli
|
|
2
|
+
|
|
3
|
+
[](https://github.com/harteWired)
|
|
4
|
+
[](https://www.npmjs.com/package/@hartewired/gmail-cli)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
A stateless, zero-dependency, **agent-friendly** Gmail CLI. Read, send, reply, forward, label, and organize mail straight from the shell — with JSON output on every command.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gmail list "is:unread newer_than:3d" --max 10
|
|
11
|
+
gmail send --to a@b.com --subject "Q2 report" --html --body-file note.html --attach report.pdf
|
|
12
|
+
gmail reply <id> --all --body "Thanks — got it."
|
|
13
|
+
gmail markread --query "is:unread older_than:30d"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why another Gmail CLI
|
|
17
|
+
|
|
18
|
+
Most Gmail tools are built for a human sitting at a terminal. This one is built to be **driven by a program** — an LLM agent, a cron job, a shell script:
|
|
19
|
+
|
|
20
|
+
1. **Stateless.** No daemon, no background auth server, no long-running process to crash or reconnect. Each command mints (or reuses a cached) access token and exits. Nothing runs when idle.
|
|
21
|
+
2. **Zero dependencies.** Pure Node (>= 20, uses the built-in `fetch`). Nothing to audit, nothing to break on an upstream release.
|
|
22
|
+
3. **Machine-readable.** `--json` (and JSON-by-default on write/organize commands) means the output parses cleanly. Search-query batch ops (`--query`) let one command act on many messages.
|
|
23
|
+
|
|
24
|
+
If you want a rich human TUI, use [himalaya](https://github.com/pimalaya/himalaya). If you want something an agent can shell out to and parse, that's this.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g @hartewired/gmail-cli
|
|
30
|
+
# or run without installing:
|
|
31
|
+
npx @hartewired/gmail-cli help
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Setup — create a Google OAuth client
|
|
35
|
+
|
|
36
|
+
gmail-cli talks to your own Google Cloud OAuth client (the client secret for a
|
|
37
|
+
Desktop app is not treated as confidential by Google). One-time:
|
|
38
|
+
|
|
39
|
+
1. Go to the [Google Cloud Console](https://console.cloud.google.com/) and create (or pick) a project.
|
|
40
|
+
2. **APIs & Services → Library →** enable the **Gmail API**.
|
|
41
|
+
3. **APIs & Services → OAuth consent screen:** configure it (External is fine), and add your Google account under **Test users** so you can authorize while the app is unverified.
|
|
42
|
+
4. **APIs & Services → Credentials → Create credentials → OAuth client ID → Application type: Desktop app.** Copy the **Client ID** and **Client secret**.
|
|
43
|
+
|
|
44
|
+
Provide the client to gmail-cli either way:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# via env vars
|
|
48
|
+
export GMAIL_CLI_CLIENT_ID="…apps.googleusercontent.com"
|
|
49
|
+
export GMAIL_CLI_CLIENT_SECRET="…"
|
|
50
|
+
|
|
51
|
+
# or persist to the config file (~/.config/gmail-cli/config.json)
|
|
52
|
+
gmail auth --client-id "…apps.googleusercontent.com" --client-secret "…"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Authentication
|
|
56
|
+
|
|
57
|
+
Run the one-time sign-in. Two modes:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
gmail auth # loopback: opens a browser, captures the redirect automatically
|
|
61
|
+
gmail auth --manual # headless: prints a URL, you paste back the `code`
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Use `--manual` on a remote/SSH box with no local browser. It stores a refresh
|
|
65
|
+
token in `~/.config/gmail-cli/config.json` (mode 600). From then on every command
|
|
66
|
+
just works — the CLI refreshes short-lived access tokens on its own.
|
|
67
|
+
|
|
68
|
+
**Prefer bringing your own token?** Any refresh token minted for your OAuth client
|
|
69
|
+
with the Gmail scopes works — obtain one however you like (e.g. the
|
|
70
|
+
[OAuth 2.0 Playground](https://developers.google.com/oauthplayground/)) and set
|
|
71
|
+
`GMAIL_CLI_REFRESH_TOKEN`, or drop `refresh_token` into the config file. `gmail auth`
|
|
72
|
+
is just a convenience wrapper around this.
|
|
73
|
+
|
|
74
|
+
Scopes requested: `gmail.modify`, `gmail.compose`, `gmail.send`.
|
|
75
|
+
|
|
76
|
+
## Commands
|
|
77
|
+
|
|
78
|
+
Read:
|
|
79
|
+
|
|
80
|
+
| Command | Purpose |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `profile` / `labels` | account info / list labels |
|
|
83
|
+
| `list [query] [--max N] [--label L] [--json]` | list messages (Gmail search syntax) |
|
|
84
|
+
| `search <query>` | alias for `list` |
|
|
85
|
+
| `threads [query] [--max N] [--json]` | list threads |
|
|
86
|
+
| `read <id> [--html] [--json] [--raw]` | full message; `--raw` streams the `.eml` |
|
|
87
|
+
| `thread <id> [--json]` | full thread |
|
|
88
|
+
| `attachments <id>` | list a message's attachments |
|
|
89
|
+
| `download <id> [--attachment ID] [--out DIR]` | save attachment(s) to disk |
|
|
90
|
+
|
|
91
|
+
Write — body from `--body TEXT`, `--body-file PATH`, or a pipe (`--body -`);
|
|
92
|
+
`--html` sends `multipart/alternative` (HTML + auto plaintext); `--attach` repeatable:
|
|
93
|
+
|
|
94
|
+
| Command | Purpose |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `send --to A --subject S <body> [--html] [--attach F]... [--cc] [--bcc]` | send a message |
|
|
97
|
+
| `reply <id> <body> [--html] [--all] [--attach F]...` | reply in-thread; `--all` = reply-all |
|
|
98
|
+
| `forward <id> --to A [--body intro] [--html] [--no-attachments]` | forward (re-attaches originals) |
|
|
99
|
+
| `draft …` / `drafts` / `draft-send <id>` / `draft-delete <id>` | draft lifecycle |
|
|
100
|
+
|
|
101
|
+
Organize — every verb accepts one or more `<id>` **and/or** `--query Q` for batch:
|
|
102
|
+
|
|
103
|
+
| Command | Purpose |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `modify <id...> [--add L]... [--remove L]...` | add/remove labels (name or id) |
|
|
106
|
+
| `trash` / `untrash` / `markread` / `markunread` / `star` / `unstar` | common actions |
|
|
107
|
+
| `archive` / `unarchive` / `spam` / `unspam` | move mail around |
|
|
108
|
+
| `label-create <name>` / `label-delete <name>` / `label-rename <name> --to <new>` | label admin |
|
|
109
|
+
|
|
110
|
+
Run `gmail help` for the full list.
|
|
111
|
+
|
|
112
|
+
## Configuration reference
|
|
113
|
+
|
|
114
|
+
| Source | Keys |
|
|
115
|
+
|---|---|
|
|
116
|
+
| Env vars | `GMAIL_CLI_CLIENT_ID`, `GMAIL_CLI_CLIENT_SECRET`, `GMAIL_CLI_REFRESH_TOKEN` |
|
|
117
|
+
| Config file (`$GMAIL_CLI_CONFIG` or `~/.config/gmail-cli/config.json`) | `client_id`, `client_secret`, `refresh_token` |
|
|
118
|
+
|
|
119
|
+
Env vars take precedence over the file. The access-token cache lives at
|
|
120
|
+
`~/.config/gmail-cli/token.json`.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
npm test # node --test (unit tests for MIME, arg parsing, config resolution)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT — see [LICENSE](./LICENSE).
|
package/bin/gmail.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gmail — a stateless, zero-dependency, agent-friendly Gmail CLI.
|
|
3
|
+
//
|
|
4
|
+
// No daemon: each command mints/reuses a cached OAuth access token and exits.
|
|
5
|
+
// Run `gmail auth` once to sign in, then `gmail help` for the command list.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { basename } from 'node:path';
|
|
9
|
+
import { gmail } from '../lib/api.js';
|
|
10
|
+
import { parseArgs } from '../lib/args.js';
|
|
11
|
+
import {
|
|
12
|
+
buildRaw, header, extractBody, htmlToText,
|
|
13
|
+
listAttachments, base64urlDecode, guessMimeType, parseAddrs,
|
|
14
|
+
} from '../lib/mime.js';
|
|
15
|
+
import { authorize } from '../lib/oauth.js';
|
|
16
|
+
import { resolveCreds, saveConfig, configPath } from '../lib/config.js';
|
|
17
|
+
|
|
18
|
+
const asArray = (v) => (v === undefined ? [] : [].concat(v));
|
|
19
|
+
const out = (obj) => console.log(JSON.stringify(obj, null, 2));
|
|
20
|
+
|
|
21
|
+
// --- body / attachment helpers ----------------------------------------------
|
|
22
|
+
function readContent(inline, file) {
|
|
23
|
+
if (file !== undefined) {
|
|
24
|
+
if (file === '-' || file === true) return readFileSync(0, 'utf8');
|
|
25
|
+
return readFileSync(file, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
if (inline === '-' || inline === true) return readFileSync(0, 'utf8');
|
|
28
|
+
return inline;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveBody(flags, { fallback = '' } = {}) {
|
|
32
|
+
const raw = readContent(flags.body, flags['body-file']);
|
|
33
|
+
const content = raw === undefined ? fallback : raw;
|
|
34
|
+
if (flags.html) {
|
|
35
|
+
return { html: content, text: flags.text ? readContent(flags.text) : undefined };
|
|
36
|
+
}
|
|
37
|
+
return { text: content };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveAttachments(flags) {
|
|
41
|
+
return asArray(flags.attach).map((p) => ({
|
|
42
|
+
filename: basename(p), mimeType: guessMimeType(p), content: readFileSync(p),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let _me = null;
|
|
47
|
+
async function myAddress() {
|
|
48
|
+
if (!_me) _me = (await gmail('GET', '/profile')).emailAddress;
|
|
49
|
+
return _me;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- label resolution -------------------------------------------------------
|
|
53
|
+
let _labels = null;
|
|
54
|
+
async function allLabels() {
|
|
55
|
+
if (!_labels) _labels = (await gmail('GET', '/labels')).labels || [];
|
|
56
|
+
return _labels;
|
|
57
|
+
}
|
|
58
|
+
const SYSTEM_LABELS = ['INBOX', 'SPAM', 'TRASH', 'UNREAD', 'STARRED', 'IMPORTANT', 'SENT', 'DRAFT', 'CHAT', 'CATEGORY_PERSONAL', 'CATEGORY_SOCIAL', 'CATEGORY_PROMOTIONS', 'CATEGORY_UPDATES', 'CATEGORY_FORUMS'];
|
|
59
|
+
async function resolveLabel(nameOrId) {
|
|
60
|
+
if (SYSTEM_LABELS.includes(nameOrId.toUpperCase())) return nameOrId.toUpperCase();
|
|
61
|
+
const hit = (await allLabels()).find((l) => l.id === nameOrId || l.name.toLowerCase() === nameOrId.toLowerCase());
|
|
62
|
+
if (!hit) throw new Error(`unknown label: ${nameOrId} (run \`gmail labels\`)`);
|
|
63
|
+
return hit.id;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function resolveTargets(pos, flags) {
|
|
67
|
+
const ids = [...pos];
|
|
68
|
+
if (flags.query) {
|
|
69
|
+
const { messages = [] } = await gmail('GET', '/messages', { query: { q: flags.query, maxResults: flags.max || 100 } });
|
|
70
|
+
ids.push(...messages.map((m) => m.id));
|
|
71
|
+
}
|
|
72
|
+
return [...new Set(ids)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function applyLabels(ids, addLabelIds, removeLabelIds) {
|
|
76
|
+
if (ids.length === 0) throw new Error('no target messages (pass ids or --query)');
|
|
77
|
+
if (ids.length === 1) {
|
|
78
|
+
const res = await gmail('POST', `/messages/${ids[0]}/modify`, { body: { addLabelIds, removeLabelIds } });
|
|
79
|
+
return { id: res.id, labelIds: res.labelIds };
|
|
80
|
+
}
|
|
81
|
+
await gmail('POST', '/messages/batchModify', { body: { ids, addLabelIds, removeLabelIds } });
|
|
82
|
+
return { modified: ids.length, ids };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- commands ---------------------------------------------------------------
|
|
86
|
+
const commands = {
|
|
87
|
+
// auth [--client-id X] [--client-secret Y] [--manual]
|
|
88
|
+
async auth(pos, flags) {
|
|
89
|
+
if (flags['client-id']) saveConfig({ client_id: flags['client-id'] });
|
|
90
|
+
if (flags['client-secret']) saveConfig({ client_secret: flags['client-secret'] });
|
|
91
|
+
const { clientId, clientSecret } = resolveCreds({ requireToken: false });
|
|
92
|
+
const refreshToken = await authorize({ clientId, clientSecret, manual: !!flags.manual });
|
|
93
|
+
saveConfig({ refresh_token: refreshToken });
|
|
94
|
+
const me = await gmail('GET', '/profile');
|
|
95
|
+
out({ authenticated: true, email: me.emailAddress, config: configPath() });
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async profile() { out(await gmail('GET', '/profile')); },
|
|
99
|
+
|
|
100
|
+
async labels() { out((await allLabels()).map(({ id, name, type }) => ({ id, name, type }))); },
|
|
101
|
+
|
|
102
|
+
async list(pos, flags) {
|
|
103
|
+
const q = pos.join(' ') || flags.query || undefined;
|
|
104
|
+
const labelIds = flags.label ? [await resolveLabel(flags.label)] : undefined;
|
|
105
|
+
const { messages = [] } = await gmail('GET', '/messages', { query: { q, maxResults: flags.max || 20, labelIds } });
|
|
106
|
+
const rows = [];
|
|
107
|
+
for (const m of messages) {
|
|
108
|
+
const msg = await gmail('GET', `/messages/${m.id}`, { query: { format: 'metadata', metadataHeaders: ['From', 'Subject', 'Date'] } });
|
|
109
|
+
rows.push({
|
|
110
|
+
id: msg.id, threadId: msg.threadId, date: header(msg.payload, 'Date'),
|
|
111
|
+
from: header(msg.payload, 'From'), subject: header(msg.payload, 'Subject'),
|
|
112
|
+
unread: (msg.labelIds || []).includes('UNREAD'), snippet: msg.snippet,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (flags.json) return out(rows);
|
|
116
|
+
if (!rows.length) return console.log('(no messages)');
|
|
117
|
+
for (const r of rows) {
|
|
118
|
+
console.log(`${r.unread ? '●' : ' '} ${r.id} ${r.date}`);
|
|
119
|
+
console.log(` From: ${r.from}`);
|
|
120
|
+
console.log(` Subject: ${r.subject}`);
|
|
121
|
+
console.log(` ${r.snippet}\n`);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async search(pos, flags) { return commands.list(pos, flags); },
|
|
126
|
+
|
|
127
|
+
async threads(pos, flags) {
|
|
128
|
+
const q = pos.join(' ') || flags.query || undefined;
|
|
129
|
+
const { threads = [] } = await gmail('GET', '/threads', { query: { q, maxResults: flags.max || 20 } });
|
|
130
|
+
const rows = [];
|
|
131
|
+
for (const t of threads) {
|
|
132
|
+
const th = await gmail('GET', `/threads/${t.id}`, { query: { format: 'metadata', metadataHeaders: ['From', 'Subject', 'Date'] } });
|
|
133
|
+
const last = th.messages[th.messages.length - 1];
|
|
134
|
+
rows.push({ threadId: th.id, messages: th.messages.length, subject: header(last.payload, 'Subject'), from: header(last.payload, 'From'), date: header(last.payload, 'Date'), snippet: th.messages[0].snippet });
|
|
135
|
+
}
|
|
136
|
+
if (flags.json) return out(rows);
|
|
137
|
+
if (!rows.length) return console.log('(no threads)');
|
|
138
|
+
for (const r of rows) {
|
|
139
|
+
console.log(`${r.threadId} (${r.messages} msg) ${r.date}`);
|
|
140
|
+
console.log(` ${r.from}`);
|
|
141
|
+
console.log(` ${r.subject}\n`);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async read(pos, flags) {
|
|
146
|
+
const id = pos[0];
|
|
147
|
+
if (!id) throw new Error('usage: gmail read <messageId>');
|
|
148
|
+
if (flags.raw) {
|
|
149
|
+
const msg = await gmail('GET', `/messages/${id}`, { query: { format: 'raw' } });
|
|
150
|
+
return process.stdout.write(base64urlDecode(msg.raw));
|
|
151
|
+
}
|
|
152
|
+
const msg = await gmail('GET', `/messages/${id}`, { query: { format: 'full' } });
|
|
153
|
+
if (flags.json) return out(msg);
|
|
154
|
+
const body = extractBody(msg.payload);
|
|
155
|
+
const atts = listAttachments(msg.payload);
|
|
156
|
+
console.log(`Date: ${header(msg.payload, 'Date')}`);
|
|
157
|
+
console.log(`From: ${header(msg.payload, 'From')}`);
|
|
158
|
+
console.log(`To: ${header(msg.payload, 'To')}`);
|
|
159
|
+
const cc = header(msg.payload, 'Cc');
|
|
160
|
+
if (cc) console.log(`Cc: ${cc}`);
|
|
161
|
+
console.log(`Subject: ${header(msg.payload, 'Subject')}`);
|
|
162
|
+
console.log(`Labels: ${(msg.labelIds || []).join(', ')}`);
|
|
163
|
+
if (atts.length) console.log(`Attach: ${atts.map((a) => `${a.filename} (${a.mimeType}, ${a.size}B)`).join('; ')}`);
|
|
164
|
+
console.log('');
|
|
165
|
+
if (flags.html && body.html) console.log(body.html);
|
|
166
|
+
else console.log(body.text || (body.html ? htmlToText(body.html) : msg.snippet));
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async thread(pos, flags) {
|
|
170
|
+
const id = pos[0];
|
|
171
|
+
if (!id) throw new Error('usage: gmail thread <threadId>');
|
|
172
|
+
const th = await gmail('GET', `/threads/${id}`, { query: { format: 'full' } });
|
|
173
|
+
if (flags.json) return out(th);
|
|
174
|
+
for (const msg of th.messages || []) {
|
|
175
|
+
const body = extractBody(msg.payload);
|
|
176
|
+
console.log('─'.repeat(60));
|
|
177
|
+
console.log(`Date: ${header(msg.payload, 'Date')}`);
|
|
178
|
+
console.log(`From: ${header(msg.payload, 'From')}`);
|
|
179
|
+
console.log(`Subject: ${header(msg.payload, 'Subject')}\n`);
|
|
180
|
+
console.log(body.text || (body.html ? htmlToText(body.html) : msg.snippet));
|
|
181
|
+
console.log('');
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async attachments(pos) {
|
|
186
|
+
const id = pos[0];
|
|
187
|
+
if (!id) throw new Error('usage: gmail attachments <messageId>');
|
|
188
|
+
const msg = await gmail('GET', `/messages/${id}`, { query: { format: 'full' } });
|
|
189
|
+
out(listAttachments(msg.payload));
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async download(pos, flags) {
|
|
193
|
+
const id = pos[0];
|
|
194
|
+
if (!id) throw new Error('usage: gmail download <messageId> [--attachment <id>] [--out <dir>]');
|
|
195
|
+
const msg = await gmail('GET', `/messages/${id}`, { query: { format: 'full' } });
|
|
196
|
+
let atts = listAttachments(msg.payload);
|
|
197
|
+
if (flags.attachment) atts = atts.filter((a) => a.attachmentId === flags.attachment);
|
|
198
|
+
if (!atts.length) throw new Error('no matching attachments');
|
|
199
|
+
const dir = (flags.out && flags.out !== true) ? flags.out : '.';
|
|
200
|
+
const saved = [];
|
|
201
|
+
for (const a of atts) {
|
|
202
|
+
const data = await gmail('GET', `/messages/${id}/attachments/${a.attachmentId}`);
|
|
203
|
+
const path = `${dir.replace(/\/$/, '')}/${a.filename}`;
|
|
204
|
+
writeFileSync(path, base64urlDecode(data.data));
|
|
205
|
+
saved.push({ file: path, bytes: a.size });
|
|
206
|
+
}
|
|
207
|
+
out({ downloaded: saved });
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
async send(pos, flags) {
|
|
211
|
+
if (!flags.to) throw new Error('usage: gmail send --to <addr> --subject <s> --body <text>|--body-file <f> [--html] [--attach <f>]... [--cc] [--bcc]');
|
|
212
|
+
const { text, html } = resolveBody(flags);
|
|
213
|
+
const raw = buildRaw({ to: asArray(flags.to), cc: asArray(flags.cc), bcc: asArray(flags.bcc), subject: flags.subject || '', text, html, attachments: resolveAttachments(flags) });
|
|
214
|
+
const res = await gmail('POST', '/messages/send', { body: { raw } });
|
|
215
|
+
out({ sent: true, id: res.id, threadId: res.threadId });
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async reply(pos, flags) {
|
|
219
|
+
const id = pos[0];
|
|
220
|
+
if (!id) throw new Error('usage: gmail reply <messageId> --body <text> [--html] [--all] [--attach <f>]...');
|
|
221
|
+
const orig = await gmail('GET', `/messages/${id}`, { query: { format: 'metadata', metadataHeaders: ['From', 'To', 'Cc', 'Subject', 'Message-ID', 'References', 'Reply-To'] } });
|
|
222
|
+
const p = orig.payload;
|
|
223
|
+
const me = (await myAddress()).toLowerCase();
|
|
224
|
+
const fromAddr = parseAddrs(header(p, 'Reply-To') || header(p, 'From'));
|
|
225
|
+
const to = fromAddr.map((a) => a.raw);
|
|
226
|
+
let cc = [];
|
|
227
|
+
if (flags.all) {
|
|
228
|
+
const seen = new Set([me, ...fromAddr.map((a) => a.email.toLowerCase())]);
|
|
229
|
+
for (const a of [...parseAddrs(header(p, 'To')), ...parseAddrs(header(p, 'Cc'))]) {
|
|
230
|
+
const key = a.email.toLowerCase();
|
|
231
|
+
if (seen.has(key)) continue;
|
|
232
|
+
seen.add(key);
|
|
233
|
+
cc.push(a.raw);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const subject = header(p, 'Subject');
|
|
237
|
+
const msgId = header(p, 'Message-ID');
|
|
238
|
+
const refs = [header(p, 'References'), msgId].filter(Boolean).join(' ');
|
|
239
|
+
const { text, html } = resolveBody(flags);
|
|
240
|
+
const raw = buildRaw({ to, cc, subject: /^re:/i.test(subject) ? subject : `Re: ${subject}`, text, html, attachments: resolveAttachments(flags), inReplyTo: msgId || undefined, references: refs || undefined });
|
|
241
|
+
const res = await gmail('POST', '/messages/send', { body: { raw, threadId: orig.threadId } });
|
|
242
|
+
out({ replied: true, id: res.id, threadId: res.threadId, cc });
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
async forward(pos, flags) {
|
|
246
|
+
const id = pos[0];
|
|
247
|
+
if (!id || !flags.to) throw new Error('usage: gmail forward <messageId> --to <addr> [--body <intro>] [--html] [--no-attachments]');
|
|
248
|
+
const orig = await gmail('GET', `/messages/${id}`, { query: { format: 'full' } });
|
|
249
|
+
const p = orig.payload;
|
|
250
|
+
const body = extractBody(p);
|
|
251
|
+
const intro = resolveBody(flags, { fallback: '' });
|
|
252
|
+
const quotedHeader = ['---------- Forwarded message ----------', `From: ${header(p, 'From')}`, `Date: ${header(p, 'Date')}`, `Subject: ${header(p, 'Subject')}`, `To: ${header(p, 'To')}`, ''].join('\n');
|
|
253
|
+
let attachments = [];
|
|
254
|
+
if (!flags['no-attachments']) {
|
|
255
|
+
for (const a of listAttachments(p)) {
|
|
256
|
+
const data = await gmail('GET', `/messages/${id}/attachments/${a.attachmentId}`);
|
|
257
|
+
attachments.push({ filename: a.filename, mimeType: a.mimeType, content: base64urlDecode(data.data) });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
let sendBody;
|
|
261
|
+
if (flags.html) {
|
|
262
|
+
const origHtml = body.html || `<pre>${htmlToText(body.text || '')}</pre>`;
|
|
263
|
+
sendBody = { html: `${intro.html || ''}<br><br>${quotedHeader.replace(/\n/g, '<br>')}<br>${origHtml}` };
|
|
264
|
+
} else {
|
|
265
|
+
sendBody = { text: `${intro.text || ''}\n\n${quotedHeader}\n${body.text || htmlToText(body.html || '')}` };
|
|
266
|
+
}
|
|
267
|
+
const subject = header(p, 'Subject');
|
|
268
|
+
const raw = buildRaw({ to: asArray(flags.to), cc: asArray(flags.cc), bcc: asArray(flags.bcc), subject: /^fwd?:/i.test(subject) ? subject : `Fwd: ${subject}`, ...sendBody, attachments });
|
|
269
|
+
const res = await gmail('POST', '/messages/send', { body: { raw } });
|
|
270
|
+
out({ forwarded: true, id: res.id, threadId: res.threadId, attachments: attachments.length });
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
async draft(pos, flags) {
|
|
274
|
+
if (!flags.to) throw new Error('usage: gmail draft --to <addr> --subject <s> --body <text> [--html] [--attach <f>]...');
|
|
275
|
+
const { text, html } = resolveBody(flags);
|
|
276
|
+
const raw = buildRaw({ to: asArray(flags.to), cc: asArray(flags.cc), bcc: asArray(flags.bcc), subject: flags.subject || '', text, html, attachments: resolveAttachments(flags) });
|
|
277
|
+
const res = await gmail('POST', '/drafts', { body: { message: { raw } } });
|
|
278
|
+
out({ draft: true, id: res.id, messageId: res.message?.id });
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
async drafts(pos, flags) {
|
|
282
|
+
const { drafts = [] } = await gmail('GET', '/drafts', { query: { maxResults: flags.max || 20 } });
|
|
283
|
+
const rows = [];
|
|
284
|
+
for (const d of drafts) {
|
|
285
|
+
const msg = await gmail('GET', `/messages/${d.message.id}`, { query: { format: 'metadata', metadataHeaders: ['To', 'Subject'] } });
|
|
286
|
+
rows.push({ draftId: d.id, to: header(msg.payload, 'To'), subject: header(msg.payload, 'Subject'), snippet: msg.snippet });
|
|
287
|
+
}
|
|
288
|
+
out(rows);
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
async 'draft-send'(pos) {
|
|
292
|
+
const id = pos[0];
|
|
293
|
+
if (!id) throw new Error('usage: gmail draft-send <draftId>');
|
|
294
|
+
const res = await gmail('POST', '/drafts/send', { body: { id } });
|
|
295
|
+
out({ sent: true, id: res.id, threadId: res.threadId });
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
async 'draft-delete'(pos) {
|
|
299
|
+
const id = pos[0];
|
|
300
|
+
if (!id) throw new Error('usage: gmail draft-delete <draftId>');
|
|
301
|
+
await gmail('DELETE', `/drafts/${id}`);
|
|
302
|
+
out({ deleted: true, draftId: id });
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
async modify(pos, flags) {
|
|
306
|
+
const ids = await resolveTargets(pos, flags);
|
|
307
|
+
const addLabelIds = [];
|
|
308
|
+
const removeLabelIds = [];
|
|
309
|
+
for (const l of asArray(flags.add)) addLabelIds.push(await resolveLabel(l));
|
|
310
|
+
for (const l of asArray(flags.remove)) removeLabelIds.push(await resolveLabel(l));
|
|
311
|
+
out(await applyLabels(ids, addLabelIds, removeLabelIds));
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
async trash(pos, flags) {
|
|
315
|
+
const ids = await resolveTargets(pos, flags);
|
|
316
|
+
const done = [];
|
|
317
|
+
for (const id of ids) { await gmail('POST', `/messages/${id}/trash`); done.push(id); }
|
|
318
|
+
out({ trashed: done.length, ids: done });
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
async untrash(pos, flags) {
|
|
322
|
+
const ids = await resolveTargets(pos, flags);
|
|
323
|
+
const done = [];
|
|
324
|
+
for (const id of ids) { await gmail('POST', `/messages/${id}/untrash`); done.push(id); }
|
|
325
|
+
out({ untrashed: done.length, ids: done });
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
async markread(pos, flags) { out(await applyLabels(await resolveTargets(pos, flags), [], ['UNREAD'])); },
|
|
329
|
+
async markunread(pos, flags) { out(await applyLabels(await resolveTargets(pos, flags), ['UNREAD'], [])); },
|
|
330
|
+
async star(pos, flags) { out(await applyLabels(await resolveTargets(pos, flags), ['STARRED'], [])); },
|
|
331
|
+
async unstar(pos, flags) { out(await applyLabels(await resolveTargets(pos, flags), [], ['STARRED'])); },
|
|
332
|
+
async archive(pos, flags) { out(await applyLabels(await resolveTargets(pos, flags), [], ['INBOX'])); },
|
|
333
|
+
async unarchive(pos, flags) { out(await applyLabels(await resolveTargets(pos, flags), ['INBOX'], [])); },
|
|
334
|
+
async spam(pos, flags) { out(await applyLabels(await resolveTargets(pos, flags), ['SPAM'], ['INBOX'])); },
|
|
335
|
+
async unspam(pos, flags) { out(await applyLabels(await resolveTargets(pos, flags), ['INBOX'], ['SPAM'])); },
|
|
336
|
+
|
|
337
|
+
async 'label-create'(pos, flags) {
|
|
338
|
+
const name = pos[0] || flags.name;
|
|
339
|
+
if (!name) throw new Error('usage: gmail label-create <name>');
|
|
340
|
+
const res = await gmail('POST', '/labels', { body: { name, labelListVisibility: 'labelShow', messageListVisibility: 'show' } });
|
|
341
|
+
out({ created: true, id: res.id, name: res.name });
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
async 'label-delete'(pos) {
|
|
345
|
+
if (!pos[0]) throw new Error('usage: gmail label-delete <name-or-id>');
|
|
346
|
+
const id = await resolveLabel(pos[0]);
|
|
347
|
+
await gmail('DELETE', `/labels/${id}`);
|
|
348
|
+
out({ deleted: true, id });
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
async 'label-rename'(pos, flags) {
|
|
352
|
+
if (!pos[0] || !flags.to) throw new Error('usage: gmail label-rename <name-or-id> --to <newName>');
|
|
353
|
+
const id = await resolveLabel(pos[0]);
|
|
354
|
+
const res = await gmail('PATCH', `/labels/${id}`, { body: { name: flags.to } });
|
|
355
|
+
out({ renamed: true, id: res.id, name: res.name });
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
help() {
|
|
359
|
+
console.log(`gmail — stateless, zero-dep Gmail CLI
|
|
360
|
+
|
|
361
|
+
Setup:
|
|
362
|
+
gmail auth [--manual] [--client-id X --client-secret Y]
|
|
363
|
+
one-time OAuth sign-in
|
|
364
|
+
|
|
365
|
+
Read:
|
|
366
|
+
profile | labels
|
|
367
|
+
list [query] [--max N] [--label L] [--json]
|
|
368
|
+
search <query> alias for list
|
|
369
|
+
threads [query] [--max N] [--json]
|
|
370
|
+
read <id> [--html] [--json] [--raw] (--raw = .eml to stdout)
|
|
371
|
+
thread <id> [--json]
|
|
372
|
+
attachments <id>
|
|
373
|
+
download <id> [--attachment ID] [--out DIR]
|
|
374
|
+
|
|
375
|
+
Write (body: --body TEXT | --body-file F | pipe '-'; --html sends HTML+text):
|
|
376
|
+
send --to A --subject S --body B [--html] [--attach F]... [--cc] [--bcc]
|
|
377
|
+
reply <id> --body B [--html] [--all] [--attach F]...
|
|
378
|
+
forward <id> --to A [--body intro] [--html] [--no-attachments]
|
|
379
|
+
draft --to A --subject S --body B [--html] [--attach F]...
|
|
380
|
+
drafts | draft-send <draftId> | draft-delete <draftId>
|
|
381
|
+
|
|
382
|
+
Organize (accept <id>... and/or --query Q for batch):
|
|
383
|
+
modify <id...> [--add L]... [--remove L]...
|
|
384
|
+
trash | untrash | markread | markunread | star | unstar
|
|
385
|
+
archive | unarchive | spam | unspam
|
|
386
|
+
label-create <name> | label-delete <name> | label-rename <name> --to <new>
|
|
387
|
+
|
|
388
|
+
Docs: https://github.com/harteWired/gmail-cli`);
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
async function main() {
|
|
393
|
+
const [, , cmd, ...rest] = process.argv;
|
|
394
|
+
const command = commands[cmd];
|
|
395
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h' || !command) {
|
|
396
|
+
if (cmd && !command) console.error(`unknown command: ${cmd}\n`);
|
|
397
|
+
commands.help();
|
|
398
|
+
process.exit(cmd && !command ? 1 : 0);
|
|
399
|
+
}
|
|
400
|
+
const { positional, flags } = parseArgs(rest);
|
|
401
|
+
try {
|
|
402
|
+
await command(positional, flags);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
console.error(`error: ${err.message}`);
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
main();
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Thin wrapper over the Gmail REST API for the authenticated user (`me`).
|
|
2
|
+
|
|
3
|
+
import { getAccessToken } from './auth.js';
|
|
4
|
+
|
|
5
|
+
const BASE = 'https://gmail.googleapis.com/gmail/v1/users/me';
|
|
6
|
+
|
|
7
|
+
// method: GET/POST; path: e.g. '/messages'; query: object; body: JSON-able.
|
|
8
|
+
export async function gmail(method, path, { query, body } = {}) {
|
|
9
|
+
const token = await getAccessToken();
|
|
10
|
+
const url = new URL(BASE + path);
|
|
11
|
+
if (query) {
|
|
12
|
+
for (const [k, v] of Object.entries(query)) {
|
|
13
|
+
if (v === undefined || v === null) continue;
|
|
14
|
+
if (Array.isArray(v)) v.forEach((x) => url.searchParams.append(k, x));
|
|
15
|
+
else url.searchParams.set(k, v);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const res = await fetch(url, {
|
|
19
|
+
method,
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${token}`,
|
|
22
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
23
|
+
},
|
|
24
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
25
|
+
});
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
const data = text ? JSON.parse(text) : {};
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const msg = data?.error?.message || res.statusText;
|
|
30
|
+
throw new Error(`Gmail API ${method} ${path} failed (HTTP ${res.status}): ${msg}`);
|
|
31
|
+
}
|
|
32
|
+
return data;
|
|
33
|
+
}
|
package/lib/args.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Minimal argv parser: splits positionals from --flags. Repeated flags become
|
|
2
|
+
// arrays; bare flags (no following value) are booleans.
|
|
3
|
+
export function parseArgs(argv) {
|
|
4
|
+
const positional = [];
|
|
5
|
+
const flags = {};
|
|
6
|
+
for (let i = 0; i < argv.length; i++) {
|
|
7
|
+
const a = argv[i];
|
|
8
|
+
if (a.startsWith('--')) {
|
|
9
|
+
const key = a.slice(2);
|
|
10
|
+
const next = argv[i + 1];
|
|
11
|
+
if (next === undefined || next.startsWith('--')) {
|
|
12
|
+
flags[key] = true;
|
|
13
|
+
} else {
|
|
14
|
+
flags[key] = key in flags ? [].concat(flags[key], next) : next;
|
|
15
|
+
i++;
|
|
16
|
+
}
|
|
17
|
+
} else {
|
|
18
|
+
positional.push(a);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { positional, flags };
|
|
22
|
+
}
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// OAuth access-token minting. Reads credentials via config.js and caches a
|
|
2
|
+
// short-lived access token so back-to-back commands don't re-hit Google.
|
|
3
|
+
// Stateless: no server, nothing runs when idle.
|
|
4
|
+
|
|
5
|
+
import { resolveCreds, readTokenCache, writeTokenCache } from './config.js';
|
|
6
|
+
|
|
7
|
+
export const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
|
8
|
+
|
|
9
|
+
async function refresh() {
|
|
10
|
+
const { clientId, clientSecret, refreshToken } = resolveCreds();
|
|
11
|
+
const body = new URLSearchParams({
|
|
12
|
+
client_id: clientId,
|
|
13
|
+
client_secret: clientSecret,
|
|
14
|
+
refresh_token: refreshToken,
|
|
15
|
+
grant_type: 'refresh_token',
|
|
16
|
+
});
|
|
17
|
+
const res = await fetch(TOKEN_ENDPOINT, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
20
|
+
body,
|
|
21
|
+
});
|
|
22
|
+
const data = await res.json();
|
|
23
|
+
if (!res.ok || !data.access_token) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`token refresh failed (HTTP ${res.status}): ${data.error || 'unknown'} — ${data.error_description || ''}`.trim() +
|
|
26
|
+
(data.error === 'invalid_grant' ? '\nThe stored refresh token was revoked or expired. Run `gmail auth` again.' : '')
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
writeTokenCache({
|
|
30
|
+
access_token: data.access_token,
|
|
31
|
+
expires_at: Math.floor(Date.now() / 1000) + (data.expires_in || 3600) - 60,
|
|
32
|
+
});
|
|
33
|
+
return data.access_token;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Returns a valid access token, refreshing only when the cached one is stale.
|
|
37
|
+
export async function getAccessToken() {
|
|
38
|
+
const cache = readTokenCache();
|
|
39
|
+
if (cache?.access_token && cache.expires_at > Math.floor(Date.now() / 1000)) {
|
|
40
|
+
return cache.access_token;
|
|
41
|
+
}
|
|
42
|
+
return refresh();
|
|
43
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Configuration + credential resolution for gmail-cli.
|
|
2
|
+
//
|
|
3
|
+
// Credentials (OAuth client id/secret + refresh token) resolve in priority
|
|
4
|
+
// order:
|
|
5
|
+
// 1. Environment variables (GMAIL_CLI_CLIENT_ID, _CLIENT_SECRET, _REFRESH_TOKEN)
|
|
6
|
+
// 2. Config file JSON (default ~/.config/gmail-cli/config.json, override with
|
|
7
|
+
// $GMAIL_CLI_CONFIG)
|
|
8
|
+
//
|
|
9
|
+
// The short-lived access token is cached next to the config file.
|
|
10
|
+
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
|
|
17
|
+
// Paths are resolved lazily (read env at call time) so overrides and tests work.
|
|
18
|
+
export function configDir() {
|
|
19
|
+
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
20
|
+
return join(xdg, 'gmail-cli');
|
|
21
|
+
}
|
|
22
|
+
export function configPath() {
|
|
23
|
+
return process.env.GMAIL_CLI_CONFIG || join(configDir(), 'config.json');
|
|
24
|
+
}
|
|
25
|
+
export function tokenPath() {
|
|
26
|
+
return join(configDir(), 'token.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureDir() {
|
|
30
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadConfig() {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Merge a patch into the config file and persist it (mode 600 — holds secrets).
|
|
42
|
+
export function saveConfig(patch) {
|
|
43
|
+
ensureDir();
|
|
44
|
+
const merged = { ...loadConfig(), ...patch };
|
|
45
|
+
const path = configPath();
|
|
46
|
+
writeFileSync(path, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
|
|
47
|
+
if (existsSync(path)) chmodSync(path, 0o600);
|
|
48
|
+
return merged;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Resolve { clientId, clientSecret, refreshToken }. `requireToken=false` skips
|
|
52
|
+
// the refresh-token requirement (used by `gmail auth`, which is minting one).
|
|
53
|
+
export function resolveCreds({ requireToken = true } = {}) {
|
|
54
|
+
const file = loadConfig();
|
|
55
|
+
const clientId = process.env.GMAIL_CLI_CLIENT_ID || file.client_id;
|
|
56
|
+
const clientSecret = process.env.GMAIL_CLI_CLIENT_SECRET || file.client_secret;
|
|
57
|
+
const refreshToken = process.env.GMAIL_CLI_REFRESH_TOKEN || file.refresh_token;
|
|
58
|
+
|
|
59
|
+
if (!clientId || !clientSecret) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'no OAuth client configured. Set GMAIL_CLI_CLIENT_ID / GMAIL_CLI_CLIENT_SECRET, ' +
|
|
62
|
+
`or add client_id / client_secret to ${configPath()}. See README "Setup".`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (requireToken && !refreshToken) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'not authenticated. Run `gmail auth` to sign in, or set GMAIL_CLI_REFRESH_TOKEN. ' +
|
|
68
|
+
'See README "Authentication".'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return { clientId, clientSecret, refreshToken };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readTokenCache() {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(tokenPath(), 'utf8'));
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function writeTokenCache(obj) {
|
|
83
|
+
ensureDir();
|
|
84
|
+
const path = tokenPath();
|
|
85
|
+
writeFileSync(path, JSON.stringify(obj), { mode: 0o600 });
|
|
86
|
+
if (existsSync(path)) chmodSync(path, 0o600);
|
|
87
|
+
}
|
package/lib/mime.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// MIME helpers: build outgoing RFC 2822 messages (with multipart/alternative
|
|
2
|
+
// and attachments) and decode incoming ones.
|
|
3
|
+
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { basename, extname } from 'node:path';
|
|
6
|
+
|
|
7
|
+
function base64url(buf) {
|
|
8
|
+
return Buffer.from(buf)
|
|
9
|
+
.toString('base64')
|
|
10
|
+
.replace(/\+/g, '-')
|
|
11
|
+
.replace(/\//g, '_')
|
|
12
|
+
.replace(/=+$/, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function base64urlDecode(str) {
|
|
16
|
+
return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Encode a header value that may contain non-ASCII (RFC 2047, B-encoding).
|
|
20
|
+
function encodeHeader(value) {
|
|
21
|
+
// eslint-disable-next-line no-control-regex
|
|
22
|
+
if (/^[\x00-\x7F]*$/.test(value)) return value;
|
|
23
|
+
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Wrap a base64 string at 76 chars per RFC 2045.
|
|
27
|
+
const wrap76 = (b64) => b64.replace(/(.{76})/g, '$1\r\n');
|
|
28
|
+
|
|
29
|
+
// A minimal extension -> MIME type map for attachments.
|
|
30
|
+
const MIME_TYPES = {
|
|
31
|
+
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
|
32
|
+
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
|
33
|
+
'.svg': 'image/svg+xml', '.txt': 'text/plain', '.md': 'text/markdown',
|
|
34
|
+
'.csv': 'text/csv', '.html': 'text/html', '.json': 'application/json',
|
|
35
|
+
'.zip': 'application/zip', '.doc': 'application/msword', '.ics': 'text/calendar',
|
|
36
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
37
|
+
'.xls': 'application/vnd.ms-excel',
|
|
38
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
39
|
+
};
|
|
40
|
+
export function guessMimeType(filename) {
|
|
41
|
+
return MIME_TYPES[extname(filename).toLowerCase()] || 'application/octet-stream';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Build a single MIME part from a set of header lines and a base64 body.
|
|
45
|
+
function part(headers, bodyBase64) {
|
|
46
|
+
return [...headers, '', wrap76(bodyBase64)].join('\r\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Build the body section (everything below the top-level headers): handles
|
|
50
|
+
// text-only, html+text (multipart/alternative), and attachments
|
|
51
|
+
// (multipart/mixed wrapping the body). Returns { headers[], body }.
|
|
52
|
+
function buildBody({ text, html, attachments }) {
|
|
53
|
+
// 1. The message body: text/plain, or multipart/alternative when HTML exists.
|
|
54
|
+
let bodyHeaders;
|
|
55
|
+
let bodyContent;
|
|
56
|
+
if (html) {
|
|
57
|
+
const altBoundary = `alt_${randomBytes(12).toString('hex')}`;
|
|
58
|
+
const textPart = part(
|
|
59
|
+
['Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64'],
|
|
60
|
+
Buffer.from(text || htmlToText(html), 'utf8').toString('base64')
|
|
61
|
+
);
|
|
62
|
+
const htmlPart = part(
|
|
63
|
+
['Content-Type: text/html; charset=UTF-8', 'Content-Transfer-Encoding: base64'],
|
|
64
|
+
Buffer.from(html, 'utf8').toString('base64')
|
|
65
|
+
);
|
|
66
|
+
bodyHeaders = [`Content-Type: multipart/alternative; boundary="${altBoundary}"`];
|
|
67
|
+
bodyContent = [
|
|
68
|
+
`--${altBoundary}`, textPart,
|
|
69
|
+
`--${altBoundary}`, htmlPart,
|
|
70
|
+
`--${altBoundary}--`,
|
|
71
|
+
].join('\r\n');
|
|
72
|
+
} else {
|
|
73
|
+
bodyHeaders = ['Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64'];
|
|
74
|
+
bodyContent = wrap76(Buffer.from(text || '', 'utf8').toString('base64'));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. No attachments -> the body is the whole message.
|
|
78
|
+
if (!attachments || attachments.length === 0) {
|
|
79
|
+
return { headers: bodyHeaders, body: bodyContent };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. Attachments -> multipart/mixed [ bodyPart, ...attachments ].
|
|
83
|
+
const mixedBoundary = `mix_${randomBytes(12).toString('hex')}`;
|
|
84
|
+
const bodyPart = [...bodyHeaders, '', bodyContent].join('\r\n');
|
|
85
|
+
const attachParts = attachments.map((a) => {
|
|
86
|
+
const name = encodeHeader(a.filename);
|
|
87
|
+
return part(
|
|
88
|
+
[
|
|
89
|
+
`Content-Type: ${a.mimeType || guessMimeType(a.filename)}; name="${name}"`,
|
|
90
|
+
'Content-Transfer-Encoding: base64',
|
|
91
|
+
`Content-Disposition: attachment; filename="${name}"`,
|
|
92
|
+
],
|
|
93
|
+
a.content.toString('base64')
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
const body = [
|
|
97
|
+
`--${mixedBoundary}`, bodyPart,
|
|
98
|
+
...attachParts.flatMap((p) => [`--${mixedBoundary}`, p]),
|
|
99
|
+
`--${mixedBoundary}--`,
|
|
100
|
+
].join('\r\n');
|
|
101
|
+
return { headers: [`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`], body };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Build a base64url-encoded raw message for messages.send / drafts.
|
|
105
|
+
// opts: { from, to[], cc[], bcc[], subject, text, html, attachments[],
|
|
106
|
+
// inReplyTo, references }
|
|
107
|
+
// attachments: [{ filename, mimeType?, content: Buffer }]
|
|
108
|
+
export function buildRaw(opts) {
|
|
109
|
+
const addr = (arr) => (Array.isArray(arr) ? arr.join(', ') : arr);
|
|
110
|
+
const top = [];
|
|
111
|
+
if (opts.from) top.push(`From: ${opts.from}`);
|
|
112
|
+
top.push(`To: ${addr(opts.to)}`);
|
|
113
|
+
if (opts.cc?.length) top.push(`Cc: ${addr(opts.cc)}`);
|
|
114
|
+
if (opts.bcc?.length) top.push(`Bcc: ${addr(opts.bcc)}`);
|
|
115
|
+
top.push(`Subject: ${encodeHeader(opts.subject || '')}`);
|
|
116
|
+
if (opts.inReplyTo) top.push(`In-Reply-To: ${opts.inReplyTo}`);
|
|
117
|
+
if (opts.references) top.push(`References: ${opts.references}`);
|
|
118
|
+
top.push('MIME-Version: 1.0');
|
|
119
|
+
|
|
120
|
+
const { headers, body } = buildBody(opts);
|
|
121
|
+
return base64url([...top, ...headers, '', body].join('\r\n'));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Pull a header value (case-insensitive) from a message payload.
|
|
125
|
+
export function header(payload, name) {
|
|
126
|
+
const h = payload?.headers?.find((x) => x.name.toLowerCase() === name.toLowerCase());
|
|
127
|
+
return h?.value || '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Recursively extract the best text body. Returns { text, html }.
|
|
131
|
+
export function extractBody(payload) {
|
|
132
|
+
const out = { text: '', html: '' };
|
|
133
|
+
const walk = (p) => {
|
|
134
|
+
if (!p) return;
|
|
135
|
+
const mime = p.mimeType || '';
|
|
136
|
+
if (p.body?.data) {
|
|
137
|
+
const decoded = base64urlDecode(p.body.data).toString('utf8');
|
|
138
|
+
if (mime === 'text/plain' && !out.text) out.text = decoded;
|
|
139
|
+
else if (mime === 'text/html' && !out.html) out.html = decoded;
|
|
140
|
+
}
|
|
141
|
+
if (p.parts) p.parts.forEach(walk);
|
|
142
|
+
};
|
|
143
|
+
walk(payload);
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// List attachment parts in a payload: [{ filename, mimeType, size, attachmentId }].
|
|
148
|
+
export function listAttachments(payload) {
|
|
149
|
+
const found = [];
|
|
150
|
+
const walk = (p) => {
|
|
151
|
+
if (!p) return;
|
|
152
|
+
if (p.filename && p.body?.attachmentId) {
|
|
153
|
+
found.push({
|
|
154
|
+
filename: p.filename,
|
|
155
|
+
mimeType: p.mimeType,
|
|
156
|
+
size: p.body.size,
|
|
157
|
+
attachmentId: p.body.attachmentId,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (p.parts) p.parts.forEach(walk);
|
|
161
|
+
};
|
|
162
|
+
walk(payload);
|
|
163
|
+
return found;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Very light HTML -> text fallback for display and for the plaintext alternative.
|
|
167
|
+
export function htmlToText(html) {
|
|
168
|
+
return html
|
|
169
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
170
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
171
|
+
.replace(/<\/(p|div|tr|h[1-6]|li)>/gi, '\n')
|
|
172
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
173
|
+
.replace(/<[^>]+>/g, '')
|
|
174
|
+
.replace(/ /g, ' ')
|
|
175
|
+
.replace(/&/g, '&')
|
|
176
|
+
.replace(/</g, '<')
|
|
177
|
+
.replace(/>/g, '>')
|
|
178
|
+
.replace(/"/g, '"')
|
|
179
|
+
.replace(/'/g, "'")
|
|
180
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
181
|
+
.trim();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Parse "Name <a@b.com>, c@d.com" -> [{ name, email, raw }].
|
|
185
|
+
export function parseAddrs(str) {
|
|
186
|
+
if (!str) return [];
|
|
187
|
+
return str.split(',').map((chunk) => {
|
|
188
|
+
const m = chunk.match(/<([^>]+)>/);
|
|
189
|
+
const email = (m ? m[1] : chunk).trim();
|
|
190
|
+
const name = m ? chunk.slice(0, m.index).trim().replace(/^"|"$/g, '') : '';
|
|
191
|
+
return { name, email, raw: chunk.trim() };
|
|
192
|
+
}).filter((a) => a.email);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export { basename };
|
package/lib/oauth.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Interactive OAuth for `gmail auth`. Two modes:
|
|
2
|
+
// - loopback (default): spin a localhost server, open the browser, capture the
|
|
3
|
+
// redirect automatically.
|
|
4
|
+
// - manual (--manual): print the URL, user pastes back the `code`. For
|
|
5
|
+
// headless / remote machines with no local browser.
|
|
6
|
+
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { createInterface } from 'node:readline';
|
|
10
|
+
import { TOKEN_ENDPOINT } from './auth.js';
|
|
11
|
+
|
|
12
|
+
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
13
|
+
|
|
14
|
+
// Full set the CLI needs: read/modify/labels/trash (modify), drafts (compose),
|
|
15
|
+
// send.
|
|
16
|
+
export const SCOPES = [
|
|
17
|
+
'https://www.googleapis.com/auth/gmail.modify',
|
|
18
|
+
'https://www.googleapis.com/auth/gmail.compose',
|
|
19
|
+
'https://www.googleapis.com/auth/gmail.send',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function buildAuthUrl({ clientId, redirectUri, state }) {
|
|
23
|
+
const u = new URL(AUTH_ENDPOINT);
|
|
24
|
+
u.searchParams.set('client_id', clientId);
|
|
25
|
+
u.searchParams.set('redirect_uri', redirectUri);
|
|
26
|
+
u.searchParams.set('response_type', 'code');
|
|
27
|
+
u.searchParams.set('scope', SCOPES.join(' '));
|
|
28
|
+
u.searchParams.set('access_type', 'offline'); // request a refresh token
|
|
29
|
+
u.searchParams.set('prompt', 'consent'); // force refresh_token every time
|
|
30
|
+
if (state) u.searchParams.set('state', state);
|
|
31
|
+
return u.toString();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function exchangeCode({ code, clientId, clientSecret, redirectUri }) {
|
|
35
|
+
const res = await fetch(TOKEN_ENDPOINT, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
38
|
+
body: new URLSearchParams({
|
|
39
|
+
code, client_id: clientId, client_secret: clientSecret,
|
|
40
|
+
redirect_uri: redirectUri, grant_type: 'authorization_code',
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
const data = await res.json();
|
|
44
|
+
if (!res.ok || !data.refresh_token) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`code exchange failed (HTTP ${res.status}): ${data.error || 'unknown'} — ${data.error_description || ''}`.trim() +
|
|
47
|
+
(!data.refresh_token && res.ok ? '\nNo refresh_token returned — revoke prior access at myaccount.google.com and retry (prompt=consent is set).' : '')
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return data.refresh_token;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function openBrowser(url) {
|
|
54
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
55
|
+
: process.platform === 'win32' ? 'cmd'
|
|
56
|
+
: 'xdg-open';
|
|
57
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '""', url] : [url];
|
|
58
|
+
try {
|
|
59
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function prompt(question) {
|
|
67
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
68
|
+
return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Manual flow: fixed OOB-style loopback URI the user completes anywhere, then
|
|
72
|
+
// pastes the code. Uses http://127.0.0.1 as redirect (must be registered on the
|
|
73
|
+
// OAuth client) — the browser lands on a "can't connect" page whose URL carries
|
|
74
|
+
// ?code=...; the user copies that code.
|
|
75
|
+
async function manualFlow({ clientId, clientSecret }) {
|
|
76
|
+
const redirectUri = 'http://127.0.0.1';
|
|
77
|
+
const url = buildAuthUrl({ clientId, redirectUri });
|
|
78
|
+
console.log('\nOpen this URL in any browser, approve access, then copy the `code`');
|
|
79
|
+
console.log('value from the address bar of the page you land on:\n');
|
|
80
|
+
console.log(url + '\n');
|
|
81
|
+
const code = await prompt('Paste the code here: ');
|
|
82
|
+
if (!code) throw new Error('no code provided');
|
|
83
|
+
return exchangeCode({ code, clientId, clientSecret, redirectUri });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Loopback flow: capture the redirect on a local port automatically.
|
|
87
|
+
function loopbackFlow({ clientId, clientSecret }) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const server = createServer(async (req, res) => {
|
|
90
|
+
try {
|
|
91
|
+
const url = new URL(req.url, `http://127.0.0.1`);
|
|
92
|
+
const code = url.searchParams.get('code');
|
|
93
|
+
const err = url.searchParams.get('error');
|
|
94
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
95
|
+
res.end(`<html><body style="font-family:sans-serif;padding:2rem"><h2>${err ? 'Authorization failed' : 'Authorized — you can close this tab.'}</h2></body></html>`);
|
|
96
|
+
server.close();
|
|
97
|
+
if (err) return reject(new Error(`authorization denied: ${err}`));
|
|
98
|
+
if (!code) return reject(new Error('no code in redirect'));
|
|
99
|
+
const port = server.address().port;
|
|
100
|
+
const refreshToken = await exchangeCode({
|
|
101
|
+
code, clientId, clientSecret, redirectUri: `http://127.0.0.1:${port}`,
|
|
102
|
+
});
|
|
103
|
+
resolve(refreshToken);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
reject(e);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
server.on('error', reject);
|
|
109
|
+
server.listen(0, '127.0.0.1', () => {
|
|
110
|
+
const port = server.address().port;
|
|
111
|
+
const redirectUri = `http://127.0.0.1:${port}`;
|
|
112
|
+
const url = buildAuthUrl({ clientId, redirectUri });
|
|
113
|
+
console.log('\nOpening your browser to authorize gmail-cli...');
|
|
114
|
+
console.log('If it does not open, visit this URL manually:\n');
|
|
115
|
+
console.log(url + '\n');
|
|
116
|
+
openBrowser(url);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Run the interactive grant and return a refresh token.
|
|
122
|
+
export function authorize({ clientId, clientSecret, manual }) {
|
|
123
|
+
return manual
|
|
124
|
+
? manualFlow({ clientId, clientSecret })
|
|
125
|
+
: loopbackFlow({ clientId, clientSecret });
|
|
126
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hartewired/gmail-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A stateless, zero-dependency, agent-friendly Gmail CLI. Read, send, reply, label, and organize mail from the shell — no daemon, JSON output on every command.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gmail": "bin/gmail.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"gmail",
|
|
23
|
+
"cli",
|
|
24
|
+
"email",
|
|
25
|
+
"google",
|
|
26
|
+
"oauth",
|
|
27
|
+
"agent",
|
|
28
|
+
"llm",
|
|
29
|
+
"automation",
|
|
30
|
+
"stateless",
|
|
31
|
+
"zero-dependency"
|
|
32
|
+
],
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/harteWired/gmail-cli.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/harteWired/gmail-cli/issues"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/harteWired/gmail-cli#readme",
|
|
41
|
+
"author": "Matt Harte (https://github.com/harteWired)",
|
|
42
|
+
"license": "MIT"
|
|
43
|
+
}
|