@hartewired/gmail-cli 1.0.0 → 1.1.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/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # gmail-cli
2
2
 
3
- [![by — harteWired](https://img.shields.io/badge/by-harteWired-e6a562?style=flat&labelColor=15151e)](https://github.com/harteWired)
3
+ [![publish](https://img.shields.io/github/actions/workflow/status/harteWired/gmail-cli/publish.yml?style=flat&labelColor=15151e&color=e6a562&label=publish)](https://github.com/harteWired/gmail-cli/actions/workflows/publish.yml)
4
4
  [![npm](https://img.shields.io/npm/v/@hartewired/gmail-cli?style=flat&labelColor=15151e&color=e6a562)](https://www.npmjs.com/package/@hartewired/gmail-cli)
5
+ [![node](https://img.shields.io/node/v/@hartewired/gmail-cli?style=flat&labelColor=15151e&color=e6a562)](https://nodejs.org)
5
6
  [![license: MIT](https://img.shields.io/badge/license-MIT-e6a562?style=flat&labelColor=15151e)](./LICENSE)
7
+ [![by — harteWired](https://img.shields.io/badge/by-harteWired-e6a562?style=flat&labelColor=15151e)](https://github.com/harteWired)
6
8
 
7
9
  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
10
 
@@ -13,6 +15,10 @@ gmail reply <id> --all --body "Thanks — got it."
13
15
  gmail markread --query "is:unread older_than:30d"
14
16
  ```
15
17
 
18
+ <p align="center">
19
+ <img src="https://raw.githubusercontent.com/harteWired/gmail-cli/main/docs/architecture.svg" width="820" alt="Stateless architecture: a caller shells out to gmail, which resolves credentials, mints or reuses a cached access token, calls the Gmail REST API, prints JSON, and exits. No daemon runs when idle.">
20
+ </p>
21
+
16
22
  ## Why another Gmail CLI
17
23
 
18
24
  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:
@@ -73,6 +79,27 @@ is just a convenience wrapper around this.
73
79
 
74
80
  Scopes requested: `gmail.modify`, `gmail.compose`, `gmail.send`.
75
81
 
82
+ ## Multiple accounts
83
+
84
+ Every command takes `--account NAME` to target a specific inbox. Authorize each one once:
85
+
86
+ ```bash
87
+ gmail auth --account personal
88
+ gmail auth --account work
89
+ ```
90
+
91
+ Then target them per command:
92
+
93
+ ```bash
94
+ gmail list --account work "is:unread"
95
+ gmail send --account personal --to a@b.com --subject Hi --body "hey"
96
+ gmail accounts # list configured accounts + the default
97
+ ```
98
+
99
+ The first account you authorize becomes the default (used whenever `--account`
100
+ is omitted). Each account's credentials and token cache are stored separately.
101
+ A single-account setup never needs `--account` at all.
102
+
76
103
  ## Commands
77
104
 
78
105
  Read:
@@ -111,13 +138,25 @@ Run `gmail help` for the full list.
111
138
 
112
139
  ## Configuration reference
113
140
 
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` |
141
+ The config file (`$GMAIL_CLI_CONFIG`, default `~/.config/gmail-cli/config.json`)
142
+ holds named accounts:
143
+
144
+ ```json
145
+ {
146
+ "default": "personal",
147
+ "accounts": {
148
+ "personal": { "client_id": "…", "client_secret": "…", "refresh_token": "…" },
149
+ "work": { "client_id": "…", "client_secret": "…", "refresh_token": "…" }
150
+ }
151
+ }
152
+ ```
153
+
154
+ Env vars override the active account per-field (handy for single-account CI):
155
+ `GMAIL_CLI_CLIENT_ID`, `GMAIL_CLI_CLIENT_SECRET`, `GMAIL_CLI_REFRESH_TOKEN`.
118
156
 
119
- Env vars take precedence over the file. The access-token cache lives at
120
- `~/.config/gmail-cli/token.json`.
157
+ A legacy flat file (`{ "client_id", "client_secret", "refresh_token" }`) is still
158
+ read as the account named `default`. Per-account token caches live at
159
+ `~/.config/gmail-cli/token-<account>.json`.
121
160
 
122
161
  ## Development
123
162
 
package/bin/gmail.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  listAttachments, base64urlDecode, guessMimeType, parseAddrs,
14
14
  } from '../lib/mime.js';
15
15
  import { authorize } from '../lib/oauth.js';
16
- import { resolveCreds, saveConfig, configPath } from '../lib/config.js';
16
+ import { resolveCreds, saveAccount, setAccount, activeAccount, listAccounts, configPath } from '../lib/config.js';
17
17
 
18
18
  const asArray = (v) => (v === undefined ? [] : [].concat(v));
19
19
  const out = (obj) => console.log(JSON.stringify(obj, null, 2));
@@ -84,15 +84,24 @@ async function applyLabels(ids, addLabelIds, removeLabelIds) {
84
84
 
85
85
  // --- commands ---------------------------------------------------------------
86
86
  const commands = {
87
- // auth [--client-id X] [--client-secret Y] [--manual]
87
+ // auth [--account NAME] [--client-id X] [--client-secret Y] [--manual]
88
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'] });
89
+ const account = flags.account || activeAccount();
90
+ setAccount(account);
91
+ const patch = {};
92
+ if (flags['client-id']) patch.client_id = flags['client-id'];
93
+ if (flags['client-secret']) patch.client_secret = flags['client-secret'];
94
+ if (Object.keys(patch).length) saveAccount(account, patch);
91
95
  const { clientId, clientSecret } = resolveCreds({ requireToken: false });
92
96
  const refreshToken = await authorize({ clientId, clientSecret, manual: !!flags.manual });
93
- saveConfig({ refresh_token: refreshToken });
97
+ saveAccount(account, { refresh_token: refreshToken });
94
98
  const me = await gmail('GET', '/profile');
95
- out({ authenticated: true, email: me.emailAddress, config: configPath() });
99
+ out({ authenticated: true, account, email: me.emailAddress, config: configPath() });
100
+ },
101
+
102
+ // accounts — list configured accounts and the active/default one.
103
+ accounts() {
104
+ out({ active: activeAccount(), accounts: listAccounts() });
96
105
  },
97
106
 
98
107
  async profile() { out(await gmail('GET', '/profile')); },
@@ -359,8 +368,12 @@ const commands = {
359
368
  console.log(`gmail — stateless, zero-dep Gmail CLI
360
369
 
361
370
  Setup:
362
- gmail auth [--manual] [--client-id X --client-secret Y]
371
+ gmail auth [--account NAME] [--manual] [--client-id X --client-secret Y]
363
372
  one-time OAuth sign-in
373
+ gmail accounts list configured accounts
374
+
375
+ Any command takes --account NAME to target a specific inbox (default: the
376
+ configured "default" account).
364
377
 
365
378
  Read:
366
379
  profile | labels
@@ -398,6 +411,7 @@ async function main() {
398
411
  process.exit(cmd && !command ? 1 : 0);
399
412
  }
400
413
  const { positional, flags } = parseArgs(rest);
414
+ if (flags.account) setAccount(flags.account); // global: select which account this command uses
401
415
  try {
402
416
  await command(positional, flags);
403
417
  } catch (err) {
package/lib/config.js CHANGED
@@ -1,12 +1,23 @@
1
1
  // Configuration + credential resolution for gmail-cli.
2
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)
3
+ // Multi-account. The config file (default ~/.config/gmail-cli/config.json,
4
+ // override with $GMAIL_CLI_CONFIG) holds named accounts:
8
5
  //
9
- // The short-lived access token is cached next to the config file.
6
+ // {
7
+ // "default": "personal",
8
+ // "accounts": {
9
+ // "personal": { "client_id": "...", "client_secret": "...", "refresh_token": "..." },
10
+ // "work": { "client_id": "...", "client_secret": "...", "refresh_token": "..." }
11
+ // }
12
+ // }
13
+ //
14
+ // A legacy flat file ({ "client_id", "client_secret", "refresh_token" }) is
15
+ // still honored and treated as the account named "default".
16
+ //
17
+ // The active account is picked by (in order): the --account flag, the config
18
+ // "default", then "default". Env vars (GMAIL_CLI_CLIENT_ID / _CLIENT_SECRET /
19
+ // _REFRESH_TOKEN) override per-field for whichever account is active — handy
20
+ // for single-account CI.
10
21
 
11
22
  import { homedir } from 'node:os';
12
23
  import { join } from 'node:path';
@@ -14,7 +25,7 @@ import {
14
25
  readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync,
15
26
  } from 'node:fs';
16
27
 
17
- // Paths are resolved lazily (read env at call time) so overrides and tests work.
28
+ // --- paths (lazy: read env at call time, so tests/overrides work) -----------
18
29
  export function configDir() {
19
30
  const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
20
31
  return join(xdg, 'gmail-cli');
@@ -22,8 +33,9 @@ export function configDir() {
22
33
  export function configPath() {
23
34
  return process.env.GMAIL_CLI_CONFIG || join(configDir(), 'config.json');
24
35
  }
25
- export function tokenPath() {
26
- return join(configDir(), 'token.json');
36
+ const sanitize = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_');
37
+ export function tokenPath(account) {
38
+ return join(configDir(), `token-${sanitize(account || activeAccount())}.json`);
27
39
  }
28
40
 
29
41
  function ensureDir() {
@@ -38,39 +50,81 @@ export function loadConfig() {
38
50
  }
39
51
  }
40
52
 
41
- // Merge a patch into the config file and persist it (mode 600 — holds secrets).
42
- export function saveConfig(patch) {
53
+ // --- active-account selection -----------------------------------------------
54
+ let _account = null;
55
+ export function setAccount(name) { _account = name || null; }
56
+
57
+ export function activeAccount() {
58
+ if (_account) return _account;
59
+ const cfg = loadConfig();
60
+ return cfg.default || 'default';
61
+ }
62
+
63
+ export function listAccounts() {
64
+ const cfg = loadConfig();
65
+ if (cfg.accounts) return Object.keys(cfg.accounts);
66
+ if (cfg.client_id || cfg.refresh_token) return ['default']; // legacy flat
67
+ return [];
68
+ }
69
+
70
+ // Per-account stored credentials from the file (no env), or {} if none.
71
+ function fileCredsFor(name) {
72
+ const cfg = loadConfig();
73
+ if (cfg.accounts?.[name]) return cfg.accounts[name];
74
+ // Legacy flat file counts as the "default" account.
75
+ if (!cfg.accounts && name === 'default' && (cfg.client_id || cfg.refresh_token)) {
76
+ return { client_id: cfg.client_id, client_secret: cfg.client_secret, refresh_token: cfg.refresh_token };
77
+ }
78
+ return {};
79
+ }
80
+
81
+ // Write credentials for a named account, migrating a legacy flat file into the
82
+ // accounts map on first multi-account write. Sets `default` if unset.
83
+ export function saveAccount(name, creds) {
43
84
  ensureDir();
44
- const merged = { ...loadConfig(), ...patch };
85
+ const cfg = loadConfig();
86
+ if (!cfg.accounts) {
87
+ cfg.accounts = {};
88
+ if (cfg.client_id || cfg.refresh_token) {
89
+ cfg.accounts.default = { client_id: cfg.client_id, client_secret: cfg.client_secret, refresh_token: cfg.refresh_token };
90
+ }
91
+ }
92
+ delete cfg.client_id; delete cfg.client_secret; delete cfg.refresh_token;
93
+ cfg.accounts[name] = { ...cfg.accounts[name], ...creds };
94
+ if (!cfg.default) cfg.default = name;
45
95
  const path = configPath();
46
- writeFileSync(path, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
96
+ writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
47
97
  if (existsSync(path)) chmodSync(path, 0o600);
48
- return merged;
98
+ return cfg;
49
99
  }
50
100
 
51
- // Resolve { clientId, clientSecret, refreshToken }. `requireToken=false` skips
52
- // the refresh-token requirement (used by `gmail auth`, which is minting one).
101
+ // --- credential resolution --------------------------------------------------
102
+ // Resolve { account, clientId, clientSecret, refreshToken } for the active
103
+ // account. `requireToken=false` skips the refresh-token requirement (used by
104
+ // `gmail auth`, which is minting one).
53
105
  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;
106
+ const account = activeAccount();
107
+ const fc = fileCredsFor(account);
108
+ const clientId = process.env.GMAIL_CLI_CLIENT_ID || fc.client_id;
109
+ const clientSecret = process.env.GMAIL_CLI_CLIENT_SECRET || fc.client_secret;
110
+ const refreshToken = process.env.GMAIL_CLI_REFRESH_TOKEN || fc.refresh_token;
58
111
 
59
112
  if (!clientId || !clientSecret) {
60
113
  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".`
114
+ `no OAuth client for account "${account}". Set GMAIL_CLI_CLIENT_ID / GMAIL_CLI_CLIENT_SECRET, ` +
115
+ `or run \`gmail auth --account ${account} --client-id … --client-secret …\`. See README "Setup".`
63
116
  );
64
117
  }
65
118
  if (requireToken && !refreshToken) {
66
119
  throw new Error(
67
- 'not authenticated. Run `gmail auth` to sign in, or set GMAIL_CLI_REFRESH_TOKEN. ' +
68
- 'See README "Authentication".'
120
+ `account "${account}" is not authenticated. Run \`gmail auth --account ${account}\`, ` +
121
+ 'or set GMAIL_CLI_REFRESH_TOKEN. See README "Authentication".'
69
122
  );
70
123
  }
71
- return { clientId, clientSecret, refreshToken };
124
+ return { account, clientId, clientSecret, refreshToken };
72
125
  }
73
126
 
127
+ // --- token cache ------------------------------------------------------------
74
128
  export function readTokenCache() {
75
129
  try {
76
130
  return JSON.parse(readFileSync(tokenPath(), 'utf8'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hartewired/gmail-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
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
5
  "type": "module",
6
6
  "bin": {