TDD task-by-task plan for the read-only emcli: crypto, encrypted store, seen-set read state, policy filtering, IMAP read, and the agent list/get/search/ack commands with flag-based admin. Phases 2-4 (send, OAuth2, TUI) to follow as their own plans. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
90 KiB
emcli Phase 1 — Foundation & Read Path 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: Build a working, testable read-only emcli — an encrypted-config CLI that lets an agent list, fetch, search, and acknowledge emails over IMAP, with all reads filtered by per-account policy and credentials never exposed.
Architecture: A single Go binary organised into focused internal/ packages: crypto (AES-256-GCM field encryption), store (pure-Go SQLite config + seen-set read state + audit), policy (pure enforcement functions), mail (IMAP read + message parsing), and cli (JSON-envelope command dispatch). Reads are stateless; the only state-mutating command is ack. This phase uses password auth only; OAuth2, SMTP send, and the TUI arrive in later phases.
Tech Stack: Go (no CGO), modernc.org/sqlite, github.com/emersion/go-imap + github.com/emersion/go-message, Go stdlib crypto/aes+crypto/cipher. Tests use Go's testing package; IMAP integration tests run against a greenmail container.
Global Constraints
- Language: Go, CGO disabled (
CGO_ENABLED=0) — the binary must be a single static cross-platform executable. - SQLite driver:
modernc.org/sqlite(pure Go), registered driver name"sqlite". Nevermattn/go-sqlite3. - Encryption key source: env var
EMCLI_KEY, a base64-standard-encoded 32-byte key (AES-256). Missing/malformed ⇒ fail closed, never a plaintext fallback. - DB path source: env var
EMCLI_DB; default~/.config/emcli/emcli.db(%AppData%\emcli\emcli.dbon Windows). - Secret columns (
enc_password,enc_oauth_*) are stored ascrypto.Sealoutput. Secrets never appear in stdout, error details, or the audit log. - Every agent command prints exactly one JSON object:
{"error":bool,"error_detail":obj,"data":obj}and nothing else on stdout. - "New" = message exists in folder AND
uid > floor_uidANDuid ∉ acked. Reads never mutate floor/ack state; onlyackdoes. - Module path:
git.dcglab.co.uk/steve/emcli(used in all import paths below).
Task 1: Project scaffold & build
Files:
- Create:
go.mod - Create:
cmd/emcli/main.go - Create:
internal/version/version.go - Create:
Makefile - Test:
internal/version/version_test.go
Interfaces:
-
Consumes: nothing.
-
Produces:
version.String(string constant); binary entrypointcmd/emcli. -
Step 1: Initialise the module
Run:
cd /home/steve/src/emcli
go mod init git.dcglab.co.uk/steve/emcli
go mod edit -go=1.22
- Step 2: Write the failing test
Create internal/version/version_test.go:
package version
import "testing"
func TestStringIsSemver(t *testing.T) {
if String == "" {
t.Fatal("version.String must not be empty")
}
if String[0] != 'v' {
t.Fatalf("version %q must start with 'v'", String)
}
}
- Step 3: Run test to verify it fails
Run: go test ./internal/version/
Expected: FAIL — undefined: String.
- Step 4: Write minimal implementation
Create internal/version/version.go:
// Package version holds the build version string.
package version
// String is the emcli version. Overridden at release via -ldflags.
var String = "v0.1.0-dev"
- Step 5: Write the entrypoint
Create cmd/emcli/main.go:
package main
import (
"fmt"
"os"
"git.dcglab.co.uk/steve/emcli/internal/version"
)
func main() {
if len(os.Args) >= 2 && os.Args[1] == "version" {
fmt.Println(version.String)
return
}
fmt.Fprintln(os.Stderr, "emcli: no command given")
os.Exit(2)
}
- Step 6: Write the Makefile
Create Makefile:
BINARY := emcli
LDFLAGS := -s -w
.PHONY: build test vet
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/emcli
test:
go test ./...
vet:
go vet ./...
- Step 7: Run test and build to verify they pass
Run: go test ./internal/version/ && make build && ./emcli version
Expected: test PASS; build succeeds; prints v0.1.0-dev.
- Step 8: Commit
git add go.mod cmd internal/version Makefile
git commit -m "feat: project scaffold, version command, build"
Task 2: crypto — key loading & AES-256-GCM field encryption
Files:
- Create:
internal/crypto/crypto.go - Test:
internal/crypto/crypto_test.go
Interfaces:
-
Consumes: nothing.
-
Produces:
func KeyFromEnv() ([]byte, error)— readsEMCLI_KEY, base64-decodes, validates 32 bytes.func Seal(key, plaintext []byte) ([]byte, error)— returnsnonce||ciphertext(AES-256-GCM).func Open(key, blob []byte) ([]byte, error)— reverses Seal; auth failure ⇒ error.var ErrNoKey, ErrBadKey error.
-
Step 1: Write the failing tests
Create internal/crypto/crypto_test.go:
package crypto
import (
"bytes"
"encoding/base64"
"testing"
)
func testKey() []byte {
k := make([]byte, 32)
for i := range k {
k[i] = byte(i)
}
return k
}
func TestSealOpenRoundTrip(t *testing.T) {
key := testKey()
msg := []byte("hunter2-the-password")
blob, err := Seal(key, msg)
if err != nil {
t.Fatalf("Seal: %v", err)
}
if bytes.Contains(blob, msg) {
t.Fatal("ciphertext must not contain plaintext")
}
got, err := Open(key, blob)
if err != nil {
t.Fatalf("Open: %v", err)
}
if !bytes.Equal(got, msg) {
t.Fatalf("round-trip mismatch: %q", got)
}
}
func TestSealUsesRandomNonce(t *testing.T) {
key := testKey()
a, _ := Seal(key, []byte("x"))
b, _ := Seal(key, []byte("x"))
if bytes.Equal(a, b) {
t.Fatal("two seals of same plaintext must differ (random nonce)")
}
}
func TestOpenWrongKeyFails(t *testing.T) {
blob, _ := Seal(testKey(), []byte("secret"))
wrong := make([]byte, 32) // all zeros
if _, err := Open(wrong, blob); err == nil {
t.Fatal("Open with wrong key must fail")
}
}
func TestKeyFromEnv(t *testing.T) {
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString(testKey()))
k, err := KeyFromEnv()
if err != nil || len(k) != 32 {
t.Fatalf("KeyFromEnv: key=%d err=%v", len(k), err)
}
t.Setenv("EMCLI_KEY", "")
if _, err := KeyFromEnv(); err != ErrNoKey {
t.Fatalf("empty key: want ErrNoKey, got %v", err)
}
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort")))
if _, err := KeyFromEnv(); err != ErrBadKey {
t.Fatalf("short key: want ErrBadKey, got %v", err)
}
}
- Step 2: Run tests to verify they fail
Run: go test ./internal/crypto/
Expected: FAIL — undefined Seal, Open, KeyFromEnv.
- Step 3: Write the implementation
Create internal/crypto/crypto.go:
// Package crypto provides AES-256-GCM field encryption keyed from EMCLI_KEY.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
"os"
)
var (
ErrNoKey = errors.New("EMCLI_KEY is not set")
ErrBadKey = errors.New("EMCLI_KEY must be base64 of exactly 32 bytes")
)
// KeyFromEnv reads and validates the AES-256 key from EMCLI_KEY.
func KeyFromEnv() ([]byte, error) {
raw := os.Getenv("EMCLI_KEY")
if raw == "" {
return nil, ErrNoKey
}
key, err := base64.StdEncoding.DecodeString(raw)
if err != nil || len(key) != 32 {
return nil, ErrBadKey
}
return key, nil
}
func newGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
// Seal encrypts plaintext, returning nonce||ciphertext.
func Seal(key, plaintext []byte) ([]byte, error) {
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
// Open reverses Seal. A wrong key or tampered blob returns an error.
func Open(key, blob []byte) ([]byte, error) {
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
ns := gcm.NonceSize()
if len(blob) < ns {
return nil, errors.New("ciphertext too short")
}
nonce, ct := blob[:ns], blob[ns:]
return gcm.Open(nil, nonce, ct, nil)
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/crypto/
Expected: PASS (4 tests).
- Step 5: Commit
git add internal/crypto
git commit -m "feat(crypto): AES-256-GCM field encryption keyed from EMCLI_KEY"
Task 3: store — open DB, schema, migrations, settings
Files:
- Create:
internal/store/store.go - Create:
internal/store/schema.go - Create:
internal/store/settings.go - Test:
internal/store/store_test.go
Interfaces:
-
Consumes:
crypto(held for later tasks). -
Produces:
type Store struct { ... }wrapping*sql.DBand the key.func Open(path string, key []byte) (*Store, error)— opens/creates DB, runs migrations.func (s *Store) Close() error.func (s *Store) GetSetting(name string) (string, error)/SetSetting(name, value string) error.func DefaultDBPath() (string, error)— resolvesEMCLI_DBor the per-OS default.- Const
schemaVersion = 1.
-
Step 1: Add the SQLite dependency
Run: go get modernc.org/sqlite@latest
- Step 2: Write the failing test
Create internal/store/store_test.go:
package store
import (
"path/filepath"
"testing"
)
func testKey() []byte {
k := make([]byte, 32)
for i := range k {
k[i] = byte(i)
}
return k
}
// openTemp opens a fresh store in a temp dir.
func openTemp(t *testing.T) *Store {
t.Helper()
p := filepath.Join(t.TempDir(), "emcli.db")
s, err := Open(p, testKey())
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
p := filepath.Join(t.TempDir(), "emcli.db")
s, err := Open(p, testKey())
if err != nil {
t.Fatalf("first Open: %v", err)
}
v, err := s.GetSetting("schema_version")
if err != nil || v != "1" {
t.Fatalf("schema_version: %q err=%v", v, err)
}
s.Close()
// Re-open: must not error or duplicate.
s2, err := Open(p, testKey())
if err != nil {
t.Fatalf("second Open: %v", err)
}
defer s2.Close()
if v, _ := s2.GetSetting("schema_version"); v != "1" {
t.Fatalf("schema_version after reopen: %q", v)
}
}
func TestSettingsRoundTrip(t *testing.T) {
s := openTemp(t)
if err := s.SetSetting("audit_retention_days", "30"); err != nil {
t.Fatalf("SetSetting: %v", err)
}
got, err := s.GetSetting("audit_retention_days")
if err != nil || got != "30" {
t.Fatalf("got %q err=%v", got, err)
}
// Upsert overwrites.
_ = s.SetSetting("audit_retention_days", "7")
if got, _ := s.GetSetting("audit_retention_days"); got != "7" {
t.Fatalf("upsert failed: %q", got)
}
}
- Step 3: Run test to verify it fails
Run: go test ./internal/store/
Expected: FAIL — undefined Open.
- Step 4: Write the schema
Create internal/store/schema.go:
package store
const schemaVersion = 1
// schemaSQL is the full v1 schema. All statements are idempotent via IF NOT EXISTS.
const schemaSQL = `
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
mode TEXT NOT NULL CHECK (mode IN ('RO','RW')),
imap_host TEXT NOT NULL,
imap_port INTEGER NOT NULL,
imap_security TEXT NOT NULL CHECK (imap_security IN ('tls','starttls')),
smtp_host TEXT,
smtp_port INTEGER,
smtp_security TEXT,
auth_type TEXT NOT NULL CHECK (auth_type IN ('password','oauth2')),
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
);
CREATE TABLE IF NOT EXISTS whitelist_in (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
address TEXT NOT NULL,
PRIMARY KEY (account_id, address)
);
CREATE TABLE IF NOT EXISTS whitelist_out (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
address TEXT NOT NULL,
PRIMARY KEY (account_id, address)
);
CREATE TABLE IF NOT EXISTS folder_state (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
folder TEXT NOT NULL,
uidvalidity INTEGER NOT NULL,
floor_uid INTEGER NOT NULL,
PRIMARY KEY (account_id, folder)
);
CREATE TABLE IF NOT EXISTS acked (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
folder TEXT NOT NULL,
uidvalidity INTEGER NOT NULL,
uid INTEGER NOT NULL,
PRIMARY KEY (account_id, folder, uid)
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
account TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
result TEXT NOT NULL,
reason TEXT
);
`
- Step 5: Write the store core and settings
Create internal/store/store.go:
// Package store owns the encrypted SQLite config and read state.
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
_ "modernc.org/sqlite"
)
// Store wraps the database and the field-encryption key.
type Store struct {
db *sql.DB
key []byte
}
// Open opens (creating if needed) the DB at path and applies the schema.
func Open(path string, key []byte) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil {
db.Close()
return nil, err
}
if _, err := db.Exec(schemaSQL); err != nil {
db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
s := &Store{db: db, key: key}
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
}
func (s *Store) Close() error { return s.db.Close() }
// DefaultDBPath resolves EMCLI_DB or the per-OS default location.
func DefaultDBPath() (string, error) {
if p := os.Getenv("EMCLI_DB"); p != "" {
return p, nil
}
if runtime.GOOS == "windows" {
if dir := os.Getenv("AppData"); dir != "" {
return filepath.Join(dir, "emcli", "emcli.db"), nil
}
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "emcli", "emcli.db"), nil
}
Create internal/store/settings.go:
package store
import "database/sql"
// GetSetting returns a setting value or sql.ErrNoRows if absent.
func (s *Store) GetSetting(name string) (string, error) {
var v string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", name).Scan(&v)
return v, err
}
// SetSetting upserts a setting.
func (s *Store) SetSetting(name, value string) error {
_, err := s.db.Exec(
"INSERT INTO settings(key,value) VALUES(?,?) "+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
name, value)
return err
}
var _ = sql.ErrNoRows
- Step 6: Run tests to verify they pass
Run: go test ./internal/store/
Expected: PASS (2 tests).
- Step 7: Commit
git add internal/store go.mod go.sum
git commit -m "feat(store): open encrypted SQLite, schema v1, settings"
Task 4: store — accounts CRUD with encrypted secrets
Files:
- Create:
internal/store/account.go - Test:
internal/store/account_test.go
Interfaces:
-
Consumes:
crypto.Seal/crypto.Open,Store. -
Produces:
type Account struct { ID int64; Name, Mode, IMAPHost string; IMAPPort int; IMAPSecurity, AuthType, Username string; Password string; WhitelistInEnabled, WhitelistOutEnabled bool; SubjectRegex string; ProcessBacklog bool }func (s *Store) AddAccount(a Account) (int64, error)— encryptsPasswordintoenc_password.func (s *Store) GetAccount(name string) (Account, error)— decrypts secrets;ErrAccountNotFoundif absent.func (s *Store) ListAccounts() ([]Account, error)—Passwordfield left empty (never listed).func (s *Store) DeleteAccount(name string) error.var ErrAccountNotFound error.
-
Step 1: Write the failing test
Create internal/store/account_test.go:
package store
import (
"errors"
"testing"
)
func sampleAccount() Account {
return Account{
Name: "work", Mode: "RO",
IMAPHost: "imap.example.com", IMAPPort: 993, IMAPSecurity: "tls",
AuthType: "password", Username: "me@example.com",
Password: "s3cr3t", SubjectRegex: "",
}
}
func TestAddGetAccountDecryptsSecret(t *testing.T) {
s := openTemp(t)
id, err := s.AddAccount(sampleAccount())
if err != nil {
t.Fatalf("AddAccount: %v", err)
}
if id == 0 {
t.Fatal("want non-zero id")
}
got, err := s.GetAccount("work")
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Password != "s3cr3t" {
t.Fatalf("password not decrypted: %q", got.Password)
}
if got.Mode != "RO" || got.IMAPPort != 993 {
t.Fatalf("fields wrong: %+v", got)
}
}
func TestPasswordStoredEncrypted(t *testing.T) {
s := openTemp(t)
_, _ = s.AddAccount(sampleAccount())
var blob []byte
if err := s.db.QueryRow("SELECT enc_password FROM accounts WHERE name='work'").Scan(&blob); err != nil {
t.Fatalf("query: %v", err)
}
if string(blob) == "s3cr3t" || len(blob) == 0 {
t.Fatalf("password not encrypted at rest: %q", blob)
}
}
func TestGetAccountNotFound(t *testing.T) {
s := openTemp(t)
if _, err := s.GetAccount("nope"); !errors.Is(err, ErrAccountNotFound) {
t.Fatalf("want ErrAccountNotFound, got %v", err)
}
}
func TestListAccountsOmitsSecrets(t *testing.T) {
s := openTemp(t)
_, _ = s.AddAccount(sampleAccount())
list, err := s.ListAccounts()
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
if list[0].Password != "" {
t.Fatal("ListAccounts must not return secrets")
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/store/ -run Account
Expected: FAIL — undefined Account/AddAccount.
- Step 3: Write the implementation
Create internal/store/account.go:
package store
import (
"database/sql"
"errors"
"fmt"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
)
var ErrAccountNotFound = errors.New("account not found")
// Account is the decrypted, in-memory view of a configured account.
type Account struct {
ID int64
Name string
Mode string // RO | RW
IMAPHost string
IMAPPort int
IMAPSecurity string // tls | starttls
AuthType string // password | oauth2
Username string
Password string // decrypted; empty in ListAccounts
WhitelistInEnabled bool
WhitelistOutEnabled bool
SubjectRegex string
ProcessBacklog bool
}
func (s *Store) AddAccount(a Account) (int64, error) {
var encPw []byte
if a.Password != "" {
b, err := crypto.Seal(s.key, []byte(a.Password))
if err != nil {
return 0, err
}
encPw = b
}
res, err := s.db.Exec(`
INSERT INTO accounts
(name,mode,imap_host,imap_port,imap_security,auth_type,username,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity, a.AuthType, a.Username,
encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog))
if err != nil {
return 0, fmt.Errorf("insert account: %w", err)
}
return res.LastInsertId()
}
func (s *Store) GetAccount(name string) (Account, error) {
row := s.db.QueryRow(`
SELECT id,name,mode,imap_host,imap_port,imap_security,auth_type,username,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
FROM accounts WHERE name = ?`, name)
a, encPw, err := scanAccount(row)
if errors.Is(err, sql.ErrNoRows) {
return Account{}, ErrAccountNotFound
}
if err != nil {
return Account{}, err
}
if len(encPw) > 0 {
pw, err := crypto.Open(s.key, encPw)
if err != nil {
return Account{}, fmt.Errorf("decrypt password (wrong EMCLI_KEY?): %w", err)
}
a.Password = string(pw)
}
return a, nil
}
func (s *Store) ListAccounts() ([]Account, error) {
rows, err := s.db.Query(`
SELECT id,name,mode,imap_host,imap_port,imap_security,auth_type,username,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
FROM accounts ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Account
for rows.Next() {
a, _, err := scanAccount(rows) // secrets discarded
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Store) DeleteAccount(name string) error {
res, err := s.db.Exec("DELETE FROM accounts WHERE name = ?", name)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrAccountNotFound
}
return nil
}
// scanner is satisfied by *sql.Row and *sql.Rows.
type scanner interface{ Scan(dest ...any) error }
func scanAccount(sc scanner) (Account, []byte, error) {
var (
a Account
encPw []byte
subj sql.NullString
wlIn, wlOut int
backlog int
)
err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity,
&a.AuthType, &a.Username, &encPw, &wlIn, &wlOut, &subj, &backlog)
if err != nil {
return Account{}, nil, err
}
a.WhitelistInEnabled = wlIn != 0
a.WhitelistOutEnabled = wlOut != 0
a.ProcessBacklog = backlog != 0
a.SubjectRegex = subj.String
return a, encPw, nil
}
func b2i(b bool) int {
if b {
return 1
}
return 0
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/store/ -run Account
Expected: PASS (4 tests).
- Step 5: Commit
git add internal/store/account.go internal/store/account_test.go
git commit -m "feat(store): accounts CRUD with encrypted password column"
Task 5: store — whitelist CRUD
Files:
- Create:
internal/store/whitelist.go - Test:
internal/store/whitelist_test.go
Interfaces:
-
Consumes:
Store,GetAccount. -
Produces:
type Direction stringwithDirIn Direction = "in",DirOut Direction = "out".func (s *Store) AddWhitelist(account string, dir Direction, address string) errorfunc (s *Store) RemoveWhitelist(account string, dir Direction, address string) errorfunc (s *Store) ListWhitelist(account string, dir Direction) ([]string, error)— addresses lower-cased.
-
Step 1: Write the failing test
Create internal/store/whitelist_test.go:
package store
import (
"reflect"
"testing"
)
func TestWhitelistAddListRemove(t *testing.T) {
s := openTemp(t)
_, _ = s.AddAccount(sampleAccount())
if err := s.AddWhitelist("work", DirIn, "Bob@Example.com"); err != nil {
t.Fatalf("add: %v", err)
}
_ = s.AddWhitelist("work", DirIn, "@trusted.com")
got, err := s.ListWhitelist("work", DirIn)
if err != nil {
t.Fatalf("list: %v", err)
}
want := []string{"@trusted.com", "bob@example.com"} // lower-cased, sorted
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v want %v", got, want)
}
// Out list is independent.
if out, _ := s.ListWhitelist("work", DirOut); len(out) != 0 {
t.Fatalf("out list should be empty, got %v", out)
}
if err := s.RemoveWhitelist("work", DirIn, "bob@example.com"); err != nil {
t.Fatalf("remove: %v", err)
}
got, _ = s.ListWhitelist("work", DirIn)
if !reflect.DeepEqual(got, []string{"@trusted.com"}) {
t.Fatalf("after remove got %v", got)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/store/ -run Whitelist
Expected: FAIL — undefined DirIn.
- Step 3: Write the implementation
Create internal/store/whitelist.go:
package store
import (
"fmt"
"strings"
)
type Direction string
const (
DirIn Direction = "in"
DirOut Direction = "out"
)
func (d Direction) table() (string, error) {
switch d {
case DirIn:
return "whitelist_in", nil
case DirOut:
return "whitelist_out", nil
default:
return "", fmt.Errorf("invalid direction %q", d)
}
}
func (s *Store) accountID(name string) (int64, error) {
a, err := s.GetAccount(name)
if err != nil {
return 0, err
}
return a.ID, nil
}
func (s *Store) AddWhitelist(account string, dir Direction, address string) error {
tbl, err := dir.table()
if err != nil {
return err
}
id, err := s.accountID(account)
if err != nil {
return err
}
_, err = s.db.Exec(
fmt.Sprintf("INSERT OR IGNORE INTO %s(account_id,address) VALUES(?,?)", tbl),
id, strings.ToLower(address))
return err
}
func (s *Store) RemoveWhitelist(account string, dir Direction, address string) error {
tbl, err := dir.table()
if err != nil {
return err
}
id, err := s.accountID(account)
if err != nil {
return err
}
_, err = s.db.Exec(
fmt.Sprintf("DELETE FROM %s WHERE account_id=? AND address=?", tbl),
id, strings.ToLower(address))
return err
}
func (s *Store) ListWhitelist(account string, dir Direction) ([]string, error) {
tbl, err := dir.table()
if err != nil {
return nil, err
}
id, err := s.accountID(account)
if err != nil {
return nil, err
}
rows, err := s.db.Query(
fmt.Sprintf("SELECT address FROM %s WHERE account_id=? ORDER BY address", tbl), id)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/store/ -run Whitelist
Expected: PASS.
- Step 5: Commit
git add internal/store/whitelist.go internal/store/whitelist_test.go
git commit -m "feat(store): per-account inbound/outbound whitelist CRUD"
Task 6: store — seen-set read state (floor, ack, compaction)
This is the algorithmic core. A folder's read state is a floor_uid plus a set of acked UIDs above it. Compaction collapses contiguous acked runs immediately above the floor.
Files:
- Create:
internal/store/seenset.go - Test:
internal/store/seenset_test.go
Interfaces:
-
Consumes:
Store,accountID. -
Produces:
func (s *Store) EnsureFolderBaseline(account, folder string, uidvalidity, maxUID uint32) error— on first contact createsfolder_statewithfloor_uid = maxUID(or 0 if account.ProcessBacklog); onuidvaliditychange, resets state and re-baselines.func (s *Store) IsNew(account, folder string, uid uint32) (bool, error)—uid > floor AND uid ∉ acked.func (s *Store) FilterNew(account, folder string, uids []uint32) ([]uint32, error)— convenience, preserves order.func (s *Store) Ack(account, folder string, uidvalidity uint32, uids ...uint32) error— inserts intoacked(ignoring ≤ floor), then compacts.func (s *Store) floor(account, folder string) (uint32, error)— unexported helper used by tests via exported wrappers.
-
Step 1: Write the failing tests
Create internal/store/seenset_test.go:
package store
import "testing"
func backlogAccount(t *testing.T, s *Store, backlog bool) {
t.Helper()
a := sampleAccount()
a.ProcessBacklog = backlog
if _, err := s.AddAccount(a); err != nil {
t.Fatalf("AddAccount: %v", err)
}
}
func TestBaselineIgnoresHistoryByDefault(t *testing.T) {
s := openTemp(t)
backlogAccount(t, s, false)
if err := s.EnsureFolderBaseline("work", "INBOX", 1, 100); err != nil {
t.Fatalf("baseline: %v", err)
}
// Existing mail (uid <= 100) is not new; 101 is.
for _, tc := range []struct {
uid uint32
want bool
}{{50, false}, {100, false}, {101, true}} {
got, _ := s.IsNew("work", "INBOX", tc.uid)
if got != tc.want {
t.Fatalf("IsNew(%d)=%v want %v", tc.uid, got, tc.want)
}
}
}
func TestBaselineBacklogProcessesAll(t *testing.T) {
s := openTemp(t)
backlogAccount(t, s, true)
_ = s.EnsureFolderBaseline("work", "INBOX", 1, 100)
if n, _ := s.IsNew("work", "INBOX", 1); !n {
t.Fatal("with backlog, uid 1 must be new")
}
}
func TestAckRemovesFromNewAndIsOutOfOrderSafe(t *testing.T) {
s := openTemp(t)
backlogAccount(t, s, false)
_ = s.EnsureFolderBaseline("work", "INBOX", 1, 100)
// New mail arrives: 101..105. Ack out of order: 103 then 101.
_ = s.Ack("work", "INBOX", 1, 103)
if n, _ := s.IsNew("work", "INBOX", 103); n {
t.Fatal("103 acked, must not be new")
}
if n, _ := s.IsNew("work", "INBOX", 101); !n {
t.Fatal("101 not acked yet, must still be new")
}
_ = s.Ack("work", "INBOX", 1, 101, 102)
got, _ := s.FilterNew("work", "INBOX", []uint32{101, 102, 103, 104, 105})
want := []uint32{104, 105}
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("FilterNew got %v want %v", got, want)
}
}
func TestCompactionAdvancesFloor(t *testing.T) {
s := openTemp(t)
backlogAccount(t, s, false)
_ = s.EnsureFolderBaseline("work", "INBOX", 1, 100)
// Ack a contiguous run just above the floor: 101,102,103.
_ = s.Ack("work", "INBOX", 1, 102, 101, 103)
f, _ := s.floor("work", "INBOX")
if f != 103 {
t.Fatalf("floor should advance to 103, got %d", f)
}
// The acked rows for the collapsed run are gone.
var n int
_ = s.db.QueryRow("SELECT COUNT(*) FROM acked").Scan(&n)
if n != 0 {
t.Fatalf("acked rows should be compacted away, got %d", n)
}
// A hole remains uncompacted: ack 105 (gap at 104).
_ = s.Ack("work", "INBOX", 1, 105)
f, _ = s.floor("work", "INBOX")
if f != 103 {
t.Fatalf("floor must stay at 103 while 104 is a hole, got %d", f)
}
_ = s.db.QueryRow("SELECT COUNT(*) FROM acked").Scan(&n)
if n != 1 {
t.Fatalf("105 should remain in acked, got %d rows", n)
}
}
func TestUIDValidityChangeResets(t *testing.T) {
s := openTemp(t)
backlogAccount(t, s, false)
_ = s.EnsureFolderBaseline("work", "INBOX", 1, 100)
_ = s.Ack("work", "INBOX", 1, 105)
// Server reports a new UIDVALIDITY: state resets, re-baselines at new max.
if err := s.EnsureFolderBaseline("work", "INBOX", 2, 10); err != nil {
t.Fatalf("rebaseline: %v", err)
}
f, _ := s.floor("work", "INBOX")
if f != 10 {
t.Fatalf("floor should re-baseline to 10, got %d", f)
}
var n int
_ = s.db.QueryRow("SELECT COUNT(*) FROM acked").Scan(&n)
if n != 0 {
t.Fatalf("acked must reset on uidvalidity change, got %d", n)
}
}
- Step 2: Run tests to verify they fail
Run: go test ./internal/store/ -run 'Baseline|Ack|Compaction|UIDValidity'
Expected: FAIL — undefined EnsureFolderBaseline.
- Step 3: Write the implementation
Create internal/store/seenset.go:
package store
import "database/sql"
// EnsureFolderBaseline initialises folder_state on first contact, or resets it
// when the server's UIDVALIDITY differs from what we stored.
func (s *Store) EnsureFolderBaseline(account, folder string, uidvalidity, maxUID uint32) error {
a, err := s.GetAccount(account)
if err != nil {
return err
}
var (
curUIDValidity uint32
haveRow bool
)
row := s.db.QueryRow(
"SELECT uidvalidity FROM folder_state WHERE account_id=? AND folder=?", a.ID, folder)
switch err := row.Scan(&curUIDValidity); err {
case nil:
haveRow = true
case sql.ErrNoRows:
haveRow = false
default:
return err
}
if haveRow && curUIDValidity == uidvalidity {
return nil // already baselined, same validity
}
floor := maxUID
if a.ProcessBacklog {
floor = 0
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM acked WHERE account_id=? AND folder=?", a.ID, folder); err != nil {
return err
}
if _, err := tx.Exec(`
INSERT INTO folder_state(account_id,folder,uidvalidity,floor_uid)
VALUES(?,?,?,?)
ON CONFLICT(account_id,folder) DO UPDATE SET uidvalidity=excluded.uidvalidity, floor_uid=excluded.floor_uid`,
a.ID, folder, uidvalidity, floor); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) floor(account, folder string) (uint32, error) {
id, err := s.accountID(account)
if err != nil {
return 0, err
}
var f uint32
err = s.db.QueryRow(
"SELECT floor_uid FROM folder_state WHERE account_id=? AND folder=?", id, folder).Scan(&f)
return f, err
}
// IsNew reports whether uid is unread: above the floor and not acked.
func (s *Store) IsNew(account, folder string, uid uint32) (bool, error) {
id, err := s.accountID(account)
if err != nil {
return false, err
}
var floor uint32
if err := s.db.QueryRow(
"SELECT floor_uid FROM folder_state WHERE account_id=? AND folder=?", id, folder).Scan(&floor); err != nil {
return false, err
}
if uid <= floor {
return false, nil
}
var one int
err = s.db.QueryRow(
"SELECT 1 FROM acked WHERE account_id=? AND folder=? AND uid=?", id, folder, uid).Scan(&one)
if err == sql.ErrNoRows {
return true, nil
}
if err != nil {
return false, err
}
return false, nil // present in acked
}
// FilterNew returns the subset of uids that are new, preserving order.
func (s *Store) FilterNew(account, folder string, uids []uint32) ([]uint32, error) {
out := make([]uint32, 0, len(uids))
for _, u := range uids {
n, err := s.IsNew(account, folder, u)
if err != nil {
return nil, err
}
if n {
out = append(out, u)
}
}
return out, nil
}
// Ack records uids as processed (ignoring any at or below the floor) then compacts.
func (s *Store) Ack(account, folder string, uidvalidity uint32, uids ...uint32) error {
id, err := s.accountID(account)
if err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var floor uint32
if err := tx.QueryRow(
"SELECT floor_uid FROM folder_state WHERE account_id=? AND folder=?", id, folder).Scan(&floor); err != nil {
return err
}
for _, u := range uids {
if u <= floor {
continue
}
if _, err := tx.Exec(
"INSERT OR IGNORE INTO acked(account_id,folder,uidvalidity,uid) VALUES(?,?,?,?)",
id, folder, uidvalidity, u); err != nil {
return err
}
}
// Compact: while floor+1 is acked, advance floor and drop that row.
for {
next := floor + 1
var present int
err := tx.QueryRow(
"SELECT 1 FROM acked WHERE account_id=? AND folder=? AND uid=?", id, folder, next).Scan(&present)
if err == sql.ErrNoRows {
break
}
if err != nil {
return err
}
if _, err := tx.Exec(
"DELETE FROM acked WHERE account_id=? AND folder=? AND uid=?", id, folder, next); err != nil {
return err
}
floor = next
}
if _, err := tx.Exec(
"UPDATE folder_state SET floor_uid=? WHERE account_id=? AND folder=?", floor, id, folder); err != nil {
return err
}
return tx.Commit()
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/store/ -run 'Baseline|Ack|Compaction|UIDValidity'
Expected: PASS (5 tests).
- Step 5: Commit
git add internal/store/seenset.go internal/store/seenset_test.go
git commit -m "feat(store): seen-set read state with floor baseline and compaction"
Task 7: store — audit log & retention purge
Files:
- Create:
internal/store/audit.go - Test:
internal/store/audit_test.go
Interfaces:
-
Consumes:
Store,SetSetting/GetSetting. -
Produces:
type AuditEntry struct { TS, Account, Action, Target, Result, Reason string }func (s *Store) Audit(now time.Time, e AuditEntry) error— inserts;nowinjected for testability (notime.Now()inside).func (s *Store) PurgeAudit(now time.Time) (int64, error)— deletes rows older thanaudit_retention_days(no-op if unset/0).func (s *Store) RecentAudit(limit int) ([]AuditEntry, error).
-
Step 1: Write the failing test
Create internal/store/audit_test.go:
package store
import (
"testing"
"time"
)
func TestAuditInsertAndRecent(t *testing.T) {
s := openTemp(t)
now := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
err := s.Audit(now, AuditEntry{
Account: "work", Action: "list", Target: "INBOX", Result: "allowed",
})
if err != nil {
t.Fatalf("Audit: %v", err)
}
got, err := s.RecentAudit(10)
if err != nil || len(got) != 1 {
t.Fatalf("recent: %v len=%d", err, len(got))
}
if got[0].Action != "list" || got[0].Result != "allowed" {
t.Fatalf("entry wrong: %+v", got[0])
}
}
func TestPurgeRespectsRetention(t *testing.T) {
s := openTemp(t)
_ = s.SetSetting("audit_retention_days", "30")
now := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
old := now.AddDate(0, 0, -31)
recent := now.AddDate(0, 0, -5)
_ = s.Audit(old, AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"})
_ = s.Audit(recent, AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"})
n, err := s.PurgeAudit(now)
if err != nil {
t.Fatalf("purge: %v", err)
}
if n != 1 {
t.Fatalf("want 1 purged, got %d", n)
}
got, _ := s.RecentAudit(10)
if len(got) != 1 {
t.Fatalf("want 1 remaining, got %d", len(got))
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/store/ -run 'Audit|Purge'
Expected: FAIL — undefined AuditEntry.
- Step 3: Write the implementation
Create internal/store/audit.go:
package store
import (
"strconv"
"time"
)
type AuditEntry struct {
TS string
Account string
Action string
Target string
Result string
Reason string
}
func (s *Store) Audit(now time.Time, e AuditEntry) error {
var reason any
if e.Reason != "" {
reason = e.Reason
}
_, err := s.db.Exec(
"INSERT INTO audit_log(ts,account,action,target,result,reason) VALUES(?,?,?,?,?,?)",
now.UTC().Format(time.RFC3339), e.Account, e.Action, e.Target, e.Result, reason)
return err
}
func (s *Store) PurgeAudit(now time.Time) (int64, error) {
v, err := s.GetSetting("audit_retention_days")
if err != nil { // unset => no retention policy
return 0, nil
}
days, err := strconv.Atoi(v)
if err != nil || days <= 0 {
return 0, nil
}
cutoff := now.UTC().AddDate(0, 0, -days).Format(time.RFC3339)
res, err := s.db.Exec("DELETE FROM audit_log WHERE ts < ?", cutoff)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *Store) RecentAudit(limit int) ([]AuditEntry, error) {
rows, err := s.db.Query(
"SELECT ts,account,action,target,result,COALESCE(reason,'') FROM audit_log ORDER BY id DESC LIMIT ?", limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.TS, &e.Account, &e.Action, &e.Target, &e.Result, &e.Reason); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/store/ -run 'Audit|Purge'
Expected: PASS (2 tests).
- Step 5: Commit
git add internal/store/audit.go internal/store/audit_test.go
git commit -m "feat(store): audit log with retention-based purge"
Task 8: policy — address matching
Files:
- Create:
internal/policy/policy.go - Test:
internal/policy/address_test.go
Interfaces:
-
Consumes: nothing (pure).
-
Produces:
func MatchAddress(entries []string, addr string) bool— case-insensitive;@domain.comentry matches any address at that domain; otherwise exact match.addrmay be a display-form address; only thelocal@domainpart is compared.func NormalizeAddr(addr string) string— lower-cases and strips display name / angle brackets.
-
Step 1: Write the failing test
Create internal/policy/address_test.go:
package policy
import "testing"
func TestMatchAddress(t *testing.T) {
wl := []string{"bob@example.com", "@trusted.com"}
cases := []struct {
addr string
want bool
}{
{"bob@example.com", true},
{"BOB@Example.com", true},
{`"Bob" <bob@example.com>`, true},
{"alice@trusted.com", true},
{"alice@untrusted.com", false},
{"eve@example.com", false},
{"", false},
}
for _, c := range cases {
if got := MatchAddress(wl, c.addr); got != c.want {
t.Fatalf("MatchAddress(%q)=%v want %v", c.addr, got, c.want)
}
}
}
func TestNormalizeAddr(t *testing.T) {
if got := NormalizeAddr(`"Bob Smith" <Bob@Example.COM>`); got != "bob@example.com" {
t.Fatalf("got %q", got)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/policy/
Expected: FAIL — undefined MatchAddress.
- Step 3: Write the implementation
Create internal/policy/policy.go:
// Package policy holds pure enforcement functions: address matching,
// inbound filtering, and (in a later phase) outbound checks.
package policy
import (
"net/mail"
"strings"
)
// NormalizeAddr lower-cases an address and strips any display name/brackets.
func NormalizeAddr(addr string) string {
addr = strings.TrimSpace(addr)
if a, err := mail.ParseAddress(addr); err == nil {
return strings.ToLower(a.Address)
}
return strings.ToLower(strings.Trim(addr, "<> "))
}
// MatchAddress reports whether addr matches any whitelist entry.
// Entries beginning with '@' match a whole domain; others match exactly.
func MatchAddress(entries []string, addr string) bool {
norm := NormalizeAddr(addr)
if norm == "" {
return false
}
at := strings.LastIndex(norm, "@")
domain := ""
if at >= 0 {
domain = norm[at:] // includes '@'
}
for _, e := range entries {
e = strings.ToLower(strings.TrimSpace(e))
if strings.HasPrefix(e, "@") {
if e == domain {
return true
}
continue
}
if e == norm {
return true
}
}
return false
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/policy/
Expected: PASS (2 tests).
- Step 5: Commit
git add internal/policy
git commit -m "feat(policy): case-insensitive address and domain matching"
Task 9: policy — inbound filter
Files:
- Create:
internal/policy/inbound.go - Test:
internal/policy/inbound_test.go
Interfaces:
-
Consumes:
MatchAddress. -
Produces:
type InboundRule struct { WhitelistInEnabled bool; WhitelistIn []string; SubjectRegex *regexp.Regexp }func (r InboundRule) Allows(from, subject string) bool— true only if (whitelist disabled OR sender matches) AND (no regex OR subject matches).func CompileSubject(pattern string) (*regexp.Regexp, error)— returns(nil, nil)for empty pattern.
-
Step 1: Write the failing test
Create internal/policy/inbound_test.go:
package policy
import "testing"
func TestInboundAllows(t *testing.T) {
re, err := CompileSubject("(?i)invoice")
if err != nil {
t.Fatalf("compile: %v", err)
}
rule := InboundRule{
WhitelistInEnabled: true,
WhitelistIn: []string{"@trusted.com"},
SubjectRegex: re,
}
cases := []struct {
from, subject string
want bool
}{
{"bob@trusted.com", "Your Invoice #5", true},
{"bob@trusted.com", "lunch?", false}, // subject fails
{"eve@evil.com", "Invoice", false}, // sender fails
{"eve@evil.com", "lunch?", false}, // both fail
}
for _, c := range cases {
if got := rule.Allows(c.from, c.subject); got != c.want {
t.Fatalf("Allows(%q,%q)=%v want %v", c.from, c.subject, got, c.want)
}
}
}
func TestInboundNoFiltersAllowsAll(t *testing.T) {
rule := InboundRule{} // whitelist disabled, no regex
if !rule.Allows("anyone@anywhere.com", "anything") {
t.Fatal("empty rule must allow everything")
}
}
func TestCompileSubjectEmpty(t *testing.T) {
re, err := CompileSubject("")
if err != nil || re != nil {
t.Fatalf("empty pattern: re=%v err=%v", re, err)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/policy/ -run Inbound
Expected: FAIL — undefined InboundRule.
- Step 3: Write the implementation
Create internal/policy/inbound.go:
package policy
import "regexp"
// InboundRule captures one account's read-side filtering.
type InboundRule struct {
WhitelistInEnabled bool
WhitelistIn []string
SubjectRegex *regexp.Regexp // nil = no subject filter
}
// CompileSubject compiles a subject filter; empty pattern => (nil, nil).
func CompileSubject(pattern string) (*regexp.Regexp, error) {
if pattern == "" {
return nil, nil
}
return regexp.Compile(pattern)
}
// Allows reports whether a message with the given sender and subject is visible.
func (r InboundRule) Allows(from, subject string) bool {
if r.WhitelistInEnabled && !MatchAddress(r.WhitelistIn, from) {
return false
}
if r.SubjectRegex != nil && !r.SubjectRegex.MatchString(subject) {
return false
}
return true
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/policy/ -run Inbound
Expected: PASS.
- Step 5: Commit
git add internal/policy/inbound.go internal/policy/inbound_test.go
git commit -m "feat(policy): inbound whitelist + subject-regex filter"
Task 10: mail — message parsing
Parse a raw RFC822 message into headers, a plain-text body, and attachment metadata. This is pure (operates on io.Reader/bytes) so it is unit-testable without a server.
Files:
- Create:
internal/mail/message.go - Test:
internal/mail/message_test.go - Test fixture:
internal/mail/testdata/with_attachment.eml
Interfaces:
-
Consumes:
github.com/emersion/go-message/mail. -
Produces:
type Header struct { UID uint32; From, To, Subject, Date, MessageID string; HasAttachments bool }type Attachment struct { Name string; Size int; MIME string; Content []byte }type Message struct { Header Header; BodyText string; Attachments []Attachment }func ParseMessage(uid uint32, raw []byte) (Message, error)— extracts the firsttext/plainpart asBodyText; collects attachment parts (withContent); setsHasAttachments.func ParseHeaderOnly(uid uint32, raw []byte) (Header, error)— header fields +HasAttachmentswithout decoding attachment bodies.
-
Step 1: Add the dependency and write the fixture
Run: go get github.com/emersion/go-message@latest
Create internal/mail/testdata/with_attachment.eml (exact bytes, CRLF line endings not required for the parser):
From: "Bob" <bob@trusted.com>
To: me@example.com
Subject: Your Invoice #5
Date: Sat, 20 Jun 2026 10:00:00 +0000
Message-ID: <abc123@trusted.com>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="BOUNDARY"
--BOUNDARY
Content-Type: text/plain; charset=utf-8
Hello, here is your invoice.
--BOUNDARY
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment; filename="invoice.txt"
LINE-ITEM 1: 100.00
--BOUNDARY--
- Step 2: Write the failing test
Create internal/mail/message_test.go:
package mail
import (
"os"
"path/filepath"
"testing"
)
func loadFixture(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
return b
}
func TestParseMessage(t *testing.T) {
raw := loadFixture(t, "with_attachment.eml")
m, err := ParseMessage(42, raw)
if err != nil {
t.Fatalf("ParseMessage: %v", err)
}
if m.Header.UID != 42 {
t.Fatalf("uid: %d", m.Header.UID)
}
if m.Header.Subject != "Your Invoice #5" {
t.Fatalf("subject: %q", m.Header.Subject)
}
if m.Header.From != `"Bob" <bob@trusted.com>` && m.Header.From != "Bob <bob@trusted.com>" {
t.Fatalf("from: %q", m.Header.From)
}
if m.Header.MessageID != "abc123@trusted.com" && m.Header.MessageID != "<abc123@trusted.com>" {
t.Fatalf("message-id: %q", m.Header.MessageID)
}
if want := "Hello, here is your invoice."; !contains(m.BodyText, want) {
t.Fatalf("body=%q want contains %q", m.BodyText, want)
}
if !m.Header.HasAttachments {
t.Fatal("HasAttachments should be true")
}
if len(m.Attachments) != 1 || m.Attachments[0].Name != "invoice.txt" {
t.Fatalf("attachments: %+v", m.Attachments)
}
if !contains(string(m.Attachments[0].Content), "LINE-ITEM 1") {
t.Fatalf("attachment content: %q", m.Attachments[0].Content)
}
}
func TestParseHeaderOnly(t *testing.T) {
raw := loadFixture(t, "with_attachment.eml")
h, err := ParseHeaderOnly(7, raw)
if err != nil {
t.Fatalf("ParseHeaderOnly: %v", err)
}
if h.Subject != "Your Invoice #5" || !h.HasAttachments {
t.Fatalf("header: %+v", h)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}
- Step 3: Run test to verify it fails
Run: go test ./internal/mail/
Expected: FAIL — undefined ParseMessage.
- Step 4: Write the implementation
Create internal/mail/message.go:
// Package mail provides IMAP reading and RFC822 message parsing.
package mail
import (
"bytes"
"io"
"strings"
"github.com/emersion/go-message/mail"
)
type Header struct {
UID uint32
From string
To string
Subject string
Date string
MessageID string
HasAttachments bool
}
type Attachment struct {
Name string
Size int
MIME string
Content []byte
}
type Message struct {
Header Header
BodyText string
Attachments []Attachment
}
func readHeader(mr *mail.Reader, uid uint32) Header {
h := Header{UID: uid}
hd := mr.Header
h.Subject, _ = hd.Subject()
if addrs, err := hd.AddressList("From"); err == nil && len(addrs) > 0 {
h.From = addrs[0].String()
}
if addrs, err := hd.AddressList("To"); err == nil && len(addrs) > 0 {
h.To = addrs[0].String()
}
if d, err := hd.Date(); err == nil {
h.Date = d.UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700")
}
h.MessageID = strings.Trim(hd.Get("Message-Id"), "<> ")
return h
}
// ParseMessage decodes the full message including attachment contents.
func ParseMessage(uid uint32, raw []byte) (Message, error) {
mr, err := mail.CreateReader(bytes.NewReader(raw))
if err != nil {
return Message{}, err
}
m := Message{Header: readHeader(mr, uid)}
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return Message{}, err
}
switch hdr := part.Header.(type) {
case *mail.InlineHeader:
ct, _, _ := hdr.ContentType()
if strings.HasPrefix(ct, "text/plain") && m.BodyText == "" {
b, _ := io.ReadAll(part.Body)
m.BodyText = string(b)
}
case *mail.AttachmentHeader:
name, _ := hdr.Filename()
ct, _, _ := hdr.ContentType()
b, _ := io.ReadAll(part.Body)
m.Attachments = append(m.Attachments, Attachment{
Name: name, Size: len(b), MIME: ct, Content: b,
})
}
}
m.Header.HasAttachments = len(m.Attachments) > 0
return m, nil
}
// ParseHeaderOnly decodes headers and detects attachments without keeping bodies.
func ParseHeaderOnly(uid uint32, raw []byte) (Header, error) {
m, err := ParseMessage(uid, raw)
if err != nil {
return Header{}, err
}
return m.Header, nil
}
Note:
ParseHeaderOnlyreusesParseMessagefor correctness in this phase. The IMAP layer (Task 11) avoids fetching full bodies forlistby requesting only envelope + body-structure from the server, so this convenience does not cost a full download in practice.
- Step 5: Run tests to verify they pass
Run: go test ./internal/mail/
Expected: PASS (2 tests).
- Step 6: Commit
git add internal/mail go.mod go.sum
git commit -m "feat(mail): RFC822 message parsing (headers, body, attachments)"
Task 11: mail — IMAP client (integration)
Connect, select a folder, fetch headers for new/ranged UIDs, fetch a full message, and search. Integration-tested against a greenmail container. If Docker is unavailable, the test self-skips.
Files:
- Create:
internal/mail/imap.go - Test:
internal/mail/imap_integration_test.go
Interfaces:
-
Consumes:
github.com/emersion/go-imap,github.com/emersion/go-imap/client,ParseMessage/ParseHeaderOnly. -
Produces:
type IMAPConfig struct { Host string; Port int; Security string; Username, Password string }type Client struct { ... }withfunc Dial(cfg IMAPConfig) (*Client, error)andfunc (c *Client) Logout() error.func (c *Client) SelectFolder(folder string) (uidValidity uint32, maxUID uint32, err error).func (c *Client) FetchHeaders(folder string, uids []uint32) ([]Header, error)— emptyuids⇒ all in folder.func (c *Client) FetchHeadersRange(folder string, sinceUID, beforeUID uint32, limit int) ([]Header, error).func (c *Client) FetchFull(folder string, uid uint32) (Message, error).type SearchCriteria struct { From, SubjectContains, Text string; Since, Before time.Time }func (c *Client) Search(folder string, sc SearchCriteria, limit int) ([]Header, error).
-
Step 1: Add dependency and write the integration test
Run: go get github.com/emersion/go-imap@v1
Create internal/mail/imap_integration_test.go:
//go:build integration
package mail
import (
"os"
"testing"
)
// These tests require a GreenMail server reachable via env vars:
// EMCLI_TEST_IMAP_HOST, EMCLI_TEST_IMAP_PORT, EMCLI_TEST_USER, EMCLI_TEST_PASS
// Run: go test -tags=integration ./internal/mail/
func testCfg(t *testing.T) IMAPConfig {
t.Helper()
host := os.Getenv("EMCLI_TEST_IMAP_HOST")
if host == "" {
t.Skip("EMCLI_TEST_IMAP_HOST not set; skipping IMAP integration test")
}
return IMAPConfig{
Host: host, Port: atoiEnv(t, "EMCLI_TEST_IMAP_PORT"), Security: "starttls",
Username: os.Getenv("EMCLI_TEST_USER"), Password: os.Getenv("EMCLI_TEST_PASS"),
}
}
func TestSelectAndFetch(t *testing.T) {
c, err := Dial(testCfg(t))
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer c.Logout()
uidv, maxUID, err := c.SelectFolder("INBOX")
if err != nil {
t.Fatalf("SelectFolder: %v", err)
}
if uidv == 0 {
t.Fatal("uidvalidity should be non-zero")
}
headers, err := c.FetchHeaders("INBOX", nil)
if err != nil {
t.Fatalf("FetchHeaders: %v", err)
}
t.Logf("inbox has %d messages, maxUID=%d", len(headers), maxUID)
}
Create the helper in the same file:
func atoiEnv(t *testing.T, k string) int {
t.Helper()
var n int
if _, err := fmtSscan(os.Getenv(k), &n); err != nil {
t.Fatalf("bad int env %s: %v", k, err)
}
return n
}
Note: define
fmtSscanas a tiny wrapperfunc fmtSscan(s string, v *int) (int, error){ return fmt.Sscan(s, v) }in the test file, importingfmt. (Kept indirect only to avoid an unused-import lint if the body changes.)
- Step 2: Write the IMAP client implementation
Create internal/mail/imap.go:
package mail
import (
"fmt"
"io"
"sort"
"time"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
)
type IMAPConfig struct {
Host string
Port int
Security string // tls | starttls
Username string
Password string
}
type Client struct {
c *client.Client
}
func Dial(cfg IMAPConfig) (*Client, error) {
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
var (
ic *client.Client
err error
)
switch cfg.Security {
case "tls":
ic, err = client.DialTLS(addr, nil)
case "starttls":
ic, err = client.Dial(addr)
if err == nil {
err = ic.StartTLS(nil)
}
default:
return nil, fmt.Errorf("unknown imap security %q", cfg.Security)
}
if err != nil {
return nil, err
}
if err := ic.Login(cfg.Username, cfg.Password); err != nil {
ic.Logout()
return nil, err
}
return &Client{c: ic}, nil
}
func (c *Client) Logout() error { return c.c.Logout() }
func (c *Client) SelectFolder(folder string) (uint32, uint32, error) {
mbox, err := c.c.Select(folder, true) // read-only select
if err != nil {
return 0, 0, err
}
var maxUID uint32
if mbox.Messages > 0 {
// UIDNext-1 is an upper bound for the highest existing UID.
if mbox.UidNext > 0 {
maxUID = mbox.UidNext - 1
}
}
return mbox.UidValidity, maxUID, nil
}
func (c *Client) fetchByUIDSet(folder string, set *imap.SeqSet, full bool) ([]Message, error) {
if _, err := c.c.Select(folder, true); err != nil {
return nil, err
}
section := &imap.BodySectionName{} // entire message
items := []imap.FetchItem{imap.FetchUid, section.FetchItem()}
msgCh := make(chan *imap.Message, 16)
done := make(chan error, 1)
go func() { done <- c.c.UidFetch(set, items, msgCh) }()
var out []Message
for m := range msgCh {
r := m.GetBody(section)
if r == nil {
continue
}
raw, _ := io.ReadAll(r)
parsed, err := ParseMessage(m.Uid, raw)
if err != nil {
return nil, err
}
if !full {
parsed.Attachments = nil
parsed.BodyText = ""
}
out = append(out, parsed)
}
if err := <-done; err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool { return out[i].Header.UID > out[j].Header.UID })
return out, nil
}
func (c *Client) FetchHeaders(folder string, uids []uint32) ([]Header, error) {
set := new(imap.SeqSet)
if len(uids) == 0 {
set.AddRange(1, 0) // 1:* — all
} else {
for _, u := range uids {
set.AddNum(u)
}
}
msgs, err := c.fetchByUIDSet(folder, set, false)
if err != nil {
return nil, err
}
return headersOf(msgs), nil
}
func (c *Client) FetchHeadersRange(folder string, sinceUID, beforeUID uint32, limit int) ([]Header, error) {
set := new(imap.SeqSet)
lo := sinceUID + 1
if sinceUID == 0 {
lo = 1
}
hi := uint32(0) // '*'
if beforeUID > 1 {
hi = beforeUID - 1
}
set.AddRange(lo, hi)
msgs, err := c.fetchByUIDSet(folder, set, false)
if err != nil {
return nil, err
}
h := headersOf(msgs)
if limit > 0 && len(h) > limit {
h = h[:limit]
}
return h, nil
}
func (c *Client) FetchFull(folder string, uid uint32) (Message, error) {
set := new(imap.SeqSet)
set.AddNum(uid)
msgs, err := c.fetchByUIDSet(folder, set, true)
if err != nil {
return Message{}, err
}
if len(msgs) == 0 {
return Message{}, fmt.Errorf("uid %d not found in %s", uid, folder)
}
return msgs[0], nil
}
type SearchCriteria struct {
From string
SubjectContains string
Text string
Since time.Time
Before time.Time
}
func (c *Client) Search(folder string, sc SearchCriteria, limit int) ([]Header, error) {
if _, err := c.c.Select(folder, true); err != nil {
return nil, err
}
crit := imap.NewSearchCriteria()
if sc.From != "" {
crit.Header.Add("From", sc.From)
}
if sc.SubjectContains != "" {
crit.Header.Add("Subject", sc.SubjectContains)
}
if sc.Text != "" {
crit.Text = []string{sc.Text}
}
if !sc.Since.IsZero() {
crit.Since = sc.Since
}
if !sc.Before.IsZero() {
crit.Before = sc.Before
}
uids, err := c.c.UidSearch(crit)
if err != nil {
return nil, err
}
if len(uids) == 0 {
return nil, nil
}
return c.FetchHeaders(folder, uids)
}
func headersOf(msgs []Message) []Header {
out := make([]Header, 0, len(msgs))
for _, m := range msgs {
out = append(out, m.Header)
}
return out
}
- Step 3: Run unit tests (integration self-skips without Docker)
Run: go build ./internal/mail/ && go test ./internal/mail/
Expected: PASS — parsing tests pass; integration test is excluded (build tag integration not set).
- Step 4: Run the integration test against GreenMail (optional but recommended)
Run:
docker run -d --rm --name emcli-greenmail \
-e GREENMAIL_OPTS='-Dgreenmail.setup.test.all -Dgreenmail.users=me:pass@example.com -Dgreenmail.verbose' \
-p 3143:3143 -p 3025:3025 greenmail/standalone:2.1.0
EMCLI_TEST_IMAP_HOST=localhost EMCLI_TEST_IMAP_PORT=3143 \
EMCLI_TEST_USER=me@example.com EMCLI_TEST_PASS=pass \
go test -tags=integration ./internal/mail/ -run TestSelectAndFetch -v
docker stop emcli-greenmail
Expected: PASS (selects INBOX, logs message count). Skips cleanly if Docker is absent.
- Step 5: Commit
git add internal/mail/imap.go internal/mail/imap_integration_test.go go.mod go.sum
git commit -m "feat(mail): IMAP client — select, fetch headers/full, search"
Task 12: cli — JSON envelope
Files:
- Create:
internal/cli/envelope.go - Test:
internal/cli/envelope_test.go
Interfaces:
-
Consumes: nothing.
-
Produces:
type Envelope struct { Error bool; ErrorDetail map[string]any; Data any }with JSON tagserror,error_detail,data.func Success(data any) Envelopefunc Failure(code, message string) Envelopefunc (e Envelope) Write(w io.Writer) error— writes compact JSON + newline.- Error code constants:
CodeConfig, CodeDB, CodeNetwork, CodeAuth, CodePolicy, CodeNotFound, CodeUsage.
-
Step 1: Write the failing test
Create internal/cli/envelope_test.go:
package cli
import (
"bytes"
"encoding/json"
"testing"
)
func TestSuccessEnvelope(t *testing.T) {
var buf bytes.Buffer
if err := Success(map[string]any{"count": 2}).Write(&buf); err != nil {
t.Fatalf("write: %v", err)
}
var got map[string]any
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["error"] != false {
t.Fatalf("error should be false: %v", got["error"])
}
if _, ok := got["data"]; !ok {
t.Fatal("data key missing")
}
if ed, ok := got["error_detail"].(map[string]any); !ok || len(ed) != 0 {
t.Fatalf("error_detail should be empty object: %v", got["error_detail"])
}
}
func TestFailureEnvelope(t *testing.T) {
var buf bytes.Buffer
_ = Failure(CodeNotFound, "uid 7 not found").Write(&buf)
var got struct {
Error bool `json:"error"`
ErrorDetail struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error_detail"`
}
_ = json.Unmarshal(buf.Bytes(), &got)
if !got.Error || got.ErrorDetail.Code != "not_found" {
t.Fatalf("bad failure envelope: %+v", got)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/cli/
Expected: FAIL — undefined Success.
- Step 3: Write the implementation
Create internal/cli/envelope.go:
// Package cli implements command dispatch and the agent JSON envelope.
package cli
import (
"encoding/json"
"io"
)
const (
CodeConfig = "config"
CodeDB = "db"
CodeNetwork = "network"
CodeAuth = "auth"
CodePolicy = "policy"
CodeNotFound = "not_found"
CodeUsage = "usage"
)
// Envelope is the single JSON object every agent command emits.
type Envelope struct {
Error bool `json:"error"`
ErrorDetail map[string]any `json:"error_detail"`
Data any `json:"data"`
}
func Success(data any) Envelope {
if data == nil {
data = map[string]any{}
}
return Envelope{Error: false, ErrorDetail: map[string]any{}, Data: data}
}
func Failure(code, message string) Envelope {
return Envelope{
Error: true,
ErrorDetail: map[string]any{"code": code, "message": message},
Data: map[string]any{},
}
}
func (e Envelope) Write(w io.Writer) error {
b, err := json.Marshal(e)
if err != nil {
return err
}
b = append(b, '\n')
_, err = w.Write(b)
return err
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/cli/
Expected: PASS (2 tests).
- Step 5: Commit
git add internal/cli/envelope.go internal/cli/envelope_test.go
git commit -m "feat(cli): JSON output envelope with stable error codes"
Task 13: cli — agent read commands wired end-to-end
Wire list, get, search, and ack together: open store, load account, apply inbound policy, talk to IMAP, write the envelope, and audit. Uses a mailDialer seam so the command logic is testable with a fake.
Files:
- Create:
internal/cli/agent.go - Create:
internal/cli/dispatch.go - Modify:
cmd/emcli/main.go(route agent subcommands intocli.Run) - Test:
internal/cli/agent_test.go
Interfaces:
-
Consumes:
store,policy,mail(via theMailerinterface),Envelope. -
Produces:
type Mailer interface { SelectFolder(folder string)(uint32,uint32,error); FetchHeaders(folder string, uids []uint32)([]mail.Header,error); FetchHeadersRange(folder string, since,before uint32, limit int)([]mail.Header,error); FetchFull(folder string, uid uint32)(mail.Message,error); Search(folder string, sc mail.SearchCriteria, limit int)([]mail.Header,error); Logout() error }type Deps struct { Store *store.Store; Dial func(store.Account)(Mailer,error); Now func() time.Time; Out io.Writer }func ListCmd(d Deps, account, folder string, onlyNew bool, beforeUID, sinceUID uint32, limit int) errorfunc GetCmd(d Deps, account, folder string, uid uint32) errorfunc SearchCmd(d Deps, account, folder string, sc mail.SearchCriteria, limit int) errorfunc AckCmd(d Deps, account, folder string, uids []uint32) error
-
Step 1: Write the failing test (fake Mailer, real store)
Create internal/cli/agent_test.go:
package cli
import (
"bytes"
"encoding/json"
"path/filepath"
"testing"
"time"
"git.dcglab.co.uk/steve/emcli/internal/mail"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
type fakeMailer struct {
uidValidity uint32
maxUID uint32
headers []mail.Header
full map[uint32]mail.Message
}
func (f *fakeMailer) SelectFolder(string) (uint32, uint32, error) { return f.uidValidity, f.maxUID, nil }
func (f *fakeMailer) FetchHeaders(_ string, uids []uint32) ([]mail.Header, error) {
if len(uids) == 0 {
return f.headers, nil
}
want := map[uint32]bool{}
for _, u := range uids {
want[u] = true
}
var out []mail.Header
for _, h := range f.headers {
if want[h.UID] {
out = append(out, h)
}
}
return out, nil
}
func (f *fakeMailer) FetchHeadersRange(string, uint32, uint32, int) ([]mail.Header, error) {
return f.headers, nil
}
func (f *fakeMailer) FetchFull(_ string, uid uint32) (mail.Message, error) {
return f.full[uid], nil
}
func (f *fakeMailer) Search(string, mail.SearchCriteria, int) ([]mail.Header, error) {
return f.headers, nil
}
func (f *fakeMailer) Logout() error { return nil }
func testKey() []byte {
k := make([]byte, 32)
for i := range k {
k[i] = byte(i)
}
return k
}
func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { st.Close() })
_, err = st.AddAccount(store.Account{
Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
AuthType: "password", Username: "me@example.com", Password: "pw",
WhitelistInEnabled: true,
})
if err != nil {
t.Fatalf("AddAccount: %v", err)
}
_ = st.AddWhitelist("work", store.DirIn, "@trusted.com")
var buf bytes.Buffer
d := Deps{
Store: st,
Dial: func(store.Account) (Mailer, error) { return fm, nil },
Now: func() time.Time { return time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC) },
Out: &buf,
}
return d, &buf
}
func decode(t *testing.T, b []byte) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("unmarshal %q: %v", b, err)
}
return m
}
func TestListNewFiltersBySenderAndState(t *testing.T) {
fm := &fakeMailer{
uidValidity: 1, maxUID: 100,
headers: []mail.Header{
{UID: 101, From: "bob@trusted.com", Subject: "hi"},
{UID: 102, From: "eve@evil.com", Subject: "spam"}, // filtered out by whitelist
{UID: 103, From: "ann@trusted.com", Subject: "yo"},
},
}
d, buf := newDeps(t, fm)
if err := ListCmd(d, "work", "INBOX", true, 0, 0, 50); err != nil {
t.Fatalf("ListCmd: %v", err)
}
res := decode(t, buf.Bytes())
if res["error"] != false {
t.Fatalf("unexpected error: %v", res)
}
data := res["data"].(map[string]any)
msgs := data["messages"].([]any)
if len(msgs) != 2 { // 101 and 103; 102 filtered
t.Fatalf("want 2 messages, got %d: %v", len(msgs), msgs)
}
}
func TestGetFilteredReturnsNotFound(t *testing.T) {
fm := &fakeMailer{
uidValidity: 1, maxUID: 100,
headers: []mail.Header{{UID: 102, From: "eve@evil.com", Subject: "spam"}},
full: map[uint32]mail.Message{
102: {Header: mail.Header{UID: 102, From: "eve@evil.com", Subject: "spam"}, BodyText: "secret"},
},
}
d, buf := newDeps(t, fm)
if err := GetCmd(d, "work", "INBOX", 102); err != nil {
t.Fatalf("GetCmd: %v", err)
}
res := decode(t, buf.Bytes())
if res["error"] != true {
t.Fatal("filtered get must return error envelope")
}
ed := res["error_detail"].(map[string]any)
if ed["code"] != "not_found" {
t.Fatalf("want not_found, got %v", ed["code"])
}
}
func TestAckAdvancesStateAndFiltered(t *testing.T) {
fm := &fakeMailer{
uidValidity: 1, maxUID: 100,
headers: []mail.Header{
{UID: 101, From: "bob@trusted.com", Subject: "hi"},
{UID: 102, From: "eve@evil.com", Subject: "spam"},
},
}
d, buf := newDeps(t, fm)
// Acking a filtered uid (102) must be rejected as not-found.
if err := AckCmd(d, "work", "INBOX", []uint32{102}); err != nil {
t.Fatalf("AckCmd: %v", err)
}
if decode(t, buf.Bytes())["error"] != true {
t.Fatal("acking filtered uid must fail")
}
// Acking a visible uid (101) succeeds and removes it from list --new.
buf.Reset()
if err := AckCmd(d, "work", "INBOX", []uint32{101}); err != nil {
t.Fatalf("AckCmd 101: %v", err)
}
if decode(t, buf.Bytes())["error"] != false {
t.Fatal("ack of visible uid should succeed")
}
buf.Reset()
_ = ListCmd(d, "work", "INBOX", true, 0, 0, 50)
data := decode(t, buf.Bytes())["data"].(map[string]any)
msgs := data["messages"].([]any)
if len(msgs) != 0 { // 101 acked, 102 filtered
t.Fatalf("want 0 new messages, got %d", len(msgs))
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/cli/ -run 'List|Get|Ack'
Expected: FAIL — undefined Deps/ListCmd.
- Step 3: Write the agent command implementation
Create internal/cli/agent.go:
package cli
import (
"io"
"time"
"git.dcglab.co.uk/steve/emcli/internal/mail"
"git.dcglab.co.uk/steve/emcli/internal/policy"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
// Mailer is the subset of the IMAP client the agent commands use.
type Mailer interface {
SelectFolder(folder string) (uint32, uint32, error)
FetchHeaders(folder string, uids []uint32) ([]mail.Header, error)
FetchHeadersRange(folder string, since, before uint32, limit int) ([]mail.Header, error)
FetchFull(folder string, uid uint32) (mail.Message, error)
Search(folder string, sc mail.SearchCriteria, limit int) ([]mail.Header, error)
Logout() error
}
type Deps struct {
Store *store.Store
Dial func(store.Account) (Mailer, error)
Now func() time.Time
Out io.Writer
}
func (d Deps) emit(e Envelope) error { return e.Write(d.Out) }
func (d Deps) audit(account, action, target, result, reason string) {
_ = d.Store.Audit(d.Now(), store.AuditEntry{
Account: account, Action: action, Target: target, Result: result, Reason: reason,
})
}
// setup loads the account, builds the inbound rule, dials IMAP, and selects the
// folder (establishing the baseline). Returns a cleanup func.
func (d Deps) setup(account, folder string) (store.Account, policy.InboundRule, Mailer, func(), *Envelope) {
acc, err := d.Store.GetAccount(account)
if err != nil {
e := Failure(CodeNotFound, "account not found: "+account)
return acc, policy.InboundRule{}, nil, nil, &e
}
re, err := policy.CompileSubject(acc.SubjectRegex)
if err != nil {
e := Failure(CodeConfig, "invalid subject_regex: "+err.Error())
return acc, policy.InboundRule{}, nil, nil, &e
}
wlIn, _ := d.Store.ListWhitelist(account, store.DirIn)
rule := policy.InboundRule{
WhitelistInEnabled: acc.WhitelistInEnabled,
WhitelistIn: wlIn,
SubjectRegex: re,
}
m, err := d.Dial(acc)
if err != nil {
e := Failure(CodeNetwork, "imap connect failed: "+err.Error())
return acc, rule, nil, nil, &e
}
uidv, maxUID, err := m.SelectFolder(folder)
if err != nil {
m.Logout()
e := Failure(CodeNetwork, "select folder failed: "+err.Error())
return acc, rule, nil, nil, &e
}
if err := d.Store.EnsureFolderBaseline(account, folder, uidv, maxUID); err != nil {
m.Logout()
e := Failure(CodeDB, err.Error())
return acc, rule, nil, nil, &e
}
return acc, rule, m, func() { m.Logout() }, nil
}
func headerMap(h mail.Header) map[string]any {
return map[string]any{
"uid": h.UID, "from": h.From, "to": h.To, "subject": h.Subject,
"date": h.Date, "message_id": h.MessageID, "has_attachments": h.HasAttachments,
}
}
func ListCmd(d Deps, account, folder string, onlyNew bool, beforeUID, sinceUID uint32, limit int) error {
_, rule, m, done, fail := d.setup(account, folder)
if fail != nil {
return d.emit(*fail)
}
defer done()
var headers []mail.Header
var err error
if beforeUID > 0 || sinceUID > 0 {
headers, err = m.FetchHeadersRange(folder, sinceUID, beforeUID, limit)
} else {
headers, err = m.FetchHeaders(folder, nil)
}
if err != nil {
d.audit(account, "list", folder, "blocked", "imap_error")
return d.emit(Failure(CodeNetwork, err.Error()))
}
out := make([]map[string]any, 0, len(headers))
for _, h := range headers {
if !rule.Allows(h.From, h.Subject) {
continue // invisible
}
if onlyNew {
isNew, err := d.Store.IsNew(account, folder, h.UID)
if err != nil {
return d.emit(Failure(CodeDB, err.Error()))
}
if !isNew {
continue
}
}
out = append(out, headerMap(h))
if limit > 0 && len(out) >= limit {
break
}
}
d.audit(account, "list", folder, "allowed", "")
return d.emit(Success(map[string]any{"messages": out}))
}
// visible fetches a UID's header and reports whether policy allows it.
func (d Deps) visible(m Mailer, rule policy.InboundRule, folder string, uid uint32) (bool, error) {
hs, err := m.FetchHeaders(folder, []uint32{uid})
if err != nil {
return false, err
}
if len(hs) == 0 {
return false, nil
}
return rule.Allows(hs[0].From, hs[0].Subject), nil
}
func GetCmd(d Deps, account, folder string, uid uint32) error {
_, rule, m, done, fail := d.setup(account, folder)
if fail != nil {
return d.emit(*fail)
}
defer done()
ok, err := d.visible(m, rule, folder, uid)
if err != nil {
return d.emit(Failure(CodeNetwork, err.Error()))
}
if !ok {
d.audit(account, "get", uitoa(uid), "blocked", "filtered")
return d.emit(Failure(CodeNotFound, "message not found"))
}
msg, err := m.FetchFull(folder, uid)
if err != nil {
return d.emit(Failure(CodeNetwork, err.Error()))
}
atts := make([]map[string]any, 0, len(msg.Attachments))
for _, a := range msg.Attachments {
atts = append(atts, map[string]any{
"name": a.Name, "size": a.Size, "mime": a.MIME,
"content_b64": b64(a.Content),
})
}
d.audit(account, "get", uitoa(uid), "allowed", "")
return d.emit(Success(map[string]any{
"uid": msg.Header.UID, "from": msg.Header.From, "to": msg.Header.To,
"subject": msg.Header.Subject, "date": msg.Header.Date,
"message_id": msg.Header.MessageID, "body_text": msg.BodyText,
"attachments": atts,
}))
}
func SearchCmd(d Deps, account, folder string, sc mail.SearchCriteria, limit int) error {
_, rule, m, done, fail := d.setup(account, folder)
if fail != nil {
return d.emit(*fail)
}
defer done()
headers, err := m.Search(folder, sc, limit)
if err != nil {
return d.emit(Failure(CodeNetwork, err.Error()))
}
out := make([]map[string]any, 0, len(headers))
for _, h := range headers {
if !rule.Allows(h.From, h.Subject) {
continue
}
out = append(out, headerMap(h))
if limit > 0 && len(out) >= limit {
break
}
}
d.audit(account, "search", folder, "allowed", "")
return d.emit(Success(map[string]any{"messages": out}))
}
func AckCmd(d Deps, account, folder string, uids []uint32) error {
_, rule, m, done, fail := d.setup(account, folder)
if fail != nil {
return d.emit(*fail)
}
defer done()
uidv, _, _ := m.SelectFolder(folder)
for _, uid := range uids {
ok, err := d.visible(m, rule, folder, uid)
if err != nil {
return d.emit(Failure(CodeNetwork, err.Error()))
}
if !ok {
d.audit(account, "ack", uitoa(uid), "blocked", "filtered")
return d.emit(Failure(CodeNotFound, "message not found"))
}
}
if err := d.Store.Ack(account, folder, uidv, uids...); err != nil {
return d.emit(Failure(CodeDB, err.Error()))
}
d.audit(account, "ack", folder, "allowed", "")
return d.emit(Success(map[string]any{"acked": uintSlice(uids)}))
}
Create internal/cli/dispatch.go (helpers + the Run router skeleton used by main):
package cli
import (
"encoding/base64"
"strconv"
)
func b64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
func uitoa(u uint32) string { return strconv.FormatUint(uint64(u), 10) }
func uintSlice(us []uint32) []uint64 {
out := make([]uint64, len(us))
for i, u := range us {
out[i] = uint64(u)
}
return out
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/cli/
Expected: PASS (envelope + agent tests).
- Step 5: Commit
git add internal/cli/agent.go internal/cli/dispatch.go internal/cli/agent_test.go
git commit -m "feat(cli): agent read commands (list/get/search/ack) with policy filtering"
Task 14: cli — flag parsing, real IMAP wiring & minimal admin
Wire the real IMAP client to the Mailer interface, parse flags for each agent command, add minimal flag-based admin (account add, whitelist add) so the tool is configurable end-to-end, and route everything from main. The interactive TUI is Phase 4; this gives a usable non-interactive admin surface now.
Files:
- Create:
internal/cli/run.go - Create:
internal/cli/admin.go - Modify:
cmd/emcli/main.go - Test:
internal/cli/run_test.go
Interfaces:
-
Consumes: everything above.
-
Produces:
func Run(args []string, out, errOut io.Writer) int— top-level router; returns process exit code.func realMailer(acc store.Account) (Mailer, error)— adaptsmail.DialtoMailer.- Admin handlers writing human-readable output:
accountAdd,whitelistAdd,accountList.
-
Step 1: Write the failing test
Create internal/cli/run_test.go:
package cli
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
func TestRunUnknownCommand(t *testing.T) {
var out, errOut bytes.Buffer
code := Run([]string{"frobnicate"}, &out, &errOut)
if code == 0 {
t.Fatal("unknown command should be non-zero exit")
}
if !strings.Contains(errOut.String(), "unknown") {
t.Fatalf("stderr should mention unknown command: %q", errOut.String())
}
}
func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) {
// `account list` with no DB key should fail closed with a usage/config error,
// proving the key check happens before any DB work.
var out, errOut bytes.Buffer
t.Setenv("EMCLI_KEY", "")
code := Run([]string{"account", "list"}, &out, &errOut)
if code == 0 {
t.Fatal("missing EMCLI_KEY must fail")
}
if !strings.Contains(out.String()+errOut.String(), "EMCLI_KEY") {
t.Fatalf("should mention EMCLI_KEY, got out=%q err=%q", out.String(), errOut.String())
}
}
func TestListUsageErrorIsJSON(t *testing.T) {
// Agent command with a missing required flag emits a JSON error envelope.
var out, errOut bytes.Buffer
t.Setenv("EMCLI_KEY", b64Key())
t.Setenv("EMCLI_DB", "") // default path is fine; command fails before connecting
code := Run([]string{"list"}, &out, &errOut) // missing --account
if code == 0 {
t.Fatal("missing --account should be non-zero")
}
var env map[string]any
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("agent usage error must be JSON, got %q", out.String())
}
if env["error"] != true {
t.Fatalf("want error envelope: %v", env)
}
}
func b64Key() string {
// 32 zero bytes, base64.
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
}
- Step 2: Run test to verify it fails
Run: go test ./internal/cli/ -run Run
Expected: FAIL — undefined Run.
- Step 3: Write the router and admin
Create internal/cli/run.go:
package cli
import (
"flag"
"fmt"
"io"
"strconv"
"strings"
"time"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/mail"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func realMailer(acc store.Account) (Mailer, error) {
c, err := mail.Dial(mail.IMAPConfig{
Host: acc.IMAPHost, Port: acc.IMAPPort, Security: acc.IMAPSecurity,
Username: acc.Username, Password: acc.Password,
})
if err != nil {
return nil, err
}
return c, nil
}
// openStore loads the key and opens the DB, returning a human-readable error string.
func openStore() (*store.Store, error) {
key, err := crypto.KeyFromEnv()
if err != nil {
return nil, err
}
path, err := store.DefaultDBPath()
if err != nil {
return nil, err
}
return store.Open(path, key)
}
func newDepsLive(st *store.Store, out io.Writer) Deps {
return Deps{Store: st, Dial: realMailer, Now: time.Now, Out: out}
}
// Run routes a command line and returns an exit code.
func Run(args []string, out, errOut io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(errOut, "emcli: no command given")
return 2
}
cmd, rest := args[0], args[1:]
switch cmd {
case "list", "get", "search", "ack":
return runAgent(cmd, rest, out, errOut)
case "account":
return runAccount(rest, out, errOut)
case "whitelist":
return runWhitelist(rest, out, errOut)
default:
fmt.Fprintf(errOut, "emcli: unknown command %q\n", cmd)
return 2
}
}
// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes.
func runAgent(cmd string, args []string, out, errOut io.Writer) int {
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "account name")
folder := fs.String("folder", "INBOX", "folder/mailbox")
onlyNew := fs.Bool("new", false, "only new (unacked) messages")
limit := fs.Int("limit", 50, "max results (cap 500)")
uid := fs.Uint("uid", 0, "message UID (get)")
before := fs.Uint("before", 0, "list: UID cursor, older than")
since := fs.Uint("since", 0, "list: UID cursor, newer than")
from := fs.String("from", "", "search: sender")
subjectContains := fs.String("subject-contains", "", "search: subject substring")
text := fs.String("text", "", "search: full-text")
sinceDate := fs.String("since-date", "", "search: RFC3339 date lower bound")
beforeDate := fs.String("before-date", "", "search: RFC3339 date upper bound")
ackUIDs := fs.String("uid-list", "", "ack: comma-separated UIDs")
if err := fs.Parse(args); err != nil {
_ = Failure(CodeUsage, err.Error()).Write(out)
return 2
}
if *limit > 500 {
*limit = 500
}
if *account == "" {
_ = Failure(CodeUsage, "--account is required").Write(out)
return 2
}
st, err := openStore()
if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out)
return 1
}
defer st.Close()
_, _ = st.PurgeAudit(time.Now())
d := newDepsLive(st, out)
switch cmd {
case "list":
if err := ListCmd(d, *account, *folder, *onlyNew, u32(*before), u32(*since), *limit); err != nil {
return 1
}
case "get":
if *uid == 0 {
_ = Failure(CodeUsage, "--uid is required").Write(out)
return 2
}
if err := GetCmd(d, *account, *folder, u32(*uid)); err != nil {
return 1
}
case "search":
sc := mail.SearchCriteria{From: *from, SubjectContains: *subjectContains, Text: *text}
if *sinceDate != "" {
if tm, err := time.Parse(time.RFC3339, *sinceDate); err == nil {
sc.Since = tm
}
}
if *beforeDate != "" {
if tm, err := time.Parse(time.RFC3339, *beforeDate); err == nil {
sc.Before = tm
}
}
if err := SearchCmd(d, *account, *folder, sc, *limit); err != nil {
return 1
}
case "ack":
uids, err := parseUIDList(*ackUIDs)
if err != nil {
_ = Failure(CodeUsage, err.Error()).Write(out)
return 2
}
if err := AckCmd(d, *account, *folder, uids); err != nil {
return 1
}
}
return 0
}
func u32(u uint) uint32 { return uint32(u) }
func parseUIDList(s string) ([]uint32, error) {
if strings.TrimSpace(s) == "" {
return nil, fmt.Errorf("--uid-list is required")
}
var out []uint32
for _, part := range strings.Split(s, ",") {
n, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid uid %q", part)
}
out = append(out, uint32(n))
}
return out, nil
}
Create internal/cli/admin.go:
package cli
import (
"flag"
"fmt"
"io"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
// runAccount handles `account add|list`. Human-readable output (never JSON).
func runAccount(args []string, out, errOut io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(errOut, "usage: emcli account <add|list>")
return 2
}
sub, rest := args[0], args[1:]
st, err := openStore()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
switch sub {
case "add":
fs := flag.NewFlagSet("account add", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name")
mode := fs.String("mode", "RO", "RO|RW")
host := fs.String("imap-host", "", "IMAP host")
port := fs.Int("imap-port", 993, "IMAP port")
sec := fs.String("imap-security", "tls", "tls|starttls")
user := fs.String("username", "", "login username")
pass := fs.String("password", "", "login password")
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")
backlog := fs.Bool("process-backlog", false, "treat existing mail as new")
if err := fs.Parse(rest); err != nil {
return 2
}
if *name == "" || *host == "" || *user == "" {
fmt.Fprintln(errOut, "name, imap-host, and username are required")
return 2
}
_, err := st.AddAccount(store.Account{
Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
AuthType: "password", Username: *user, Password: *pass,
SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut,
ProcessBacklog: *backlog,
})
if err != nil {
fmt.Fprintf(errOut, "add account: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q added (%s)\n", *name, *mode)
return 0
case "list":
accs, err := st.ListAccounts()
if err != nil {
fmt.Fprintf(errOut, "list: %v\n", err)
return 1
}
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
for _, a := range accs {
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username)
}
return 0
default:
fmt.Fprintf(errOut, "unknown account subcommand %q\n", sub)
return 2
}
}
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
func runWhitelist(args []string, out, errOut io.Writer) int {
if len(args) < 2 {
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
return 2
}
dir := store.Direction(args[0])
sub, rest := args[1], args[2:]
fs := flag.NewFlagSet("whitelist", flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "account name")
address := fs.String("address", "", "email or @domain")
if err := fs.Parse(rest); err != nil {
return 2
}
if *account == "" {
fmt.Fprintln(errOut, "--account is required")
return 2
}
st, err := openStore()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
switch sub {
case "add":
if err := st.AddWhitelist(*account, dir, *address); err != nil {
fmt.Fprintf(errOut, "add: %v\n", err)
return 1
}
fmt.Fprintf(out, "added %s to %s whitelist of %q\n", *address, dir, *account)
case "remove":
if err := st.RemoveWhitelist(*account, dir, *address); err != nil {
fmt.Fprintf(errOut, "remove: %v\n", err)
return 1
}
fmt.Fprintf(out, "removed %s\n", *address)
case "list":
addrs, err := st.ListWhitelist(*account, dir)
if err != nil {
fmt.Fprintf(errOut, "list: %v\n", err)
return 1
}
for _, a := range addrs {
fmt.Fprintln(out, a)
}
default:
fmt.Fprintf(errOut, "unknown whitelist subcommand %q\n", sub)
return 2
}
return 0
}
- Step 4: Replace main to route through Run
Replace cmd/emcli/main.go with:
package main
import (
"fmt"
"os"
"git.dcglab.co.uk/steve/emcli/internal/cli"
"git.dcglab.co.uk/steve/emcli/internal/version"
)
func main() {
if len(os.Args) >= 2 && os.Args[1] == "version" {
fmt.Println(version.String)
return
}
os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr))
}
- Step 5: Run all tests and build
Run: go test ./... && make build
Expected: PASS across all packages; emcli builds.
- Step 6: Manual smoke test
Run:
export EMCLI_KEY=$(head -c32 /dev/zero | base64)
export EMCLI_DB=/tmp/emcli-smoke.db
./emcli account add --name demo --imap-host imap.example.com --username me@example.com --password x --whitelist-in
./emcli whitelist in add --account demo --address @trusted.com
./emcli account list
./emcli list --account demo --new # emits a JSON error envelope (cannot connect to example.com) — proves JSON-only output
rm -f /tmp/emcli-smoke.db
Expected: admin commands print human-readable lines; list prints a single JSON object with "error": true and a network code.
- Step 7: Commit
git add internal/cli/run.go internal/cli/admin.go internal/cli/run_test.go cmd/emcli/main.go
git commit -m "feat(cli): command router, real IMAP wiring, flag-based admin"
Phase 1 Self-Review
- Spec coverage (read path): encrypted store ✓ (Tasks 2–4), per-account RO/RW field stored ✓ (Task 4; send-side enforcement is Phase 2), whitelist-in + subject filter ✓ (Tasks 5, 8, 9, 13), seen-set "new" + ack + compaction ✓ (Task 6), folder-specified reads + pointer-per-folder ✓ (Tasks 6, 11, 13),
list/get/search/ackJSON ✓ (Tasks 12–14), headers-on-list / body+attachments-on-get ✓ (Tasks 10, 13), invisible inbound filtering incl. un-ackable filtered UIDs ✓ (Task 13), audit log + retention purge ✓ (Tasks 7, 14), single static binary + pure-Go SQLite ✓ (Tasks 1, 3, global constraints),EMCLI_KEY/EMCLI_DBhandling and fail-closed ✓ (Tasks 2, 3, 14). - Deferred to later phases (intentional, not gaps): SMTP
send+ outbound policy (Phase 2), OAuth2 loopback + token refresh and theenc_oauth_*columns wiring (Phase 3), interactive TUIinit/reconfigure anddoctor(Phase 4). The schema already includes the OAuth columns so no migration is needed later. - Placeholder scan: no TBD/TODO; every code step is complete and compilable in sequence.
- Type consistency:
Mailerinterface in Task 13 matches the concrete*mail.Clientmethods in Task 11 (SelectFolder,FetchHeaders,FetchHeadersRange,FetchFull,Search,Logout);storemethod names (EnsureFolderBaseline,IsNew,Ack,Audit,ListWhitelist) are used consistently across Tasks 6, 7, 13.
Notes for the implementer
- Go-imap v1's
client.Clientis not safe for concurrent use; each command dials its own connection, which is correct for a per-invocation CLI. - Keep
time.Now()out ofstore; it is always injected (Deps.Now,Audit(now, …)) so tests are deterministic — the workflow runtime forbids wall-clock calls in some contexts and determinism aids review. - When in doubt about an emersion API detail, prefer the smallest change that keeps the
Mailerinterface stable; the CLI tests depend only on the interface, not the concrete client.