Skip to content

Commit 2f9553b

Browse files
committed
Pretty HTML emails! Amazing!
1 parent 34bb1cd commit 2f9553b

10 files changed

Lines changed: 540 additions & 32 deletions

File tree

src/admintools/admintools.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,13 @@ func init() {
300300
err = email.SendRegistrationEmail(toAddress, toName, "test_user", "test_token", "", p)
301301
case "passwordreset":
302302
err = email.SendPasswordReset(toAddress, toName, "test_user", "test_token", time.Now().Add(time.Hour*24), p)
303+
case "ticketpurchased":
304+
err = email.SendTicketPurchaseEmail(toAddress, toName, &models.Ticket{
305+
ID: uuid.New(),
306+
EventSlug: hmndata.HMNExpo2026.Slug,
307+
OwnerName: "Test User",
308+
OwnerEmail: "test@example.org",
309+
})
303310
default:
304311
fmt.Printf("You must provide a valid email type\n\n")
305312
cmd.Usage()

src/email/email.go

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import (
1111
"time"
1212

1313
"git.handmade.network/hmn/hmn/src/config"
14+
"git.handmade.network/hmn/hmn/src/hmndata"
1415
"git.handmade.network/hmn/hmn/src/hmnurl"
16+
"git.handmade.network/hmn/hmn/src/models"
1517
"git.handmade.network/hmn/hmn/src/oops"
1618
"git.handmade.network/hmn/hmn/src/perf"
1719
"git.handmade.network/hmn/hmn/src/templates"
@@ -165,36 +167,72 @@ func SendTimeMachineEmail(profileUrl, username, userEmail, discordUsername strin
165167
return nil
166168
}
167169

170+
func SendTicketPurchaseEmail(toAddress string, toName string, ticket *models.Ticket) error {
171+
event, ok := hmndata.FindTicketEventBySlug(ticket.EventSlug)
172+
if !ok {
173+
return oops.New(nil, "failed to find event for ticket in email")
174+
}
175+
176+
type TicketPurchaseEmailData struct {
177+
EventName string
178+
Name string
179+
Email string
180+
CodeURL string
181+
TicketURL string
182+
}
183+
contents, err := renderTemplate("email_ticket_purchase.html", TicketPurchaseEmailData{
184+
EventName: event.Name,
185+
Name: ticket.OwnerName,
186+
Email: ticket.OwnerEmail,
187+
CodeURL: hmnurl.BuildTicketQRCode(ticket.ID.String()),
188+
TicketURL: hmnurl.BuildTicketSingle(ticket.ID.String()),
189+
})
190+
if err != nil {
191+
return err
192+
}
193+
194+
err = sendMail(toAddress, toName, fmt.Sprintf("Your ticket for the %s", event.Name), contents)
195+
if err != nil {
196+
return oops.New(err, "Failed to send email")
197+
}
198+
199+
return nil
200+
}
201+
168202
var EmailRegex = regexp.MustCompile(`^[^:\p{Cc} ]+@[^:\p{Cc} ]+\.[^:\p{Cc} ]+$`)
169203

170204
func IsEmail(address string) bool {
171205
return EmailRegex.Match([]byte(address))
172206
}
173207

174-
func renderTemplate(name string, data any) (string, error) {
208+
func renderTemplate(name string, data any) ([]byte, error) {
175209
var buffer bytes.Buffer
176210
template, hasTemplate := templates.GetTemplate(name)
177211
if !hasTemplate {
178-
return "", oops.New(nil, "template not found: %s", name)
212+
return nil, oops.New(nil, "template not found: %s", name)
179213
}
180214
err := template.Execute(&buffer, data)
181215
if err != nil {
182-
return "", oops.New(err, "Failed to render template for email")
216+
return nil, oops.New(err, "Failed to render template for email")
183217
}
184-
contentString := string(buffer.Bytes())
185-
contentString = strings.ReplaceAll(contentString, "\n", "\r\n")
218+
contentString := buffer.Bytes()
219+
contentString = bytes.ReplaceAll(contentString, []byte("\n"), []byte("\r\n"))
186220
return contentString, nil
187221
}
188222

189-
func sendMail(toAddress, toName, subject, contentHtml string) error {
223+
func sendMail(toAddress, toName, subject string, contentHTML []byte) error {
190224
if config.Config.Email.ForceToAddress != "" {
191225
toAddress = config.Config.Email.ForceToAddress
192226
}
227+
processedHTML, err := preprocessEmailHTML(contentHTML)
228+
if err != nil {
229+
return err
230+
}
193231
contents := prepMailContents(
194232
makeHeaderAddress(toAddress, toName),
195233
makeHeaderAddress(config.Config.Email.FromAddress, config.Config.Email.FromName),
196234
subject,
197-
contentHtml,
235+
processedHTML,
198236
)
199237
return smtp.SendMail(
200238
fmt.Sprintf("%s:%d", config.Config.Email.ServerAddress, config.Config.Email.ServerPort),
@@ -218,18 +256,18 @@ func makeHeaderAddress(email, fullname string) string {
218256
}
219257
}
220258

221-
func prepMailContents(toLine string, fromLine string, subject string, contentHtml string) []byte {
259+
func prepMailContents(toLine string, fromLine string, subject string, contentHtml []byte) []byte {
222260
var builder strings.Builder
223261

224-
builder.WriteString(fmt.Sprintf("To: %s\r\n", toLine))
225-
builder.WriteString(fmt.Sprintf("From: %s\r\n", fromLine))
226-
builder.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
227-
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject)))
262+
fmt.Fprintf(&builder, "To: %s\r\n", toLine)
263+
fmt.Fprintf(&builder, "From: %s\r\n", fromLine)
264+
fmt.Fprintf(&builder, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
265+
fmt.Fprintf(&builder, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
228266
builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
229267
builder.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
230268
builder.WriteString("\r\n")
231269
writer := quotedprintable.NewWriter(&builder)
232-
writer.Write([]byte(contentHtml))
270+
writer.Write(contentHtml)
233271
writer.Close()
234272
builder.WriteString("\r\n")
235273

src/email/preprocessor.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package email
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"regexp"
8+
"strings"
9+
)
10+
11+
func preprocessEmailHTML(html []byte) ([]byte, error) {
12+
var errs []error
13+
14+
rules := make(cssRules)
15+
remaining := html
16+
out := make([]byte, 0, len(html))
17+
nextTag:
18+
for {
19+
locs := reOpeningTag.FindSubmatchIndex(remaining)
20+
if locs == nil {
21+
out = append(out, remaining...)
22+
break
23+
}
24+
beforeTag := remaining[:locs[0]]
25+
// fullMatch := remaining[locs[0]:locs[1]]
26+
tagName := remaining[locs[2]:locs[3]]
27+
var atts []byte
28+
if locs[4] != -1 {
29+
atts = remaining[locs[4]:locs[5]]
30+
}
31+
selfClose := remaining[locs[6]:locs[7]]
32+
33+
// Advance to just past the end of the tag. Sub-components of the tag will be
34+
// parsed separately.
35+
remaining = remaining[min(locs[1], len(remaining)):]
36+
37+
out = append(out, beforeTag...)
38+
if string(tagName) == "style" {
39+
// Naively find the closing tag, then parse the CSS rules.
40+
locs := reClosingStyleTag.FindIndex(remaining)
41+
if locs == nil {
42+
// No closing tag? Strange. Just continue to the next iteration.
43+
continue nextTag
44+
}
45+
rawCSS := remaining[:locs[0]]
46+
remaining = remaining[min(locs[1], len(remaining)):] // Advance to past </style>
47+
48+
err := parseCSSRules(rawCSS, rules)
49+
if err != nil {
50+
errs = append(errs, fmt.Errorf("failed to parse CSS: %w", err))
51+
}
52+
} else {
53+
// Process the tag's attributes, replacing `style` and `class` with a single new
54+
// style attribute.
55+
56+
out = append(out, '<')
57+
out = append(out, tagName...)
58+
59+
// First find any styles pertaining to the tag alone.
60+
var finalStyles []string
61+
if styles, ok := rules[string(tagName)]; ok {
62+
finalStyles = append(finalStyles, styles...)
63+
}
64+
65+
// Parse / output attributes and gather up styles from classes and styles.
66+
attsRemaining := atts
67+
for {
68+
locs := reAttribute.FindSubmatchIndex(attsRemaining)
69+
if locs == nil {
70+
break
71+
}
72+
fullMatch := attsRemaining[locs[0]:locs[1]]
73+
attName := attsRemaining[locs[2]:locs[3]]
74+
attValue := attsRemaining[locs[4]:locs[5]]
75+
76+
attsRemaining = attsRemaining[min(locs[1], len(attsRemaining)):] // Advance to next attribute
77+
78+
switch string(attName) {
79+
case "class":
80+
// Classes are parsed and their styles pulled out of the map.
81+
classesRemaining := attValue
82+
for {
83+
classesRemaining = bytes.TrimLeft(classesRemaining, " ")
84+
if len(classesRemaining) == 0 {
85+
break
86+
}
87+
afterClass := bytes.IndexByte(classesRemaining, ' ')
88+
if afterClass == -1 {
89+
afterClass = len(classesRemaining)
90+
}
91+
class := string(classesRemaining[:afterClass])
92+
classesRemaining = classesRemaining[afterClass:]
93+
94+
if styles, ok := rules["."+class]; ok {
95+
finalStyles = append(finalStyles, styles...)
96+
} else {
97+
errs = append(errs, fmt.Errorf("unknown class %s", class))
98+
}
99+
}
100+
case "style":
101+
// Existing inline styles are just dumped in as is (minus semicolons).
102+
finalStyles = append(finalStyles, string(bytes.Trim(attValue, ";")))
103+
default:
104+
// All other attributes are immediately echoed.
105+
out = append(out, ' ')
106+
out = append(out, fullMatch...)
107+
}
108+
}
109+
110+
// Emit final styles and close the tag.
111+
if len(finalStyles) > 0 {
112+
out = append(out, []byte(` style="`)...)
113+
out = append(out, []byte(strings.Join(finalStyles, ";"))...)
114+
out = append(out, []byte(`;"`)...)
115+
}
116+
117+
out = append(out, selfClose...)
118+
out = append(out, '>')
119+
}
120+
}
121+
122+
return out, errors.Join(errs...)
123+
}
124+
125+
var reOpeningTag = regexp.MustCompile(`<([a-zA-Z-]+)((?:\s+[a-zA-Z-]+=".*?")+)?\s*(/?)>`)
126+
var reClosingStyleTag = regexp.MustCompile(`</style\s*>`)
127+
var reAttribute = regexp.MustCompile(`([a-zA-Z-]+)="(.*?)"`)
128+
var reCSSRule = regexp.MustCompile(`(?s)(\.?[a-zA-Z0-9-]+)\s\{(.*?)}`)
129+
var reCSSComment = regexp.MustCompile(`/\*.*?\*/`)
130+
var reCSSStyle = regexp.MustCompile(`([a-zA-Z0-9-]+):\s*(.*?);`)
131+
132+
type cssRules map[string][]string
133+
134+
func parseCSSRules(css []byte, rules cssRules) error {
135+
// We are a bit lazier with the parsing here.
136+
cssMinusComments := reCSSComment.ReplaceAllLiteralString(string(css), "")
137+
for _, rule := range reCSSRule.FindAllStringSubmatch(cssMinusComments, -1) {
138+
selector := rule[1]
139+
styles := rule[2]
140+
141+
var mapStyles []string
142+
for line := range strings.SplitSeq(styles, "\n") {
143+
match := reCSSStyle.FindStringSubmatch(line)
144+
if match == nil {
145+
continue
146+
}
147+
mapStyles = append(mapStyles, fmt.Sprintf("%s:%s", match[1], match[2]))
148+
}
149+
rules[selector] = mapStyles
150+
}
151+
152+
return nil
153+
}

src/email/preprocessor_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package email
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestSimple(t *testing.T) {
10+
input := `
11+
<!DOCTYPE html>
12+
<html>
13+
14+
<head>
15+
<meta charset="utf-8">
16+
</head>
17+
18+
<body>
19+
<div style="background:#fff;font-size:16px">Hello!</div>
20+
</body>
21+
22+
</html>
23+
`
24+
expected := `
25+
<!DOCTYPE html>
26+
<html>
27+
28+
<head>
29+
<meta charset="utf-8">
30+
</head>
31+
32+
<body>
33+
<div style="background:#fff;font-size:16px;">Hello!</div>
34+
</body>
35+
36+
</html>
37+
`
38+
actual, err := preprocessEmailHTML([]byte(input))
39+
assert.Nil(t, err)
40+
assert.Equal(t, expected, string(actual))
41+
}
42+
43+
func TestSimpleCSS(t *testing.T) {
44+
input := `
45+
<!DOCTYPE html>
46+
<html>
47+
<head>
48+
<meta charset="utf-8">
49+
<style>
50+
/* Hey, comments */
51+
body {
52+
font-size: 16px; /* we love comments */
53+
}
54+
55+
.bg-white {
56+
/* comments are so good */
57+
background: #fff;
58+
}
59+
.f5 {
60+
font-size: /* so good */ 16px;
61+
}
62+
</style>
63+
</head>
64+
<body>
65+
<div class="bg-white f5" style="font-weight:bold;text-decoration:underline">Hello!</div>
66+
</body>
67+
</html>
68+
`
69+
expected := `
70+
<!DOCTYPE html>
71+
<html>
72+
<head>
73+
<meta charset="utf-8">
74+
75+
</head>
76+
<body style="font-size:16px;">
77+
<div style="background:#fff;font-size:16px;font-weight:bold;text-decoration:underline;">Hello!</div>
78+
</body>
79+
</html>
80+
`
81+
actual, err := preprocessEmailHTML([]byte(input))
82+
assert.Nil(t, err)
83+
assert.Equal(t, expected, string(actual))
84+
}

src/hmndata/tickets.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,13 @@ package hmndata
33
var AllTicketEvents = []Event{
44
HMNExpo2026.Event,
55
}
6+
7+
func FindTicketEventBySlug(slugOrUrlSlug string) (Event, bool) {
8+
for _, e := range AllTicketEvents {
9+
if e.Slug == slugOrUrlSlug || e.UrlSlug == slugOrUrlSlug {
10+
return e, true
11+
}
12+
}
13+
14+
return Event{}, false
15+
}

0 commit comments

Comments
 (0)