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) } if _, err := database.CreateAppPassword(mailboxID, "test client", appHash, nil); err != nil { t.Fatal(err) } raw := []byte("From: Alice \r\nSubject: Hello there\r\n\r\nBody text.") if _, err := store.StoreMessage(mailboxID, "INBOX", raw, "", "Alice ", "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 \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) } if _, err := database.CreateAppPassword(mailboxID, "test client", appHash, nil); err != nil { t.Fatal(err) } if _, err := store.StoreMessage(mailboxID, "INBOX", []byte("Subject: normal\r\n\r\nhi"), "", "a@example.com", "normal"); err != nil { t.Fatal(err) } if _, err := store.StoreMessage(mailboxID, "Spam", []byte("Subject: junk\r\n\r\nspam"), "", "b@example.com", "junk"); 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() }) 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) } var names []string for _, m := range mailboxes { names = append(names, m.Mailbox) } if len(names) != 2 { t.Fatalf("expected 2 folders (INBOX, Spam), got %v", names) } 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) } spamData, err := client.Select("Spam", nil).Wait() if err != nil { t.Fatalf("select Spam: %v", err) } if spamData.NumMessages != 1 { t.Fatalf("Spam NumMessages = %d, want 1", spamData.NumMessages) } msgs, err := client.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{Envelope: true}).Collect() if err != nil { t.Fatalf("fetch in Spam: %v", err) } if len(msgs) != 1 || msgs[0].Envelope.Subject != "junk" { t.Fatalf("expected the Spam-folder message (subject %q), got %+v", "junk", msgs) } } 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) } }