diff --git a/.env.example b/.env.example
index 80ddf0d..b9075b2 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go
index 56793ef..d2d8d53 100644
--- a/internal/api/handlers_auth.go
+++ b/internal/api/handlers_auth.go
@@ -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),
diff --git a/internal/api/handlers_team.go b/internal/api/handlers_team.go
index bb24e7d..bfbe76c 100644
--- a/internal/api/handlers_team.go
+++ b/internal/api/handlers_team.go
@@ -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))
}
diff --git a/internal/api/server.go b/internal/api/server.go
index f4f561d..c687f5f 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -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)
diff --git a/internal/email/email.go b/internal/email/email.go
new file mode 100644
index 0000000..89482e0
--- /dev/null
+++ b/internal/email/email.go
@@ -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
+}
diff --git a/internal/email/email_test.go b/internal/email/email_test.go
new file mode 100644
index 0000000..a292ce1
--- /dev/null
+++ b/internal/email/email_test.go
@@ -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, "") {
+ 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", "
HTML
", "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", "\u00c5ngstr\u00f6m
", "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)
+ }
+ }
+}
diff --git a/internal/email/templates.go b/internal/email/templates.go
new file mode 100644
index 0000000..081a158
--- /dev/null
+++ b/internal/email/templates.go
@@ -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
+}
diff --git a/internal/email/templates/base.html b/internal/email/templates/base.html
new file mode 100644
index 0000000..6011b48
--- /dev/null
+++ b/internal/email/templates/base.html
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+ Wrenn
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+ |
+
+
+ {{if .RecipientName}}
+
+ Hello {{.RecipientName}},
+
+ {{end}}
+
+
+
+ {{.Message}}
+
+
+
+ {{if .Button}}
+
+
+
+ If the button doesn't work, copy and paste this URL into your browser:
+ {{.Button.URL}}
+
+ {{end}}
+
+
+ {{if .Closing}}
+
+ {{.Closing}}
+
+ {{end}}
+
+ |
+
+
+
+
+
+
+ |
+
+ This is a transactional email from Wrenn.
+
+ |
+
+
+
+ |
+
+
+
+
+
diff --git a/internal/email/templates/base.txt b/internal/email/templates/base.txt
new file mode 100644
index 0000000..64312b3
--- /dev/null
+++ b/internal/email/templates/base.txt
@@ -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).
diff --git a/pkg/config/config.go b/pkg/config/config.go
index dbc1f1f..2274bb2 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -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
+}
diff --git a/pkg/cpextension/extension.go b/pkg/cpextension/extension.go
index 7dcc98b..b2065f2 100644
--- a/pkg/cpextension/extension.go
+++ b/pkg/cpextension/extension.go
@@ -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
}
diff --git a/pkg/cpserver/run.go b/pkg/cpserver/run.go
index ee6be00..d7be9ad 100644
--- a/pkg/cpserver/run.go
+++ b/pkg/cpserver/run.go
@@ -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)