9 Commits

Author SHA1 Message Date
steve bd06b4b900 chore(release): default installer to v0.5.0
release / release (push) Successful in 40s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:38:01 +01:00
steve 8e5c06a4cb style: fix test name typo, table-test reporting, validator wording
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:32:33 +01:00
steve 32f5a8d933 fix(cli): clarify edit --from help; test edit --from validation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:29:37 +01:00
steve b6e68ddeae feat(cli): configurable send-as From address (flags, TUI, validation)
- tui.ValidFromAddress: exported validator; blank passes, malformed rejects
- Fields.FromAddress: new field, round-trips through ToAccount/FieldsFromAccount
- Fields.Validate: calls ValidFromAddress before returning nil
- TUI form: from_address fieldDef between username and password
- send.go: From set via acc.SendFrom() instead of acc.Username
- admin.go account add: --from flag with pre-parse validation
- admin.go account edit: --from flag; validate before Visit, apply in Visit
- USER-MANUAL.md: --from flag added to account add flags table

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:25:14 +01:00
steve 6a99e5bb6e feat(mail): derive bare envelope sender from display-name From
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:20:54 +01:00
steve c5e42ffbae fix(store): surface invalid schema_version; split migration test assertion
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:19:35 +01:00
steve cdffb15004 feat(store): add account from_address field + v2 migration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:16:15 +01:00
steve a4c49d4aca docs: implementation plan for send-as From address
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:12:28 +01:00
steve 852bb1dc5b docs: design for send-as From address field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:06:38 +01:00
18 changed files with 1117 additions and 30 deletions
+1
View File
@@ -191,6 +191,7 @@ emcli account add --name work --mode RW \
| `--smtp-security` | `tls` | `tls` or `starttls` | | `--smtp-security` | `tls` | `tls` or `starttls` |
| `--username` | — | Login username, usually your full email (required) | | `--username` | — | Login username, usually your full email (required) |
| `--password` | — | Login password or app password | | `--password` | — | Login password or app password |
| `--from` | — | Send-as address (blank = use username); bare or `"Display Name <addr>"` |
| `--subject-regex` | — | Inbound subject filter (optional) | | `--subject-regex` | — | Inbound subject filter (optional) |
| `--whitelist-in` | off | Enable inbound whitelist | | `--whitelist-in` | off | Enable inbound whitelist |
| `--whitelist-out` | off | Enable outbound 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.
+13
View File
@@ -45,6 +45,7 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
smtpSec := fs.String("smtp-security", "tls", "tls|starttls") smtpSec := fs.String("smtp-security", "tls", "tls|starttls")
user := fs.String("username", "", "login username") user := fs.String("username", "", "login username")
pass := fs.String("password", "", "login password") pass := fs.String("password", "", "login password")
from := fs.String("from", "", "send-as address (blank = use username)")
subj := fs.String("subject-regex", "", "inbound subject filter") subj := fs.String("subject-regex", "", "inbound subject filter")
wlIn := fs.Bool("whitelist-in", false, "enable inbound whitelist") wlIn := fs.Bool("whitelist-in", false, "enable inbound whitelist")
wlOut := fs.Bool("whitelist-out", false, "enable outbound 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") fmt.Fprintln(errOut, "name, imap-host, and username are required")
return 2 return 2
} }
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
acc := store.Account{ acc := store.Account{
Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec, Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
AuthType: "password", Username: *user, Password: *pass, AuthType: "password", Username: *user, Password: *pass,
FromAddress: *from,
SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut, SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut,
ProcessBacklog: *backlog, 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") smtpSec := fs.String("smtp-security", "", "tls|starttls")
user := fs.String("username", "", "login username") user := fs.String("username", "", "login username")
pass := fs.String("password", "", "login password (blank keeps existing)") 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") subj := fs.String("subject-regex", "", "inbound subject filter")
if err := fs.Parse(rest); err != nil { if err := fs.Parse(rest); err != nil {
return 2 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 if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled
return editInteractive(st, *name, out, errOut) return editInteractive(st, *name, out, errOut)
} }
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
acc, err := st.GetAccount(*name) acc, err := st.GetAccount(*name)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err) 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 acc.Username = *user
case "password": case "password":
acc.Password = *pass acc.Password = *pass
case "from":
acc.FromAddress = *from
case "subject-regex": case "subject-regex":
acc.SubjectRegex = *subj acc.SubjectRegex = *subj
} }
+14
View File
@@ -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")
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func SendCmd(d Deps, account string, to, cc, bcc []string, subject, body string,
} }
msg := mail.OutgoingMessage{ 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, Subject: subject, BodyText: body,
} }
recipients := msg.Recipients() recipients := msg.Recipients()
+26
View File
@@ -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) { func TestSendReplyToFilteredSourceNotFound(t *testing.T) {
acc := rwAccount() acc := rwAccount()
acc.WhitelistInEnabled = true // inbound filter active acc.WhitelistInEnabled = true // inbound filter active
+13 -1
View File
@@ -46,6 +46,18 @@ func (m OutgoingMessage) Recipients() []string {
return out 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 { func addrList(addrs []string) []*gomail.Address {
out := make([]*gomail.Address, 0, len(addrs)) out := make([]*gomail.Address, 0, len(addrs))
for _, a := range addrs { for _, a := range addrs {
@@ -163,7 +175,7 @@ func SendSMTP(cfg SMTPConfig, m OutgoingMessage) error {
if err := c.Auth(auth); err != nil { if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err) 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 fmt.Errorf("smtp send: %w", err)
} }
return c.Quit() return c.Quit()
+30
View File
@@ -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) { func TestReadHeaderParsesReferences(t *testing.T) {
raw := "From: a@x.com\r\n" + raw := "From: a@x.com\r\n" +
"To: b@x.com\r\n" + "To: b@x.com\r\n" +
+25 -14
View File
@@ -23,6 +23,7 @@ type Account struct {
SMTPSecurity string // tls | starttls SMTPSecurity string // tls | starttls
AuthType string // password | oauth2 AuthType string // password | oauth2
Username string Username string
FromAddress string // send-as identity; blank ⇒ fall back to Username
Password string // decrypted; empty in ListAccounts Password string // decrypted; empty in ListAccounts
WhitelistInEnabled bool WhitelistInEnabled bool
WhitelistOutEnabled bool WhitelistOutEnabled bool
@@ -30,6 +31,15 @@ type Account struct {
ProcessBacklog bool 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) { func (s *Store) AddAccount(a Account) (int64, error) {
var encPw []byte var encPw []byte
if a.Password != "" { if a.Password != "" {
@@ -42,12 +52,12 @@ func (s *Store) AddAccount(a Account) (int64, error) {
res, err := s.db.Exec(` res, err := s.db.Exec(`
INSERT INTO accounts INSERT INTO accounts
(name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security, (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) enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity, a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity), 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), encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog)) nullStr(a.SubjectRegex), b2i(a.ProcessBacklog))
if err != nil { if err != nil {
@@ -59,7 +69,7 @@ func (s *Store) AddAccount(a Account) (int64, error) {
func (s *Store) GetAccount(name string) (Account, error) { func (s *Store) GetAccount(name string) (Account, error) {
row := s.db.QueryRow(` row := s.db.QueryRow(`
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security, 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 enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
FROM accounts WHERE name = ?`, name) FROM accounts WHERE name = ?`, name)
a, encPw, err := scanAccount(row) a, encPw, err := scanAccount(row)
@@ -82,7 +92,7 @@ func (s *Store) GetAccount(name string) (Account, error) {
func (s *Store) ListAccounts() ([]Account, error) { func (s *Store) ListAccounts() ([]Account, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security, 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 enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
FROM accounts ORDER BY name`) FROM accounts ORDER BY name`)
if err != nil { if err != nil {
@@ -108,12 +118,12 @@ func (s *Store) UpdateAccount(a Account) error {
// Build the SET clause, conditionally including secret columns. // Build the SET clause, conditionally including secret columns.
set := `mode=?, imap_host=?, imap_port=?, imap_security=?, set := `mode=?, imap_host=?, imap_port=?, imap_security=?,
smtp_host=?, smtp_port=?, smtp_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=?` whitelist_in_enabled=?, whitelist_out_enabled=?, subject_regex=?, process_backlog=?`
args := []any{ args := []any{
a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity), 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), b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog), nullStr(a.SubjectRegex), b2i(a.ProcessBacklog),
} }
@@ -152,16 +162,16 @@ type scanner interface{ Scan(dest ...any) error }
func scanAccount(sc scanner) (Account, []byte, error) { func scanAccount(sc scanner) (Account, []byte, error) {
var ( var (
a Account a Account
encPw []byte encPw []byte
subj, smtpHost, smtpSec sql.NullString subj, smtpHost, smtpSec, fromAddr sql.NullString
smtpPort sql.NullInt64 smtpPort sql.NullInt64
wlIn, wlOut int wlIn, wlOut int
backlog int backlog int
) )
err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity, err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity,
&smtpHost, &smtpPort, &smtpSec, &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 { if err != nil {
return Account{}, nil, err return Account{}, nil, err
} }
@@ -172,6 +182,7 @@ func scanAccount(sc scanner) (Account, []byte, error) {
a.WhitelistOutEnabled = wlOut != 0 a.WhitelistOutEnabled = wlOut != 0
a.ProcessBacklog = backlog != 0 a.ProcessBacklog = backlog != 0
a.SubjectRegex = subj.String a.SubjectRegex = subj.String
a.FromAddress = fromAddr.String
return a, encPw, nil return a, encPw, nil
} }
+27
View File
@@ -84,3 +84,30 @@ func TestListAccountsOmitsSecrets(t *testing.T) {
t.Fatal("ListAccounts must not return secrets") 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)
}
}
+3 -2
View File
@@ -1,8 +1,8 @@
package store 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 = ` const schemaSQL = `
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS accounts (
smtp_security TEXT, smtp_security TEXT,
auth_type TEXT NOT NULL CHECK (auth_type IN ('password','oauth2')), auth_type TEXT NOT NULL CHECK (auth_type IN ('password','oauth2')),
username TEXT NOT NULL, username TEXT NOT NULL,
from_address TEXT,
enc_password BLOB, enc_password BLOB,
enc_oauth_client_id BLOB, enc_oauth_client_id BLOB,
enc_oauth_client_secret BLOB, enc_oauth_client_secret BLOB,
+29 -5
View File
@@ -42,15 +42,39 @@ func Open(path string) (*Store, error) {
return nil, fmt.Errorf("apply schema: %w", err) return nil, fmt.Errorf("apply schema: %w", err)
} }
s := &Store{db: db} s := &Store{db: db}
if _, err := s.GetSetting("schema_version"); err != nil { if err := s.migrate(); err != nil {
if err := s.SetSetting("schema_version", strconv.Itoa(schemaVersion)); err != nil { db.Close()
db.Close() return nil, err
return nil, err
}
} }
return s, nil 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() } func (s *Store) Close() error { return s.db.Close() }
// DefaultDBPath resolves EMCLI_DB or the per-OS default location. // DefaultDBPath resolves EMCLI_DB or the per-OS default location.
+66 -2
View File
@@ -1,6 +1,7 @@
package store package store
import ( import (
"database/sql"
"path/filepath" "path/filepath"
"testing" "testing"
) )
@@ -28,7 +29,7 @@ func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
t.Fatalf("first Open: %v", err) t.Fatalf("first Open: %v", err)
} }
v, err := s.GetSetting("schema_version") 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) t.Fatalf("schema_version: %q err=%v", v, err)
} }
s.Close() s.Close()
@@ -39,7 +40,7 @@ func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
t.Fatalf("second Open: %v", err) t.Fatalf("second Open: %v", err)
} }
defer s2.Close() 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) 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) 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
View File
@@ -6,6 +6,7 @@ package tui
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/mail"
"strconv" "strconv"
"strings" "strings"
@@ -22,10 +23,24 @@ type Fields struct {
IMAPHost, IMAPPort, IMAPSecurity string IMAPHost, IMAPPort, IMAPSecurity string
SMTPHost, SMTPPort, SMTPSecurity string SMTPHost, SMTPPort, SMTPSecurity string
Username, Password string Username, Password string
FromAddress string
WhitelistIn, WhitelistOut, ProcessBacklog bool WhitelistIn, WhitelistOut, ProcessBacklog bool
SubjectRegex string 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" } func validSecurity(s string) bool { return s == "tls" || s == "starttls" }
// Validate checks required fields, enum fields, and numeric ports. RW accounts // 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") return errors.New("smtp port must be a number")
} }
} }
if err := ValidFromAddress(f.FromAddress); err != nil {
return err
}
return nil return nil
} }
@@ -71,6 +89,7 @@ func (f Fields) ToAccount() (store.Account, bool) {
Name: strings.TrimSpace(f.Name), Mode: f.Mode, Name: strings.TrimSpace(f.Name), Mode: f.Mode,
IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity, IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity,
AuthType: "password", Username: f.Username, Password: f.Password, AuthType: "password", Username: f.Username, Password: f.Password,
FromAddress: f.FromAddress,
WhitelistInEnabled: f.WhitelistIn, WhitelistOutEnabled: f.WhitelistOut, WhitelistInEnabled: f.WhitelistIn, WhitelistOutEnabled: f.WhitelistOut,
SubjectRegex: f.SubjectRegex, ProcessBacklog: f.ProcessBacklog, SubjectRegex: f.SubjectRegex, ProcessBacklog: f.ProcessBacklog,
} }
@@ -95,7 +114,8 @@ func FieldsFromAccount(a store.Account) Fields {
Name: a.Name, Mode: a.Mode, Name: a.Name, Mode: a.Mode,
IMAPHost: a.IMAPHost, IMAPPort: itoaPort(a.IMAPPort), IMAPSecurity: a.IMAPSecurity, IMAPHost: a.IMAPHost, IMAPPort: itoaPort(a.IMAPPort), IMAPSecurity: a.IMAPSecurity,
SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity, SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity,
Username: a.Username, Username: a.Username,
FromAddress: a.FromAddress,
WhitelistIn: a.WhitelistInEnabled, WhitelistIn: a.WhitelistInEnabled,
WhitelistOut: a.WhitelistOutEnabled, WhitelistOut: a.WhitelistOutEnabled,
ProcessBacklog: a.ProcessBacklog, ProcessBacklog: a.ProcessBacklog,
@@ -122,6 +142,7 @@ var fieldDefs = []fieldDef{
{key: "smtp_port", label: "SMTP port (RW)"}, {key: "smtp_port", label: "SMTP port (RW)"},
{key: "smtp_security", label: "SMTP security (tls/starttls)"}, {key: "smtp_security", label: "SMTP security (tls/starttls)"},
{key: "username", label: "Username"}, {key: "username", label: "Username"},
{key: "from_address", label: "From address (optional)"},
{key: "password", label: "Password", password: true}, {key: "password", label: "Password", password: true},
{key: "whitelist_in", label: "Whitelist inbound (y/n)", isBool: true}, {key: "whitelist_in", label: "Whitelist inbound (y/n)", isBool: true},
{key: "whitelist_out", label: "Whitelist outbound (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 return f.SMTPSecurity
case "username": case "username":
return f.Username return f.Username
case "from_address":
return f.FromAddress
case "password": case "password":
return f.Password return f.Password
case "whitelist_in": case "whitelist_in":
@@ -249,6 +272,8 @@ func (m AccountForm) collect() Fields {
f.SMTPSecurity = strings.ToLower(v) f.SMTPSecurity = strings.ToLower(v)
case "username": case "username":
f.Username = v f.Username = v
case "from_address":
f.FromAddress = v
case "password": case "password":
f.Password = m.inputs[i].Value() // do not trim a password f.Password = m.inputs[i].Value() // do not trim a password
case "whitelist_in": case "whitelist_in":
+33
View File
@@ -157,3 +157,36 @@ func TestAccountFormCancel(t *testing.T) {
t.Fatal("esc should cancel the form") 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)
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ checksum, makes it executable in `~/.local/bin` (ensure that's on your PATH), an
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `EMCLI_VERSION` | `v0.4.1` | Release tag to fetch | | `EMCLI_VERSION` | `v0.5.0` | Release tag to fetch |
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL | | `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location | | `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
+3 -3
View File
@@ -7,17 +7,17 @@
# bash install.sh # bash install.sh
# #
# Environment overrides: # Environment overrides:
# EMCLI_VERSION release tag to fetch (default: v0.4.1) # EMCLI_VERSION release tag to fetch (default: v0.5.0)
# EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli) # EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli)
# EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin) # EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
# #
# Release assets follow this naming scheme: # Release assets follow this naming scheme:
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.4.1_linux_amd64 # emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.5.0_linux_amd64
# checksums.txt (sha256, one "<sum> <asset>" line per asset) # checksums.txt (sha256, one "<sum> <asset>" line per asset)
set -euo pipefail set -euo pipefail
VERSION="${EMCLI_VERSION:-v0.4.1}" VERSION="${EMCLI_VERSION:-v0.5.0}"
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}" BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}" INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"