Files
mailgoserver/internal/imapserver/imapserver_test.go
T

299 lines
8.8 KiB
Go
Raw Normal View History

2026-08-12 21:14:19 +01:00
package imapserver_test
import (
"net"
"path/filepath"
"testing"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"mailgoserver/internal/db"
"mailgoserver/internal/imapserver"
"mailgoserver/internal/mailstore"
)
// newTestMailboxWithAppPassword seeds a domain + mailbox + app password (returning the
// plaintext, since only its bcrypt hash is stored) and one stored message, and starts
// a plain IMAP listener against it.
func newTestMailboxWithAppPassword(t *testing.T) (client *imapclient.Client, mailboxEmail, appPassword string, mailboxID int64) {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close() })
domainID, err := database.CreateDomain("example.com")
if err != nil {
t.Fatal(err)
}
store := mailstore.New(database, mailstore.GenerateDEK(), t.TempDir())
dek := mailstore.GenerateDEK()
wrapped, nonce, err := store.WrapDEK(dek)
if err != nil {
t.Fatal(err)
}
portalHash, err := db.HashPassword("portal-password-unused")
if err != nil {
t.Fatal(err)
}
mailboxID, err = database.CreateMailbox("inbox@example.com", portalHash, domainID, 5*1024*1024*1024, wrapped, nonce)
if err != nil {
t.Fatal(err)
}
appPassword = db.GenerateAppPassword(25)
appHash, err := db.HashPassword(appPassword)
if err != nil {
t.Fatal(err)
}
2026-08-13 07:03:40 +01:00
if _, err := database.CreateAppPassword(mailboxID, "test client", appHash, nil); err != nil {
2026-08-12 21:14:19 +01:00
t.Fatal(err)
}
raw := []byte("From: Alice <alice@example.com>\r\nSubject: Hello there\r\n\r\nBody text.")
if _, err := store.StoreMessage(mailboxID, "INBOX", raw, "<abc@example.com>", "Alice <alice@example.com>", "Hello there"); err != nil {
t.Fatal(err)
}
backend := &imapserver.Backend{DB: database, Mailstore: store}
srv := imapserver.NewPlainServer(backend)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go srv.Serve(ln)
t.Cleanup(func() { srv.Close() })
conn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
client = imapclient.New(conn, nil)
t.Cleanup(func() { client.Close() })
return client, "inbox@example.com", appPassword, mailboxID
}
func TestIMAPLoginSelectFetch(t *testing.T) {
client, email, appPassword, _ := newTestMailboxWithAppPassword(t)
if err := client.Login(email, appPassword).Wait(); err != nil {
t.Fatalf("login with app password: %v", err)
}
selectData, err := client.Select("INBOX", nil).Wait()
if err != nil {
t.Fatalf("select INBOX: %v", err)
}
if selectData.NumMessages != 1 {
t.Fatalf("NumMessages = %d, want 1", selectData.NumMessages)
}
msgs, err := client.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{
Envelope: true,
Flags: true,
BodySection: []*imap.FetchItemBodySection{{}},
}).Collect()
if err != nil {
t.Fatalf("fetch: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("got %d messages, want 1", len(msgs))
}
msg := msgs[0]
if msg.Envelope == nil || msg.Envelope.Subject != "Hello there" {
t.Fatalf("envelope subject = %+v, want %q", msg.Envelope, "Hello there")
}
if len(msg.Envelope.From) != 1 || msg.Envelope.From[0].Addr() != "alice@example.com" {
t.Fatalf("envelope from = %+v, want alice@example.com", msg.Envelope.From)
}
if len(msg.BodySection) != 1 {
t.Fatalf("expected one body section, got %d", len(msg.BodySection))
}
got := string(msg.BodySection[0].Bytes)
want := "From: Alice <alice@example.com>\r\nSubject: Hello there\r\n\r\nBody text."
if got != want {
t.Fatalf("body section = %q, want %q", got, want)
}
if err := client.Logout().Wait(); err != nil {
t.Fatalf("logout: %v", err)
}
}
func TestIMAPLoginRejectsPortalPassword(t *testing.T) {
client, email, _, _ := newTestMailboxWithAppPassword(t)
// The mailbox's own portal password must never work for IMAP login — only an
// app password does (see Session.Login).
err := client.Login(email, "portal-password-unused").Wait()
if err == nil {
t.Fatal("expected login with the portal password to fail")
}
}
func TestIMAPListAndSelectAdditionalFolder(t *testing.T) {
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close() })
domainID, err := database.CreateDomain("example.com")
if err != nil {
t.Fatal(err)
}
store := mailstore.New(database, mailstore.GenerateDEK(), t.TempDir())
dek := mailstore.GenerateDEK()
wrapped, nonce, err := store.WrapDEK(dek)
if err != nil {
t.Fatal(err)
}
portalHash, err := db.HashPassword("portal-password-unused")
if err != nil {
t.Fatal(err)
}
mailboxID, err := database.CreateMailbox("inbox@example.com", portalHash, domainID, 5*1024*1024*1024, wrapped, nonce)
if err != nil {
t.Fatal(err)
}
appPassword := db.GenerateAppPassword(25)
appHash, err := db.HashPassword(appPassword)
if err != nil {
t.Fatal(err)
}
2026-08-13 07:03:40 +01:00
if _, err := database.CreateAppPassword(mailboxID, "test client", appHash, nil); err != nil {
2026-08-12 21:14:19 +01:00
t.Fatal(err)
}
if _, err := store.StoreMessage(mailboxID, "INBOX", []byte("Subject: normal\r\n\r\nhi"), "<a@example.com>", "a@example.com", "normal"); err != nil {
t.Fatal(err)
}
2026-08-15 16:31:27 +01:00
if _, err := store.StoreMessage(mailboxID, "Junk", []byte("Subject: junk\r\n\r\nspam"), "<b@example.com>", "b@example.com", "junk"); err != nil {
2026-08-12 21:14:19 +01:00
t.Fatal(err)
}
backend := &imapserver.Backend{DB: database, Mailstore: store}
srv := imapserver.NewPlainServer(backend)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go srv.Serve(ln)
t.Cleanup(func() { srv.Close() })
conn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
client := imapclient.New(conn, nil)
t.Cleanup(func() { client.Close() })
if err := client.Login("inbox@example.com", appPassword).Wait(); err != nil {
t.Fatalf("login: %v", err)
}
mailboxes, err := client.List("", "%", nil).Collect()
if err != nil {
t.Fatalf("list: %v", err)
}
2026-08-15 16:31:27 +01:00
// LIST must report every standard folder (INBOX, Junk, Sent, Drafts, Trash) even
// though only INBOX and Junk actually hold a message here — a desktop client that
// only sees folders with existing mail never learns Trash/Drafts/Sent exist. See
// db.AllFoldersForMailbox / imapserver.Session.List.
byName := map[string]*imap.ListData{}
2026-08-12 21:14:19 +01:00
for _, m := range mailboxes {
2026-08-15 16:31:27 +01:00
byName[m.Mailbox] = m
2026-08-12 21:14:19 +01:00
}
2026-08-15 16:31:27 +01:00
wantFolders := []string{"INBOX", "Junk", "Sent", "Drafts", "Trash"}
if len(mailboxes) != len(wantFolders) {
names := make([]string, 0, len(mailboxes))
for _, m := range mailboxes {
names = append(names, m.Mailbox)
}
t.Fatalf("expected folders %v, got %v", wantFolders, names)
}
for _, name := range wantFolders {
if _, ok := byName[name]; !ok {
t.Errorf("LIST is missing folder %q", name)
}
}
// Trash/Junk/Sent/Drafts should each carry their RFC 6154 SPECIAL-USE attribute
// so a desktop client (Thunderbird, Apple Mail, etc.) recognizes them regardless
// of the exact folder name.
wantAttrs := map[string]imap.MailboxAttr{
"Trash": imap.MailboxAttrTrash,
"Junk": imap.MailboxAttrJunk,
"Sent": imap.MailboxAttrSent,
"Drafts": imap.MailboxAttrDrafts,
}
for name, attr := range wantAttrs {
found := false
for _, a := range byName[name].Attrs {
if a == attr {
found = true
}
}
if !found {
t.Errorf("folder %q missing SPECIAL-USE attr %q, got %v", name, attr, byName[name].Attrs)
}
2026-08-12 21:14:19 +01:00
}
inboxData, err := client.Select("INBOX", nil).Wait()
if err != nil {
t.Fatalf("select INBOX: %v", err)
}
if inboxData.NumMessages != 1 {
t.Fatalf("INBOX NumMessages = %d, want 1", inboxData.NumMessages)
}
2026-08-15 16:31:27 +01:00
spamData, err := client.Select("Junk", nil).Wait()
2026-08-12 21:14:19 +01:00
if err != nil {
2026-08-15 16:31:27 +01:00
t.Fatalf("select Junk: %v", err)
2026-08-12 21:14:19 +01:00
}
if spamData.NumMessages != 1 {
2026-08-15 16:31:27 +01:00
t.Fatalf("Junk NumMessages = %d, want 1", spamData.NumMessages)
2026-08-12 21:14:19 +01:00
}
msgs, err := client.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{Envelope: true}).Collect()
if err != nil {
2026-08-15 16:31:27 +01:00
t.Fatalf("fetch in Junk: %v", err)
2026-08-12 21:14:19 +01:00
}
if len(msgs) != 1 || msgs[0].Envelope.Subject != "junk" {
2026-08-15 16:31:27 +01:00
t.Fatalf("expected the Junk-folder message (subject %q), got %+v", "junk", msgs)
2026-08-12 21:14:19 +01:00
}
}
func TestIMAPStoreSeenFlag(t *testing.T) {
client, email, appPassword, _ := newTestMailboxWithAppPassword(t)
if err := client.Login(email, appPassword).Wait(); err != nil {
t.Fatalf("login: %v", err)
}
if _, err := client.Select("INBOX", nil).Wait(); err != nil {
t.Fatalf("select: %v", err)
}
storeFlags := &imap.StoreFlags{Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagSeen}}
msgs, err := client.Store(imap.SeqSetNum(1), storeFlags, nil).Collect()
if err != nil {
t.Fatalf("store: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("got %d fetch responses from STORE, want 1", len(msgs))
}
found := false
for _, f := range msgs[0].Flags {
if f == imap.FlagSeen {
found = true
}
}
if !found {
t.Fatalf("expected \\Seen in returned flags, got %v", msgs[0].Flags)
}
}