Files
warmbly/internal/app/compose/service.go
T
Matthew Meszaros 222c9d2554 feat: scope campaign sender resolution to the campaign's organization instead of its owner so a multi-org user can no longer send organization A's campaign from an organization B mailbox: GetByTags/GetAllActiveInScope/GetByCampaignSenders now take a repository.AccountScope keyed on organization_id where a scope with no organization resolves to no mailboxes rather than widening to the owner (tags carry no organization of their own, so one user's tag legitimately spans workspaces and the predicate is the only thing holding the boundary), the campaign scheduler and the preflight tracking-domain check build that scope from campaign.OrganizationID, unibox compose scores only the current workspace's mailboxes, the 'all' lanes of AccountHasActiveCampaign/CountActiveCampaignsForAccount join ea.organization_id = c.organization_id instead of ea.user_id = c.user_id, dead and broken PauseAllByUserID (which wrote the reason string into status and had no callers) is removed, the campaigns/unibox guides now state that senders resolve inside the campaign's workspace, and TestLiveOrglessCampaignDoesNotSendToSuppressedRecipient is updated because an orgless campaign now finds no senders before routing is consulted while the send gate it covers still refuses; live-tested in TestLiveSenderResolutionStaysInsideTheCampaignOrg, TestLiveSenderSchedulerNeverPicksAnotherOrgMailbox, TestLiveSenderSchedulerPicksTheCampaignOrgMailbox, TestLiveSenderScopeWithoutAnOrganizationReachesNothing and TestLiveActiveCampaignLookupIsOrgScoped
2026-08-24 09:20:41 -07:00

269 lines
8.0 KiB
Go

// Package compose picks the sending mailbox for a brand-new outbound email.
// The scoring is deliberately explainable: an existing conversation with the
// recipient dominates (contacts should keep hearing from the same mailbox),
// then remaining daily budget, then domain-auth health. Every candidate
// carries human-readable reasons so the picker can show why, not just what.
package compose
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type Candidate struct {
Account models.Email
SentToday int
DailyLimit int
History int
LastContactAt *time.Time
Score int
Reasons []string
Recommended bool
}
// Remaining is today's unspent send budget, floored at zero.
func (c *Candidate) Remaining() int {
if r := c.DailyLimit - c.SentToday; r > 0 {
return r
}
return 0
}
type Service interface {
// Candidates returns the user's active mailboxes scored against the
// recipient address, sorted best-first, with Recommended set on the
// mailbox auto-send would use. Address may be empty (no affinity signal).
Candidates(ctx context.Context, userID, orgID uuid.UUID, address string) ([]Candidate, *errx.Error)
// Resolve returns the mailbox a compose send should use: the explicit
// accountID when given (validated against the user's active mailboxes),
// otherwise the best-scored candidate. A non-nil tagID scopes the
// automatic pick to mailboxes carrying that tag ("Auto within a tag");
// it is ignored when accountID is explicit. The bool reports whether
// the pick was automatic.
Resolve(ctx context.Context, userID, orgID uuid.UUID, accountID, tagID *uuid.UUID, address string) (*Candidate, bool, *errx.Error)
// Drafts: autosaved per-user working copies from the compose window.
UpsertDraft(ctx context.Context, userID, orgID uuid.UUID, d *repository.ComposeDraft) *errx.Error
ListDrafts(ctx context.Context, userID, orgID uuid.UUID) ([]repository.ComposeDraft, *errx.Error)
DeleteDraft(ctx context.Context, userID, id uuid.UUID) *errx.Error
}
type service struct {
emailRepo repository.EmailRepository
composeRepo repository.ComposeRepository
}
func NewService(emailRepo repository.EmailRepository, composeRepo repository.ComposeRepository) Service {
return &service{emailRepo: emailRepo, composeRepo: composeRepo}
}
func (s *service) Candidates(ctx context.Context, userID, orgID uuid.UUID, address string) ([]Candidate, *errx.Error) {
// Scoped to the caller's current organization, not to the caller: a member of
// two workspaces must not compose from the other workspace's mailboxes.
accounts, xerr := s.emailRepo.GetAllActiveInScope(ctx, repository.NewAccountScope(&orgID))
if xerr != nil {
return nil, xerr
}
if len(accounts) == 0 {
return []Candidate{}, nil
}
ids := make([]uuid.UUID, len(accounts))
for i, a := range accounts {
ids[i] = a.ID
}
sent, err := s.composeRepo.TodaySentCounts(ctx, ids)
if err != nil {
return nil, errx.InternalError()
}
history := map[uuid.UUID]repository.ComposeAffinity{}
address = strings.TrimSpace(address)
if address != "" {
history, err = s.composeRepo.AddressHistoryByAccount(ctx, orgID, address)
if err != nil {
return nil, errx.InternalError()
}
}
candidates := make([]Candidate, 0, len(accounts))
for _, a := range accounts {
c := Candidate{
Account: a,
SentToday: sent[a.ID],
DailyLimit: a.CampaignLimit,
}
if aff, ok := history[a.ID]; ok {
c.History = aff.Messages
c.LastContactAt = aff.LastAt
}
score(&c)
candidates = append(candidates, c)
}
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].Score > candidates[j].Score
})
// Recommend the best mailbox that still has budget today; if every
// mailbox is spent, the top-scored one is still marked so the UI has
// a default (the send itself queues for tomorrow's window anyway).
for i := range candidates {
if candidates[i].Remaining() > 0 {
candidates[i].Recommended = true
break
}
}
if len(candidates) > 0 && !hasRecommended(candidates) {
candidates[0].Recommended = true
}
return candidates, nil
}
func (s *service) Resolve(ctx context.Context, userID, orgID uuid.UUID, accountID, tagID *uuid.UUID, address string) (*Candidate, bool, *errx.Error) {
candidates, xerr := s.Candidates(ctx, userID, orgID, address)
if xerr != nil {
return nil, false, xerr
}
if len(candidates) == 0 {
return nil, false, errx.New(errx.BadRequest, "no active mailboxes to send from; connect a mailbox first")
}
if accountID != nil {
for i := range candidates {
if candidates[i].Account.ID == *accountID {
return &candidates[i], false, nil
}
}
return nil, false, errx.New(errx.NotFound, "mailbox not found or not active")
}
// Tag-scoped auto: restrict the pool to mailboxes carrying the tag,
// then apply the same best-with-budget-first rule within it. GetByTags
// is scoped to the same organization, so a foreign tag id just yields no
// members.
if tagID != nil {
members, merr := s.emailRepo.GetByTags(ctx, repository.NewAccountScope(&orgID), []string{tagID.String()})
if merr != nil {
return nil, false, merr
}
in := make(map[uuid.UUID]bool, len(members))
for _, m := range members {
in[m.ID] = true
}
scoped := candidates[:0:0]
for i := range candidates {
if in[candidates[i].Account.ID] {
scoped = append(scoped, candidates[i])
}
}
if len(scoped) == 0 {
return nil, false, errx.New(errx.BadRequest, "no active mailbox carries the selected tag")
}
for i := range scoped {
if scoped[i].Remaining() > 0 {
return &scoped[i], true, nil
}
}
return &scoped[0], true, nil
}
for i := range candidates {
if candidates[i].Recommended {
return &candidates[i], true, nil
}
}
return &candidates[0], true, nil
}
func (s *service) UpsertDraft(ctx context.Context, userID, orgID uuid.UUID, d *repository.ComposeDraft) *errx.Error {
if err := s.composeRepo.UpsertDraft(ctx, userID, orgID, d); err != nil {
return errx.InternalError()
}
return nil
}
func (s *service) ListDrafts(ctx context.Context, userID, orgID uuid.UUID) ([]repository.ComposeDraft, *errx.Error) {
drafts, err := s.composeRepo.ListDrafts(ctx, userID, orgID)
if err != nil {
return nil, errx.InternalError()
}
return drafts, nil
}
func (s *service) DeleteDraft(ctx context.Context, userID, id uuid.UUID) *errx.Error {
if err := s.composeRepo.DeleteDraft(ctx, userID, id); err != nil {
return errx.InternalError()
}
return nil
}
// score fills Score + Reasons. Affinity dominates (+1000 band), budget and
// auth health order the rest; a spent daily budget pushes the mailbox below
// every fresh one without hiding it from the picker.
func score(c *Candidate) {
remaining := c.Remaining()
c.Score = remaining
if c.History > 0 {
bonus := c.History
if bonus > 50 {
bonus = 50
}
c.Score += 1000 + bonus*10
c.Reasons = append(c.Reasons, fmt.Sprintf("%d past messages with this contact", c.History))
if c.LastContactAt != nil && time.Since(*c.LastContactAt) < 30*24*time.Hour {
c.Score += 50
}
}
switch c.Account.AuthState {
case "passing":
c.Score += 25
c.Reasons = append(c.Reasons, "domain authenticated")
case "failing":
c.Score -= 200
c.Reasons = append(c.Reasons, "domain auth failing")
}
if remaining == 0 {
c.Score -= 5000
c.Reasons = append(c.Reasons, "daily limit reached")
} else {
c.Reasons = append(c.Reasons, fmt.Sprintf("%d of %d sends left today", remaining, c.DailyLimit))
}
}
func hasRecommended(cs []Candidate) bool {
for i := range cs {
if cs[i].Recommended {
return true
}
}
return false
}
// RecommendedReason is the one-line explanation shown next to the Auto pick.
func RecommendedReason(c *Candidate) string {
if c == nil {
return ""
}
if c.History > 0 {
return "already in conversation with this contact"
}
if c.Remaining() > 0 {
return "most sending budget left today"
}
return "least loaded mailbox (all daily budgets spent)"
}