package main import ( "context" "log/slog" "os" "os/signal" "syscall" "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) rsvpSub, err := natspub.NewRSVPConfirmedSubscriber( rootCtx, natsClient, "notifier-rsvp-confirmed", func(ctx context.Context, evt natspub.RSVPConfirmed) error { return handleRSVPConfirmed(ctx, logger, repo, sender, 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() 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, evt natspub.RSVPConfirmed, ) error { msg := notification.OutboundMessage{ GuestID: evt.GuestID, Channel: notification.ChannelEmail, Type: notification.TypeConfirmation, Subject: "Your RSVP is confirmed", Body: "Thanks for your response.", Metadata: map[string]any{ "rsvp_id": evt.RSVPID, "event_id": evt.EventID, "response": evt.Response, "plus_ones": evt.PlusOnes, "risk_score": evt.RiskScore, "submitted_at": evt.SubmittedAt, }, } 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 }