From 9d68eb5f00a0ceb52acbd35b9fa5f1c7d496c1f3 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 16 Apr 2026 00:46:08 +0600 Subject: [PATCH] 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. --- .env.example | 7 + internal/api/handlers_auth.go | 16 +- internal/api/handlers_team.go | 35 ++++- internal/api/server.go | 6 +- internal/email/email.go | 233 +++++++++++++++++++++++++++++ internal/email/email_test.go | 191 +++++++++++++++++++++++ internal/email/templates.go | 52 +++++++ internal/email/templates/base.html | 112 ++++++++++++++ internal/email/templates/base.txt | 13 ++ pkg/config/config.go | 26 ++++ pkg/cpextension/extension.go | 2 + pkg/cpserver/run.go | 13 +- 12 files changed, 697 insertions(+), 9 deletions(-) create mode 100644 internal/email/email.go create mode 100644 internal/email/email_test.go create mode 100644 internal/email/templates.go create mode 100644 internal/email/templates/base.html create mode 100644 internal/email/templates/base.txt 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 + + + + + + + + + + +
+ + + + + + +
+ + Wrenn + +
+ + + + + + +
+ + + {{if .RecipientName}} +

+ Hello {{.RecipientName}}, +

+ {{end}} + + +

+ {{.Message}} +

+ + + {{if .Button}} + + + + +
+ + + + {{.Button.Text}} + + +
+ +

+ 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)