Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e5c06a4cb | |||
| 32f5a8d933 | |||
| b6e68ddeae | |||
| 6a99e5bb6e | |||
| c5e42ffbae | |||
| cdffb15004 | |||
| a4c49d4aca | |||
| 852bb1dc5b |
@@ -191,6 +191,7 @@ emcli account add --name work --mode RW \
|
||||
| `--smtp-security` | `tls` | `tls` or `starttls` |
|
||||
| `--username` | — | Login username, usually your full email (required) |
|
||||
| `--password` | — | Login password or app password |
|
||||
| `--from` | — | Send-as address (blank = use username); bare or `"Display Name <addr>"` |
|
||||
| `--subject-regex` | — | Inbound subject filter (optional) |
|
||||
| `--whitelist-in` | off | Enable inbound whitelist |
|
||||
| `--whitelist-out` | off | Enable outbound whitelist |
|
||||
|
||||
@@ -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 <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`:
|
||||
|
||||
```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:
|
||||
|
||||
```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) <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`:
|
||||
|
||||
```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`):
|
||||
|
||||
```go
|
||||
// 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:
|
||||
|
||||
```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) <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`:
|
||||
|
||||
```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):
|
||||
|
||||
```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 <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`:
|
||||
|
||||
```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 <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:
|
||||
|
||||
```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) <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.
|
||||
@@ -0,0 +1,118 @@
|
||||
# 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.
|
||||
@@ -45,6 +45,7 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
smtpSec := fs.String("smtp-security", "tls", "tls|starttls")
|
||||
user := fs.String("username", "", "login username")
|
||||
pass := fs.String("password", "", "login password")
|
||||
from := fs.String("from", "", "send-as address (blank = use username)")
|
||||
subj := fs.String("subject-regex", "", "inbound subject filter")
|
||||
wlIn := fs.Bool("whitelist-in", false, "enable inbound whitelist")
|
||||
wlOut := fs.Bool("whitelist-out", false, "enable outbound whitelist")
|
||||
@@ -56,9 +57,14 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
fmt.Fprintln(errOut, "name, imap-host, and username are required")
|
||||
return 2
|
||||
}
|
||||
if err := tui.ValidFromAddress(*from); err != nil {
|
||||
fmt.Fprintln(errOut, err)
|
||||
return 2
|
||||
}
|
||||
acc := store.Account{
|
||||
Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
|
||||
AuthType: "password", Username: *user, Password: *pass,
|
||||
FromAddress: *from,
|
||||
SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut,
|
||||
ProcessBacklog: *backlog,
|
||||
}
|
||||
@@ -85,6 +91,7 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
smtpSec := fs.String("smtp-security", "", "tls|starttls")
|
||||
user := fs.String("username", "", "login username")
|
||||
pass := fs.String("password", "", "login password (blank keeps existing)")
|
||||
from := fs.String("from", "", "send-as address (empty reverts to username)")
|
||||
subj := fs.String("subject-regex", "", "inbound subject filter")
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return 2
|
||||
@@ -96,6 +103,10 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled
|
||||
return editInteractive(st, *name, out, errOut)
|
||||
}
|
||||
if err := tui.ValidFromAddress(*from); err != nil {
|
||||
fmt.Fprintln(errOut, err)
|
||||
return 2
|
||||
}
|
||||
acc, err := st.GetAccount(*name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "edit: %v\n", err)
|
||||
@@ -122,6 +133,8 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
acc.Username = *user
|
||||
case "password":
|
||||
acc.Password = *pass
|
||||
case "from":
|
||||
acc.FromAddress = *from
|
||||
case "subject-regex":
|
||||
acc.SubjectRegex = *subj
|
||||
}
|
||||
|
||||
@@ -132,3 +132,17 @@ func TestAuditListCoreRenders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountEditFromValidationRejectsMalformed(t *testing.T) {
|
||||
adminEnv(t)
|
||||
// Seed an account so the failure is from --from validation, not a missing account.
|
||||
run(t, "account", "add", "--name", "valacc", "--imap-host", "imap.x.com", "--username", "u@x.com")
|
||||
// A malformed --from value must be rejected with exit code 2 before touching the account.
|
||||
code, _, errStr := run(t, "account", "edit", "--name", "valacc", "--from", "not an address")
|
||||
if code != 2 {
|
||||
t.Fatalf("expected exit code 2 for malformed --from, got %d (stderr: %q)", code, errStr)
|
||||
}
|
||||
if errStr == "" {
|
||||
t.Fatal("expected an error message on stderr for malformed --from, got none")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func SendCmd(d Deps, account string, to, cc, bcc []string, subject, body string,
|
||||
}
|
||||
|
||||
msg := mail.OutgoingMessage{
|
||||
From: acc.Username, To: to, Cc: cc, Bcc: bcc,
|
||||
From: acc.SendFrom(), To: to, Cc: cc, Bcc: bcc,
|
||||
Subject: subject, BodyText: body,
|
||||
}
|
||||
recipients := msg.Recipients()
|
||||
|
||||
@@ -139,6 +139,32 @@ func TestSendReplyToThreadsHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendReplyToFilteredSourceNotFound(t *testing.T) {
|
||||
acc := rwAccount()
|
||||
acc.WhitelistInEnabled = true // inbound filter active
|
||||
|
||||
+13
-1
@@ -46,6 +46,18 @@ func (m OutgoingMessage) Recipients() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func addrList(addrs []string) []*gomail.Address {
|
||||
out := make([]*gomail.Address, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
@@ -163,7 +175,7 @@ func SendSMTP(cfg SMTPConfig, m OutgoingMessage) error {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
if err := c.SendMail(m.From, m.Recipients(), bytes.NewReader(raw)); err != nil {
|
||||
if err := c.SendMail(envelopeFrom(m.From), m.Recipients(), bytes.NewReader(raw)); err != nil {
|
||||
return fmt.Errorf("smtp send: %w", err)
|
||||
}
|
||||
return c.Quit()
|
||||
|
||||
@@ -100,6 +100,36 @@ func TestRecipientsCombinesAllFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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.Errorf("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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHeaderParsesReferences(t *testing.T) {
|
||||
raw := "From: a@x.com\r\n" +
|
||||
"To: b@x.com\r\n" +
|
||||
|
||||
+25
-14
@@ -23,6 +23,7 @@ type Account struct {
|
||||
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
|
||||
@@ -30,6 +31,15 @@ type Account struct {
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Store) AddAccount(a Account) (int64, error) {
|
||||
var encPw []byte
|
||||
if a.Password != "" {
|
||||
@@ -42,12 +52,12 @@ func (s *Store) AddAccount(a Account) (int64, error) {
|
||||
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,
|
||||
auth_type,username,from_address,
|
||||
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
|
||||
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
|
||||
a.AuthType, a.Username,
|
||||
a.AuthType, a.Username, nullStr(a.FromAddress),
|
||||
encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
|
||||
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog))
|
||||
if err != nil {
|
||||
@@ -59,7 +69,7 @@ func (s *Store) AddAccount(a Account) (int64, error) {
|
||||
func (s *Store) GetAccount(name string) (Account, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
|
||||
auth_type,username,
|
||||
auth_type,username,from_address,
|
||||
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
|
||||
FROM accounts WHERE name = ?`, name)
|
||||
a, encPw, err := scanAccount(row)
|
||||
@@ -82,7 +92,7 @@ func (s *Store) GetAccount(name string) (Account, error) {
|
||||
func (s *Store) ListAccounts() ([]Account, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
|
||||
auth_type,username,
|
||||
auth_type,username,from_address,
|
||||
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
|
||||
FROM accounts ORDER BY name`)
|
||||
if err != nil {
|
||||
@@ -108,12 +118,12 @@ func (s *Store) UpdateAccount(a Account) error {
|
||||
// Build the SET clause, conditionally including secret columns.
|
||||
set := `mode=?, imap_host=?, imap_port=?, imap_security=?,
|
||||
smtp_host=?, smtp_port=?, smtp_security=?,
|
||||
auth_type=?, username=?,
|
||||
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,
|
||||
a.AuthType, a.Username, nullStr(a.FromAddress),
|
||||
b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
|
||||
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog),
|
||||
}
|
||||
@@ -152,16 +162,16 @@ type scanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanAccount(sc scanner) (Account, []byte, error) {
|
||||
var (
|
||||
a Account
|
||||
encPw []byte
|
||||
subj, smtpHost, smtpSec sql.NullString
|
||||
smtpPort sql.NullInt64
|
||||
wlIn, wlOut int
|
||||
backlog int
|
||||
a Account
|
||||
encPw []byte
|
||||
subj, smtpHost, smtpSec, fromAddr sql.NullString
|
||||
smtpPort sql.NullInt64
|
||||
wlIn, wlOut int
|
||||
backlog int
|
||||
)
|
||||
err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity,
|
||||
&smtpHost, &smtpPort, &smtpSec,
|
||||
&a.AuthType, &a.Username, &encPw, &wlIn, &wlOut, &subj, &backlog)
|
||||
&a.AuthType, &a.Username, &fromAddr, &encPw, &wlIn, &wlOut, &subj, &backlog)
|
||||
if err != nil {
|
||||
return Account{}, nil, err
|
||||
}
|
||||
@@ -172,6 +182,7 @@ func scanAccount(sc scanner) (Account, []byte, error) {
|
||||
a.WhitelistOutEnabled = wlOut != 0
|
||||
a.ProcessBacklog = backlog != 0
|
||||
a.SubjectRegex = subj.String
|
||||
a.FromAddress = fromAddr.String
|
||||
return a, encPw, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -84,3 +84,30 @@ func TestListAccountsOmitsSecrets(t *testing.T) {
|
||||
t.Fatal("ListAccounts must not return secrets")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package store
|
||||
|
||||
const schemaVersion = 1
|
||||
const schemaVersion = 2
|
||||
|
||||
// schemaSQL is the full v1 schema. All statements are idempotent via IF NOT EXISTS.
|
||||
// schemaSQL is the full current schema. All statements are idempotent via IF NOT EXISTS.
|
||||
const schemaSQL = `
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS accounts (
|
||||
smtp_security TEXT,
|
||||
auth_type TEXT NOT NULL CHECK (auth_type IN ('password','oauth2')),
|
||||
username TEXT NOT NULL,
|
||||
from_address TEXT,
|
||||
enc_password BLOB,
|
||||
enc_oauth_client_id BLOB,
|
||||
enc_oauth_client_secret BLOB,
|
||||
|
||||
+29
-5
@@ -42,15 +42,39 @@ func Open(path string) (*Store, error) {
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
var ver int
|
||||
ver, err = strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid schema_version %q: %w", v, err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// DefaultDBPath resolves EMCLI_DB or the per-OS default location.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -28,7 +29,7 @@ func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
|
||||
t.Fatalf("first Open: %v", err)
|
||||
}
|
||||
v, err := s.GetSetting("schema_version")
|
||||
if err != nil || v != "1" {
|
||||
if err != nil || v != "2" {
|
||||
t.Fatalf("schema_version: %q err=%v", v, err)
|
||||
}
|
||||
s.Close()
|
||||
@@ -39,7 +40,7 @@ func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
|
||||
t.Fatalf("second Open: %v", err)
|
||||
}
|
||||
defer s2.Close()
|
||||
if v, _ := s2.GetSetting("schema_version"); v != "1" {
|
||||
if v, _ := s2.GetSetting("schema_version"); v != "2" {
|
||||
t.Fatalf("schema_version after reopen: %q", v)
|
||||
}
|
||||
}
|
||||
@@ -104,3 +105,66 @@ func TestForeignKeyCascade(t *testing.T) {
|
||||
t.Fatalf("whitelist_in row not cascade-deleted: count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("want 1 account after migrate, got %d", len(accs))
|
||||
}
|
||||
if accs[0].FromAddress != "" {
|
||||
t.Fatalf("legacy account FromAddress should be empty, got %q", accs[0].FromAddress)
|
||||
}
|
||||
if got := accs[0].SendFrom(); got != "login@example.com" {
|
||||
t.Fatalf("legacy account should send from username, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -6,6 +6,7 @@ package tui
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -22,10 +23,24 @@ type Fields struct {
|
||||
IMAPHost, IMAPPort, IMAPSecurity string
|
||||
SMTPHost, SMTPPort, SMTPSecurity string
|
||||
Username, Password string
|
||||
FromAddress string
|
||||
WhitelistIn, WhitelistOut, ProcessBacklog bool
|
||||
SubjectRegex string
|
||||
}
|
||||
|
||||
// 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 or \"Name <email>\"")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validSecurity(s string) bool { return s == "tls" || s == "starttls" }
|
||||
|
||||
// Validate checks required fields, enum fields, and numeric ports. RW accounts
|
||||
@@ -60,6 +75,9 @@ func (f Fields) Validate() error {
|
||||
return errors.New("smtp port must be a number")
|
||||
}
|
||||
}
|
||||
if err := ValidFromAddress(f.FromAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,6 +89,7 @@ func (f Fields) ToAccount() (store.Account, bool) {
|
||||
Name: strings.TrimSpace(f.Name), Mode: f.Mode,
|
||||
IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity,
|
||||
AuthType: "password", Username: f.Username, Password: f.Password,
|
||||
FromAddress: f.FromAddress,
|
||||
WhitelistInEnabled: f.WhitelistIn, WhitelistOutEnabled: f.WhitelistOut,
|
||||
SubjectRegex: f.SubjectRegex, ProcessBacklog: f.ProcessBacklog,
|
||||
}
|
||||
@@ -95,7 +114,8 @@ func FieldsFromAccount(a store.Account) Fields {
|
||||
Name: a.Name, Mode: a.Mode,
|
||||
IMAPHost: a.IMAPHost, IMAPPort: itoaPort(a.IMAPPort), IMAPSecurity: a.IMAPSecurity,
|
||||
SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity,
|
||||
Username: a.Username,
|
||||
Username: a.Username,
|
||||
FromAddress: a.FromAddress,
|
||||
WhitelistIn: a.WhitelistInEnabled,
|
||||
WhitelistOut: a.WhitelistOutEnabled,
|
||||
ProcessBacklog: a.ProcessBacklog,
|
||||
@@ -122,6 +142,7 @@ var fieldDefs = []fieldDef{
|
||||
{key: "smtp_port", label: "SMTP port (RW)"},
|
||||
{key: "smtp_security", label: "SMTP security (tls/starttls)"},
|
||||
{key: "username", label: "Username"},
|
||||
{key: "from_address", label: "From address (optional)"},
|
||||
{key: "password", label: "Password", password: true},
|
||||
{key: "whitelist_in", label: "Whitelist inbound (y/n)", isBool: true},
|
||||
{key: "whitelist_out", label: "Whitelist outbound (y/n)", isBool: true},
|
||||
@@ -164,6 +185,8 @@ func fieldValue(f Fields, key string) string {
|
||||
return f.SMTPSecurity
|
||||
case "username":
|
||||
return f.Username
|
||||
case "from_address":
|
||||
return f.FromAddress
|
||||
case "password":
|
||||
return f.Password
|
||||
case "whitelist_in":
|
||||
@@ -249,6 +272,8 @@ func (m AccountForm) collect() Fields {
|
||||
f.SMTPSecurity = strings.ToLower(v)
|
||||
case "username":
|
||||
f.Username = v
|
||||
case "from_address":
|
||||
f.FromAddress = v
|
||||
case "password":
|
||||
f.Password = m.inputs[i].Value() // do not trim a password
|
||||
case "whitelist_in":
|
||||
|
||||
@@ -157,3 +157,36 @@ func TestAccountFormCancel(t *testing.T) {
|
||||
t.Fatal("esc should cancel the form")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user