diff --git a/docs/superpowers/specs/2026-06-23-send-from-address-design.md b/docs/superpowers/specs/2026-06-23-send-from-address-design.md new file mode 100644 index 0000000..8b04958 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-send-from-address-design.md @@ -0,0 +1,118 @@ +# Send-as "From" address — design + +**Date:** 2026-06-23 +**Status:** Approved (pending spec review) + +## Problem + +An account's configuration has no field for the email address used as the +`From:` when sending mail. Today the From is silently aliased to the login +username (`internal/cli/send.go:26`, `From: acc.Username`), and neither +`store.Account` nor the `accounts` table has any `from`/`address`/`email` +column. + +This works only when the login username is exactly the desired send-as +address. It breaks for: + +- providers where the login is an account ID rather than an email, +- sending from an **alias** of the mailbox, +- wanting a **display name** (`Steve Cliff `) rather than a bare address, +- Gmail App Passwords where envelope-from and header-from may differ. + +## Decisions + +- **Field shape:** a single freeform RFC 5322 From identity — bare + (`me@stevecliff.com`) or with a display name + (`Steve Cliff `). One field, not a split + address/display-name pair. +- **Fallback:** when the from-address is blank, fall back to + `Account.Username` (current behaviour). No migration/backfill of existing + accounts required; they keep working unchanged. + +## Design + +### 1. Data model + +Add `FromAddress string` to `store.Account` and a `from_address TEXT` column to +the `accounts` table. **Not encrypted** — it is not a secret (it appears in +every outgoing header), so it is stored as plaintext like `username`. + +### 2. Fallback in one place + +Add a method so the fallback rule lives in exactly one spot: + +```go +// SendFrom returns the From identity for outgoing mail, falling back to the +// login username when no explicit from-address is configured. +func (a Account) SendFrom() string { + if a.FromAddress != "" { + return a.FromAddress + } + return a.Username +} +``` + +`internal/cli/send.go` changes `From: acc.Username` → `From: acc.SendFrom()`. + +### 3. Envelope sender vs header From + +A display-name From breaks the SMTP envelope: `c.SendMail("Steve ", …)` +is invalid — the envelope sender must be the **bare** address. + +- `mail.BuildMIME` keeps using the full `m.From` for the `From:` header + (go-message renders `Name ` correctly via `SetAddressList`). +- `mail.SendSMTP` derives the envelope sender as the bare address: + `gomail.ParseAddress(m.From).Address`. If parsing fails, fall back to the raw + `m.From` (preserves today's behaviour for plain addresses). + +Header carries the display name; envelope carries the bare address. + +### 4. Migration + +The schema is v1, applied via `CREATE TABLE … IF NOT EXISTS`, which will not add +a column to an existing DB, and there is no migration runner yet. + +- Add the `from_address` column to the `CREATE TABLE accounts` statement (new + DBs get it directly). +- In `store.Open`, after applying the schema, run a version-gated migration: if + the stored `schema_version` is `< 2`, execute + `ALTER TABLE accounts ADD COLUMN from_address TEXT` and set `schema_version` + to `2`. +- Bump the `schemaVersion` constant to `2`. + +SQLite `ALTER TABLE … ADD COLUMN` is cheap and safe. The migration is +idempotent under the version gate. + +### 5. Inputs & validation + +- **CLI:** add a `--from` flag to `account add` and `account edit`. On `edit`, + follow the existing `fs.Visit` overlay pattern (only set when the flag was + passed). +- **TUI:** add a `FromAddress` field to `tui.Fields`, a `fieldDef` + (`{key: "from_address", label: "From address (optional)"}`), and wire it + through `ToAccount`, `FieldsFromAccount`, `fieldValue`, and `collect`. +- **Validation:** when the from-address is non-empty, reject it unless + `gomail.ParseAddress` accepts it (covers bare and display-name forms). A + blank from-address is always valid (→ fallback). Applied in + `Fields.Validate` and on the `account add`/`edit` flag path. +- **Persistence:** thread `from_address` through `AddAccount`, `UpdateAccount`, + `scanAccount`, and the three `SELECT` column lists in + `internal/store/account.go`. Stored via `nullStr` (blank → NULL). + +### 6. Tests + +- `SendFrom()`: returns the from-address when set; returns username when blank. +- `SendSMTP` envelope: when From is `Name `, the envelope sender passed to + the server is the bare `addr` (table test on the extraction helper). +- Migration: open a v1 DB with no `from_address` column → column is added, + `schema_version` becomes 2, and an existing account still sends from its + username. +- `Fields.Validate`: rejects a malformed from-address; accepts bare and + display-name forms; accepts blank. +- Round-trip: `FieldsFromAccount` then `ToAccount` preserves `FromAddress`. + +## Out of scope (YAGNI) + +- Separate envelope-from override field (derive it from From instead). +- Per-message From override at send time. +- Multiple aliases per account.