mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-20 00:01:21 +00:00
267 lines
7.6 KiB
Go
267 lines
7.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/compose"
|
|
"github.com/warmbly/warmbly/internal/app/emailsend"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// bareAddress extracts the address from a "Display Name <addr@x>" header
|
|
// entry; plain addresses pass through unchanged.
|
|
func bareAddress(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if i := strings.LastIndex(s, "<"); i != -1 {
|
|
if j := strings.Index(s[i:], ">"); j != -1 {
|
|
return strings.TrimSpace(s[i+1 : i+j])
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
type composeCandidateResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
Provider string `json:"provider"`
|
|
AuthState string `json:"auth_state"`
|
|
WarmupActive bool `json:"warmup_active"`
|
|
DailyLimit int `json:"daily_limit"`
|
|
SentToday int `json:"sent_today"`
|
|
RemainingDay int `json:"remaining_today"`
|
|
History int `json:"history_messages"`
|
|
LastContactAt *time.Time `json:"last_contact_at,omitempty"`
|
|
Score int `json:"score"`
|
|
Reasons []string `json:"reasons"`
|
|
Recommended bool `json:"recommended"`
|
|
}
|
|
|
|
func toComposeCandidateResponse(c *compose.Candidate) composeCandidateResponse {
|
|
reasons := c.Reasons
|
|
if reasons == nil {
|
|
reasons = []string{}
|
|
}
|
|
return composeCandidateResponse{
|
|
ID: c.Account.ID,
|
|
Email: c.Account.Email,
|
|
Name: c.Account.Name,
|
|
Provider: c.Account.Provider,
|
|
AuthState: c.Account.AuthState,
|
|
WarmupActive: c.Account.IsWarmingActive(),
|
|
DailyLimit: c.DailyLimit,
|
|
SentToday: c.SentToday,
|
|
RemainingDay: c.Remaining(),
|
|
History: c.History,
|
|
LastContactAt: c.LastContactAt,
|
|
Score: c.Score,
|
|
Reasons: reasons,
|
|
Recommended: c.Recommended,
|
|
}
|
|
}
|
|
|
|
// GetComposeCandidates powers the compose mailbox picker: every active
|
|
// mailbox scored against the recipient, the Auto recommendation with its
|
|
// reason, plus the resolved contact and suppression state for the address.
|
|
// GET /unibox/compose/candidates?to=<address>
|
|
func (h *Handler) GetComposeCandidates(c *gin.Context) {
|
|
if !h.gateUnibox(c) {
|
|
return
|
|
}
|
|
userID, err := middleware.GetUserUUID(c)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrUser)
|
|
return
|
|
}
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
address := bareAddress(c.Query("to"))
|
|
|
|
candidates, xerr := h.ComposeService.Candidates(c.Request.Context(), userID, *orgID, address)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
out := make([]composeCandidateResponse, len(candidates))
|
|
var recommendedID *uuid.UUID
|
|
var recommendedReason string
|
|
for i := range candidates {
|
|
out[i] = toComposeCandidateResponse(&candidates[i])
|
|
if candidates[i].Recommended {
|
|
id := candidates[i].Account.ID
|
|
recommendedID = &id
|
|
recommendedReason = compose.RecommendedReason(&candidates[i])
|
|
}
|
|
}
|
|
|
|
resp := gin.H{
|
|
"accounts": out,
|
|
"recommended_account_id": recommendedID,
|
|
"recommended_reason": recommendedReason,
|
|
"contact": nil,
|
|
"suppression": nil,
|
|
}
|
|
|
|
if address != "" {
|
|
if contact, cerr := h.ContactService.GetByEmail(c.Request.Context(), orgID, address); cerr == nil && contact != nil {
|
|
resp["contact"] = contact
|
|
}
|
|
if h.AdvancedService != nil {
|
|
if suppressed, reason, serr := h.AdvancedService.ShouldSuppressRecipient(c.Request.Context(), *orgID, address); serr == nil && suppressed {
|
|
resp["suppression"] = gin.H{"reason": reason}
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
type UniboxComposeRequest struct {
|
|
// EmailAccountID empty (or "auto") lets the backend pick the best
|
|
// mailbox for the first recipient.
|
|
EmailAccountID string `json:"email_account_id"`
|
|
// FromTagID scopes the automatic pick to mailboxes carrying this tag
|
|
// ("Auto within a tag"). Ignored when EmailAccountID is explicit.
|
|
FromTagID string `json:"from_tag_id"`
|
|
To []string `json:"to" binding:"required,min=1"`
|
|
CC []string `json:"cc"`
|
|
BCC []string `json:"bcc"`
|
|
Subject string `json:"subject" binding:"required"`
|
|
BodyHTML string `json:"body_html"`
|
|
BodyPlain string `json:"body_plain"`
|
|
SendMode string `json:"send_mode"`
|
|
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
|
|
}
|
|
|
|
// UniboxCompose sends a brand-new outbound email (not a reply). Unlike the
|
|
// reply path it enforces org-wide recipient suppression before queueing, and
|
|
// supports auto mailbox selection.
|
|
// POST /unibox/compose
|
|
func (h *Handler) UniboxCompose(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
userID, err := middleware.GetUserUUID(c)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrUser)
|
|
return
|
|
}
|
|
|
|
var req UniboxComposeRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.Handle(c, errx.ErrInvalid)
|
|
return
|
|
}
|
|
|
|
// Suppression gate: a recipient who bounced, complained, or
|
|
// unsubscribed org-wide must not be reachable through compose.
|
|
if h.AdvancedService != nil {
|
|
all := make([]string, 0, len(req.To)+len(req.CC)+len(req.BCC))
|
|
all = append(all, req.To...)
|
|
all = append(all, req.CC...)
|
|
all = append(all, req.BCC...)
|
|
for _, rcpt := range all {
|
|
addr := bareAddress(rcpt)
|
|
if addr == "" {
|
|
continue
|
|
}
|
|
suppressed, reason, serr := h.AdvancedService.ShouldSuppressRecipient(c.Request.Context(), *orgID, addr)
|
|
if serr != nil {
|
|
errx.Handle(c, serr)
|
|
return
|
|
}
|
|
if suppressed {
|
|
msg := addr + " is suppressed for this workspace"
|
|
if reason != "" {
|
|
msg += " (" + reason + ")"
|
|
}
|
|
errx.Handle(c, errx.New(errx.BadRequest, msg))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
var accountID *uuid.UUID
|
|
if v := strings.TrimSpace(req.EmailAccountID); v != "" && v != "auto" {
|
|
id, perr := uuid.Parse(v)
|
|
if perr != nil {
|
|
errx.Handle(c, errx.ErrUuid)
|
|
return
|
|
}
|
|
accountID = &id
|
|
}
|
|
|
|
var tagID *uuid.UUID
|
|
if accountID == nil {
|
|
if v := strings.TrimSpace(req.FromTagID); v != "" {
|
|
id, perr := uuid.Parse(v)
|
|
if perr != nil {
|
|
errx.Handle(c, errx.ErrUuid)
|
|
return
|
|
}
|
|
tagID = &id
|
|
}
|
|
}
|
|
|
|
candidate, auto, xerr := h.ComposeService.Resolve(c.Request.Context(), userID, *orgID, accountID, tagID, bareAddress(req.To[0]))
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
sendReq := &emailsend.SendEmailRequest{
|
|
To: req.To,
|
|
CC: req.CC,
|
|
BCC: req.BCC,
|
|
Subject: req.Subject,
|
|
BodyHTML: req.BodyHTML,
|
|
BodyPlain: req.BodyPlain,
|
|
SendMode: req.SendMode,
|
|
ScheduledAt: req.ScheduledAt,
|
|
}
|
|
if sendReq.SendMode == "" {
|
|
sendReq.SendMode = "instant"
|
|
}
|
|
|
|
resp, xerr := h.EmailSendService.SendEmail(c.Request.Context(), userID, *orgID, candidate.Account.ID, sendReq)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
chosenID := candidate.Account.ID
|
|
h.auditOrg(c, models.AuditActionSend, models.AuditEntityUnibox, &chosenID, nil, map[string]string{
|
|
"compose": "true",
|
|
"auto": strconv.FormatBool(auto),
|
|
"send_mode": sendReq.SendMode,
|
|
"recipients": strconv.Itoa(len(req.To)),
|
|
})
|
|
|
|
out := gin.H{
|
|
"task_id": resp.TaskID,
|
|
"scheduled_at": resp.ScheduledAt,
|
|
"send_mode": resp.SendMode,
|
|
"account_id": candidate.Account.ID,
|
|
"account_email": candidate.Account.Email,
|
|
"auto": auto,
|
|
}
|
|
if auto {
|
|
out["picked_reason"] = compose.RecommendedReason(candidate)
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|