diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3f258f2..e2bbfbe 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "cli-tools", - "description": "Profullstack's command-line tools as installable plugins \u2014 publish to the plain-HTML blog without getting a convention wrong.", + "description": "Profullstack's command-line tools as installable plugins — publish to the plain-HTML blog without getting a convention wrong.", "owner": { "name": "profullstack", "url": "https://profullstack.com" @@ -119,6 +119,25 @@ "bluesky", "myna" ] + }, + { + "name": "mail", + "description": "The inbox from the terminal, for more than one account: list and search over IMAP, read, reply in the thread, send over SMTP with Resend as the fallback, and mark, file or delete without a browser tab.", + "source": "./plugins/mail", + "category": "productivity", + "author": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "homepage": "https://github.com/profullstack/cli-tools#mail", + "keywords": [ + "mail", + "email", + "imap", + "smtp", + "resend", + "inbox" + ] } ] } diff --git a/README.md b/README.md index 82d3e96..d694bd0 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,7 @@ carries the same masked previews, not the values. | `porkbun` | `PORKBUN_API_KEY` | `porkbun` | | `porkbun_secret` | `PORKBUN_SECRET_API_KEY` | `porkbun` | | `moshcode` | `MOSHCODE_API_KEY` | `shorten` | +| `resend` | `RESEND_API_KEY` | `mail`, as the fallback sender | A key earns a row here by being read by a command in this repository, not by being a key the team owns. The vault holds more than twice as many; the rest @@ -624,6 +625,54 @@ with `{"status":"ERROR"}`, so checking the response code reports success for all three. And API access is **off per domain** until you switch it on in that domain's settings — a key that pings fine still gets `Invalid domain` until you do. +### `mail` + +The inbox from the terminal, for more than one account: list and search over +IMAP, read, reply in the thread, send over SMTP with Resend as the fallback, +and mark, file or delete without a browser tab. + +```sh +mail accounts pull # accounts from the cli-tools-mail vault +mail accounts add work you@example.com # or by hand; prompts for the password +mail accounts add home you@gmail.com # gmail is inferred; wants an App Password + +mail ls # newest 25, default account +mail ls -a all --unread # unread across every account +mail search from:substack is:unread +mail search -a all "pottery" since:2026-09-01 +mail read 4213 # headers, then text; marks it read +mail reply 4213 --body "Thanks." # quoted, threaded, to the Reply-To +mail reply 4213 --all --file answer.md --draft +mail send --to a@x --subject Hi --body B --attach deck.pdf +mail mark 4213 --flag +mail archive 4213 +mail rm 4213 # to Trash; --purge --yes to expunge +``` + +Accounts are configuration, not code — this repository is public and names +nobody. They live in `~/.config/cli-tools/mail.json` (0600), and +`mail accounts pull` imports them from the `cli-tools-mail` team vault as +`MAIL__EMAIL`, `_PROVIDER` (`forwardemail`, `gmail`, `custom`), +`_PASSWORD`, optional `_NAME`, `_USER`, `_IMAP_HOST`, `_SMTP_HOST`, `_SMTP_PORT`, +and `MAIL_DEFAULT`. An exported `MAIL__PASSWORD` wins over the stored one; +`mail accounts` says which source is in effect and never prints a password. + +Two providers are built in. Forward Email wants the alias password generated in +its dashboard; Gmail wants an App Password (2-step verification on), and refuses +the account password over IMAP. `custom` takes explicit hosts. + +Sending is SMTP with the account's password. If the *pipe* fails — refused +login, dead host — Resend carries the message when `RESEND_API_KEY` is stored +(`cli-tools config pull`) and the domain is verified there, and a copy is filed +to Sent over IMAP because Resend never files one. A refused *message* is never +retried on the other path. A webmail address (gmail.com and friends) cannot be +verified at Resend, so a Gmail account sends over SMTP only. `--via smtp|resend` +pins one and refuses rather than swapping; `--draft` files to Drafts instead. + +Search takes `from:`, `to:`, `cc:`, `subject:`, `body:`, `since:`/`before:`/`on:` +(`YYYY-MM-DD`), `is:unread|read|flagged|answered`, and bare words for the text; +`--gmail` hands the whole query to Gmail's own search language instead. + ### `blog-post` Publishes to the plain-HTML blog at `~/public_html/blog`. That blog has no build @@ -1288,11 +1337,13 @@ moshcode plugin install domain@cli-tools # /domain:free, /domain:lookup moshcode plugin install ai@cli-tools # /ai:ask, /ai:tts moshcode plugin install bo@cli-tools # /bo:capture, :search, :read, :ask moshcode plugin install myna@cli-tools # /myna:post, :schedule, :queue, :feed +moshcode plugin install mail@cli-tools # /mail:inbox, /mail:send ``` See [plugins/tools](plugins/tools/README.md), [plugins/blog](plugins/blog/README.md), [plugins/domain](plugins/domain/README.md), [plugins/ai](plugins/ai/README.md), -[plugins/bo](plugins/bo/README.md) and [plugins/myna](plugins/myna/README.md). +[plugins/bo](plugins/bo/README.md), [plugins/myna](plugins/myna/README.md) +and [plugins/mail](plugins/mail/README.md). Two of them front a command this repo does not implement, for opposite reasons. `myna` fronts one it *installs* — the companion above — so the plugin is purely diff --git a/bin/cli-tools.ts b/bin/cli-tools.ts index e8933a0..9f5789d 100755 --- a/bin/cli-tools.ts +++ b/bin/cli-tools.ts @@ -42,6 +42,7 @@ import { } from '../src/credentials.ts'; import { pullVault, vaultTarget } from '../src/vault.ts'; import { isMain } from '../src/is-main.ts'; +import { promptSecret } from '../src/prompt.ts'; import { aliasesPath, commands, @@ -373,58 +374,6 @@ function writeAliases(): number { return 0; } -/** - * Read one line without echoing it. - * - * A key typed at a visible prompt ends up in the scrollback of whatever - * terminal, screen share or recording happens to be running, which is most of - * the reason to have this command rather than telling people to edit the file. - * Piped input is read as-is, so `… | cli-tools config set openai` works in a - * script without a TTY. - */ -async function promptSecret(label: string): Promise { - if (!process.stdin.isTTY) { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); - return Buffer.concat(chunks).toString('utf8').trim(); - } - - process.stderr.write(label); - process.stdin.setRawMode(true); - process.stdin.resume(); - - return new Promise((resolve) => { - let value = ''; - const onData = (chunk: Buffer) => { - for (const byte of chunk) { - // Enter, or EOF/interrupt. - if (byte === 0x0d || byte === 0x0a || byte === 0x04) { - finish(); - return; - } - if (byte === 0x03) { - process.stderr.write('\n'); - process.exit(130); - } - // Backspace / delete. - if (byte === 0x7f || byte === 0x08) { - value = value.slice(0, -1); - continue; - } - value += String.fromCharCode(byte); - } - }; - const finish = () => { - process.stdin.off('data', onData); - process.stdin.setRawMode(false); - process.stdin.pause(); - process.stderr.write('\n'); - resolve(value.trim()); - }; - process.stdin.on('data', onData); - }); -} - async function configCommand(rest: readonly string[], json: boolean): Promise { const [verb, name, ...more] = rest; diff --git a/bin/mail.ts b/bin/mail.ts new file mode 100644 index 0000000..319dce5 --- /dev/null +++ b/bin/mail.ts @@ -0,0 +1,684 @@ +#!/usr/bin/env node +/** + * mail — the inbox from the terminal: read, search, reply, send, file, delete. + * + * mail ls the newest messages in the default account + * mail ls -a all --unread unread across every account + * mail read 4213 one message, headers then text + * mail reply 4213 --body "Thanks." answer it, quoted, in the same thread + * mail send --to a@b.c --subject Hi a new message; body from --body, --file or stdin + * mail rm 4213 to Trash; --purge to actually expunge + * + * Two accounts, a business one and a personal one, and neither should need a + * browser tab to answer. Reading is IMAP; sending is the account's SMTP, with + * Resend as the fallback for a domain the team has verified there — never for + * a webmail address, which Resend cannot send as at all. + * + * Accounts are configuration: `mail accounts add`, or `mail accounts pull` + * from the `cli-tools-mail` team vault. Nothing here names a person. + */ + +import { readFileSync } from 'node:fs'; +import { basename } from 'node:path'; + +import { UsageError, csv, integer, parseArgs } from '../src/args.ts'; +import { resolveCredentials } from '../src/credentials.ts'; +import { isMain } from '../src/is-main.ts'; +import { + type Account, + type Folder, + type MailConfig, + type Mailbox, + type Outgoing, + type ProviderName, + type Transport, + MAIL_VAULT_PROJECT, + MailError, + PROVIDERS, + accountsFromVault, + buildReply, + chooseTransport, + composeRaw, + folderFor, + formatAccounts, + formatFolders, + formatList, + formatMessage, + fromHeader, + guessProvider, + loadConfig, + mailConfigPath, + mergeVaultAccounts, + openMailbox, + parseQuery, + passwordVariable, + resolveAccount, + saveConfig, + selectAccount, + selectAccounts, + sendMail, +} from '../src/mail.ts'; +import { confirm, promptSecret } from '../src/prompt.ts'; +import { pullVault, vaultTarget } from '../src/vault.ts'; + +const USAGE = `Usage: + mail accounts the configured accounts + mail accounts add [options] add or update one (prompts for the password) + mail accounts password store or replace a password + mail accounts default which account a bare command means + mail accounts rm + mail accounts pull import accounts from the team vault + + mail folders [-a ACCOUNT] + mail ls [-a ACCOUNT|all] [--folder F] [--unread] [--limit N] [--json] + mail search [-a ACCOUNT|all] [--folder F] [--limit N] [--gmail] [--json] + mail read [-a ACCOUNT] [--folder F] [--keep-unread] [--raw] [--json] + + mail send --to A[,B] --subject S [--cc …] [--bcc …] [--body T | --file P] [--attach P]… [--via smtp|resend] [--draft] + mail reply [--all] [--body T | --file P] [--no-quote] [--via smtp|resend] [--draft] + mail mark … (--read | --unread | --flag | --unflag) [--folder F] + mail mv [--folder F] + mail archive … [--folder F] + mail rm … [--purge] [--yes] [--folder F] + +Options: + -a, --account A which account: its name, its address, or "all" for ls/search + --folder F the folder to work in (default INBOX) + --unread ls: only unread + --limit N how many, newest first (default 25) + --gmail search: hand the query to Gmail's own search language + --keep-unread read: leave the message unread afterwards + --raw read: the message as received, headers and all + --json machine-readable output + --to, --cc, --bcc comma-separated addresses; --to may repeat + --subject S + --body T the text; --file P reads it from a file; otherwise stdin + --attach P a file to attach; may repeat + --all reply: everyone on the original, not just the sender + --no-quote reply: do not quote the original under the answer + --via smtp|resend which way to send; default is SMTP, falling back to Resend + --draft put the message in Drafts instead of sending it + --purge rm: expunge for good instead of moving to Trash + --yes rm --purge: skip the confirmation + -h, --help show this help + +Account options for \`accounts add\`: + --provider forwardemail|gmail|custom (gmail is inferred from the address) + --name "Display Name" --user LOGIN + --imap-host H --imap-port N --smtp-host H --smtp-port N --starttls + --password prompt for it now (the default when on a terminal) + --no-password do not prompt; export ${'MAIL__PASSWORD'} or pull it later + --default make this the default account + +Search: bare words match the text; from: to: cc: subject: body: narrow a header, +since: before: on: take YYYY-MM-DD, and is:unread / is:flagged / is:answered +filter by state. Quote a value with spaces: subject:"pottery wheel". + +Accounts live in ${mailConfigPath()} (0600). A password exported as +MAIL__PASSWORD wins over the stored one. \`mail accounts pull\` imports +MAIL__EMAIL / _PROVIDER / _PASSWORD (and optional _NAME, _USER, _IMAP_HOST, +_SMTP_HOST, …) plus MAIL_DEFAULT from the \`${MAIL_VAULT_PROJECT}\` vault; +CLI_TOOLS_MAIL_VAULT_PROJECT / _ENV point it elsewhere. + +Sending needs either the account's password (SMTP) or a RESEND_API_KEY for a +domain verified at Resend (\`cli-tools config pull\` imports it). A Gmail +account can only send over SMTP — gmail.com cannot be verified at Resend. +`; + +function fail(message: string, code = 2): never { + process.stderr.write(`mail: ${message}\n`); + process.exit(code); +} + +function out(text: string): void { + process.stdout.write(text.endsWith('\n') ? text : `${text}\n`); +} + +function json(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +/** Every positional that is a uid; the rest are what the verb wants otherwise. */ +function splitUids(items: string[]): { uids: number[]; rest: string[] } { + const uids: number[] = []; + const rest: string[] = []; + for (const item of items) { + if (/^\d+$/.test(item)) uids.push(Number(item)); + else rest.push(item); + } + return { uids, rest }; +} + +function needUids(items: string[], verb: string): number[] { + const { uids } = splitUids(items); + if (uids.length === 0) throw new UsageError(`${verb} needs at least one uid — \`mail ls\` shows them`); + return uids; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8'); +} + +/** The body from --body, --file, or stdin — in that order, and never empty. */ +async function bodyFrom(values: Map): Promise { + const inline = values.get('--body'); + if (inline !== undefined) return inline; + const file = values.get('--file'); + if (file !== undefined) return readFileSync(file, 'utf8'); + if (process.stdin.isTTY) { + throw new UsageError('no body — pass --body, --file, or pipe the text on stdin'); + } + const text = await readStdin(); + if (!text.trim()) throw new UsageError('stdin was empty — nothing to send'); + return text; +} + +function addressesFrom(values: Map, repeated: Map, flag: string): string[] { + const all = [...(repeated.get(flag) ?? []), ...(values.has(flag) ? [values.get(flag)!] : [])]; + return all + .flatMap((item) => item.split(',')) + .map((item) => item.trim()) + .filter(Boolean); +} + +async function withMailbox(account: Account, work: (box: Mailbox) => Promise): Promise { + const box = await openMailbox(account); + try { + return await work(box); + } finally { + await box.close().catch(() => undefined); + } +} + +/** File a copy where the account's client will see it: Sent, or Drafts. */ +async function fileCopy( + account: Account, + outgoing: Outgoing, + role: 'Sent' | 'Drafts', +): Promise { + if (!account.password) return null; + return withMailbox(account, async (box) => { + const folder = folderFor(await box.folders(), role); + if (!folder) return null; + const raw = await composeRaw(outgoing); + await box.append(folder, raw, role === 'Drafts' ? ['\\Draft'] : ['\\Seen']); + return folder; + }); +} + +async function accountsVerb(config: MailConfig, args: string[], parsed: ReturnType): Promise { + const [verb, ...rest] = args; + const isJson = parsed.flags.has('--json'); + + if (!verb || verb === 'ls' || verb === 'list') { + const accounts = Object.entries(config.accounts).map(([name, entry]) => { + try { + return resolveAccount(name, entry); + } catch { + // A custom account missing its hosts still deserves a row. + return { + name, + email: entry.email, + displayName: entry.name ?? null, + user: entry.user ?? entry.email, + password: null, + passwordSource: 'unset' as const, + provider: entry.provider, + imap: { host: entry.imapHost ?? '?', port: entry.imapPort ?? 993 }, + smtp: { host: entry.smtpHost ?? '?', port: entry.smtpPort ?? 465, secure: true }, + }; + } + }); + if (isJson) { + json({ + default: config.default ?? null, + accounts: accounts.map(({ password: _password, ...account }) => account), + }); + } else { + out(formatAccounts(accounts, config.default)); + } + return 0; + } + + if (verb === 'add') { + const [name, email] = rest; + if (!name || !email) throw new UsageError('accounts add needs '); + if (!/^[a-z0-9][a-z0-9_-]*$/i.test(name)) { + throw new UsageError('an account name is letters, digits, - and _ — it becomes MAIL__PASSWORD'); + } + if (!email.includes('@')) throw new UsageError(`"${email}" is not an address`); + + const requested = parsed.values.get('--provider')?.toLowerCase(); + let provider: ProviderName; + if (requested === undefined) { + provider = guessProvider(email) ?? (parsed.values.has('--imap-host') ? 'custom' : 'forwardemail'); + } else if (requested === 'forwardemail' || requested === 'gmail' || requested === 'custom') { + provider = requested; + } else { + throw new UsageError(`--provider must be forwardemail, gmail or custom, got "${requested}"`); + } + + const existing = config.accounts[name.toLowerCase()]; + const account = { ...(existing ?? {}), email: email.toLowerCase(), provider }; + const setString = (flag: string, key: 'name' | 'user' | 'imapHost' | 'smtpHost') => { + const value = parsed.values.get(flag); + if (value !== undefined) account[key] = value; + }; + setString('--name', 'name'); + setString('--user', 'user'); + setString('--imap-host', 'imapHost'); + setString('--smtp-host', 'smtpHost'); + if (parsed.values.has('--imap-port')) account.imapPort = integer(parsed.values, '--imap-port', 993, { min: 1, max: 65_535 }); + if (parsed.values.has('--smtp-port')) account.smtpPort = integer(parsed.values, '--smtp-port', 465, { min: 1, max: 65_535 }); + if (parsed.flags.has('--starttls')) account.smtpSecure = false; + + if (provider === 'custom' && (!account.imapHost || !account.smtpHost)) { + throw new UsageError('a custom provider needs --imap-host and --smtp-host'); + } + + const wantsPrompt = + parsed.flags.has('--password') || (!parsed.flags.has('--no-password') && process.stdin.isTTY); + if (wantsPrompt) { + const hint = provider === 'custom' ? '' : `\n (${PROVIDERS[provider].passwordHint})`; + process.stderr.write(`Password for ${email}${hint}\n`); + const password = await promptSecret('password: '); + if (password) account.password = password; + } + + config.accounts[name.toLowerCase()] = account; + if (parsed.flags.has('--default') || Object.keys(config.accounts).length === 1) { + config.default = name.toLowerCase(); + } + const path = saveConfig(config); + out( + `${existing ? 'updated' : 'added'} ${name.toLowerCase()} (${email}, ${provider}` + + `${account.password ? ', password stored' : ', no password'}) in ${path}`, + ); + if (!account.password) { + out(`store one later with \`mail accounts password ${name.toLowerCase()}\` or export ${passwordVariable(name)}`); + } + return 0; + } + + if (verb === 'password') { + const name = rest[0]?.toLowerCase(); + if (!name) throw new UsageError('accounts password needs the account name'); + const account = config.accounts[name]; + if (!account) fail(`no account "${name}". Configured: ${Object.keys(config.accounts).join(', ') || 'none'}`, 1); + if (account.provider !== 'custom') { + process.stderr.write(`(${PROVIDERS[account.provider].passwordHint})\n`); + } + const password = await promptSecret(`password for ${account.email}: `); + if (!password) fail('empty — nothing stored', 1); + account.password = password; + out(`stored the password for ${name} in ${saveConfig(config)}`); + if (process.env[passwordVariable(name)]) { + out(`note: ${passwordVariable(name)} is exported and wins over the stored one`); + } + return 0; + } + + if (verb === 'default') { + const name = rest[0]?.toLowerCase(); + if (!name) throw new UsageError('accounts default needs the account name'); + if (!config.accounts[name]) fail(`no account "${name}"`, 1); + config.default = name; + saveConfig(config); + out(`default account is now ${name}`); + return 0; + } + + if (verb === 'rm' || verb === 'remove') { + const name = rest[0]?.toLowerCase(); + if (!name) throw new UsageError('accounts rm needs the account name'); + if (!config.accounts[name]) fail(`no account "${name}"`, 1); + delete config.accounts[name]; + if (config.default === name) delete config.default; + saveConfig(config); + out(`removed ${name}`); + return 0; + } + + if (verb === 'pull') { + const base = vaultTarget(); + const target = { + team: base.team, + project: process.env.CLI_TOOLS_MAIL_VAULT_PROJECT || MAIL_VAULT_PROJECT, + env: process.env.CLI_TOOLS_MAIL_VAULT_ENV || base.env, + }; + const label = `${target.team}/${target.project}--${target.env}`; + process.stderr.write(`accounts: pulling ${label}…\n`); + const fromVault = accountsFromVault(pullVault(target)); + if (Object.keys(fromVault.accounts).length === 0) { + fail( + `${label} holds no MAIL__EMAIL keys. Push accounts there as\n` + + ' MAIL_WORK_EMAIL=… MAIL_WORK_PROVIDER=forwardemail MAIL_WORK_PASSWORD=…\n' + + ' MAIL_HOME_EMAIL=… MAIL_HOME_PROVIDER=gmail MAIL_HOME_PASSWORD=… MAIL_DEFAULT=work', + 1, + ); + } + const { merged, imported, unchanged } = mergeVaultAccounts(config, fromVault); + if (imported.length > 0) { + const path = saveConfig(merged); + for (const name of imported) { + const account = merged.accounts[name]!; + out(`accounts: imported ${name} (${account.email}, ${account.provider}${account.password ? '' : ', no password'})`); + } + out(`accounts: written to ${path}`); + } + for (const name of unchanged) out(`accounts: ${name} already matches the vault`); + const missing = Object.keys(merged.accounts).filter((name) => !merged.accounts[name]!.password); + if (missing.length > 0) { + out( + `\n${missing.join(', ')} ${missing.length === 1 ? 'has' : 'have'} no password yet — ` + + 'add MAIL__PASSWORD to the vault and pull again, or `mail accounts password `.', + ); + } + return 0; + } + + throw new UsageError(`unknown accounts verb "${verb}" (add, password, default, rm, pull)`); +} + +async function main(argv: string[]): Promise { + const parsed = parseArgs(argv, { + boolean: [ + '--json', '--unread', '--gmail', '--keep-unread', '--raw', '--all', '--no-quote', '--draft', + '--purge', '--yes', '--read', '--flag', '--unflag', '--password', '--no-password', '--default', + '--starttls', '-h', '--help', + ], + string: [ + '-a', '--account', '--folder', '--limit', '--to', '--cc', '--bcc', '--subject', '--body', + '--file', '--attach', '--via', '--provider', '--name', '--user', '--imap-host', '--imap-port', + '--smtp-host', '--smtp-port', + ], + }); + + if (parsed.flags.has('-h') || parsed.flags.has('--help') || parsed.positional.length === 0) { + process.stdout.write(USAGE); + return 0; + } + + // parseArgs keeps the last value of a repeated flag; --to and --attach are + // the two that legitimately repeat, so collect them from argv directly. + const repeated = new Map(); + for (const flag of ['--to', '--attach', '--cc', '--bcc']) { + const found: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]!; + if (item === flag && argv[index + 1] !== undefined) found.push(argv[index + 1]!); + else if (item.startsWith(`${flag}=`)) found.push(item.slice(flag.length + 1)); + } + if (found.length > 1) repeated.set(flag, found.slice(0, -1)); + } + + const [command, ...rest] = parsed.positional; + const isJson = parsed.flags.has('--json'); + const selector = parsed.values.get('-a') ?? parsed.values.get('--account'); + const folder = parsed.values.get('--folder') ?? 'INBOX'; + const limit = integer(parsed.values, '--limit', 25, { min: 1, max: 5000 }); + const config = loadConfig(); + + switch (command) { + case 'accounts': + case 'account': + return accountsVerb(config, rest, parsed); + + case 'folders': { + const account = selectAccount(config, selector); + const folders = await withMailbox(account, (box) => box.folders()); + if (isJson) json(folders); + else out(formatFolders(folders)); + return 0; + } + + case 'ls': + case 'list': + case 'inbox': { + const accounts = selectAccounts(config, selector); + const results: { account: string; messages: Awaited> }[] = []; + for (const account of accounts) { + const messages = await withMailbox(account, (box) => + box.list(folder, { limit, unreadOnly: parsed.flags.has('--unread') }), + ); + results.push({ account: account.name, messages }); + } + if (isJson) { + json(accounts.length === 1 ? results[0]!.messages : results); + return 0; + } + const width = process.stdout.columns ?? 100; + out( + results + .map(({ account, messages }) => + formatList(messages, { width, ...(accounts.length > 1 ? { account } : {}) }), + ) + .join('\n\n'), + ); + return 0; + } + + case 'search': { + const text = rest.join(' ').trim(); + if (!text) throw new UsageError('search needs a query'); + const accounts = selectAccounts(config, selector); + const results: { account: string; messages: Awaited> }[] = []; + for (const account of accounts) { + const query = parsed.flags.has('--gmail') ? { gmailraw: text } : parseQuery(text); + if (parsed.flags.has('--gmail') && account.provider !== 'gmail') { + process.stderr.write(`mail: --gmail ignored for ${account.name}, which is not a Gmail account\n`); + } + const messages = await withMailbox(account, (box) => + box.search(folder, account.provider === 'gmail' || !parsed.flags.has('--gmail') ? query : parseQuery(text), limit), + ); + results.push({ account: account.name, messages }); + } + if (isJson) { + json(accounts.length === 1 ? results[0]!.messages : results); + return 0; + } + const width = process.stdout.columns ?? 100; + out( + results + .map(({ account, messages }) => + formatList(messages, { width, ...(accounts.length > 1 ? { account } : {}) }), + ) + .join('\n\n'), + ); + return 0; + } + + case 'read': + case 'show': + case 'cat': { + const uid = needUids(rest, 'read')[0]!; + const account = selectAccount(config, selector); + await withMailbox(account, async (box) => { + if (parsed.flags.has('--raw')) { + process.stdout.write(await box.raw(folder, uid)); + } else { + const message = await box.read(folder, uid); + if (isJson) json(message); + else out(formatMessage(message)); + } + // PEEK on the way in, so nothing was marked; a client would mark it now. + if (!parsed.flags.has('--keep-unread')) await box.flag(folder, [uid], ['\\Seen'], []); + }); + return 0; + } + + case 'send': + case 'draft': + case 'reply': { + const account = selectAccount(config, selector); + const via = parsed.values.get('--via') as Transport | undefined; + if (via !== undefined && via !== 'smtp' && via !== 'resend') { + throw new UsageError(`--via must be smtp or resend, got "${via}"`); + } + const asDraft = command === 'draft' || parsed.flags.has('--draft'); + const attachments = addressesFrom(parsed.values, repeated, '--attach').map((path) => ({ + filename: basename(path), + path, + })); + + let outgoing: Outgoing; + if (command === 'reply') { + const uid = needUids(rest, 'reply')[0]!; + const body = await bodyFrom(parsed.values); + const original = await withMailbox(account, (box) => box.read(folder, uid)); + outgoing = buildReply(original, account, { + all: parsed.flags.has('--all'), + body, + quoteOriginal: !parsed.flags.has('--no-quote'), + }); + outgoing.cc.push(...addressesFrom(parsed.values, repeated, '--cc')); + outgoing.bcc.push(...addressesFrom(parsed.values, repeated, '--bcc')); + outgoing.attachments = attachments; + } else { + const to = addressesFrom(parsed.values, repeated, '--to'); + if (to.length === 0) throw new UsageError(`${command} needs --to`); + const subject = parsed.values.get('--subject'); + if (!subject) throw new UsageError(`${command} needs --subject`); + outgoing = { + from: fromHeader(account), + to, + cc: addressesFrom(parsed.values, repeated, '--cc'), + bcc: addressesFrom(parsed.values, repeated, '--bcc'), + subject, + text: await bodyFrom(parsed.values), + attachments, + }; + } + + if (asDraft) { + const where = await fileCopy(account, outgoing, 'Drafts'); + if (!where) fail(`no Drafts folder found for ${account.name}, and a draft needs IMAP to be filed`, 1); + if (isJson) json({ draft: true, folder: where, to: outgoing.to, subject: outgoing.subject }); + else out(`draft saved to ${where} for ${outgoing.to.join(', ')}: ${outgoing.subject}`); + return 0; + } + + const resendKey = resolveCredentials(process.env).RESEND_API_KEY; + const choice = chooseTransport(account, resendKey, via); + process.stderr.write(`mail: sending via ${choice.transport} (${choice.reason})\n`); + const result = await sendMail(account, outgoing, { + ...(resendKey ? { resendKey } : {}), + ...(via ? { via } : {}), + }); + if (result.fellBackFrom) { + process.stderr.write( + `mail: ${result.fellBackFrom.transport} failed (${result.fellBackFrom.error}); sent via ${result.transport}\n`, + ); + } + // SMTP servers file their own Sent copy; Resend never does. + let filed: string | null = null; + if (result.transport === 'resend') { + filed = await fileCopy(account, outgoing, 'Sent').catch((error: Error) => { + process.stderr.write(`mail: sent, but could not file a Sent copy: ${error.message}\n`); + return null; + }); + } + if (command === 'reply' && account.password) { + const uid = needUids(rest, 'reply')[0]!; + await withMailbox(account, (box) => box.flag(folder, [uid], ['\\Answered'], [])).catch(() => undefined); + } + if (isJson) json({ transport: result.transport, id: result.id, to: outgoing.to, subject: outgoing.subject, filed }); + else { + out(`sent via ${result.transport} to ${outgoing.to.join(', ')}: ${outgoing.subject}${result.id ? ` (${result.id})` : ''}`); + if (filed) out(`copy filed in ${filed}`); + } + return 0; + } + + case 'mark': { + const uids = needUids(rest, 'mark'); + const add: string[] = []; + const remove: string[] = []; + if (parsed.flags.has('--read')) add.push('\\Seen'); + if (parsed.flags.has('--unread')) remove.push('\\Seen'); + if (parsed.flags.has('--flag')) add.push('\\Flagged'); + if (parsed.flags.has('--unflag')) remove.push('\\Flagged'); + if (add.length === 0 && remove.length === 0) { + throw new UsageError('mark needs one of --read, --unread, --flag, --unflag'); + } + const account = selectAccount(config, selector); + await withMailbox(account, (box) => box.flag(folder, uids, add, remove)); + out(`marked ${uids.length} message(s)${add.length ? ` +${add.join(' ')}` : ''}${remove.length ? ` -${remove.join(' ')}` : ''}`); + return 0; + } + + case 'mv': + case 'move': { + const { uids, rest: named } = splitUids(rest); + const destination = named[0]; + if (uids.length === 0 || !destination) throw new UsageError('mv needs '); + const account = selectAccount(config, selector); + await withMailbox(account, (box) => box.move(folder, uids, destination)); + out(`moved ${uids.length} message(s) from ${folder} to ${destination}`); + return 0; + } + + case 'archive': { + const uids = needUids(rest, 'archive'); + const account = selectAccount(config, selector); + await withMailbox(account, async (box) => { + const folders: Folder[] = await box.folders(); + const archive = folderFor(folders, 'Archive'); + if (!archive) throw new MailError(`no Archive folder for ${account.name} — use \`mail mv … \``); + // Gmail's "archive" is removing the Inbox label; a move to All Mail is + // how that looks over IMAP. + await box.move(folder, uids, archive); + out(`archived ${uids.length} message(s) to ${archive}`); + }); + return 0; + } + + case 'rm': + case 'delete': + case 'trash': { + const uids = needUids(rest, 'rm'); + const account = selectAccount(config, selector); + const purge = parsed.flags.has('--purge'); + if (purge && !parsed.flags.has('--yes')) { + if (!(await confirm(`expunge ${uids.length} message(s) from ${folder} for good?`))) { + fail(process.stdin.isTTY ? 'cancelled' : 'not a terminal — pass --yes to purge non-interactively', 1); + } + } + await withMailbox(account, async (box) => { + if (purge) { + await box.expunge(folder, uids); + out(`expunged ${uids.length} message(s) from ${folder}`); + return; + } + const trash = folderFor(await box.folders(), 'Trash'); + if (!trash) throw new MailError(`no Trash folder for ${account.name} — use --purge to expunge instead`); + if (trash === folder) { + await box.expunge(folder, uids); + out(`expunged ${uids.length} message(s) already in ${folder}`); + return; + } + await box.move(folder, uids, trash); + out(`moved ${uids.length} message(s) to ${trash}`); + }); + return 0; + } + + default: + throw new UsageError(`unknown command: ${command}`); + } +} + +if (isMain(import.meta.url)) { + main(process.argv.slice(2)) + .then((code) => process.exit(code)) + .catch((error: unknown) => { + if (error instanceof UsageError) { + process.stderr.write(`${USAGE}\n`); + fail(error.message); + } + if (error instanceof MailError) fail(error.message, 1); + fail(error instanceof Error ? error.message : String(error), 1); + }); +} diff --git a/package.json b/package.json index 40df4d6..c3159f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.23.0", + "version": "0.24.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", @@ -21,12 +21,19 @@ "unlink:bin": "node scripts/install-links.mjs --remove" }, "devDependencies": { + "@types/mailparser": "^3.4.6", "@types/node": "^22.10.2", + "@types/nodemailer": "^8.0.1", "tsx": "^4.19.2", "typescript": "^5.7.2", "vitest": "^4.1.10" }, "optionalDependencies": { "sharp": "^0.35.3" + }, + "dependencies": { + "imapflow": "^1.7.8", + "mailparser": "^3.9.20", + "nodemailer": "^10.0.0" } } diff --git a/plugins/mail/.claude-plugin/plugin.json b/plugins/mail/.claude-plugin/plugin.json new file mode 100644 index 0000000..8c94b73 --- /dev/null +++ b/plugins/mail/.claude-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "mail", + "description": "The inbox from the terminal, for more than one account: list and search over IMAP, read a message, reply in its thread, send a new one over SMTP with Resend as the fallback, and mark, file or delete without a browser tab.", + "version": "0.1.0", + "author": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "homepage": "https://github.com/profullstack/cli-tools#mail", + "license": "MIT", + "keywords": [ + "mail", + "email", + "imap", + "smtp", + "resend", + "inbox" + ] +} diff --git a/plugins/mail/README.md b/plugins/mail/README.md new file mode 100644 index 0000000..1b12d86 --- /dev/null +++ b/plugins/mail/README.md @@ -0,0 +1,41 @@ +# mail + +The inbox from the terminal, for more than one account. + +`/mail:inbox` lists, searches and reads over IMAP, and marks, files or deletes. +`/mail:send` replies in the original's thread or sends a new message, over the +account's SMTP with Resend as the fallback, or files it as a draft. + +## Install + +```bash +moshcode plugin marketplace add profullstack/cli-tools +moshcode plugin install mail@cli-tools +``` + +Or install the command directly, without the plugin: + +```bash +curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | sh +mail accounts pull # accounts from the cli-tools-mail team vault +cli-tools config pull # the Resend key, for the fallback +``` + +## The thing worth knowing + +**Two accounts, two rule sets.** A business address on its own domain can +send through Resend when SMTP is down, because the team has verified the +domain there. A Gmail address cannot: gmail.com is not anyone's to verify, so +that account reads and sends only with an App Password, and `mail` says so +rather than trying Resend and reporting a 403. + +**A reply threads.** `mail reply` carries `In-Reply-To` and `References`, +answers the Reply-To when one was set, and quotes the original underneath — +so the other side's client files it under the same conversation, which is the +difference between a reply and a new message that happens to share a subject. + +**Nothing here names a person.** Accounts live in +`~/.config/cli-tools/mail.json` (0600) or in the vault as +`MAIL__EMAIL` / `_PROVIDER` / `_PASSWORD`; the environment's +`MAIL__PASSWORD` wins over the stored one, and `mail accounts` shows +which source is in effect without ever printing a password. diff --git a/plugins/mail/commands/inbox.md b/plugins/mail/commands/inbox.md new file mode 100644 index 0000000..66fc59c --- /dev/null +++ b/plugins/mail/commands/inbox.md @@ -0,0 +1,51 @@ +--- +description: List, search and read mail in any configured account, and mark, file or delete it. +allowed-tools: Bash(mail:*), Read +--- + +## Task + +Work the inbox from the terminal. The uid in the first column is what every +other verb takes. + +```bash +mail ls # newest 25 in the default account +mail ls -a all --unread # unread across every account +mail ls -a personal --limit 50 +mail search from:substack is:unread # header keys narrow, bare words match text +mail search -a all "pottery" since:2026-09-01 +mail read 4213 # headers, then the text; marks it read +mail read 4213 --keep-unread --json +mail mark 4213 --flag +mail archive 4213 +mail rm 4213 # to Trash; --purge --yes to expunge +``` + +`$ARGUMENTS` is passed through: `/mail:inbox -a all --unread`. + +## Which account + +`-a` takes the account's name, its address, or `all` for `ls` and `search`. +With no `-a`, the default account applies (`mail accounts default `), +or `MAIL_ACCOUNT` from the environment. `mail accounts` shows what is +configured and where each password comes from, never the password itself. + +## Setting up + +```bash +mail accounts pull # import from the cli-tools-mail team vault +mail accounts add work you@example.com # or by hand; prompts for the password +mail accounts add home you@gmail.com # gmail is inferred; needs an App Password +``` + +Gmail refuses the account password over IMAP — it wants an App Password from +https://myaccount.google.com/apppasswords, which needs 2-step verification on. +Forward Email wants the alias password generated in its dashboard. Neither is +the password you log in to the website with. + +## Reading marks it read + +IMAP fetches here use PEEK, so nothing is marked by the fetch itself; `mail +read` then sets `\Seen` the way a client would, so that `--unread` stops +listing it. `--keep-unread` skips that. `--raw` prints the message as +received, for headers and DKIM spelunking. diff --git a/plugins/mail/commands/send.md b/plugins/mail/commands/send.md new file mode 100644 index 0000000..c8dec8b --- /dev/null +++ b/plugins/mail/commands/send.md @@ -0,0 +1,44 @@ +--- +description: Reply to a message in its thread, or send a new one, over SMTP with Resend as the fallback; or file it as a draft. +allowed-tools: Bash(mail:*), Read +--- + +## Task + +Send mail from a configured account, or answer a message so that it lands in +the other side's thread. + +```bash +mail reply 4213 --body "Thanks — sending the invoice today." +mail reply 4213 --all --file answer.md # everyone on the original +cat answer.md | mail reply 4213 --draft # into Drafts, not sent +mail send --to a@example.org --subject "Hello" --body "Short and sweet." +mail send --to a@x,b@x --cc c@x --subject S --file body.txt --attach deck.pdf +mail send -a personal --to a@x --subject S --body B --via smtp +``` + +`$ARGUMENTS` is passed through: `/mail:send reply 4213 --body "…"`. + +## Before sending anything on someone's behalf + +Show the text and the recipients and get a yes first, or use `--draft` and let +them send it from Drafts. A reply quotes the original underneath by default +(`--no-quote` to omit), carries `In-Reply-To` and `References` so it threads, +and answers the Reply-To address when the original set one. `--all` copies +the original's To and Cc, minus the account itself. + +## How it goes out + +SMTP with the account's password, and if the *pipe* fails — a refused login, +a dead host — Resend carries it, when a `RESEND_API_KEY` is stored +(`cli-tools config pull`) and the domain is verified there. A refused +*message* (bad recipient, unverified domain) is never retried on the other +path: it would fail the same way, and a late acceptance would mean two copies. + +A webmail address (gmail.com and friends) cannot be verified at Resend, so a +Gmail account sends over SMTP only; without an App Password it cannot send at +all, and the error says so. + +`--via smtp` or `--via resend` pins one transport and refuses rather than +swapping. A message sent through Resend is appended to the account's Sent +folder over IMAP, because Resend never files one; SMTP servers file their own. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c624395..490899b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,26 @@ settings: importers: .: + dependencies: + imapflow: + specifier: ^1.7.8 + version: 1.7.8 + mailparser: + specifier: ^3.9.20 + version: 3.9.20 + nodemailer: + specifier: ^10.0.0 + version: 10.0.0 devDependencies: + '@types/mailparser': + specifier: ^3.4.6 + version: 3.4.6 '@types/node': specifier: ^22.10.2 version: 22.20.1 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 tsx: specifier: ^4.19.2 version: 4.23.12 @@ -354,6 +370,9 @@ packages: '@oxc-project/types@0.144.0': resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@rolldown/binding-android-arm64@1.2.4': resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -447,6 +466,11 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@selderee/plugin-htmlparser2@0.12.0': + resolution: {integrity: sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==} + peerDependencies: + selderee: ~0.12.0 + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -459,9 +483,15 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/mailparser@3.4.6': + resolution: {integrity: sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==} + '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -491,10 +521,17 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@zone-eu/mailsplit@5.4.16': + resolution: {integrity: sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -502,10 +539,39 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + deepmerge-ts@8.0.2: + resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==} + engines: {node: '>=16.9.0'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + encoding-japanese@2.3.0: + resolution: {integrity: sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==} + engines: {node: '>=18.0.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} @@ -535,6 +601,44 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + html-to-text@10.0.1: + resolution: {integrity: sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==} + engines: {node: '>=20.19.0'} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + imapflow@1.7.8: + resolution: {integrity: sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==} + + ip-address@10.7.0: + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + engines: {node: '>= 12'} + + leac@0.7.0: + resolution: {integrity: sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==} + + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.4.3: + resolution: {integrity: sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==} + + libqp@2.1.1: + resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -609,21 +713,45 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mailparser@3.9.20: + resolution: {integrity: sha512-PZ9RD6B7SkmyQ9rj8JvYNS18rL6pWoNkSw9KGVuJQrrdouFxFIB05ugnR3ubInlnjLXV3KHXhGyz8MlTz1VGzw==} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nodemailer@10.0.0: + resolution: {integrity: sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==} + engines: {node: '>=20.0.0'} + + nodemailer@9.1.1: + resolution: {integrity: sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==} + engines: {node: '>=6.0.0'} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + parseley@0.13.1: + resolution: {integrity: sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + peberminta@0.10.0: + resolution: {integrity: sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -631,15 +759,52 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + rolldown@1.2.4: resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + selderee@0.12.0: + resolution: {integrity: sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -657,16 +822,35 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -682,6 +866,10 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -695,6 +883,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -983,6 +1174,8 @@ snapshots: '@oxc-project/types@0.144.0': {} + '@pinojs/redact@0.4.0': {} + '@rolldown/binding-android-arm64@1.2.4': optional: true @@ -1027,6 +1220,12 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@selderee/plugin-htmlparser2@0.12.0(selderee@0.12.0)': + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + selderee: 0.12.0 + '@standard-schema/spec@1.1.0': {} '@types/chai@5.2.3': @@ -1038,10 +1237,19 @@ snapshots: '@types/estree@1.0.9': {} + '@types/mailparser@3.4.6': + dependencies: + '@types/node': 22.20.1 + iconv-lite: 0.6.3 + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 22.20.1 + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -1083,14 +1291,48 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + '@zone-eu/mailsplit@5.4.16': + dependencies: + libbase64: 1.3.0 + libmime: 5.4.3 + libqp: 2.1.1 + assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} + chai@6.2.2: {} convert-source-map@2.0.0: {} + deepmerge-ts@8.0.2: {} + detect-libc@2.1.2: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + encoding-japanese@2.3.0: {} + + entities@4.5.0: {} + + entities@7.0.1: {} + es-module-lexer@2.3.1: {} esbuild@0.28.2: @@ -1135,6 +1377,57 @@ snapshots: fsevents@2.3.3: optional: true + he@1.2.0: {} + + html-to-text@10.0.1: + dependencies: + '@selderee/plugin-htmlparser2': 0.12.0(selderee@0.12.0) + deepmerge-ts: 8.0.2 + dom-serializer: 2.0.0 + htmlparser2: 10.1.0 + selderee: 0.12.0 + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + imapflow@1.7.8: + dependencies: + '@zone-eu/mailsplit': 5.4.16 + encoding-japanese: 2.3.0 + iconv-lite: 0.7.3 + libbase64: 1.3.0 + libmime: 5.4.3 + libqp: 2.1.1 + pino: 10.3.1 + socks: 2.8.9 + + ip-address@10.7.0: {} + + leac@0.7.0: {} + + libbase64@1.3.0: {} + + libmime@5.4.3: + dependencies: + encoding-japanese: 2.3.0 + iconv-lite: 0.7.3 + libbase64: 1.3.0 + libqp: 2.1.1 + + libqp@2.1.1: {} + lightningcss-android-arm64@1.33.0: optional: true @@ -1184,26 +1477,86 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mailparser@3.9.20: + dependencies: + '@zone-eu/mailsplit': 5.4.16 + encoding-japanese: 2.3.0 + he: 1.2.0 + html-to-text: 10.0.1 + iconv-lite: 0.7.3 + libmime: 5.4.3 + linkify-it: 5.0.2 + nodemailer: 9.1.1 + punycode.js: 2.3.1 + tlds: 1.261.0 + nanoid@3.3.18: {} + nodemailer@10.0.0: {} + + nodemailer@9.1.1: {} + obug@2.1.4: {} + on-exit-leak-free@2.1.2: {} + + parseley@0.13.1: + dependencies: + leac: 0.7.0 + peberminta: 0.10.0 + pathe@2.0.3: {} + peberminta@0.10.0: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + postcss@8.5.26: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 + process-warning@5.1.0: {} + + punycode.js@2.3.1: {} + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + rolldown@1.2.4: dependencies: '@oxc-project/types': 0.144.0 @@ -1224,6 +1577,14 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.4 '@rolldown/binding-win32-x64-msvc': 1.2.4 + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + selderee@0.12.0: + dependencies: + parseley: 0.13.1 + semver@7.8.5: optional: true @@ -1263,12 +1624,29 @@ snapshots: siginfo@2.0.0: {} + smart-buffer@4.2.0: {} + + socks@2.8.9: + dependencies: + ip-address: 10.7.0 + smart-buffer: 4.2.0 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} + split2@4.2.0: {} + stackback@0.0.2: {} std-env@4.2.0: {} + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tinybench@2.9.0: {} tinyexec@1.3.0: {} @@ -1280,6 +1658,8 @@ snapshots: tinyrainbow@3.1.1: {} + tlds@1.261.0: {} + tslib@2.8.1: optional: true @@ -1291,6 +1671,8 @@ snapshots: typescript@5.9.3: {} + uc.micro@2.1.0: {} + undici-types@6.21.0: {} vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(tsx@4.23.12): diff --git a/src/credentials.ts b/src/credentials.ts index 0fe2276..d89c79b 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -34,6 +34,8 @@ export const KNOWN_KEYS: Record = { elevenlabs: 'ELEVENLABS_API_KEY', porkbun: 'PORKBUN_API_KEY', porkbun_secret: 'PORKBUN_SECRET_API_KEY', + // Read by `mail`, as the fallback sender for a domain verified at Resend. + resend: 'RESEND_API_KEY', // Read by `shorten`. Usually not needed: on a machine where the pit works, // `moshcode login` has already written the same token to // ~/.moshcode/credentials.json and that is what gets picked up. This is for a diff --git a/src/mail.ts b/src/mail.ts new file mode 100644 index 0000000..f4d6438 --- /dev/null +++ b/src/mail.ts @@ -0,0 +1,1233 @@ +/** + * Mail from the command line: read, search, reply, send, file, delete. + * + * Two mailboxes live here, a business one and a personal one, and the point + * of the command is to reach either without a browser tab — the inbox is read + * over IMAP, and a message goes out over the account's SMTP or, for a domain + * the team has verified there, through Resend. + * + * Three facts shape the code below. + * + * **Accounts are configuration, not code.** This repository is public. The + * addresses, the providers and the passwords live in `~/.config/cli-tools/ + * mail.json` (0600) and can be imported from a logicsrc vault; nothing here + * names a person. A password exported as `MAIL__PASSWORD` wins over the + * file, the same rule as {@link ../credentials.ts}. + * + * **Resend can only send from a verified domain.** It is the fallback when an + * account has no SMTP password, or SMTP refuses — but a public webmail address + * (gmail.com and friends) can never be verified there, so for those accounts + * SMTP with an app password is the only way out. `chooseTransport` says which + * applies and why, rather than trying Resend and reporting its 403. + * + * **IMAP is the one place the inbox exists.** A message sent through Resend + * never reaches the Sent folder by itself, so `sendMail` appends a copy over + * IMAP afterwards, and everything that reads or changes state goes through the + * {@link Mailbox} interface so tests never open a socket. + */ + +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { ImapFlow, type FetchMessageObject, type ListResponse, type SearchObject } from 'imapflow'; +import { simpleParser, type AddressObject, type ParsedMail } from 'mailparser'; +import nodemailer from 'nodemailer'; +import MailComposer from 'nodemailer/lib/mail-composer/index.js'; + +export class MailError extends Error { + constructor(message: string) { + super(message); + this.name = 'MailError'; + } +} + +// --------------------------------------------------------------------------- +// Providers +// --------------------------------------------------------------------------- + +export type ProviderName = 'forwardemail' | 'gmail' | 'custom'; + +export interface Provider { + imapHost: string; + imapPort: number; + smtpHost: string; + smtpPort: number; + /** Implicit TLS on connect (465), as opposed to STARTTLS (587). */ + smtpSecure: boolean; + /** Where the password comes from, for the setup message. */ + passwordHint: string; +} + +/** + * Hosts for the providers these mailboxes actually use. + * + * Forward Email is where the business domain's mail lives; Gmail is the + * personal one. `custom` exists so a third account is a matter of naming its + * hosts rather than editing this file. + */ +export const PROVIDERS: Record, Provider> = { + forwardemail: { + imapHost: 'imap.forwardemail.net', + imapPort: 993, + smtpHost: 'smtp.forwardemail.net', + smtpPort: 465, + smtpSecure: true, + passwordHint: + 'the alias password generated in the Forward Email dashboard (Aliases → the address → ' + + 'Generate Password); it is shown once', + }, + gmail: { + imapHost: 'imap.gmail.com', + imapPort: 993, + smtpHost: 'smtp.gmail.com', + smtpPort: 465, + smtpSecure: true, + passwordHint: + 'an App Password from https://myaccount.google.com/apppasswords (needs 2-step ' + + 'verification on the account); the normal account password is refused', + }, +}; + +/** Domains no one can verify at a sending service: the mail belongs to the webmail host. */ +const WEBMAIL_DOMAINS = new Set([ + 'gmail.com', + 'googlemail.com', + 'outlook.com', + 'hotmail.com', + 'live.com', + 'yahoo.com', + 'icloud.com', + 'me.com', + 'proton.me', + 'protonmail.com', + 'aol.com', +]); + +export function domainOf(email: string): string { + const at = email.lastIndexOf('@'); + return at === -1 ? '' : email.slice(at + 1).toLowerCase(); +} + +/** The provider an address implies, when it implies one. */ +export function guessProvider(email: string): ProviderName | null { + const domain = domainOf(email); + if (domain === 'gmail.com' || domain === 'googlemail.com') return 'gmail'; + return null; +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +export interface AccountConfig { + email: string; + provider: ProviderName; + /** Display name for the From header. */ + name?: string; + /** Login, when it is not the address itself. */ + user?: string; + password?: string; + imapHost?: string; + imapPort?: number; + smtpHost?: string; + smtpPort?: number; + smtpSecure?: boolean; +} + +export interface MailConfig { + default?: string; + accounts: Record; +} + +export type PasswordSource = 'env' | 'file' | 'unset'; + +/** An account with every host filled in and the password resolved. */ +export interface Account { + name: string; + email: string; + displayName: string | null; + user: string; + password: string | null; + passwordSource: PasswordSource; + provider: ProviderName; + imap: { host: string; port: number }; + smtp: { host: string; port: number; secure: boolean }; +} + +function xdgConfigHome(env: NodeJS.ProcessEnv): string { + return env.XDG_CONFIG_HOME || join(homedir(), '.config'); +} + +export function mailConfigPath(env: NodeJS.ProcessEnv = process.env): string { + return env.CLI_TOOLS_MAIL_CONFIG || join(xdgConfigHome(env), 'cli-tools', 'mail.json'); +} + +/** The environment variable that overrides a stored password. */ +export function passwordVariable(name: string): string { + return `MAIL_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_PASSWORD`; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): MailConfig { + const path = mailConfigPath(env); + let text: string; + try { + text = readFileSync(path, 'utf8'); + } catch { + return { accounts: {} }; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new MailError(`${path}: not valid JSON — ${(error as Error).message}`); + } + return normalizeConfig(parsed); +} + +/** Accept only the shape we write, so a hand edit cannot smuggle in nonsense. */ +export function normalizeConfig(parsed: unknown): MailConfig { + const config: MailConfig = { accounts: {} }; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return config; + const record = parsed as Record; + if (typeof record.default === 'string' && record.default.trim()) config.default = record.default.trim(); + + const accounts = record.accounts; + if (!accounts || typeof accounts !== 'object' || Array.isArray(accounts)) return config; + for (const [name, raw] of Object.entries(accounts as Record)) { + if (!raw || typeof raw !== 'object') continue; + const entry = raw as Record; + if (typeof entry.email !== 'string' || !entry.email.includes('@')) continue; + const provider = entry.provider; + const account: AccountConfig = { + email: entry.email.trim().toLowerCase(), + provider: + provider === 'forwardemail' || provider === 'gmail' || provider === 'custom' + ? provider + : (guessProvider(entry.email) ?? 'custom'), + }; + for (const key of ['name', 'user', 'password', 'imapHost', 'smtpHost'] as const) { + const value = entry[key]; + if (typeof value === 'string' && value.trim()) account[key] = value.trim(); + } + for (const key of ['imapPort', 'smtpPort'] as const) { + const value = entry[key]; + if (typeof value === 'number' && Number.isInteger(value) && value > 0) account[key] = value; + } + if (typeof entry.smtpSecure === 'boolean') account.smtpSecure = entry.smtpSecure; + config.accounts[name] = account; + } + return config; +} + +export function saveConfig(config: MailConfig, env: NodeJS.ProcessEnv = process.env): string { + const path = mailConfigPath(env); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + // The mode only applies on create; an existing file keeps a hand-set one. + chmodSync(path, 0o600); + return path; +} + +/** Fill hosts from the provider and resolve the password, environment first. */ +export function resolveAccount( + name: string, + config: AccountConfig, + env: NodeJS.ProcessEnv = process.env, +): Account { + const preset = config.provider === 'custom' ? null : PROVIDERS[config.provider]; + const imapHost = config.imapHost ?? preset?.imapHost; + const smtpHost = config.smtpHost ?? preset?.smtpHost; + if (!imapHost || !smtpHost) { + throw new MailError( + `account "${name}" is provider "custom" and needs imapHost and smtpHost — ` + + `set them with \`mail accounts add ${name} ${config.email} --imap-host … --smtp-host …\``, + ); + } + + const fromEnv = env[passwordVariable(name)]; + const password = fromEnv || config.password || null; + const passwordSource: PasswordSource = fromEnv ? 'env' : config.password ? 'file' : 'unset'; + + const smtpPort = config.smtpPort ?? preset?.smtpPort ?? 465; + return { + name, + email: config.email, + displayName: config.name ?? null, + user: config.user ?? config.email, + password, + passwordSource, + provider: config.provider, + imap: { host: imapHost, port: config.imapPort ?? preset?.imapPort ?? 993 }, + smtp: { + host: smtpHost, + port: smtpPort, + // 465 is implicit TLS everywhere; anything else is STARTTLS unless told. + secure: config.smtpSecure ?? (preset ? preset.smtpSecure : smtpPort === 465), + }, + }; +} + +/** + * Which account a selector means. + * + * A name, an address, `all`, or nothing — nothing is the configured default, + * or the only account when there is exactly one. With two accounts and no + * default, "nothing" is a question, not a guess. + */ +export function selectAccounts( + config: MailConfig, + selector: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): Account[] { + const names = Object.keys(config.accounts); + if (names.length === 0) { + throw new MailError( + 'no mail accounts configured. Add one with `mail accounts add work you@example.com`, ' + + 'or `mail accounts pull` to import them from the team vault.', + ); + } + + const wanted = (selector ?? env.MAIL_ACCOUNT ?? '').trim(); + if (wanted.toLowerCase() === 'all') { + return names.map((name) => resolveAccount(name, config.accounts[name]!, env)); + } + + let name: string | undefined; + if (wanted) { + name = + names.find((candidate) => candidate === wanted) ?? + names.find((candidate) => config.accounts[candidate]!.email === wanted.toLowerCase()); + if (!name) { + throw new MailError(`no account "${wanted}". Configured: ${names.join(', ')}`); + } + } else if (config.default && config.accounts[config.default]) { + name = config.default; + } else if (names.length === 1) { + name = names[0]!; + } else { + throw new MailError( + `which account? Pass --account (${names.join(', ')}), export MAIL_ACCOUNT, ` + + 'or set one as the default with `mail accounts default `.', + ); + } + return [resolveAccount(name, config.accounts[name]!, env)]; +} + +export function selectAccount( + config: MailConfig, + selector: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): Account { + const accounts = selectAccounts(config, selector, env); + if (accounts.length !== 1) { + throw new MailError('this command works on one account at a time — name it with --account'); + } + return accounts[0]!; +} + +// --------------------------------------------------------------------------- +// Vault import +// --------------------------------------------------------------------------- + +/** The vault that holds the mailboxes, separate from the shared API keys. */ +export const MAIL_VAULT_PROJECT = 'cli-tools-mail'; + +/** + * Accounts as a vault spells them: one prefix per account. + * + * MAIL_WORK_EMAIL=you@example.com + * MAIL_WORK_PROVIDER=forwardemail + * MAIL_WORK_PASSWORD=… + * MAIL_WORK_NAME="Your Name" optional + * MAIL_WORK_USER=… optional, when the login is not the address + * MAIL_WORK_IMAP_HOST / _IMAP_PORT / _SMTP_HOST / _SMTP_PORT / _SMTP_SECURE + * MAIL_DEFAULT=work optional + * + * A dotenv vault cannot hold structure, so the account name is the middle of + * the key and is lower-cased on the way in. `MAIL_DEFAULT` and the password + * variables are the same names the environment override reads, so what is in + * the vault and what is exported never disagree about spelling. + */ +export function accountsFromVault(vault: Record): MailConfig { + const config: MailConfig = { accounts: {} }; + const pattern = /^MAIL_([A-Z0-9_]+?)_(EMAIL|PROVIDER|PASSWORD|NAME|USER|IMAP_HOST|IMAP_PORT|SMTP_HOST|SMTP_PORT|SMTP_SECURE)$/; + const partial: Record> = {}; + + for (const [key, value] of Object.entries(vault)) { + if (key === 'MAIL_DEFAULT') { + config.default = value.trim().toLowerCase(); + continue; + } + const match = pattern.exec(key); + if (!match) continue; + const name = match[1]!.toLowerCase(); + (partial[name] ??= {})[match[2]!] = value.trim(); + } + + for (const [name, fields] of Object.entries(partial)) { + const email = fields.EMAIL; + if (!email || !email.includes('@')) continue; + const provider = fields.PROVIDER?.toLowerCase(); + const account: AccountConfig = { + email: email.toLowerCase(), + provider: + provider === 'forwardemail' || provider === 'gmail' || provider === 'custom' + ? provider + : (guessProvider(email) ?? (fields.IMAP_HOST ? 'custom' : 'forwardemail')), + }; + if (fields.PASSWORD) account.password = fields.PASSWORD; + if (fields.NAME) account.name = fields.NAME; + if (fields.USER) account.user = fields.USER; + if (fields.IMAP_HOST) account.imapHost = fields.IMAP_HOST; + if (fields.SMTP_HOST) account.smtpHost = fields.SMTP_HOST; + if (fields.IMAP_PORT && /^\d+$/.test(fields.IMAP_PORT)) account.imapPort = Number(fields.IMAP_PORT); + if (fields.SMTP_PORT && /^\d+$/.test(fields.SMTP_PORT)) account.smtpPort = Number(fields.SMTP_PORT); + if (fields.SMTP_SECURE) account.smtpSecure = /^(true|1|yes)$/i.test(fields.SMTP_SECURE); + config.accounts[name] = account; + } + + if (config.default && !config.accounts[config.default]) delete config.default; + return config; +} + +/** + * Bring vault accounts into the local config. + * + * The vault wins for every field it names, and a local account the vault does + * not mention is left alone — an account added by hand on this machine is not + * an error in the vault. + */ +export function mergeVaultAccounts( + local: MailConfig, + fromVault: MailConfig, +): { merged: MailConfig; imported: string[]; unchanged: string[] } { + const merged: MailConfig = { ...local, accounts: { ...local.accounts } }; + const imported: string[] = []; + const unchanged: string[] = []; + for (const [name, account] of Object.entries(fromVault.accounts)) { + const existing = local.accounts[name]; + if (existing && JSON.stringify(existing) === JSON.stringify(account)) { + unchanged.push(name); + continue; + } + merged.accounts[name] = account; + imported.push(name); + } + if (fromVault.default) merged.default = fromVault.default; + else if (!merged.default && Object.keys(merged.accounts).length === 1) { + merged.default = Object.keys(merged.accounts)[0]!; + } + return { merged, imported: imported.sort(), unchanged: unchanged.sort() }; +} + +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +export interface MessageSummary { + uid: number; + seq: number; + date: string | null; + from: string; + to: string; + subject: string; + seen: boolean; + flagged: boolean; + answered: boolean; + size: number | null; + messageId: string | null; +} + +export interface Attachment { + filename: string; + contentType: string; + size: number; +} + +export interface FullMessage extends MessageSummary { + cc: string; + replyTo: string; + inReplyTo: string | null; + references: string[]; + text: string; + html: string | null; + attachments: Attachment[]; +} + +export interface Folder { + path: string; + specialUse: string | null; + delimiter: string; +} + +export interface ListOptions { + limit: number; + unreadOnly?: boolean; +} + +/** + * Everything the command does to a mailbox, so the network is one + * implementation of it and a test can be another. + */ +export interface Mailbox { + folders(): Promise; + list(folder: string, options: ListOptions): Promise; + search(folder: string, query: SearchObject, limit: number): Promise; + read(folder: string, uid: number): Promise; + raw(folder: string, uid: number): Promise; + flag(folder: string, uids: number[], add: string[], remove: string[]): Promise; + move(folder: string, uids: number[], destination: string): Promise; + expunge(folder: string, uids: number[]): Promise; + append(folder: string, raw: Buffer, flags: string[]): Promise; + close(): Promise; +} + +/** Format an address list as a header would: `Name , addr`. */ +export function formatAddresses(list: { name?: string; address?: string }[] | undefined): string { + if (!list) return ''; + return list + .map((entry) => { + const address = entry.address ?? ''; + const name = (entry.name ?? '').trim(); + if (!name) return address; + return address ? `${name} <${address}>` : name; + }) + .filter(Boolean) + .join(', '); +} + +/** The bare address out of `Name `, lower-cased. */ +export function bareAddress(formatted: string): string { + const match = /<([^>]+)>/.exec(formatted); + return (match ? match[1]! : formatted).trim().toLowerCase(); +} + +/** Split a header-style list on commas that are outside quotes and brackets. */ +export function splitAddresses(value: string): string[] { + const out: string[] = []; + let current = ''; + let depth = 0; + let quoted = false; + for (const char of value) { + if (char === '"') quoted = !quoted; + else if (!quoted && char === '<') depth += 1; + else if (!quoted && char === '>') depth = Math.max(0, depth - 1); + if (char === ',' && !quoted && depth === 0) { + if (current.trim()) out.push(current.trim()); + current = ''; + continue; + } + current += char; + } + if (current.trim()) out.push(current.trim()); + return out; +} + +/** What `fetch` returns, as the command wants to see it. */ +export function summaryFrom(message: FetchMessageObject): MessageSummary { + const envelope = message.envelope ?? {}; + const flags = message.flags ?? new Set(); + const date = envelope.date ?? (message.internalDate ? new Date(message.internalDate) : null); + return { + uid: message.uid, + seq: message.seq, + date: date && !Number.isNaN(date.getTime()) ? date.toISOString() : null, + from: formatAddresses(envelope.from), + to: formatAddresses(envelope.to), + subject: envelope.subject ?? '', + seen: flags.has('\\Seen'), + flagged: flags.has('\\Flagged'), + answered: flags.has('\\Answered'), + size: typeof message.size === 'number' ? message.size : null, + messageId: envelope.messageId ?? null, + }; +} + +function addressText(value: AddressObject | AddressObject[] | undefined): string { + if (!value) return ''; + const list = Array.isArray(value) ? value : [value]; + return list.map((entry) => entry.text).filter(Boolean).join(', '); +} + +/** A parsed message joined to the flags and uid IMAP knows about it. */ +export function fullFrom(summary: MessageSummary, parsed: ParsedMail): FullMessage { + const references = parsed.references + ? Array.isArray(parsed.references) + ? parsed.references + : [parsed.references] + : []; + return { + ...summary, + // The envelope is what the server indexed; the parsed headers are the + // message itself. Prefer the message when it has the field. + from: addressText(parsed.from) || summary.from, + to: addressText(parsed.to) || summary.to, + subject: parsed.subject ?? summary.subject, + date: parsed.date ? parsed.date.toISOString() : summary.date, + messageId: parsed.messageId ?? summary.messageId, + cc: addressText(parsed.cc), + replyTo: addressText(parsed.replyTo), + inReplyTo: parsed.inReplyTo ?? null, + references, + text: parsed.text ?? (parsed.html ? stripHtml(parsed.html) : ''), + html: typeof parsed.html === 'string' ? parsed.html : null, + attachments: (parsed.attachments ?? []).map((attachment) => ({ + filename: attachment.filename ?? '(unnamed)', + contentType: attachment.contentType, + size: attachment.size, + })), + }; +} + +/** Enough of an HTML-only message to read it; not a renderer. */ +export function stripHtml(html: string): string { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '\n') + .replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +/** + * `from:alice subject:"pottery wheel" since:2026-09-01 unread invoice` + * + * Bare words search the text; `key:value` narrows a header. Dates are + * `YYYY-MM-DD`. Quotes group a value with spaces. Gmail's own search language + * is far richer; `--gmail` hands the whole string to it instead. + */ +export function parseQuery(input: string): SearchObject { + const query: SearchObject = {}; + const text: string[] = []; + const tokens = input.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []; + + for (const token of tokens) { + const at = token.indexOf(':'); + const key = at === -1 ? '' : token.slice(0, at).toLowerCase(); + const rawValue = at === -1 ? token : token.slice(at + 1); + const value = rawValue.replace(/^"|"$/g, ''); + + switch (key) { + case 'from': + case 'to': + case 'cc': + case 'subject': + case 'body': + query[key] = value; + break; + case 'since': + case 'before': + case 'on': + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new MailError(`${key}: must be YYYY-MM-DD, got ${JSON.stringify(rawValue)}`); + } + query[key] = new Date(`${value}T00:00:00Z`); + break; + case 'is': + if (value === 'unread') query.seen = false; + else if (value === 'read') query.seen = true; + else if (value === 'flagged' || value === 'starred') query.flagged = true; + else if (value === 'answered') query.answered = true; + else throw new MailError(`is:${value} — expected unread, read, flagged or answered`); + break; + case '': + if (value === 'unread') query.seen = false; + else if (value === 'flagged' || value === 'starred') query.flagged = true; + else if (value) text.push(value); + break; + default: + throw new MailError( + `unknown search key "${key}:" — use from:, to:, cc:, subject:, body:, since:, before:, on:, is:`, + ); + } + } + + if (text.length > 0) query.text = text.join(' '); + return query; +} + +// --------------------------------------------------------------------------- +// IMAP +// --------------------------------------------------------------------------- + +const SUMMARY_FIELDS = { uid: true, flags: true, envelope: true, size: true, internalDate: true } as const; + +/** Open the account's mailbox over IMAP. */ +export async function openMailbox( + account: Account, + options: { logger?: boolean } = {}, +): Promise { + if (!account.password) { + const hint = account.provider === 'custom' ? '' : ` — ${PROVIDERS[account.provider].passwordHint}`; + throw new MailError( + `account "${account.name}" has no password${hint}.\n` + + `Store it with \`mail accounts password ${account.name}\`, export ${passwordVariable(account.name)}, ` + + 'or put it in the vault and run `mail accounts pull`.', + ); + } + + const client = new ImapFlow({ + host: account.imap.host, + port: account.imap.port, + secure: true, + auth: { user: account.user, pass: account.password }, + // imapflow logs every command at info by default; only on request. + ...(options.logger ? {} : { logger: false as const }), + // Fail on a black-holed port rather than hanging the shell. + connectionTimeout: 20_000, + greetingTimeout: 20_000, + socketTimeout: 120_000, + }); + + try { + await client.connect(); + } catch (error) { + throw new MailError( + `IMAP login to ${account.imap.host} as ${account.user} failed: ${(error as Error).message}` + + (account.provider === 'gmail' + ? '\nGmail refuses the account password over IMAP; use an App Password.' + : ''), + ); + } + + async function withFolder(folder: string, work: () => Promise): Promise { + const lock = await client.getMailboxLock(folder); + try { + return await work(); + } finally { + lock.release(); + } + } + + async function fetchSummaries(range: number[] | SearchObject | string, uid: boolean): Promise { + const out: MessageSummary[] = []; + for await (const message of client.fetch(range, SUMMARY_FIELDS, { uid })) { + out.push(summaryFrom(message)); + } + return out; + } + + return { + async folders() { + const list: ListResponse[] = await client.list(); + return list.map((entry) => ({ + path: entry.path, + specialUse: entry.specialUse ?? null, + delimiter: entry.delimiter, + })); + }, + + async list(folder, { limit, unreadOnly }) { + return withFolder(folder, async () => { + const uids = await client.search(unreadOnly ? { seen: false } : { all: true }, { uid: true }); + if (!uids || uids.length === 0) return []; + // Newest last in UID order; take the tail and show it newest first. + const wanted = uids.sort((a, b) => a - b).slice(-limit); + const summaries = await fetchSummaries(wanted, true); + return summaries.sort((a, b) => b.uid - a.uid); + }); + }, + + async search(folder, query, limit) { + return withFolder(folder, async () => { + const uids = await client.search(query, { uid: true }); + if (!uids || uids.length === 0) return []; + const wanted = uids.sort((a, b) => a - b).slice(-limit); + const summaries = await fetchSummaries(wanted, true); + return summaries.sort((a, b) => b.uid - a.uid); + }); + }, + + async raw(folder, uid) { + return withFolder(folder, async () => { + const message = await client.fetchOne(String(uid), { source: true }, { uid: true }); + if (!message || !message.source) throw new MailError(`no message with uid ${uid} in ${folder}`); + return message.source; + }); + }, + + async read(folder, uid) { + return withFolder(folder, async () => { + const message = await client.fetchOne( + String(uid), + { ...SUMMARY_FIELDS, source: true }, + { uid: true }, + ); + if (!message || !message.source) throw new MailError(`no message with uid ${uid} in ${folder}`); + const parsed = await simpleParser(message.source); + return fullFrom(summaryFrom(message), parsed); + }); + }, + + async flag(folder, uids, add, remove) { + await withFolder(folder, async () => { + if (add.length > 0) await client.messageFlagsAdd(uids, add, { uid: true }); + if (remove.length > 0) await client.messageFlagsRemove(uids, remove, { uid: true }); + }); + }, + + async move(folder, uids, destination) { + await withFolder(folder, async () => { + const result = await client.messageMove(uids, destination, { uid: true }); + if (!result) throw new MailError(`could not move ${uids.join(',')} from ${folder} to ${destination}`); + }); + }, + + async expunge(folder, uids) { + await withFolder(folder, async () => { + const ok = await client.messageDelete(uids, { uid: true }); + if (!ok) throw new MailError(`could not delete ${uids.join(',')} from ${folder}`); + }); + }, + + async append(folder, raw, flags) { + const result = await client.append(folder, raw, flags); + if (!result) throw new MailError(`could not append to ${folder}`); + }, + + async close() { + await client.logout(); + }, + }; +} + +/** The folder a special-use role maps to, falling back to the usual names. */ +export function folderFor(folders: Folder[], role: 'Trash' | 'Sent' | 'Drafts' | 'Archive' | 'Junk'): string | null { + const byUse = folders.find((folder) => folder.specialUse === `\\${role}`); + if (byUse) return byUse.path; + const candidates: Record = { + Trash: ['Trash', 'Deleted Items', 'Deleted Messages', '[Gmail]/Trash', '[Gmail]/Bin'], + Sent: ['Sent', 'Sent Items', 'Sent Messages', '[Gmail]/Sent Mail'], + Drafts: ['Drafts', '[Gmail]/Drafts'], + Archive: ['Archive', '[Gmail]/All Mail'], + Junk: ['Junk', 'Spam', '[Gmail]/Spam'], + }; + for (const name of candidates[role]) { + const hit = folders.find((folder) => folder.path.toLowerCase() === name.toLowerCase()); + if (hit) return hit.path; + } + return null; +} + +// --------------------------------------------------------------------------- +// Composing +// --------------------------------------------------------------------------- + +export interface Outgoing { + from: string; + to: string[]; + cc: string[]; + bcc: string[]; + subject: string; + text: string; + html?: string; + inReplyTo?: string; + references?: string[]; + attachments: { filename: string; path: string }[]; +} + +/** `Name
`, or just the address when there is no name. */ +export function fromHeader(account: Account): string { + return account.displayName ? `${account.displayName} <${account.email}>` : account.email; +} + +/** Quote a message body the way every mail client does. */ +export function quote(text: string): string { + return text + .replace(/\r\n/g, '\n') + .trimEnd() + .split('\n') + .map((line) => (line ? `> ${line}` : '>')) + .join('\n'); +} + +/** `Re: subject`, without stacking a second `Re:` on a reply to a reply. */ +export function replySubject(subject: string): string { + const trimmed = subject.trim(); + return /^(re|aw|sv|fwd?)\s*:/i.test(trimmed) ? trimmed : `Re: ${trimmed}`; +} + +/** + * A reply, addressed the way the original asked to be answered. + * + * Reply-To beats From. `all` adds the original's To and Cc, minus ourselves — + * a reply that copies your own address is a message you will read twice — and + * the thread headers carry on from the original so the other side's client + * files it under the same conversation. + */ +export function buildReply( + original: FullMessage, + account: Account, + options: { all?: boolean; body: string; quoteOriginal?: boolean }, +): Outgoing { + const self = account.email.toLowerCase(); + const primary = original.replyTo || original.from; + const to = splitAddresses(primary).filter((entry) => bareAddress(entry) !== self); + const cc: string[] = []; + + if (options.all) { + const seen = new Set(to.map(bareAddress)); + seen.add(self); + for (const entry of [...splitAddresses(original.to), ...splitAddresses(original.cc)]) { + const bare = bareAddress(entry); + if (seen.has(bare)) continue; + seen.add(bare); + cc.push(entry); + } + } + if (to.length === 0 && cc.length > 0) to.push(cc.shift()!); + if (to.length === 0) throw new MailError('the original has no address to reply to'); + + const references = [...original.references]; + if (original.messageId && !references.includes(original.messageId)) references.push(original.messageId); + + let text = options.body.trimEnd(); + if (options.quoteOriginal !== false && original.text.trim()) { + const stamp = original.date ? new Date(original.date).toUTCString() : 'an earlier message'; + const who = original.from || 'they'; + text += `\n\nOn ${stamp}, ${who} wrote:\n${quote(original.text)}`; + } + + return { + from: fromHeader(account), + to, + cc, + bcc: [], + subject: replySubject(original.subject), + text: `${text}\n`, + ...(original.messageId ? { inReplyTo: original.messageId } : {}), + references, + attachments: [], + }; +} + +/** The message as RFC 822 bytes, for a Drafts or Sent append. */ +export async function composeRaw(outgoing: Outgoing): Promise { + const composer = new MailComposer({ + from: outgoing.from, + to: outgoing.to, + cc: outgoing.cc, + bcc: outgoing.bcc, + subject: outgoing.subject, + text: outgoing.text, + ...(outgoing.html ? { html: outgoing.html } : {}), + ...(outgoing.inReplyTo ? { inReplyTo: outgoing.inReplyTo } : {}), + ...(outgoing.references && outgoing.references.length > 0 + ? { references: outgoing.references.join(' ') } + : {}), + attachments: outgoing.attachments, + }); + return composer.compile().build(); +} + +// --------------------------------------------------------------------------- +// Sending +// --------------------------------------------------------------------------- + +export type Transport = 'smtp' | 'resend'; + +export interface TransportChoice { + transport: Transport; + /** The order to try: the choice, then the fallback when one applies. */ + fallback: Transport | null; + reason: string; +} + +/** + * Which way a message leaves. + * + * SMTP when the account has a password; Resend when it does not and the key + * is here; and never Resend for a webmail address, because the domain cannot + * be verified there and the failure would only surface as a 403 after the + * message was ready to go. An explicit `--via` is honoured or refused, not + * quietly swapped. + */ +export function chooseTransport( + account: Account, + resendKey: string | undefined, + requested?: Transport, +): TransportChoice { + const webmail = WEBMAIL_DOMAINS.has(domainOf(account.email)); + const canSmtp = Boolean(account.password); + const canResend = Boolean(resendKey) && !webmail; + + if (requested === 'smtp') { + if (!canSmtp) { + throw new MailError(`--via smtp: account "${account.name}" has no password to log in with`); + } + return { transport: 'smtp', fallback: null, reason: 'requested' }; + } + if (requested === 'resend') { + if (!resendKey) { + throw new MailError('--via resend: no RESEND_API_KEY — `cli-tools config pull` imports it'); + } + if (webmail) { + throw new MailError( + `--via resend: ${domainOf(account.email)} cannot be verified at Resend, so it cannot send as ${account.email}`, + ); + } + return { transport: 'resend', fallback: null, reason: 'requested' }; + } + + if (canSmtp) { + return { + transport: 'smtp', + fallback: canResend ? 'resend' : null, + reason: `${account.smtp.host} as ${account.user}`, + }; + } + if (canResend) { + return { transport: 'resend', fallback: null, reason: 'no SMTP password for this account' }; + } + throw new MailError( + webmail + ? `account "${account.name}" has no password, and ${domainOf(account.email)} cannot send through Resend — ` + + `store one with \`mail accounts password ${account.name}\`` + : `account "${account.name}" has no password and there is no RESEND_API_KEY — ` + + `store one with \`mail accounts password ${account.name}\` or run \`cli-tools config pull\``, + ); +} + +export interface SendResult { + transport: Transport; + id: string | null; + /** Set when the first transport failed and the second carried it. */ + fellBackFrom?: { transport: Transport; error: string }; +} + +export type SmtpSender = (account: Account, outgoing: Outgoing) => Promise; +export type ResendSender = (key: string, outgoing: Outgoing) => Promise; + +export const sendViaSmtp: SmtpSender = async (account, outgoing) => { + const transporter = nodemailer.createTransport({ + host: account.smtp.host, + port: account.smtp.port, + secure: account.smtp.secure, + auth: { user: account.user, pass: account.password ?? '' }, + connectionTimeout: 20_000, + greetingTimeout: 20_000, + }); + const info = await transporter.sendMail({ + from: outgoing.from, + to: outgoing.to, + cc: outgoing.cc, + bcc: outgoing.bcc, + subject: outgoing.subject, + text: outgoing.text, + ...(outgoing.html ? { html: outgoing.html } : {}), + ...(outgoing.inReplyTo ? { inReplyTo: outgoing.inReplyTo } : {}), + ...(outgoing.references && outgoing.references.length > 0 + ? { references: outgoing.references.join(' ') } + : {}), + attachments: outgoing.attachments, + }); + return info.messageId ?? null; +}; + +export const RESEND_API = 'https://api.resend.com/emails'; + +/** Resend's JSON body. Header names are Resend's, not SMTP's. */ +export function resendPayload(outgoing: Outgoing): Record { + const headers: Record = {}; + if (outgoing.inReplyTo) headers['In-Reply-To'] = outgoing.inReplyTo; + if (outgoing.references && outgoing.references.length > 0) { + headers.References = outgoing.references.join(' '); + } + return { + from: outgoing.from, + to: outgoing.to, + ...(outgoing.cc.length > 0 ? { cc: outgoing.cc } : {}), + ...(outgoing.bcc.length > 0 ? { bcc: outgoing.bcc } : {}), + subject: outgoing.subject, + text: outgoing.text, + ...(outgoing.html ? { html: outgoing.html } : {}), + ...(Object.keys(headers).length > 0 ? { headers } : {}), + ...(outgoing.attachments.length > 0 + ? { + attachments: outgoing.attachments.map((attachment) => ({ + filename: attachment.filename, + content: readFileSync(attachment.path).toString('base64'), + })), + } + : {}), + }; +} + +export function resendSender(fetchImpl: typeof fetch = fetch): ResendSender { + return async (key, outgoing) => { + const response = await fetchImpl(RESEND_API, { + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(resendPayload(outgoing)), + }); + const body = (await response.json().catch(() => ({}))) as Record; + if (!response.ok) { + const message = typeof body.message === 'string' ? body.message : `HTTP ${response.status}`; + throw new MailError(`Resend refused the message: ${message}`); + } + return typeof body.id === 'string' ? body.id : null; + }; +} + +/** + * Send, and fall back once. + * + * A fallback is only taken on a transport failure — a refused login, a dead + * host — never on a refused *message*. Resend's "domain not verified" and + * SMTP's "recipient rejected" mean the same message would fail the same way + * on the other path, and trying anyway risks two copies when the first one + * was actually accepted late. + */ +export async function sendMail( + account: Account, + outgoing: Outgoing, + options: { + resendKey?: string; + via?: Transport; + smtp?: SmtpSender; + resend?: ResendSender; + } = {}, +): Promise { + const choice = chooseTransport(account, options.resendKey, options.via); + const smtp = options.smtp ?? sendViaSmtp; + const resend = options.resend ?? resendSender(); + + const attempt = async (transport: Transport): Promise => + transport === 'smtp' ? smtp(account, outgoing) : resend(options.resendKey!, outgoing); + + try { + return { transport: choice.transport, id: await attempt(choice.transport) }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!choice.fallback || !isTransportFailure(message)) { + throw error instanceof MailError ? error : new MailError(`${choice.transport}: ${message}`); + } + const id = await attempt(choice.fallback); + return { + transport: choice.fallback, + id, + fellBackFrom: { transport: choice.transport, error: message }, + }; + } +} + +/** A failure of the pipe, as opposed to a refusal of the message. */ +export function isTransportFailure(message: string): boolean { + return /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|timed? ?out|greeting|Invalid login|authentication|auth(?:orization)? failed|535|454|421|connection closed/i.test( + message, + ); +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +function truncate(text: string, width: number): string { + const clean = text.replace(/\s+/g, ' ').trim(); + if (clean.length <= width) return clean.padEnd(width); + return `${clean.slice(0, Math.max(0, width - 1))}…`; +} + +function shortDate(iso: string | null, now: Date = new Date()): string { + if (!iso) return ' '; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ' '; + const sameDay = date.toISOString().slice(0, 10) === now.toISOString().slice(0, 10); + return sameDay ? ` ${date.toISOString().slice(11, 16)}` : date.toISOString().slice(0, 10); +} + +/** + * One line per message, newest first. + * + * uid flags date from subject + * + * Flags are `N` unread, `*` flagged, `r` answered — the three that change what + * you do next. The uid is first because it is what every other verb takes. + */ +export function formatList( + messages: MessageSummary[], + options: { width?: number; account?: string; now?: Date } = {}, +): string { + if (messages.length === 0) return '(no messages)'; + const width = Math.max(60, options.width ?? 100); + const uidWidth = Math.max(3, ...messages.map((message) => String(message.uid).length)); + const fromWidth = Math.min(28, Math.max(8, ...messages.map((message) => senderName(message.from).length))); + const prefixWidth = uidWidth + 1 + 3 + 1 + 10 + 1 + fromWidth + 2; + const subjectWidth = Math.max(12, width - prefixWidth); + + const lines = messages.map((message) => { + const flags = `${message.seen ? ' ' : 'N'}${message.flagged ? '*' : ' '}${message.answered ? 'r' : ' '}`; + return ( + `${String(message.uid).padStart(uidWidth)} ${flags} ${shortDate(message.date, options.now)} ` + + `${truncate(senderName(message.from), fromWidth)} ${truncate(message.subject || '(no subject)', subjectWidth).trimEnd()}` + ); + }); + return (options.account ? [`# ${options.account}`, ...lines] : lines).join('\n'); +} + +/** The human part of `Name `, or the address when there is none. */ +export function senderName(from: string): string { + const first = splitAddresses(from)[0] ?? ''; + const match = /^"?([^"<]*?)"?\s*<[^>]+>$/.exec(first.trim()); + const name = match ? match[1]!.trim() : ''; + return name || bareAddress(first) || ''; +} + +/** Headers, then the text, the way `less` wants to see it. */ +export function formatMessage(message: FullMessage): string { + const lines = [ + `From: ${message.from}`, + `To: ${message.to}`, + ...(message.cc ? [`Cc: ${message.cc}`] : []), + ...(message.replyTo ? [`Reply-To: ${message.replyTo}`] : []), + `Date: ${message.date ?? '(none)'}`, + `Subject: ${message.subject || '(no subject)'}`, + `Uid: ${message.uid}${message.messageId ? ` Message-Id: ${message.messageId}` : ''}`, + ]; + if (message.attachments.length > 0) { + lines.push( + `Attachments: ${message.attachments + .map((attachment) => `${attachment.filename} (${attachment.contentType}, ${attachment.size} bytes)`) + .join('; ')}`, + ); + } + return `${lines.join('\n')}\n\n${message.text.replace(/\r\n/g, '\n').trimEnd()}\n`; +} + +/** Folder listing with the role next to the ones that have one. */ +export function formatFolders(folders: Folder[]): string { + if (folders.length === 0) return '(no folders)'; + const width = Math.max(...folders.map((folder) => folder.path.length)); + return folders + .map((folder) => `${folder.path.padEnd(width)}${folder.specialUse ? ` ${folder.specialUse}` : ''}`) + .join('\n'); +} + +/** `mail accounts` output; never a password. */ +export function formatAccounts(accounts: Account[], defaultName: string | undefined): string { + if (accounts.length === 0) return '(no accounts — `mail accounts add `)'; + const width = Math.max(...accounts.map((account) => account.name.length)); + return accounts + .map((account) => { + const marker = account.name === defaultName ? '*' : ' '; + const password = + account.passwordSource === 'unset' + ? 'no password' + : `password from ${account.passwordSource === 'env' ? passwordVariable(account.name) : 'mail.json'}`; + return `${marker} ${account.name.padEnd(width)} ${account.email} ${account.provider} ${password}`; + }) + .join('\n'); +} diff --git a/src/prompt.ts b/src/prompt.ts new file mode 100644 index 0000000..2184f46 --- /dev/null +++ b/src/prompt.ts @@ -0,0 +1,64 @@ +/** + * Read a secret at the terminal without echoing it. + * + * A key typed at a visible prompt ends up in the scrollback of whatever + * terminal multiplexer or screen recorder is running, which is a worse place + * for it than the 0600 file it is about to go into. Raw mode, no echo, and + * only Enter or EOF ends it. + * + * Without a terminal (`echo "$KEY" | cli-tools config set openai`), stdin is + * read whole and trimmed, so a script can feed it. + */ +export async function promptSecret(label: string): Promise { + if (!process.stdin.isTTY) { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8').trim(); + } + + process.stderr.write(label); + process.stdin.setRawMode(true); + process.stdin.resume(); + + return new Promise((resolve) => { + let value = ''; + const onData = (chunk: Buffer) => { + for (const byte of chunk) { + // Enter, or EOF/interrupt. + if (byte === 0x0d || byte === 0x0a || byte === 0x04) { + finish(); + return; + } + if (byte === 0x03) { + process.stderr.write('\n'); + process.exit(130); + } + // Backspace / delete. + if (byte === 0x7f || byte === 0x08) { + value = value.slice(0, -1); + continue; + } + value += String.fromCharCode(byte); + } + }; + const finish = () => { + process.stdin.off('data', onData); + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stderr.write('\n'); + resolve(value.trim()); + }; + process.stdin.on('data', onData); + }); +} + +/** A yes/no on stdin. Non-interactive callers must pass --yes rather than hang. */ +export async function confirm(question: string): Promise { + if (!process.stdin.isTTY) return false; + process.stderr.write(`${question} [y/N] `); + const answer = await new Promise((resolve) => { + process.stdin.setEncoding('utf8'); + process.stdin.once('data', (chunk) => resolve(String(chunk))); + }); + return /^y(es)?$/i.test(answer.trim()); +} diff --git a/src/registry.ts b/src/registry.ts index 5e01b3a..72eb3b5 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -40,6 +40,7 @@ const SUMMARIES: Record = { 'generate-names': 'Turn a sentence about a product into a thousand candidate names', genrewatch: 'What is coming out, and whether it exists at all', img: 'Resize, convert and inspect images, with sharp or ImageMagick', + mail: 'The inbox from the terminal: read, search, reply, send, file, delete', 'gh-prs': 'Every open PR across the owners you name', 'gh-prs-fix-all': 'Repair the open scan PRs that are broken because of us', 'gh-prs-merge': 'Squash-merge the PRs that are genuinely ready', diff --git a/test/mail.test.ts b/test/mail.test.ts new file mode 100644 index 0000000..5710bc5 --- /dev/null +++ b/test/mail.test.ts @@ -0,0 +1,653 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + type Account, + type FullMessage, + type MessageSummary, + type Outgoing, + MailError, + accountsFromVault, + bareAddress, + buildReply, + chooseTransport, + composeRaw, + folderFor, + formatAccounts, + formatAddresses, + formatList, + formatMessage, + fromHeader, + guessProvider, + isTransportFailure, + loadConfig, + mailConfigPath, + mergeVaultAccounts, + normalizeConfig, + parseQuery, + passwordVariable, + quote, + replySubject, + resendPayload, + resendSender, + resolveAccount, + saveConfig, + selectAccount, + selectAccounts, + sendMail, + senderName, + splitAddresses, + stripHtml, + summaryFrom, +} from '../src/mail.ts'; + +const env = (extra: Record = {}): NodeJS.ProcessEnv => ({ ...extra }); + +function account(partial: Partial = {}): Account { + return { + name: 'work', + email: 'me@example.com', + displayName: 'Me', + user: 'me@example.com', + password: 'secret', + passwordSource: 'file', + provider: 'forwardemail', + imap: { host: 'imap.example.com', port: 993 }, + smtp: { host: 'smtp.example.com', port: 465, secure: true }, + ...partial, + }; +} + +function summary(partial: Partial = {}): MessageSummary { + return { + uid: 1, + seq: 1, + date: '2026-09-01T10:00:00.000Z', + from: 'Alice ', + to: 'me@example.com', + subject: 'Hello', + seen: true, + flagged: false, + answered: false, + size: 1024, + messageId: '', + ...partial, + }; +} + +function full(partial: Partial = {}): FullMessage { + return { + ...summary(), + cc: '', + replyTo: '', + inReplyTo: null, + references: [], + text: 'first line\n\nsecond line', + html: null, + attachments: [], + ...partial, + }; +} + +describe('providers', () => { + it('infers gmail from the address and nothing else', () => { + expect(guessProvider('a@gmail.com')).toBe('gmail'); + expect(guessProvider('a@GoogleMail.com')).toBe('gmail'); + expect(guessProvider('a@example.com')).toBeNull(); + }); + + it('names the password variable from the account name', () => { + expect(passwordVariable('work')).toBe('MAIL_WORK_PASSWORD'); + expect(passwordVariable('my-home')).toBe('MAIL_MY_HOME_PASSWORD'); + }); +}); + +describe('resolveAccount', () => { + it('fills hosts from the provider preset', () => { + const resolved = resolveAccount('home', { email: 'a@gmail.com', provider: 'gmail', password: 'p' }, env()); + expect(resolved.imap).toEqual({ host: 'imap.gmail.com', port: 993 }); + expect(resolved.smtp).toEqual({ host: 'smtp.gmail.com', port: 465, secure: true }); + expect(resolved.user).toBe('a@gmail.com'); + expect(resolved.passwordSource).toBe('file'); + }); + + // The rule shared with credentials.ts: what is exported beats what is stored, + // because CI and a one-off shell have to be able to override a stale file. + it('lets the environment override the stored password', () => { + const resolved = resolveAccount( + 'work', + { email: 'a@example.com', provider: 'forwardemail', password: 'stored' }, + env({ MAIL_WORK_PASSWORD: 'exported' }), + ); + expect(resolved.password).toBe('exported'); + expect(resolved.passwordSource).toBe('env'); + }); + + it('reports an unset password rather than inventing one', () => { + const resolved = resolveAccount('work', { email: 'a@example.com', provider: 'forwardemail' }, env()); + expect(resolved.password).toBeNull(); + expect(resolved.passwordSource).toBe('unset'); + }); + + it('refuses a custom provider with no hosts', () => { + expect(() => resolveAccount('x', { email: 'a@b.c', provider: 'custom' }, env())).toThrow(/imapHost and smtpHost/); + }); + + it('treats a non-465 custom port as STARTTLS unless told otherwise', () => { + const resolved = resolveAccount( + 'x', + { email: 'a@b.c', provider: 'custom', imapHost: 'i', smtpHost: 's', smtpPort: 587 }, + env(), + ); + expect(resolved.smtp.secure).toBe(false); + }); +}); + +describe('selectAccounts', () => { + const config = { + accounts: { + work: { email: 'a@example.com', provider: 'forwardemail' as const }, + home: { email: 'b@gmail.com', provider: 'gmail' as const }, + }, + }; + + it('finds an account by name or by address', () => { + expect(selectAccounts(config, 'home', env())[0]!.name).toBe('home'); + expect(selectAccounts(config, 'a@example.com', env())[0]!.name).toBe('work'); + expect(selectAccounts(config, 'A@EXAMPLE.COM', env())[0]!.name).toBe('work'); + }); + + it('returns every account for "all"', () => { + expect(selectAccounts(config, 'all', env()).map((a) => a.name)).toEqual(['work', 'home']); + }); + + it('uses the default, then MAIL_ACCOUNT, then asks', () => { + expect(selectAccounts({ ...config, default: 'home' }, undefined, env())[0]!.name).toBe('home'); + expect(selectAccounts(config, undefined, env({ MAIL_ACCOUNT: 'work' }))[0]!.name).toBe('work'); + expect(() => selectAccounts(config, undefined, env())).toThrow(/which account/); + }); + + it('needs no selector when there is exactly one account', () => { + const one = { accounts: { work: config.accounts.work } }; + expect(selectAccounts(one, undefined, env())[0]!.name).toBe('work'); + }); + + it('names the configured accounts in the error for an unknown one', () => { + expect(() => selectAccounts(config, 'other', env())).toThrow(/work, home/); + }); + + it('explains how to add an account when there are none', () => { + expect(() => selectAccounts({ accounts: {} }, undefined, env())).toThrow(/mail accounts add/); + }); + + it('selectAccount refuses "all"', () => { + expect(() => selectAccount(config, 'all', env())).toThrow(/one account at a time/); + }); +}); + +describe('config file', () => { + let dir: string; + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('round-trips through a 0600 file in the configured directory', () => { + dir = mkdtempSync(join(tmpdir(), 'mail-config-')); + const e = env({ XDG_CONFIG_HOME: dir }); + expect(mailConfigPath(e)).toBe(join(dir, 'cli-tools', 'mail.json')); + const path = saveConfig( + { default: 'work', accounts: { work: { email: 'a@example.com', provider: 'forwardemail', password: 'p' } } }, + e, + ); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(loadConfig(e)).toEqual({ + default: 'work', + accounts: { work: { email: 'a@example.com', provider: 'forwardemail', password: 'p' } }, + }); + }); + + it('is empty on first run and loud on corruption', () => { + dir = mkdtempSync(join(tmpdir(), 'mail-config-')); + const e = env({ XDG_CONFIG_HOME: dir }); + expect(loadConfig(e)).toEqual({ accounts: {} }); + writeFileSync(join(dir, 'mail.json'), '{nope'); + expect(() => loadConfig(env({ CLI_TOOLS_MAIL_CONFIG: join(dir, 'mail.json') }))).toThrow(/not valid JSON/); + }); + + it('drops entries that are not accounts and infers a missing provider', () => { + const config = normalizeConfig({ + default: 'home', + accounts: { + home: { email: 'B@Gmail.com', name: ' Bee ' }, + junk: { email: 'not-an-address' }, + alsoJunk: 'string', + }, + }); + expect(config).toEqual({ default: 'home', accounts: { home: { email: 'b@gmail.com', provider: 'gmail', name: 'Bee' } } }); + }); +}); + +describe('accountsFromVault', () => { + it('builds accounts from MAIL__* keys and the default', () => { + const config = accountsFromVault({ + MAIL_WORK_EMAIL: 'A@Example.com', + MAIL_WORK_PROVIDER: 'forwardemail', + MAIL_WORK_PASSWORD: 'w', + MAIL_WORK_NAME: 'Anyone', + MAIL_HOME_EMAIL: 'b@gmail.com', + MAIL_HOME_PASSWORD: 'h', + MAIL_DEFAULT: 'work', + RESEND_API_KEY: 'unrelated', + }); + expect(config).toEqual({ + default: 'work', + accounts: { + work: { email: 'a@example.com', provider: 'forwardemail', password: 'w', name: 'Anyone' }, + home: { email: 'b@gmail.com', provider: 'gmail', password: 'h' }, + }, + }); + }); + + it('lets a custom account name its hosts, and reads the port and TLS flag', () => { + const config = accountsFromVault({ + MAIL_OLD_EMAIL: 'x@corp.example', + MAIL_OLD_IMAP_HOST: 'imap.corp.example', + MAIL_OLD_SMTP_HOST: 'smtp.corp.example', + MAIL_OLD_SMTP_PORT: '587', + MAIL_OLD_SMTP_SECURE: 'false', + MAIL_OLD_USER: 'x', + }); + expect(config.accounts.old).toEqual({ + email: 'x@corp.example', + provider: 'custom', + user: 'x', + imapHost: 'imap.corp.example', + smtpHost: 'smtp.corp.example', + smtpPort: 587, + smtpSecure: false, + }); + }); + + it('ignores a password with no address, and a default that names nothing', () => { + const config = accountsFromVault({ MAIL_GHOST_PASSWORD: 'p', MAIL_DEFAULT: 'ghost' }); + expect(config).toEqual({ accounts: {} }); + }); + + it('merges over the local file, vault first, leaving local-only accounts alone', () => { + const local = { + default: 'home', + accounts: { + work: { email: 'a@example.com', provider: 'forwardemail' as const }, + lab: { email: 'l@example.com', provider: 'forwardemail' as const, password: 'kept' }, + }, + }; + const fromVault = accountsFromVault({ + MAIL_WORK_EMAIL: 'a@example.com', + MAIL_WORK_PROVIDER: 'forwardemail', + MAIL_WORK_PASSWORD: 'now', + MAIL_HOME_EMAIL: 'b@gmail.com', + MAIL_HOME_PASSWORD: 'h', + }); + const { merged, imported, unchanged } = mergeVaultAccounts(local, fromVault); + expect(imported).toEqual(['home', 'work']); + expect(unchanged).toEqual([]); + expect(merged.accounts.lab?.password).toBe('kept'); + expect(merged.accounts.work?.password).toBe('now'); + expect(merged.default).toBe('home'); + // A second pull with nothing new says so. + expect(mergeVaultAccounts(merged, fromVault).unchanged).toEqual(['home', 'work']); + }); +}); + +describe('addresses', () => { + it('formats and splits header lists', () => { + expect(formatAddresses([{ name: 'A B', address: 'a@b.c' }, { address: 'd@e.f' }, { name: 'Only' }])).toBe( + 'A B , d@e.f, Only', + ); + expect(splitAddresses('"Doe, Jane" , bob@x.y')).toEqual(['"Doe, Jane" ', 'bob@x.y']); + expect(bareAddress('Jane ')).toBe('jane@x.y'); + expect(senderName('"Doe, Jane" ')).toBe('Doe, Jane'); + expect(senderName('jane@x.y')).toBe('jane@x.y'); + }); + + it('puts the display name on the From header only when there is one', () => { + expect(fromHeader(account())).toBe('Me '); + expect(fromHeader(account({ displayName: null }))).toBe('me@example.com'); + }); +}); + +describe('summaryFrom', () => { + it('maps the IMAP fetch object, flags included', () => { + const mapped = summaryFrom({ + seq: 3, + uid: 42, + size: 999, + flags: new Set(['\\Flagged', '\\Answered']), + envelope: { + date: new Date('2026-09-02T12:00:00Z'), + subject: 'Hi', + messageId: '', + from: [{ name: 'A', address: 'a@x' }], + to: [{ address: 'me@x' }], + }, + }); + expect(mapped).toEqual({ + uid: 42, + seq: 3, + date: '2026-09-02T12:00:00.000Z', + from: 'A ', + to: 'me@x', + subject: 'Hi', + seen: false, + flagged: true, + answered: true, + size: 999, + messageId: '', + }); + }); + + it('survives a message with no envelope at all', () => { + expect(summaryFrom({ seq: 1, uid: 1 })).toMatchObject({ uid: 1, from: '', subject: '', date: null, size: null }); + }); +}); + +describe('parseQuery', () => { + it('turns keys into IMAP search fields and bare words into text', () => { + expect(parseQuery('from:alice subject:"pottery wheel" since:2026-09-01 unread invoice due')).toEqual({ + from: 'alice', + subject: 'pottery wheel', + since: new Date('2026-09-01T00:00:00Z'), + seen: false, + text: 'invoice due', + }); + }); + + it('understands is: filters', () => { + expect(parseQuery('is:flagged is:answered')).toEqual({ flagged: true, answered: true }); + expect(parseQuery('is:read')).toEqual({ seen: true }); + expect(() => parseQuery('is:huge')).toThrow(/is:huge/); + }); + + it('rejects a date that is not a date, and a key it does not know', () => { + expect(() => parseQuery('since:yesterday')).toThrow(/YYYY-MM-DD/); + expect(() => parseQuery('label:foo')).toThrow(/unknown search key/); + }); +}); + +describe('folderFor', () => { + const folders = [ + { path: 'INBOX', specialUse: null, delimiter: '/' }, + { path: '[Gmail]/Bin', specialUse: '\\Trash', delimiter: '/' }, + { path: '[Gmail]/Sent Mail', specialUse: '\\Sent', delimiter: '/' }, + { path: 'Drafts', specialUse: null, delimiter: '/' }, + ]; + + it('prefers the special-use attribute, then a conventional name', () => { + expect(folderFor(folders, 'Trash')).toBe('[Gmail]/Bin'); + expect(folderFor(folders, 'Sent')).toBe('[Gmail]/Sent Mail'); + expect(folderFor(folders, 'Drafts')).toBe('Drafts'); + expect(folderFor(folders, 'Archive')).toBeNull(); + }); +}); + +describe('replies', () => { + it('quotes like a mail client', () => { + expect(quote('a\r\nb\n\nc\n')).toBe('> a\n> b\n>\n> c'); + }); + + it('adds Re: once', () => { + expect(replySubject('Hello')).toBe('Re: Hello'); + expect(replySubject('Re: Hello')).toBe('Re: Hello'); + expect(replySubject('RE: Hello')).toBe('RE: Hello'); + expect(replySubject('Fwd: Hello')).toBe('Fwd: Hello'); + }); + + it('answers the Reply-To, threads on the original, and quotes it', () => { + const original = full({ + replyTo: 'Alice Replies ', + references: [''], + }); + const reply = buildReply(original, account(), { body: 'Thanks!' }); + expect(reply.to).toEqual(['Alice Replies ']); + expect(reply.cc).toEqual([]); + expect(reply.subject).toBe('Re: Hello'); + expect(reply.inReplyTo).toBe(''); + expect(reply.references).toEqual(['', '']); + expect(reply.text).toContain('Thanks!\n\nOn Tue, 01 Sep 2026 10:00:00 GMT, Alice wrote:\n> first line\n>\n> second line'); + expect(reply.from).toBe('Me '); + }); + + // Reply-all that copies yourself is a message you read twice; and the + // original sender must not be duplicated into Cc. + it('reply-all copies everyone else exactly once', () => { + const original = full({ + to: 'me@example.com, Bob ', + cc: 'Carol , alice@example.org', + }); + const reply = buildReply(original, account(), { all: true, body: 'x', quoteOriginal: false }); + expect(reply.to).toEqual(['Alice ']); + expect(reply.cc).toEqual(['Bob ', 'Carol ']); + expect(reply.text).toBe('x\n'); + }); + + it('refuses when there is nobody to answer', () => { + expect(() => buildReply(full({ from: 'me@example.com' }), account(), { body: 'x' })).toThrow(/no address/); + }); + + it('composes RFC 822 bytes with the thread headers', async () => { + const raw = (await composeRaw(buildReply(full(), account(), { body: 'ok', quoteOriginal: false }))).toString(); + expect(raw).toMatch(/^From: Me /m); + expect(raw).toMatch(/^In-Reply-To: /m); + expect(raw).toMatch(/^References: /m); + expect(raw).toMatch(/^Subject: Re: Hello/m); + }); +}); + +describe('chooseTransport', () => { + it('prefers SMTP with Resend behind it for a verifiable domain', () => { + expect(chooseTransport(account(), 'key')).toMatchObject({ transport: 'smtp', fallback: 'resend' }); + expect(chooseTransport(account(), undefined)).toMatchObject({ transport: 'smtp', fallback: null }); + }); + + it('goes straight to Resend when there is no password', () => { + expect(chooseTransport(account({ password: null }), 'key')).toMatchObject({ transport: 'resend', fallback: null }); + }); + + // gmail.com cannot be verified at Resend, so a Gmail account without a + // password has nowhere to go — and the message says which fix applies. + it('never offers Resend for a webmail address', () => { + const gmail = account({ email: 'me@gmail.com', provider: 'gmail' }); + expect(chooseTransport(gmail, 'key')).toMatchObject({ transport: 'smtp', fallback: null }); + expect(() => chooseTransport(account({ email: 'me@gmail.com', password: null }), 'key')).toThrow(/cannot send through Resend/); + expect(() => chooseTransport(gmail, 'key', 'resend')).toThrow(/cannot be verified/); + }); + + it('honours or refuses an explicit --via, never swaps it', () => { + expect(chooseTransport(account(), 'key', 'resend')).toMatchObject({ transport: 'resend', fallback: null }); + expect(() => chooseTransport(account({ password: null }), 'key', 'smtp')).toThrow(/no password/); + expect(() => chooseTransport(account(), undefined, 'resend')).toThrow(/RESEND_API_KEY/); + }); + + it('says both fixes when nothing can send', () => { + expect(() => chooseTransport(account({ password: null }), undefined)).toThrow(/mail accounts password work.*cli-tools config pull/s); + }); +}); + +describe('sendMail', () => { + const outgoing: Outgoing = { + from: 'Me ', + to: ['a@example.org'], + cc: [], + bcc: [], + subject: 'Hi', + text: 'body\n', + attachments: [], + }; + + it('falls back to Resend when SMTP cannot connect', async () => { + const calls: string[] = []; + const result = await sendMail(account(), outgoing, { + resendKey: 'key', + smtp: async () => { + calls.push('smtp'); + throw new Error('connect ECONNREFUSED 1.2.3.4:465'); + }, + resend: async () => { + calls.push('resend'); + return 'r-1'; + }, + }); + expect(calls).toEqual(['smtp', 'resend']); + expect(result).toEqual({ + transport: 'resend', + id: 'r-1', + fellBackFrom: { transport: 'smtp', error: 'connect ECONNREFUSED 1.2.3.4:465' }, + }); + }); + + // A refused recipient would be refused the same way on the other path, and + // retrying a message the first server may have accepted late sends it twice. + it('does not fall back on a refused message', async () => { + let resendCalls = 0; + await expect( + sendMail(account(), outgoing, { + resendKey: 'key', + smtp: async () => { + throw new Error('550 5.1.1 recipient rejected'); + }, + resend: async () => { + resendCalls += 1; + return null; + }, + }), + ).rejects.toThrow(/recipient rejected/); + expect(resendCalls).toBe(0); + }); + + it('does not fall back when --via pinned the transport', async () => { + await expect( + sendMail(account(), outgoing, { + resendKey: 'key', + via: 'smtp', + smtp: async () => { + throw new Error('ETIMEDOUT'); + }, + resend: async () => 'never', + }), + ).rejects.toThrow(/ETIMEDOUT/); + }); + + it('classifies pipe failures apart from message refusals', () => { + expect(isTransportFailure('Invalid login: 535 5.7.8 Authentication failed')).toBe(true); + expect(isTransportFailure('getaddrinfo ENOTFOUND smtp.example.com')).toBe(true); + expect(isTransportFailure('Greeting never received')).toBe(true); + expect(isTransportFailure('550 5.1.1 recipient rejected')).toBe(false); + expect(isTransportFailure('Resend refused the message: domain is not verified')).toBe(false); + }); +}); + +describe('Resend', () => { + it('shapes the payload with Resend header names and base64 attachments', () => { + const dir = mkdtempSync(join(tmpdir(), 'mail-attach-')); + const path = join(dir, 'a.txt'); + writeFileSync(path, 'hello'); + try { + const payload = resendPayload({ + from: 'Me ', + to: ['a@x'], + cc: ['c@x'], + bcc: [], + subject: 'S', + text: 'T', + inReplyTo: '', + references: ['', ''], + attachments: [{ filename: 'a.txt', path }], + }); + expect(payload).toEqual({ + from: 'Me ', + to: ['a@x'], + cc: ['c@x'], + subject: 'S', + text: 'T', + headers: { 'In-Reply-To': '', References: ' ' }, + attachments: [{ filename: 'a.txt', content: Buffer.from('hello').toString('base64') }], + }); + expect(payload).not.toHaveProperty('bcc'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('posts with the bearer key and surfaces the refusal message', async () => { + const seen: { url: string; init: RequestInit }[] = []; + const ok = resendSender((async (url: string | URL | Request, init?: RequestInit) => { + seen.push({ url: String(url), init: init ?? {} }); + return new Response(JSON.stringify({ id: 'abc' }), { status: 200 }); + }) as typeof fetch); + const outgoing: Outgoing = { from: 'a@x', to: ['b@x'], cc: [], bcc: [], subject: 's', text: 't', attachments: [] }; + await expect(ok('key', outgoing)).resolves.toBe('abc'); + expect(seen[0]!.url).toBe('https://api.resend.com/emails'); + expect((seen[0]!.init.headers as Record).Authorization).toBe('Bearer key'); + + const refused = resendSender((async () => + new Response(JSON.stringify({ message: 'The example.com domain is not verified.' }), { status: 403 })) as typeof fetch); + await expect(refused('key', outgoing)).rejects.toThrow(MailError); + await expect(refused('key', outgoing)).rejects.toThrow(/domain is not verified/); + }); +}); + +describe('output', () => { + const now = new Date('2026-09-05T12:00:00Z'); + + it('lists newest first with uid, state flags, date and a trimmed subject', () => { + const text = formatList( + [ + summary({ uid: 12, seen: false, flagged: true, subject: 'A very long subject '.repeat(10), date: '2026-09-05T09:30:00Z' }), + summary({ uid: 3, answered: true, from: 'bob@example.org' }), + ], + { width: 80, now }, + ); + const lines = text.split('\n'); + expect(lines).toHaveLength(2); + expect(lines[0]).toMatch(/^ 12 N\* 09:30 Alice\s+A very long subject/); + expect(lines[0]!.length).toBeLessThanOrEqual(80); + expect(lines[1]).toMatch(/^ 3 r 2026-09-01 bob@example.org\s+Hello$/); + }); + + it('labels the account when asked, and says when there is nothing', () => { + expect(formatList([], { now })).toBe('(no messages)'); + expect(formatList([summary()], { account: 'home', now })).toMatch(/^# home\n/); + }); + + it('prints a message as headers then text', () => { + const text = formatMessage( + full({ cc: 'c@x', attachments: [{ filename: 'bowl.jpg', contentType: 'image/jpeg', size: 2048 }] }), + ); + expect(text).toBe( + 'From: Alice \n' + + 'To: me@example.com\n' + + 'Cc: c@x\n' + + 'Date: 2026-09-01T10:00:00.000Z\n' + + 'Subject: Hello\n' + + 'Uid: 1 Message-Id: \n' + + 'Attachments: bowl.jpg (image/jpeg, 2048 bytes)\n' + + '\n' + + 'first line\n\nsecond line\n', + ); + }); + + it('never prints a password in the accounts listing', () => { + const text = formatAccounts([account(), account({ name: 'home', password: null, passwordSource: 'unset' })], 'work'); + expect(text).toContain('* work'); + expect(text).toContain('password from mail.json'); + expect(text).toContain('no password'); + expect(text).not.toContain('secret'); + }); + + it('reduces HTML-only mail to readable text', () => { + expect(stripHtml('

Hi there

Bye
now
')).toBe('Hi there\nBye\nnow'); + }); +});