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.
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user