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

@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -29,6 +30,7 @@ func main() {
classifyNow = flag.Bool("classify", false, "classify pending messages with JEV and exit")
classifyAll = flag.Bool("classify-all", false, "re-classify every stored message with JEV and exit")
checkSMTP = flag.Bool("check-smtp", false, "verify the Gmail SMTP connection (IPv4 + STARTTLS + login) and exit; sends no mail")
checkThread = flag.Int64("check-thread", 0, "verify that a stored message's Gmail thread has a sent reply (read-only), then exit")
)
flag.Parse()
@@ -79,6 +81,14 @@ func main() {
return
}
if *checkThread > 0 {
if err := runCheckThread(cfg, logger, *checkThread); err != nil {
fmt.Fprintf(os.Stderr, "thread check failed: %v\n", err)
os.Exit(1)
}
return
}
if err := run(cfg, logger); err != nil {
logger.Error("fatal", "error", err)
os.Exit(1)
@@ -194,6 +204,37 @@ func runCheckSMTP(cfg *config.Config, logger *slog.Logger) error {
return mailops.New(cfg, logger).CheckSMTP(ctx)
}
// runCheckThread reports whether a stored message's Gmail thread already has a
// message in the Sent mailbox. It is read-only and exists so the Phase 5 thread
// check can be exercised against real Gmail without sending anything.
func runCheckThread(cfg *config.Config, logger *slog.Logger, messageID int64) error {
if !cfg.GmailConfigured() {
return mailops.ErrNotConfigured
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
st, err := store.Open(ctx, cfg.DBPath)
if err != nil {
return fmt.Errorf("open store: %w", err)
}
defer func() { _ = st.Close() }()
msg, err := st.MessageByID(ctx, messageID)
if err != nil {
return err
}
if strings.TrimSpace(msg.ThreadID) == "" {
return fmt.Errorf("message %d has no Gmail thread id", messageID)
}
ok, err := mailops.New(cfg, logger).VerifyThreadUpdated(ctx, msg.ThreadID)
if err != nil {
return err
}
fmt.Printf("message=%d thread=%s sent_reply_in_thread=%v\n", messageID, msg.ThreadID, ok)
return nil
}
// probeHealth requests the local health endpoint. It exists so the container
// healthcheck does not need an external HTTP client in the image.
func probeHealth(port int) error {