Move emails to platform, make sure to create phone and email in DB

This commit is contained in:
Eli Ribble 2026-02-10 04:07:59 +00:00
parent dd33c6ab5e
commit 648e0ee567
No known key found for this signature in database
17 changed files with 203 additions and 281 deletions

View file

@ -1,129 +0,0 @@
package email
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"sort"
"strings"
"time"
"github.com/Gleipnir-Technology/bob/types/pgtypes"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/aarondl/opt/omit"
"github.com/aarondl/opt/omitnull"
"github.com/rs/zerolog/log"
)
func convertToPGData(data map[string]string) pgtypes.HStore {
result := pgtypes.HStore{}
for k, v := range data {
result[k] = sql.Null[string]{V: v, Valid: true}
}
return result
}
func convertFromPGData(d pgtypes.HStore) map[string]string {
result := make(map[string]string, 0)
for k, v := range d {
value, err := v.Value()
if err != nil {
log.Warn().Err(err).Str("key", k).Msg("Failed to convert from HSTORE")
continue
}
value_str, ok := value.(string)
if !ok {
log.Warn().Msg("Failed to convert to string")
}
result[k] = value_str
}
return result
}
func ensureInDB(ctx context.Context, destination string) (err error) {
_, err = models.FindCommsEmailContact(ctx, db.PGInstance.BobDB, destination)
if err != nil {
// doesn't exist
if err.Error() == "sql: no rows in result set" {
public_id := fmt.Sprintf("%x", sha256.Sum256([]byte(destination)))
_, err = models.CommsEmailContacts.Insert(&models.CommsEmailContactSetter{
Address: omit.From(destination),
Confirmed: omit.From(false),
IsSubscribed: omit.From(false),
PublicID: omit.From(public_id),
}).One(ctx, db.PGInstance.BobDB)
if err != nil {
return fmt.Errorf("Failed to insert new email: %w", err)
}
log.Info().Str("email", destination).Msg("Added email to the comms database")
return nil
}
return fmt.Errorf("Unexpected error searching for contact: %w", err)
}
return nil
}
func insertEmailLog(ctx context.Context, data map[string]string, destination string, public_id string, source string, subject string, template_id int32) (err error) {
data_for_insert := convertToPGData(data)
var type_ enums.CommsMessagetypeemail
switch template_id {
case templateReportNotificationConfirmationID:
type_ = enums.CommsMessagetypeemailReportNotificationConfirmation
case templateInitialID:
type_ = enums.CommsMessagetypeemailInitialContact
default:
return fmt.Errorf("Unrecognized template ID %d", template_id)
}
_, err = models.CommsEmailLogs.Insert(&models.CommsEmailLogSetter{
//ID:
Created: omit.From(time.Now()),
DeliveryStatus: omit.From("initial"),
Destination: omit.From(destination),
PublicID: omit.From(public_id),
SentAt: omitnull.FromPtr[time.Time](nil),
Source: omit.From(source),
Subject: omit.From(subject),
TemplateID: omit.From(template_id),
TemplateData: omit.From(data_for_insert),
Type: omit.From(type_),
}).One(ctx, db.PGInstance.BobDB)
return err
}
func generatePublicId(t enums.CommsMessagetypeemail, m map[string]string) string {
if m == nil || len(m) == 0 {
// Return hash of empty string for empty maps
emptyHash := sha256.Sum256([]byte(""))
return hex.EncodeToString(emptyHash[:])
}
// Get and sort keys for deterministic ordering
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
// Build a string with all key-value pairs
var sb strings.Builder
// Add type first
sb.WriteString(fmt.Sprintf("type:%s,", t))
for _, k := range keys {
sb.WriteString(k)
sb.WriteString(":") // Separator between key and value
sb.WriteString(m[k])
sb.WriteString(",") // Separator between pairs
}
// Compute SHA-256 hash
hasher := sha256.New()
hasher.Write([]byte(sb.String()))
hashBytes := hasher.Sum(nil)
// Convert to hex string and return
return hex.EncodeToString(hashBytes)
}

