diff --git a/docs/superpowers/plans/2026-06-23-send-from-address.md b/docs/superpowers/plans/2026-06-23-send-from-address.md new file mode 100644 index 0000000..1c64d3c --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-send-from-address.md @@ -0,0 +1,688 @@ +# Send-as "From" Address Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let each account configure the email address used as the `From:` when sending, instead of always reusing the login username. + +**Architecture:** Add a single freeform RFC 5322 `from_address` field to the account (bare address or `Display Name `). When blank, sending falls back to the login username — no migration of existing data. The header `From:` carries the full identity; the SMTP envelope sender is derived as the bare address. A version-gated `ALTER TABLE` migration adds the column to existing databases. + +**Tech Stack:** Go, SQLite (`modernc.org/sqlite`), `github.com/emersion/go-message/mail` for MIME, `net/mail` (stdlib) for address validation, bubbletea TUI. + +## Global Constraints + +- Module path: `git.dcglab.co.uk/steve/emcli`. +- The `from_address` field is **not** a secret — store as plaintext (like `username`), never encrypted. +- A blank from-address is always valid and means "fall back to `Account.Username`". +- Follow existing patterns: `nullStr` for nullable text columns, `sql.NullString` in `scanAccount`, `fs.Visit` overlay for `account edit` flags. +- Tests are Go table/unit tests in the same package; reuse the existing `openTemp(t)` helper where keys are needed. + +--- + +### Task 1: Store — field, migration, persistence + +**Files:** +- Modify: `internal/store/account.go` (Account struct, AddAccount, GetAccount, ListAccounts, UpdateAccount, scanAccount) +- Modify: `internal/store/schema.go` (add column, bump schemaVersion) +- Modify: `internal/store/store.go` (run migration in Open) +- Modify: `internal/store/store_test.go` (update schema_version expectations to "2") +- Test: `internal/store/account_test.go` (SendFrom + round-trip), `internal/store/store_test.go` (migration) + +**Interfaces:** +- Produces: `store.Account.FromAddress string` field; method `func (a Account) SendFrom() string`; schema at version 2 with `accounts.from_address TEXT` column. + +- [ ] **Step 1: Write the failing test for SendFrom + round-trip** + +Add to `internal/store/account_test.go`: + +```go +func TestSendFromFallsBackToUsername(t *testing.T) { + a := Account{Username: "login@example.com"} + if got := a.SendFrom(); got != "login@example.com" { + t.Fatalf("blank from-address should fall back to username, got %q", got) + } + a.FromAddress = "Steve Cliff " + if got := a.SendFrom(); got != "Steve Cliff " { + t.Fatalf("set from-address should win, got %q", got) + } +} + +func TestAddGetAccountRoundTripsFromAddress(t *testing.T) { + s := openTemp(t) + a := sampleAccount() + a.FromAddress = "Steve Cliff " + if _, err := s.AddAccount(a); err != nil { + t.Fatalf("AddAccount: %v", err) + } + got, err := s.GetAccount("work") + if err != nil { + t.Fatalf("GetAccount: %v", err) + } + if got.FromAddress != "Steve Cliff " { + t.Fatalf("FromAddress not round-tripped: %q", got.FromAddress) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/store/ -run 'TestSendFrom|TestAddGetAccountRoundTripsFromAddress' -v` +Expected: FAIL — `a.SendFrom undefined` and `a.FromAddress undefined`. + +- [ ] **Step 3: Add the field and SendFrom method** + +In `internal/store/account.go`, add `FromAddress` to the struct (right after `Username`) and the method. The struct becomes: + +```go +type Account struct { + ID int64 + Name string + Mode string // RO | RW + IMAPHost string + IMAPPort int + IMAPSecurity string // tls | starttls + SMTPHost string // nullable for RO accounts + SMTPPort int + SMTPSecurity string // tls | starttls + AuthType string // password | oauth2 + Username string + FromAddress string // send-as identity; blank ⇒ fall back to Username + Password string // decrypted; empty in ListAccounts + WhitelistInEnabled bool + WhitelistOutEnabled bool + SubjectRegex string + ProcessBacklog bool +} + +// 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 +} +``` + +- [ ] **Step 4: Thread from_address through persistence** + +In `internal/store/account.go`: + +AddAccount — add `from_address` to the column list and a value placeholder. The INSERT becomes: + +```go + res, err := s.db.Exec(` + INSERT INTO accounts + (name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security, + auth_type,username,from_address, + enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity, + nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity), + a.AuthType, a.Username, nullStr(a.FromAddress), + encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled), + nullStr(a.SubjectRegex), b2i(a.ProcessBacklog)) +``` + +GetAccount and ListAccounts — add `from_address` to both SELECT column lists, right after `username`: + +```go + SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security, + auth_type,username,from_address, + enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog +``` + +UpdateAccount — add `from_address=?` to the SET clause and its arg (after `username=?` / `a.Username`): + +```go + set := `mode=?, imap_host=?, imap_port=?, imap_security=?, + smtp_host=?, smtp_port=?, smtp_security=?, + auth_type=?, username=?, from_address=?, + whitelist_in_enabled=?, whitelist_out_enabled=?, subject_regex=?, process_backlog=?` + args := []any{ + a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity, + nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity), + a.AuthType, a.Username, nullStr(a.FromAddress), + b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled), + nullStr(a.SubjectRegex), b2i(a.ProcessBacklog), + } +``` + +scanAccount — add a `fromAddr sql.NullString` local, scan it after `&a.Username`, and assign. The var block gains `fromAddr sql.NullString`; the Scan call becomes: + +```go + err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity, + &smtpHost, &smtpPort, &smtpSec, + &a.AuthType, &a.Username, &fromAddr, &encPw, &wlIn, &wlOut, &subj, &backlog) +``` + +and after the existing assignments add: + +```go + a.FromAddress = fromAddr.String +``` + +- [ ] **Step 5: Add the column to the schema and bump the version** + +In `internal/store/schema.go`, change `const schemaVersion = 1` to `const schemaVersion = 2`, and add the column to the `accounts` CREATE TABLE, right after the `username` line: + +```sql + username TEXT NOT NULL, + from_address TEXT, + enc_password BLOB, +``` + +- [ ] **Step 6: Run the round-trip + SendFrom tests to verify they pass** + +Run: `go test ./internal/store/ -run 'TestSendFrom|TestAddGetAccountRoundTripsFromAddress' -v` +Expected: PASS. + +- [ ] **Step 7: Write the failing migration test** + +The existing `TestOpenCreatesSchemaAndIsIdempotent` will now fail because it expects `schema_version == "1"`. Update both assertions in `internal/store/store_test.go` from `"1"` to `"2"`. Then add a new migration test in `internal/store/store_test.go`: + +```go +func TestOpenMigratesV1AddsFromAddress(t *testing.T) { + p := filepath.Join(t.TempDir(), "emcli.db") + + // Hand-build a v1 database: accounts table WITHOUT from_address, a settings + // table pinned at schema_version=1, and one pre-existing account row. + raw, err := sql.Open("sqlite", p) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + const v1Schema = ` +CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); +CREATE TABLE accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + mode TEXT NOT NULL, + imap_host TEXT NOT NULL, + imap_port INTEGER NOT NULL, + imap_security TEXT NOT NULL, + smtp_host TEXT, smtp_port INTEGER, smtp_security TEXT, + auth_type TEXT NOT NULL, + username TEXT NOT NULL, + enc_password BLOB, + enc_oauth_client_id BLOB, enc_oauth_client_secret BLOB, enc_oauth_refresh_token BLOB, + whitelist_in_enabled INTEGER NOT NULL DEFAULT 0, + whitelist_out_enabled INTEGER NOT NULL DEFAULT 0, + subject_regex TEXT, + process_backlog INTEGER NOT NULL DEFAULT 0 +); +INSERT INTO settings(key,value) VALUES ('schema_version','1'); +INSERT INTO accounts(name,mode,imap_host,imap_port,imap_security,auth_type,username) + VALUES ('legacy','RO','imap.example.com',993,'tls','password','login@example.com'); +` + if _, err := raw.Exec(v1Schema); err != nil { + t.Fatalf("seed v1 schema: %v", err) + } + raw.Close() + + // Open via the store: the migration must add from_address and bump to v2. + s, err := Open(p) + if err != nil { + t.Fatalf("Open (migrate): %v", err) + } + defer s.Close() + + if v, _ := s.GetSetting("schema_version"); v != "2" { + t.Fatalf("schema_version after migrate: %q, want 2", v) + } + // ListAccounts SELECTs from_address; it would error if the column were missing. + accs, err := s.ListAccounts() + if err != nil { + t.Fatalf("ListAccounts after migrate: %v", err) + } + if len(accs) != 1 || accs[0].FromAddress != "" { + t.Fatalf("legacy account wrong after migrate: %+v", accs) + } + if got := accs[0].SendFrom(); got != "login@example.com" { + t.Fatalf("legacy account should send from username, got %q", got) + } +} +``` + +Ensure `internal/store/store_test.go` imports `"database/sql"` (add it to the import block). + +- [ ] **Step 8: Run the migration test to verify it fails** + +Run: `go test ./internal/store/ -run 'TestOpenMigratesV1AddsFromAddress|TestOpenCreatesSchemaAndIsIdempotent' -v` +Expected: migration test FAILS with a "no such column: from_address" error from `ListAccounts` (the column is in the schema for new DBs but not added to the seeded v1 DB). + +- [ ] **Step 9: Add the migration runner to Open** + +In `internal/store/store.go`, replace the post-schema version block with a call to a new `migrate` method. Change the tail of `Open` from: + +```go + s := &Store{db: db} + if _, err := s.GetSetting("schema_version"); err != nil { + if err := s.SetSetting("schema_version", strconv.Itoa(schemaVersion)); err != nil { + db.Close() + return nil, err + } + } + return s, nil +``` + +to: + +```go + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +``` + +and add the method: + +```go +// migrate brings an existing database up to the current schemaVersion. A brand- +// new database (no schema_version yet) already has every column from schemaSQL, +// so it is simply stamped at the current version. Each older version runs its +// forward step. The version gate makes every step idempotent across reopens. +func (s *Store) migrate() error { + v, err := s.GetSetting("schema_version") + if err != nil { + // Fresh database: schemaSQL created all columns already. + return s.SetSetting("schema_version", strconv.Itoa(schemaVersion)) + } + ver, _ := strconv.Atoi(v) + if ver < 2 { + if _, err := s.db.Exec(`ALTER TABLE accounts ADD COLUMN from_address TEXT`); err != nil { + return fmt.Errorf("migrate to v2: %w", err) + } + if err := s.SetSetting("schema_version", "2"); err != nil { + return err + } + } + return nil +} +``` + +Confirm `internal/store/store.go` already imports `fmt` and `strconv` (it does); no import changes needed. + +- [ ] **Step 10: Run the full store test suite to verify it passes** + +Run: `go test ./internal/store/ -v` +Expected: PASS (migration, idempotency, round-trip, and existing tests all green). + +- [ ] **Step 11: Commit** + +```bash +git add internal/store/account.go internal/store/schema.go internal/store/store.go internal/store/account_test.go internal/store/store_test.go +git commit -m "feat(store): add account from_address field + v2 migration + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: Mail — envelope sender vs header From + +**Files:** +- Modify: `internal/mail/send.go` (add `envelopeFrom` helper, use it in `SendSMTP`) +- Test: `internal/mail/send_test.go` (envelopeFrom table test + BuildMIME display-name assertion) + +**Interfaces:** +- Consumes: `OutgoingMessage.From` may now hold `Display Name `. +- Produces: `func envelopeFrom(from string) string` (package-private) — bare address for the SMTP envelope. + +- [ ] **Step 1: Write the failing test for envelopeFrom and the display-name header** + +Add to `internal/mail/send_test.go`: + +```go +func TestEnvelopeFromStripsDisplayName(t *testing.T) { + cases := map[string]string{ + "Steve Cliff ": "me@stevecliff.com", + "me@stevecliff.com": "me@stevecliff.com", + "": "me@stevecliff.com", + "not a valid address": "not a valid address", // unparseable ⇒ passthrough + } + for in, want := range cases { + if got := envelopeFrom(in); got != want { + t.Fatalf("envelopeFrom(%q) = %q, want %q", in, got, want) + } + } +} + +func TestBuildMIMEKeepsDisplayNameInHeader(t *testing.T) { + raw, err := BuildMIME(OutgoingMessage{ + From: "Steve Cliff ", + To: []string{"you@example.com"}, + Subject: "hi", + BodyText: "body", + Date: time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatalf("BuildMIME: %v", err) + } + if !strings.Contains(string(raw), "Steve Cliff") { + t.Fatalf("From header lost display name:\n%s", raw) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/mail/ -run 'TestEnvelopeFromStripsDisplayName|TestBuildMIMEKeepsDisplayNameInHeader' -v` +Expected: FAIL — `envelopeFrom` undefined. (The BuildMIME test may already pass, since `SetAddressList` renders display names; the envelopeFrom test is the gating failure.) + +- [ ] **Step 3: Add the envelopeFrom helper and use it in SendSMTP** + +In `internal/mail/send.go`, add the helper (near `addrList`): + +```go +// envelopeFrom returns the bare address for the SMTP envelope sender, stripping +// any display name. A display-name From (e.g. "Name ") is a valid header +// but an invalid envelope sender, so it must be reduced to the bare address. +// Unparseable input is passed through unchanged (preserves prior behaviour for +// plain addresses). +func envelopeFrom(from string) string { + if a, err := gomail.ParseAddress(from); err == nil { + return a.Address + } + return from +} +``` + +In `SendSMTP`, change the send line from: + +```go + if err := c.SendMail(m.From, m.Recipients(), bytes.NewReader(raw)); err != nil { +``` + +to: + +```go + if err := c.SendMail(envelopeFrom(m.From), m.Recipients(), bytes.NewReader(raw)); err != nil { +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/mail/ -run 'TestEnvelopeFromStripsDisplayName|TestBuildMIMEKeepsDisplayNameInHeader' -v` +Expected: PASS. + +- [ ] **Step 5: Run the full mail suite** + +Run: `go test ./internal/mail/` +Expected: PASS (`imap_integration_test` may skip without a live server — that is fine). + +- [ ] **Step 6: Commit** + +```bash +git add internal/mail/send.go internal/mail/send_test.go +git commit -m "feat(mail): derive bare envelope sender from display-name From + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 3: CLI + TUI — inputs, validation, and send wiring + +**Files:** +- Modify: `internal/cli/send.go:26` (use `acc.SendFrom()`) +- Modify: `internal/cli/admin.go` (`--from` flag on `account add` and `account edit`) +- Modify: `internal/tui/account.go` (Fields field, fieldDef, ToAccount, FieldsFromAccount, fieldValue, collect, validation helper, Validate) +- Test: `internal/tui/account_test.go` (validation + round-trip), `internal/cli/send_test.go` (send uses configured from) + +**Interfaces:** +- Consumes: `store.Account.FromAddress`, `store.Account.SendFrom()` (Task 1). +- Produces: `func ValidFromAddress(s string) error` exported from `tui` package, used by both `Fields.Validate` and `internal/cli/admin.go`. + +- [ ] **Step 1: Write the failing TUI validation + round-trip tests** + +Add to `internal/tui/account_test.go`: + +```go +func TestValidateRejectsBadFromAddress(t *testing.T) { + f := validFields() + f.FromAddress = "not an address" + if err := f.Validate(); err == nil { + t.Fatal("malformed from-address should fail validation") + } + f.FromAddress = "Steve Cliff " + if err := f.Validate(); err != nil { + t.Fatalf("display-name from-address should validate: %v", err) + } + f.FromAddress = "me@stevecliff.com" + if err := f.Validate(); err != nil { + t.Fatalf("bare from-address should validate: %v", err) + } + f.FromAddress = "" // blank ⇒ fall back, always valid + if err := f.Validate(); err != nil { + t.Fatalf("blank from-address should validate: %v", err) + } +} + +func TestFieldsFromToAccountCarriesFromAddress(t *testing.T) { + f := validFields() + f.FromAddress = "Steve Cliff " + acc, _ := f.ToAccount() + if acc.FromAddress != "Steve Cliff " { + t.Fatalf("ToAccount lost FromAddress: %q", acc.FromAddress) + } + back := FieldsFromAccount(acc) + if back.FromAddress != "Steve Cliff " { + t.Fatalf("FieldsFromAccount lost FromAddress: %q", back.FromAddress) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/tui/ -run 'TestValidateRejectsBadFromAddress|TestFieldsFromToAccountCarriesFromAddress' -v` +Expected: FAIL — `f.FromAddress` undefined. + +- [ ] **Step 3: Add the field, validation helper, and wiring in tui/account.go** + +In `internal/tui/account.go`: + +Add `"net/mail"` to the import block. + +Add `FromAddress` to `Fields` (after the `Username, Password` line): + +```go +type Fields struct { + Name, Mode string + IMAPHost, IMAPPort, IMAPSecurity string + SMTPHost, SMTPPort, SMTPSecurity string + Username, Password string + FromAddress string + WhitelistIn, WhitelistOut, ProcessBacklog bool + SubjectRegex string +} +``` + +Add the exported validator: + +```go +// ValidFromAddress returns an error if s is set but is not a valid RFC 5322 +// address (bare or "Display Name "). A blank value is valid: sending +// falls back to the login username. +func ValidFromAddress(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + if _, err := mail.ParseAddress(s); err != nil { + return errors.New("from address must be a valid email address") + } + return nil +} +``` + +In `Fields.Validate`, add before the final `return nil`: + +```go + if err := ValidFromAddress(f.FromAddress); err != nil { + return err + } +``` + +In `ToAccount`, set the field on the assembled account (add to the struct literal, after `Username/Password`): + +```go + AuthType: "password", Username: f.Username, Password: f.Password, + FromAddress: f.FromAddress, +``` + +In `FieldsFromAccount`, prefill it (after `Username: a.Username,`): + +```go + Username: a.Username, + FromAddress: a.FromAddress, +``` + +Add a `fieldDef` to `fieldDefs`, immediately after the `username` entry (so it appears next to it in the form): + +```go + {key: "username", label: "Username"}, + {key: "from_address", label: "From address (optional)"}, + {key: "password", label: "Password", password: true}, +``` + +- [ ] **Step 4: Wire from_address through fieldValue and collect** + +In `internal/tui/account.go`, find `fieldValue` (≈ line 147) and add a `case "from_address": return f.FromAddress` alongside the other string cases. Find `collect` (≈ line 228) and add the inverse mapping so the typed value is written back to `f.FromAddress` (mirror exactly how `username` is handled in that function's switch). + +- [ ] **Step 5: Run the tui tests to verify they pass** + +Run: `go test ./internal/tui/ -v` +Expected: PASS (new validation/round-trip tests plus existing form tests). + +- [ ] **Step 6: Write the failing CLI send test** + +The harness in `internal/cli/send_test.go` records every sent message into `*sent`, so assert directly on `m.From`. Add: + +```go +func TestSendUsesConfiguredFromAddress(t *testing.T) { + acc := rwAccount() + acc.FromAddress = "Steve Cliff " + d, sent, _ := sendDeps(t, acc, nil) + if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil { + t.Fatalf("SendCmd: %v", err) + } + if len(*sent) != 1 { + t.Fatalf("want 1 send, got %d", len(*sent)) + } + if got := (*sent)[0].From; got != "Steve Cliff " { + t.Fatalf("From = %q, want configured from-address", got) + } +} + +func TestSendFallsBackToUsernameAsFrom(t *testing.T) { + // rwAccount has no FromAddress, so From must be the login username. + d, sent, _ := sendDeps(t, rwAccount(), nil) + if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil { + t.Fatalf("SendCmd: %v", err) + } + if got := (*sent)[0].From; got != "emcli@stevecliff.com" { + t.Fatalf("From = %q, want username fallback", got) + } +} +``` + +- [ ] **Step 7: Run the CLI send test to verify it fails** + +Run: `go test ./internal/cli/ -run 'TestSendUsesConfiguredFromAddress' -v` +Expected: FAIL — `send.go` still sets `From: acc.Username`. + +- [ ] **Step 8: Wire send.go and add the --from flags** + +In `internal/cli/send.go`, change: + +```go + msg := mail.OutgoingMessage{ + From: acc.Username, To: to, Cc: cc, Bcc: bcc, + Subject: subject, BodyText: body, + } +``` + +to: + +```go + msg := mail.OutgoingMessage{ + From: acc.SendFrom(), To: to, Cc: cc, Bcc: bcc, + Subject: subject, BodyText: body, + } +``` + +In `internal/cli/admin.go`, `account add`: register the flag and validate it. + +Add alongside the other `add` flags: + +```go + from := fs.String("from", "", "send-as address (blank = use username)") +``` + +After the required-fields check, before building `acc`: + +```go + if err := tui.ValidFromAddress(*from); err != nil { + fmt.Fprintln(errOut, err) + return 2 + } +``` + +Add `FromAddress: *from,` to the `store.Account{...}` literal. + +In `account edit`: register the flag: + +```go + from := fs.String("from", "", "send-as address (blank keeps existing)") +``` + +Add a case to the `fs.Visit` switch: + +```go + case "from": + if err := tui.ValidFromAddress(*from); err != nil { + fmt.Fprintln(errOut, err) + return // see note below + } + acc.FromAddress = *from +``` + +Because `fs.Visit`'s callback cannot return an exit code, instead validate `--from` before the `fs.Visit` block (the flag value is available regardless of Visit) and set the field inside Visit: + +```go + if err := tui.ValidFromAddress(*from); err != nil { + fmt.Fprintln(errOut, err) + return 2 + } + // ... existing GetAccount + fs.Visit ... + case "from": + acc.FromAddress = *from +``` + +Use this pre-Visit validation form (not the in-callback `return`). + +- [ ] **Step 9: Run the CLI suite to verify it passes** + +Run: `go test ./internal/cli/ -v` +Expected: PASS. + +- [ ] **Step 10: Build and vet the whole module** + +Run: `go build ./... && go vet ./... && go test ./...` +Expected: clean build, no vet complaints, all tests PASS. + +- [ ] **Step 11: Commit** + +```bash +git add internal/cli/send.go internal/cli/admin.go internal/cli/send_test.go internal/tui/account.go internal/tui/account_test.go +git commit -m "feat(cli): configurable send-as From address (flags, TUI, validation) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Notes for the implementer + +- The `account list` output (`admin.go`, `case "list"`) shows NAME/MODE/IMAP/USER. Adding a FROM column is optional polish, not required — leave it unless asked. +- `USER-MANUAL.md` / `README.md` mention `account add` flags; if they enumerate flags explicitly, add `--from` there in the relevant commit. Grep first: `grep -rn 'account add\|--username' README.md USER-MANUAL.md docs/`. +- Existing send tests in `internal/cli/send_test.go` define the harness shape — read them before writing Task 3 Step 6 rather than inventing a new fake.