Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4.7 KiB
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:
// 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.BuildMIMEkeeps using the fullm.Fromfor theFrom:header (go-message rendersName <addr>correctly viaSetAddressList).mail.SendSMTPderives the envelope sender as the bare address:gomail.ParseAddress(m.From).Address. If parsing fails, fall back to the rawm.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_addresscolumn to theCREATE TABLE accountsstatement (new DBs get it directly). - In
store.Open, after applying the schema, run a version-gated migration: if the storedschema_versionis< 2, executeALTER TABLE accounts ADD COLUMN from_address TEXTand setschema_versionto2. - Bump the
schemaVersionconstant to2.
SQLite ALTER TABLE … ADD COLUMN is cheap and safe. The migration is
idempotent under the version gate.
5. Inputs & validation
- CLI: add a
--fromflag toaccount addandaccount edit. Onedit, follow the existingfs.Visitoverlay pattern (only set when the flag was passed). - TUI: add a
FromAddressfield totui.Fields, afieldDef({key: "from_address", label: "From address (optional)"}), and wire it throughToAccount,FieldsFromAccount,fieldValue, andcollect. - Validation: when the from-address is non-empty, reject it unless
gomail.ParseAddressaccepts it (covers bare and display-name forms). A blank from-address is always valid (→ fallback). Applied inFields.Validateand on theaccount add/editflag path. - Persistence: thread
from_addressthroughAddAccount,UpdateAccount,scanAccount, and the threeSELECTcolumn lists ininternal/store/account.go. Stored vianullStr(blank → NULL).
6. Tests
SendFrom(): returns the from-address when set; returns username when blank.SendSMTPenvelope: when From isName <addr>, the envelope sender passed to the server is the bareaddr(table test on the extraction helper).- Migration: open a v1 DB with no
from_addresscolumn → column is added,schema_versionbecomes 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:
FieldsFromAccountthenToAccountpreservesFromAddress.
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.