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)

233
internal/email/email.go Normal file
View 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
}

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

View 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
}

View 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>

View 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).