1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-04 12:46:45 +00:00

refactor: use react email for email templates (#734)

Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-08-31 11:54:13 -05:00
committed by GitHub
parent 6c843228eb
commit 802754c24c
33 changed files with 5092 additions and 336 deletions

View File

@@ -23,8 +23,6 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -3,15 +3,15 @@ on:
push: push:
branches: [main] branches: [main]
paths-ignore: paths-ignore:
- "docs/**" - 'docs/**'
- "**.md" - '**.md'
- ".github/**" - '.github/**'
pull_request: pull_request:
branches: [main] branches: [main]
paths-ignore: paths-ignore:
- "docs/**" - 'docs/**'
- "**.md" - '**.md'
- ".github/**" - '.github/**'
jobs: jobs:
build: build:
@@ -61,13 +61,11 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: "pnpm" cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Cache Playwright Browsers - name: Cache Playwright Browsers

View File

@@ -18,8 +18,6 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@@ -71,6 +69,7 @@ jobs:
run: pnpm --filter pocket-id-frontend install --frozen-lockfile run: pnpm --filter pocket-id-frontend install --frozen-lockfile
- name: Build frontend - name: Build frontend
run: pnpm --filter pocket-id-frontend build run: pnpm --filter pocket-id-frontend build
- name: Build binaries - name: Build binaries
run: sh scripts/development/build-binaries.sh run: sh scripts/development/build-binaries.sh
- name: Build and push container image - name: Build and push container image

View File

@@ -38,8 +38,6 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -61,4 +61,4 @@ formatters:
paths: paths:
- third_party$ - third_party$
- builtin$ - builtin$
- examples$ - examples$

View File

@@ -262,7 +262,7 @@ func prepareBody[V any](srv *EmailService, template email.Template[V], data *ema
// prepare text part // prepare text part
var textHeader = textproto.MIMEHeader{} var textHeader = textproto.MIMEHeader{}
textHeader.Add("Content-Type", "text/plain;\n charset=UTF-8") textHeader.Add("Content-Type", "text/plain; charset=UTF-8")
textHeader.Add("Content-Transfer-Encoding", "quoted-printable") textHeader.Add("Content-Transfer-Encoding", "quoted-printable")
textPart, err := mpart.CreatePart(textHeader) textPart, err := mpart.CreatePart(textHeader)
if err != nil { if err != nil {
@@ -274,18 +274,17 @@ func prepareBody[V any](srv *EmailService, template email.Template[V], data *ema
if err != nil { if err != nil {
return "", "", fmt.Errorf("execute text template: %w", err) return "", "", fmt.Errorf("execute text template: %w", err)
} }
textQp.Close()
// prepare html part
var htmlHeader = textproto.MIMEHeader{} var htmlHeader = textproto.MIMEHeader{}
htmlHeader.Add("Content-Type", "text/html;\n charset=UTF-8") htmlHeader.Add("Content-Type", "text/html; charset=UTF-8")
htmlHeader.Add("Content-Transfer-Encoding", "quoted-printable") htmlHeader.Add("Content-Transfer-Encoding", "8bit")
htmlPart, err := mpart.CreatePart(htmlHeader) htmlPart, err := mpart.CreatePart(htmlHeader)
if err != nil { if err != nil {
return "", "", fmt.Errorf("create html part: %w", err) return "", "", fmt.Errorf("create html part: %w", err)
} }
htmlQp := quotedprintable.NewWriter(htmlPart) err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlPart, "root", data)
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlQp, "root", data)
if err != nil { if err != nil {
return "", "", fmt.Errorf("execute html template: %w", err) return "", "", fmt.Errorf("execute html template: %w", err)
} }

View File

@@ -3,7 +3,6 @@ package email
import ( import (
"fmt" "fmt"
htemplate "html/template" htemplate "html/template"
"io/fs"
"path" "path"
ttemplate "text/template" ttemplate "text/template"
@@ -27,71 +26,35 @@ func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V])
return templateMap[template.Path] return templateMap[template.Path]
} }
type cloneable[V pareseable[V]] interface {
Clone() (V, error)
}
type pareseable[V any] interface {
ParseFS(fs.FS, ...string) (V, error)
}
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate cloneable[V], suffix string) (V, error) {
tmpl, err := rootTemplate.Clone()
if err != nil {
return *new(V), fmt.Errorf("clone root template: %w", err)
}
filename := fmt.Sprintf("%s%s", template, suffix)
templatePath := path.Join("email-templates", filename)
_, err = tmpl.ParseFS(templateFS, templatePath)
if err != nil {
return *new(V), fmt.Errorf("parsing template '%s': %w", template, err)
}
return tmpl, nil
}
func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) { func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) {
components := path.Join("email-templates", "components", "*_text.tmpl")
rootTmpl, err := ttemplate.ParseFS(resources.FS, components)
if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
}
textTemplates := make(map[string]*ttemplate.Template, len(templates)) textTemplates := make(map[string]*ttemplate.Template, len(templates))
for _, tmpl := range templates { for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone() filename := tmpl + "_text.tmpl"
templatePath := path.Join("email-templates", filename)
parsedTemplate, err := ttemplate.ParseFS(resources.FS, templatePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("clone root template: %w", err) return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err)
} }
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](resources.FS, tmpl, rootTmplClone, "_text.tmpl") textTemplates[tmpl] = parsedTemplate
if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
}
} }
return textTemplates, nil return textTemplates, nil
} }
func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) { func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) {
components := path.Join("email-templates", "components", "*_html.tmpl")
rootTmpl, err := htemplate.ParseFS(resources.FS, components)
if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
}
htmlTemplates := make(map[string]*htemplate.Template, len(templates)) htmlTemplates := make(map[string]*htemplate.Template, len(templates))
for _, tmpl := range templates { for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone() filename := tmpl + "_html.tmpl"
templatePath := path.Join("email-templates", filename)
parsedTemplate, err := htemplate.ParseFS(resources.FS, templatePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("clone root template: %w", err) return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err)
} }
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](resources.FS, tmpl, rootTmplClone, "_html.tmpl") htmlTemplates[tmpl] = parsedTemplate
if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
}
} }
return htmlTemplates, nil return htmlTemplates, nil

