Phase 5 follow-up: discover the \Sent mailbox; record the approved real send (AUD-71)

The approved real test reply was sent and the thread updated (same
X-GM-THRID as the original). The live test exposed one bug: the thread
check hardcoded Gmail's sent mailbox as "[Gmail]/Sent Mail", but this
account exposes it as "[Google Mail]/Sent Mail".

- internal/mailfetch: LIST parser, ListMailboxes, and SentMailbox that
  finds the mailbox flagged \Sent (RFC 6154) with name fallbacks.
- internal/mailops: VerifyThreadUpdated selects the discovered \Sent
  mailbox instead of a hardcoded name.
- cmd/email-agent: -check-thread <id> exercises the check against real
  Gmail without sending mail.
- Tests for LIST parsing, sent-mailbox discovery, and the discovery
  error path.
- docs/phase5-replies.md: record send-as verification, the sent reply's
  thread id, the app confirmation (sent_reply_in_thread=true), and the
  fix.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip Builder
2026-10-11 22:18:05 +11:00
parent 7ca888f0c6
commit 7c66e4ad31
7 changed files with 341 additions and 34 deletions

View File

@@ -0,0 +1,77 @@
package mailfetch
import "testing"
func TestParseList(t *testing.T) {
// Gmail on this account advertises "[Google Mail]/..." rather than
// "[Gmail]/...". The \Sent attribute is what matters.
cases := []struct {
line string
name string
attr string
ok bool
}{
{`* LIST (\HasChildren \Noselect) "/" "[Gmail]"`, "[Gmail]", `\Noselect`, true},
{`* LIST (\HasNoChildren \Sent) "/" "[Google Mail]/Sent Mail"`, "[Google Mail]/Sent Mail", `\Sent`, true},
{`* LIST (\HasNoChildren) "/" "Replied-to/hello@where-woof.com"`, "Replied-to/hello@where-woof.com", `\HasNoChildren`, true},
{`* LIST (\All \HasNoChildren) "/" "[Google Mail]/All Mail"`, "[Google Mail]/All Mail", `\All`, true},
{`* LIST (\HasNoChildren) NIL "Sent"`, "Sent", `\HasNoChildren`, true},
{`* OK no list here`, "", "", false},
}
for _, tc := range cases {
mb, ok := parseList(tc.line)
if ok != tc.ok {
t.Errorf("parseList(%q) ok = %v, want %v", tc.line, ok, tc.ok)
continue
}
if !ok {
continue
}
if mb.Name != tc.name {
t.Errorf("parseList(%q) name = %q, want %q", tc.line, mb.Name, tc.name)
}
if !mb.Has(tc.attr) {
t.Errorf("parseList(%q) missing attribute %q (got %v)", tc.line, tc.attr, mb.Attributes)
}
}
}
func TestListMailboxesAndSentMailbox(t *testing.T) {
script := "* LIST (\\HasChildren \\Noselect) \"/\" \"[Gmail]\"\r\n" +
"* LIST (\\HasNoChildren) \"/\" \"Replied-to/hello@where-woof.com\"\r\n" +
"* LIST (\\All \\HasNoChildren) \"/\" \"[Google Mail]/All Mail\"\r\n" +
"* LIST (\\HasChildren \\Sent) \"/\" \"[Google Mail]/Sent Mail\"\r\n" +
"A0001 OK LIST completed\r\n"
c, fc := newFakeClient(script)
boxes, err := c.ListMailboxes()
if err != nil {
t.Fatalf("ListMailboxes() error = %v", err)
}
if len(boxes) != 4 {
t.Fatalf("ListMailboxes() = %d, want 4", len(boxes))
}
if got := fc.w.String(); got == "" || !contains(got, `LIST "" "*"`) {
t.Errorf("command sent = %q, want LIST", got)
}
// SentMailbox issues its own LIST, so it needs a fresh script (a live
// connection would simply answer again).
c2, _ := newFakeClient(script)
sent, err := c2.SentMailbox()
if err != nil {
t.Fatalf("SentMailbox() error = %v", err)
}
if sent != "[Google Mail]/Sent Mail" {
t.Errorf("SentMailbox() = %q, want [Google Mail]/Sent Mail", sent)
}
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}