1
0
forked from wrenn/wrenn
This commit is contained in:
2026-04-16 19:24:25 +00:00
parent 172413e91e
commit 605ad666a0
239 changed files with 19966 additions and 3454 deletions

233
internal/email/email.go Normal file
View File

@ -0,0 +1,233 @@
// Package email provides transactional email sending via SMTP.
//
// Emails are rendered from embedded Go templates (html/template + text/template)
// and sent as multipart/alternative MIME messages. When SMTP is not configured
// (Host is empty), a no-op mailer is returned that logs and discards.
package email
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"log/slog"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/smtp"
"net/textproto"
"net/url"
"strconv"
"strings"
)
// Config holds SMTP connection credentials. All fields except Host are
// optional — omitting Host disables email entirely (no-op mailer).
type Config struct {
Host string // SMTP server hostname
Port int // SMTP server port (default 587)
Username string // SMTP auth username
Password string // SMTP auth password
FromEmail string // envelope sender address
}
// Mailer sends transactional emails.
type Mailer interface {
Send(ctx context.Context, to string, subject string, data EmailData) error
}
// EmailData is the generic payload for all transactional emails.
// Templates conditionally render each field based on presence.
type EmailData struct {
RecipientName string // optional — used after "Hello"
Message string // main body (plain text; HTML template wraps it)
Button *Button // optional CTA button
Closing string // optional closing/footer message
}
// Button represents a call-to-action link rendered as a button in HTML
// and as a plain URL in the text variant.
type Button struct {
Text string // button label
URL string // target URL
}
// New constructs a Mailer. If cfg.Host is empty, returns a no-op mailer
// that logs at debug level and discards. Panics if templates fail to parse
// (indicates a build-time bug in embedded templates).
func New(cfg Config) Mailer {
if cfg.Host == "" {
slog.Info("email: SMTP not configured, using no-op mailer")
return &noopMailer{}
}
if cfg.Port == 0 {
cfg.Port = 587
}
tmpl := mustLoadTemplates()
slog.Info("email: SMTP configured", "host", cfg.Host, "port", cfg.Port, "from", cfg.FromEmail)
return &mailer{cfg: cfg, tmpl: tmpl}
}
// mailer is the live SMTP implementation.
type mailer struct {
cfg Config
tmpl *templates
}
func (m *mailer) Send(ctx context.Context, to string, subject string, data EmailData) error {
if data.Button != nil {
u, err := url.Parse(data.Button.URL)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
return fmt.Errorf("invalid button URL scheme: %s", data.Button.URL)
}
}
htmlBody, err := m.tmpl.renderHTML(data)
if err != nil {
return fmt.Errorf("render html: %w", err)
}
textBody, err := m.tmpl.renderText(data)
if err != nil {
return fmt.Errorf("render text: %w", err)
}
msg, err := buildMIME(m.cfg.FromEmail, to, subject, htmlBody, textBody)
if err != nil {
return fmt.Errorf("build mime: %w", err)
}
if err := m.send(to, msg); err != nil {
return fmt.Errorf("send email to %s: %w", to, err)
}
slog.Info("email: sent", "to", to, "subject", subject)
return nil
}
// send dials the SMTP server and delivers the message.
// Port 465 uses implicit TLS; all other ports use STARTTLS.
func (m *mailer) send(to string, msg []byte) error {
addr := net.JoinHostPort(m.cfg.Host, strconv.Itoa(m.cfg.Port))
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
if m.cfg.Port == 465 {
return m.sendImplicitTLS(addr, auth, to, msg)
}
// STARTTLS (port 587 or other).
return smtp.SendMail(addr, auth, m.cfg.FromEmail, []string{to}, msg)
}
// sendImplicitTLS handles port 465 (SMTPS) where the entire connection is TLS.
func (m *mailer) sendImplicitTLS(addr string, auth smtp.Auth, to string, msg []byte) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: m.cfg.Host})
if err != nil {
return fmt.Errorf("tls dial: %w", err)
}
defer conn.Close()
c, err := smtp.NewClient(conn, m.cfg.Host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
if err := c.Mail(m.cfg.FromEmail); err != nil {
return fmt.Errorf("smtp mail: %w", err)
}
if err := c.Rcpt(to); err != nil {
return fmt.Errorf("smtp rcpt: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w", err)
}
return c.Quit()
}
// buildMIME assembles a multipart/alternative message with text and HTML parts.
// Both parts are quoted-printable encoded per RFC 2045.
func buildMIME(from, to, subject, htmlBody, textBody string) ([]byte, error) {
var headerBuf bytes.Buffer
var bodyBuf bytes.Buffer
// Sanitize header values to prevent header injection.
from = sanitizeHeader(from)
to = sanitizeHeader(to)
// Encode "From" with display name.
encodedFrom := mime.QEncoding.Encode("utf-8", "Wrenn") + " <" + from + ">"
// Build multipart body first to get the boundary.
mw := multipart.NewWriter(&bodyBuf)
// Text part (first = lowest preference per RFC 2046).
textPart, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {"text/plain; charset=utf-8"},
"Content-Transfer-Encoding": {"quoted-printable"},
})
if err != nil {
return nil, err
}
qpw := quotedprintable.NewWriter(textPart)
if _, err := qpw.Write([]byte(textBody)); err != nil {
return nil, err
}
if err := qpw.Close(); err != nil {
return nil, err
}
// HTML part (second = highest preference).
htmlPart, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {"text/html; charset=utf-8"},
"Content-Transfer-Encoding": {"quoted-printable"},
})
if err != nil {
return nil, err
}
qpw = quotedprintable.NewWriter(htmlPart)
if _, err := qpw.Write([]byte(htmlBody)); err != nil {
return nil, err
}
if err := qpw.Close(); err != nil {
return nil, err
}
if err := mw.Close(); err != nil {
return nil, err
}
// Write headers.
fmt.Fprintf(&headerBuf, "From: %s\r\n", encodedFrom)
fmt.Fprintf(&headerBuf, "To: %s\r\n", to)
fmt.Fprintf(&headerBuf, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
fmt.Fprintf(&headerBuf, "MIME-Version: 1.0\r\n")
fmt.Fprintf(&headerBuf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n", mw.Boundary())
fmt.Fprintf(&headerBuf, "\r\n")
headerBuf.Write(bodyBuf.Bytes())
return headerBuf.Bytes(), nil
}
// sanitizeHeader strips CR and LF characters to prevent SMTP header injection.
func sanitizeHeader(s string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
}
// noopMailer discards emails when SMTP is not configured.
type noopMailer struct{}
func (n *noopMailer) Send(_ context.Context, to string, subject string, _ EmailData) error {
slog.Debug("email: no-op send", "to", to, "subject", subject)
return nil
}

View File

@ -0,0 +1,191 @@
package email
import (
"context"
"strings"
"testing"
)
func TestNoopMailerDoesNotError(t *testing.T) {
m := &noopMailer{}
err := m.Send(context.Background(), "test@example.com", "Test Subject", EmailData{
RecipientName: "Alice",
Message: "Hello world",
})
if err != nil {
t.Fatalf("noopMailer.Send() returned error: %v", err)
}
}
func TestNewReturnsNoopWhenHostEmpty(t *testing.T) {
m := New(Config{})
if _, ok := m.(*noopMailer); !ok {
t.Fatalf("expected noopMailer, got %T", m)
}
}
func TestNewReturnsMailerWhenHostSet(t *testing.T) {
m := New(Config{Host: "smtp.example.com"})
if _, ok := m.(*mailer); !ok {
t.Fatalf("expected *mailer, got %T", m)
}
}
func TestTemplateRenderHTML(t *testing.T) {
tmpl := mustLoadTemplates()
tests := []struct {
name string
data EmailData
want []string // substrings that must appear in output
}{
{
name: "with all fields",
data: EmailData{
RecipientName: "Alice",
Message: "Welcome to Wrenn!",
Button: &Button{Text: "Get Started", URL: "https://wrenn.dev"},
Closing: "See you soon.",
},
want: []string{"Alice", "Welcome to Wrenn!", "Get Started", "https://wrenn.dev", "See you soon."},
},
{
name: "message only",
data: EmailData{
Message: "Your password has been changed.",
},
want: []string{"Your password has been changed."},
},
{
name: "with button no closing",
data: EmailData{
RecipientName: "Bob",
Message: "Reset your password.",
Button: &Button{Text: "Reset Password", URL: "https://wrenn.dev/reset?token=abc"},
},
want: []string{"Bob", "Reset your password.", "Reset Password", "https://wrenn.dev/reset?token=abc"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
html, err := tmpl.renderHTML(tt.data)
if err != nil {
t.Fatalf("renderHTML() error: %v", err)
}
for _, s := range tt.want {
if !strings.Contains(html, s) {
t.Errorf("renderHTML() missing substring %q", s)
}
}
// Verify basic HTML structure.
if !strings.Contains(html, "<!DOCTYPE html>") {
t.Error("renderHTML() missing DOCTYPE")
}
if !strings.Contains(html, "wrenn.dev") {
t.Error("renderHTML() missing wrenn.dev reference")
}
})
}
}
func TestTemplateRenderText(t *testing.T) {
tmpl := mustLoadTemplates()
tests := []struct {
name string
data EmailData
want []string
}{
{
name: "with all fields",
data: EmailData{
RecipientName: "Alice",
Message: "Welcome to Wrenn!",
Button: &Button{Text: "Get Started", URL: "https://wrenn.dev"},
Closing: "See you soon.",
},
want: []string{"Hello Alice", "Welcome to Wrenn!", "Get Started: https://wrenn.dev", "See you soon."},
},
{
name: "message only",
data: EmailData{
Message: "Done.",
},
want: []string{"Hello,", "Done."},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
text, err := tmpl.renderText(tt.data)
if err != nil {
t.Fatalf("renderText() error: %v", err)
}
for _, s := range tt.want {
if !strings.Contains(text, s) {
t.Errorf("renderText() missing substring %q\nGot:\n%s", s, text)
}
}
})
}
}
func TestBuildMIME(t *testing.T) {
msg, err := buildMIME("noreply@wrenn.dev", "user@example.com", "Test Subject", "<h1>HTML</h1>", "Plain text")
if err != nil {
t.Fatalf("buildMIME() error: %v", err)
}
s := string(msg)
if !strings.Contains(s, "From:") {
t.Error("missing From header")
}
if !strings.Contains(s, "To: user@example.com") {
t.Error("missing To header")
}
if !strings.Contains(s, "Wrenn") {
t.Error("missing Wrenn sender name")
}
if !strings.Contains(s, "multipart/alternative") {
t.Error("missing multipart/alternative content type")
}
if !strings.Contains(s, "text/plain") {
t.Error("missing text/plain part")
}
if !strings.Contains(s, "text/html") {
t.Error("missing text/html part")
}
}
func TestBuildMIMENonASCII(t *testing.T) {
msg, err := buildMIME("noreply@wrenn.dev", "user@example.com", "Test", "<p>\u00c5ngstr\u00f6m</p>", "Hello \u00c5ngstr\u00f6m")
if err != nil {
t.Fatalf("buildMIME() error: %v", err)
}
s := string(msg)
// Non-ASCII characters should be QP-encoded, not appear as raw bytes.
// \u00c5 (U+00C5, 0xC3 0x85 in UTF-8) should be encoded as =C3=85.
if !strings.Contains(s, "=C3=85") {
t.Error("non-ASCII character not quoted-printable encoded")
}
}
func TestSanitizeHeader(t *testing.T) {
tests := []struct {
input string
want string
}{
{"normal@example.com", "normal@example.com"},
{"injected\r\nBcc: evil@example.com", "injectedBcc: evil@example.com"},
{"has\nnewline", "hasnewline"},
{"has\rcarriage", "hascarriage"},
}
for _, tt := range tests {
got := sanitizeHeader(tt.input)
if got != tt.want {
t.Errorf("sanitizeHeader(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

View File

@ -0,0 +1,52 @@
package email
import (
"bytes"
"embed"
"fmt"
"html/template"
text_template "text/template"
)
//go:embed templates/*.html templates/*.txt
var templateFS embed.FS
// templates holds the parsed HTML and plain-text template sets.
type templates struct {
html *template.Template
text *text_template.Template
}
// mustLoadTemplates parses all embedded templates. Panics on error
// because malformed templates are a build-time bug.
func mustLoadTemplates() *templates {
html, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
panic(fmt.Sprintf("email: failed to parse HTML templates: %v", err))
}
text, err := text_template.ParseFS(templateFS, "templates/*.txt")
if err != nil {
panic(fmt.Sprintf("email: failed to parse text templates: %v", err))
}
return &templates{html: html, text: text}
}
// renderHTML executes the HTML base template with the given data.
func (t *templates) renderHTML(data EmailData) (string, error) {
var buf bytes.Buffer
if err := t.html.ExecuteTemplate(&buf, "base.html", data); err != nil {
return "", fmt.Errorf("execute html template: %w", err)
}
return buf.String(), nil
}
// renderText executes the plain-text base template with the given data.
func (t *templates) renderText(data EmailData) (string, error) {
var buf bytes.Buffer
if err := t.text.ExecuteTemplate(&buf, "base.txt", data); err != nil {
return "", fmt.Errorf("execute text template: %w", err)
}
return buf.String(), nil
}

View File

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Wrenn</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style type="text/css">
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
body { margin: 0; padding: 0; width: 100% !important; }
</style>
</head>
<body style="margin: 0; padding: 0; background-color: #f4f3f1; font-family: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased;">
<!-- Outer wrapper -->
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f4f3f1;">
<tr>
<td align="center" style="padding: 40px 16px;">
<!-- Logo + Wordmark -->
<table role="presentation" cellpadding="0" cellspacing="0" width="560" style="max-width: 560px;">
<tr>
<td align="left" style="padding-bottom: 32px;">
<a href="https://wrenn.dev" style="text-decoration: none;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="vertical-align: middle;">
<img src="https://wrenn.dev/logo.png" alt="Wrenn" width="36" height="36" style="display: block; border-radius: 6px;">
</td>
<td style="vertical-align: middle; padding-left: 10px;">
<span style="font-family: 'Alice', Georgia, 'Times New Roman', serif; font-size: 22px; color: #1a1917; letter-spacing: 0.01em;">Wrenn</span>
</td>
</tr>
</table>
</a>
</td>
</tr>
</table>
<!-- Card -->
<table role="presentation" cellpadding="0" cellspacing="0" width="560" style="max-width: 560px; background-color: #ffffff; border: 1px solid #e5e4e0; border-radius: 8px;">
<tr>
<td style="padding: 44px 48px;">
<!-- Greeting -->
<p style="margin: 0 0 8px 0; font-size: 15px; line-height: 1.6; color: #3a3835;">
Hello{{if .RecipientName}} {{.RecipientName}}{{end}},
</p>
<!-- Message -->
<p style="margin: 0 0 36px 0; font-size: 15px; line-height: 1.7; color: #3a3835;">
{{.Message}}
</p>
<!-- Button -->
{{if .Button}}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 36px 0;">
<tr>
<td align="center" style="background-color: #5e8c58; border-radius: 5px;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{.Button.URL}}" style="height:44px;v-text-anchor:middle;width:200px;" arcsize="12%" strokecolor="#5e8c58" fillcolor="#5e8c58">
<w:anchorlock/>
<center style="color:#ffffff;font-family:'Manrope',-apple-system,sans-serif;font-size:14px;font-weight:600;">{{.Button.Text}}</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="{{.Button.URL}}" target="_blank" style="display: inline-block; padding: 12px 32px; font-size: 14px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 5px; background-color: #5e8c58;">
{{.Button.Text}}
</a>
<!--<![endif]-->
</td>
</tr>
</table>
<p style="margin: 0 0 12px 0; font-size: 12px; line-height: 1.5; color: #b5b0a8;">
If the button doesn't work, copy and paste this URL into your browser:<br>
<a href="{{.Button.URL}}" style="color: #5e8c58; word-break: break-all;">{{.Button.URL}}</a>
</p>
{{end}}
<!-- Closing -->
{{if .Closing}}
<p style="margin: {{if .Button}}20px{{else}}0{{end}} 0 0 0; font-size: 15px; line-height: 1.7; color: #3a3835;">
{{.Closing}}
</p>
{{end}}
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellpadding="0" cellspacing="0" width="560" style="max-width: 560px;">
<tr>
<td style="padding: 32px 0 16px 0; text-align: center;">
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #9b9790;">
This is a transactional email from <a href="https://wrenn.dev" style="color: #5e8c58; text-decoration: none;">Wrenn</a>.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,13 @@
Hello{{if .RecipientName}} {{.RecipientName}}{{end}},
{{.Message}}
{{if .Button}}
{{.Button.Text}}: {{.Button.URL}}
{{end}}{{if .Closing}}
{{.Closing}}
{{end}}
---
This is a transactional email from Wrenn (https://wrenn.dev).