forked from wrenn/wrenn
Add transactional email system via SMTP
Introduce internal/email package with SMTP sending, embedded HTML/text templates, and multipart MIME assembly. Emails use a generic EmailData struct (recipient name, message, optional button, optional closing) so new email types can be added without code changes. Wired into signup (welcome email), team creation, and team member addition. No-op mailer when SMTP_HOST is not configured.
This commit is contained in:
@ -35,3 +35,10 @@ OAUTH_GITHUB_CLIENT_ID=
|
||||
OAUTH_GITHUB_CLIENT_SECRET=
|
||||
OAUTH_REDIRECT_URL=https://app.wrenn.dev
|
||||
CP_PUBLIC_URL=https://app.wrenn.dev
|
||||
|
||||
# SMTP — transactional email (optional; omit SMTP_HOST to disable)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=noreply@wrenn.dev
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/email"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
@ -60,10 +61,11 @@ type authHandler struct {
|
||||
db *db.Queries
|
||||
pool *pgxpool.Pool
|
||||
jwtSecret []byte
|
||||
mailer email.Mailer
|
||||
}
|
||||
|
||||
func newAuthHandler(db *db.Queries, pool *pgxpool.Pool, jwtSecret []byte) *authHandler {
|
||||
return &authHandler{db: db, pool: pool, jwtSecret: jwtSecret}
|
||||
func newAuthHandler(db *db.Queries, pool *pgxpool.Pool, jwtSecret []byte, mailer email.Mailer) *authHandler {
|
||||
return &authHandler{db: db, pool: pool, jwtSecret: jwtSecret, mailer: mailer}
|
||||
}
|
||||
|
||||
type signupRequest struct {
|
||||
@ -190,6 +192,16 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := h.mailer.Send(context.Background(), req.Email, "Welcome to Wrenn", email.EmailData{
|
||||
RecipientName: req.Name,
|
||||
Message: "Welcome to Wrenn! Your account has been created and you're ready to start building with secure, isolated sandboxes.",
|
||||
Closing: "If you have any questions, feel free to reach out. We're glad to have you.",
|
||||
}); err != nil {
|
||||
slog.Warn("failed to send welcome email", "email", req.Email, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusCreated, authResponse{
|
||||
Token: token,
|
||||
UserID: id.FormatUserID(userID),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@ -10,6 +11,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/email"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
@ -18,12 +20,13 @@ import (
|
||||
)
|
||||
|
||||
type teamHandler struct {
|
||||
svc *service.TeamService
|
||||
audit *audit.AuditLogger
|
||||
svc *service.TeamService
|
||||
audit *audit.AuditLogger
|
||||
mailer email.Mailer
|
||||
}
|
||||
|
||||
func newTeamHandler(svc *service.TeamService, al *audit.AuditLogger) *teamHandler {
|
||||
return &teamHandler{svc: svc, audit: al}
|
||||
func newTeamHandler(svc *service.TeamService, al *audit.AuditLogger, mailer email.Mailer) *teamHandler {
|
||||
return &teamHandler{svc: svc, audit: al, mailer: mailer}
|
||||
}
|
||||
|
||||
// teamResponse is the JSON shape for a team.
|
||||
@ -132,6 +135,15 @@ func (h *teamHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := h.mailer.Send(context.Background(), ac.Email, "Your team has been created", email.EmailData{
|
||||
RecipientName: ac.Name,
|
||||
Message: fmt.Sprintf("Your team \"%s\" has been created on Wrenn. You can now invite members and start creating sandboxes under this team.", req.Name),
|
||||
}); err != nil {
|
||||
slog.Warn("failed to send team created email", "email", ac.Email, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusCreated, teamWithRoleResponse{
|
||||
teamResponse: teamToResponse(team.Team),
|
||||
Role: team.Role,
|
||||
@ -280,6 +292,21 @@ func (h *teamHandler) AddMember(w http.ResponseWriter, r *http.Request) {
|
||||
if parseErr == nil {
|
||||
h.audit.LogMemberAdd(r.Context(), ac, targetUserID, member.Email, member.Role)
|
||||
}
|
||||
|
||||
go func() {
|
||||
team, err := h.svc.GetTeam(context.Background(), teamID)
|
||||
teamName := "a team"
|
||||
if err == nil {
|
||||
teamName = team.Name
|
||||
}
|
||||
if err := h.mailer.Send(context.Background(), member.Email, "You've been added to a team on Wrenn", email.EmailData{
|
||||
RecipientName: member.Name,
|
||||
Message: fmt.Sprintf("%s has added you to the team \"%s\" on Wrenn.", ac.Name, teamName),
|
||||
}); err != nil {
|
||||
slog.Warn("failed to send team invitation email", "email", member.Email, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusCreated, memberInfoToResponse(member))
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/email"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
|
||||
@ -44,6 +45,7 @@ func New(
|
||||
ca *auth.CA,
|
||||
al *audit.AuditLogger,
|
||||
channelSvc *channels.Service,
|
||||
mailer email.Mailer,
|
||||
extensions []cpextension.Extension,
|
||||
sctx cpextension.ServerContext,
|
||||
) *Server {
|
||||
@ -68,11 +70,11 @@ func New(
|
||||
filesStream := newFilesStreamHandler(queries, pool)
|
||||
fsH := newFSHandler(queries, pool)
|
||||
snapshots := newSnapshotHandler(templateSvc, queries, pool, al)
|
||||
authH := newAuthHandler(queries, pgPool, jwtSecret)
|
||||
authH := newAuthHandler(queries, pgPool, jwtSecret, mailer)
|
||||
oauthH := newOAuthHandler(queries, pgPool, jwtSecret, oauthRegistry, oauthRedirectURL)
|
||||
apiKeys := newAPIKeyHandler(apiKeySvc, al)
|
||||
hostH := newHostHandler(hostSvc, queries, al)
|
||||
teamH := newTeamHandler(teamSvc, al)
|
||||
teamH := newTeamHandler(teamSvc, al, mailer)
|
||||
usersH := newUsersHandler(queries, userSvc)
|
||||
auditH := newAuditHandler(auditSvc)
|
||||
statsH := newStatsHandler(statsSvc)
|
||||
|
||||
233
internal/email/email.go
Normal file
233
internal/email/email.go
Normal 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
|
||||
}
|
||||
191
internal/email/email_test.go
Normal file
191
internal/email/email_test.go
Normal 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{"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
internal/email/templates.go
Normal file
52
internal/email/templates.go
Normal 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
|
||||
}
|
||||
112
internal/email/templates/base.html
Normal file
112
internal/email/templates/base.html
Normal file
@ -0,0 +1,112 @@
|
||||
<!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 -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="560" style="max-width: 560px;">
|
||||
<tr>
|
||||
<td align="left" style="padding-bottom: 24px;">
|
||||
<a href="https://wrenn.dev" style="text-decoration: none;">
|
||||
<img src="https://wrenn.dev/logo.png" alt="Wrenn" width="36" height="36" style="display: block; border-radius: 6px;">
|
||||
</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: 40px 44px;">
|
||||
|
||||
<!-- Greeting -->
|
||||
{{if .RecipientName}}
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; line-height: 1.6; color: #3a3835;">
|
||||
Hello {{.RecipientName}},
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<!-- Message -->
|
||||
<p style="margin: 0 0 24px 0; font-size: 15px; line-height: 1.7; color: #3a3835;">
|
||||
{{.Message}}
|
||||
</p>
|
||||
|
||||
<!-- Button -->
|
||||
{{if .Button}}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 28px 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 28px; 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 24px 0; font-size: 13px; line-height: 1.6; color: #9b9790;">
|
||||
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: 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: 24px 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>
|
||||
13
internal/email/templates/base.txt
Normal file
13
internal/email/templates/base.txt
Normal file
@ -0,0 +1,13 @@
|
||||
{{if .RecipientName}}Hello {{.RecipientName}},
|
||||
|
||||
{{end}}{{.Message}}
|
||||
{{if .Button}}
|
||||
|
||||
{{.Button.Text}}: {{.Button.URL}}
|
||||
{{end}}{{if .Closing}}
|
||||
|
||||
{{.Closing}}
|
||||
{{end}}
|
||||
|
||||
---
|
||||
This is a transactional email from Wrenn (https://wrenn.dev).
|
||||
@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
@ -27,6 +28,13 @@ type Config struct {
|
||||
// Channels — encryption for channel secrets (AES-256-GCM).
|
||||
EncryptionKeyHex string // WRENN_ENCRYPTION_KEY raw hex string (for validation)
|
||||
EncryptionKey [32]byte // parsed 32-byte key
|
||||
|
||||
// SMTP — transactional email. All fields optional; omitting SMTPHost disables email.
|
||||
SMTPHost string // SMTP_HOST
|
||||
SMTPPort int // SMTP_PORT (default 587)
|
||||
SMTPUsername string // SMTP_USERNAME
|
||||
SMTPPassword string // SMTP_PASSWORD
|
||||
SMTPFromEmail string // SMTP_FROM_EMAIL
|
||||
}
|
||||
|
||||
// Load reads configuration from a .env file (if present) and environment variables.
|
||||
@ -50,6 +58,12 @@ func Load() Config {
|
||||
CPPublicURL: os.Getenv("CP_PUBLIC_URL"),
|
||||
|
||||
EncryptionKeyHex: os.Getenv("WRENN_ENCRYPTION_KEY"),
|
||||
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: envOrDefaultInt("SMTP_PORT", 587),
|
||||
SMTPUsername: os.Getenv("SMTP_USERNAME"),
|
||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||
SMTPFromEmail: envOrDefault("SMTP_FROM_EMAIL", "noreply@wrenn.dev"),
|
||||
}
|
||||
|
||||
if cfg.EncryptionKeyHex != "" {
|
||||
@ -68,3 +82,15 @@ func envOrDefault(key, def string) string {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envOrDefaultInt(key string, def int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/email"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/config"
|
||||
@ -29,6 +30,7 @@ type ServerContext struct {
|
||||
Scheduler scheduler.HostScheduler
|
||||
CA *auth.CA
|
||||
Audit *audit.AuditLogger
|
||||
Mailer email.Mailer
|
||||
JWTSecret []byte
|
||||
Config config.Config
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/api"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/email"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
|
||||
@ -150,6 +151,15 @@ func Run(opts ...Option) {
|
||||
// Shared audit logger with event publishing.
|
||||
al := audit.NewWithPublisher(queries, channelPub)
|
||||
|
||||
// Transactional email (no-op if SMTP_HOST is not set).
|
||||
mailer := email.New(email.Config{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: cfg.SMTPPort,
|
||||
Username: cfg.SMTPUsername,
|
||||
Password: cfg.SMTPPassword,
|
||||
FromEmail: cfg.SMTPFromEmail,
|
||||
})
|
||||
|
||||
// Build the server context that extensions receive.
|
||||
sctx := ServerContext{
|
||||
Queries: queries,
|
||||
@ -159,12 +169,13 @@ func Run(opts ...Option) {
|
||||
Scheduler: hostScheduler,
|
||||
CA: ca,
|
||||
Audit: al,
|
||||
Mailer: mailer,
|
||||
JWTSecret: []byte(cfg.JWTSecret),
|
||||
Config: cfg,
|
||||
}
|
||||
|
||||
// API server.
|
||||
srv := api.New(queries, hostPool, hostScheduler, pool, rdb, []byte(cfg.JWTSecret), oauthRegistry, cfg.OAuthRedirectURL, ca, al, channelSvc, o.extensions, sctx)
|
||||
srv := api.New(queries, hostPool, hostScheduler, pool, rdb, []byte(cfg.JWTSecret), oauthRegistry, cfg.OAuthRedirectURL, ca, al, channelSvc, mailer, o.extensions, sctx)
|
||||
|
||||
// Start template build workers (2 concurrent).
|
||||
stopBuildWorkers := srv.BuildSvc.StartWorkers(ctx, 2)
|
||||
|
||||
Reference in New Issue
Block a user