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

@ -5,10 +5,10 @@ import (
"fmt"
"sync"
"github.com/Gleipnir-Technology/nidus-sync/comms/email"
"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/Gleipnir-Technology/nidus-sync/platform/email"
"github.com/Gleipnir-Technology/nidus-sync/platform/text"
"github.com/rs/zerolog/log"
)

View file

@ -3,7 +3,7 @@ package background
import (
"context"
"github.com/Gleipnir-Technology/nidus-sync/comms/email"
"github.com/Gleipnir-Technology/nidus-sync/platform/email"
"github.com/rs/zerolog/log"
)

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,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

@ -13,11 +13,11 @@ import (
"github.com/Gleipnir-Technology/nidus-sync/auth"
"github.com/Gleipnir-Technology/nidus-sync/background"
"github.com/Gleipnir-Technology/nidus-sync/comms/email"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/html"
"github.com/Gleipnir-Technology/nidus-sync/llm"
"github.com/Gleipnir-Technology/nidus-sync/platform/email"
"github.com/Gleipnir-Technology/nidus-sync/platform/text"
"github.com/Gleipnir-Technology/nidus-sync/rmo"
nidussync "github.com/Gleipnir-Technology/nidus-sync/sync"

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"github.com/Gleipnir-Technology/nidus-sync/comms/email"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
@ -11,6 +12,12 @@ import (
"github.com/rs/zerolog/log"
)
type contentEmailInitial struct {
Base contentEmailBase
Destination string
URLSubscribe string
}
type jobInitial struct {
base jobEmailBase
}
@ -20,7 +27,7 @@ func (job jobInitial) Destination() string {
}
func maybeSendInitialEmail(ctx context.Context, destination string) error {
err := ensureInDB(ctx, destination)
err := EnsureInDB(ctx, destination)
if err != nil {
return fmt.Errorf("Failed to add email recipient to database: %w", err)
}
@ -65,7 +72,7 @@ func sendEmailInitialContact(ctx context.Context, destination string) error {
if err != nil {
return fmt.Errorf("Failed to store email log: %w", err)
}
resp, err := sendEmail(ctx, emailRequest{
resp, err := email.Send(ctx, email.Request{
From: source,
HTML: html,
Subject: subject,

View file

@ -4,11 +4,17 @@ import (
"context"
"fmt"
"github.com/Gleipnir-Technology/nidus-sync/comms/email"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/rs/zerolog/log"
)
type contentEmailReportConfirmation struct {
Base contentEmailBase
URLReportStatus string
}
func NewJobReportNotificationConfirmation(destination, report_id string) Job {
return jobEmailReportNotificationConfirmation{
dest: destination,
@ -68,7 +74,7 @@ func sendEmailReportConfirmation(ctx context.Context, job Job) error {
if err != nil {
return fmt.Errorf("Failed to store email log: %w", err)
}
resp, err := sendEmail(ctx, emailRequest{
resp, err := email.Send(ctx, email.Request{
From: config.ForwardEmailReportAddress,
HTML: html,
Subject: subject,

View file

@ -37,6 +37,12 @@ var (
templateReportNotificationConfirmationID int32
)
type contentEmailBase struct {
URLLogo string
URLUnsubscribe string
URLViewInBrowser string
}
type ContentEmailRender struct {
IsBrowser bool
C any

View file

@ -0,0 +1,130 @@
<!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

@ -19,6 +19,7 @@ import (
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/db/sql"
"github.com/Gleipnir-Technology/nidus-sync/platform/email"
"github.com/Gleipnir-Technology/nidus-sync/platform/text"
"github.com/rs/zerolog/log"
//"github.com/stephenafamo/scan"
@ -65,16 +66,20 @@ func GenerateReportID() (string, error) {
return builder.String(), nil
}
func RegisterNotificationEmail(ctx context.Context, txn bob.Tx, report_id string, email string) *ErrorWithCode {
func RegisterNotificationEmail(ctx context.Context, txn bob.Tx, report_id string, destination string) *ErrorWithCode {
some_report, err := findSomeReport(ctx, report_id)
if err != nil {
return err
}
err = some_report.addNotificationEmail(ctx, txn, email)
e := email.EnsureInDB(ctx, destination)
if e != nil {
return newInternalError(e, "Failed to ensure phone is in DB")
}
err = some_report.addNotificationEmail(ctx, txn, destination)
if err != nil {
return err
}
background.ReportSubscriptionConfirmationEmail(email, report_id)
background.ReportSubscriptionConfirmationEmail(destination, report_id)
return nil
}
@ -83,6 +88,10 @@ func RegisterNotificationPhone(ctx context.Context, txn bob.Tx, report_id string
if err != nil {
return err
}
e := text.EnsureInDB(ctx, phone)
if e != nil {
return newInternalError(e, "Failed to ensure phone is in DB")
}
err = some_report.addNotificationPhone(ctx, txn, phone)
if err != nil {
return err
@ -92,11 +101,11 @@ func RegisterNotificationPhone(ctx context.Context, txn bob.Tx, report_id string
}
func RegisterSubscriptionEmail(ctx context.Context, txn bob.Tx, email string) *ErrorWithCode {
log.Warn().Msg("RegisterSubscription not implemented yet")
log.Warn().Str("email", email).Msg("RegisterSubscription not implemented yet")
return nil
}
func RegisterSubscriptionPhone(ctx context.Context, txn bob.Tx, phone text.E164) *ErrorWithCode {
log.Warn().Msg("RegisterSubscription not implemented yet")
log.Warn().Str("phone", text.PhoneString(phone)).Msg("RegisterSubscription not implemented yet")
return nil
}

View file

@ -23,10 +23,9 @@ import (
type E164 = phonenumbers.PhoneNumber
func PhoneString(p E164) string {
return phonenumbers.Format(&p, phonenumbers.E164)
func EnsureInDB(ctx context.Context, destination E164) (err error) {
return ensureInDB(ctx, PhoneString(destination))
}
func HandleTextMessage(src string, dst string, body string) {
ctx := context.Background()
@ -98,15 +97,27 @@ func ParsePhoneNumber(input string) (*E164, error) {
return phonenumbers.Parse(input, "US")
}
func PhoneString(p E164) string {
return phonenumbers.Format(&p, phonenumbers.E164)
}
func StoreSources() error {
ctx := context.TODO()
for _, n := range []string{config.PhoneNumberReportStr, config.PhoneNumberSupportStr, config.VoipMSNumber} {
var err error
// Deal with Voip.ms not expecting API calls with the prefixed +1
if !strings.HasPrefix(n, "+1") {
err = ensureInDB(ctx, "+1"+n)
dest, err := ParsePhoneNumber("+1" + n)
if err != nil {
return fmt.Errorf("Failed to parse +1'%s' as phone number: %w", n, err)
}
err = EnsureInDB(ctx, *dest)
} else {
err = ensureInDB(ctx, n)
dest, err := ParsePhoneNumber(n)
if err != nil {
return fmt.Errorf("Failed to parse '%s' as phone number: %w", n, err)
}
err = EnsureInDB(ctx, *dest)
}
if err != nil {
return fmt.Errorf("Failed to add number '%s' to DB: %w", n, err)
@ -172,21 +183,6 @@ func sendInitialText(ctx context.Context, src string, dst string) error {
return nil
}
func ensureInitialText(ctx context.Context, src string, dst string) error {
//
rows, err := models.CommsTextLogs.Query(
models.SelectWhere.CommsTextLogs.Destination.EQ(dst),
models.SelectWhere.CommsTextLogs.IsWelcome.EQ(true),
).All(ctx, db.PGInstance.BobDB)
if err != nil {
return fmt.Errorf("Failed to query text logs: %w", err)
}
if len(rows) > 0 {
return nil
}
return sendInitialText(ctx, src, dst)
}
func ensureInDB(ctx context.Context, destination string) (err error) {
_, err = models.FindCommsPhone(ctx, db.PGInstance.BobDB, destination)
if err != nil {
@ -208,6 +204,21 @@ func ensureInDB(ctx context.Context, destination string) (err error) {
return nil
}
func ensureInitialText(ctx context.Context, src string, dst string) error {
//
rows, err := models.CommsTextLogs.Query(
models.SelectWhere.CommsTextLogs.Destination.EQ(dst),
models.SelectWhere.CommsTextLogs.IsWelcome.EQ(true),
).All(ctx, db.PGInstance.BobDB)
if err != nil {
return fmt.Errorf("Failed to query text logs: %w", err)
}
if len(rows) > 0 {
return nil
}
return sendInitialText(ctx, src, dst)
}
func generateNextMessage(ctx context.Context, history []llm.Message, customer_phone string) (llm.Message, error) {
_handle_report_status := func() (string, error) {
return "Report: ABCD-1234-5678, District: Delta MVCD, Status: scheduled, Appointment: Wednesday 3:30pm", nil

View file

@ -3,10 +3,10 @@ package rmo
import (
"net/http"
"github.com/Gleipnir-Technology/nidus-sync/comms/email"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/html"
"github.com/Gleipnir-Technology/nidus-sync/platform/email"
"github.com/aarondl/opt/omit"
"github.com/go-chi/chi/v5"
)