refactor: modularize email module and use actor for email verification (#1625)

This commit is contained in:
Elias Schneider
2026-07-27 20:00:59 +02:00
committed by GitHub
parent 6bd4679bab
commit 43aaccd5bf
36 changed files with 1562 additions and 923 deletions
-215
View File
@@ -1,215 +0,0 @@
package email
import (
"fmt"
"strings"
"unicode"
)
const maxLineLength = 78
const continuePrefix = " "
const addressSeparator = ", "
type Composer struct {
isClosed bool
content strings.Builder
}
func NewComposer() *Composer {
return &Composer{}
}
type Address struct {
Name string
Email string
}
func (c *Composer) AddAddressHeader(name string, addresses []Address) {
c.content.WriteString(genAddressHeader(name, addresses, maxLineLength))
c.content.WriteString("\n")
}
func genAddressHeader(name string, addresses []Address, maxLength int) string {
hl := &headerLine{
maxLineLength: maxLength,
continuePrefix: continuePrefix,
}
hl.Write(name)
hl.Write(": ")
for i, addr := range addresses {
var email string
if i < len(addresses)-1 {
email = fmt.Sprintf("<%s>%s", addr.Email, addressSeparator)
} else {
email = fmt.Sprintf("<%s>", addr.Email)
}
if isPrintableASCII(addr.Name) {
writeHeaderAtom(hl, addr.Name)
} else {
writeHeaderQ(hl, addr.Name)
}
writeHeaderAtom(hl, " ")
writeHeaderAtom(hl, email)
}
hl.EndLine()
return hl.String()
}
func (c *Composer) AddHeader(name, value string) {
if isPrintableASCII(value) && len(value)+len(name)+len(": ") < maxLineLength {
c.AddHeaderRaw(name, value)
return
}
c.content.WriteString(genHeader(name, value, maxLineLength))
c.content.WriteString("\n")
}
func genHeader(name, value string, maxLength int) string {
// add content as raw header when it is printable ASCII and shorter than maxLineLength
hl := &headerLine{
maxLineLength: maxLength,
continuePrefix: continuePrefix,
}
hl.Write(name)
hl.Write(": ")
writeHeaderQ(hl, value)
hl.EndLine()
return hl.String()
}
const qEncStart = "=?utf-8?q?"
const qEncEnd = "?="
type headerLine struct {
buffer strings.Builder
line strings.Builder
maxLineLength int
continuePrefix string
}
func (h *headerLine) FitsLine(length int) bool {
return h.line.Len()+len(h.continuePrefix)+length+2 < h.maxLineLength
}
func (h *headerLine) Write(str string) {
h.line.WriteString(str)
}
func (h *headerLine) EndLineWith(str string) {
h.line.WriteString(str)
h.EndLine()
}
func (h *headerLine) EndLine() {
if h.line.Len() == 0 {
return
}
if h.buffer.Len() != 0 {
h.buffer.WriteString("\n")
h.buffer.WriteString(h.continuePrefix)
}
h.buffer.WriteString(h.line.String())
h.line.Reset()
}
func (h *headerLine) String() string {
return h.buffer.String()
}
func writeHeaderQ(header *headerLine, value string) {
// current line does not fit event the first character - do \n
if !header.FitsLine(len(qEncStart) + len(convertRunes(value[0:1])[0]) + len(qEncEnd)) {
header.EndLineWith("")
}
header.Write(qEncStart)
for _, token := range convertRunes(value) {
if header.FitsLine(len(token) + len(qEncEnd)) {
header.Write(token)
} else {
header.EndLineWith(qEncEnd)
header.Write(qEncStart)
header.Write(token)
}
}
header.Write(qEncEnd)
}
func writeHeaderAtom(header *headerLine, value string) {
if !header.FitsLine(len(value)) {
header.EndLine()
}
header.Write(value)
}
func (c *Composer) AddHeaderRaw(name, value string) {
if c.isClosed {
panic("composer had already written body!")
}
header := fmt.Sprintf("%s: %s\n", name, value)
c.content.WriteString(header)
}
func (c *Composer) Body(body string) {
c.content.WriteString("\n")
c.content.WriteString(body)
c.isClosed = true
}
func (c *Composer) String() string {
return c.content.String()
}
func convertRunes(str string) []string {
var enc = make([]string, 0, len(str))
for _, r := range str {
switch {
case r == ' ':
enc = append(enc, "_")
case isPrintableASCIIRune(r) && r != '=' && r != '?' && r != '_':
enc = append(enc, string(r))
default:
enc = append(enc, string(toHex([]byte(string(r)))))
}
}
return enc
}
func toHex(in []byte) []byte {
enc := make([]byte, 0, len(in)*2)
for _, b := range in {
enc = append(enc, '=')
enc = append(enc, hex(b/16))
enc = append(enc, hex(b%16))
}
return enc
}
func hex(n byte) byte {
if n > 9 {
return n + (65 - 10)
} else {
return n + 48
}
}
func isPrintableASCII(str string) bool {
for _, r := range str {
if !unicode.IsPrint(r) || r >= unicode.MaxASCII {
return false
}
}
return true
}
func isPrintableASCIIRune(r rune) bool {
return r > 31 && r < 127
}
@@ -1,92 +0,0 @@
package email
import (
"strings"
"testing"
)
func TestConvertRunes(t *testing.T) {
var testData = map[string]string{
"=??=_.": "=3D=3F=3F=3D=5F.",
"Příšerně žluťoučký kůn úpěl ďábelské ódy 🐎": "P=C5=99=C3=AD=C5=A1ern=C4=9B_=C5=BElu=C5=A5ou=C4=8Dk=C3=BD_k=C5=AFn_=C3=BAp=C4=9Bl_=C4=8F=C3=A1belsk=C3=A9_=C3=B3dy_=F0=9F=90=8E",
}
for input, expected := range testData {
got := strings.Join(convertRunes(input), "")
if got != expected {
t.Errorf("Input: '%s', expected '%s', got: '%s'", input, expected, got)
}
}
}
type genHeaderTestData struct {
name string
value string
expected string
maxWidth int
}
func TestGenHeaderQ(t *testing.T) {
var testData = []genHeaderTestData{
{
name: "Subject",
value: "Příšerně žluťoučký kůn úpěl ďábelské ódy 🐎",
expected: "Subject: =?utf-8?q?P=C5=99=C3=AD=C5=A1ern=C4=9B_=C5=BElu=C5=A5ou=C4=8Dk?=\n" +
" =?utf-8?q?=C3=BD_k=C5=AFn_=C3=BAp=C4=9Bl_=C4=8F=C3=A1belsk=C3=A9_=C3=B3?=\n" +
" =?utf-8?q?dy_=F0=9F=90=8E?=",
maxWidth: 80,
},
}
for _, data := range testData {
got := genHeader(data.name, data.value, data.maxWidth)
if got != data.expected {
t.Errorf("Input: '%s', expected \n===\n%s\n===, got: \n===\n%s\n==='", data.value, data.expected, got)
}
}
}
type genAddressHeaderTestData struct {
name string
addresses []Address
expected string
maxLength int
}
func TestGenAddressHeader(t *testing.T) {
var testData = []genAddressHeaderTestData{
{
name: "To",
addresses: []Address{
{
Name: "Oldřich Jánský",
Email: "olrd@example.com",
},
},
expected: "To: =?utf-8?q?Old=C5=99ich_J=C3=A1nsk=C3=BD?= <olrd@example.com>",
maxLength: 80,
},
{
name: "Subject",
addresses: []Address{
{
Name: "Oldřich Jánský",
Email: "olrd@example.com",
},
{
Name: "Jan Novák",
Email: "novak@example.com",
},
},
expected: "Subject: =?utf-8?q?Old=C5=99ich_J=C3=A1nsk=C3=BD?= <olrd@example.com>, \n" +
" =?utf-8?q?Jan_Nov=C3=A1k?= <novak@example.com>",
maxLength: 80,
},
}
for _, data := range testData {
got := genAddressHeader(data.name, data.addresses, data.maxLength)
if got != data.expected {
t.Errorf("Test: '%s', expected \n===\n%s\n===, got: \n===\n%s\n==='", data.name, data.expected, got)
}
}
}
@@ -1,61 +0,0 @@
package email
import (
"fmt"
htemplate "html/template"
"path"
ttemplate "text/template"
"github.com/pocket-id/pocket-id/backend/resources"
)
type Template[V any] struct {
Path string
Title func(data *TemplateData[V]) string
}
type TemplateData[V any] struct {
AppName string
LogoURL string
Data *V
}
type TemplateMap[V any] map[string]*V
func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V]) *U {
return templateMap[template.Path]
}
func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) {
textTemplates := make(map[string]*ttemplate.Template, len(templates))
for _, tmpl := range templates {
filename := tmpl + "_text.tmpl"
templatePath := path.Join("email-templates", filename)
parsedTemplate, err := ttemplate.ParseFS(resources.FS, templatePath)
if err != nil {
return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err)
}
textTemplates[tmpl] = parsedTemplate
}
return textTemplates, nil
}
func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) {
htmlTemplates := make(map[string]*htemplate.Template, len(templates))
for _, tmpl := range templates {
filename := tmpl + "_html.tmpl"
templatePath := path.Join("email-templates", filename)
parsedTemplate, err := htemplate.ParseFS(resources.FS, templatePath)
if err != nil {
return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err)
}
htmlTemplates[tmpl] = parsedTemplate
}
return htmlTemplates, nil
}