package webui import ( "bytes" "io" "net/http" "net/http/httptest" "net/mail" "net/url" "strconv" "strings" "testing" "mailgoserver/internal/pgp" ) // parseRawAsPGPEntity is a test-only helper that reads a stored raw RFC822 // message's Content-Type header and body into a pgp.Entity — mirrors // parseRawAsEntity (webmail_smime_compose_test.go) for the PGP side. func parseRawAsPGPEntity(t *testing.T, raw []byte) pgp.Entity { t.Helper() msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { t.Fatalf("parse raw message: %v", err) } body, err := io.ReadAll(msg.Body) if err != nil { t.Fatalf("read body: %v", err) } entity := pgp.Entity{Headers: []string{"Content-Type: " + msg.Header.Get("Content-Type")}, Body: body} if cte := msg.Header.Get("Content-Transfer-Encoding"); cte != "" { entity.Headers = append(entity.Headers, "Content-Transfer-Encoding: "+cte) } return entity } // genPGPIdentity generates and stores a passphrase-protected PGP identity for a // mailbox directly (bypassing HTTP — the identity-management HTTP flow itself is // covered in webmail_pgp_test.go), returning its ID. func genPGPIdentity(t *testing.T, app *App, mailboxID int64, email, passphrase string) int64 { t.Helper() pubArmor, privArmor, err := pgp.GenerateKeyPair(email, passphrase) if err != nil { t.Fatal(err) } if err := app.storePGPIdentity(mailboxID, "", email, pubArmor, privArmor); err != nil { t.Fatal(err) } identities, err := app.DB.ListPGPIdentities(mailboxID) if err != nil || len(identities) == 0 { t.Fatalf("expected the identity to be stored, err=%v", err) } return identities[0].ID // most recently created } // TestWebmailComposePGPEncryptSend confirms checking "Encrypt" alone — no PGP key // unlocked, no passphrase submitted — still works, since encrypting only ever needs // public keys. The message only the recipient (or the sender's own Sent copy) can // decrypt, and plaintext never appears on the wire. func TestWebmailComposePGPEncryptSend(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() domainID := domains[0].ID senderID := createTestMailboxWithPassword(t, app, "penc-sender@example.com", domainID, "sender-password-1!") recipientID := createTestMailboxWithPassword(t, app, "penc-recip@example.com", domainID, "recip-password-1!") senderIdentityID := genPGPIdentity(t, app, senderID, "penc-sender@example.com", "sender passphrase") recipIdentityID := genPGPIdentity(t, app, recipientID, "penc-recip@example.com", "recipient passphrase") senderIdentity, err := app.DB.GetPGPIdentity(senderID, senderIdentityID) if err != nil || senderIdentity == nil { t.Fatal(err) } recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID) if err != nil || recipIdentity == nil { t.Fatal(err) } if err := app.DB.UpsertPGPContact(senderID, "penc-recip@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil { t.Fatal(err) } recipContact, err := app.DB.GetPGPContact(senderID, "penc-recip@example.com") if err != nil || recipContact == nil { t.Fatal(err) } cookie := webmailLoginSession(t, app, senderID) form := url.Values{ "to": {"penc-recip@example.com"}, "subject": {"Secret"}, "body_html": {"the launch code is 1234"}, "pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(recipContact.ID, 10)}, } req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String()) } msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX") if err != nil || len(msgs) != 1 { t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err) } raw, err := app.Mailstore.FetchMessage(recipientID, msgs[0].ID) if err != nil { t.Fatal(err) } if bytes.Contains(raw, []byte("launch code")) { t.Fatal("plaintext leaked into the stored encrypted message") } entity := parseRawAsPGPEntity(t, raw) recipParsedIdentity, err := pgp.ParsePrivateKey([]byte(recipIdentity.PrivateKeyArmor)) if err != nil { t.Fatal(err) } if err := pgp.UnlockPrivateKey(recipParsedIdentity, "recipient passphrase"); err != nil { t.Fatal(err) } decrypted, err := pgp.DecryptEntity(entity, recipParsedIdentity) if err != nil { t.Fatalf("recipient DecryptEntity: %v", err) } if !bytes.Contains(decrypted.Body, []byte("launch code")) { t.Fatalf("expected plaintext after decrypt, got %q", decrypted.Body) } // Sender's own Sent copy must also decrypt, with the sender's own key — proving // encrypt-only (no passphrase involved at compose time) still included the // sender's own public key as a recipient. sentMsgs, err := app.DB.ListMessagesInFolder(senderID, "Sent") if err != nil || len(sentMsgs) != 1 { t.Fatalf("expected 1 sent message, got %d (err=%v)", len(sentMsgs), err) } sentRaw, err := app.Mailstore.FetchMessage(senderID, sentMsgs[0].ID) if err != nil { t.Fatal(err) } senderParsedIdentity, err := pgp.ParsePrivateKey([]byte(senderIdentity.PrivateKeyArmor)) if err != nil { t.Fatal(err) } if err := pgp.UnlockPrivateKey(senderParsedIdentity, "sender passphrase"); err != nil { t.Fatal(err) } sentEntity := parseRawAsPGPEntity(t, sentRaw) sentDecrypted, err := pgp.DecryptEntity(sentEntity, senderParsedIdentity) if err != nil { t.Fatalf("sender DecryptEntity of own Sent copy: %v", err) } if !bytes.Contains(sentDecrypted.Body, []byte("launch code")) { t.Fatal("sender's own Sent copy did not decrypt to the original body") } } // TestWebmailComposePGPEncryptDoesNotLogPlaintext confirms the admin-visible email // log doesn't capture the plaintext body of a PGP-encrypted send. func TestWebmailComposePGPEncryptDoesNotLogPlaintext(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() domainID := domains[0].ID senderID := createTestMailboxWithPassword(t, app, "penc-sender3@example.com", domainID, "sender-password-1!") recipientID := createTestMailboxWithPassword(t, app, "penc-recip3@example.com", domainID, "recip-password-1!") genPGPIdentity(t, app, senderID, "penc-sender3@example.com", "sender passphrase") recipIdentityID := genPGPIdentity(t, app, recipientID, "penc-recip3@example.com", "recipient passphrase") recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID) if err != nil || recipIdentity == nil { t.Fatal(err) } if err := app.DB.UpsertPGPContact(senderID, "penc-recip3@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil { t.Fatal(err) } recipContact, err := app.DB.GetPGPContact(senderID, "penc-recip3@example.com") if err != nil || recipContact == nil { t.Fatal(err) } cookie := webmailLoginSession(t, app, senderID) const secretPhrase = "nuclear launch codes are 00000000" form := url.Values{ "to": {"penc-recip3@example.com"}, "subject": {"Top secret"}, "body_html": {secretPhrase}, "pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(recipContact.ID, 10)}, } req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String()) } logs, err := app.DB.ListEmailLogsPage(0, 10) if err != nil { t.Fatal(err) } found := false for _, l := range logs { if l.Subject != "Top secret" { continue } found = true if strings.Contains(l.MessageBody, secretPhrase) { t.Fatalf("plaintext leaked into the admin email log: %q", l.MessageBody) } } if !found { t.Fatal("expected the send to appear in the email log (just without the plaintext body)") } } // TestWebmailComposePGPEncryptNoRecipientPickedFails confirms encryption is refused // (not silently skipped) when "Encrypt" is checked but no recipient key was picked // from the dropdown — the picker replaced address-based auto-matching, so there's no // implicit recipient to fall back to. func TestWebmailComposePGPEncryptNoRecipientPickedFails(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() domainID := domains[0].ID senderID := createTestMailboxWithPassword(t, app, "penc-sender2@example.com", domainID, "sender-password-1!") recipientID := createTestMailboxWithPassword(t, app, "penc-recip2@example.com", domainID, "recip-password-1!") genPGPIdentity(t, app, senderID, "penc-sender2@example.com", "sender passphrase") // No contact key added, and no pgp_recipient_id submitted either. cookie := webmailLoginSession(t, app, senderID) form := url.Values{ "to": {"penc-recip2@example.com"}, "subject": {"x"}, "body_html": {"x"}, "pgp_encrypt": {"1"}, } req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX") if err != nil || len(msgs) != 0 { t.Fatalf("expected no message delivered, got %d (err=%v)", len(msgs), err) } } // TestWebmailComposePGPEncryptPickedContactFromAnotherMailboxFails confirms a // mailbox can't encrypt to a contact ID it doesn't own — GetPGPContactByID is scoped // per mailbox the same way every other identity/contact lookup is. func TestWebmailComposePGPEncryptPickedContactFromAnotherMailboxFails(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() domainID := domains[0].ID senderID := createTestMailboxWithPassword(t, app, "penc-sender5@example.com", domainID, "sender-password-1!") otherMailboxID := createTestMailboxWithPassword(t, app, "penc-other5@example.com", domainID, "other-password-1!") recipientID := createTestMailboxWithPassword(t, app, "penc-recip5@example.com", domainID, "recip-password-1!") genPGPIdentity(t, app, senderID, "penc-sender5@example.com", "sender passphrase") recipIdentityID := genPGPIdentity(t, app, recipientID, "penc-recip5@example.com", "recipient passphrase") recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID) if err != nil || recipIdentity == nil { t.Fatal(err) } // The contact is filed under a different mailbox than the sender. if err := app.DB.UpsertPGPContact(otherMailboxID, "penc-recip5@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil { t.Fatal(err) } otherContact, err := app.DB.GetPGPContact(otherMailboxID, "penc-recip5@example.com") if err != nil || otherContact == nil { t.Fatal(err) } cookie := webmailLoginSession(t, app, senderID) form := url.Values{ "to": {"penc-recip5@example.com"}, "subject": {"x"}, "body_html": {"x"}, "pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(otherContact.ID, 10)}, } req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX") if err != nil || len(msgs) != 0 { t.Fatalf("expected no message delivered, got %d (err=%v)", len(msgs), err) } } // TestWebmailComposePGPEncryptWithoutOwnKeyFails confirms encrypting requires the // sender's own PGP key too (so the Sent copy stays readable) — not just the // recipient's. func TestWebmailComposePGPEncryptWithoutOwnKeyFails(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() domainID := domains[0].ID senderID := createTestMailboxWithPassword(t, app, "penc-sender4@example.com", domainID, "sender-password-1!") recipientID := createTestMailboxWithPassword(t, app, "penc-recip4@example.com", domainID, "recip-password-1!") recipIdentityID := genPGPIdentity(t, app, recipientID, "penc-recip4@example.com", "recipient passphrase") recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID) if err != nil || recipIdentity == nil { t.Fatal(err) } if err := app.DB.UpsertPGPContact(senderID, "penc-recip4@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil { t.Fatal(err) } // Sender has no PGP identity of their own. cookie := webmailLoginSession(t, app, senderID) form := url.Values{ "to": {"penc-recip4@example.com"}, "subject": {"x"}, "body_html": {"x"}, "pgp_encrypt": {"1"}, } req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX") if err != nil || len(msgs) != 0 { t.Fatalf("expected no message delivered, got %d (err=%v)", len(msgs), err) } }