package httpapi import ( "errors" "net/http" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/campaigns" "github.com/descrybe/descrybe-v2/apps/api/internal/email" "github.com/jackc/pgx/v5" ) func (s *Server) handleGetEmailIntegration(w http.ResponseWriter, r *http.Request) { if s.Email == nil { Error(w, http.StatusServiceUnavailable, "email integration unavailable") return } cid, _ := CompanyIDFromContext(r.Context()) cfg, err := s.Email.GetConfig(r.Context(), cid) if err != nil { Error(w, http.StatusInternalServerError, "failed to load email settings") return } JSON(w, http.StatusOK, cfg) } func (s *Server) handlePutEmailIntegration(w http.ResponseWriter, r *http.Request) { role, _ := RoleFromContext(r.Context()) if role != "admin" { Error(w, http.StatusForbidden, "admin required") return } if s.Email == nil { Error(w, http.StatusServiceUnavailable, "email integration unavailable") return } cid, _ := CompanyIDFromContext(r.Context()) var body email.UpdateInput if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } cfg, err := s.Email.UpdateConfig(r.Context(), cid, body) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not update email settings", err, email.ClientError) return } JSON(w, http.StatusOK, cfg) } func (s *Server) handleVerifyEmailIntegration(w http.ResponseWriter, r *http.Request) { role, _ := RoleFromContext(r.Context()) if role != "admin" { Error(w, http.StatusForbidden, "admin required") return } if s.Email == nil { Error(w, http.StatusServiceUnavailable, "email integration unavailable") return } cid, _ := CompanyIDFromContext(r.Context()) cfg, msg, err := s.Email.VerifyDomain(r.Context(), cid) if errors.Is(err, email.ErrNotConfigured) { Error(w, http.StatusBadRequest, "email provider not configured") return } if errors.Is(err, email.ErrProviderMisconfig) { Error(w, http.StatusBadRequest, "email provider credentials incomplete") return } if err != nil { ClientOrLog(w, http.StatusBadRequest, "email verification failed", err, email.ClientError) return } JSON(w, http.StatusOK, map[string]any{"config": cfg, "message": msg}) } func (s *Server) handleTestEmailIntegration(w http.ResponseWriter, r *http.Request) { role, _ := RoleFromContext(r.Context()) if role != "admin" { Error(w, http.StatusForbidden, "admin required") return } if s.Email == nil { Error(w, http.StatusServiceUnavailable, "email integration unavailable") return } cid, _ := CompanyIDFromContext(r.Context()) var body struct { To string `json:"to"` Subject string `json:"subject"` Text string `json:"text"` HTML string `json:"html"` } if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } to := strings.TrimSpace(body.To) if to == "" { Error(w, http.StatusBadRequest, "to is required") return } normalized, nerr := campaigns.NormalizeEmail(to) if nerr != nil { Error(w, http.StatusBadRequest, "invalid email") return } to = normalized // Fixed probe content — do not accept client HTML/subject (header injection / phishing via test). result, err := s.Email.Send(r.Context(), cid, email.SendRequest{ To: []string{to}, Subject: "Descrybe email test", Text: "This is a Descrybe email provider test.", HTML: "
This is a Descrybe email provider test.
", Mode: "test", }) if err != nil { writeEmailSendError(w, err) return } JSON(w, http.StatusOK, result) } func (s *Server) handleSendEmail(w http.ResponseWriter, r *http.Request) { role, _ := RoleFromContext(r.Context()) if role != "admin" { Error(w, http.StatusForbidden, "admin required") return } if s.Email == nil { Error(w, http.StatusServiceUnavailable, "email integration unavailable") return } cid, _ := CompanyIDFromContext(r.Context()) if s.Billing != nil { if err := s.Billing.AssertFeatures(r.Context(), cid, "capability.email_live_send", "integrations.email.blast"); err != nil { if writePlanGate(w, err) { return } Error(w, http.StatusInternalServerError, "feature check failed") return } } var body email.SendRequest if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } result, err := s.Email.Send(r.Context(), cid, body) if err != nil { writeEmailSendError(w, err) return } JSON(w, http.StatusOK, result) } func writeEmailSendError(w http.ResponseWriter, err error) { switch { case errors.Is(err, email.ErrMissingConfirm): Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, email.ErrNotVerified): Error(w, http.StatusPreconditionFailed, "email_not_verified") case errors.Is(err, email.ErrNotConfigured): Error(w, http.StatusBadRequest, "email provider not configured") case errors.Is(err, email.ErrNotEnabled): Error(w, http.StatusBadRequest, "email provider is disabled") case errors.Is(err, email.ErrRateLimited): w.Header().Set("Retry-After", "60") Error(w, http.StatusTooManyRequests, "rate limit exceeded") case errors.Is(err, email.ErrProviderMisconfig): Error(w, http.StatusBadRequest, "email provider credentials incomplete") default: LogAndError(w, http.StatusBadRequest, "email send failed", err) } } func (s *Server) handlePublicUnsubscribeGet(w http.ResponseWriter, r *http.Request) { if s.Email == nil { Error(w, http.StatusServiceUnavailable, "email integration unavailable") return } token := strings.TrimSpace(r.URL.Query().Get("token")) _, emailAddr, already, err := s.Email.LookupUnsubscribeToken(r.Context(), token) if errors.Is(err, pgx.ErrNoRows) { Error(w, http.StatusNotFound, "invalid unsubscribe token") return } if err != nil { Error(w, http.StatusInternalServerError, "lookup failed") return } // Mask in response — one-click clients only need status. _ = emailAddr JSON(w, http.StatusOK, map[string]any{ "ok": true, "already_unsubscribed": already, "supports_one_click": true, }) } func (s *Server) handlePublicUnsubscribePost(w http.ResponseWriter, r *http.Request) { if s.Email == nil { Error(w, http.StatusServiceUnavailable, "email integration unavailable") return } token := strings.TrimSpace(r.URL.Query().Get("token")) reason := "" if r.Header.Get("Content-Type") != "" && strings.Contains(r.Header.Get("Content-Type"), "application/json") { var body struct { Token string `json:"token"` Reason string `json:"reason"` } if err := DecodeJSONOptional(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } if body.Token != "" { token = body.Token } reason = body.Reason } else if token == "" { r.Body = http.MaxBytesReader(w, r.Body, 64<<10) if err := r.ParseForm(); err != nil { Error(w, http.StatusBadRequest, "invalid form") return } token = strings.TrimSpace(r.Form.Get("token")) reason = strings.TrimSpace(r.Form.Get("reason")) } info, err := s.Email.UnsubscribeByToken(r.Context(), token, reason) if err != nil { Error(w, http.StatusInternalServerError, "unsubscribe failed") return } if !info.OK { Error(w, http.StatusNotFound, info.Message) return } JSON(w, http.StatusOK, info) }