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)