852bb1dc5b
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
4.7 KiB
Markdown
119 lines
4.7 KiB
Markdown
# 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.
|