docs: design for send-as From address field

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 20:06:38 +01:00
parent 76ada04442
commit 852bb1dc5b
@@ -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 <me@…>`) 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 <me@stevecliff.com>`). 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 <me@…>", …)`
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 <addr>` 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 <addr>`, 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.