93 lines
2.6 KiB
Go
93 lines
2.6 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: MIT
|
||
|
|
|
||
|
|
//go:build linux || freebsd
|
||
|
|
|
||
|
|
package email
|
||
|
|
|
||
|
|
import (
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||
|
|
)
|
||
|
|
|
||
|
|
func ackForm() *config.Form {
|
||
|
|
return &config.Form{
|
||
|
|
Name: "contact",
|
||
|
|
Type: "contact",
|
||
|
|
To: "owner@example.com",
|
||
|
|
From: "noreply@example.com",
|
||
|
|
SubjectPrefix: new("nuntius"),
|
||
|
|
EmailBrand: new("nuntius"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestComposeAcknowledgement(t *testing.T) {
|
||
|
|
msg, err := composeAcknowledgement(ackForm(), "jane@example.com")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("compose: %v", err)
|
||
|
|
}
|
||
|
|
raw := string(msg)
|
||
|
|
|
||
|
|
for _, want := range []string{
|
||
|
|
"From: noreply@example.com\r\n",
|
||
|
|
"To: jane@example.com\r\n",
|
||
|
|
"Subject: [nuntius/contact] Message received\r\n",
|
||
|
|
// The reply lands in the owner's inbox, not in the void.
|
||
|
|
"Reply-To: owner@example.com\r\n",
|
||
|
|
"Content-Type: multipart/alternative; boundary=nuntius-",
|
||
|
|
"your message to contact was received",
|
||
|
|
"Delivered by nuntius",
|
||
|
|
} {
|
||
|
|
if !strings.Contains(raw, want) {
|
||
|
|
t.Errorf("message missing %q", want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// The submitted values are deliberately never echoed back.
|
||
|
|
if strings.Contains(raw, "jane@example.com\r\n\r\n") && strings.Count(raw, "jane@example.com") != 1 {
|
||
|
|
t.Errorf("message echoes the submitter context beyond the To header")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestComposeAcknowledgementHeaderInjection(t *testing.T) {
|
||
|
|
msg, err := composeAcknowledgement(ackForm(), "jane@example.com\r\nBcc: victim@example.com")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("compose: %v", err)
|
||
|
|
}
|
||
|
|
raw := string(msg)
|
||
|
|
// The whole string collapses into one To header line: no line starts
|
||
|
|
// with the injected header name.
|
||
|
|
for line := range strings.SplitSeq(raw, "\r\n") {
|
||
|
|
if strings.HasPrefix(line, "Bcc:") {
|
||
|
|
t.Errorf("a CR/LF in the recipient injected a header line %q", line)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !strings.Contains(raw, "To: jane@example.com Bcc: victim@example.com\r\n") {
|
||
|
|
t.Errorf("the CR/LF was not neutralised into spaces")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestComposeAcknowledgementSubjectWithoutPrefix(t *testing.T) {
|
||
|
|
form := ackForm()
|
||
|
|
form.SubjectPrefix = new("")
|
||
|
|
msg, err := composeAcknowledgement(form, "jane@example.com")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("compose: %v", err)
|
||
|
|
}
|
||
|
|
if !strings.Contains(string(msg), "Subject: Message received\r\n") {
|
||
|
|
t.Errorf("subject with an empty prefix = %q, want the bare subject", subjectOf(t, string(msg)))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func subjectOf(t *testing.T, raw string) string {
|
||
|
|
t.Helper()
|
||
|
|
for line := range strings.SplitSeq(raw, "\r\n") {
|
||
|
|
if after, ok := strings.CutPrefix(line, "Subject: "); ok {
|
||
|
|
return after
|
||
|
|
}
|
||
|
|
}
|
||
|
|
t.Fatalf("no subject line in message")
|
||
|
|
return ""
|
||
|
|
}
|