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

@@ -31,9 +31,6 @@ const (
LabelDelete = "Delete"
LabelSaved = "Saved"
repliedPrefix = "Replied-to/"
// LabelSent is Gmail's IMAP name for the Sent mailbox. Phase 5 checks it to
// confirm a reply joined the original thread.
LabelSent = "[Gmail]/Sent Mail"
)
// ErrNotConfigured means Gmail credentials are missing, so no mailbox action can
@@ -50,6 +47,9 @@ type Conn interface {
StoreFlags(uid uint32, op string, flag string) error
SearchGmailID(gmailID string) ([]uint32, error)
SearchGmailThreadID(threadID string) ([]uint32, error)
// SentMailbox returns the server's \Sent mailbox (Gmail may call it
// "[Gmail]/Sent Mail" or "[Google Mail]/Sent Mail").
SentMailbox() (string, error)
Logout() error
}
@@ -164,12 +164,16 @@ func (c *Client) VerifyThreadUpdated(ctx context.Context, threadID string) (bool
}
defer func() { _ = conn.Logout() }()
if _, err := conn.Select(LabelSent); err != nil {
return false, fmt.Errorf("select %q: %w", LabelSent, err)
sent, err := conn.SentMailbox()
if err != nil {
return false, fmt.Errorf("find the sent mailbox: %w", err)
}
if _, err := conn.Select(sent); err != nil {
return false, fmt.Errorf("select %q: %w", sent, err)
}
uids, err := conn.SearchGmailThreadID(threadID)
if err != nil {
return false, fmt.Errorf("search thread %s in %q: %w", threadID, LabelSent, err)
return false, fmt.Errorf("search thread %s in %q: %w", threadID, sent, err)
}
return len(uids) > 0, nil
}

View File

@@ -21,6 +21,8 @@ type fakeConn struct {
searched []string
searchUIDs []uint32
searchErr error
sentMailbox string
sentErr error
loggedOut bool
}
@@ -49,6 +51,15 @@ func (f *fakeConn) SearchGmailThreadID(id string) ([]uint32, error) {
f.searched = append(f.searched, "thrid:"+id)
return f.searchUIDs, f.searchErr
}
func (f *fakeConn) SentMailbox() (string, error) {
if f.sentErr != nil {
return "", f.sentErr
}
if f.sentMailbox == "" {
return "[Google Mail]/Sent Mail", nil
}
return f.sentMailbox, nil
}
func (f *fakeConn) Logout() error { f.loggedOut = true; return nil }
func configured() *config.Config {

View File

@@ -2,6 +2,7 @@ package mailops
import (
"context"
"errors"
"net"
"strings"
"testing"
@@ -113,14 +114,34 @@ func TestVerifyThreadUpdatedSearchesSentMailbox(t *testing.T) {
if !ok {
t.Error("VerifyThreadUpdated() = false, want true when the thread has a sent message")
}
if fc.selected != LabelSent {
t.Errorf("selected = %q, want %q", fc.selected, LabelSent)
if fc.selected != "[Google Mail]/Sent Mail" {
t.Errorf("selected = %q, want the discovered sent mailbox", fc.selected)
}
if len(fc.searched) != 1 || fc.searched[0] != "thrid:987654321" {
t.Errorf("searched = %v, want [thrid:987654321]", fc.searched)
}
}
func TestVerifyThreadUpdatedUsesDiscoveredSentMailbox(t *testing.T) {
fc := &fakeConn{uidValidity: 3, searchUIDs: []uint32{5}, sentMailbox: "[Gmail]/Sent Mail"}
c := newFakeClient(fc)
ok, err := c.VerifyThreadUpdated(context.Background(), "77")
if err != nil || !ok {
t.Fatalf("VerifyThreadUpdated() ok=%v err=%v", ok, err)
}
if fc.selected != "[Gmail]/Sent Mail" {
t.Errorf("selected = %q, want the configured sent mailbox", fc.selected)
}
}
func TestVerifyThreadUpdatedSentMailboxError(t *testing.T) {
fc := &fakeConn{sentErr: errors.New("no sent mailbox")}
c := newFakeClient(fc)
if _, err := c.VerifyThreadUpdated(context.Background(), "77"); err == nil {
t.Fatal("VerifyThreadUpdated() error = nil, want a sent-mailbox discovery error")
}
}
func TestVerifyThreadUpdatedEmptyThread(t *testing.T) {
c := newFakeClient(&fakeConn{searchUIDs: nil})
if _, err := c.VerifyThreadUpdated(context.Background(), ""); err == nil {