242 lines
7.1 KiB
Go
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)
|
||
|
|
}
|