feat: require verification for forwarding targets

This commit is contained in:
zxyszx
2026-08-02 09:08:20 +08:00
parent 7f05a70f60
commit 9f330fdf57
8 changed files with 541 additions and 54 deletions
+123 -5
View File
@@ -21,9 +21,12 @@ import (
"io"
"log/slog"
"math/big"
"mime"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
netmail "net/mail"
"net/textproto"
"net/url"
"os"
@@ -219,6 +222,71 @@ func handleFakeSMTPConn(conn net.Conn, received chan<- string) {
}
}
type testMIMEHeader interface {
Get(string) string
}
func extractForwardingVerificationToken(t *testing.T, raw string) string {
t.Helper()
msg, err := netmail.ReadMessage(strings.NewReader(raw))
if err != nil {
t.Fatalf("read verification message: %v", err)
}
body := extractMIMETextForTest(t, msg.Header, msg.Body)
marker := "/api/verify-email?token="
idx := strings.Index(body, marker)
if idx < 0 {
t.Fatalf("verification link not found in body: %q", body)
}
token := body[idx+len(marker):]
if end := strings.IndexAny(token, "\"'<>\r\n\t "); end >= 0 {
token = token[:end]
}
token, _ = url.QueryUnescape(token)
if token == "" {
t.Fatalf("verification token empty in body: %q", body)
}
return token
}
func extractMIMETextForTest(t *testing.T, header testMIMEHeader, body io.Reader) string {
t.Helper()
contentType := header.Get("Content-Type")
mediaType, params, _ := mime.ParseMediaType(contentType)
if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") {
boundary := params["boundary"]
if boundary == "" {
t.Fatalf("multipart message missing boundary: %s", contentType)
}
mr := multipart.NewReader(body, boundary)
var out strings.Builder
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("read mime part: %v", err)
}
out.WriteString(extractMIMETextForTest(t, part.Header, part))
out.WriteString("\n")
}
return out.String()
}
data, err := io.ReadAll(body)
if err != nil {
t.Fatalf("read mime body: %v", err)
}
if strings.EqualFold(strings.TrimSpace(header.Get("Content-Transfer-Encoding")), "base64") {
decoded, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(string(data)), ""))
if err != nil {
t.Fatalf("decode mime base64: %v", err)
}
data = decoded
}
return string(data)
}
type testClient struct {
t *testing.T
server *httptest.Server
@@ -1645,7 +1713,7 @@ func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) {
func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 2)
host, port, received := startCapturingSMTP(t, 4)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
ts := httptest.NewServer(a.Router())
@@ -1657,15 +1725,67 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
t.Fatalf("login code=%d body=%v", code, login)
}
_, mb := defaultAdminUserAndMailbox(t, a)
ctx := context.Background()
verifyTarget := func(email string) {
t.Helper()
var settings ForwardingSettings
if code := admin.do("POST", "/api/me/forwarding/verified-emails", map[string]string{"email": email}, &settings); code != http.StatusCreated {
t.Fatalf("add forwarding target %s code=%d settings=%+v", email, code, settings)
}
if len(settings.VerifiedEmails) == 0 || settings.VerifiedEmails[0].Verified {
t.Fatalf("target should start pending: %+v", settings.VerifiedEmails)
}
if err := a.processDueSendQueue(ctx); err != nil {
t.Fatal(err)
}
var verificationBody string
select {
case verificationBody = <-received:
case <-time.After(2 * time.Second):
t.Fatal("verification email was not relayed")
}
token := extractForwardingVerificationToken(t, verificationBody)
if code := admin.do("GET", "/api/verify-email?token="+url.QueryEscape(token), nil, nil); code != http.StatusOK {
t.Fatalf("verify email code=%d", code)
}
if code := admin.do("GET", "/api/me/forwarding", nil, &settings); code != http.StatusOK {
t.Fatalf("reload forwarding settings code=%d", code)
}
found := false
for _, item := range settings.VerifiedEmails {
if item.Email == email {
found = item.Verified
}
}
if !found {
t.Fatalf("target %s was not marked verified: %+v", email, settings.VerifiedEmails)
}
}
var settings ForwardingSettings
if code := admin.do("POST", "/api/me/forwarding/verified-emails", map[string]string{"email": "account-forward@example.test"}, &settings); code != http.StatusCreated {
t.Fatalf("add account forwarding target code=%d settings=%+v", code, settings)
}
if code := admin.do("POST", "/api/me/forwarding/account", map[string]string{"targetEmail": "account-forward@example.test"}, &settings); code != http.StatusBadRequest {
t.Fatalf("unverified account forwarding should be rejected, code=%d settings=%+v", code, settings)
}
if err := a.processDueSendQueue(ctx); err != nil {
t.Fatal(err)
}
var verificationBody string
select {
case verificationBody = <-received:
case <-time.After(2 * time.Second):
t.Fatal("account verification email was not relayed")
}
token := extractForwardingVerificationToken(t, verificationBody)
if code := admin.do("GET", "/api/verify-email?token="+url.QueryEscape(token), nil, nil); code != http.StatusOK {
t.Fatalf("verify account target code=%d", code)
}
if code := admin.do("POST", "/api/me/forwarding/account", map[string]string{"targetEmail": "account-forward@example.test"}, &settings); code != http.StatusOK || settings.AccountTargetEmail != "account-forward@example.test" {
t.Fatalf("save account forwarding code=%d settings=%+v", code, settings)
}
ctx := context.Background()
inboxID, err := a.ensureFolder(ctx, mb.ID, "Inbox")
if err != nil {
t.Fatal(err)
@@ -1716,9 +1836,7 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
t.Fatal("account forwarding mail was not relayed")
}
if code := admin.do("POST", "/api/me/forwarding/verified-emails", map[string]string{"email": "mailbox-forward@example.test"}, &settings); code != http.StatusCreated {
t.Fatalf("add mailbox forwarding target code=%d settings=%+v", code, settings)
}
verifyTarget("mailbox-forward@example.test")
if code := admin.do("POST", "/api/me/mailboxes/"+mb.ID+"/forwarding", map[string]string{"targetEmail": "mailbox-forward@example.test"}, &settings); code != http.StatusOK {
t.Fatalf("save mailbox forwarding code=%d settings=%+v", code, settings)
}