View File

@@ -1,17 +1,3 @@
{{ define "base" }} {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
<div class="header"> <img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column">
<div class="logo"> <p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
<h1>{{ .AppName }}</h1>
</div>
<div class="warning">Warning</div>
</div>
<div class="content">
<h2>API Key Expiring Soon</h2>
<p>
Hello {{ .Data.Name }},<br/><br/>
This is a reminder that your API key <strong>{{ .Data.ApiKeyName }}</strong> will expire on <strong>{{ .Data.ExpiresAt.Format "2006-01-02 15:04:05 MST" }}</strong>.<br/><br/>
Please generate a new API key if you need continued access.
</p>
</div>
{{ end }}

View File

@@ -1,10 +1,12 @@
{{ define "base" -}} {{define "root"}}{{.AppName}}
API Key Expiring Soon
====================
Hello {{ .Data.Name }},
This is a reminder that your API key "{{ .Data.ApiKeyName }}" will expire on {{ .Data.ExpiresAt.Format "2006-01-02 15:04:05 MST" }}. API KEY EXPIRING SOON
Please generate a new API key if you need continued access. Warning
{{ end -}}
Hello {{.Data.Name}},
This is a reminder that your API key {{.Data.APIKeyName}} will expire on
{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
Please generate a new API key if you need continued access.{{end}}

View File

@@ -1,14 +0,0 @@
{{ define "root" }}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ template "style" . }}
</head>
<body>
<div class="container">
{{ template "base" . }}
</div>
</body>
</html>
{{ end }}

View File

@@ -1,7 +0,0 @@
{{- define "root" -}}
{{- template "base" . -}}
{{- end }}
--
This is automatically sent email from {{.AppName}}.

View File

