package main import ( "context" "encoding/base64" htmltemplate "html/template" "log/slog" "os" "os/signal" "strings" "syscall" "time" "github.com/skip2/go-qrcode" "github.com/alchemistkay/guestguard/internal/auth" "github.com/alchemistkay/guestguard/internal/config" "github.com/alchemistkay/guestguard/internal/natspub" "github.com/alchemistkay/guestguard/internal/notification" "github.com/alchemistkay/guestguard/internal/storage" ) func main() { if err := run(); err != nil { slog.Error("fatal", "err", err) os.Exit(1) } } func run() error { cfg, err := config.Load() if err != nil { return err } logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: levelFor(cfg.Env)})) slog.SetDefault(logger) logger = logger.With("service", "notifier") rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() logger.Info("connecting to database") db, err := storage.NewDB(rootCtx, cfg.DatabaseURL) if err != nil { return err } defer db.Close() logger.Info("connecting to nats", "url", cfg.NATSURL) natsClient, err := natspub.Connect(rootCtx, cfg.NATSURL, logger) if err != nil { return err } defer natsClient.Close() repo := notification.NewRepo(db) suppressions := notification.NewSuppressionRepo(db) tpls, err := notification.NewTemplates() if err != nil { return err } // Email dispatcher: Resend > SMTP > SES > log stub, same picker as cmd/api. combinedEmail, backend, err := notification.PickEmailSender(rootCtx, notification.EmailSenderConfig{ Resend: notification.ResendConfig{ APIKey: cfg.ResendAPIKey, FromEmail: cfg.ResendFromEmail, FromName: cfg.ResendFromName, }, SMTP: notification.SMTPConfig{ Host: cfg.SMTPHost, Port: cfg.SMTPPort, Username: cfg.SMTPUsername, Password: cfg.SMTPPassword, FromEmail: cfg.SMTPFromEmail, FromName: cfg.SMTPFromName, TLS: cfg.SMTPTLS, }, SES: notification.SESConfig{ Region: cfg.SESRegion, FromEmail: cfg.SESFromEmail, FromName: cfg.SESFromName, ConfigurationSet: cfg.SESConfigurationSet, PublicBaseURL: cfg.PublicBaseURL, }, }, tpls, logger) if err != nil { return err } logger.Info("email backend selected", "backend", backend) emailSender := notification.NewEmailSender(combinedEmail, suppressions) // SMS: Twilio when creds are set, otherwise no-op log sender. var smsSender notification.Sender if cfg.TwilioAccountSID != "" && cfg.TwilioAuthToken != "" && cfg.TwilioFromNumber != "" { t, err := notification.NewTwilioSender(notification.TwilioConfig{ AccountSID: cfg.TwilioAccountSID, AuthToken: cfg.TwilioAuthToken, FromNumber: cfg.TwilioFromNumber, }) if err != nil { return err } smsSender = t logger.Info("twilio sms sender configured", "from", cfg.TwilioFromNumber) } else { smsSender = notification.LogSender{} logger.Info("twilio not configured — SMS will use the log stub") } sender := notification.NewRouter(emailSender, smsSender) // Tier 2 Block H — QR signer for the confirmation email's door // code. Same secret as the API so the same JWT verifies on either // service. checkInQRSigner, err := auth.NewCheckInQRSigner(cfg.JWTSecret, cfg.JWTIssuer, 6*time.Hour) if err != nil { return err } guestRepo := storage.NewGuestRepo(db) eventRepo := storage.NewEventRepo(db) rsvpSub, err := natspub.NewRSVPConfirmedSubscriber( rootCtx, natsClient, "notifier-rsvp-confirmed", func(ctx context.Context, evt natspub.RSVPConfirmed) error { return handleRSVPConfirmed(ctx, logger, repo, sender, guestRepo, eventRepo, checkInQRSigner, cfg.PublicBaseURL, evt) }, logger, ) if err != nil { return err } rsvpCC, err := rsvpSub.Start(rootCtx) if err != nil { return err } defer rsvpCC.Stop() fraudSub, err := natspub.NewFraudScoredSubscriber( rootCtx, natsClient, "notifier-fraud-scored", func(ctx context.Context, evt natspub.FraudScored) error { return handleFraudScored(ctx, logger, repo, sender, evt) }, logger, ) if err != nil { return err } fraudCC, err := fraudSub.Start(rootCtx) if err != nil { return err } defer fraudCC.Stop() invitationSub, err := natspub.NewInvitationSendSubscriber( rootCtx, natsClient, "notifier-invitation-send", func(ctx context.Context, evt natspub.InvitationSend) error { return handleInvitationSend(ctx, logger, repo, sender, evt) }, logger, ) if err != nil { return err } invitationCC, err := invitationSub.Start(rootCtx) if err != nil { return err } defer invitationCC.Stop() // Block F — scheduled-message worker. Polls scheduled_messages // every 30s and dispatches due rows through the same email // pipeline as the NATS-driven flows. scheduler := newScheduledMessageWorker(logger, db, combinedEmail, cfg.PublicBaseURL) go scheduler.Start(rootCtx) logger.Info("notifier started") <-rootCtx.Done() logger.Info("notifier shutting down") return nil } func handleRSVPConfirmed( ctx context.Context, logger *slog.Logger, repo *notification.Repo, sender notification.Sender, guests *storage.GuestRepo, events *storage.EventRepo, qr *auth.CheckInQRSigner, publicBaseURL string, evt natspub.RSVPConfirmed, ) error { // Look up the guest + event so the templated confirmation has real // names, venue, and date. The NATS event only carries IDs. guest, gerr := guests.Get(ctx, evt.GuestID) if gerr != nil { logger.Warn("load guest for confirmation email", "err", gerr, "guest_id", evt.GuestID) } event, eerr := events.Get(ctx, evt.EventID) if eerr != nil { logger.Warn("load event for confirmation email", "err", eerr, "event_id", evt.EventID) } if guest == nil || guest.Email == nil || *guest.Email == "" { // No email on file: nothing to do here. The host either has // SMS wired up (handled separately) or shares the QR via the // confirmation page link. logger.Info("skip rsvp confirmation email — no guest email", "guest_id", evt.GuestID, "event_id", evt.EventID) return nil } // Mint a QR for attending guests so the email carries their door // code along with the confirmation. Other responses get the same // email minus the QR section. // // The image is rendered as a data: URL and handed to html/template as // a template.URL — without the type wrapper, html/template's // contextual-URL escaper treats `data:` as unsafe and substitutes // the placeholder `#ZgotmplZ`, which is why the image appeared // broken in Mailpit and inbox clients. Marking it template.URL // signals "we trust this URL" and lets the data: prefix through. var qrImage htmltemplate.URL if evt.Response == "attending" && event != nil && qr != nil { now := time.Now().UTC() raw, _, qerr := qr.Issue(evt.EventID, evt.GuestID, event.EventDate, now) if qerr != nil { logger.Warn("issue qr for confirmation email", "err", qerr, "guest_id", evt.GuestID) } else if png, perr := qrcode.Encode(raw, qrcode.Medium, 320); perr != nil { logger.Warn("render qr png for confirmation email", "err", perr) } else { qrImage = htmltemplate.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) } } eventName := "" venue := "" eventDate := "" if event != nil { eventName = event.Name venue = event.Venue if !event.EventDate.IsZero() { eventDate = event.EventDate.Format("Mon, 02 Jan 2006 · 15:04") } } // Prefer the access link carried on the NATS event — that's the // guest's actual /rsvp/ URL. If we lost it (older event // schema, or token was somehow missing), fall back to the bare // dashboard origin so the template still renders without a 404. rsvpLink := strings.TrimSpace(evt.AccessLink) if rsvpLink == "" && publicBaseURL != "" { rsvpLink = strings.TrimRight(publicBaseURL, "/") } subject := "RSVP confirmed" if eventName != "" { subject = "RSVP confirmed — " + eventName } msg := notification.OutboundMessage{ GuestID: evt.GuestID, Channel: notification.ChannelEmail, Type: notification.TypeConfirmation, Subject: subject, Metadata: map[string]any{ "to": *guest.Email, "GuestName": guest.Name, "HostName": "your host", // notifier doesn't have the host's name handy "EventName": eventName, "Venue": venue, "EventDate": eventDate, "Response": evt.Response, "PlusOnes": evt.PlusOnes, "QRImage": qrImage, "RSVPLink": rsvpLink, }, } providerID, sendErr := sender.Send(ctx, msg) status := notification.StatusSent errStr := "" if sendErr != nil { status = notification.StatusFailed errStr = sendErr.Error() } id, err := repo.Record(ctx, notification.RecordParams{ GuestID: evt.GuestID, Channel: msg.Channel, Type: msg.Type, Status: status, ProviderID: providerID, Error: errStr, }) if err != nil { return err } logger.Info("notification dispatched", "notification_id", id, "guest_id", evt.GuestID, "channel", msg.Channel, "type", msg.Type, "status", status, "provider_id", providerID, ) return nil } func handleFraudScored( ctx context.Context, logger *slog.Logger, repo *notification.Repo, sender notification.Sender, evt natspub.FraudScored, ) error { // Only alert when the score crosses a meaningful threshold; low/medium // scores are noise for the host. if evt.Score < 60 { return nil } msg := notification.OutboundMessage{ GuestID: evt.GuestID, Channel: notification.ChannelEmail, Type: notification.TypeReminder, // reusing existing enum; a fraud_alert type would be nicer Subject: "Suspicious access attempt detected", Body: "A flagged access attempt occurred for one of your guests.", Metadata: map[string]any{ "event_id": evt.EventID, "score": evt.Score, "risk": evt.Risk, "reasons": evt.Reasons, }, } providerID, sendErr := sender.Send(ctx, msg) status := notification.StatusSent errStr := "" if sendErr != nil { status = notification.StatusFailed errStr = sendErr.Error() } id, err := repo.Record(ctx, notification.RecordParams{ GuestID: evt.GuestID, Channel: msg.Channel, Type: msg.Type, Status: status, ProviderID: providerID, Error: errStr, }) if err != nil { return err } logger.Warn("fraud alert dispatched", "notification_id", id, "guest_id", evt.GuestID, "score", evt.Score, "risk", evt.Risk, "reasons", evt.Reasons, "status", status, "provider_id", providerID, ) return nil } // handleInvitationSend renders the invitation template + sends through // the configured sender (Resend in prod, Mailpit in dev), then writes a // notification row so the host can audit the delivery history. func handleInvitationSend( ctx context.Context, logger *slog.Logger, repo *notification.Repo, sender notification.Sender, evt natspub.InvitationSend, ) error { if evt.GuestEmail == "" { // Nothing to do — host-managed delivery. return nil } eventDate := "" if !evt.EventDate.IsZero() { eventDate = evt.EventDate.Format("Mon, 02 Jan 2006 · 15:04") } msg := notification.OutboundMessage{ GuestID: evt.GuestID, Channel: notification.ChannelEmail, Type: notification.TypeInvitation, Subject: "You're invited — " + evt.EventName, Metadata: map[string]any{ "to": evt.GuestEmail, "GuestName": evt.GuestName, "HostName": evt.HostName, "EventName": evt.EventName, "Venue": evt.Venue, "EventDate": eventDate, "Link": evt.Link, }, } providerID, sendErr := sender.Send(ctx, msg) status := notification.StatusSent errStr := "" if sendErr != nil { status = notification.StatusFailed errStr = sendErr.Error() } id, err := repo.Record(ctx, notification.RecordParams{ GuestID: evt.GuestID, Channel: msg.Channel, Type: msg.Type, Status: status, ProviderMessageID: providerID, Error: errStr, }) if err != nil { return err } logger.Info("invitation dispatched", "notification_id", id, "guest_id", evt.GuestID, "event_id", evt.EventID, "to", evt.GuestEmail, "status", status, "provider_message_id", providerID, ) if sendErr != nil { return sendErr } return nil } func levelFor(env string) slog.Level { if env == "development" { return slog.LevelDebug } return slog.LevelInfo }