diff --git a/internal/cli/agent.go b/internal/cli/agent.go index 0a6a7e0..6cfd0ce 100644 --- a/internal/cli/agent.go +++ b/internal/cli/agent.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "io" "time" @@ -9,6 +10,10 @@ import ( "git.dcglab.co.uk/steve/emcli/internal/store" ) +// errCommandFailed signals that a command emitted an error envelope, so the +// process should exit non-zero. The JSON envelope is the authoritative detail. +var errCommandFailed = errors.New("command reported error") + // Mailer is the subset of the IMAP client the agent commands use. type Mailer interface { SelectFolder(folder string) (uint32, uint32, error) @@ -26,7 +31,15 @@ type Deps struct { Out io.Writer } -func (d Deps) emit(e Envelope) error { return e.Write(d.Out) } +func (d Deps) emit(e Envelope) error { + if err := e.Write(d.Out); err != nil { + return err // write failure (rare) — propagate as-is + } + if e.Error { + return errCommandFailed + } + return nil +} func (d Deps) audit(account, action, target, result, reason string) { _ = d.Store.Audit(d.Now(), store.AuditEntry{ diff --git a/internal/cli/agent_test.go b/internal/cli/agent_test.go index 8821a25..28a9996 100644 --- a/internal/cli/agent_test.go +++ b/internal/cli/agent_test.go @@ -122,9 +122,7 @@ func TestGetFilteredReturnsNotFound(t *testing.T) { }, } d, buf := newDeps(t, fm) - if err := GetCmd(d, "work", "INBOX", 102); err != nil { - t.Fatalf("GetCmd: %v", err) - } + _ = GetCmd(d, "work", "INBOX", 102) res := decode(t, buf.Bytes()) if res["error"] != true { t.Fatal("filtered get must return error envelope") @@ -135,6 +133,25 @@ func TestGetFilteredReturnsNotFound(t *testing.T) { } } +func TestGetFilteredReturnsErrorForExit(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) + err := GetCmd(d, "work", "INBOX", 102) + if err == nil { + t.Fatal("GetCmd on filtered uid must return non-nil error so caller can exit non-zero") + } + res := decode(t, buf.Bytes()) + if res["error"] != true { + t.Fatalf("envelope must report error=true, got %v", res["error"]) + } +} + func TestAckAdvancesStateAndFiltered(t *testing.T) { fm := &fakeMailer{ uidValidity: 1, maxUID: 100, @@ -145,9 +162,7 @@ func TestAckAdvancesStateAndFiltered(t *testing.T) { } 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) - } + _ = AckCmd(d, "work", "INBOX", []uint32{102}) if decode(t, buf.Bytes())["error"] != true { t.Fatal("acking filtered uid must fail") }