mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-21 16:01:32 +00:00
bd6a045751
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).
70 lines
1.7 KiB
Go
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})
|
|
}
|