Files
Bookra/apps/backend/internal/notifications/service.go
T
Tomas Dvorak 48c3e15a38 cleanup
2026-05-05 09:48:07 +02:00

288 lines
7.4 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
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() },
}
}
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++
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
}
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 := RenderEmailMessage(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 := RenderEmailMessage(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 := RenderEmailMessage(data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
// 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 reminderRecipient(job db.ReminderJobRecord) string {
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
}