Files
emcli/docs/superpowers/plans/2026-06-23-send-from-address.md
2026-06-23 20:12:28 +01:00

24 KiB

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 <addr>). 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:

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 <me@stevecliff.com>"
	if got := a.SendFrom(); got != "Steve Cliff <me@stevecliff.com>" {
		t.Fatalf("set from-address should win, got %q", got)
	}
}

func TestAddGetAccountRoundTripsFromAddress(t *testing.T) {
	s := openTemp(t)
	a := sampleAccount()
	a.FromAddress = "Steve Cliff <me@stevecliff.com>"
	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 <me@stevecliff.com>" {
		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:

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:

	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:

		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):

	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:

	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:

	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:

  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:

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:

	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:

	s := &Store{db: db}
	if err := s.migrate(); err != nil {
		db.Close()
		return nil, err
	}
	return s, nil

and add the method:

// 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
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) <noreply@anthropic.com>"

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 <addr>.

  • 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:

func TestEnvelopeFromStripsDisplayName(t *testing.T) {
	cases := map[string]string{
		"Steve Cliff <me@stevecliff.com>": "me@stevecliff.com",
		"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 <me@stevecliff.com>",
		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):

// envelopeFrom returns the bare address for the SMTP envelope sender, stripping
// any display name. A display-name From (e.g. "Name <addr>") 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:

	if err := c.SendMail(m.From, m.Recipients(), bytes.NewReader(raw)); err != nil {

to:

	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
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) <noreply@anthropic.com>"

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:

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 <me@stevecliff.com>"
	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 <me@stevecliff.com>"
	acc, _ := f.ToAccount()
	if acc.FromAddress != "Steve Cliff <me@stevecliff.com>" {
		t.Fatalf("ToAccount lost FromAddress: %q", acc.FromAddress)
	}
	back := FieldsFromAccount(acc)
	if back.FromAddress != "Steve Cliff <me@stevecliff.com>" {
		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):

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:

// ValidFromAddress returns an error if s is set but is not a valid RFC 5322
// address (bare or "Display Name <addr>"). 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:

	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):

		AuthType: "password", Username: f.Username, Password: f.Password,
		FromAddress: f.FromAddress,

In FieldsFromAccount, prefill it (after Username: a.Username,):

		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):

	{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:

func TestSendUsesConfiguredFromAddress(t *testing.T) {
	acc := rwAccount()
	acc.FromAddress = "Steve Cliff <me@stevecliff.com>"
	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 <me@stevecliff.com>" {
		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:

	msg := mail.OutgoingMessage{
		From: acc.Username, To: to, Cc: cc, Bcc: bcc,
		Subject: subject, BodyText: body,
	}

to:

	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:

		from := fs.String("from", "", "send-as address (blank = use username)")

After the required-fields check, before building acc:

		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:

		from := fs.String("from", "", "send-as address (blank keeps existing)")

Add a case to the fs.Visit switch:

			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:

		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
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) <noreply@anthropic.com>"

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.