- internal/mailfetch: stdlib-only read-only IMAP client (LOGIN, SELECT, UID SEARCH, UID FETCH) that always dials tcp4; IPv6 to Gmail is broken from .13 and caused a socket EOF. - MIME parsing to text, RFC2047 header decoding, FR-2 source address: X-Pm-Original-To -> To -> Received 'for <addr>' -> Return-Path, never Delivered-To. The matched header is recorded for audit. - store: Message upsert/list/count + LogAction; schema migration v2 adds imap_uid and imap_uidvalidity. - server: dashboard and /partials/inbox render real mail grouped by category with a recommended action; POST /actions/fetch runs a scan (CSRF-checked); nav counts; 'Check mail' button. - config: GMAIL_USER, GMAIL_APP_PASSWORD, IMAP host/port/lookback/max. - cmd: -fetch runs one scan and exits (n8n/CLI entry point, FR-1). - compose/.env.example expose the Gmail + IMAP settings. - tests: FR-2 precedence, MIME bodies, IMAP literal/parser, store upsert/migration, inbox rendering. Co-Authored-By: Paperclip <noreply@paperclip.ing>
168 lines
4.6 KiB
Go
168 lines
4.6 KiB
Go
package mailfetch
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"errors"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeConn is an in-memory net.Conn for protocol tests.
|
|
type fakeConn struct {
|
|
r *bytes.Reader
|
|
w bytes.Buffer
|
|
}
|
|
|
|
func newFakeConn(script string) *fakeConn {
|
|
return &fakeConn{r: bytes.NewReader([]byte(script))}
|
|
}
|
|
|
|
func (c *fakeConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
|
func (c *fakeConn) Write(p []byte) (int, error) { return c.w.Write(p) }
|
|
func (c *fakeConn) Close() error { return nil }
|
|
func (c *fakeConn) LocalAddr() net.Addr { return dummyAddr{} }
|
|
func (c *fakeConn) RemoteAddr() net.Addr { return dummyAddr{} }
|
|
func (c *fakeConn) SetDeadline(time.Time) error { return nil }
|
|
func (c *fakeConn) SetReadDeadline(time.Time) error {
|
|
return nil
|
|
}
|
|
func (c *fakeConn) SetWriteDeadline(time.Time) error { return nil }
|
|
|
|
type dummyAddr struct{}
|
|
|
|
func (dummyAddr) Network() string { return "fake" }
|
|
func (dummyAddr) String() string { return "fake" }
|
|
|
|
func newFakeClient(script string) (*Client, *fakeConn) {
|
|
fc := newFakeConn(script)
|
|
return &Client{conn: fc, br: bufio.NewReader(fc), timeout: time.Second}, fc
|
|
}
|
|
|
|
func TestReadResponseSingleLiteral(t *testing.T) {
|
|
script := "* 1 FETCH (UID 10 BODY[] {5}\r\nHELLO)\r\n"
|
|
c, _ := newFakeClient(script)
|
|
|
|
resp, err := c.readResponse()
|
|
if err != nil {
|
|
t.Fatalf("readResponse() error = %v", err)
|
|
}
|
|
if len(resp.literals) != 1 || string(resp.literals[0]) != "HELLO" {
|
|
t.Fatalf("literals = %q, want [HELLO]", resp.literals)
|
|
}
|
|
if !strings.Contains(resp.line, "UID 10") || !strings.Contains(resp.line, ")") {
|
|
t.Errorf("assembled line = %q, want UID and closing paren", resp.line)
|
|
}
|
|
}
|
|
|
|
func TestReadResponseLiteralAtEnd(t *testing.T) {
|
|
script := "* 1 FETCH (BODY[] {5}\r\nHELLO\r\n\r\n"
|
|
c, _ := newFakeClient(script)
|
|
|
|
resp, err := c.readResponse()
|
|
if err != nil {
|
|
t.Fatalf("readResponse() error = %v", err)
|
|
}
|
|
if len(resp.literals) != 1 || string(resp.literals[0]) != "HELLO" {
|
|
t.Fatalf("literals = %q, want [HELLO]", resp.literals)
|
|
}
|
|
}
|
|
|
|
func TestSearchSince(t *testing.T) {
|
|
script := "* SEARCH 10 11 12\r\nA0001 OK SEARCH completed\r\n"
|
|
c, fc := newFakeClient(script)
|
|
|
|
uids, err := c.SearchSince(time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("SearchSince() error = %v", err)
|
|
}
|
|
want := []uint32{10, 11, 12}
|
|
if len(uids) != len(want) {
|
|
t.Fatalf("uids = %v, want %v", uids, want)
|
|
}
|
|
for i := range want {
|
|
if uids[i] != want[i] {
|
|
t.Errorf("uids[%d] = %d, want %d", i, uids[i], want[i])
|
|
}
|
|
}
|
|
if !strings.Contains(fc.w.String(), "UID SEARCH SINCE 02-Jan-2026") {
|
|
t.Errorf("command sent = %q, want UID SEARCH SINCE 02-Jan-2026", fc.w.String())
|
|
}
|
|
}
|
|
|
|
func TestFetchParsesGmailIDs(t *testing.T) {
|
|
script := "* 1 FETCH (UID 10 X-GM-MSGID 1234000000000000000 X-GM-THRID 999 BODY[] {5}\r\nHELLO)\r\n" +
|
|
"A0001 OK FETCH completed\r\n"
|
|
c, _ := newFakeClient(script)
|
|
|
|
results, err := c.Fetch([]uint32{10})
|
|
if err != nil {
|
|
t.Fatalf("Fetch() error = %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("results = %d, want 1", len(results))
|
|
}
|
|
if results[0].UID != 10 {
|
|
t.Errorf("UID = %d, want 10", results[0].UID)
|
|
}
|
|
if results[0].GmailMsgID != "1234000000000000000" {
|
|
t.Errorf("GmailMsgID = %q", results[0].GmailMsgID)
|
|
}
|
|
if results[0].GmailThreadID != "999" {
|
|
t.Errorf("GmailThreadID = %q", results[0].GmailThreadID)
|
|
}
|
|
if string(results[0].Raw) != "HELLO" {
|
|
t.Errorf("Raw = %q, want HELLO", results[0].Raw)
|
|
}
|
|
}
|
|
|
|
func TestLoginFailureIsServerError(t *testing.T) {
|
|
script := "* OK greeting\r\nA0001 NO [AUTHENTICATIONFAILED] Invalid credentials\r\n"
|
|
c, _ := newFakeClient(script)
|
|
|
|
err := c.Login("user@example.test", "wrong")
|
|
if err == nil {
|
|
t.Fatal("Login() error = nil, want failure")
|
|
}
|
|
var se *ServerError
|
|
if !errors.As(err, &se) {
|
|
t.Fatalf("Login() error = %T, want *ServerError", err)
|
|
}
|
|
if !strings.Contains(se.Response, "AUTHENTICATIONFAILED") {
|
|
t.Errorf("ServerError.Response = %q", se.Response)
|
|
}
|
|
}
|
|
|
|
func TestLiteralLen(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
n int
|
|
ok bool
|
|
}{
|
|
{"* 1 FETCH (BODY[] {12}", 12, true},
|
|
{"* 1 FETCH (BODY[] {abc}", 0, false},
|
|
{"* 1 FETCH (BODY[])", 0, false},
|
|
{"* SEARCH 1 2", 0, false},
|
|
}
|
|
for _, tc := range cases {
|
|
n, ok := literalLen(tc.in)
|
|
if n != tc.n || ok != tc.ok {
|
|
t.Errorf("literalLen(%q) = (%d, %v), want (%d, %v)", tc.in, n, ok, tc.n, tc.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestImapQuote(t *testing.T) {
|
|
if got := imapQuote(`pa"ss\word`); got != `"pa\"ss\\word"` {
|
|
t.Errorf("imapQuote() = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestUIDSet(t *testing.T) {
|
|
if got := uidSet([]uint32{1, 2, 3}); got != "1,2,3" {
|
|
t.Errorf("uidSet() = %q, want 1,2,3", got)
|
|
}
|
|
}
|