mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 16:01:16 +00:00
1afe453eb0
Add seed inbox-placement testing with admin management, placement result polling, and seed mailbox persistence. Add pre-send email verification, invalid-recipient skipping, warmup-health campaign gating, and RFC 8058 one-click unsubscribe headers/endpoints.
303 lines
8.6 KiB
Go
303 lines
8.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// placementEntity tags placement actions in the admin audit trail.
|
|
const placementEntity models.AuditEntityType = "placement_test"
|
|
|
|
// --- DTOs --------------------------------------------------------------
|
|
|
|
type placementTestRow struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrganizationID *uuid.UUID `json:"organization_id"`
|
|
SenderAccountID uuid.UUID `json:"sender_account_id"`
|
|
Subject string `json:"subject"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
FinishedAt *time.Time `json:"finished_at"`
|
|
}
|
|
|
|
func toPlacementTestRow(t repository.PlacementTest) placementTestRow {
|
|
return placementTestRow{
|
|
ID: t.ID,
|
|
OrganizationID: t.OrganizationID,
|
|
SenderAccountID: t.SenderAccountID,
|
|
Subject: t.Subject,
|
|
Status: t.Status,
|
|
CreatedAt: t.CreatedAt,
|
|
FinishedAt: t.FinishedAt,
|
|
}
|
|
}
|
|
|
|
type placementResultRow struct {
|
|
SeedAccountID uuid.UUID `json:"seed_account_id"`
|
|
Provider string `json:"provider"`
|
|
Folder string `json:"folder"`
|
|
DetectedAt *time.Time `json:"detected_at"`
|
|
RawFlags string `json:"raw_flags"`
|
|
}
|
|
|
|
// providerRollup aggregates per-provider folder counts for a test.
|
|
type providerRollup struct {
|
|
Provider string `json:"provider"`
|
|
Inbox int `json:"inbox"`
|
|
Promotions int `json:"promotions"`
|
|
Spam int `json:"spam"`
|
|
Other int `json:"other"`
|
|
Pending int `json:"pending"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
type seedAccountRow struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
Provider string `json:"provider"`
|
|
Status string `json:"status"`
|
|
WorkerID *uuid.UUID `json:"worker_id"`
|
|
IsSeed bool `json:"is_seed"`
|
|
}
|
|
|
|
func toSeedRow(s repository.SeedAccount) seedAccountRow {
|
|
return seedAccountRow{
|
|
ID: s.ID,
|
|
Email: s.Email,
|
|
Name: s.Name,
|
|
Provider: s.Provider,
|
|
Status: s.Status,
|
|
WorkerID: s.WorkerID,
|
|
IsSeed: s.IsSeed,
|
|
}
|
|
}
|
|
|
|
// --- Tests -------------------------------------------------------------
|
|
|
|
type createPlacementTestRequest struct {
|
|
SenderAccountID string `json:"sender_account_id"`
|
|
Subject string `json:"subject"`
|
|
BodyPlain string `json:"body_plain"`
|
|
BodyHTML string `json:"body_html"`
|
|
}
|
|
|
|
// AdminCreatePlacementTest sends a tokenized copy of a template through a chosen
|
|
// sender to every active seed mailbox, recording one pending result per seed.
|
|
func (h *Handler) AdminCreatePlacementTest(c *gin.Context) {
|
|
if h.PlacementService == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
|
|
return
|
|
}
|
|
|
|
var req createPlacementTestRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
senderID, err := uuid.Parse(req.SenderAccountID)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid sender_account_id"))
|
|
return
|
|
}
|
|
|
|
test, serr := h.PlacementService.CreateTest(c.Request.Context(), nil, senderID, req.Subject, req.BodyPlain, req.BodyHTML)
|
|
if serr != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, serr.Error()))
|
|
return
|
|
}
|
|
|
|
h.audit(c, models.AuditActionCreate, placementEntity, &test.ID, map[string]string{
|
|
"sender_account_id": senderID.String(),
|
|
"subject": test.Subject,
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{"data": toPlacementTestRow(*test)})
|
|
}
|
|
|
|
// AdminListPlacementTests lists placement tests, newest first.
|
|
func (h *Handler) AdminListPlacementTests(c *gin.Context) {
|
|
if h.PlacementRepo == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
|
|
return
|
|
}
|
|
offset, ok := decodeOffsetCursor(c.Query("cursor"))
|
|
if !ok {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid cursor"))
|
|
return
|
|
}
|
|
limit := parseLimit(c.Query("limit"), 25)
|
|
|
|
tests, total, err := h.PlacementRepo.ListTests(c.Request.Context(), nil, limit, offset)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
rows := make([]placementTestRow, 0, len(tests))
|
|
for _, t := range tests {
|
|
rows = append(rows, toPlacementTestRow(t))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": rows, "pagination": pageMetaFor(offset, limit, len(tests), total)})
|
|
}
|
|
|
|
// AdminGetPlacementTest returns a test with its per-provider rollup and the
|
|
// per-seed detail rows.
|
|
func (h *Handler) AdminGetPlacementTest(c *gin.Context) {
|
|
if h.PlacementRepo == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
|
|
return
|
|
}
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
|
return
|
|
}
|
|
|
|
test, results, err := h.PlacementRepo.GetTestWithResults(c.Request.Context(), id)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
if test == nil {
|
|
errx.JSON(c, errx.New(errx.NotFound, "placement test not found"))
|
|
return
|
|
}
|
|
|
|
rollups := map[string]*providerRollup{}
|
|
resultRows := make([]placementResultRow, 0, len(results))
|
|
for _, r := range results {
|
|
provider := r.Provider
|
|
if provider == "" {
|
|
provider = "unknown"
|
|
}
|
|
ru, ok := rollups[provider]
|
|
if !ok {
|
|
ru = &providerRollup{Provider: provider}
|
|
rollups[provider] = ru
|
|
}
|
|
ru.Total++
|
|
switch r.Folder {
|
|
case repository.PlacementFolderInbox:
|
|
ru.Inbox++
|
|
case repository.PlacementFolderPromotions:
|
|
ru.Promotions++
|
|
case repository.PlacementFolderSpam:
|
|
ru.Spam++
|
|
case repository.PlacementFolderOther:
|
|
ru.Other++
|
|
default:
|
|
ru.Pending++
|
|
}
|
|
resultRows = append(resultRows, placementResultRow{
|
|
SeedAccountID: r.SeedAccountID,
|
|
Provider: r.Provider,
|
|
Folder: r.Folder,
|
|
DetectedAt: r.DetectedAt,
|
|
RawFlags: r.RawFlags,
|
|
})
|
|
}
|
|
|
|
rollup := make([]providerRollup, 0, len(rollups))
|
|
for _, ru := range rollups {
|
|
rollup = append(rollup, *ru)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"data": gin.H{
|
|
"test": toPlacementTestRow(*test),
|
|
"rollup": rollup,
|
|
"results": resultRows,
|
|
},
|
|
})
|
|
}
|
|
|
|
// --- Seeds -------------------------------------------------------------
|
|
|
|
// AdminListSeedMailboxes lists the configured seed panel.
|
|
func (h *Handler) AdminListSeedMailboxes(c *gin.Context) {
|
|
if h.PlacementRepo == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
|
|
return
|
|
}
|
|
seeds, err := h.PlacementRepo.ListSeedAccounts(c.Request.Context(), false)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
rows := make([]seedAccountRow, 0, len(seeds))
|
|
for _, s := range seeds {
|
|
rows = append(rows, toSeedRow(s))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": rows})
|
|
}
|
|
|
|
// AdminListSeedCandidates lists connected mailboxes an admin can flag as seeds,
|
|
// optionally filtered by an email substring.
|
|
func (h *Handler) AdminListSeedCandidates(c *gin.Context) {
|
|
if h.PlacementRepo == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
|
|
return
|
|
}
|
|
limit := parseLimit(c.Query("limit"), 50)
|
|
candidates, err := h.PlacementRepo.ListSeedCandidates(c.Request.Context(), c.Query("search"), limit)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
rows := make([]seedAccountRow, 0, len(candidates))
|
|
for _, s := range candidates {
|
|
rows = append(rows, toSeedRow(s))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": rows})
|
|
}
|
|
|
|
type setSeedRequest struct {
|
|
IsSeed bool `json:"is_seed"`
|
|
}
|
|
|
|
// AdminSetSeedMailbox toggles is_seed on a mailbox (register/unregister a seed).
|
|
func (h *Handler) AdminSetSeedMailbox(c *gin.Context) {
|
|
if h.PlacementRepo == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
|
|
return
|
|
}
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
|
return
|
|
}
|
|
var req setSeedRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
|
|
seed, err := h.PlacementRepo.GetSeedAccount(c.Request.Context(), id)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
if seed == nil {
|
|
errx.JSON(c, errx.New(errx.NotFound, "mailbox not found"))
|
|
return
|
|
}
|
|
|
|
if err := h.PlacementRepo.SetIsSeed(c.Request.Context(), id, req.IsSeed); err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
|
|
action := models.AuditActionUpdate
|
|
h.audit(c, action, placementEntity, &id, map[string]string{
|
|
"is_seed": map[bool]string{true: "true", false: "false"}[req.IsSeed],
|
|
"email": seed.Email,
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|