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:
@@ -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.
|
||||
Reference in New Issue
Block a user