Files
Bookra/apps/backend/internal/notifications/service.go
T
Tomas DvorakandDevin 7509e82261 feat(reminders): SMS channel for reminder jobs
Tenants paying for the SMS add-on still got email-only reminders —
the channel column existed but nothing armed or dispatched sms jobs.
Booking creation now adds a second job when SMS is enabled and the
customer left a phone; dispatch routes sms jobs through an injected
sender adapter wired to the existing sms.Service, so monthly limits,
usage metering, and Stripe billing apply unchanged. Lifecycle handling
from the previous milestone clears and re-arms both channels together.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-18 13:03:11 +02:00

478 lines
14 KiB
Go

package notifications
import (
"context"
"errors"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
var ErrUnsupportedChannel = errors.New("unsupported notification channel")
type DeliveryReceipt struct {
Provider string
ExternalID string
}
type EmailMessage struct {
From string
To string
Subject string
Text string
HTML string
}
type EmailProvider interface {
Send(context.Context, EmailMessage) (DeliveryReceipt, error)
}
type Service struct {
cfg config.Config
repo db.Repository
emailProvider EmailProvider
smsSender func(ctx context.Context, tenantID, phone, body string) error
now func() time.Time
}
func NewService(cfg config.Config, repo db.Repository) *Service {
emailProvider := EmailProvider(noopEmailProvider{})
if cfg.SMTPHost != "" {
emailProvider = smtpEmailProvider{cfg: cfg}
}
return &Service{
cfg: cfg,
repo: repo,
emailProvider: emailProvider,
now: func() time.Time { return time.Now().UTC() },
}
}
// SetSMSSender plugs in the SMS delivery path used for sms-channel reminder
// jobs. Kept as a function adapter so this package stays free of the sms
// dependency; the concrete sms.Service is wired in the API server.
func (s *Service) SetSMSSender(sender func(ctx context.Context, tenantID, phone, body string) error) {
s.smsSender = sender
}
func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchReminderJobsResponse, error) {
if limit <= 0 {
limit = 25
}
jobs, err := s.repo.ListDueReminderJobs(ctx, s.now(), limit)
if err != nil {
return domain.DispatchReminderJobsResponse{}, err
}
response := domain.DispatchReminderJobsResponse{}
for _, job := range jobs {
response.ProcessedCount++
if job.BookingStatus != "confirmed" {
if err := s.repo.MarkReminderJobDispatched(ctx, job.ID, "skipped", s.now()); err != nil {
return domain.DispatchReminderJobsResponse{}, err
}
response.SkippedCount++
continue
}
status := "sent"
provider := "unknown"
externalID := ""
errorMessage := ""
switch job.Channel {
case "email":
receipt, sendErr := s.emailProvider.Send(ctx, renderEmailMessage(s.cfg.EmailFrom, job))
if sendErr != nil {
status = "failed"
errorMessage = sendErr.Error()
} else {
provider = receipt.Provider
externalID = receipt.ExternalID
}
case "sms":
provider = "smsmanager"
switch {
case s.smsSender == nil:
status = "failed"
errorMessage = "sms delivery not configured"
case job.CustomerPhone == "":
status = "failed"
errorMessage = "missing customer phone"
default:
if sendErr := s.smsSender(ctx, job.TenantID, job.CustomerPhone, renderSmsBody(job)); sendErr != nil {
status = "failed"
errorMessage = sendErr.Error()
}
}
default:
status = "failed"
errorMessage = ErrUnsupportedChannel.Error()
}
if provider == "unknown" {
if job.Channel == "email" {
provider = "noop-email"
}
}
if err := s.repo.MarkReminderJobDispatched(ctx, job.ID, status, s.now()); err != nil {
return domain.DispatchReminderJobsResponse{}, err
}
if err := s.repo.CreateNotificationDeliveryLog(ctx, db.NotificationDeliveryLogParams{
TenantID: job.TenantID,
ReminderJobID: job.ID,
Channel: job.Channel,
Provider: provider,
Recipient: reminderRecipient(job),
Status: status,
ExternalID: externalID,
ErrorMessage: errorMessage,
}); err != nil {
return domain.DispatchReminderJobsResponse{}, err
}
if status == "sent" {
response.SentCount++
} else {
response.FailedCount++
}
}
return response, nil
}
// SendBookingConfirmation sends a booking confirmation email to the customer
func (s *Service) SendBookingConfirmation(ctx context.Context, data BookingEmailData) error {
if data.BusinessEmail == "" {
data.BusinessEmail = s.cfg.EmailFrom
}
data.Type = EmailTypeConfirmation
msg := s.renderWithOverride(ctx, data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
// SendBookingReschedule sends a reschedule notification email to the customer
func (s *Service) SendBookingReschedule(ctx context.Context, data BookingEmailData) error {
if data.BusinessEmail == "" {
data.BusinessEmail = s.cfg.EmailFrom
}
data.Type = EmailTypeReschedule
msg := s.renderWithOverride(ctx, data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
// SendBookingCancellation sends a cancellation notification email to the customer
func (s *Service) SendBookingCancellation(ctx context.Context, data BookingEmailData) error {
if data.BusinessEmail == "" {
data.BusinessEmail = s.cfg.EmailFrom
}
data.Type = EmailTypeCancellation
msg := s.renderWithOverride(ctx, data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
// renderWithOverride renders the default message and, when the tenant has an
// enabled custom template for this email type, swaps in the stored subject and
// body with {{variable}} substitution.
func (s *Service) renderWithOverride(ctx context.Context, data BookingEmailData) EmailMessage {
msg := RenderEmailMessage(data)
if s.repo == nil || data.TenantID == "" {
return msg
}
tpl, err := s.repo.GetEmailTemplate(ctx, data.TenantID, string(data.Type))
if err != nil || !tpl.IsEnabled {
return msg
}
vars := map[string]string{
"customerName": data.CustomerName,
"customerEmail": data.CustomerEmail,
"service": data.Service,
"location": data.Location,
"reference": data.Reference,
"startsAt": formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale),
"tenantName": data.TenantName,
"manageUrl": data.ManagementURL,
}
if tpl.Subject != "" {
msg.Subject = substituteTemplateVars(tpl.Subject, vars)
}
if tpl.BodyText != "" {
msg.Text = substituteTemplateVars(tpl.BodyText, vars)
if tpl.BodyHTML == "" {
msg.HTML = wrapTextAsHTML(msg.Text, data.BrandColor, data.TenantName, data.ManagementURL)
}
}
if tpl.BodyHTML != "" {
msg.HTML = substituteTemplateVars(tpl.BodyHTML, vars)
}
return msg
}
func substituteTemplateVars(body string, vars map[string]string) string {
for key, value := range vars {
body = strings.ReplaceAll(body, "{{"+key+"}}", value)
}
return body
}
func wrapTextAsHTML(text, brandColor, tenantName, manageURL string) string {
if brandColor == "" {
brandColor = "#c25e3a"
}
html := "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
html += "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
html += fmt.Sprintf("<h2 style='color: %s; margin-bottom: 20px;'>%s</h2>", brandColor, tenantName)
for _, p := range splitParagraphs(text) {
if len(p) > 0 {
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
}
}
if manageURL != "" {
html += fmt.Sprintf("<div style='margin-top: 30px;'><a href='%s' style='display: inline-block; background: %s; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;'>Manage Booking</a></div>", manageURL, brandColor)
}
return html + "</div></body></html>"
}
// SendBusinessNotification sends a notification to the business about a new booking
func (s *Service) SendBusinessNotification(ctx context.Context, businessEmail string, data BookingEmailData) error {
if businessEmail == "" {
return nil // Skip if no business email configured
}
data.Type = EmailTypeBusinessNotify
msg := RenderEmailMessage(data)
msg.To = businessEmail
_, err := s.emailProvider.Send(ctx, msg)
return err
}
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
return RenderReminderEmail(from, job)
}
func renderReminderCopy(job db.ReminderJobRecord) (string, string) {
startLabel := localizedStartsAt(job)
if job.Locale == "cs" {
return "Pripominka rezervace Bookra", fmt.Sprintf(
"Dobry den %s,\n\npripominame rezervaci %s u %s na %s.\n\nReference: %s\n",
job.CustomerName,
job.Reference,
job.TenantName,
startLabel,
job.Reference,
)
}
return "Bookra booking reminder", fmt.Sprintf(
"Hello %s,\n\nthis is a reminder for booking %s with %s at %s.\n\nReference: %s\n",
job.CustomerName,
job.Reference,
job.TenantName,
startLabel,
job.Reference,
)
}
func localizedStartsAt(job db.ReminderJobRecord) string {
location, err := time.LoadLocation(job.Timezone)
if err != nil {
location = time.UTC
}
localStartsAt := job.StartsAt.In(location)
if job.Locale == "cs" {
return localStartsAt.Format("02.01.2006 15:04")
}
return localStartsAt.Format("Jan 02, 2006 15:04")
}
func renderSmsBody(job db.ReminderJobRecord) string {
startLabel := localizedStartsAt(job)
if job.Locale == "cs" {
return fmt.Sprintf("%s: pripominka rezervace %s na %s.", job.TenantName, job.Reference, startLabel)
}
return fmt.Sprintf("%s: reminder for booking %s at %s.", job.TenantName, job.Reference, startLabel)
}
func reminderRecipient(job db.ReminderJobRecord) string {
if job.Channel == "sms" {
return job.CustomerPhone
}
return job.CustomerEmail
}
type noopEmailProvider struct{}
func (noopEmailProvider) Send(_ context.Context, message EmailMessage) (DeliveryReceipt, error) {
if message.To == "" {
return DeliveryReceipt{Provider: "noop-email"}, errors.New("missing email recipient")
}
return DeliveryReceipt{
Provider: "noop-email",
ExternalID: fmt.Sprintf("noop-email-%d", time.Now().UnixNano()),
}, nil
}
type smtpEmailProvider struct {
cfg config.Config
}
func (p smtpEmailProvider) Send(_ context.Context, message EmailMessage) (DeliveryReceipt, error) {
if strings.TrimSpace(message.To) == "" {
return DeliveryReceipt{Provider: "smtp"}, errors.New("missing email recipient")
}
host := strings.TrimSpace(p.cfg.SMTPHost)
if host == "" {
return DeliveryReceipt{Provider: "smtp"}, errors.New("smtp host is not configured")
}
address := net.JoinHostPort(host, strings.TrimSpace(p.cfg.SMTPPort))
var auth smtp.Auth
if p.cfg.SMTPUsername != "" {
auth = smtp.PlainAuth("", p.cfg.SMTPUsername, p.cfg.SMTPPassword, host)
}
// Build multipart email with both plain text and HTML
var payload string
if message.HTML != "" {
boundary := "BOOKRA-BOUNDARY"
payload = strings.Join([]string{
fmt.Sprintf("From: %s", message.From),
fmt.Sprintf("To: %s", message.To),
fmt.Sprintf("Subject: %s", message.Subject),
"MIME-Version: 1.0",
fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"", boundary),
"",
fmt.Sprintf("--%s", boundary),
"Content-Type: text/plain; charset=UTF-8",
"",
message.Text,
"",
fmt.Sprintf("--%s", boundary),
"Content-Type: text/html; charset=UTF-8",
"",
message.HTML,
"",
fmt.Sprintf("--%s--", boundary),
}, "\r\n")
} else {
payload = strings.Join([]string{
fmt.Sprintf("From: %s", message.From),
fmt.Sprintf("To: %s", message.To),
fmt.Sprintf("Subject: %s", message.Subject),
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
message.Text,
}, "\r\n")
}
if err := smtp.SendMail(address, auth, message.From, []string{message.To}, []byte(payload)); err != nil {
return DeliveryReceipt{Provider: "smtp"}, err
}
return DeliveryReceipt{
Provider: "smtp",
ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()),
}, nil
}
// SendContactEmail sends a contact form submission to the business email
func (s *Service) SendContactEmail(ctx context.Context, name, email, message string) error {
subject := fmt.Sprintf("Bookra Contact: Message from %s", name)
text := fmt.Sprintf("Name: %s\nEmail: %s\n\nMessage:\n%s", name, email, message)
html := fmt.Sprintf(
"<h2>New contact form submission</h2><p><strong>Name:</strong> %s</p><p><strong>Email:</strong> %s</p><p><strong>Message:</strong></p><p>%s</p>",
name, email, message,
)
msg := EmailMessage{
From: s.cfg.EmailFrom,
To: s.cfg.EmailFrom,
Subject: subject,
Text: text,
HTML: html,
}
_, err := s.emailProvider.Send(ctx, msg)
return err
}
func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
return fmt.Errorf("failed to get tenant: %w", err)
}
ownerEmail, err := s.repo.GetTenantOwnerEmail(ctx, tenantID)
if err != nil || ownerEmail == "" {
return nil
}
emailData := UsageNotificationData{
Type: EmailTypeUsageWarning,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom,
AdminEmail: ownerEmail,
Locale: tenant.Locale,
PlanCode: tenant.PlanCode,
LocationCount: locationCount,
LocationLimit: locationLimit,
UsagePercent: usagePercent,
UpgradeURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard/billing",
DashboardURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard",
}
msg := RenderUsageNotificationEmail(emailData)
_, err = s.emailProvider.Send(ctx, msg)
return err
}
// SendRawEmail sends a pre-built email message
func (s *Service) SendRawEmail(ctx context.Context, msg EmailMessage) (DeliveryReceipt, error) {
if msg.From == "" {
msg.From = s.cfg.EmailFrom
}
return s.emailProvider.Send(ctx, msg)
}
func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
return fmt.Errorf("failed to get tenant: %w", err)
}
ownerEmail, err := s.repo.GetTenantOwnerEmail(ctx, tenantID)
if err != nil || ownerEmail == "" {
return nil
}
emailData := UsageNotificationData{
Type: EmailTypeTrialEnding,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom,
AdminEmail: ownerEmail,
Locale: tenant.Locale,
PlanCode: tenant.PlanCode,
UpgradeURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard/billing",
DashboardURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard",
}
msg := RenderUsageNotificationEmail(emailData)
_, err = s.emailProvider.Send(ctx, msg)
return err
}