Files
2026-09-16 06:26:16 +02:00

132 lines
4.6 KiB
Go

package mailingress
import (
"context"
"crypto/tls"
"github.com/emersion/go-imap/backend/memory"
"github.com/emersion/go-imap/server"
"github.com/example/notify-gateway/internal/gateway"
"github.com/example/notify-gateway/internal/outbox"
"github.com/example/notify-gateway/internal/testutil"
"io"
"log"
"path/filepath"
"strings"
"testing"
"time"
"github.com/example/notify-gateway/internal/config"
)
func TestIMAPCheckpointsAndDurableAcceptance(t *testing.T) {
ctx := context.Background()
serverTLS, clientTLS := testutil.TLSConfigs()
listener, err := tls.Listen("tcp", "127.0.0.1:0", serverTLS)
if err != nil {
t.Fatal(err)
}
backend := memory.New()
srv := server.New(backend)
srv.ErrorLog = log.New(io.Discard, "", 0)
done := make(chan struct{})
go func() { defer close(done); srv.Serve(listener) }()
defer func() { srv.Close(); <-done }()
dir := t.TempDir()
cfg, _ := config.Open(filepath.Join(dir, "config.json"))
db, err := outbox.Open(filepath.Join(dir, "outbox.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
c := cfg.Get()
c.Mappings = []config.Mapping{{ID: "mail", Enabled: true, Source: "mail", Target: "news", TextTemplate: "{{.Message}}"}}
if err := cfg.Replace(c); err != nil {
t.Fatal(err)
}
q := &gateway.Queue{Store: db, Dispatcher: gateway.New(cfg, nil)}
p := &Poller{Config: cfg, Queue: q}
m := config.MailIngress{ID: "mail", Address: listener.Addr().String(), Username: "username", Password: "password", Folder: "INBOX", Channel: "mail"}
// Existing UID 6 is skipped on first setup; mailbox remains read-only.
if err := p.poll(ctx, m, clientTLS); err != nil {
t.Fatal(err)
}
rows, _ := db.List(ctx, 50, 0)
if len(rows) != 0 {
t.Fatal("old mail imported unexpectedly")
}
cpID := gateway.ScopedKey("imap", m.ID, m.Address, m.Username, m.Folder)
old, _ := db.Checkpoint(ctx, cpID)
user, _ := backend.Login(nil, "username", "password")
mailbox, _ := user.GetMailbox("INBOX")
appendMail := func(body string) {
t.Helper()
if err := mailbox.CreateMessage(nil, time.Now(), strings.NewReader("From: sender@example.org\r\nSubject: Test\r\nContent-Type: text/plain\r\n\r\n"+body)); err != nil {
t.Fatal(err)
}
}
appendMail("new mail")
if err := p.poll(ctx, m, clientTLS); err != nil {
t.Fatal(err)
}
rows, _ = db.List(ctx, 50, 0)
if len(rows) != 1 {
t.Fatalf("new mail jobs=%d", len(rows))
}
// Simulate a crash after receipt commit but before checkpoint persistence.
if err := db.SetCheckpoint(ctx, cpID, old); err != nil {
t.Fatal(err)
}
if err := p.poll(ctx, m, clientTLS); err != nil {
t.Fatal(err)
}
rows, _ = db.List(ctx, 50, 0)
if len(rows) != 1 {
t.Fatal("duplicate IMAP delivery")
}
saved, _ := db.Checkpoint(ctx, cpID)
c.Mappings[0].Enabled = false
cfg.Replace(c)
appendMail("pending routing")
if err := p.poll(ctx, m, clientTLS); err == nil {
t.Fatal("missing mapping accepted")
}
after, _ := db.Checkpoint(ctx, cpID)
if after != saved {
t.Fatal("checkpoint advanced past unaccepted mail")
}
c.Mappings[0].Enabled = true
cfg.Replace(c)
if err := p.poll(ctx, m, clientTLS); err != nil {
t.Fatal(err)
}
rows, _ = db.List(ctx, 50, 0)
if len(rows) != 2 {
t.Fatal("mail was lost")
}
if err := p.Poll(ctx, m); err == nil {
t.Fatal("untrusted IMAP certificate accepted")
}
}
func TestMIMEFiltersAndLoopProtection(t *testing.T) {
raw := "From: Sender <sender@example.org>\r\nTo: ops@example.org\r\nSubject: =?UTF-8?Q?Gr=C3=BC=C3=9Fe?=\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=test\r\n\r\n--test\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\nSGFsbG8=\r\n--test\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename=test.txt\r\n\r\nnot forwarded\r\n--test--\r\n"
cfg := config.MailIngress{Channel: "ops", From: []string{"sender@example.org"}, To: []string{"ops@example.org"}}
msg, skip, err := Parse([]byte(raw), cfg)
if err != nil || skip || msg.Title != "Grüße" || msg.Message != "Hallo" || msg.Source != "mail" {
t.Fatalf("%+v %v %v", msg, skip, err)
}
if _, skip, err := Parse([]byte("X-Notify-Gateway: 1\r\n"+raw), cfg); err != nil || !skip {
t.Fatalf("loop: %v %v", skip, err)
}
cfg.From = []string{"other@example.org"}
if _, skip, err := Parse([]byte(raw), cfg); err != nil || !skip {
t.Fatalf("sender filter: %v %v", skip, err)
}
if _, _, err := Parse([]byte(strings.Repeat("x", maxMailBytes+1)), cfg); err == nil {
t.Fatal("oversize accepted")
}
if _, _, err := Parse([]byte("Content-Type: text/html\r\n\r\n<b>hello</b>"), config.MailIngress{}); err == nil {
t.Fatal("HTML-only mail should require attention")
}
}