Files
emcli/docs/superpowers/specs/2026-06-23-send-from-address-design.md
T
steve 852bb1dc5b docs: design for send-as From address field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:06:38 +01:00

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.UsernameFrom: 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.