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 } // 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( "

New contact form submission

Name: %s

Email: %s

Message:

%s

", 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) } // Use a placeholder admin email - in production, would get from tenant owner adminEmail := "admin@" + tenant.Slug + ".bookra.eu" emailData := UsageNotificationData{ Type: EmailTypeUsageWarning, TenantName: tenant.Name, TenantSlug: tenant.Slug, BusinessEmail: s.cfg.EmailFrom, AdminEmail: adminEmail, Locale: tenant.Locale, PlanCode: tenant.PlanCode, LocationCount: locationCount, LocationLimit: locationLimit, UsagePercent: usagePercent, UpgradeURL: "https://bookra.eu/pricing", DashboardURL: "https://bookra.eu/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) } // Use a placeholder admin email - in production, would get from tenant owner adminEmail := "admin@" + tenant.Slug + ".bookra.eu" emailData := UsageNotificationData{ Type: EmailTypeTrialEnding, TenantName: tenant.Name, TenantSlug: tenant.Slug, BusinessEmail: s.cfg.EmailFrom, AdminEmail: adminEmail, Locale: tenant.Locale, PlanCode: tenant.PlanCode, UpgradeURL: "https://bookra.eu/pricing", DashboardURL: "https://bookra.eu/dashboard", } msg := RenderUsageNotificationEmail(emailData) _, err = s.emailProvider.Send(ctx, msg) return err }