Files
descrybe/apps/api/internal/httpapi/email_handlers.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

242 lines
7.1 KiB
Go

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: "<p>This is a Descrybe email provider test.</p>",
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)
}