From bcb1cfbbf7e4d555a8afb27587ddda24af0762cf Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 5 Sep 2026 23:16:48 +0000 Subject: [PATCH 1/3] mail login: sign in to any provider that still takes a password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mail login [address]` says which kind of password the host wants before asking for it — Gmail, Yahoo, AOL, iCloud, Fastmail and Yandex refuse the account password over IMAP and their refusal reads like a typo — then tries IMAP and SMTP and stores nothing on a refusal. The provider comes off the name, a webmail domain, or a custom domain's MX records, so a domain hosted at Google, Zoho, Fastmail, Proton, iCloud or Forward Email needs no --provider. Fifteen providers are built in (Forward Email, Gmail, Yahoo, AOL, iCloud, Fastmail, Zoho, Proton via its Bridge on localhost, GMX, Yandex, mail.com, Posteo, mailbox.org, Migadu, Purelymail); `mail providers` lists them with hosts, ports and the password rule. Outlook.com / Microsoft 365, Tuta and HEY are listed as unreachable with the reason, since Microsoft takes only OAuth2 now and the other two have no IMAP at all. Accounts gain imapSecure and insecureTls (IMAP STARTTLS and a self-signed bridge certificate, both needed for Proton), readable from the vault as _IMAP_SECURE / _INSECURE_TLS. The login-failure message names the fix per provider instead of only for Gmail. Plugin command /mail:login. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013h8jopY81BGQ4Pn22NfZTu --- .claude-plugin/marketplace.json | 2 +- README.md | 37 +- bin/mail.ts | 196 ++++++-- plugins/mail/.claude-plugin/plugin.json | 2 +- plugins/mail/README.md | 12 + plugins/mail/commands/login.md | 51 +++ src/mail.ts | 565 +++++++++++++++++++++--- src/prompt.ts | 18 + test/mail.test.ts | 211 ++++++++- 9 files changed, 999 insertions(+), 95 deletions(-) create mode 100644 plugins/mail/commands/login.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e2bbfbe..1cc2248 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -122,7 +122,7 @@ }, { "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.", + "description": "The inbox from the terminal, for more than one account: sign in to Gmail, Yahoo, iCloud, Fastmail, Zoho, Proton, Forward Email or any IMAP host with the right kind of password, then 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": { diff --git a/README.md b/README.md index d694bd0..996f72b 100644 --- a/README.md +++ b/README.md @@ -632,9 +632,12 @@ 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 providers # every host built in, and the password each wants +mail login gmail you@gmail.com # says "app password", checks IMAP + SMTP, stores it +mail login you@yourdomain.com # provider read off the domain's MX records +mail login forwardemail you@x.com --as work --default +mail login custom you@x.org --imap-host imap.x.org --smtp-host smtp.x.org --starttls +mail accounts pull # or import accounts from the cli-tools-mail vault mail ls # newest 25, default account mail ls -a all --unread # unread across every account @@ -652,14 +655,26 @@ mail rm 4213 # to Trash; --purge --yes to expun 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. +`MAIL__EMAIL`, `_PROVIDER` (a provider name below, or `custom`), +`_PASSWORD`, optional `_NAME`, `_USER`, `_IMAP_HOST`, `_IMAP_PORT`, `_IMAP_SECURE`, +`_SMTP_HOST`, `_SMTP_PORT`, `_SMTP_SECURE`, `_INSECURE_TLS`, 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. + +Fifteen providers are built in — Forward Email, Gmail / Google Workspace, Yahoo, +AOL, iCloud, Fastmail, Zoho, Proton (through Proton Mail Bridge on localhost), +GMX, Yandex, mail.com, Posteo, mailbox.org, Migadu and Purelymail — each with +its hosts, ports and the *kind* of password it takes, because that is what +stalls a first login: Gmail, Yahoo, AOL, iCloud, Fastmail and Yandex refuse the +account password over IMAP and want a generated app password, Forward Email +wants the per-alias one, Proton wants the one its Bridge shows. `mail login` +says which before asking, tries IMAP and SMTP, and stores nothing on a refusal. +The provider is read off a webmail address, or off a custom domain's MX records +(a domain hosted at Google, Zoho, Fastmail, Proton, iCloud or Forward Email +needs no `--provider`). Outlook.com and Microsoft 365 are listed as unreachable: +Microsoft takes only OAuth2 now, and app passwords no longer count; Tuta and HEY +have no IMAP at all. `custom` takes explicit hosts, with `--starttls`, +`--imap-starttls` and `--insecure-tls` for the odd server. 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 diff --git a/bin/mail.ts b/bin/mail.ts index 319dce5..5b8e147 100644 --- a/bin/mail.ts +++ b/bin/mail.ts @@ -26,6 +26,7 @@ import { resolveCredentials } from '../src/credentials.ts'; import { isMain } from '../src/is-main.ts'; import { type Account, + type AccountConfig, type Folder, type MailConfig, type Mailbox, @@ -34,7 +35,7 @@ import { type Transport, MAIL_VAULT_PROJECT, MailError, - PROVIDERS, + PROVIDER_NAMES, accountsFromVault, buildReply, chooseTransport, @@ -44,26 +45,38 @@ import { formatFolders, formatList, formatMessage, + formatProviders, fromHeader, guessProvider, + isProviderName, loadConfig, + loginHint, mailConfigPath, mergeVaultAccounts, openMailbox, parseQuery, passwordVariable, + providerFor, + providerFromMx, resolveAccount, saveConfig, selectAccount, selectAccounts, sendMail, + unsupportedProvider, + verifyAccount, } from '../src/mail.ts'; -import { confirm, promptSecret } from '../src/prompt.ts'; +import { confirm, promptLine, promptSecret } from '../src/prompt.ts'; import { pullVault, vaultTarget } from '../src/vault.ts'; const USAGE = `Usage: + mail login [email] [--as NAME] sign in to a provider: says which password it wants, + checks IMAP and SMTP, then stores the account + mail login the same, with the provider read off the address + mail providers every provider built in, and what each wants + mail accounts the configured accounts - mail accounts add [options] add or update one (prompts for the password) + mail accounts add [options] add or update one by hand (prompts for the password) mail accounts password store or replace a password mail accounts default which account a bare command means mail accounts rm @@ -102,10 +115,20 @@ Options: --yes rm --purge: skip the confirmation -h, --help show this help +Options for \`login\`: + --as NAME the account name to store it under (default: the provider's name) + --default make it the account a bare command means + --no-verify store without trying the IMAP and SMTP logins first + --name, --user, and the host options below, for \`custom\` + Account options for \`accounts add\`: - --provider forwardemail|gmail|custom (gmail is inferred from the address) + --provider NAME one of: ${PROVIDER_NAMES.join(', ')}, custom + (inferred from the address when it is a known webmail domain) --name "Display Name" --user LOGIN - --imap-host H --imap-port N --smtp-host H --smtp-port N --starttls + --imap-host H --imap-port N --smtp-host H --smtp-port N + --starttls SMTP upgrades with STARTTLS (587) instead of TLS on connect (465) + --imap-starttls IMAP upgrades with STARTTLS (143) instead of TLS on connect (993) + --insecure-tls accept a certificate no CA signed (a bridge on localhost, never a real host) --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 @@ -208,6 +231,118 @@ async function fileCopy( }); } +/** The host and TLS flags shared by `login custom` and `accounts add`. */ +function applyHostOptions(account: AccountConfig, parsed: ReturnType): void { + 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 (parsed.flags.has('--imap-starttls')) account.imapSecure = false; + if (parsed.flags.has('--insecure-tls')) account.insecureTls = true; +} + +/** + * `mail login [address]`. + * + * The provider comes first because it decides what the password prompt + * says: Gmail and Yahoo refuse the account password and their error reads + * like a typo, so the kind of password is announced before it is asked for. + * Both logins are tried before anything is stored — a stored password that + * does not work is worse than none, because every later command fails + * somewhere further away from the cause. + */ +async function loginVerb(config: MailConfig, args: string[], parsed: ReturnType): Promise { + const [first, second] = args; + if (!first) throw new UsageError('login needs a provider or an address: `mail login gmail you@gmail.com`'); + + let provider: ProviderName; + let email: string | undefined; + if (first.includes('@')) { + email = first; + const blocked = unsupportedProvider(email); + if (blocked) fail(`${blocked.label} cannot be reached with a password: ${blocked.reason}`, 1); + const guessed = guessProvider(email); + if (guessed) provider = guessed; + else if (parsed.values.has('--imap-host')) provider = 'custom'; + else { + const mx = await providerFromMx(email); + if (mx && 'unsupported' in mx) { + fail(`${email} is hosted at ${mx.unsupported.label}, which cannot be reached with a password: ${mx.unsupported.reason}`, 1); + } + if (mx) { + provider = mx.provider; + process.stderr.write(`${email} is hosted at ${providerFor(provider)!.label} (from its MX records)\n`); + } else { + fail( + `"${email}" is not on a domain that names its provider, and its MX records do not either. Say which: ` + + `\`mail login ${email}\` with one of ${PROVIDER_NAMES.join(', ')}, ` + + `or \`mail login custom ${email} --imap-host … --smtp-host …\`. \`mail providers\` lists them.`, + 1, + ); + } + } + } else { + const requested = first.toLowerCase(); + if (isProviderName(requested)) provider = requested; + else { + const blocked = unsupportedProvider(requested); + if (blocked) fail(`${blocked.label} cannot be reached with a password: ${blocked.reason}`, 1); + fail(`no provider "${first}". Built in: ${PROVIDER_NAMES.join(', ')}, custom — \`mail providers\` for details.`, 1); + } + email = second; + } + + if (!email) { + if (!process.stdin.isTTY) throw new UsageError(`login needs the address: \`mail login ${provider} you@example.com\``); + email = await promptLine('address: '); + } + if (!email.includes('@')) throw new UsageError(`"${email}" is not an address`); + email = email.toLowerCase(); + + const name = (parsed.values.get('--as') ?? provider).toLowerCase(); + if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) { + throw new UsageError('an account name is letters, digits, - and _ — it becomes MAIL__PASSWORD'); + } + + const existing = config.accounts[name]; + const account: AccountConfig = { ...(existing ?? {}), email, provider }; + applyHostOptions(account, parsed); + if (provider === 'custom' && (!account.imapHost || !account.smtpHost)) { + throw new UsageError('`login custom` needs --imap-host and --smtp-host'); + } + + const preset = providerFor(provider); + if (preset) process.stderr.write(`${loginHint(preset)}\n`); + const password = await promptSecret(`password for ${email}: `); + if (!password) fail('empty — nothing stored', 1); + account.password = password; + + if (!parsed.flags.has('--no-verify')) { + const resolved = resolveAccount(name, account, {}); + process.stderr.write(`checking ${resolved.imap.host} and ${resolved.smtp.host}…\n`); + const result = await verifyAccount(resolved); + if (result.imap) fail(`${result.imap}\nNothing stored. --no-verify stores it anyway.`, 1); + if (result.smtp) { + process.stderr.write(`warning: reading works but sending does not — ${result.smtp}\n`); + } + } + + config.accounts[name] = account; + if (parsed.flags.has('--default') || Object.keys(config.accounts).length === 1) config.default = name; + const path = saveConfig(config); + out(`${existing ? 'updated' : 'logged in'}: ${name} (${email}, ${provider}) in ${path}`); + if (config.default === name) out(`${name} is the default account`); + else out(`\`mail ls -a ${name}\` reads it; \`mail accounts default ${name}\` makes it the default`); + return 0; +} + async function accountsVerb(config: MailConfig, args: string[], parsed: ReturnType): Promise { const [verb, ...rest] = args; const isJson = parsed.flags.has('--json'); @@ -226,8 +361,9 @@ async function accountsVerb(config: MailConfig, args: string[], parsed: ReturnTy password: null, passwordSource: 'unset' as const, provider: entry.provider, - imap: { host: entry.imapHost ?? '?', port: entry.imapPort ?? 993 }, + imap: { host: entry.imapHost ?? '?', port: entry.imapPort ?? 993, secure: true }, smtp: { host: entry.smtpHost ?? '?', port: entry.smtpPort ?? 465, secure: true }, + insecureTls: false, }; } }); @@ -253,26 +389,27 @@ async function accountsVerb(config: MailConfig, args: string[], parsed: ReturnTy 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') { + const blocked = unsupportedProvider(email); + if (blocked && !parsed.values.has('--imap-host')) { + fail(`${blocked.label} cannot be reached with a password: ${blocked.reason}`, 1); + } + const mx = guessProvider(email) || parsed.values.has('--imap-host') ? null : await providerFromMx(email); + if (mx && 'unsupported' in mx) { + fail(`${email} is hosted at ${mx.unsupported.label}, which cannot be reached with a password: ${mx.unsupported.reason}`, 1); + } + provider = + guessProvider(email) ?? (mx ? mx.provider : parsed.values.has('--imap-host') ? 'custom' : 'forwardemail'); + } else if (isProviderName(requested)) { provider = requested; } else { - throw new UsageError(`--provider must be forwardemail, gmail or custom, got "${requested}"`); + const blocked = unsupportedProvider(requested); + if (blocked) fail(`${blocked.label} cannot be reached with a password: ${blocked.reason}`, 1); + throw new UsageError(`--provider must be one of ${PROVIDER_NAMES.join(', ')} 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; + applyHostOptions(account, parsed); if (provider === 'custom' && (!account.imapHost || !account.smtpHost)) { throw new UsageError('a custom provider needs --imap-host and --smtp-host'); @@ -281,7 +418,8 @@ async function accountsVerb(config: MailConfig, args: string[], parsed: ReturnTy const wantsPrompt = parsed.flags.has('--password') || (!parsed.flags.has('--no-password') && process.stdin.isTTY); if (wantsPrompt) { - const hint = provider === 'custom' ? '' : `\n (${PROVIDERS[provider].passwordHint})`; + const preset = providerFor(provider); + const hint = preset ? `\n (${preset.passwordHint})` : ''; process.stderr.write(`Password for ${email}${hint}\n`); const password = await promptSecret('password: '); if (password) account.password = password; @@ -307,9 +445,8 @@ async function accountsVerb(config: MailConfig, args: string[], parsed: ReturnTy 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 preset = providerFor(account.provider); + if (preset) process.stderr.write(`(${preset.passwordHint})\n`); const password = await promptSecret(`password for ${account.email}: `); if (!password) fail('empty — nothing stored', 1); account.password = password; @@ -387,12 +524,12 @@ async function main(argv: string[]): Promise { boolean: [ '--json', '--unread', '--gmail', '--keep-unread', '--raw', '--all', '--no-quote', '--draft', '--purge', '--yes', '--read', '--flag', '--unflag', '--password', '--no-password', '--default', - '--starttls', '-h', '--help', + '--starttls', '--imap-starttls', '--insecure-tls', '--no-verify', '-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', + '--smtp-host', '--smtp-port', '--as', ], }); @@ -426,6 +563,13 @@ async function main(argv: string[]): Promise { case 'account': return accountsVerb(config, rest, parsed); + case 'login': + return loginVerb(config, rest, parsed); + + case 'providers': + out(formatProviders()); + return 0; + case 'folders': { const account = selectAccount(config, selector); const folders = await withMailbox(account, (box) => box.folders()); diff --git a/plugins/mail/.claude-plugin/plugin.json b/plugins/mail/.claude-plugin/plugin.json index 8c94b73..a5988bd 100644 --- a/plugins/mail/.claude-plugin/plugin.json +++ b/plugins/mail/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$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.", + "description": "The inbox from the terminal, for more than one account: sign in to Gmail, Yahoo, iCloud, Fastmail, Zoho, Proton, Forward Email or any IMAP host with the right kind of password, then 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", diff --git a/plugins/mail/README.md b/plugins/mail/README.md index 1b12d86..288b5ae 100644 --- a/plugins/mail/README.md +++ b/plugins/mail/README.md @@ -2,6 +2,10 @@ The inbox from the terminal, for more than one account. +`/mail:login` signs in to a provider — Gmail, Yahoo, AOL, iCloud, Fastmail, +Zoho, Proton (through its Bridge), GMX, Yandex, mail.com, Posteo, mailbox.org, +Migadu, Purelymail, Forward Email, or any IMAP host by name — saying which +kind of password it wants and checking both logins before storing anything. `/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. @@ -23,6 +27,14 @@ cli-tools config pull # the Resend key, for the fallback ## The thing worth knowing +**The password is the whole problem.** Gmail, Yahoo, AOL, iCloud, Fastmail and +Yandex refuse the account password over IMAP on purpose and want a generated +app password, and their refusal reads like a typo. `mail login ` +says which kind of password the host wants, and where to make one, *before* +asking for it, then tries IMAP and SMTP and stores nothing on a refusal. +Outlook.com and Microsoft 365 take only OAuth2 now, and Tuta and HEY have no +IMAP at all; `mail login` says so rather than failing. + **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 diff --git a/plugins/mail/commands/login.md b/plugins/mail/commands/login.md new file mode 100644 index 0000000..befd91b --- /dev/null +++ b/plugins/mail/commands/login.md @@ -0,0 +1,51 @@ +--- +description: Sign in to a mail provider — Gmail, Yahoo, iCloud, Fastmail, Zoho, Proton, Forward Email and more — with the right kind of password, verified before it is stored. +allowed-tools: Bash(mail:*), Read +--- + +## Task + +Add a mailbox to `mail`. Name the provider, or just the address when its +domain gives the provider away (a known webmail domain, or a custom domain +whose MX records point at a known host). + +```bash +mail providers # everything built in, and what each wants +mail login gmail you@gmail.com # says "app password", checks IMAP + SMTP, stores it +mail login you@yahoo.com # provider read off the address +mail login you@yourdomain.com # provider read off the domain's MX records +mail login forwardemail you@yourdomain.com --as work --default +mail login proton you@proton.me # through Proton Mail Bridge on localhost +mail login custom you@example.org --imap-host imap.example.org --smtp-host smtp.example.org --starttls +``` + +`$ARGUMENTS` is passed through: `/mail:login gmail you@gmail.com --as home`. + +## What happens + +1. The provider's password rule is printed first. Gmail, Yahoo, AOL, iCloud, + Fastmail, Yandex and (with two-factor on) Zoho refuse the account password + over IMAP and want a generated app password; the message says where to make + one. Forward Email wants the per-alias password; Proton wants the password + its Bridge shows. The rest take the account password. +2. The password is read without echo (or from stdin when piped). +3. IMAP and SMTP logins are both tried. A failed IMAP login stores nothing and + names the likely cause. A failed SMTP login stores the account with a + warning, because reading still works. +4. The account lands in `~/.config/cli-tools/mail.json` (0600) under the + provider's name, or `--as NAME`. The first account becomes the default; + `--default` makes a later one the default. + +## Not reachable with a password + +Outlook.com / Hotmail / Live and Microsoft 365 take only OAuth2 since +Microsoft removed basic authentication; app passwords no longer count. Tuta +and HEY have no IMAP or SMTP at all. `mail login outlook` says so instead of +failing a login. + +## When asked to set someone up + +Run `mail providers` and read the row for their host before asking for +anything: the answer to "which password" is there, and it is the question +that stalls every first login. Never ask for a password in chat — have them +run `mail login` themselves, or pipe it from a file they control. diff --git a/src/mail.ts b/src/mail.ts index f4d6438..65c51a4 100644 --- a/src/mail.ts +++ b/src/mail.ts @@ -26,6 +26,7 @@ * {@link Mailbox} interface so tests never open a socket. */ +import { resolveMx } from 'node:dns/promises'; import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -46,63 +47,332 @@ export class MailError extends Error { // Providers // --------------------------------------------------------------------------- -export type ProviderName = 'forwardemail' | 'gmail' | 'custom'; +/** + * What a provider accepts as the password over IMAP and SMTP. + * + * `account`: the same password as the website. `app`: a separate, generated + * app password, because the account password is refused on purpose. `alias`: + * a password generated per address in the provider's dashboard. `bridge`: a + * local bridge process speaks IMAP/SMTP on localhost and mints its own. + */ +export type PasswordKind = 'account' | 'app' | 'alias' | 'bridge'; export interface Provider { + /** The name people use for it. */ + label: string; imapHost: string; imapPort: number; + /** Implicit TLS on connect (993), as opposed to STARTTLS (143). */ + imapSecure: boolean; smtpHost: string; smtpPort: number; /** Implicit TLS on connect (465), as opposed to STARTTLS (587). */ smtpSecure: boolean; + /** Accept a certificate no CA signed. Only for a bridge on localhost. */ + insecureTls?: boolean; + passwordKind: PasswordKind; /** Where the password comes from, for the setup message. */ passwordHint: string; + /** Where to generate it, when there is a page for that. */ + passwordUrl?: string; + /** Address domains that imply this provider. */ + domains: string[]; + /** Something to know before the first login. */ + note?: string; } +export type BuiltInProvider = + | 'forwardemail' + | 'gmail' + | 'yahoo' + | 'aol' + | 'icloud' + | 'fastmail' + | 'zoho' + | 'proton' + | 'gmx' + | 'yandex' + | 'mailcom' + | 'posteo' + | 'mailbox' + | 'migadu' + | 'purelymail'; + +export type ProviderName = BuiltInProvider | 'custom'; + /** - * Hosts for the providers these mailboxes actually use. + * Every host that still takes a password over IMAP and SMTP, with the ports + * and the kind of password it wants. A provider whose only way in is OAuth or + * its own app is in UNSUPPORTED_PROVIDERS instead, with the reason, so + * `mail login outlook` explains itself rather than failing a login. * - * 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. + * `custom` exists so any other host is a matter of naming its hosts rather + * than editing this file. */ -export const PROVIDERS: Record, Provider> = { +export const PROVIDERS: Record = { forwardemail: { + label: 'Forward Email', imapHost: 'imap.forwardemail.net', imapPort: 993, + imapSecure: true, smtpHost: 'smtp.forwardemail.net', smtpPort: 465, smtpSecure: true, + passwordKind: 'alias', passwordHint: 'the alias password generated in the Forward Email dashboard (Aliases → the address → ' + 'Generate Password); it is shown once', + passwordUrl: 'https://forwardemail.net/my-account/domains', + domains: ['forwardemail.net'], + note: 'The address must have IMAP/SMTP storage enabled on its alias; forwarding-only aliases have no mailbox.', }, gmail: { + label: 'Gmail / Google Workspace', imapHost: 'imap.gmail.com', imapPort: 993, + imapSecure: true, smtpHost: 'smtp.gmail.com', smtpPort: 465, smtpSecure: true, + passwordKind: 'app', passwordHint: 'an App Password from https://myaccount.google.com/apppasswords (needs 2-step ' + 'verification on the account); the normal account password is refused', + passwordUrl: 'https://myaccount.google.com/apppasswords', + domains: ['gmail.com', 'googlemail.com'], + note: 'A Workspace domain works the same way once its admin has left IMAP on.', + }, + yahoo: { + label: 'Yahoo Mail', + imapHost: 'imap.mail.yahoo.com', + imapPort: 993, + imapSecure: true, + smtpHost: 'smtp.mail.yahoo.com', + smtpPort: 465, + smtpSecure: true, + passwordKind: 'app', + passwordHint: + 'an app password from Account Security → Generate app password; the account password is refused', + passwordUrl: 'https://login.yahoo.com/account/security', + domains: ['yahoo.com', 'yahoo.co.uk', 'yahoo.ca', 'yahoo.com.au', 'yahoo.fr', 'yahoo.de', 'ymail.com', 'rocketmail.com'], + }, + aol: { + label: 'AOL Mail', + imapHost: 'imap.aol.com', + imapPort: 993, + imapSecure: true, + smtpHost: 'smtp.aol.com', + smtpPort: 465, + smtpSecure: true, + passwordKind: 'app', + passwordHint: 'an app password from Account Security → Generate app password', + passwordUrl: 'https://login.aol.com/account/security', + domains: ['aol.com', 'aim.com'], + }, + icloud: { + label: 'iCloud Mail', + imapHost: 'imap.mail.me.com', + imapPort: 993, + imapSecure: true, + smtpHost: 'smtp.mail.me.com', + smtpPort: 587, + smtpSecure: false, + passwordKind: 'app', + passwordHint: + 'an app-specific password from the Apple Account page (Sign-In and Security → App-Specific ' + + 'Passwords); needs two-factor authentication on the Apple Account', + passwordUrl: 'https://account.apple.com/account/manage', + domains: ['icloud.com', 'me.com', 'mac.com'], + note: 'Log in with the full address, including a custom iCloud domain.', + }, + fastmail: { + label: 'Fastmail', + imapHost: 'imap.fastmail.com', + imapPort: 993, + imapSecure: true, + smtpHost: 'smtp.fastmail.com', + smtpPort: 465, + smtpSecure: true, + passwordKind: 'app', + passwordHint: + 'an app password from Settings → Privacy & Security → Integrations → New app password; ' + + 'the account password is refused by third-party clients', + passwordUrl: 'https://app.fastmail.com/settings/security/devices', + domains: ['fastmail.com', 'fastmail.fm', 'fastmail.us', 'sent.com'], + }, + zoho: { + label: 'Zoho Mail', + imapHost: 'imap.zoho.com', + imapPort: 993, + imapSecure: true, + smtpHost: 'smtp.zoho.com', + smtpPort: 465, + smtpSecure: true, + passwordKind: 'app', + passwordHint: + 'the account password, or an application-specific password when two-factor authentication ' + + 'is on (Zoho Accounts → Security → App Passwords)', + passwordUrl: 'https://accounts.zoho.com/home#security/security_password', + domains: ['zoho.com', 'zohomail.com', 'zoho.eu', 'zoho.in'], + note: + 'IMAP has to be switched on first (Zoho Mail → Settings → Mail Accounts → IMAP Access). ' + + 'An EU or IN data centre uses imap.zoho.eu / smtp.zoho.eu or .in — pass --imap-host and --smtp-host.', + }, + proton: { + label: 'Proton Mail (through Proton Mail Bridge)', + imapHost: '127.0.0.1', + imapPort: 1143, + imapSecure: false, + smtpHost: '127.0.0.1', + smtpPort: 1025, + smtpSecure: false, + insecureTls: true, + passwordKind: 'bridge', + passwordHint: + 'the password Proton Mail Bridge shows for the account (Bridge → the account → Mailbox ' + + 'configuration), not the Proton password', + passwordUrl: 'https://proton.me/mail/bridge', + domains: ['proton.me', 'protonmail.com', 'protonmail.ch', 'pm.me'], + note: + 'Proton has no IMAP of its own: the Bridge app must be installed, signed in and running on this ' + + 'machine, and it needs a paid Proton plan. It serves STARTTLS on localhost with a self-signed certificate.', + }, + gmx: { + label: 'GMX', + imapHost: 'imap.gmx.com', + imapPort: 993, + imapSecure: true, + smtpHost: 'mail.gmx.com', + smtpPort: 465, + smtpSecure: true, + passwordKind: 'account', + passwordHint: 'the account password, once IMAP is enabled (Settings → POP3/IMAP)', + domains: ['gmx.com', 'gmx.us', 'gmx.net', 'gmx.de', 'gmx.at', 'gmx.ch'], + note: 'A gmx.net / .de / .at / .ch address may prefer imap.gmx.net and mail.gmx.net — pass --imap-host and --smtp-host.', + }, + yandex: { + label: 'Yandex Mail', + imapHost: 'imap.yandex.com', + imapPort: 993, + imapSecure: true, + smtpHost: 'smtp.yandex.com', + smtpPort: 465, + smtpSecure: true, + passwordKind: 'app', + passwordHint: 'an app password from Yandex ID → Security → App passwords', + passwordUrl: 'https://id.yandex.com/security/app-passwords', + domains: ['yandex.com', 'yandex.ru', 'ya.ru'], + }, + mailcom: { + label: 'mail.com', + imapHost: 'imap.mail.com', + imapPort: 993, + imapSecure: true, + smtpHost: 'smtp.mail.com', + smtpPort: 465, + smtpSecure: true, + passwordKind: 'account', + passwordHint: 'the account password, once IMAP is enabled (Settings → POP3/IMAP)', + domains: ['mail.com', 'email.com', 'usa.com', 'consultant.com', 'engineer.com', 'post.com'], + }, + posteo: { + label: 'Posteo', + imapHost: 'posteo.de', + imapPort: 993, + imapSecure: true, + smtpHost: 'posteo.de', + smtpPort: 465, + smtpSecure: true, + passwordKind: 'account', + passwordHint: 'the account password', + domains: ['posteo.de', 'posteo.net', 'posteo.eu', 'posteo.org'], + }, + mailbox: { + label: 'mailbox.org', + imapHost: 'imap.mailbox.org', + imapPort: 993, + imapSecure: true, + smtpHost: 'smtp.mailbox.org', + smtpPort: 465, + smtpSecure: true, + passwordKind: 'account', + passwordHint: 'the account password, or an app password when two-factor authentication is on', + domains: ['mailbox.org'], + }, + migadu: { + label: 'Migadu', + imapHost: 'imap.migadu.com', + imapPort: 993, + imapSecure: true, + smtpHost: 'smtp.migadu.com', + smtpPort: 465, + smtpSecure: true, + passwordKind: 'account', + passwordHint: 'the mailbox password set in the Migadu admin', + domains: [], + }, + purelymail: { + label: 'Purelymail', + imapHost: 'mailserver.purelymail.com', + imapPort: 993, + imapSecure: true, + smtpHost: 'mailserver.purelymail.com', + smtpPort: 465, + smtpSecure: true, + passwordKind: 'account', + passwordHint: 'the account password', + domains: ['purelymail.com'], + }, +}; + +export const PROVIDER_NAMES = Object.keys(PROVIDERS) as BuiltInProvider[]; + +export function isProviderName(value: unknown): value is ProviderName { + return value === 'custom' || (typeof value === 'string' && Object.hasOwn(PROVIDERS, value)); +} + +/** The preset for a provider, or null for `custom`. */ +export function providerFor(name: ProviderName): Provider | null { + return name === 'custom' ? null : PROVIDERS[name]; +} + +export interface UnsupportedProvider { + label: string; + domains: string[]; + reason: string; +} + +/** + * Hosts a password cannot reach. Named so `mail login ` and an address + * on one of their domains get the reason instead of a login failure. + */ +export const UNSUPPORTED_PROVIDERS: Record = { + outlook: { + label: 'Outlook.com / Hotmail / Live', + domains: ['outlook.com', 'hotmail.com', 'live.com', 'msn.com'], + reason: + 'Microsoft removed password (basic) authentication — for Microsoft 365 IMAP in October 2022 and for ' + + 'personal accounts on 2024-09-16: IMAP and SMTP now take only OAuth2 tokens, and app passwords no ' + + 'longer count. Use Outlook or a client with Microsoft sign-in.', + }, + tuta: { + label: 'Tuta (Tutanota)', + domains: ['tuta.com', 'tuta.io', 'tutanota.com', 'tutanota.de', 'tutamail.com', 'keemail.me'], + reason: "no IMAP or SMTP at all; the mailbox is only reachable through Tuta's own apps", + }, + hey: { + label: 'HEY', + domains: ['hey.com'], + reason: 'no IMAP or SMTP; HEY only offers its own apps', }, }; /** 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', + ...Object.values(PROVIDERS).flatMap((provider) => provider.domains), + ...Object.values(UNSUPPORTED_PROVIDERS).flatMap((provider) => provider.domains), ]); +WEBMAIL_DOMAINS.delete('forwardemail.net'); export function domainOf(email: string): string { const at = email.lastIndexOf('@'); @@ -110,12 +380,121 @@ export function domainOf(email: string): string { } /** The provider an address implies, when it implies one. */ -export function guessProvider(email: string): ProviderName | null { +export function guessProvider(email: string): BuiltInProvider | null { + const domain = domainOf(email); + for (const name of PROVIDER_NAMES) { + if (PROVIDERS[name].domains.includes(domain)) return name; + } + return null; +} + +/** The unsupported host an address or a name points at, when it does. */ +export function unsupportedProvider(nameOrEmail: string): (UnsupportedProvider & { name: string }) | null { + const key = nameOrEmail.toLowerCase(); + const domain = domainOf(key); + for (const [name, provider] of Object.entries(UNSUPPORTED_PROVIDERS)) { + if (name === key || (domain && provider.domains.includes(domain))) return { name, ...provider }; + } + return null; +} + +/** MX host suffixes that give away the provider behind a custom domain. */ +const MX_SIGNATURES: [suffix: string, provider: BuiltInProvider][] = [ + ['forwardemail.net', 'forwardemail'], + ['google.com', 'gmail'], + ['googlemail.com', 'gmail'], + ['zoho.com', 'zoho'], + ['zoho.eu', 'zoho'], + ['zoho.in', 'zoho'], + ['messagingengine.com', 'fastmail'], + ['protonmail.ch', 'proton'], + ['icloud.com', 'icloud'], + ['migadu.com', 'migadu'], + ['purelymail.com', 'purelymail'], + ['mailbox.org', 'mailbox'], + ['posteo.de', 'posteo'], + ['yandex.net', 'yandex'], +]; + +/** MX suffixes of hosts a password cannot reach; the value names UNSUPPORTED_PROVIDERS. */ +const MX_UNSUPPORTED: [suffix: string, name: string][] = [['protection.outlook.com', 'outlook'], ['outlook.com', 'outlook']]; + +export type MxResolver = (domain: string) => Promise<{ exchange: string; priority: number }[]>; + +export type MxGuess = { provider: BuiltInProvider } | { unsupported: UnsupportedProvider & { name: string } }; + +/** + * The provider a custom domain's MX records point at, when they point at one + * we know. A domain hosted at Google, Zoho, Fastmail, Proton, iCloud, + * Forward Email and the like has its own name on the address and the host's + * name in DNS; this reads the second so `mail login you@yourdomain.com` + * needs no provider spelled out. A lookup that fails is simply no answer. + */ +export async function providerFromMx(email: string, resolve: MxResolver = resolveMx): Promise { const domain = domainOf(email); - if (domain === 'gmail.com' || domain === 'googlemail.com') return 'gmail'; + if (!domain) return null; + let records: { exchange: string; priority: number }[]; + try { + records = await resolve(domain); + } catch { + return null; + } + const hosts = [...records] + .sort((a, b) => a.priority - b.priority) + .map((record) => record.exchange.toLowerCase().replace(/\.$/, '')); + const matches = (host: string, suffix: string) => host === suffix || host.endsWith(`.${suffix}`); + for (const host of hosts) { + for (const [suffix, provider] of MX_SIGNATURES) if (matches(host, suffix)) return { provider }; + for (const [suffix, name] of MX_UNSUPPORTED) { + if (matches(host, suffix)) return { unsupported: { name, ...UNSUPPORTED_PROVIDERS[name]! } }; + } + } return null; } +/** The lines to show before asking for a provider's password. */ +export function loginHint(provider: Provider): string { + const kind: Record = { + account: `${provider.label} takes the account password.`, + app: `${provider.label} takes an app password, not the account password.`, + alias: `${provider.label} takes a password generated per address.`, + bridge: `${provider.label} takes the password its local bridge generates.`, + }; + const lines = [kind[provider.passwordKind], ` ${provider.passwordHint}`]; + if (provider.passwordUrl) lines.push(` ${provider.passwordUrl}`); + if (provider.note) lines.push(` ${provider.note}`); + return lines.join('\n'); +} + +/** The providers table for `mail providers`. */ +export function formatProviders(): string { + const kinds: Record = { + account: 'account password', + app: 'app password', + alias: 'per-address password', + bridge: 'bridge password', + }; + const width = Math.max(...PROVIDER_NAMES.map((name) => name.length), 'custom'.length); + const rows = PROVIDER_NAMES.map((name) => { + const provider = PROVIDERS[name]; + const imap = `${provider.imapHost}:${provider.imapPort}${provider.imapSecure ? '' : ' (STARTTLS)'}`; + const smtp = `${provider.smtpHost}:${provider.smtpPort}${provider.smtpSecure ? '' : ' (STARTTLS)'}`; + return ( + ` ${name.padEnd(width)} ${provider.label}\n` + + `${' '.repeat(width + 4)}${kinds[provider.passwordKind]}; imap ${imap}; smtp ${smtp}` + ); + }); + rows.push( + ` ${'custom'.padEnd(width)} any other host: --imap-host H --smtp-host H [--imap-port N --smtp-port N --starttls --imap-starttls]`, + ); + const unsupported = Object.entries(UNSUPPORTED_PROVIDERS).map( + ([name, provider]) => ` ${name.padEnd(width)} ${provider.label}: ${provider.reason}`, + ); + return ['Providers (`mail login
`):', ...rows, '', 'Not reachable with a password:', ...unsupported].join( + '\n', + ); +} + // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- @@ -130,9 +509,12 @@ export interface AccountConfig { password?: string; imapHost?: string; imapPort?: number; + imapSecure?: boolean; smtpHost?: string; smtpPort?: number; smtpSecure?: boolean; + /** Accept a certificate no CA signed — a local bridge, never a real host. */ + insecureTls?: boolean; } export interface MailConfig { @@ -151,8 +533,9 @@ export interface Account { password: string | null; passwordSource: PasswordSource; provider: ProviderName; - imap: { host: string; port: number }; + imap: { host: string; port: number; secure: boolean }; smtp: { host: string; port: number; secure: boolean }; + insecureTls: boolean; } function xdgConfigHome(env: NodeJS.ProcessEnv): string { @@ -201,10 +584,7 @@ export function normalizeConfig(parsed: unknown): MailConfig { const provider = entry.provider; const account: AccountConfig = { email: entry.email.trim().toLowerCase(), - provider: - provider === 'forwardemail' || provider === 'gmail' || provider === 'custom' - ? provider - : (guessProvider(entry.email) ?? 'custom'), + provider: isProviderName(provider) ? provider : (guessProvider(entry.email) ?? 'custom'), }; for (const key of ['name', 'user', 'password', 'imapHost', 'smtpHost'] as const) { const value = entry[key]; @@ -214,7 +594,10 @@ export function normalizeConfig(parsed: unknown): MailConfig { 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; + for (const key of ['imapSecure', 'smtpSecure', 'insecureTls'] as const) { + const value = entry[key]; + if (typeof value === 'boolean') account[key] = value; + } config.accounts[name] = account; } return config; @@ -235,13 +618,13 @@ export function resolveAccount( config: AccountConfig, env: NodeJS.ProcessEnv = process.env, ): Account { - const preset = config.provider === 'custom' ? null : PROVIDERS[config.provider]; + const preset = providerFor(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 …\``, + `set them with \`mail login custom ${config.email} --imap-host … --smtp-host …\``, ); } @@ -249,6 +632,7 @@ export function resolveAccount( const password = fromEnv || config.password || null; const passwordSource: PasswordSource = fromEnv ? 'env' : config.password ? 'file' : 'unset'; + const imapPort = config.imapPort ?? preset?.imapPort ?? 993; const smtpPort = config.smtpPort ?? preset?.smtpPort ?? 465; return { name, @@ -258,13 +642,19 @@ export function resolveAccount( password, passwordSource, provider: config.provider, - imap: { host: imapHost, port: config.imapPort ?? preset?.imapPort ?? 993 }, + imap: { + host: imapHost, + port: imapPort, + // 993 is implicit TLS everywhere; anything else is STARTTLS unless told. + secure: config.imapSecure ?? (preset ? preset.imapSecure : 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), }, + insecureTls: config.insecureTls ?? preset?.insecureTls ?? false, }; } @@ -351,7 +741,8 @@ export const MAIL_VAULT_PROJECT = 'cli-tools-mail'; */ 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 pattern = + /^MAIL_([A-Z0-9_]+?)_(EMAIL|PROVIDER|PASSWORD|NAME|USER|IMAP_HOST|IMAP_PORT|IMAP_SECURE|SMTP_HOST|SMTP_PORT|SMTP_SECURE|INSECURE_TLS)$/; const partial: Record> = {}; for (const [key, value] of Object.entries(vault)) { @@ -371,10 +762,9 @@ export function accountsFromVault(vault: Record): MailConfig { 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')), + provider: isProviderName(provider) + ? provider + : (guessProvider(email) ?? (fields.IMAP_HOST ? 'custom' : 'forwardemail')), }; if (fields.PASSWORD) account.password = fields.PASSWORD; if (fields.NAME) account.name = fields.NAME; @@ -383,7 +773,9 @@ export function accountsFromVault(vault: Record): MailConfig { 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.IMAP_SECURE) account.imapSecure = /^(true|1|yes)$/i.test(fields.IMAP_SECURE); if (fields.SMTP_SECURE) account.smtpSecure = /^(true|1|yes)$/i.test(fields.SMTP_SECURE); + if (fields.INSECURE_TLS) account.insecureTls = /^(true|1|yes)$/i.test(fields.INSECURE_TLS); config.accounts[name] = account; } @@ -666,12 +1058,87 @@ export function parseQuery(input: string): SearchObject { const SUMMARY_FIELDS = { uid: true, flags: true, envelope: true, size: true, internalDate: true } as const; /** Open the account's mailbox over IMAP. */ +/** An IMAP client for an account, not yet connected. */ +export function imapClient(account: Account, options: { logger?: boolean } = {}): ImapFlow { + return new ImapFlow({ + host: account.imap.host, + port: account.imap.port, + secure: account.imap.secure, + auth: { user: account.user, pass: account.password ?? '' }, + ...(account.insecureTls ? { tls: { rejectUnauthorized: false } } : {}), + // 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, + }); +} + +/** + * A login failure, with the one thing the provider is known to refuse. + * + * Every app-password provider rejects the account password with a message + * that reads like a typo; naming the real fix saves the second attempt. + */ +export function loginFailure(account: Account, protocol: 'IMAP' | 'SMTP', reason: string): string { + const host = protocol === 'IMAP' ? account.imap.host : account.smtp.host; + const preset = providerFor(account.provider); + let advice = ''; + if (preset?.passwordKind === 'app') { + advice = `\n${preset.label} refuses the account password over ${protocol}; use an app password` + + (preset.passwordUrl ? ` (${preset.passwordUrl})` : '') + '.'; + } else if (preset?.passwordKind === 'bridge') { + advice = `\n${preset.label}: is the bridge running on this machine, and is this the password it generated?`; + } else if (preset?.passwordKind === 'alias') { + advice = `\n${preset.label}: ${preset.passwordHint}.`; + } + return `${protocol} login to ${host} as ${account.user} failed: ${reason}${advice}`; +} + +export interface VerifyResult { + /** null when the login worked, otherwise why it did not. */ + imap: string | null; + smtp: string | null; +} + +export interface LoginProbes { + imap: (account: Account) => Promise; + smtp: (account: Account) => Promise; +} + +export const defaultProbes: LoginProbes = { + async imap(account) { + const client = imapClient(account); + await client.connect(); + await client.logout(); + }, + async smtp(account) { + await smtpTransport(account).verify(); + }, +}; + +/** Try both logins and say which worked. Never throws for a refusal. */ +export async function verifyAccount(account: Account, probes: LoginProbes = defaultProbes): Promise { + const attempt = async (protocol: 'IMAP' | 'SMTP', probe: (account: Account) => Promise) => { + try { + await probe(account); + return null; + } catch (error) { + return loginFailure(account, protocol, (error as Error).message); + } + }; + const [imap, smtp] = await Promise.all([attempt('IMAP', probes.imap), attempt('SMTP', probes.smtp)]); + return { imap, smtp }; +} + export async function openMailbox( account: Account, options: { logger?: boolean } = {}, ): Promise { if (!account.password) { - const hint = account.provider === 'custom' ? '' : ` — ${PROVIDERS[account.provider].passwordHint}`; + const preset = providerFor(account.provider); + const hint = preset ? ` — ${preset.passwordHint}` : ''; throw new MailError( `account "${account.name}" has no password${hint}.\n` + `Store it with \`mail accounts password ${account.name}\`, export ${passwordVariable(account.name)}, ` + @@ -679,28 +1146,12 @@ export async function openMailbox( ); } - 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, - }); + const client = imapClient(account, options); 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.' - : ''), - ); + throw new MailError(loginFailure(account, 'IMAP', (error as Error).message)); } async function withFolder(folder: string, work: () => Promise): Promise { @@ -1011,15 +1462,21 @@ export interface SendResult { 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({ +/** The SMTP transport for an account, TLS settings included. */ +export function smtpTransport(account: Account) { + return nodemailer.createTransport({ host: account.smtp.host, port: account.smtp.port, secure: account.smtp.secure, auth: { user: account.user, pass: account.password ?? '' }, + ...(account.insecureTls ? { tls: { rejectUnauthorized: false } } : {}), connectionTimeout: 20_000, greetingTimeout: 20_000, }); +} + +export const sendViaSmtp: SmtpSender = async (account, outgoing) => { + const transporter = smtpTransport(account); const info = await transporter.sendMail({ from: outgoing.from, to: outgoing.to, diff --git a/src/prompt.ts b/src/prompt.ts index 2184f46..e0c6d77 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -52,6 +52,24 @@ export async function promptSecret(label: string): Promise { }); } +/** One visible line from the terminal. Without one, the first line of stdin. */ +export async function promptLine(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').split('\n')[0] ?? '').trim(); + } + process.stderr.write(label); + return new Promise((resolve) => { + process.stdin.setEncoding('utf8'); + process.stdin.resume(); + process.stdin.once('data', (chunk) => { + process.stdin.pause(); + resolve(String(chunk).trim()); + }); + }); +} + /** 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; diff --git a/test/mail.test.ts b/test/mail.test.ts index 5710bc5..49cd129 100644 --- a/test/mail.test.ts +++ b/test/mail.test.ts @@ -10,6 +10,8 @@ import { type MessageSummary, type Outgoing, MailError, + PROVIDERS, + PROVIDER_NAMES, accountsFromVault, bareAddress, buildReply, @@ -20,15 +22,20 @@ import { formatAddresses, formatList, formatMessage, + formatProviders, fromHeader, guessProvider, + isProviderName, isTransportFailure, loadConfig, + loginHint, mailConfigPath, mergeVaultAccounts, normalizeConfig, parseQuery, passwordVariable, + providerFor, + providerFromMx, quote, replySubject, resendPayload, @@ -42,6 +49,8 @@ import { splitAddresses, stripHtml, summaryFrom, + unsupportedProvider, + verifyAccount, } from '../src/mail.ts'; const env = (extra: Record = {}): NodeJS.ProcessEnv => ({ ...extra }); @@ -55,8 +64,9 @@ function account(partial: Partial = {}): Account { password: 'secret', passwordSource: 'file', provider: 'forwardemail', - imap: { host: 'imap.example.com', port: 993 }, + imap: { host: 'imap.example.com', port: 993, secure: true }, smtp: { host: 'smtp.example.com', port: 465, secure: true }, + insecureTls: false, ...partial, }; } @@ -108,7 +118,8 @@ describe('providers', () => { 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.imap).toEqual({ host: 'imap.gmail.com', port: 993, secure: true }); + expect(resolved.insecureTls).toBe(false); expect(resolved.smtp).toEqual({ host: 'smtp.gmail.com', port: 465, secure: true }); expect(resolved.user).toBe('a@gmail.com'); expect(resolved.passwordSource).toBe('file'); @@ -651,3 +662,199 @@ describe('output', () => { expect(stripHtml('

Hi there

Bye
now
')).toBe('Hi there\nBye\nnow'); }); }); + +describe('provider table', () => { + it('names every built-in provider with hosts, ports and a password rule', () => { + expect(PROVIDER_NAMES).toHaveLength(15); + for (const name of PROVIDER_NAMES) { + const provider = PROVIDERS[name]; + expect(provider.imapHost, name).toBeTruthy(); + expect(provider.smtpHost, name).toBeTruthy(); + expect([993, 143, 1143], name).toContain(provider.imapPort); + expect([465, 587, 1025], name).toContain(provider.smtpPort); + expect(provider.passwordHint.length, name).toBeGreaterThan(10); + // 465/993 are implicit TLS; anything else upgrades. The table must agree with itself. + expect(provider.smtpSecure, name).toBe(provider.smtpPort === 465); + expect(provider.imapSecure, name).toBe(provider.imapPort === 993); + } + }); + + it('infers the provider from every webmail domain it knows', () => { + expect(guessProvider('a@yahoo.co.uk')).toBe('yahoo'); + expect(guessProvider('a@ME.com')).toBe('icloud'); + expect(guessProvider('a@pm.me')).toBe('proton'); + expect(guessProvider('a@fastmail.fm')).toBe('fastmail'); + expect(guessProvider('a@zohomail.com')).toBe('zoho'); + expect(guessProvider('a@gmx.de')).toBe('gmx'); + expect(guessProvider('a@ya.ru')).toBe('yandex'); + expect(guessProvider('a@aim.com')).toBe('aol'); + expect(guessProvider('a@mailbox.org')).toBe('mailbox'); + expect(guessProvider('a@posteo.net')).toBe('posteo'); + expect(guessProvider('a@purelymail.com')).toBe('purelymail'); + expect(guessProvider('a@forwardemail.net')).toBe('forwardemail'); + expect(guessProvider('a@outlook.com')).toBeNull(); + expect(guessProvider('a@example.com')).toBeNull(); + }); + + it('accepts every provider name and custom, nothing else', () => { + for (const name of PROVIDER_NAMES) expect(isProviderName(name)).toBe(true); + expect(isProviderName('custom')).toBe(true); + expect(isProviderName('outlook')).toBe(false); + expect(isProviderName('pigeon')).toBe(false); + expect(isProviderName(undefined)).toBe(false); + expect(providerFor('custom')).toBeNull(); + expect(providerFor('proton')?.insecureTls).toBe(true); + expect(providerFor('gmail')?.insecureTls).toBeUndefined(); + }); + + it('knows the hosts a password cannot reach, by name and by address', () => { + expect(unsupportedProvider('outlook')?.name).toBe('outlook'); + expect(unsupportedProvider('a@Hotmail.com')?.label).toContain('Outlook'); + expect(unsupportedProvider('a@tuta.com')?.name).toBe('tuta'); + expect(unsupportedProvider('a@hey.com')?.reason).toContain('no IMAP'); + expect(unsupportedProvider('a@gmail.com')).toBeNull(); + expect(unsupportedProvider('gmail')).toBeNull(); + }); + + it('says which kind of password before asking for it', () => { + expect(loginHint(PROVIDERS.gmail)).toContain('app password, not the account password'); + expect(loginHint(PROVIDERS.gmail)).toContain('https://myaccount.google.com/apppasswords'); + expect(loginHint(PROVIDERS.forwardemail)).toContain('generated per address'); + expect(loginHint(PROVIDERS.proton)).toContain('bridge'); + expect(loginHint(PROVIDERS.proton)).toContain('paid Proton plan'); + expect(loginHint(PROVIDERS.posteo)).toContain('takes the account password'); + }); + + it('lists every provider and the unreachable ones', () => { + const text = formatProviders(); + for (const name of PROVIDER_NAMES) expect(text).toContain(` ${name}`); + expect(text).toContain('custom'); + expect(text).toContain('(STARTTLS)'); + expect(text).toContain('Not reachable with a password'); + expect(text).toContain('OAuth2'); + expect(text).toContain('HEY'); + }); +}); + +describe('resolveAccount across the table', () => { + it('turns Proton into a localhost STARTTLS bridge that trusts its own certificate', () => { + const resolved = resolveAccount('p', { email: 'a@proton.me', provider: 'proton', password: 'x' }, env()); + expect(resolved.imap).toEqual({ host: '127.0.0.1', port: 1143, secure: false }); + expect(resolved.smtp).toEqual({ host: '127.0.0.1', port: 1025, secure: false }); + expect(resolved.insecureTls).toBe(true); + }); + + it('gives iCloud implicit TLS for IMAP and STARTTLS for SMTP', () => { + const resolved = resolveAccount('i', { email: 'a@icloud.com', provider: 'icloud', password: 'x' }, env()); + expect(resolved.imap.secure).toBe(true); + expect(resolved.smtp).toEqual({ host: 'smtp.mail.me.com', port: 587, secure: false }); + expect(resolved.insecureTls).toBe(false); + }); + + it('treats a non-993 custom IMAP port as STARTTLS unless told otherwise', () => { + const base = { email: 'a@x.org', provider: 'custom' as const, imapHost: 'imap.x.org', smtpHost: 'smtp.x.org' }; + expect(resolveAccount('c', { ...base, imapPort: 143 }, env()).imap.secure).toBe(false); + expect(resolveAccount('c', { ...base, imapPort: 143, imapSecure: true }, env()).imap.secure).toBe(true); + expect(resolveAccount('c', { ...base, insecureTls: true }, env()).insecureTls).toBe(true); + }); +}); + +describe('providerFromMx', () => { + const mx = (hosts: string[]) => async () => hosts.map((exchange, index) => ({ exchange, priority: index * 10 })); + + it('reads the provider off the MX host, trailing dot and case included', async () => { + expect(await providerFromMx('a@x.com', mx(['ASPMX.L.GOOGLE.COM.', 'alt1.aspmx.l.google.com']))).toEqual({ provider: 'gmail' }); + expect(await providerFromMx('a@x.com', mx(['mx1.forwardemail.net', 'mx2.forwardemail.net']))).toEqual({ provider: 'forwardemail' }); + expect(await providerFromMx('a@x.com', mx(['in1-smtp.messagingengine.com']))).toEqual({ provider: 'fastmail' }); + expect(await providerFromMx('a@x.com', mx(['mail.protonmail.ch', 'mailsec.protonmail.ch']))).toEqual({ provider: 'proton' }); + expect(await providerFromMx('a@x.com', mx(['mx.zoho.eu']))).toEqual({ provider: 'zoho' }); + expect(await providerFromMx('a@x.com', mx(['mx01.mail.icloud.com']))).toEqual({ provider: 'icloud' }); + }); + + it('reports a Microsoft-hosted domain as unreachable, with the reason', async () => { + const guess = await providerFromMx('a@x.com', mx(['x-com.mail.protection.outlook.com'])); + expect(guess && 'unsupported' in guess ? guess.unsupported.name : null).toBe('outlook'); + expect(guess && 'unsupported' in guess ? guess.unsupported.reason : '').toContain('OAuth2'); + }); + + it('prefers the lowest priority record', async () => { + const resolve = async () => [ + { exchange: 'mx.zoho.com', priority: 20 }, + { exchange: 'aspmx.l.google.com', priority: 10 }, + ]; + expect(await providerFromMx('a@x.com', resolve)).toEqual({ provider: 'gmail' }); + }); + + it('answers null for an unknown host, no records, a failed lookup, or no domain', async () => { + expect(await providerFromMx('a@x.com', mx(['mail.x.com']))).toBeNull(); + expect(await providerFromMx('a@x.com', mx([]))).toBeNull(); + expect( + await providerFromMx('a@x.com', async () => { + throw new Error('ENOTFOUND'); + }), + ).toBeNull(); + expect(await providerFromMx('nobody', mx(['aspmx.l.google.com']))).toBeNull(); + }); +}); + +describe('verifyAccount', () => { + const ok = async () => {}; + const refuse = (message: string) => async () => { + throw new Error(message); + }; + + it('reports each login separately and never throws', async () => { + const result = await verifyAccount(account({ provider: 'custom' }), { imap: ok, smtp: refuse('535 Authentication failed') }); + expect(result.imap).toBeNull(); + expect(result.smtp).toBe('SMTP login to smtp.example.com as me@example.com failed: 535 Authentication failed'); + // A Forward Email refusal points at the per-alias password. + const forwarded = await verifyAccount(account(), { imap: refuse('Invalid credentials'), smtp: ok }); + expect(forwarded.imap).toContain('Forward Email: the alias password'); + }); + + it('names the app password when an app-password provider refuses', async () => { + const gmail = account({ provider: 'gmail', user: 'a@gmail.com', imap: { host: 'imap.gmail.com', port: 993, secure: true } }); + const result = await verifyAccount(gmail, { imap: refuse('Command failed'), smtp: ok }); + expect(result.imap).toContain('IMAP login to imap.gmail.com as a@gmail.com failed: Command failed'); + expect(result.imap).toContain('use an app password (https://myaccount.google.com/apppasswords)'); + expect(result.smtp).toBeNull(); + }); + + it('asks whether the bridge is running when Proton refuses', async () => { + const proton = account({ provider: 'proton', imap: { host: '127.0.0.1', port: 1143, secure: false } }); + const result = await verifyAccount(proton, { imap: refuse('ECONNREFUSED'), smtp: refuse('ECONNREFUSED') }); + expect(result.imap).toContain('is the bridge running'); + expect(result.smtp).toContain('is the bridge running'); + }); +}); + +describe('the wider table in configuration', () => { + it('accepts every provider name and the TLS flags from the vault', () => { + const config = accountsFromVault({ + MAIL_Y_EMAIL: 'a@yahoo.com', + MAIL_Y_PROVIDER: 'yahoo', + MAIL_P_EMAIL: 'a@proton.me', + MAIL_P_PROVIDER: 'proton', + MAIL_P_IMAP_SECURE: 'false', + MAIL_P_INSECURE_TLS: 'yes', + MAIL_Z_EMAIL: 'a@zoho.eu', + MAIL_Z_PROVIDER: 'Zoho', + }); + expect(config.accounts.y?.provider).toBe('yahoo'); + expect(config.accounts.p).toMatchObject({ provider: 'proton', imapSecure: false, insecureTls: true }); + expect(config.accounts.z?.provider).toBe('zoho'); + }); + + it('keeps the TLS booleans in the file and infers a provider from any known domain', () => { + const config = normalizeConfig({ + accounts: { + i: { email: 'a@icloud.com' }, + c: { email: 'a@x.org', provider: 'custom', imapHost: 'imap.x.org', smtpHost: 'smtp.x.org', imapSecure: false, insecureTls: true }, + bad: { email: 'a@x.org', provider: 'outlook' }, + }, + }); + expect(config.accounts.i?.provider).toBe('icloud'); + expect(config.accounts.c).toMatchObject({ imapSecure: false, insecureTls: true }); + expect(config.accounts.bad?.provider).toBe('custom'); + }); +}); From 038536edd0e0a3dfffb8f885ba0fac2b4988c3af Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 5 Sep 2026 23:23:23 +0000 Subject: [PATCH 2/3] 0.25.0: mail login, sign in to any provider that still takes a password Version bump for the login work: the mail plugin goes to 0.2.0 with its third command, /mail:login. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013h8jopY81BGQ4Pn22NfZTu --- package.json | 2 +- plugins/mail/.claude-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c3159f2..5c89d4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.24.0", + "version": "0.25.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/plugins/mail/.claude-plugin/plugin.json b/plugins/mail/.claude-plugin/plugin.json index a5988bd..1c17a6c 100644 --- a/plugins/mail/.claude-plugin/plugin.json +++ b/plugins/mail/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "mail", "description": "The inbox from the terminal, for more than one account: sign in to Gmail, Yahoo, iCloud, Fastmail, Zoho, Proton, Forward Email or any IMAP host with the right kind of password, then 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", + "version": "0.2.0", "author": { "name": "profullstack", "url": "https://profullstack.com" From dd58681cccf0773541546060c0583c4790cc9764 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 5 Sep 2026 23:29:19 +0000 Subject: [PATCH 3/3] mail: pin a bridge's certificate instead of switching verification off ThreatCrush flagged rejectUnauthorized: false (CWE-295) in the IMAP and SMTP clients, there for Proton Mail Bridge's self-signed certificate on localhost. Verification now stays on everywhere: an account can name a PEM file to trust in place of the system roots (--tls-ca, MAIL__TLS_CA), and the Proton preset finds the copy Bridge keeps at its usual path on Linux, macOS and Windows by itself. A pinned path that cannot be read is an error at connect time, never a silent downgrade. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013h8jopY81BGQ4Pn22NfZTu --- README.md | 9 +++-- bin/mail.ts | 11 +++--- plugins/mail/commands/login.md | 2 +- src/mail.ts | 65 ++++++++++++++++++++++++++-------- test/mail.test.ts | 41 ++++++++++++++------- 5 files changed, 91 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 996f72b..a551de6 100644 --- a/README.md +++ b/README.md @@ -657,7 +657,7 @@ 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` (a provider name below, or `custom`), `_PASSWORD`, optional `_NAME`, `_USER`, `_IMAP_HOST`, `_IMAP_PORT`, `_IMAP_SECURE`, -`_SMTP_HOST`, `_SMTP_PORT`, `_SMTP_SECURE`, `_INSECURE_TLS`, and `MAIL_DEFAULT`. An +`_SMTP_HOST`, `_SMTP_PORT`, `_SMTP_SECURE`, `_TLS_CA`, 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. @@ -673,8 +673,11 @@ The provider is read off a webmail address, or off a custom domain's MX records (a domain hosted at Google, Zoho, Fastmail, Proton, iCloud or Forward Email needs no `--provider`). Outlook.com and Microsoft 365 are listed as unreachable: Microsoft takes only OAuth2 now, and app passwords no longer count; Tuta and HEY -have no IMAP at all. `custom` takes explicit hosts, with `--starttls`, -`--imap-starttls` and `--insecure-tls` for the odd server. +have no IMAP at all. `custom` takes explicit hosts, with `--starttls` and +`--imap-starttls` for the odd server. Certificate verification is never switched +off: a host that signs for itself, such as Proton Mail Bridge on localhost, is +handled by pinning its certificate (`--tls-ca cert.pem`, or the copy Bridge keeps +at its usual path, found automatically). 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 diff --git a/bin/mail.ts b/bin/mail.ts index 5b8e147..27b4227 100644 --- a/bin/mail.ts +++ b/bin/mail.ts @@ -128,7 +128,7 @@ Account options for \`accounts add\`: --imap-host H --imap-port N --smtp-host H --smtp-port N --starttls SMTP upgrades with STARTTLS (587) instead of TLS on connect (465) --imap-starttls IMAP upgrades with STARTTLS (143) instead of TLS on connect (993) - --insecure-tls accept a certificate no CA signed (a bridge on localhost, never a real host) + --tls-ca P trust this PEM certificate in place of the system roots (a bridge's own, on localhost) --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 @@ -245,7 +245,8 @@ function applyHostOptions(account: AccountConfig, parsed: ReturnType { boolean: [ '--json', '--unread', '--gmail', '--keep-unread', '--raw', '--all', '--no-quote', '--draft', '--purge', '--yes', '--read', '--flag', '--unflag', '--password', '--no-password', '--default', - '--starttls', '--imap-starttls', '--insecure-tls', '--no-verify', '-h', '--help', + '--starttls', '--imap-starttls', '--no-verify', '-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', '--as', + '--smtp-host', '--smtp-port', '--as', '--tls-ca', ], }); diff --git a/plugins/mail/commands/login.md b/plugins/mail/commands/login.md index befd91b..d1693c8 100644 --- a/plugins/mail/commands/login.md +++ b/plugins/mail/commands/login.md @@ -15,7 +15,7 @@ mail login gmail you@gmail.com # says "app password", checks I mail login you@yahoo.com # provider read off the address mail login you@yourdomain.com # provider read off the domain's MX records mail login forwardemail you@yourdomain.com --as work --default -mail login proton you@proton.me # through Proton Mail Bridge on localhost +mail login proton you@proton.me # through Proton Mail Bridge on localhost; its certificate is pinned, never ignored mail login custom you@example.org --imap-host imap.example.org --smtp-host smtp.example.org --starttls ``` diff --git a/src/mail.ts b/src/mail.ts index 65c51a4..abde4f2 100644 --- a/src/mail.ts +++ b/src/mail.ts @@ -27,7 +27,7 @@ */ import { resolveMx } from 'node:dns/promises'; -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -68,8 +68,12 @@ export interface Provider { smtpPort: number; /** Implicit TLS on connect (465), as opposed to STARTTLS (587). */ smtpSecure: boolean; - /** Accept a certificate no CA signed. Only for a bridge on localhost. */ - insecureTls?: boolean; + /** + * Where the host keeps its own certificate, for a bridge on localhost that + * signs for itself. The first path that exists is trusted in place of the + * system roots; verification is never switched off. `~` is the home. + */ + caPaths?: string[]; passwordKind: PasswordKind; /** Where the password comes from, for the setup message. */ passwordHint: string; @@ -226,7 +230,11 @@ export const PROVIDERS: Record = { smtpHost: '127.0.0.1', smtpPort: 1025, smtpSecure: false, - insecureTls: true, + caPaths: [ + '~/.config/protonmail/bridge-v3/cert.pem', + '~/Library/Application Support/protonmail/bridge-v3/cert.pem', + '~/AppData/Roaming/protonmail/bridge-v3/cert.pem', + ], passwordKind: 'bridge', passwordHint: 'the password Proton Mail Bridge shows for the account (Bridge → the account → Mailbox ' + @@ -235,7 +243,9 @@ export const PROVIDERS: Record = { domains: ['proton.me', 'protonmail.com', 'protonmail.ch', 'pm.me'], note: 'Proton has no IMAP of its own: the Bridge app must be installed, signed in and running on this ' + - 'machine, and it needs a paid Proton plan. It serves STARTTLS on localhost with a self-signed certificate.', + 'machine, and it needs a paid Proton plan. It serves STARTTLS on localhost with a certificate of its ' + + 'own; the copy Bridge keeps at its usual path is trusted automatically, or pass --tls-ca with the file ' + + 'from Bridge → Settings → Advanced settings → Export TLS certificates.', }, gmx: { label: 'GMX', @@ -513,8 +523,8 @@ export interface AccountConfig { smtpHost?: string; smtpPort?: number; smtpSecure?: boolean; - /** Accept a certificate no CA signed — a local bridge, never a real host. */ - insecureTls?: boolean; + /** A PEM file to trust in place of the system roots — a local bridge's own certificate. */ + tlsCa?: string; } export interface MailConfig { @@ -535,7 +545,8 @@ export interface Account { provider: ProviderName; imap: { host: string; port: number; secure: boolean }; smtp: { host: string; port: number; secure: boolean }; - insecureTls: boolean; + /** Path of the certificate to trust instead of the system roots, when there is one. */ + tlsCa: string | null; } function xdgConfigHome(env: NodeJS.ProcessEnv): string { @@ -586,7 +597,7 @@ export function normalizeConfig(parsed: unknown): MailConfig { email: entry.email.trim().toLowerCase(), provider: isProviderName(provider) ? provider : (guessProvider(entry.email) ?? 'custom'), }; - for (const key of ['name', 'user', 'password', 'imapHost', 'smtpHost'] as const) { + for (const key of ['name', 'user', 'password', 'imapHost', 'smtpHost', 'tlsCa'] as const) { const value = entry[key]; if (typeof value === 'string' && value.trim()) account[key] = value.trim(); } @@ -594,7 +605,7 @@ export function normalizeConfig(parsed: unknown): MailConfig { const value = entry[key]; if (typeof value === 'number' && Number.isInteger(value) && value > 0) account[key] = value; } - for (const key of ['imapSecure', 'smtpSecure', 'insecureTls'] as const) { + for (const key of ['imapSecure', 'smtpSecure'] as const) { const value = entry[key]; if (typeof value === 'boolean') account[key] = value; } @@ -612,6 +623,30 @@ export function saveConfig(config: MailConfig, env: NodeJS.ProcessEnv = process. return path; } +function expandHome(path: string): string { + return path.startsWith('~/') ? join(homedir(), path.slice(2)) : path; +} + +/** + * The certificate an account pins, read for the TLS options. + * + * Trusting one file in place of the system roots keeps verification on: + * the bridge's certificate must still match, and a stranger's will not. A + * path that cannot be read is an error at connect time, not a silent + * downgrade. + */ +export function trustedCa(account: Account): { ca: Buffer } | null { + if (!account.tlsCa) return null; + try { + return { ca: readFileSync(account.tlsCa) }; + } catch (error) { + throw new MailError( + `account "${account.name}" pins the certificate at ${account.tlsCa}, which cannot be read: ` + + `${(error as Error).message}`, + ); + } +} + /** Fill hosts from the provider and resolve the password, environment first. */ export function resolveAccount( name: string, @@ -654,7 +689,7 @@ export function resolveAccount( // 465 is implicit TLS everywhere; anything else is STARTTLS unless told. secure: config.smtpSecure ?? (preset ? preset.smtpSecure : smtpPort === 465), }, - insecureTls: config.insecureTls ?? preset?.insecureTls ?? false, + tlsCa: config.tlsCa ?? preset?.caPaths?.map(expandHome).find((path) => existsSync(path)) ?? null, }; } @@ -742,7 +777,7 @@ export const MAIL_VAULT_PROJECT = 'cli-tools-mail'; 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|IMAP_SECURE|SMTP_HOST|SMTP_PORT|SMTP_SECURE|INSECURE_TLS)$/; + /^MAIL_([A-Z0-9_]+?)_(EMAIL|PROVIDER|PASSWORD|NAME|USER|IMAP_HOST|IMAP_PORT|IMAP_SECURE|SMTP_HOST|SMTP_PORT|SMTP_SECURE|TLS_CA)$/; const partial: Record> = {}; for (const [key, value] of Object.entries(vault)) { @@ -775,7 +810,7 @@ export function accountsFromVault(vault: Record): MailConfig { if (fields.SMTP_PORT && /^\d+$/.test(fields.SMTP_PORT)) account.smtpPort = Number(fields.SMTP_PORT); if (fields.IMAP_SECURE) account.imapSecure = /^(true|1|yes)$/i.test(fields.IMAP_SECURE); if (fields.SMTP_SECURE) account.smtpSecure = /^(true|1|yes)$/i.test(fields.SMTP_SECURE); - if (fields.INSECURE_TLS) account.insecureTls = /^(true|1|yes)$/i.test(fields.INSECURE_TLS); + if (fields.TLS_CA) account.tlsCa = fields.TLS_CA; config.accounts[name] = account; } @@ -1065,7 +1100,7 @@ export function imapClient(account: Account, options: { logger?: boolean } = {}) port: account.imap.port, secure: account.imap.secure, auth: { user: account.user, pass: account.password ?? '' }, - ...(account.insecureTls ? { tls: { rejectUnauthorized: false } } : {}), + ...(account.tlsCa ? { tls: trustedCa(account) ?? {} } : {}), // 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. @@ -1469,7 +1504,7 @@ export function smtpTransport(account: Account) { port: account.smtp.port, secure: account.smtp.secure, auth: { user: account.user, pass: account.password ?? '' }, - ...(account.insecureTls ? { tls: { rejectUnauthorized: false } } : {}), + ...(account.tlsCa ? { tls: trustedCa(account) ?? {} } : {}), connectionTimeout: 20_000, greetingTimeout: 20_000, }); diff --git a/test/mail.test.ts b/test/mail.test.ts index 49cd129..8b12dc2 100644 --- a/test/mail.test.ts +++ b/test/mail.test.ts @@ -49,6 +49,7 @@ import { splitAddresses, stripHtml, summaryFrom, + trustedCa, unsupportedProvider, verifyAccount, } from '../src/mail.ts'; @@ -66,7 +67,7 @@ function account(partial: Partial = {}): Account { provider: 'forwardemail', imap: { host: 'imap.example.com', port: 993, secure: true }, smtp: { host: 'smtp.example.com', port: 465, secure: true }, - insecureTls: false, + tlsCa: null, ...partial, }; } @@ -119,7 +120,7 @@ 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, secure: true }); - expect(resolved.insecureTls).toBe(false); + expect(resolved.tlsCa).toBeNull(); expect(resolved.smtp).toEqual({ host: 'smtp.gmail.com', port: 465, secure: true }); expect(resolved.user).toBe('a@gmail.com'); expect(resolved.passwordSource).toBe('file'); @@ -703,8 +704,8 @@ describe('provider table', () => { expect(isProviderName('pigeon')).toBe(false); expect(isProviderName(undefined)).toBe(false); expect(providerFor('custom')).toBeNull(); - expect(providerFor('proton')?.insecureTls).toBe(true); - expect(providerFor('gmail')?.insecureTls).toBeUndefined(); + expect(providerFor('proton')?.caPaths?.length).toBeGreaterThan(0); + expect(providerFor('gmail')?.caPaths).toBeUndefined(); }); it('knows the hosts a password cannot reach, by name and by address', () => { @@ -737,25 +738,35 @@ describe('provider table', () => { }); describe('resolveAccount across the table', () => { - it('turns Proton into a localhost STARTTLS bridge that trusts its own certificate', () => { - const resolved = resolveAccount('p', { email: 'a@proton.me', provider: 'proton', password: 'x' }, env()); + it('turns Proton into a localhost STARTTLS bridge, pinning its certificate when told where', () => { + const resolved = resolveAccount('p', { email: 'a@proton.me', provider: 'proton', password: 'x', tlsCa: '/x/cert.pem' }, env()); expect(resolved.imap).toEqual({ host: '127.0.0.1', port: 1143, secure: false }); expect(resolved.smtp).toEqual({ host: '127.0.0.1', port: 1025, secure: false }); - expect(resolved.insecureTls).toBe(true); + expect(resolved.tlsCa).toBe('/x/cert.pem'); }); it('gives iCloud implicit TLS for IMAP and STARTTLS for SMTP', () => { const resolved = resolveAccount('i', { email: 'a@icloud.com', provider: 'icloud', password: 'x' }, env()); expect(resolved.imap.secure).toBe(true); expect(resolved.smtp).toEqual({ host: 'smtp.mail.me.com', port: 587, secure: false }); - expect(resolved.insecureTls).toBe(false); + expect(resolved.tlsCa).toBeNull(); }); it('treats a non-993 custom IMAP port as STARTTLS unless told otherwise', () => { const base = { email: 'a@x.org', provider: 'custom' as const, imapHost: 'imap.x.org', smtpHost: 'smtp.x.org' }; expect(resolveAccount('c', { ...base, imapPort: 143 }, env()).imap.secure).toBe(false); expect(resolveAccount('c', { ...base, imapPort: 143, imapSecure: true }, env()).imap.secure).toBe(true); - expect(resolveAccount('c', { ...base, insecureTls: true }, env()).insecureTls).toBe(true); + expect(resolveAccount('c', { ...base, tlsCa: '/x/ca.pem' }, env()).tlsCa).toBe('/x/ca.pem'); + }); + + it('reads a pinned certificate for the TLS options and refuses a path it cannot read', () => { + expect(trustedCa(account())).toBeNull(); + const dir = mkdtempSync(join(tmpdir(), 'mail-ca-')); + const pem = join(dir, 'cert.pem'); + writeFileSync(pem, '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n'); + expect(trustedCa(account({ tlsCa: pem }))?.ca.toString()).toContain('BEGIN CERTIFICATE'); + expect(() => trustedCa(account({ tlsCa: join(dir, 'missing.pem') }))).toThrow(/cannot be read/); + rmSync(dir, { recursive: true, force: true }); }); }); @@ -836,12 +847,16 @@ describe('the wider table in configuration', () => { MAIL_P_EMAIL: 'a@proton.me', MAIL_P_PROVIDER: 'proton', MAIL_P_IMAP_SECURE: 'false', - MAIL_P_INSECURE_TLS: 'yes', + MAIL_P_TLS_CA: '/home/me/.config/protonmail/bridge-v3/cert.pem', MAIL_Z_EMAIL: 'a@zoho.eu', MAIL_Z_PROVIDER: 'Zoho', }); expect(config.accounts.y?.provider).toBe('yahoo'); - expect(config.accounts.p).toMatchObject({ provider: 'proton', imapSecure: false, insecureTls: true }); + expect(config.accounts.p).toMatchObject({ + provider: 'proton', + imapSecure: false, + tlsCa: '/home/me/.config/protonmail/bridge-v3/cert.pem', + }); expect(config.accounts.z?.provider).toBe('zoho'); }); @@ -849,12 +864,12 @@ describe('the wider table in configuration', () => { const config = normalizeConfig({ accounts: { i: { email: 'a@icloud.com' }, - c: { email: 'a@x.org', provider: 'custom', imapHost: 'imap.x.org', smtpHost: 'smtp.x.org', imapSecure: false, insecureTls: true }, + c: { email: 'a@x.org', provider: 'custom', imapHost: 'imap.x.org', smtpHost: 'smtp.x.org', imapSecure: false, tlsCa: '/x/ca.pem' }, bad: { email: 'a@x.org', provider: 'outlook' }, }, }); expect(config.accounts.i?.provider).toBe('icloud'); - expect(config.accounts.c).toMatchObject({ imapSecure: false, insecureTls: true }); + expect(config.accounts.c).toMatchObject({ imapSecure: false, tlsCa: '/x/ca.pem' }); expect(config.accounts.bad?.provider).toBe('custom'); }); });