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 = "

Здравствуйте, Иван!

\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: `Иван `, 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, "`, 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, "