Initial work on email templates

At this point I got a nice-looking formatted message in my mail client.
This commit is contained in:
Eli Ribble 2026-01-19 17:58:30 +00:00
parent 087f29d491
commit 2c880568dd
No known key found for this signature in database
5 changed files with 298 additions and 12 deletions

View file

@ -2,6 +2,7 @@ package comms
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io"
@ -11,12 +12,46 @@ import (
"github.com/rs/zerolog/log"
)
type AttachmentRequest struct {
func SendEmailReportConfirmation(to string, report_id string) error {
content := contentEmailReportConfirmation{
URLLogo: "https://dev-sync.nidus.cloud/static/img/nidus-logo-no-lettering-64.png",
URLReportStatus: fmt.Sprintf("https://dev-sync.nidus.cloud/report/%s", report_id),
URLReportUnsubscribe: fmt.Sprintf("https://dev-sync.nidus.cloud/report/%s/unsubscribe", report_id),
URLViewInBrowser: fmt.Sprintf("https://dev-sync.nidus.cloud/report/%s/subscribe-confirmation", report_id),
}
text, html, err := renderEmailTemplates(reportConfirmationT, content)
if err != nil {
return fmt.Errorf("Failed to render template %s: %w", reportConfirmationT.name, err)
}
return sendEmail(emailRequest{
From: config.ForwardEmailReportAddress,
HTML: html,
Subject: fmt.Sprintf("Mosquito Report %s Submission", report_id),
Text: text,
To: to,
})
}
var (
reportConfirmationT = buildTemplate("report-subscription-confirmation")
)
//go:embed template/*
var embeddedFiles embed.FS
type attachmentRequest struct {
Filename string `json:"filename"`
Content string `json:"content"`
}
type EmailRequest struct {
type contentEmailReportConfirmation struct {
URLLogo string
URLReportStatus string
URLReportUnsubscribe string
URLViewInBrowser string
}
type emailRequest struct {
From string `json:"from"`
To string `json:"to"`
CC []string `json:"cc,omitempty"`
@ -24,18 +59,18 @@ type EmailRequest struct {
Subject string `json:"subject"`
Text string `json:"text"`
HTML string `json:"html,omitempty"`
Attachments []AttachmentRequest `json:"attachments,omitempty"`
Attachments []attachmentRequest `json:"attachments,omitempty"`
Sender string `json:"sender"`
ReplyTo string `json:"replyTo,omitempty"`
InReplyTo string `json:"inReplyTo,omitempty"`
References []string `json:"references,omitempty"`
}
type EmailResponse struct {
type emailResponse struct {
Message string `json:"message"`
}
func SendEmail(email EmailRequest) error {
func sendEmail(email emailRequest) error {
url := "https://api.forwardemail.net/v1/emails"
payload, err := json.Marshal(email)

146
comms/template.go Normal file
View file

@ -0,0 +1,146 @@
package comms
import (
"bytes"
"embed"
"errors"
"fmt"
templatehtml "html/template"
"io"
"os"
"path"
"strings"
templatetxt "text/template"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/rs/zerolog/log"
)
type builtTemplate struct {
name string
templateHTML *templatehtml.Template
templateTXT *templatetxt.Template
}
func (bt *builtTemplate) executeTemplateHTML(w io.Writer, content any) error {
if bt.templateHTML == nil {
file := templateFileHTML(bt.name)
templ, err := parseFromDiskHTML(file)
if err != nil {
return fmt.Errorf("Failed to parse template file: %w", err)
}
if templ == nil {
w.Write([]byte("Failed to read from disk: "))
return errors.New("Template parsing failed")
}
//log.Debug().Str("name", templ.Name()).Msg("Parsed template")
return templ.ExecuteTemplate(w, bt.name+".html", content)
} else {
return bt.templateHTML.ExecuteTemplate(w, bt.name+".html", content)
}
}
func (bt *builtTemplate) executeTemplateTXT(w io.Writer, content any) error {
if bt.templateTXT == nil {
file := templateFileTXT(bt.name)
templ, err := parseFromDiskTXT(file)
if err != nil {
return fmt.Errorf("Failed to parse template file: %w", err)
}
if templ == nil {
w.Write([]byte("Failed to read from disk: "))
return errors.New("Template parsing failed")
}
//log.Debug().Str("name", templ.Name()).Msg("Parsed template")
return templ.ExecuteTemplate(w, bt.name+".txt", content)
} else {
return bt.templateTXT.ExecuteTemplate(w, bt.name+".txt", content)
}
}
func templateFileHTML(name string) string {
return fmt.Sprintf("comms/template/%s.html", name)
}
func templateFileTXT(name string) string {
return fmt.Sprintf("comms/template/%s.txt", name)
}
func buildTemplate(name string) *builtTemplate {
files_on_disk := true
file_html := templateFileHTML(name)
file_txt := templateFileTXT(name)
for _, f := range []string{file_html, file_txt} {
_, err := os.Stat(f)
if err != nil {
files_on_disk = false
if !config.IsProductionEnvironment() {
log.Warn().Str("file", f).Msg("email template file is not on disk")
}
break
}
}
var result builtTemplate
if files_on_disk {
result = builtTemplate{
name: name,
templateHTML: nil,
templateTXT: nil,
}
} else {
result = builtTemplate{
name: name,
templateHTML: parseEmbeddedHTML(embeddedFiles, "comms", file_html),
templateTXT: parseEmbeddedTXT(embeddedFiles, "comms", file_txt),
}
}
return &result
}
func parseEmbeddedHTML(embeddedFiles embed.FS, subdir string, file string) *templatehtml.Template {
// Remap the file names to embedded paths
embeddedFilePaths := []string{strings.TrimPrefix(file, subdir)}
name := path.Base(embeddedFilePaths[0])
log.Debug().Str("name", name).Strs("paths", embeddedFilePaths).Msg("Parsing embedded template")
return templatehtml.Must(
templatehtml.New(name).ParseFS(embeddedFiles, embeddedFilePaths...))
}
func parseEmbeddedTXT(embeddedFiles embed.FS, subdir string, file string) *templatetxt.Template {
// Remap the file names to embedded paths
embeddedFilePaths := []string{strings.TrimPrefix(file, subdir)}
name := path.Base(embeddedFilePaths[0])
log.Debug().Str("name", name).Strs("paths", embeddedFilePaths).Msg("Parsing embedded template")
return templatetxt.Must(
templatetxt.New(name).ParseFS(embeddedFiles, embeddedFilePaths...))
}
func parseFromDiskHTML(file string) (*templatehtml.Template, error) {
name := path.Base(file)
//log.Debug().Str("name", name).Strs("files", files).Msg("parsing from disk")
templ, err := templatehtml.New(name).ParseFiles(file)
if err != nil {
return nil, fmt.Errorf("Failed to parse %s: %w", file, err)
}
return templ, nil
}
func parseFromDiskTXT(file string) (*templatetxt.Template, error) {
name := path.Base(file)
//log.Debug().Str("name", name).Strs("files", files).Msg("parsing from disk")
templ, err := templatetxt.New(name).ParseFiles(file)
if err != nil {
return nil, fmt.Errorf("Failed to parse %s: %w", file, err)
}
return templ, nil
}
func renderEmailTemplates(t *builtTemplate, content interface{}) (text string, html string, err error) {
buf_txt := &bytes.Buffer{}
err = t.executeTemplateTXT(buf_txt, content)
if err != nil {
return "", "", fmt.Errorf("Failed to render TXT template: %w", err)
}
buf_html := &bytes.Buffer{}
err = t.executeTemplateHTML(buf_html, content)
if err != nil {
return "", "", fmt.Errorf("Failed to render HTML template: %w", err)
}
return buf_txt.String(), buf_html.String(), nil
}

View file

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Thank You for Your Mosquito Report</title>
<style type="text/css">
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: #333333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.view-browser {
text-align: center;
font-size: 12px;
color: #777777;
padding-bottom: 15px;
}
.view-browser a {
color: #555555;
text-decoration: underline;
}
.header {
text-align: center;
padding: 20px 0;
}
.logo {
max-width: 150px;
height: auto;
}
.content {
background-color: #f9f9f9;
padding: 30px;
border-radius: 5px;
}
.button {
display: inline-block;
background-color: #0066cc;
color: #ffffff;
text-decoration: none;
padding: 12px 25px;
border-radius: 4px;
margin: 20px 0;
font-weight: bold;
}
.footer {
text-align: center;
font-size: 14px;
color: #777777;
padding: 20px 0;
}
.footer a {
color: #777777;
}
</style>
</head>
<body>
<div class="container">
<div class="view-browser">
Email not displaying correctly? <a href="{{.URLViewInBrowser}}">View it in your browser</a>
</div>
<div class="header">
<!-- Logo Placeholder -->
<img src="{{.URLLogo}}" alt="Report Mosquitoes Online Logo" class="logo">
</div>
<div class="content">
<h1>Thank You for Your Report</h1>
<p>We've received your mosquito report. Thanks! We appreciate you taking the time to submit it.</p>
<p>You can check the current status of your report at any time by clicking the button below:</p>
<div style="text-align: center;">
<a href="{{.URLReportStatus}}" class="button">View Report Status</a>
</div>
<p>We'll send you additional updates as work is scheduled and completed.</p>
<p>If you have any questions or need further assistance, please don't hesitate to contact us by replying to this email.</p>
</div>
<div class="footer">
<p>This email was sent to you because you requested updates on your mosquito nuisance report.</p>
<p>If you no longer wish to receive these updates, <a href="{{.URLReportUnsubscribe}}">click here to unsubscribe</a>.</p>
<p>&copy; 2026 Gleipnir Technology. All rights reserved.</p>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,9 @@
We've received your mosquito report. Thanks! We appreciate you taking the time to submit it.
You can check the current status of your report at any time at {{.URLReportStatus}}
We'll send you additional updates as work is scheduled and completed.
If you have any questions or need further assistance, please don't hesitate to contact us by replying to this email.
If you no longer wish to receive these updates, navigate your browser to {{.URLReportUnsubscribe}} to unsubscribe.