@@ -1,92 +0,0 @@
{{ define "style" }}
<style>
/* Reset styles for email clients */
body, table, td, p, a {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font-family: Arial, sans-serif;
line-height: 1.5;
}
body {
background-color: #f0f0f0;
color: #333;
}
.container {
width: 100%;
max-width: 600px;
margin: 40px auto;
background-color: #fff;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 32px;
}
.header {
display: flex;
margin-bottom: 24px;
}
.header .logo img {
width: 32px;
height: 32px;
vertical-align: middle;
}
.header h1 {
font-size: 1.5rem;
font-weight: bold;
display: inline-block;
vertical-align: middle;
margin-left: 8px;
}
.warning {
background-color: #ffd966;
color: #7f6000;
padding: 4px 12px;
border-radius: 50px;
font-size: 0.875rem;
margin: auto 0 auto auto;
}
.content {
background-color: #fafafa;
padding: 24px;
border-radius: 10px;
}
.content h2 {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 16px;
}
.grid {
width: 100%;
margin-bottom: 16px;
}
.grid td {
width: 50%;
padding-bottom: 8px;
vertical-align: top;
}
.label {
color: #888;
font-size: 0.875rem;
}
.message {
font-size: 1rem;
line-height: 1.5;
margin-top: 16px;
}
.button {
background-color: #000000;
color: #ffffff;
padding: 0.7rem 1.5rem;
text-decoration: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
display: inline-block;
margin-top: 24px;
}
.button-container {
text-align: center;
}
</style>
{{ end }}

View File

