Files
Paperclip Builder 7c66e4ad31 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>
2026-10-11 22:18:05 +11:00

119 lines
2.9 KiB
Go

// Mailbox discovery for the IMAP client.
//
// Gmail exposes its special-use mailboxes with RFC 6154 attributes. The Sent
// mailbox is named "[Gmail]/Sent Mail" on most accounts but "[Google Mail]/Sent
// Mail" on others (and "Sent" on non-Gmail servers). Phase 5 asks the server for
// the mailbox flagged \Sent instead of hardcoding a name.
package mailfetch
import (
"fmt"
"strings"
)
// Mailbox is one entry from an IMAP LIST response.
type Mailbox struct {
Name string
Attributes []string
}
// Has reports whether the mailbox carries the given attribute (for example
// `\Sent` or `\All`). The comparison is case-insensitive.
func (m Mailbox) Has(attr string) bool {
for _, a := range m.Attributes {
if strings.EqualFold(a, attr) {
return true
}
}
return false
}
// ListMailboxes returns every mailbox the server advertises.
func (c *Client) ListMailboxes() ([]Mailbox, error) {
var out []Mailbox
err := c.exec(`LIST "" "*"`, func(r *response) error {
if mb, ok := parseList(r.line); ok {
out = append(out, mb)
}
return nil
})
return out, err
}
// sentFallbacks are used when the server does not advertise the \Sent
// attribute.
var sentFallbacks = []string{
"[Gmail]/Sent Mail",
"[Google Mail]/Sent Mail",
"Sent",
"Sent Mail",
}
// SentMailbox returns the mailbox the server flags \Sent. It falls back to the
// common names when the attribute is absent.
func (c *Client) SentMailbox() (string, error) {
boxes, err := c.ListMailboxes()
if err != nil {
return "", err
}
for _, mb := range boxes {
if mb.Has(`\Sent`) {
return mb.Name, nil
}
}
for _, want := range sentFallbacks {
for _, mb := range boxes {
if strings.EqualFold(mb.Name, want) {
return mb.Name, nil
}
}
}
return "", fmt.Errorf("no sent mailbox advertised by the server")
}
// parseList parses one "* LIST (attrs) delim name" response line. XLIST is
// accepted as well, for servers that only implement the older extension.
func parseList(line string) (Mailbox, bool) {
upper := strings.ToUpper(line)
if !strings.HasPrefix(upper, "* LIST ") && !strings.HasPrefix(upper, "* XLIST ") {
return Mailbox{}, false
}
open := strings.IndexByte(line, '(')
if open < 0 {
return Mailbox{}, false
}
closeIdx := strings.IndexByte(line[open:], ')')
if closeIdx < 0 {
return Mailbox{}, false
}
closeIdx += open
attrs := strings.Fields(line[open+1 : closeIdx])
tail := strings.TrimSpace(line[closeIdx+1:])
name := lastQuoted(tail)
if name == "" {
fields := strings.Fields(tail)
if len(fields) == 0 {
return Mailbox{}, false
}
name = strings.Trim(fields[len(fields)-1], `"`)
}
if name == "" {
return Mailbox{}, false
}
return Mailbox{Name: name, Attributes: attrs}, true
}
// lastQuoted returns the contents of the last "..." segment in s.
func lastQuoted(s string) string {
end := strings.LastIndexByte(s, '"')
if end <= 0 {
return ""
}
start := strings.LastIndexByte(s[:end], '"')
if start < 0 {
return ""
}
return s[start+1 : end]
}