mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-04 13:21: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:
2
.github/workflows/build-next.yml
vendored
2
.github/workflows/build-next.yml
vendored
@@ -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
|
||||||
|
|||||||
16
.github/workflows/e2e-tests.yml
vendored
16
.github/workflows/e2e-tests.yml
vendored
@@ -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
|
||||||
|
|||||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
2
.github/workflows/svelte-check.yml
vendored
2
.github/workflows/svelte-check.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
|
||||||
@@ -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}}
|
||||||
@@ -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 }}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{{- define "root" -}}
|
|
||||||
{{- template "base" . -}}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
This is automatically sent email from {{.AppName}}.
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -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 -}}
|
|
||||||
@@ -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}}
|
||||||
@@ -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>   </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>   ​</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 -}}
|
|
||||||
@@ -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}}
|
||||||
@@ -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 -}}
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
{{ define "base" -}}
|
{{define "root"}}{{.AppName}}
|
||||||
This is a test email.
|
|
||||||
{{ end -}}
|
|
||||||
|
TEST EMAIL
|
||||||
|
|
||||||
|
Your email setup is working correctly!{{end}}
|
||||||
@@ -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
115
email-templates/build.ts
Normal 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(/"/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);
|
||||||
87
email-templates/components/base-template.tsx
Normal file
87
email-templates/components/base-template.tsx
Normal 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)",
|
||||||
|
};
|
||||||
33
email-templates/components/button.tsx
Normal file
33
email-templates/components/button.tsx
Normal 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,
|
||||||
|
};
|
||||||
38
email-templates/components/card-header.tsx
Normal file
38
email-templates/components/card-header.tsx
Normal 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,
|
||||||
|
};
|
||||||
55
email-templates/emails/api-key-expiring-soon.tsx
Normal file
55
email-templates/emails/api-key-expiring-soon.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
104
email-templates/emails/login-with-new-device.tsx
Normal file
104
email-templates/emails/login-with-new-device.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
71
email-templates/emails/one-time-access.tsx
Normal file
71
email-templates/emails/one-time-access.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
26
email-templates/emails/test.tsx
Normal file
26
email-templates/emails/test.tsx
Normal 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,
|
||||||
|
};
|
||||||
25
email-templates/package.json
Normal file
25
email-templates/package.json
Normal 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
9
email-templates/props.ts
Normal 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}}",
|
||||||
|
};
|
||||||
25
email-templates/tsconfig.json
Normal file
25
email-templates/tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
4445
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
packages:
|
packages:
|
||||||
- 'frontend'
|
- 'frontend'
|
||||||
- 'tests'
|
- 'tests'
|
||||||
|
- 'email-templates'
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
'devalue': '^5.3.2'
|
'devalue': '^5.3.2'
|
||||||
|
|||||||
Reference in New Issue
Block a user