@@ -1,40 +1,5 @@
{{ define "base" }} {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
<div class="header"> <img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column">
<div class="logo"> <p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your <!-- -->{{.AppName}}<!-- --> account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.</p><h4 style="font-size:1rem;font-weight:bold;margin:30px 0 10px 0">Details</h4><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Approximate Location</p>
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/> <p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.City}}<!-- -->, <!-- -->{{.Data.Country}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">
<h1>{{ .AppName }}</h1> {{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
</div>
<div class="warning">Warning</div>
</div>
<div class="content">
<h2>New Sign-In Detected</h2>
<table class="grid">
<tr>
{{ if and .Data.City .Data.Country }}
<td>
<p class="label">Approximate Location</p>
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
</td>
{{ end }}
<td>
<p class="label">IP Address</p>
<p>{{ .Data.IPAddress }}</p>
</td>
</tr>
<tr>
<td>
<p class="label">Device</p>
<p>{{ .Data.Device }}</p>
</td>
<td>
<p class="label">Sign-In Time</p>
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
</td>
</tr>
</table>
<p class="message">
This sign-in was detected from a new device or location. If you recognize this activity, you can
safely ignore this message. If not, please review your account and security settings.
</p>
</div>
{{ end -}}

View File

@@ -1,15 +1,27 @@
{{ define "base" -}} {{define "root"}}{{.AppName}}
New Sign-In Detected
====================
{{ if and .Data.City .Data.Country }}
Approximate Location: {{ .Data.City }}, {{ .Data.Country }}
{{ end }}
IP Address: {{ .Data.IPAddress }}
Device: {{ .Data.Device }}
Time: {{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC"}}
This sign-in was detected from a new device or location. If you recognize NEW SIGN-IN DETECTED
this activity, you can safely ignore this message. If not, please review
your account and security settings. Warning
{{ end -}}
Your {{.AppName}} account was recently accessed from a new IP address or
browser. If you recognize this activity, no further action is required.
DETAILS
Approximate Location
{{.Data.City}}, {{.Data.Country}}
IP Address
{{.Data.IPAddress}}
Device
{{.Data.Device}}
Sign-In Time
{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}{{end}}

View File

@@ -1,17 +1,4 @@
{{ define "base" }} {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
<div class="header"> <img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">
<div class="logo"> Click the button below to sign in to <!-- -->{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span>
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/> <span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<h2>Login Code</h2>
<p class="message">
Click the button below to sign in to {{ .AppName }} with a login code.</br>Or visit <a href="{{ .Data.LoginLink }}">{{ .Data.LoginLink }}</a> and enter the code <strong>{{ .Data.Code }}</strong>.</br></br>This code expires in {{.Data.ExpirationString}}.
</p>
<div class="button-container">
<a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a>
</div>
</div>
{{ end -}}

View File

@@ -1,10 +1,12 @@
{{ define "base" -}} {{define "root"}}{{.AppName}}
Login Code
====================
Click the link below to sign in to {{ .AppName }} with a login code. This code expires in {{.Data.ExpirationString}}.
{{ .Data.LoginLinkWithCode }} YOUR LOGIN CODE
Or visit {{ .Data.LoginLink }} and enter the the code "{{ .Data.Code }}". Click the button below to sign in to {{.AppName}} with a login code.
{{ end -}} Or visit {{.Data.LoginLink}} {{.Data.LoginLink}} and enter the code
{{.Data.Code}}.
This code expires in {{.Data.ExpirationString}}.
Sign In {{.Data.LoginLinkWithCode}}{{end}}

View File

@@ -1,11 +1,3 @@
{{ define "base" -}} {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
<div class="header"> <img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">
<div class="logo"> Your email setup is working correctly!</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<p>This is a test email.</p>
</div>
{{ end -}}

View File

@@ -1,3 +1,6 @@
{{ define "base" -}} {{define "root"}}{{.AppName}}
This is a test email.
{{ end -}}
TEST EMAIL
Your email setup is working correctly!{{end}}

View File

@@ -4,5 +4,5 @@ import "embed"
// Embedded file systems for the project // Embedded file systems for the project
//go:embed email-templates images migrations fonts aaguids.json //go:embed email-templates/*.tmpl images migrations fonts aaguids.json
var FS embed.FS var FS embed.FS

115
email-templates/build.ts Normal file
View File

@@ -0,0 +1,115 @@
import { render } from "@react-email/components";
import * as fs from "node:fs";
import * as path from "node:path";
const outputDir = "../backend/resources/email-templates";
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
function getTemplateName(filename: string): string {
return filename.replace(".tsx", "");
}
/**
* Tag-aware wrapping:
* - Prefer breaking immediately after the last '>' within maxLen.
* - Never break at spaces.
* - If no '>' exists in the window, hard-break at maxLen.
*/
function tagAwareWrap(input: string, maxLen: number): string {
const out: string[] = [];
for (const originalLine of input.split(/\r?\n/)) {
let line = originalLine;
while (line.length > maxLen) {
let breakPos = line.lastIndexOf(">", maxLen);
// If '>' happens to be exactly at maxLen, break after it
if (breakPos === maxLen) breakPos = maxLen;
// If we found a '>' before the limit, break right after it
if (breakPos > -1 && breakPos < maxLen) {
out.push(line.slice(0, breakPos + 1));
line = line.slice(breakPos + 1);
continue;
}
// No suitable tag end found—hard break
out.push(line.slice(0, maxLen));
line = line.slice(maxLen);
}
out.push(line);
}
return out.join("\n");
}
async function buildTemplateFile(
Component: any,
templateName: string,
isPlainText: boolean
) {
const rendered = await render(Component(Component.TemplateProps), {
plainText: isPlainText,
});
// Normalize quotes
const normalized = rendered.replace(/&quot;/g, '"');
// Enforce line length: prefer tag boundaries, never spaces
const maxLen = isPlainText ? 78 : 998; // RFC-safe
const safe = tagAwareWrap(normalized, maxLen);
const goTemplate = `{{define "root"}}${safe}{{end}}`;
const suffix = isPlainText ? "_text.tmpl" : "_html.tmpl";
const templatePath = path.join(outputDir, `${templateName}${suffix}`);
fs.writeFileSync(templatePath, goTemplate);
}
async function discoverAndBuildTemplates() {
console.log("Discovering and building email templates...");
const emailsDir = "./emails";
const files = fs.readdirSync(emailsDir);
for (const file of files) {
if (!file.endsWith(".tsx")) continue;
const templateName = getTemplateName(file);
const modulePath = `./${emailsDir}/${file}`;
console.log(`Building ${templateName}...`);
try {
const module = await import(modulePath);
const Component = module.default || module[Object.keys(module)[0]];
if (!Component) {
console.error(`✗ No component found in ${file}`);
continue;
}
if (!Component.TemplateProps) {
console.error(`✗ No TemplateProps found in ${file}`);
continue;
}
await buildTemplateFile(Component, templateName, false); // HTML
await buildTemplateFile(Component, templateName, true); // Text
console.log(`✓ Built ${templateName}`);
} catch (error) {
console.error(`✗ Error building ${templateName}:`, error);
}
}
}
async function main() {
await discoverAndBuildTemplates();
console.log("All templates built successfully!");
}
main().catch(console.error);

View File

@@ -0,0 +1,87 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Row,
Section,
Text,
} from "@react-email/components";
interface BaseTemplateProps {
logoURL?: string;
appName: string;
children: React.ReactNode;
}
export const BaseTemplate = ({
logoURL,
appName,
children,
}: BaseTemplateProps) => {
const finalLogoURL =
logoURL ||
"https://private-user-images.githubusercontent.com/58886915/359183039-4ceb2708-9f29-4694-b797-be833efce17d.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NTY0NTk5MzksIm5iZiI6MTc1NjQ1OTYzOSwicGF0aCI6Ii81ODg4NjkxNS8zNTkxODMwMzktNGNlYjI3MDgtOWYyOS00Njk0LWI3OTctYmU4MzNlZmNlMTdkLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA4MjklMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwODI5VDA5MjcxOVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWM4ZWI5NzlkMDA5NDNmZGU5MjQwMGE1YjA0NWZiNzEzM2E0MzAzOTFmOWRmNDUzNmJmNjQwZTMxNGIzZmMyYmQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.YdfLv1tD5KYnRZPSA3QlR1SsvScpP0rt-J3YD6ZHsCk";
return (
<Html>
<Head />
<Body style={mainStyle}>
<Container style={{ width: "500px", margin: "0 auto" }}>
<Section>
<Row
align="left"
style={{
width: "210px",
marginBottom: "16px",
}}
>
<Column>
<Img
src={finalLogoURL}
width="32"
height="32"
alt={appName}
style={logoStyle}
/>
</Column>
<Column>
<Text style={titleStyle}>{appName}</Text>
</Column>
</Row>
</Section>
<div style={content}>{children}</div>
</Container>
</Body>
</Html>
);
};
const mainStyle = {
padding: "50px",
backgroundColor: "#FBFBFB",
fontFamily: "Arial, sans-serif",
};
const logoStyle = {
width: "32px",
height: "32px",
verticalAlign: "middle",
marginRight: "8px",
};
const titleStyle = {
fontSize: "23px",
fontWeight: "bold",
margin: "0",
padding: "0",
};
const content = {
backgroundColor: "white",
padding: "24px",
borderRadius: "10px",
boxShadow: "0 1px 4px 0px rgba(0, 0, 0, 0.1)",
};

View File

@@ -0,0 +1,33 @@
import { Button as EmailButton } from "@react-email/components";
interface ButtonProps {
href: string;
children: React.ReactNode;
style?: React.CSSProperties;
}
export const Button = ({ href, children, style = {} }: ButtonProps) => {
const buttonStyle = {
backgroundColor: "#000000",
color: "#ffffff",
padding: "12px 24px",
borderRadius: "4px",
fontSize: "15px",
fontWeight: "500",
cursor: "pointer",
marginTop: "10px",
...style,
};
return (
<div style={buttonContainer}>
<EmailButton style={buttonStyle} href={href}>
{children}
</EmailButton>
</div>
);
};
const buttonContainer = {
textAlign: "center" as const,
};

View File

@@ -0,0 +1,38 @@
import { Column, Heading, Row, Text } from "@react-email/components";
export default function CardHeader({
title,
warning,
}: {
title: string;
warning?: boolean;
}) {
return (
<Row>
<Column>
<Heading as="h1" style={titleStyle}>
{title}
</Heading>
</Column>
<Column align="right">
{warning && <Text style={warningStyle}>Warning</Text>}
</Column>
</Row>
);
}
const titleStyle = {
fontSize: "20px",
fontWeight: "bold" as const,
margin: 0,
};
const warningStyle = {
backgroundColor: "#ffd966",
color: "#7f6000",
padding: "1px 12px",
borderRadius: "50px",
fontSize: "12px",
display: "inline-block",
margin: 0,
};

View File

@@ -0,0 +1,55 @@
import { Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
interface ApiKeyExpiringData {
name: string;
apiKeyName: string;
expiresAt: string;
}
interface ApiKeyExpiringEmailProps {
logoURL: string;
appName: string;
data: ApiKeyExpiringData;
}
export const ApiKeyExpiringEmail = ({
logoURL,
appName,
data,
}: ApiKeyExpiringEmailProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="API Key Expiring Soon" warning />
<Text>
Hello {data.name}, <br />
This is a reminder that your API key <strong>
{data.apiKeyName}
</strong>{" "}
will expire on <strong>{data.expiresAt}</strong>.
</Text>
<Text>Please generate a new API key if you need continued access.</Text>
</BaseTemplate>
);
export default ApiKeyExpiringEmail;
ApiKeyExpiringEmail.TemplateProps = {
...sharedTemplateProps,
data: {
name: "{{.Data.Name}}",
apiKeyName: "{{.Data.APIKeyName}}",
expiresAt: '{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}',
},
};
ApiKeyExpiringEmail.PreviewProps = {
...sharedPreviewProps,
data: {
name: "Elias Schneider",
apiKeyName: "My API Key",
expiresAt: "September 30, 2024",
},
};

View File

@@ -0,0 +1,104 @@
import { Column, Heading, Row, Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
interface SignInData {
city?: string;
country?: string;
ipAddress: string;
device: string;
dateTime: string;
}
interface NewSignInEmailProps {
logoURL: string;
appName: string;
data: SignInData;
}
export const NewSignInEmail = ({
logoURL,
appName,
data,
}: NewSignInEmailProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="New Sign-In Detected" warning />
<Text>
Your {appName} account was recently accessed from a new IP address or
browser. If you recognize this activity, no further action is required.
</Text>
<Heading
style={{
fontSize: "1rem",
fontWeight: "bold",
margin: "30px 0 10px 0",
}}
as="h4"
>
Details
</Heading>
<Row>
<Column style={detailsBoxStyle}>
<Text style={detailsLabelStyle}>Approximate Location</Text>
<Text style={detailsBoxValueStyle}>
{data.city}, {data.country}
</Text>
</Column>
<Column style={detailsBoxStyle}>
<Text style={detailsLabelStyle}>IP Address</Text>
<Text style={detailsBoxValueStyle}>{data.ipAddress}</Text>
</Column>
</Row>
<Row style={{ marginTop: "10px" }}>
<Column style={detailsBoxStyle}>
<Text style={detailsLabelStyle}>Device</Text>
<Text style={detailsBoxValueStyle}>{data.device}</Text>
</Column>
<Column style={detailsBoxStyle}>
<Text style={detailsLabelStyle}>Sign-In Time</Text>
<Text style={detailsBoxValueStyle}>{data.dateTime}</Text>
</Column>
</Row>
</BaseTemplate>
);
export default NewSignInEmail;
const detailsBoxStyle = {
width: "225px",
};
const detailsLabelStyle = {
margin: 0,
fontSize: "12px",
color: "gray",
};
const detailsBoxValueStyle = {
margin: 0,
};
NewSignInEmail.TemplateProps = {
...sharedTemplateProps,
data: {
city: "{{.Data.City}}",
country: "{{.Data.Country}}",
ipAddress: "{{.Data.IPAddress}}",
device: "{{.Data.Device}}",
dateTime: '{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}',
},
};
NewSignInEmail.PreviewProps = {
...sharedPreviewProps,
data: {
city: "San Francisco",
country: "USA",
ipAddress: "127.0.0.1",
device: "Chrome on macOS",
dateTime: "2024-01-01 12:00 PM UTC",
},
};

View File

@@ -0,0 +1,71 @@
import { Link, Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import { Button } from "../components/button";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
interface OneTimeAccessData {
code: string;
loginLink: string;
buttonCodeLink: string;
expirationString: string;
}
interface OneTimeAccessEmailProps {
logoURL: string;
appName: string;
data: OneTimeAccessData;
}
export const OneTimeAccessEmail = ({
logoURL,
appName,
data,
}: OneTimeAccessEmailProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="Your Login Code" />
<Text>
Click the button below to sign in to {appName} with a login code.
<br />
Or visit{" "}
<Link href={data.loginLink} style={linkStyle}>
{data.loginLink}
</Link>{" "}
and enter the code <strong>{data.code}</strong>.
<br />
<br />
This code expires in {data.expirationString}.
</Text>
<Button href={data.buttonCodeLink}>Sign In</Button>
</BaseTemplate>
);
export default OneTimeAccessEmail;
const linkStyle = {
color: "#000",
textDecoration: "underline",
fontFamily: "Arial, sans-serif",
};
OneTimeAccessEmail.TemplateProps = {
...sharedTemplateProps,
data: {
code: "{{.Data.Code}}",
loginLink: "{{.Data.LoginLink}}",
buttonCodeLink: "{{.Data.LoginLinkWithCode}}",
expirationString: "{{.Data.ExpirationString}}",
},
};
OneTimeAccessEmail.PreviewProps = {
...sharedPreviewProps,
data: {
code: "123456",
loginLink: "https://example.com/login",
buttonCodeLink: "https://example.com/login?code=123456",
expirationString: "15 minutes",
},
};

View File

@@ -0,0 +1,26 @@
import { Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
interface TestEmailProps {
logoURL: string;
appName: string;
}
export const TestEmail = ({ logoURL, appName }: TestEmailProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="Test Email" />
<Text>Your email setup is working correctly!</Text>
</BaseTemplate>
);
export default TestEmail;
TestEmail.TemplateProps = {
...sharedTemplateProps,
};
TestEmail.PreviewProps = {
...sharedPreviewProps,
};

View File

@@ -0,0 +1,25 @@
{
"name": "pocketid-email-templates",
"version": "1.0.0",
"scripts": {
"preinstall": "npx only-allow pnpm",
"build": "tsx build.ts",
"build:watch": "tsx watch build.ts",
"dev": "email dev --port 3030",
"export": "email export"
},
"dependencies": {
"@react-email/components": "0.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@react-email/preview-server": "4.2.8",
"@types/node": "^24.0.10",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"react-email": "4.2.8",
"tsx": "^4.0.0"
}
}

9
email-templates/props.ts Normal file
View File

@@ -0,0 +1,9 @@
export const sharedPreviewProps = {
logoURL: "https://pocket-id.org/img/logo.png",
appName: "Pocket ID",
};
export const sharedTemplateProps = {
logoURL: "{{.LogoURL}}",
appName: "{{.AppName}}",
};

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["node"]
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@@ -11,5 +11,6 @@
"build": "pnpm --filter pocket-id-frontend build", "build": "pnpm --filter pocket-id-frontend build",
"test": "pnpm --filter pocket-id-tests test", "test": "pnpm --filter pocket-id-tests test",
"format": "pnpm --filter pocket-id-frontend format" "format": "pnpm --filter pocket-id-frontend format"
} },
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b"
} }

4445
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
packages: packages:
- 'frontend' - 'frontend'
- 'tests' - 'tests'
- 'email-templates'
overrides: overrides:
'devalue': '^5.3.2' 'devalue': '^5.3.2'