This commit is contained in:
Tomas Dvorak
2026-05-05 09:48:07 +02:00
parent d854614a87
commit 48c3e15a38
295 changed files with 178381 additions and 1039 deletions
@@ -0,0 +1,354 @@
package notifications
import (
"fmt"
"time"
"bookra/apps/backend/internal/db"
)
type EmailType string
const (
EmailTypeConfirmation EmailType = "confirmation"
EmailTypeReminder EmailType = "reminder"
EmailTypeReschedule EmailType = "reschedule"
EmailTypeCancellation EmailType = "cancellation"
EmailTypeBusinessNotify EmailType = "business_notify"
)
type BookingEmailData struct {
Type EmailType
TenantName string
TenantSlug string
BusinessEmail string
BusinessPhone string
BusinessAddress string
BrandColor string
CustomerName string
CustomerEmail string
Service string
Location string
Reference string
StartsAt time.Time
EndsAt time.Time
Timezone string
Locale string
Notes string
ManagementURL string
AddToCalendarURL string
}
func RenderEmailMessage(data BookingEmailData) EmailMessage {
subject := renderSubject(data)
htmlBody := renderHTMLBody(data)
textBody := renderTextBody(data)
return EmailMessage{
From: data.BusinessEmail,
To: data.CustomerEmail,
Subject: subject,
Text: textBody,
HTML: htmlBody,
}
}
func renderSubject(data BookingEmailData) string {
localizedTime := formatLocalizedTime(data.StartsAt, data.Timezone, data.Locale)
switch data.Type {
case EmailTypeConfirmation:
if data.Locale == "cs" {
return fmt.Sprintf("Potvrzení rezervace %s - %s", data.Reference, data.TenantName)
}
return fmt.Sprintf("Booking Confirmation %s - %s", data.Reference, data.TenantName)
case EmailTypeReminder:
if data.Locale == "cs" {
return fmt.Sprintf("Připomínka: Máte rezervaci zítra v %s", localizedTime)
}
return fmt.Sprintf("Reminder: You have a booking tomorrow at %s", localizedTime)
case EmailTypeReschedule:
if data.Locale == "cs" {
return fmt.Sprintf("Vaše rezervace byla přesunuta - %s", data.Reference)
}
return fmt.Sprintf("Your booking has been rescheduled - %s", data.Reference)
case EmailTypeCancellation:
if data.Locale == "cs" {
return fmt.Sprintf("Vaše rezervace byla zrušena - %s", data.Reference)
}
return fmt.Sprintf("Your booking has been cancelled - %s", data.Reference)
case EmailTypeBusinessNotify:
if data.Locale == "cs" {
return fmt.Sprintf("Nová rezervace od %s - %s", data.CustomerName, data.Reference)
}
return fmt.Sprintf("New booking from %s - %s", data.CustomerName, data.Reference)
default:
return "Booking Update"
}
}
func renderTextBody(data BookingEmailData) string {
localizedTime := formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale)
switch data.Type {
case EmailTypeConfirmation:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
vaše rezervace byla potvrzena.
Detaily rezervace:
- Služba: %s
- Datum a čas: %s
- Místo: %s
- Reference: %s
Pro správu rezervace navštivte: %s
Děkujeme,
%s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
}
return fmt.Sprintf(`Hello %s,
Your booking has been confirmed.
Booking Details:
- Service: %s
- Date & Time: %s
- Location: %s
- Reference: %s
Manage your booking at: %s
Thank you,
%s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
case EmailTypeReminder:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
připomínáme vám zítřejší rezervaci.
- Služba: %s
- Čas: %s
- Místo: %s
- Reference: %s
Pro správu rezervace: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
}
return fmt.Sprintf(`Hello %s,
This is a reminder for your booking tomorrow.
- Service: %s
- Time: %s
- Location: %s
- Reference: %s
Manage booking: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
case EmailTypeReschedule:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
vaše rezervace byla přesunuta na nový termín.
Nové detaily:
- Služba: %s
- Datum a čas: %s
- Místo: %s
- Reference: %s
Pro správu rezervace: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
}
return fmt.Sprintf(`Hello %s,
Your booking has been rescheduled.
New details:
- Service: %s
- Date & Time: %s
- Location: %s
- Reference: %s
Manage booking: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
case EmailTypeCancellation:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
vaše rezervace byla zrušena.
Zrušená rezervace:
- Služba: %s
- Datum a čas: %s
- Reference: %s
Pokud jste rezervaci nezrušili vy, kontaktujte nás: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
}
return fmt.Sprintf(`Hello %s,
Your booking has been cancelled.
Cancelled booking:
- Service: %s
- Date & Time: %s
- Reference: %s
If you didn't cancel this, please contact us: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
case EmailTypeBusinessNotify:
if data.Locale == "cs" {
return fmt.Sprintf(`Nová rezervace od %s
Detaily:
- Služba: %s
- Datum a čas: %s
- Reference: %s
- Email: %s
Spravovat v administraci: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
}
return fmt.Sprintf(`New booking from %s
Details:
- Service: %s
- Date & Time: %s
- Reference: %s
- Email: %s
Manage in dashboard: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
default:
return "Booking update"
}
}
func renderHTMLBody(data BookingEmailData) string {
// For now, return simple HTML version. In production, this would use proper HTML templates
textBody := renderTextBody(data)
// Simple conversion: wrap paragraphs in <p> tags and preserve line breaks
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>", data.BrandColor, data.TenantName)
// Convert text to simple HTML
paragraphs := splitParagraphs(textBody)
for _, p := range paragraphs {
if len(p) > 0 {
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
}
}
// Add management button
if data.ManagementURL != "" {
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>", data.ManagementURL, data.BrandColor)
}
html += "</div></body></html>"
return html
}
func formatLocalizedTime(t time.Time, timezone, locale string) string {
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
}
localTime := t.In(loc)
if locale == "cs" {
return localTime.Format("15:04")
}
return localTime.Format("3:04 PM")
}
func formatLocalizedDateTime(t time.Time, timezone, locale string) string {
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
}
localTime := t.In(loc)
if locale == "cs" {
return localTime.Format("02.01.2006 15:04")
}
return localTime.Format("Jan 02, 2006 3:04 PM")
}
func splitParagraphs(text string) []string {
var paragraphs []string
current := ""
for _, line := range splitLines(text) {
trimmed := trimSpace(line)
if trimmed == "" {
if current != "" {
paragraphs = append(paragraphs, current)
current = ""
}
} else {
if current != "" {
current += " "
}
current += trimmed
}
}
if current != "" {
paragraphs = append(paragraphs, current)
}
return paragraphs
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
lines = append(lines, s[start:])
return lines
}
func trimSpace(s string) string {
start := 0
end := len(s)
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r' || s[start] == '\n') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r' || s[end-1] == '\n') {
end--
}
return s[start:end]
}
// RenderReminderEmail renders the legacy reminder email from a job record
func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
data := BookingEmailData{
Type: EmailTypeReminder,
TenantName: job.TenantName,
TenantSlug: "", // Not available in job record
CustomerName: job.CustomerName,
CustomerEmail: job.CustomerEmail,
Reference: job.Reference,
StartsAt: job.StartsAt,
Timezone: job.Timezone,
Locale: job.Locale,
Service: "Service", // Legacy
Location: "Location", // Legacy
}
return RenderEmailMessage(data)
}
+112 -46
View File
@@ -4,6 +4,9 @@ import (
"context"
"errors"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"bookra/apps/backend/internal/config"
@@ -23,36 +26,30 @@ type EmailMessage struct {
To string
Subject string
Text string
}
type SMSMessage struct {
From string
To string
Text string
HTML string
}
type EmailProvider interface {
Send(context.Context, EmailMessage) (DeliveryReceipt, error)
}
type SMSProvider interface {
Send(context.Context, SMSMessage) (DeliveryReceipt, error)
}
type Service struct {
cfg config.Config
repo db.Repository
emailProvider EmailProvider
smsProvider SMSProvider
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: noopEmailProvider{},
smsProvider: noopSMSProvider{},
emailProvider: emailProvider,
now: func() time.Time { return time.Now().UTC() },
}
}
@@ -86,15 +83,6 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
provider = receipt.Provider
externalID = receipt.ExternalID
}
case "sms":
receipt, sendErr := s.smsProvider.Send(ctx, renderSMSMessage(s.cfg.SMSFrom, job))
if sendErr != nil {
status = "failed"
errorMessage = sendErr.Error()
} else {
provider = receipt.Provider
externalID = receipt.ExternalID
}
default:
status = "failed"
errorMessage = ErrUnsupportedChannel.Error()
@@ -103,8 +91,6 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
if provider == "unknown" {
if job.Channel == "email" {
provider = "noop-email"
} else if job.Channel == "sms" {
provider = "noop-sms"
}
}
@@ -134,23 +120,53 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
return response, nil
}
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
subject, body := renderReminderCopy(job)
return EmailMessage{
From: from,
To: job.CustomerEmail,
Subject: subject,
Text: body,
// 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
}
func renderSMSMessage(from string, job db.ReminderJobRecord) SMSMessage {
subject, body := renderReminderCopy(job)
return SMSMessage{
From: from,
To: job.CustomerEmail,
Text: fmt.Sprintf("%s: %s", subject, body),
// 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) {
@@ -190,9 +206,6 @@ func localizedStartsAt(job db.ReminderJobRecord) string {
}
func reminderRecipient(job db.ReminderJobRecord) string {
if job.Channel == "email" {
return job.CustomerEmail
}
return job.CustomerEmail
}
@@ -208,14 +221,67 @@ func (noopEmailProvider) Send(_ context.Context, message EmailMessage) (Delivery
}, nil
}
type noopSMSProvider struct{}
type smtpEmailProvider struct {
cfg config.Config
}
func (noopSMSProvider) Send(_ context.Context, message SMSMessage) (DeliveryReceipt, error) {
if message.To == "" {
return DeliveryReceipt{Provider: "noop-sms"}, errors.New("missing sms recipient")
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: "noop-sms",
ExternalID: fmt.Sprintf("noop-sms-%d", time.Now().UnixNano()),
Provider: "smtp",
ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()),
}, nil
}
@@ -38,7 +38,6 @@ func TestDispatchDueProcessesPendingEmailReminders(t *testing.T) {
service := NewService(config.Config{
Environment: "development",
EmailFrom: "noreply@bookra.dev",
SMSFrom: "Bookra",
}, repo)
response, err := service.DispatchDue(context.Background(), 10)