View file

@ -18,23 +18,7 @@ type attachmentRequest struct {
Content string `json:"content"`
}
type contentEmailBase struct {
URLLogo string
URLUnsubscribe string
URLViewInBrowser string
}
type contentEmailReportConfirmation struct {
Base contentEmailBase
URLReportStatus string
}
type contentEmailInitial struct {
Base contentEmailBase
Destination string
URLSubscribe string
}
type emailRequest struct {
type Request struct {
From string `json:"from"`
To string `json:"to"`
CC []string `json:"cc,omitempty"`
@ -83,7 +67,7 @@ type emailResponse struct {
var FORWARDEMAIL_API = "https://api.forwardemail.net/v1/emails"
func sendEmail(ctx context.Context, email emailRequest, t enums.CommsMessagetypeemail) (response emailResponse, err error) {
func Send(ctx context.Context, email Request, t enums.CommsMessagetypeemail) (response emailResponse, err error) {
payload, err := json.Marshal(email)
if err != nil {
return response, fmt.Errorf("Failed to marshal email request: %w", err)

View file

@ -1,81 +0,0 @@
package email
import (
"context"
"fmt"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/rs/zerolog/log"
)
type jobInitial struct {
base jobEmailBase
}
func (job jobInitial) Destination() string {
return job.base.destination
}
func maybeSendInitialEmail(ctx context.Context, destination string) error {
err := ensureInDB(ctx, destination)
if err != nil {
return fmt.Errorf("Failed to add email recipient to database: %w", err)
}
rows, err := models.CommsEmailLogs.Query(
models.SelectWhere.CommsEmailLogs.Destination.EQ(destination),
models.SelectWhere.CommsEmailLogs.TemplateID.EQ(templateInitialID),
).All(ctx, db.PGInstance.BobDB)
// We already sent an initial email
if len(rows) > 0 {
return nil
}
return sendEmailInitialContact(ctx, destination)
}
func urlEmailInBrowser(public_id string) string {
return config.MakeURLReport("/email/render/%s", public_id)
}
func urlUnsubscribe(email string) string {
return config.MakeURLReport("/email/unsubscribe?email=%s", email)
}
func sendEmailInitialContact(ctx context.Context, destination string) error {
//data := pgtypes.HStore{}
data := make(map[string]string, 0)
source := config.ForwardEmailReportAddress
data["Destination"] = destination
data["Source"] = source
data["URLLogo"] = config.MakeURLReport("/static/img/nidus-logo-no-lettering-64.png")
data["URLSubscribe"] = config.MakeURLReport("/email/confirm?email=%s", destination)
data["URLUnsubscribe"] = urlUnsubscribe(destination)
public_id := generatePublicId(enums.CommsMessagetypeemailInitialContact, data)
data["URLBrowser"] = urlEmailInBrowser(public_id)
text, html, err := renderEmailTemplates(templateInitialID, data)
if err != nil {
return fmt.Errorf("Failed to render email temlates: %w", err)
}
subject := "Welcome"
err = insertEmailLog(ctx, data, destination, public_id, source, subject, templateInitialID)
if err != nil {
return fmt.Errorf("Failed to store email log: %w", err)
}
resp, err := sendEmail(ctx, emailRequest{
From: source,
HTML: html,
Subject: subject,
Text: text,
To: destination,
}, enums.CommsMessagetypeemailInitialContact)
if err != nil {
return fmt.Errorf("Failed to send email to %s: %w", err)
}
log.Info().Str("id", resp.ID).Str("to", destination).Msg("Sent initial contact email")
return nil
}

View file

@ -1,41 +0,0 @@
package email
import (
"context"
"errors"
"fmt"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/rs/zerolog/log"
)
type Job interface {
destination() string
messageType() enums.CommsMessagetypeemail
renderHTML() (string, error)
renderTXT() (string, error)
subject() string
}
type jobEmailBase struct {
destination string
source string
}
func Handle(ctx context.Context, job Job) error {
var err error
log.Debug().Str("dest", job.destination()).Str("type", string(job.messageType())).Msg("Handling email job")
switch job.messageType() {
case enums.CommsMessagetypeemailReportSubscriptionConfirmation:
return errors.New("ReportSubscription has been deprecated.")
case enums.CommsMessagetypeemailReportNotificationConfirmation:
err = sendEmailReportConfirmation(ctx, job)
default:
return errors.New("not implemented")
}
if err != nil {
log.Error().Err(err).Str("dest", job.destination()).Str("type", string(job.messageType())).Msg("Error processing email")
return fmt.Errorf("Failed to handle email: %w", err)
}
return nil
}

View file

@ -1,88 +0,0 @@
package email
import (
"context"
"fmt"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/rs/zerolog/log"
)
func NewJobReportNotificationConfirmation(destination, report_id string) Job {
return jobEmailReportNotificationConfirmation{
dest: destination,
reportID: report_id,
}
}
type jobEmailReportNotificationConfirmation struct {
dest string
reportID string
}
func (job jobEmailReportNotificationConfirmation) destination() string {
return job.dest
}
func (job jobEmailReportNotificationConfirmation) messageType() enums.CommsMessagetypeemail {
return enums.CommsMessagetypeemailReportNotificationConfirmation
}
func (job jobEmailReportNotificationConfirmation) renderHTML() (string, error) {
_ = newContentEmailNotificationConfirmation(job)
return "", nil
}
func (job jobEmailReportNotificationConfirmation) renderTXT() (string, error) {
return "fake txt", nil
}
func (job jobEmailReportNotificationConfirmation) subject() string {
return ""
}
func sendEmailReportConfirmation(ctx context.Context, job Job) error {
j, ok := job.(jobEmailReportNotificationConfirmation)
if !ok {
return fmt.Errorf("job is not for report subscription confirmation")
}
err := maybeSendInitialEmail(ctx, j.destination())
if err != nil {
return fmt.Errorf("Failed to handle initial email: %w", err)
}
data := make(map[string]string, 0)
data["report_id"] = j.reportID
report_id_str := publicReportID(j.reportID)
data["ReportIDStr"] = report_id_str
data["URLLogo"] = config.MakeURLReport("/static/img/nidus-logo-no-lettering-64.png")
data["URLReportStatus"] = config.MakeURLReport("/status/%s", j.reportID)
data["URLReportUnsubscribe"] = config.MakeURLReport("/email/unsubscribe/report/%s", j.reportID)
data["URLUnsubscribe"] = urlUnsubscribe(j.destination())
public_id := generatePublicId(enums.CommsMessagetypeemailReportNotificationConfirmation, data)
data["URLViewInBrowser"] = urlEmailInBrowser(public_id)
text, html, err := renderEmailTemplates(templateReportNotificationConfirmationID, data)
if err != nil {
return fmt.Errorf("Failed to render email report notification template: %w", err)
}
subject := fmt.Sprintf("Mosquito Report Submission - %s", report_id_str)
err = insertEmailLog(ctx, data, j.destination(), public_id, config.ForwardEmailReportAddress, subject, templateReportNotificationConfirmationID)
if err != nil {
return fmt.Errorf("Failed to store email log: %w", err)
}
resp, err := sendEmail(ctx, emailRequest{
From: config.ForwardEmailReportAddress,
HTML: html,
Subject: subject,
Text: text,
To: j.destination(),
}, enums.CommsMessagetypeemailReportNotificationConfirmation)
if err != nil {
return fmt.Errorf("Failed to send email report confirmation to %s for report %s: %w", j.dest, j.reportID, err)
}
log.Info().Str("id", resp.ID).Str("dest", j.dest).Str("report_id", j.reportID).Msg("Sent report confirmation email")
return nil
}
func newContentEmailNotificationConfirmation(job jobEmailReportNotificationConfirmation) (result contentEmailReportConfirmation) {
result.URLReportStatus = config.MakeURLReport("/status/%s", job.reportID)
return result
}

View file

@ -1,352 +0,0 @@
package email
import (
"bytes"
"context"
"crypto/sha256"
"embed"
"errors"
"fmt"
templatehtml "html/template"
"io"
"io/fs"
"path"
"path/filepath"
"strings"
templatetxt "text/template"
"time"
"github.com/Gleipnir-Technology/bob"
"github.com/Gleipnir-Technology/bob/dialect/psql"
"github.com/Gleipnir-Technology/bob/dialect/psql/um"
"github.com/Gleipnir-Technology/bob/types/pgtypes"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/aarondl/opt/omit"
"github.com/aarondl/opt/omitnull"
"github.com/rs/zerolog/log"
)
//go:embed template/*
var embeddedFiles embed.FS
var (
templateByID map[int32]*builtTemplate
templateInitialID int32
templateReportNotificationConfirmationID int32
)
type ContentEmailRender struct {
IsBrowser bool
C any
}
type templatePair struct {
baseName string
messageType enums.CommsMessagetypeemail
htmlContent string
txtContent string
htmlHash string
txtHash string
}
func LoadTemplates() error {
all_templates, err := readTemplates(embeddedFiles)
if err != nil {
return fmt.Errorf("Failed to read templates: %w", err)
}
ctx := context.TODO()
tx, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("Failed to start transaction: %w", err)
}
defer tx.Rollback(ctx)
templateByID = make(map[int32]*builtTemplate, 0)
for name, p := range all_templates {
template_id, err := templateDBID(tx, name, p)
if err != nil {
return fmt.Errorf("Failed to add '%s' to DB: %w", name, err)
}
template_html, err := templatehtml.New(name).Parse(p.htmlContent)
if err != nil {
return fmt.Errorf("Failed to parse HTML portion of '%s': %w", name, err)
}
template_txt, err := templatetxt.New(name).Parse(p.txtContent)
if err != nil {
return fmt.Errorf("Failed to parse HTML portion of '%s': %w", name, err)
}
built := builtTemplate{
name: name,
templateHTML: template_html,
templateTXT: template_txt,
}
templateByID[template_id] = &built
log.Info().Int32("id", template_id).Str("name", name).Msg("Added template to cache")
}
templateInitialID, err = loadTemplateID(ctx, tx, enums.CommsMessagetypeemailInitialContact)
if err != nil {
return fmt.Errorf("Failed to load template ID: %s", err)
}
templateReportNotificationConfirmationID, err = loadTemplateID(ctx, tx, enums.CommsMessagetypeemailReportNotificationConfirmation)
if err != nil {
return fmt.Errorf("Failed to load report-notification-confirmation template ID: %s", err)
}
tx.Commit(ctx)
return nil
}
func RenderHTML(template_id int32, s pgtypes.HStore) (html []byte, err error) {
data := convertFromPGData(s)
t, ok := templateByID[template_id]
if !ok {
return []byte{}, fmt.Errorf("Failed to lookup template %d", template_id)
}
buf_html := &bytes.Buffer{}
content := ContentEmailRender{
C: data,
IsBrowser: true,
}
err = t.executeTemplateHTML(buf_html, content)
if err != nil {
return []byte{}, fmt.Errorf("Failed to render HTML template: %w", err)
}
return buf_html.Bytes(), nil
}
func loadTemplateID(ctx context.Context, tx bob.Tx, t enums.CommsMessagetypeemail) (int32, error) {
templates, err := models.CommsEmailTemplates.Query(
models.SelectWhere.CommsEmailTemplates.MessageType.EQ(t),
models.SelectWhere.CommsEmailTemplates.Superceded.IsNull(),
).All(ctx, tx)
if err != nil {
return 0, fmt.Errorf("Failed to query template '%s': %w", t, err)
}
switch len(templates) {
case 0:
return 0, fmt.Errorf("No matching templates for '%s", t)
case 1:
return templates[0].ID, nil
default:
return 0, fmt.Errorf("Found %d templates for '%s', should only have 1", len(templates), t)
}
}
func readTemplates(filesystem embed.FS) (results map[string]*templatePair, err error) {
// First pass: read files and organize by base name
results = make(map[string]*templatePair)
err = fs.WalkDir(filesystem, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// Read file content
content, err := filesystem.ReadFile(path)
if err != nil {
return fmt.Errorf("error reading template %s: %w", path, err)
}
// Calculate hash
hash := fmt.Sprintf("%x", sha256.Sum256(content))
// Extract base name and extension
ext := strings.ToLower(filepath.Ext(path))
baseName := strings.TrimSuffix(filepath.Base(path), ext)
// Store in map by base name
if _, exists := results[baseName]; !exists {
t, err := messageTypeFromName(baseName)
if err != nil {
return fmt.Errorf("Cannot parse email templates: %w", err)
}
results[baseName] = &templatePair{
baseName: baseName,
messageType: *t,
}
}
// Add content based on extension
switch ext {
case ".html", ".htm":
results[baseName].htmlContent = string(content)
results[baseName].htmlHash = hash
case ".txt":
results[baseName].txtContent = string(content)
results[baseName].txtHash = hash
}
return nil
})
if err != nil {
return results, fmt.Errorf("error walking template directory: %w", err)
}
return results, nil
}
func templateDBID(tx bob.Tx, name string, pair *templatePair) (int32, error) {
ctx := context.Background()
// Skip incomplete pairs
if pair.htmlContent == "" {
return 0, fmt.Errorf("Bad template pair '%s': no html content")
}
if pair.txtContent == "" {
return 0, fmt.Errorf("Bad template pair '%s': no txt content")
}
// Check if a template with these hashes already exists
rows, err := models.CommsEmailTemplates.Query(
models.SelectWhere.CommsEmailTemplates.ContentHashHTML.EQ(pair.htmlHash),
models.SelectWhere.CommsEmailTemplates.ContentHashTXT.EQ(pair.txtHash),
models.SelectWhere.CommsEmailTemplates.MessageType.EQ(pair.messageType),
).All(ctx, tx)
if err != nil {
return 0, fmt.Errorf("Failed to query for existing template: %w", err)
}
if len(rows) > 1 {
return 0, fmt.Errorf("Got %d template rows, should only have 1", len(rows))
} else if len(rows) == 1 {
return rows[0].ID, nil
}
// Supercede previous templates of this type
_, err = psql.Update(
um.Table(models.CommsEmailTemplates.Alias()),
um.SetCol("superceded").ToArg(time.Now()),
//um.Where(models.CommsEmailTemplates.Columns.MessageType.EQ(psql.Arg(pair.messageType))),
um.Where(psql.Quote("message_type").EQ(psql.Arg(pair.messageType))),
//um.Where(models.CommsEmailTemplates.Columns.Superceded.IsNull()),
um.Where(psql.Quote("superceded").IsNull()),
).Exec(ctx, tx)
if err != nil {
return 0, fmt.Errorf("error superceding templates: %w", err)
}
new_template, err := models.CommsEmailTemplates.Insert(&models.CommsEmailTemplateSetter{
ContentHTML: omit.From(pair.htmlContent),
ContentTXT: omit.From(pair.txtContent),
ContentHashHTML: omit.From(pair.htmlHash),
ContentHashTXT: omit.From(pair.txtHash),
Created: omit.From(time.Now()),
Superceded: omitnull.FromPtr[time.Time](nil),
MessageType: omit.From(pair.messageType),
}).One(ctx, tx)
if err != nil {
return 0, fmt.Errorf("Failed to insert new template: %w", err)
}
log.Info().Int32("id", new_template.ID).Str("type", string(pair.messageType)).Msg("Added new email template")
return new_template.ID, nil
}
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, content)
} else {
return bt.templateHTML.ExecuteTemplate(w, bt.name, 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, content)
} else {
return bt.templateTXT.ExecuteTemplate(w, bt.name, 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 messageTypeFromName(n string) (*enums.CommsMessagetypeemail, error) {
for _, t := range enums.AllCommsMessagetypeemail() {
if n == string(t) {
return &t, nil
}
}
return nil, fmt.Errorf("Unrecognized email type '%s'", n)
}
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 publicReportID(s string) string {
if len(s) != 12 {
return s
}
return s[0:4] + "-" + s[4:8] + "-" + s[8:12]
}
func renderEmailTemplates(template_id int32, data map[string]string) (text string, html string, err error) {
buf_txt := &bytes.Buffer{}
t, ok := templateByID[template_id]
if !ok {
return "", "", fmt.Errorf("Failed to lookup template %d", template_id)
}
content := ContentEmailRender{
C: data,
IsBrowser: false,
}
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

@ -1,124 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Welcome</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">
{{ if not .IsBrowser }}
<div class="view-browser">
Email not displaying correctly?
<a href="{{ .C.URLBrowser }}">View it in your browser</a>
</div>
{{ end }}
<div class="header">
<!-- Logo Placeholder -->
<img
src="{{ .C.URLLogo }}"
alt="Report Mosquitoes Online Logo"
class="logo"
/>
</div>
<div class="content">
<h1>Welcome</h1>
<p>
We're sending you this email because it's the first time we've gotten
this email address ({{ .C.Destination }}).
</p>
<p>
If you'd rather not receive emails from us you can reply with
"Unsubscribe" in the subject or body of the email. You can also use
the "Unsubscribe" feature of your mail client, if it supports list
unsubscribes.
</p>
<p>
If instead you'd like to confirm that you're willing to receive emails
at this address, you can do so by clicking below:
</p>
<div style="text-align: center;">
<a href="{{ .C.URLSubscribe }}" class="button"
>I want emails from Report Mosquitoes Online</a
>
</div>
</div>
<div class="footer">
<p>
This email was sent to you because you or someone else gave your email
address to Report Mosquitoes Online.
</p>
<p>
If you no longer wish to receive these updates,
<a href="{{ .C.URLUnsubscribe }}">click here to unsubscribe</a>.
</p>
<p>&copy; 2026 Gleipnir LLC. All rights reserved.</p>
</div>
</div>
</body>
</html>

View file

@ -1,7 +0,0 @@
We're sending you this email because it's the first time we've gotten this email address ({{.C.Destination}}).
If you'd rather not receive emails from us you can reply with "Unsubscribe" in the subject or body of the email. You can also use the "Unsubscribe" feature of your mail client, if it supports list unsubscribes.
If instead you'd like to confirm that you're willing to receive emails at this address, you can do so by openining the following URL in a web browser: {{.C.URLSubscribe}}. You can also confirm your willingness by replying to this email with 'Confirm' in the subject on the body of the email.
Thank you,
Report Mosquitoes Online

View file

@ -1,102 +0,0 @@
<!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">
{{if not .IsBrowser}}
<div class="view-browser">
Email not displaying correctly? <a href="{{.C.URLViewInBrowser}}">View it in your browser</a>
</div>
{{end}}
<div class="header">
<!-- Logo Placeholder -->
<img src="{{.C.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 {{.C.ReportIDStr}}. 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="{{.C.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>
<p>You can unsubscribe from notifications about this report by clicking <a hrep="{{.C.URLReportUnsubscribe}}">here</a></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="{{.C.URLReportUnsubscribe}}">click here to unsubscribe</a>.</p>
<p>&copy; 2026 Gleipnir LLC. All rights reserved.</p>
</div>
</div>
</body>
</html>

View file

@ -1,9 +0,0 @@
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 {{.C.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 {{.C.URLReportUnsubscribe}} to unsubscribe.