package api import ( "context" "encoding/json" "io" "log/slog" "net/http" "github.com/alchemistkay/guestguard/internal/notification" ) // webhookHandler accepts provider status notifications and reflects them // onto the notifications table + suppression list. // // Signature verification is intentionally a TODO until the user provisions // real Twilio + SES creds — verifying against test fixtures alone would // give a false sense of security. The endpoint is therefore *not* exposed // publicly until the deployment is ready. type webhookHandler struct { logger *slog.Logger notifs *notification.Repo suppress *notification.SuppressionRepo } // POST /webhooks/twilio/status — Twilio status callback (form-encoded). // Fields we care about: MessageSid, MessageStatus (sent|delivered| // undelivered|failed), ErrorCode, To. func (h *webhookHandler) twilio(w http.ResponseWriter, r *http.Request) { if h.notifs == nil { w.WriteHeader(http.StatusNoContent) return } // TODO(blockD2): verify X-Twilio-Signature with GG_TWILIO_AUTH_TOKEN. if err := r.ParseForm(); err != nil { writeError(w, http.StatusBadRequest, "invalid form") return } sid := r.PostForm.Get("MessageSid") status := r.PostForm.Get("MessageStatus") if sid == "" || status == "" { writeError(w, http.StatusBadRequest, "missing MessageSid / MessageStatus") return } ctx := r.Context() switch status { case "delivered": _ = h.notifs.MarkDelivered(ctx, sid) case "undelivered", "failed": _ = h.notifs.MarkBounce(ctx, sid, "permanent") } h.logger.Info("twilio status callback", "sid", sid, "status", status) w.WriteHeader(http.StatusNoContent) } // POST /webhooks/ses/notifications — SNS-delivered SES notification (JSON). // Handles the two shapes SES uses: bounce + complaint events. Each event // carries the messageId we stored in provider_message_id and an array of // affected recipients. func (h *webhookHandler) ses(w http.ResponseWriter, r *http.Request) { if h.notifs == nil { w.WriteHeader(http.StatusNoContent) return } // TODO(blockD2): verify SNS signature using the cert URL field. body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { writeError(w, http.StatusBadRequest, "read body") return } defer r.Body.Close() var envelope struct { Type string `json:"Type"` // "Notification" | "SubscriptionConfirmation" Message string `json:"Message"` // stringified JSON for Notification } if err := json.Unmarshal(body, &envelope); err != nil { writeError(w, http.StatusBadRequest, "invalid json envelope") return } if envelope.Type == "SubscriptionConfirmation" { // Confirmed by visiting SubscribeURL — manual op-side step. h.logger.Info("ses subscription confirmation received (manual confirm required)") w.WriteHeader(http.StatusNoContent) return } if envelope.Message == "" { w.WriteHeader(http.StatusNoContent) return } var inner struct { NotificationType string `json:"notificationType"` // "Bounce" | "Complaint" | "Delivery" Mail struct { MessageID string `json:"messageId"` } `json:"mail"` Bounce struct { BounceType string `json:"bounceType"` // "Permanent" | "Transient" BouncedRecipients []struct { EmailAddress string `json:"emailAddress"` } `json:"bouncedRecipients"` } `json:"bounce"` Complaint struct { ComplainedRecipients []struct { EmailAddress string `json:"emailAddress"` } `json:"complainedRecipients"` } `json:"complaint"` } if err := json.Unmarshal([]byte(envelope.Message), &inner); err != nil { writeError(w, http.StatusBadRequest, "invalid inner json") return } ctx := r.Context() switch inner.NotificationType { case "Bounce": bt := "transient" if inner.Bounce.BounceType == "Permanent" { bt = "permanent" } _ = h.notifs.MarkBounce(ctx, inner.Mail.MessageID, bt) if h.suppress != nil && bt == "permanent" { for _, rcp := range inner.Bounce.BouncedRecipients { _ = h.suppress.Add(ctx, rcp.EmailAddress, "ses permanent bounce", notification.SuppressionBounce) } } case "Complaint": _ = h.notifs.MarkComplaint(ctx, inner.Mail.MessageID) if h.suppress != nil { for _, rcp := range inner.Complaint.ComplainedRecipients { _ = h.suppress.Add(ctx, rcp.EmailAddress, "ses complaint", notification.SuppressionComplaint) } } case "Delivery": _ = h.notifs.MarkDelivered(ctx, inner.Mail.MessageID) } h.logger.Info("ses notification", "type", inner.NotificationType, "message_id", inner.Mail.MessageID) w.WriteHeader(http.StatusNoContent) } // Compile-time check that ctx is unused in package — silences linter on // some Go versions when the file would otherwise import context only for // the handler signatures. var _ = context.Background