228 lines
7.8 KiB
Go
228 lines
7.8 KiB
Go
|
|
package mailer
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"encoding/base64"
|
|||
|
|
"io"
|
|||
|
|
"log/slog"
|
|||
|
|
"strings"
|
|||
|
|
"testing"
|
|||
|
|
"time"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
func testLogger() *slog.Logger {
|
|||
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// decodePart — вырезает часть multipart по её Content-Type и декодирует
|
|||
|
|
// base64-тело обратно в строку.
|
|||
|
|
func decodePart(t *testing.T, msg, contentType string) string {
|
|||
|
|
t.Helper()
|
|||
|
|
idx := strings.Index(msg, "Content-Type: "+contentType)
|
|||
|
|
if idx < 0 {
|
|||
|
|
t.Fatalf("no part %q in message:\n%s", contentType, msg)
|
|||
|
|
}
|
|||
|
|
rest := msg[idx:]
|
|||
|
|
sep := strings.Index(rest, "\r\n\r\n")
|
|||
|
|
if sep < 0 {
|
|||
|
|
t.Fatalf("no body separator after %q", contentType)
|
|||
|
|
}
|
|||
|
|
body := rest[sep+4:]
|
|||
|
|
if end := strings.Index(body, "\r\n--"+altBoundary); end >= 0 {
|
|||
|
|
body = body[:end]
|
|||
|
|
}
|
|||
|
|
decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(body, "\r\n", ""))
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("part %q decode: %v", contentType, err)
|
|||
|
|
}
|
|||
|
|
return string(decoded)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestBuildMessage — из письма не должен сыпаться UTF-8: тема в
|
|||
|
|
// encoded-word (RFC 2047), обе части тела в base64 (RFC 2045),
|
|||
|
|
// разделители CRLF, multipart/alternative (текст + HTML).
|
|||
|
|
func TestBuildMessage(t *testing.T) {
|
|||
|
|
const text = "Здравствуйте, Иван!\n"
|
|||
|
|
const htmlBody = "<p>Здравствуйте, Иван!</p>\n"
|
|||
|
|
msg := buildMessage("from@example.test", "to@example.test", "Аккаунт подтверждён", text, htmlBody)
|
|||
|
|
|
|||
|
|
if !strings.HasPrefix(msg, "From: from@example.test\r\nTo: to@example.test\r\n") {
|
|||
|
|
t.Errorf("envelope headers:\n%q", msg)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(msg, "\r\nSubject: =?UTF-8?B?") &&
|
|||
|
|
!strings.Contains(msg, "\r\nSubject: =?UTF-8?b?") {
|
|||
|
|
t.Errorf("subject must be encoded-word (RFC 2047): %q", msg)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(msg, "Content-Type: multipart/alternative; boundary=\""+altBoundary+"\"") {
|
|||
|
|
t.Errorf("missing multipart/alternative header: %q", msg)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Обе части на месте и декодируются обратно без потерь.
|
|||
|
|
if got := decodePart(t, msg, "text/plain; charset=UTF-8"); got != text {
|
|||
|
|
t.Errorf("text part = %q, want %q", got, text)
|
|||
|
|
}
|
|||
|
|
if got := decodePart(t, msg, "text/html; charset=UTF-8"); got != htmlBody {
|
|||
|
|
t.Errorf("html part = %q, want %q", got, htmlBody)
|
|||
|
|
}
|
|||
|
|
// Последняя граница закрыта.
|
|||
|
|
if !strings.HasSuffix(msg, "--"+altBoundary+"--\r\n") {
|
|||
|
|
t.Errorf("multipart not closed: %q", msg[len(msg)-80:])
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestConfirmedContent — состав письма: ссылка на вход, роль
|
|||
|
|
// по-человечески, дата подтверждения. Имя/email пользователя —
|
|||
|
|
// данные из аккаунта, в HTML они обязаны быть экранированы.
|
|||
|
|
func TestConfirmedContent(t *testing.T) {
|
|||
|
|
a := AccountConfirmed{
|
|||
|
|
To: "user@example.test",
|
|||
|
|
Name: `Иван <script>alert(1)</script>`,
|
|||
|
|
Role: "worker",
|
|||
|
|
ConfirmedAt: time.Date(2026, 10, 6, 19, 4, 0, 0, time.UTC),
|
|||
|
|
}
|
|||
|
|
const base = "https://example.test/"
|
|||
|
|
|
|||
|
|
text := confirmedText(a, base)
|
|||
|
|
for _, want := range []string{
|
|||
|
|
"Email аккаунта: user@example.test",
|
|||
|
|
"Роль: Мастер",
|
|||
|
|
"Подтверждено: 06.10.2026 19:04",
|
|||
|
|
"Вход: https://example.test/#/login",
|
|||
|
|
} {
|
|||
|
|
if !strings.Contains(text, want) {
|
|||
|
|
t.Errorf("text part misses %q:\n%s", want, text)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
page := confirmedHTML(a, base)
|
|||
|
|
if strings.Contains(page, "<script>") {
|
|||
|
|
t.Errorf("HTML must escape the name:\n%s", page)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(page, "Иван <script>") {
|
|||
|
|
t.Errorf("escaped name not found:\n%s", page)
|
|||
|
|
}
|
|||
|
|
for _, want := range []string{
|
|||
|
|
`href="https://example.test/#/login"`,
|
|||
|
|
"Мастер",
|
|||
|
|
"user@example.test",
|
|||
|
|
} {
|
|||
|
|
if !strings.Contains(page, want) {
|
|||
|
|
t.Errorf("HTML misses %q", want)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Без даты (нулевое время) строка «Подтверждено» опускается.
|
|||
|
|
noDate := confirmedHTML(
|
|||
|
|
AccountConfirmed{To: "a@b.c", Name: "N", Role: "client"},
|
|||
|
|
"http://localhost:3000")
|
|||
|
|
if strings.Contains(noDate, "Подтверждено") {
|
|||
|
|
t.Errorf("zero ConfirmedAt must omit the date row:\n%s", noDate)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestResetContent — состав письма сброса: ссылка с токеном, срок
|
|||
|
|
// действия, email. Имя/адрес — данные пользователя, в HTML обязаны
|
|||
|
|
// быть экранированы.
|
|||
|
|
func TestResetContent(t *testing.T) {
|
|||
|
|
p := PasswordReset{
|
|||
|
|
To: "user@example.test",
|
|||
|
|
Name: `Иван <script>alert(1)</script>`,
|
|||
|
|
Token: "abc123TOKEN",
|
|||
|
|
ExpiresAt: time.Date(2026, 10, 7, 12, 30, 0, 0, time.UTC),
|
|||
|
|
}
|
|||
|
|
const base = "https://example.test/"
|
|||
|
|
|
|||
|
|
text := resetText(p, base)
|
|||
|
|
for _, want := range []string{
|
|||
|
|
"Email аккаунта: user@example.test",
|
|||
|
|
"https://example.test/#/reset-password?token=abc123TOKEN",
|
|||
|
|
"до 07.10.2026 12:30",
|
|||
|
|
} {
|
|||
|
|
if !strings.Contains(text, want) {
|
|||
|
|
t.Errorf("text part misses %q:\n%s", want, text)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
page := resetHTML(p, base)
|
|||
|
|
if strings.Contains(page, "<script>") {
|
|||
|
|
t.Errorf("HTML must escape the name:\n%s", page)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(page, "Иван <script>") {
|
|||
|
|
t.Errorf("escaped name not found:\n%s", page)
|
|||
|
|
}
|
|||
|
|
for _, want := range []string{
|
|||
|
|
`href="https://example.test/#/reset-password?token=abc123TOKEN"`,
|
|||
|
|
"Задать новый пароль",
|
|||
|
|
"user@example.test",
|
|||
|
|
"07.10.2026 12:30",
|
|||
|
|
} {
|
|||
|
|
if !strings.Contains(page, want) {
|
|||
|
|
t.Errorf("HTML misses %q", want)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestWrapBase64 — длина строки 76 символов (RFC 2045), содержимое
|
|||
|
|
// сохраняется при склейке.
|
|||
|
|
func TestWrapBase64(t *testing.T) {
|
|||
|
|
in := strings.Repeat("ы", 300)
|
|||
|
|
out := wrapBase64(in)
|
|||
|
|
|
|||
|
|
for i, line := range strings.Split(strings.TrimRight(out, "\r\n"), "\r\n") {
|
|||
|
|
if len(line) > 76 {
|
|||
|
|
t.Errorf("line %d: %d bytes, want <= 76", i, len(line))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
joined := strings.ReplaceAll(out, "\r\n", "")
|
|||
|
|
decoded, err := base64.StdEncoding.DecodeString(joined)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("decode: %v", err)
|
|||
|
|
}
|
|||
|
|
if string(decoded) != in {
|
|||
|
|
t.Error("content lost after wrap/unwrap")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestNewValidatesConfig — фейл-фаст на старте: пустой хост — заглушка,
|
|||
|
|
// кривые порт/режим/адрес — ошибка, а не сюрприз на первом письме.
|
|||
|
|
func TestNewValidatesConfig(t *testing.T) {
|
|||
|
|
if _, err := New(Config{}, testLogger()); err != nil {
|
|||
|
|
t.Errorf("empty host must be disabled mailer, got %v", err)
|
|||
|
|
}
|
|||
|
|
if _, err := New(Config{Host: "smtp.example", Port: "abc", From: "a@b.c"}, testLogger()); err == nil {
|
|||
|
|
t.Error("invalid SMTP_PORT must fail")
|
|||
|
|
}
|
|||
|
|
if _, err := New(Config{Host: "smtp.example", Port: "587", TLS: "bogus", From: "a@b.c"}, testLogger()); err == nil {
|
|||
|
|
t.Error("invalid SMTP_TLS must fail")
|
|||
|
|
}
|
|||
|
|
if _, err := New(Config{Host: "smtp.example", Port: "587"}, testLogger()); err == nil {
|
|||
|
|
t.Error("empty From and User must fail")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestNewDefaults — дефолты: порт 587, режим по порту (465 → implicit TLS),
|
|||
|
|
// адрес отправителя берётся из пользователя.
|
|||
|
|
func TestNewDefaults(t *testing.T) {
|
|||
|
|
m, err := New(Config{Host: "smtp.example", User: "me@example.test"}, testLogger())
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("new: %v", err)
|
|||
|
|
}
|
|||
|
|
sm, ok := m.(*smtpMailer)
|
|||
|
|
if !ok {
|
|||
|
|
t.Fatalf("type = %T, want *smtpMailer", m)
|
|||
|
|
}
|
|||
|
|
if sm.cfg.Port != "587" || sm.mode != tlsStart {
|
|||
|
|
t.Errorf("defaults: port=%s mode=%s, want 587/starttls", sm.cfg.Port, sm.mode)
|
|||
|
|
}
|
|||
|
|
if sm.cfg.From != "me@example.test" {
|
|||
|
|
t.Errorf("from = %q, want SMTP_USER", sm.cfg.From)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
m, err = New(Config{Host: "smtp.example", Port: "465", From: "a@b.c"}, testLogger())
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("new 465: %v", err)
|
|||
|
|
}
|
|||
|
|
if sm := m.(*smtpMailer); sm.mode != tlsImplicit {
|
|||
|
|
t.Errorf("port 465 mode = %s, want %s", sm.mode, tlsImplicit)
|
|||
|
|
}
|
|||
|
|
}
|