Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
]
}
]
}
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_<NAME>_EMAIL`, `_PROVIDER` (`forwardemail`, `gmail`, `custom`),
`_PASSWORD`, optional `_NAME`, `_USER`, `_IMAP_HOST`, `_SMTP_HOST`, `_SMTP_PORT`,
and `MAIL_DEFAULT`. An exported `MAIL_<NAME>_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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 1 addition & 52 deletions bin/cli-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> {
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<string>((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<number> {
const [verb, name, ...more] = rest;

Expand Down
Loading
Loading