Files
warmbly/internal/api/handler/admin_outreach.go
T
Matt bd6a045751 feat(admin): outreach composer (platform mailer + reply-to + audit log)
Adds a dedicated admin path for sending platform email — distinct from
the campaign emailsend service (which sends through customer mailboxes)
so the two abuse surfaces never share code paths.

Schema (000047) adds admin_outreach_messages: every send is recorded
with sent_by, the resolved to_email, the optional reply_to, subject,
body, and a queued → sent/failed status. Failed sends keep their error
column populated for the audit log.

Extends notify.EmailNotificationService with SendOutreach so both
backends (SES + SMTP) support custom Reply-To: SES via the native
ReplyToAddresses field, SMTP via a forged Reply-To header. The
existing transactional Send() remains unchanged so no other caller is
affected.

Service (internal/app/adminoutreach) resolves recipients three ways:
to_email (raw address), to_user_id (sends to the user's account email),
or to_org_id (sends to the workspace owner). Persist-then-send-then-
mark ensures the audit row exists even if the mailer hangs, and
mark-failed captures the error string verbatim.

Routes:
  POST /admin/outreach            manage_organizations
  GET  /admin/outreach            view_organizations

Admin UI: composer with recipient mode picker (email / user_id / org_id),
configurable Reply-To (defaults to support@warmbly.com so customers can
actually reply), subject + HTML body editor, and an outreach log below
showing the last 50 sends with status badges and error details. Sidebar
entry under Accounts (Send icon).
2026-05-28 12:25:18 +02:00

70 lines
1.7 KiB
Go

package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// AdminSendOutreach is POST /admin/outreach.
func (h *Handler) AdminSendOutreach(c *gin.Context) {
if h.AdminOutreachService == nil {
errx.JSON(c, errx.New(errx.Internal, "admin outreach service not available"))
return
}
adminID := middleware.GetAdminUserID(c)
if adminID == nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
var req models.SendAdminOutreachRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
msg, xerr := h.AdminOutreachService.Send(c.Request.Context(), *adminID, &req)
if xerr != nil {
errx.JSON(c, xerr)
return
}
// Audit log entry uses the message id as the target so admin
// audit + outreach log can be joined.
h.AdminService.LogAdminAction(
c.Request.Context(),
*adminID,
"send_admin_outreach",
"admin_outreach_message",
&msg.ID,
map[string]any{
"to_email": msg.ToEmail,
"subject": msg.Subject,
"reply_to": req.ReplyTo,
},
c.ClientIP(),
c.Request.UserAgent(),
)
c.JSON(http.StatusCreated, msg)
}
// AdminListOutreach is GET /admin/outreach?limit=50.
func (h *Handler) AdminListOutreach(c *gin.Context) {
if h.AdminOutreachService == nil {
errx.JSON(c, errx.New(errx.Internal, "admin outreach service not available"))
return
}
limit, _ := strconv.Atoi(c.Query("limit"))
rows, xerr := h.AdminOutreachService.List(c.Request.Context(), limit)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": rows})
}