forked from wrenn/wrenn
v0.1.0 (#17)
This commit is contained in:
233
internal/email/email.go
Normal file
233
internal/email/email.go
Normal 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
|
||||
}
|
||||
191
internal/email/email_test.go
Normal file
191
internal/email/email_test.go
Normal 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{"Hello,", "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)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
internal/email/templates.go
Normal file
52
internal/email/templates.go
Normal 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
|
||||
}
|
||||
119
internal/email/templates/base.html
Normal file
119
internal/email/templates/base.html
Normal file
@ -0,0 +1,119 @@
|
||||
<!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 + Wordmark -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="560" style="max-width: 560px;">
|
||||
<tr>
|
||||
<td align="left" style="padding-bottom: 32px;">
|
||||
<a href="https://wrenn.dev" style="text-decoration: none;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
<img src="https://wrenn.dev/logo.png" alt="Wrenn" width="36" height="36" style="display: block; border-radius: 6px;">
|
||||
</td>
|
||||
<td style="vertical-align: middle; padding-left: 10px;">
|
||||
<span style="font-family: 'Alice', Georgia, 'Times New Roman', serif; font-size: 22px; color: #1a1917; letter-spacing: 0.01em;">Wrenn</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</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: 44px 48px;">
|
||||
|
||||
<!-- Greeting -->
|
||||
<p style="margin: 0 0 8px 0; font-size: 15px; line-height: 1.6; color: #3a3835;">
|
||||
Hello{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||
</p>
|
||||
|
||||
<!-- Message -->
|
||||
<p style="margin: 0 0 36px 0; font-size: 15px; line-height: 1.7; color: #3a3835;">
|
||||
{{.Message}}
|
||||
</p>
|
||||
|
||||
<!-- Button -->
|
||||
{{if .Button}}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 36px 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 32px; 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 12px 0; font-size: 12px; line-height: 1.5; color: #b5b0a8;">
|
||||
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: {{if .Button}}20px{{else}}0{{end}} 0 0 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: 32px 0 16px 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>
|
||||
13
internal/email/templates/base.txt
Normal file
13
internal/email/templates/base.txt
Normal file
@ -0,0 +1,13 @@
|
||||
Hello{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||
|
||||
{{.Message}}
|
||||
{{if .Button}}
|
||||
|
||||
{{.Button.Text}}: {{.Button.URL}}
|
||||
{{end}}{{if .Closing}}
|
||||
|
||||
{{.Closing}}
|
||||
{{end}}
|
||||
|
||||
---
|
||||
This is a transactional email from Wrenn (https://wrenn.dev).
|
||||
Reference in New Issue
Block a user