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:
118
internal/mailfetch/mailbox.go
Normal file
118
internal/mailfetch/mailbox.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// 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]
|
||||
}
|
||||
77
internal/mailfetch/mailbox_test.go
Normal file
77
internal/mailfetch/mailbox_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user