1
0
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:
2026-04-16 00:46:08 +06:00
parent 700512b627
commit 9d68eb5f00
12 changed files with 697 additions and 9 deletions

View File

@ -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),

View File

@ -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))
}

View File

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