mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
Merge remote-tracking branch 'origin/main' into fix/issue-245-resolution
This commit is contained in:
@@ -1148,6 +1148,10 @@ func main() {
|
||||
if instanceSettings != nil {
|
||||
emailService.WireSyncBudget(instanceSettings)
|
||||
}
|
||||
// A restricted organization's mailboxes join the free pool on connect.
|
||||
if aware, ok := emailService.(email.OrgRiskAware); ok {
|
||||
aware.WireOrgRisk(orgRiskRepository)
|
||||
}
|
||||
analyticsRepository := repository.NewAnalyticsRepository(primaryDB)
|
||||
emailAccountErrorRepository := repository.NewEmailAccountErrorRepository(primaryDB)
|
||||
analyticsService = analytics.NewService(analyticsRepository, emailRepostory, campaignRepostory, emailAccountErrorRepository, warmupRepository)
|
||||
@@ -1289,6 +1293,11 @@ func main() {
|
||||
if aware, ok := advancedService.(advanced.AudienceAware); ok {
|
||||
aware.WireAudience(campaignAudienceRepository)
|
||||
}
|
||||
// Preflight's content check weighs attachments the way the send path
|
||||
// does, so the launch dialog and the campaign feed agree on the score.
|
||||
if aware, ok := advancedService.(advanced.AttachmentAware); ok {
|
||||
aware.WireAttachments(attachmentRepoForHandler)
|
||||
}
|
||||
|
||||
// Shared AI tool registry: every tool calls a service-layer function as
|
||||
// the invoking user, so the dashboard agent (M3) and MCP server (M8) can
|
||||
|
||||
@@ -79,6 +79,10 @@ Returns the `AdvancedOutreachSettings` object directly (not wrapped in an envelo
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type="info" title="Content score floor is clamped">
|
||||
`preflight.min_content_score` is stored clamped to `1`-`100`. A value outside that range is corrected on write rather than rejected, so a floor above `100` cannot flag every campaign permanently. Set `preflight.check_content_score` to `false` to turn the check off; the score never blocks or delays a send either way. See [Content checks](/guides/campaigns/).
|
||||
</Callout>
|
||||
|
||||
<Callout type="info" title="Send-time optimization is off by default">
|
||||
`send_time_optimization.enabled` defaults to `false`. Set it to `true` and campaign scheduling holds each send until the recipient's local clock reaches one of `preferred_hours`, resolving the recipient's timezone from the contact's `timezone` custom field, then the country-code suffix of its email domain, then `default_contact_timezone`. It can only delay a send: the campaign window, the mailbox's sending profile, its daily cap, and the campaign end date all still bind. See [Sending behavior](/guides/sending-behavior/).
|
||||
</Callout>
|
||||
|
||||
@@ -138,13 +138,13 @@ Warmbly scores each step's copy out of 100 for the signals spam filters weight:
|
||||
|
||||
You see it in three places:
|
||||
|
||||
- **In the editor**, live as you write a step.
|
||||
- **In the launch dialog**, alongside the other pre-send checks, with the lowest-scoring step named.
|
||||
- **In the editor**, re-scored as you write a step.
|
||||
- **In the launch dialog**, alongside the other pre-send checks, with the lowest-scoring step named. Only email steps are scored; wait and action steps carry no copy.
|
||||
- **In the campaign activity feed**, if the copy that actually goes out scores below your floor.
|
||||
|
||||
That last one catches what the first two cannot. The editor scores the template; the send path scores the message after merge fields, spintax, A/B selection and AI blocks have resolved, which is where a clean template becomes "Hi ," or picks the one spammy spintax branch. It logs once per step per day, not once per recipient.
|
||||
|
||||
**Settings** > **Sending** > **Content checks** turns it off or moves the floor, which defaults to `60`.
|
||||
**Settings** > **Sending** > **Content checks** turns it off or moves the floor, which defaults to `60` and accepts `1` to `100`.
|
||||
|
||||
<Callout type="info" title="Advisory, not a gate">
|
||||
A low score never blocks or delays a send. It is a signal to rewrite, not a verdict: a legitimate email can score badly, and a well-scoring one sent to a bad list will still fail.
|
||||
|
||||
@@ -17,13 +17,14 @@ import (
|
||||
// Read-only: it reports what DNS says right now and leaves the mailbox's stored
|
||||
// auth_state alone. Use RefreshEmailAuthCheck to record the verdict.
|
||||
func (h *Handler) GetEmailAuthCheck(c *gin.Context) {
|
||||
userID, err := middleware.GetUserUUID(c)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.ErrUnauthorized)
|
||||
// The mailbox lookup is organization-scoped; a user id here 404s for everyone.
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
|
||||
res, xerr := h.EmailService.CheckDomainAuth(c.Request.Context(), userID.String(), c.Param("id"))
|
||||
res, xerr := h.EmailService.CheckDomainAuth(c.Request.Context(), orgID.String(), c.Param("id"))
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
@@ -42,13 +43,13 @@ func (h *Handler) GetEmailAuthCheck(c *gin.Context) {
|
||||
// to change it. No Idempotency-Key: the write is derived entirely from public
|
||||
// DNS with no caller input, so repeating it converges on the same row.
|
||||
func (h *Handler) RefreshEmailAuthCheck(c *gin.Context) {
|
||||
userID, err := middleware.GetUserUUID(c)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.ErrUnauthorized)
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
|
||||
res, xerr := h.EmailService.RefreshDomainAuth(c.Request.Context(), userID.String(), c.Param("id"))
|
||||
res, xerr := h.EmailService.RefreshDomainAuth(c.Request.Context(), orgID.String(), c.Param("id"))
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package advanced
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
func emailStep(pos int, subject, body string) models.Sequence {
|
||||
return models.Sequence{
|
||||
Kind: "email",
|
||||
Position: pos,
|
||||
Subject: subject,
|
||||
BodyHTML: "<p>" + body + "</p>",
|
||||
BodyPlain: body,
|
||||
}
|
||||
}
|
||||
|
||||
// A wait or action node has no subject and no body. Scoring it as copy made
|
||||
// every campaign that used one fail preflight with "scores 55/100 for spam
|
||||
// signals" about a step that was never an email.
|
||||
func TestWorstStepContentScoreSkipsNonEmailSteps(t *testing.T) {
|
||||
good := strings.Repeat("A real sentence about the recipient's work. ", 5)
|
||||
seqs := []models.Sequence{
|
||||
emailStep(0, "Quick question about hiring", good),
|
||||
{Kind: "wait", Position: 1},
|
||||
{Kind: "action", Position: 2},
|
||||
emailStep(3, "Following up on my note", good),
|
||||
}
|
||||
|
||||
worst, _, _, scored := worstStepContentScore(seqs, 0)
|
||||
if scored != 2 {
|
||||
t.Errorf("scored %d steps, want the 2 email steps", scored)
|
||||
}
|
||||
if worst != 100 {
|
||||
t.Errorf("clean campaign scored %d, want 100", worst)
|
||||
}
|
||||
}
|
||||
|
||||
// A campaign of nothing but control nodes has no copy to judge, which must read
|
||||
// as "nothing to score" rather than as a perfect or a failing score.
|
||||
func TestWorstStepContentScoreReportsNothingToScore(t *testing.T) {
|
||||
_, _, _, scored := worstStepContentScore([]models.Sequence{
|
||||
{Kind: "wait", Position: 0},
|
||||
{Kind: "action", Position: 1},
|
||||
}, 0)
|
||||
if scored != 0 {
|
||||
t.Errorf("scored %d steps, want 0", scored)
|
||||
}
|
||||
}
|
||||
|
||||
// The reported step number must be the step's position, so preflight and the
|
||||
// per-send warning name the same step.
|
||||
func TestWorstStepContentScoreReportsThePositionOfTheWorstStep(t *testing.T) {
|
||||
good := strings.Repeat("A real sentence about the recipient's work. ", 5)
|
||||
seqs := []models.Sequence{
|
||||
emailStep(0, "Quick question about hiring", good),
|
||||
{Kind: "wait", Position: 1},
|
||||
emailStep(2, "FREE CASH PRIZE GUARANTEED!!!", "Act now, click here, 100% free, risk free."),
|
||||
}
|
||||
|
||||
worst, step, issue, scored := worstStepContentScore(seqs, 0)
|
||||
if scored != 2 {
|
||||
t.Fatalf("scored %d steps, want 2", scored)
|
||||
}
|
||||
if step != 3 {
|
||||
t.Errorf("worst step reported as %d, want 3 (position 2)", step)
|
||||
}
|
||||
if worst >= 60 {
|
||||
t.Errorf("obviously spammy copy scored %d, want it below the default floor", worst)
|
||||
}
|
||||
if issue == "" {
|
||||
t.Error("no leading issue reported for the worst step")
|
||||
}
|
||||
}
|
||||
|
||||
// An empty step list falls out with nothing scored: the caller reports that
|
||||
// rather than treating it as passing content.
|
||||
func TestWorstStepContentScoreOnEmptyCampaign(t *testing.T) {
|
||||
if _, _, _, scored := worstStepContentScore(nil, 0); scored != 0 {
|
||||
t.Errorf("scored %d steps on an empty campaign, want 0", scored)
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments are campaign-wide, so preflight weighs them the way the send path
|
||||
// does instead of reporting a score the activity feed later contradicts.
|
||||
func TestWorstStepContentScoreCountsAttachments(t *testing.T) {
|
||||
good := strings.Repeat("A real sentence about the recipient's work. ", 5)
|
||||
seqs := []models.Sequence{emailStep(0, "Quick question about hiring", good)}
|
||||
|
||||
clean, _, _, _ := worstStepContentScore(seqs, 0)
|
||||
withAtt, _, _, _ := worstStepContentScore(seqs, 2)
|
||||
if withAtt >= clean {
|
||||
t.Errorf("attachment score %d not below the clean %d", withAtt, clean)
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,10 @@ type service struct {
|
||||
dispatcher EventDispatcher
|
||||
// audienceRepo measures a campaign's list for the preflight report.
|
||||
// Optional/nil-safe: without it the list check is simply absent.
|
||||
audienceRepo repository.CampaignAudienceRepository
|
||||
audienceRepo repository.CampaignAudienceRepository
|
||||
// attachmentRepo lets preflight weigh attachments as the send path does.
|
||||
// Optional/nil-safe: without it the content check scores none.
|
||||
attachmentRepo repository.AttachmentRepository
|
||||
notifier Notifier
|
||||
realtime ReplyRealtimePublisher
|
||||
automationRunner AutomationRunner
|
||||
@@ -222,6 +225,7 @@ func (s *service) UpdateOrganizationSettings(ctx context.Context, organizationID
|
||||
if settings == nil {
|
||||
return errx.New(errx.BadRequest, "settings are required")
|
||||
}
|
||||
settings.Normalize()
|
||||
if err := s.repo.UpsertOutreachSettings(ctx, organizationID, updatedBy, settings); err != nil {
|
||||
return toErrx(err)
|
||||
}
|
||||
@@ -247,6 +251,7 @@ func (s *service) UpdateCampaignSettings(ctx context.Context, campaignID uuid.UU
|
||||
if settings == nil {
|
||||
return errx.New(errx.BadRequest, "settings are required")
|
||||
}
|
||||
settings.Normalize()
|
||||
if err := s.repo.UpsertCampaignAdvancedSettings(ctx, campaignID, settings); err != nil {
|
||||
return toErrx(err)
|
||||
}
|
||||
@@ -1760,11 +1765,40 @@ func (s *service) ProcessRetryableDeadLetters(ctx context.Context) (int, *errx.E
|
||||
return retried, nil
|
||||
}
|
||||
|
||||
// worstStepContentScore returns the lowest-scoring email step's score, number,
|
||||
// leading issue, and how many steps were scored. Only email steps carry copy: a
|
||||
// wait or action node would otherwise score as the campaign's worst content.
|
||||
func worstStepContentScore(seqs []models.Sequence, attachments int) (worst, worstStep int, issue string, scored int) {
|
||||
worst = 101
|
||||
for _, seq := range seqs {
|
||||
if seq.Kind != "" && seq.Kind != "email" {
|
||||
continue
|
||||
}
|
||||
scored++
|
||||
r := warmlint.ScoreWithAttachments(seq.Subject, seq.BodyHTML, seq.BodyPlain, attachments)
|
||||
if r.Score >= worst {
|
||||
continue
|
||||
}
|
||||
worst, worstStep, issue = r.Score, seq.Position+1, ""
|
||||
for _, is := range r.Issues {
|
||||
if is.Severity == "high" {
|
||||
issue = is.Message
|
||||
break
|
||||
}
|
||||
}
|
||||
if issue == "" && len(r.Issues) > 0 {
|
||||
issue = r.Issues[0].Message
|
||||
}
|
||||
}
|
||||
return worst, worstStep, issue, scored
|
||||
}
|
||||
|
||||
// contentScoreCheck scores every step's copy and reports the worst. A step list
|
||||
// it could not read reports as FAILED, not passed: a check that did not run
|
||||
// must never look like one that succeeded.
|
||||
func (s *service) contentScoreCheck(ctx context.Context, campaignID uuid.UUID, floor int, recommendations *[]string) models.PreflightCheckResult {
|
||||
if floor <= 0 {
|
||||
// Out of range means a row written before the floor was clamped.
|
||||
if floor <= 0 || floor > 100 {
|
||||
floor = 60
|
||||
}
|
||||
seqs, err := s.campaignRepo.GetSequencesByCampaignID(ctx, campaignID)
|
||||
@@ -1787,21 +1821,32 @@ func (s *service) contentScoreCheck(ctx context.Context, campaignID uuid.UUID, f
|
||||
}
|
||||
}
|
||||
|
||||
worst, worstStep, issue := 101, 0, ""
|
||||
for i, seq := range seqs {
|
||||
r := warmlint.Score(seq.Subject, seq.BodyHTML, seq.BodyPlain)
|
||||
if r.Score >= worst {
|
||||
continue
|
||||
}
|
||||
worst, worstStep, issue = r.Score, i+1, ""
|
||||
for _, is := range r.Issues {
|
||||
if is.Severity == "high" {
|
||||
issue = is.Message
|
||||
break
|
||||
// Attachments are campaign-wide and the send path scores them, so preflight
|
||||
// weighs them too rather than reporting a score the feed later contradicts.
|
||||
attachments := 0
|
||||
if s.attachmentRepo != nil {
|
||||
atts, aerr := s.attachmentRepo.ListByCampaign(ctx, campaignID)
|
||||
if aerr != nil {
|
||||
// Scoring as none would pass copy the send path then warns about.
|
||||
*recommendations = append(*recommendations, "Re-run preflight; the campaign's attachments could not be read.")
|
||||
return models.PreflightCheckResult{
|
||||
Key: "content_score",
|
||||
Passed: false,
|
||||
Severity: "warning",
|
||||
Message: "Could not read the campaign's attachments to score its copy.",
|
||||
Remediation: "Re-run preflight.",
|
||||
}
|
||||
}
|
||||
if issue == "" && len(r.Issues) > 0 {
|
||||
issue = r.Issues[0].Message
|
||||
attachments = len(atts)
|
||||
}
|
||||
|
||||
worst, worstStep, issue, scored := worstStepContentScore(seqs, attachments)
|
||||
if scored == 0 {
|
||||
return models.PreflightCheckResult{
|
||||
Key: "content_score",
|
||||
Passed: true,
|
||||
Severity: "info",
|
||||
Message: "No email steps to score yet.",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1862,3 +1907,17 @@ func (s *service) WireAudience(r repository.CampaignAudienceRepository) {
|
||||
type AudienceAware interface {
|
||||
WireAudience(r repository.CampaignAudienceRepository)
|
||||
}
|
||||
|
||||
// WireAttachments attaches the campaign attachment counter the content check
|
||||
// scores with, so preflight and the send path weigh attachments the same way.
|
||||
func (s *service) WireAttachments(r repository.AttachmentRepository) {
|
||||
s.attachmentRepo = r
|
||||
}
|
||||
|
||||
// AttachmentAware is the optional capability the caller uses to attach it.
|
||||
type AttachmentAware interface {
|
||||
WireAttachments(r repository.AttachmentRepository)
|
||||
}
|
||||
|
||||
// main attaches this by type assertion, which fails silently, so pin it here.
|
||||
var _ AttachmentAware = (*service)(nil)
|
||||
|
||||
@@ -216,8 +216,8 @@ func (s *emailService) resolveTrackingDomain(ctx context.Context, domain string)
|
||||
|
||||
// CheckDomainAuth runs a live SPF/DKIM/DMARC lookup for a mailbox's sending
|
||||
// domain and reports it without writing anything.
|
||||
func (s *emailService) CheckDomainAuth(ctx context.Context, userID, emailAccountID string) (*dnsauth.Result, *errx.Error) {
|
||||
_, res, xerr := s.resolveDomainAuth(ctx, userID, emailAccountID)
|
||||
func (s *emailService) CheckDomainAuth(ctx context.Context, orgID, emailAccountID string) (*dnsauth.Result, *errx.Error) {
|
||||
_, res, xerr := s.resolveDomainAuth(ctx, orgID, emailAccountID)
|
||||
return res, xerr
|
||||
}
|
||||
|
||||
@@ -229,8 +229,8 @@ func (s *emailService) CheckDomainAuth(ctx context.Context, userID, emailAccount
|
||||
// their DNS would keep being blocked until the background sweep next reached
|
||||
// their domain, which can be a day away, and "I fixed it and nothing happened"
|
||||
// is how a correct gate still becomes a support incident.
|
||||
func (s *emailService) RefreshDomainAuth(ctx context.Context, userID, emailAccountID string) (*dnsauth.Result, *errx.Error) {
|
||||
domain, res, xerr := s.resolveDomainAuth(ctx, userID, emailAccountID)
|
||||
func (s *emailService) RefreshDomainAuth(ctx context.Context, orgID, emailAccountID string) (*dnsauth.Result, *errx.Error) {
|
||||
domain, res, xerr := s.resolveDomainAuth(ctx, orgID, emailAccountID)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
@@ -247,11 +247,10 @@ func (s *emailService) RefreshDomainAuth(ctx context.Context, userID, emailAccou
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// resolveDomainAuth loads the caller's mailbox and runs the DNS lookup for its
|
||||
// sending domain, returning the domain alongside the result so the persisting
|
||||
// caller does not re-derive it.
|
||||
func (s *emailService) resolveDomainAuth(ctx context.Context, userID, emailAccountID string) (string, *dnsauth.Result, *errx.Error) {
|
||||
account, xerr := s.emailRepository.Get(ctx, userID, emailAccountID)
|
||||
// resolveDomainAuth loads the organization's mailbox (Get is org-scoped, never user-scoped)
|
||||
// and runs the DNS lookup, returning the domain so the persisting caller does not re-derive it.
|
||||
func (s *emailService) resolveDomainAuth(ctx context.Context, orgID, emailAccountID string) (string, *dnsauth.Result, *errx.Error) {
|
||||
account, xerr := s.emailRepository.Get(ctx, orgID, emailAccountID)
|
||||
if xerr != nil {
|
||||
return "", nil, xerr
|
||||
}
|
||||
@@ -359,18 +358,36 @@ func (s *emailService) canUseWarmupPool(ctx context.Context, account *models.Ema
|
||||
return err == nil && canWarmup
|
||||
}
|
||||
|
||||
// orgSuspendedOrRestricted reports whether the workspace's posture bars the
|
||||
// paid warmup pool. Fails open.
|
||||
func (s *emailService) orgSuspendedOrRestricted(ctx context.Context, orgID uuid.UUID) bool {
|
||||
if s.orgRiskRepo == nil {
|
||||
return false
|
||||
}
|
||||
states, err := s.orgRiskRepo.GetOrgRiskStates(ctx, []uuid.UUID{orgID})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return states[orgID].ForcesFreeWarmupPool()
|
||||
}
|
||||
|
||||
func (s *emailService) resolveWarmupPoolType(ctx context.Context, account *models.Email) string {
|
||||
if account == nil {
|
||||
return "premium"
|
||||
}
|
||||
if account.WarmupPoolType != "" {
|
||||
return account.WarmupPoolType
|
||||
}
|
||||
// No organization means no entitlement to check, so the mailbox gets the
|
||||
// lower-trust pool rather than defaulting into the paid one.
|
||||
if account.OrganizationID == nil {
|
||||
return "free"
|
||||
}
|
||||
// A restricted organization leaves the paid pool whatever it pays. Checked
|
||||
// before the stored tier, which is never empty and would short-circuit it.
|
||||
if s.orgSuspendedOrRestricted(ctx, *account.OrganizationID) {
|
||||
return "free"
|
||||
}
|
||||
if account.WarmupPoolType != "" {
|
||||
return account.WarmupPoolType
|
||||
}
|
||||
if s.featureGate != nil {
|
||||
isPaid, err := s.featureGate.IsPaidOrganization(ctx, *account.OrganizationID)
|
||||
if err == nil && !isPaid {
|
||||
|
||||
@@ -48,11 +48,11 @@ type EmailService interface {
|
||||
StartTrackingDomainSweep(ctx context.Context, interval, staleAfter time.Duration)
|
||||
// CheckDomainAuth runs a live SPF/DKIM/DMARC lookup for a mailbox's
|
||||
// sending domain and returns it without touching stored state.
|
||||
CheckDomainAuth(ctx context.Context, userID, emailAccountID string) (*dnsauth.Result, *errx.Error)
|
||||
CheckDomainAuth(ctx context.Context, orgID, emailAccountID string) (*dnsauth.Result, *errx.Error)
|
||||
// RefreshDomainAuth does the same and PERSISTS the verdict. That write can
|
||||
// lift the cold-send and warmup gate, so it sits behind the write
|
||||
// permission while CheckDomainAuth stays readable.
|
||||
RefreshDomainAuth(ctx context.Context, userID, emailAccountID string) (*dnsauth.Result, *errx.Error)
|
||||
RefreshDomainAuth(ctx context.Context, orgID, emailAccountID string) (*dnsauth.Result, *errx.Error)
|
||||
Delete(ctx context.Context, userID, emailAccountID string) *errx.Error
|
||||
|
||||
// Onboarding flow
|
||||
@@ -108,8 +108,24 @@ type emailService struct {
|
||||
// (email_account.connected, email_account.removed) are dispatched to
|
||||
// subscribed customer webhooks.
|
||||
webhookService webhook.Service
|
||||
// orgRiskRepo bars a restricted organization from the paid warmup pool.
|
||||
// Optional/nil-safe.
|
||||
orgRiskRepo repository.OrgRiskRepository
|
||||
}
|
||||
|
||||
// WireOrgRisk attaches the organization risk posture.
|
||||
func (s *emailService) WireOrgRisk(r repository.OrgRiskRepository) {
|
||||
s.orgRiskRepo = r
|
||||
}
|
||||
|
||||
// OrgRiskAware is the optional capability the caller uses to attach org risk.
|
||||
type OrgRiskAware interface {
|
||||
WireOrgRisk(r repository.OrgRiskRepository)
|
||||
}
|
||||
|
||||
// main.go attaches the posture by type assertion, which fails silently.
|
||||
var _ OrgRiskAware = (*emailService)(nil)
|
||||
|
||||
// SyncBudgetSource is the operator-editable sync fair-use section, satisfied
|
||||
// by instancesettings.Service. Injected post-construction; when unset the
|
||||
// loader ships compiled defaults.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
type poolTypeRiskRepo struct {
|
||||
repository.OrgRiskRepository
|
||||
states map[uuid.UUID]models.OrgRiskState
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *poolTypeRiskRepo) GetOrgRiskStates(_ context.Context, _ []uuid.UUID) (map[uuid.UUID]models.OrgRiskState, error) {
|
||||
return r.states, r.err
|
||||
}
|
||||
|
||||
// A mailbox connected under a restricted workspace joins the free pool, not the
|
||||
// tier its worker assignment wrote (issue #242).
|
||||
func TestResolveWarmupPoolTypeDemotesARestrictedOrganization(t *testing.T) {
|
||||
org := uuid.New()
|
||||
account := &models.Email{ID: uuid.New(), OrganizationID: &org, Status: "active", WarmupPoolType: "premium"}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
risk *poolTypeRiskRepo
|
||||
wants string
|
||||
}{
|
||||
{"restricted", &poolTypeRiskRepo{states: map[uuid.UUID]models.OrgRiskState{org: models.OrgRiskRestricted}}, "free"},
|
||||
{"suspended", &poolTypeRiskRepo{states: map[uuid.UUID]models.OrgRiskState{org: models.OrgRiskSuspended}}, "free"},
|
||||
{"trusted", &poolTypeRiskRepo{states: map[uuid.UUID]models.OrgRiskState{org: models.OrgRiskTrusted}}, "premium"},
|
||||
{"unknown org", &poolTypeRiskRepo{states: map[uuid.UUID]models.OrgRiskState{}}, "premium"},
|
||||
{"lookup failure fails open", &poolTypeRiskRepo{err: errors.New("connection reset by peer")}, "premium"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := &emailService{orgRiskRepo: tc.risk}
|
||||
if got := svc.resolveWarmupPoolType(context.Background(), account); got != tc.wants {
|
||||
t.Fatalf("resolved %q, want %q", got, tc.wants)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Without the risk repository wired (jobs, tests) the stored tier stands.
|
||||
func TestResolveWarmupPoolTypeWithoutRiskUsesTheStoredTier(t *testing.T) {
|
||||
org := uuid.New()
|
||||
svc := &emailService{}
|
||||
account := &models.Email{ID: uuid.New(), OrganizationID: &org, Status: "active", WarmupPoolType: "premium"}
|
||||
|
||||
if got := svc.resolveWarmupPoolType(context.Background(), account); got != "premium" {
|
||||
t.Fatalf("resolved %q, want premium", got)
|
||||
}
|
||||
}
|
||||
@@ -61,10 +61,24 @@ type PreflightValidationSettings struct {
|
||||
// and again per send against the rendered text. Advisory: it warns, it
|
||||
// never blocks a send.
|
||||
CheckContentScore bool `json:"check_content_score"`
|
||||
// MinContentScore is the 0-100 floor below which copy is flagged.
|
||||
// MinContentScore is the 1-100 floor below which copy is flagged.
|
||||
MinContentScore int `json:"min_content_score"`
|
||||
}
|
||||
|
||||
// Normalize clamps the settings an API caller can put out of range, so a stored
|
||||
// value can never make every campaign fail the check or none of them.
|
||||
func (s *AdvancedOutreachSettings) Normalize() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.Preflight.MinContentScore > 100 {
|
||||
s.Preflight.MinContentScore = 100
|
||||
}
|
||||
if s.Preflight.MinContentScore < 1 {
|
||||
s.Preflight.MinContentScore = 1
|
||||
}
|
||||
}
|
||||
|
||||
type DeliverabilityDashboardSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ShowSuppressionLog bool `json:"show_suppression_log"`
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
// The content-score floor reaches the API as a plain integer. Left unclamped, a
|
||||
// floor above 100 flags every campaign forever and a floor at or below 0 is a
|
||||
// control that does nothing, since the readers fall back to the default.
|
||||
func TestNormalizeClampsTheContentScoreFloor(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want int }{
|
||||
{-40, 1},
|
||||
{0, 1},
|
||||
{1, 1},
|
||||
{60, 60},
|
||||
{100, 100},
|
||||
{5000, 100},
|
||||
} {
|
||||
s := DefaultAdvancedOutreachSettings()
|
||||
s.Preflight.MinContentScore = tc.in
|
||||
s.Normalize()
|
||||
if s.Preflight.MinContentScore != tc.want {
|
||||
t.Errorf("floor %d normalized to %d, want %d", tc.in, s.Preflight.MinContentScore, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLeavesTheDefaultsAlone(t *testing.T) {
|
||||
s := DefaultAdvancedOutreachSettings()
|
||||
before := s
|
||||
s.Normalize()
|
||||
if s.Preflight != before.Preflight {
|
||||
t.Errorf("defaults changed under Normalize: %+v -> %+v", before.Preflight, s.Preflight)
|
||||
}
|
||||
}
|
||||
@@ -108,9 +108,7 @@ type WarmupPoolHealthSummary struct {
|
||||
ByState map[string]int `json:"by_state"`
|
||||
AvgSpamScore float64 `json:"avg_spam_score"`
|
||||
AvgSpamPlacement float64 `json:"avg_spam_placement_rate"`
|
||||
// SpamPlacementByProvider breaks recent spam-placement counts down by the
|
||||
// recipient provider so the admin can see where warmup mail is being
|
||||
// filtered (e.g. mostly at Outlook vs Gmail) rather than one flat rate.
|
||||
// Keyed by who runs the recipient's mail, the vocabulary routing reads.
|
||||
SpamPlacementByProvider map[string]int `json:"spam_placement_by_provider"`
|
||||
BlockedCount int `json:"blocked_count"`
|
||||
AtRiskCount int `json:"at_risk_count"`
|
||||
|
||||
@@ -13,7 +13,8 @@ import (
|
||||
var (
|
||||
stackedPunct = regexp.MustCompile(`[!?]{2,}`)
|
||||
wordToken = regexp.MustCompile(`[a-z0-9%]+`)
|
||||
linkPattern = regexp.MustCompile(`https?://`)
|
||||
linkPattern = regexp.MustCompile(`https?://[^\s"'<>)\]]*`)
|
||||
hrefPattern = regexp.MustCompile(`(?i)href\s*=\s*["']?\s*(https?://[^\s"'<>]*)`)
|
||||
htmlTag = regexp.MustCompile(`(?i)<[a-z!/][^>]*>`)
|
||||
imgTag = regexp.MustCompile(`(?i)<img\b[^>]*>`)
|
||||
)
|
||||
@@ -99,7 +100,7 @@ func Score(subject, bodyHTML, bodyPlain string) ScoreResult {
|
||||
if subj == "" {
|
||||
deduct(20, "high", "empty_subject", "Subject is empty.")
|
||||
} else if isAllCaps(subj) {
|
||||
deduct(15, "high", "all_caps_subject", "Subject is all caps — a strong spam signal.")
|
||||
deduct(15, "high", "all_caps_subject", "Subject is all caps, a strong spam signal.")
|
||||
}
|
||||
if stackedPunct.MatchString(combined) {
|
||||
deduct(10, "warn", "stacked_punctuation", "Stacked punctuation (e.g. !!! or ?!) reads as promotional.")
|
||||
@@ -115,12 +116,12 @@ func Score(subject, bodyHTML, bodyPlain string) ScoreResult {
|
||||
}
|
||||
deduct(d, severity, "spam_trigger_terms", fmt.Sprintf("%d spam-trigger term(s) found in subject/body.", n))
|
||||
}
|
||||
if links := len(linkPattern.FindAllString(combined, -1)); links > 3 {
|
||||
if links := countLinks(combined, bodyHTML); links > 3 {
|
||||
d := (links - 3) * 5
|
||||
if d > 20 {
|
||||
d = 20
|
||||
}
|
||||
deduct(d, "warn", "too_many_links", fmt.Sprintf("%d links — keep cold-email link count low.", links))
|
||||
deduct(d, "warn", "too_many_links", fmt.Sprintf("%d links. Keep the link count low in cold email.", links))
|
||||
}
|
||||
if strings.TrimSpace(body) == "" {
|
||||
deduct(25, "high", "empty_body", "Body has no text content (image-only or empty body hurts deliverability).")
|
||||
@@ -134,13 +135,13 @@ func Score(subject, bodyHTML, bodyPlain string) ScoreResult {
|
||||
switch {
|
||||
case len(strings.TrimSpace(body)) < 200 && images >= 1:
|
||||
deduct(20, "high", "image_heavy",
|
||||
"Almost all of this email is images — filters cannot read it and treat that as evasion.")
|
||||
"Almost all of this email is images. Filters cannot read it and treat that as evasion.")
|
||||
case images > 3:
|
||||
d := (images - 3) * 5
|
||||
if d > 15 {
|
||||
d = 15
|
||||
}
|
||||
deduct(d, "warn", "many_images", fmt.Sprintf("%d images — cold email from a person rarely has many.", images))
|
||||
deduct(d, "warn", "many_images", fmt.Sprintf("%d images. Cold email from a person rarely has many.", images))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +165,7 @@ func ScoreWithAttachments(subject, bodyHTML, bodyPlain string, attachments int)
|
||||
res.Issues = append(res.Issues, Issue{
|
||||
Severity: "warn",
|
||||
Code: "has_attachments",
|
||||
Message: fmt.Sprintf("%d attachment(s) on a cold email — link to the file instead.", attachments),
|
||||
Message: fmt.Sprintf("%d attachment(s) on a cold email. Link to the file instead.", attachments),
|
||||
})
|
||||
}
|
||||
return res
|
||||
@@ -187,6 +188,31 @@ func isAllCaps(s string) bool {
|
||||
return letters >= 4
|
||||
}
|
||||
|
||||
// countLinks counts every anchor plus any bare URL in the text that is not
|
||||
// already an anchor's destination. Stripping tags throws hrefs away, so the text
|
||||
// alone reports zero links for an HTML email; matching destinations keeps a URL
|
||||
// used as its own anchor text from counting twice.
|
||||
func countLinks(text, bodyHTML string) int {
|
||||
destinations := map[string]struct{}{}
|
||||
n := 0
|
||||
for _, m := range hrefPattern.FindAllStringSubmatch(bodyHTML, -1) {
|
||||
destinations[trimURL(m[1])] = struct{}{}
|
||||
n++
|
||||
}
|
||||
for _, u := range linkPattern.FindAllString(text, -1) {
|
||||
if _, seen := destinations[trimURL(u)]; !seen {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// trimURL drops the sentence punctuation a URL picks up in prose, so the same
|
||||
// link matches whether it was written inline or as an anchor's destination.
|
||||
func trimURL(u string) string {
|
||||
return strings.TrimRight(u, ".,;:!?)]}\"'")
|
||||
}
|
||||
|
||||
func countTriggerTerms(text string) int {
|
||||
lower := strings.ToLower(text)
|
||||
found := map[string]struct{}{}
|
||||
|
||||
@@ -83,3 +83,79 @@ func TestScoreNeverGoesNegative(t *testing.T) {
|
||||
t.Error("obviously spammy copy produced no issues")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreCountsLinksInHTMLAnchors(t *testing.T) {
|
||||
// A normal HTML email carries its URLs in href attributes. Stripping tags
|
||||
// throws those away, so counting the text alone reported zero links and the
|
||||
// cap never fired for the case it exists for.
|
||||
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
|
||||
html := "<p>" + body + "</p><p>" +
|
||||
strings.Repeat(`<a href="https://example.com/x">see this</a> `, 6) + "</p>"
|
||||
|
||||
res := Score("Quick question", html, "")
|
||||
if !hasIssue(res, "too_many_links") {
|
||||
t.Errorf("six anchors in an HTML body were not counted: %+v", res.Issues)
|
||||
}
|
||||
|
||||
// The editor stores a plain-text body derived from the HTML, which also
|
||||
// drops the hrefs. The score must not depend on which one is present.
|
||||
res = Score("Quick question", html, body)
|
||||
if !hasIssue(res, "too_many_links") {
|
||||
t.Errorf("six anchors alongside a link-free plain body were not counted: %+v", res.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreDoesNotDoubleCountSelfLinkingAnchors(t *testing.T) {
|
||||
// A URL used as its own anchor text appears in both the href and the text.
|
||||
// Counting both would flag three links as six.
|
||||
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
|
||||
html := "<p>" + body + "</p><p>" +
|
||||
`<a href="https://a.com/1">https://a.com/1</a> ` +
|
||||
`<a href="https://b.com/2">https://b.com/2</a> ` +
|
||||
`<a href="https://c.com/3">https://c.com/3</a>` + "</p>"
|
||||
plain := body + " https://a.com/1 https://b.com/2 https://c.com/3"
|
||||
|
||||
res := Score("Quick question", html, plain)
|
||||
if hasIssue(res, "too_many_links") {
|
||||
t.Errorf("three self-linking anchors were counted as more: %+v", res.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreStillCountsPlainTextURLs(t *testing.T) {
|
||||
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
|
||||
plain := body + " https://a.com/1 https://b.com/2 https://c.com/3 https://d.com/4 https://e.com/5"
|
||||
|
||||
res := Score("Quick question", "", plain)
|
||||
if !hasIssue(res, "too_many_links") {
|
||||
t.Errorf("five bare URLs in a plain body were not counted: %+v", res.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreCountsAnchorsAndBareURLsTogether(t *testing.T) {
|
||||
// Labeled anchors and bare URLs are different destinations. Counting only
|
||||
// the larger of the two sets let six distinct links score a clean 100.
|
||||
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
|
||||
html := "<p>" + body + "</p><p>" +
|
||||
`<a href="https://a.com/1">one</a> <a href="https://b.com/2">two</a> <a href="https://c.com/3">three</a> ` +
|
||||
"https://d.com/4 https://e.com/5 https://f.com/6</p>"
|
||||
plain := body + " one two three https://d.com/4 https://e.com/5 https://f.com/6"
|
||||
|
||||
res := Score("Quick question", html, plain)
|
||||
if !hasIssue(res, "too_many_links") {
|
||||
t.Errorf("three anchors plus three bare URLs were not counted as six: %+v", res.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreIgnoresTrailingPunctuationWhenMatchingAnchors(t *testing.T) {
|
||||
// A URL that ends a sentence in the plain text is the same link as the
|
||||
// anchor's destination, so it must not count a second time.
|
||||
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
|
||||
html := "<p>" + body + "</p><p>" +
|
||||
`<a href="https://a.com/1">https://a.com/1</a>, <a href="https://b.com/2">https://b.com/2</a>.` + "</p>"
|
||||
plain := body + " https://a.com/1, https://b.com/2."
|
||||
|
||||
res := Score("Quick question", html, plain)
|
||||
if hasIssue(res, "too_many_links") {
|
||||
t.Errorf("two self-linking anchors counted as more than two: %+v", res.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1265,7 +1265,7 @@ func (r *campaignRepository) GetSequenceByID(ctx context.Context, sequenceID uui
|
||||
// GetSequencesByCampaignID retrieves all sequences for a campaign ordered by position
|
||||
func (r *campaignRepository) GetSequencesByCampaignID(ctx context.Context, campaignID uuid.UUID) ([]models.Sequence, error) {
|
||||
query := `
|
||||
SELECT id, name, subject, body_plain, body_html, body_sync, body_code, wait_after, position, updated_at, created_at
|
||||
SELECT id, name, subject, body_plain, body_html, body_sync, body_code, wait_after, position, kind, updated_at, created_at
|
||||
FROM sequences
|
||||
WHERE campaign_id = $1
|
||||
ORDER BY position ASC, created_at ASC
|
||||
@@ -1283,7 +1283,7 @@ func (r *campaignRepository) GetSequencesByCampaignID(ctx context.Context, campa
|
||||
var seq models.Sequence
|
||||
err := rows.Scan(
|
||||
&seq.ID, &seq.Name, &seq.Subject, &seq.BodyPlain, &seq.BodyHTML,
|
||||
&seq.BodySync, &seq.BodyCode, &seq.WaitAfter, &seq.Position, &seq.UpdatedAt, &seq.CreatedAt,
|
||||
&seq.BodySync, &seq.BodyCode, &seq.WaitAfter, &seq.Position, &seq.Kind, &seq.UpdatedAt, &seq.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "", nil, "scan")
|
||||
|
||||
@@ -862,12 +862,11 @@ func (r *warmupRepository) PoolSpamPlacementRate(ctx context.Context, since time
|
||||
return float64(placements) / float64(sent) * 100, nil
|
||||
}
|
||||
|
||||
// PoolSpamPlacementsByProvider returns spam-placement counts grouped by the
|
||||
// recipient provider over the window, so the admin overview can show where
|
||||
// warmup mail is being filtered (e.g. mostly at Outlook vs Gmail).
|
||||
// PoolSpamPlacementsByProvider counts spam placements in the window keyed by who
|
||||
// runs the recipient's mail, not the stored connect method.
|
||||
func (r *warmupRepository) PoolSpamPlacementsByProvider(ctx context.Context, since time.Time) (map[string]int, error) {
|
||||
query := `
|
||||
SELECT COALESCE(NULLIF(recipient_provider, ''), 'unknown'), COUNT(*)
|
||||
SELECT recipient_domain, COUNT(*)
|
||||
FROM warmup_spam_reports
|
||||
WHERE report_type = 'spam_placement' AND created_at >= $1
|
||||
GROUP BY 1
|
||||
@@ -880,12 +879,17 @@ func (r *warmupRepository) PoolSpamPlacementsByProvider(ctx context.Context, sin
|
||||
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var provider string
|
||||
var domain string
|
||||
var n int
|
||||
if err := rows.Scan(&provider, &n); err != nil {
|
||||
if err := rows.Scan(&domain, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[provider] = n
|
||||
// A domainless row belongs to no provider, so it stays out of custom.
|
||||
key := "unknown"
|
||||
if domain != "" {
|
||||
key = string(models.ClassifyProvider(domain))
|
||||
}
|
||||
out[key] += n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -938,10 +942,13 @@ func (r *warmupRepository) SenderPlacementByProvider(ctx context.Context, sender
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A blank domain is unattributable and the send side never produces one, so
|
||||
// counting it would demote every custom-domain partner for nobody's failure.
|
||||
placementRows, err := r.db.Query(ctx, `
|
||||
SELECT recipient_domain, COUNT(*)
|
||||
FROM warmup_spam_reports
|
||||
WHERE reported_account_id = $1 AND report_type = 'spam_placement' AND created_at >= $2
|
||||
AND recipient_domain <> ''
|
||||
GROUP BY 1
|
||||
`, senderAccountID, since)
|
||||
if err != nil {
|
||||
|
||||
@@ -496,6 +496,8 @@ func (r *workerRepository) ClearEmailAccountWorker(ctx context.Context, emailAcc
|
||||
// UpdateEmailAccountWarmupPoolType writes the tier and moves the mailbox's pool membership to
|
||||
// match in one transaction: they record the same fact, and updating only the column left
|
||||
// downgraded mailboxes in the premium pool (issue #211). A mailbox in no pool stays in none.
|
||||
// The move into premium is refused while the organization is restricted (issue #242); the tier
|
||||
// column is still written, since it records what the workspace pays for, not where it warms.
|
||||
func (r *workerRepository) UpdateEmailAccountWarmupPoolType(ctx context.Context, emailAccountID uuid.UUID, poolType string) error {
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -515,7 +517,14 @@ func (r *workerRepository) UpdateEmailAccountWarmupPoolType(ctx context.Context,
|
||||
FROM warmup_pools wp
|
||||
WHERE wp.pool_type = $1::warmup_pool_type
|
||||
AND wpp.email_account_id = $2::uuid
|
||||
AND wpp.pool_id <> wp.id`,
|
||||
AND wpp.pool_id <> wp.id
|
||||
AND ($1::text <> 'premium' OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM email_accounts ea
|
||||
JOIN organizations o ON o.id = ea.organization_id
|
||||
WHERE ea.id = $2::uuid
|
||||
AND o.risk_state IN ('restricted', 'suspended')
|
||||
))`,
|
||||
poolType, emailAccountID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GetSequencesByCampaignID did not select `kind`, so every step came back
|
||||
// looking like an email. Preflight's content check then scored wait and action
|
||||
// nodes as copy and reported their empty subject and body as the campaign's
|
||||
// worst content.
|
||||
//
|
||||
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
|
||||
// go test ./internal/repository/ -run LiveSequenceKind -v
|
||||
func TestLiveSequenceKindSurvivesTheRoundTrip(t *testing.T) {
|
||||
handle, pool := liveContactDB(t)
|
||||
f := newSharedOrgFixture(t, pool)
|
||||
repo := NewCampaignRepostory(handle)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`DELETE FROM sequences WHERE campaign_id = $1`, f.campaign); err != nil {
|
||||
t.Errorf("cleanup sequences: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, step := range []struct {
|
||||
pos int
|
||||
kind string
|
||||
name string
|
||||
}{{0, "email", "Intro"}, {1, "wait", "Hold"}, {2, "action", "Tag"}} {
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO sequences (id, campaign_id, organization_id, name, subject, body_plain, body_html, wait_after, position, kind)
|
||||
VALUES ($1, $2, $3, $4, '', '', '', 0, $5, $6)`,
|
||||
uuid.New(), f.campaign, f.org, step.name, step.pos, step.kind); err != nil {
|
||||
t.Fatalf("insert %s step: %v", step.kind, err)
|
||||
}
|
||||
}
|
||||
|
||||
seqs, err := repo.GetSequencesByCampaignID(ctx, f.campaign)
|
||||
if err != nil {
|
||||
t.Fatalf("get sequences: %v", err)
|
||||
}
|
||||
if len(seqs) != 3 {
|
||||
t.Fatalf("got %d steps, want 3", len(seqs))
|
||||
}
|
||||
for i, want := range []string{"email", "wait", "action"} {
|
||||
if seqs[i].Kind != want {
|
||||
t.Errorf("step %d kind = %q, want %q", i, seqs[i].Kind, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type providerRoutingFixture struct {
|
||||
sender uuid.UUID
|
||||
atGoogle uuid.UUID
|
||||
atMSGraph uuid.UUID
|
||||
atCustom uuid.UUID
|
||||
}
|
||||
|
||||
func newProviderRoutingFixture(t *testing.T) *providerRoutingFixture {
|
||||
@@ -41,7 +42,7 @@ func newProviderRoutingFixture(t *testing.T) *providerRoutingFixture {
|
||||
|
||||
f := &providerRoutingFixture{
|
||||
pool: pool, user: uuid.New(), org: uuid.New(),
|
||||
sender: uuid.New(), atGoogle: uuid.New(), atMSGraph: uuid.New(),
|
||||
sender: uuid.New(), atGoogle: uuid.New(), atMSGraph: uuid.New(), atCustom: uuid.New(),
|
||||
}
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
@@ -65,15 +66,16 @@ func newProviderRoutingFixture(t *testing.T) *providerRoutingFixture {
|
||||
{f.sender, "smtp_imap", "test.local"},
|
||||
{f.atGoogle, "gmail", "gmail.com"},
|
||||
{f.atMSGraph, "smtp_imap", "outlook.com"},
|
||||
{f.atCustom, "smtp_imap", "acme.test"},
|
||||
} {
|
||||
exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain,
|
||||
signature_html, provider, status, campaign_limit, min_wait_time, timezone)
|
||||
VALUES ($1, $2, $3, $4, 'Route', '', '', $5, 'active', 50, 600, 'UTC')`,
|
||||
m.id, f.user, f.org, "route-"+m.id.String()[:8]+"@"+m.domain, m.provider)
|
||||
}
|
||||
// Only the two recipients join the pool; the sender does not, so the
|
||||
// participants map must contain exactly them.
|
||||
for _, id := range []uuid.UUID{f.atGoogle, f.atMSGraph} {
|
||||
// Only the recipients join the pool; the sender does not, so it must never
|
||||
// appear in the participants map.
|
||||
for _, id := range []uuid.UUID{f.atGoogle, f.atMSGraph, f.atCustom} {
|
||||
exec(`INSERT INTO warmup_pool_participants (pool_id, email_account_id, participant_role, health_state)
|
||||
VALUES ($1, $2, 'sender_receiver', 'healthy')`, premiumPoolID, id)
|
||||
}
|
||||
@@ -121,13 +123,20 @@ func (f *providerRoutingFixture) send(t *testing.T, recipient uuid.UUID, n int,
|
||||
}
|
||||
|
||||
func (f *providerRoutingFixture) placement(t *testing.T, domain string, n int) {
|
||||
t.Helper()
|
||||
f.placementFrom(t, "smtp_imap", domain, n)
|
||||
}
|
||||
|
||||
// placementFrom writes the row the way the consumer does: recipient_provider is
|
||||
// the connect method the mailbox uses, recipient_domain is who its mail is at.
|
||||
func (f *providerRoutingFixture) placementFrom(t *testing.T, connectMethod, domain string, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
if _, err := f.pool.Exec(context.Background(),
|
||||
`INSERT INTO warmup_spam_reports (id, reporter_account_id, reported_account_id, message_id,
|
||||
report_type, recipient_domain, created_at)
|
||||
VALUES (gen_random_uuid(), $1, $1, $2, 'spam_placement', $3, NOW())`,
|
||||
f.sender, "m-"+uuid.New().String(), domain); err != nil {
|
||||
report_type, recipient_provider, recipient_domain, created_at)
|
||||
VALUES (gen_random_uuid(), $1, $1, $2, 'spam_placement', $3, $4, NOW())`,
|
||||
f.sender, "m-"+uuid.New().String(), connectMethod, domain); err != nil {
|
||||
t.Fatalf("insert placement: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -222,3 +231,70 @@ func TestLiveProviderRoutingIgnoresFailedSends(t *testing.T) {
|
||||
t.Errorf("rate = %v, want 0.5; counting the failures would have read 0.05", m.Rate())
|
||||
}
|
||||
}
|
||||
|
||||
// An unattributed placement (the recipient account could not be resolved when
|
||||
// it was recorded) has no domain, so it belongs to no provider. Charging it to
|
||||
// ProviderCustom would demote every custom-domain partner for a failure that
|
||||
// was never theirs, and the send side can never produce a blank domain to put
|
||||
// underneath it.
|
||||
func TestLiveProviderRoutingIgnoresUnattributedPlacement(t *testing.T) {
|
||||
handle, _ := liveContactDB(t)
|
||||
f := newProviderRoutingFixture(t)
|
||||
repo := NewWarmupRepository(handle.Pool)
|
||||
|
||||
f.send(t, f.atCustom, 10, "completed")
|
||||
f.placement(t, "", 5)
|
||||
|
||||
got, err := repo.SenderPlacementByProvider(context.Background(), f.sender, time.Now().Add(-24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("SenderPlacementByProvider: %v", err)
|
||||
}
|
||||
c := got["custom"]
|
||||
if c.Sends != 10 {
|
||||
t.Errorf("sends = %d, want the 10 that went to the custom domain", c.Sends)
|
||||
}
|
||||
if c.Placements != 0 || c.Rate() != 0 {
|
||||
t.Errorf("custom = %+v (rate %v), want the domainless placements ignored", c, c.Rate())
|
||||
}
|
||||
}
|
||||
|
||||
// The admin overview has to name the same providers routing does. The stored
|
||||
// recipient_provider column is the connect method, which files a custom-domain
|
||||
// Microsoft 365 mailbox under smtp_imap: the bucket an operator most needs
|
||||
// split, and one routing never uses.
|
||||
func TestLivePoolPlacementsUseTheRoutingVocabulary(t *testing.T) {
|
||||
handle, _ := liveContactDB(t)
|
||||
f := newProviderRoutingFixture(t)
|
||||
repo := NewWarmupRepository(handle.Pool)
|
||||
ctx := context.Background()
|
||||
since := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
// The rollup is pool-wide, so measure what these rows added to it.
|
||||
before, err := repo.PoolSpamPlacementsByProvider(ctx, since)
|
||||
if err != nil {
|
||||
t.Fatalf("PoolSpamPlacementsByProvider: %v", err)
|
||||
}
|
||||
|
||||
f.placementFrom(t, "smtp_imap", "outlook.com", 3)
|
||||
f.placementFrom(t, "gmail", "", 2)
|
||||
|
||||
after, err := repo.PoolSpamPlacementsByProvider(ctx, since)
|
||||
if err != nil {
|
||||
t.Fatalf("PoolSpamPlacementsByProvider: %v", err)
|
||||
}
|
||||
delta := func(k string) int { return after[k] - before[k] }
|
||||
|
||||
if got := delta("microsoft"); got != 3 {
|
||||
t.Errorf("microsoft delta = %d, want 3: mail run by Microsoft over plain IMAP", got)
|
||||
}
|
||||
if got := delta("smtp_imap"); got != 0 {
|
||||
t.Errorf("smtp_imap delta = %d, want 0: the connect method is not a provider", got)
|
||||
}
|
||||
// Domainless rows stay their own bucket rather than inflating custom.
|
||||
if got := delta("unknown"); got != 2 {
|
||||
t.Errorf("unknown delta = %d, want 2", got)
|
||||
}
|
||||
if got := delta("custom"); got != 0 {
|
||||
t.Errorf("custom delta = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
type poolTypeRiskRepo struct {
|
||||
repository.OrgRiskRepository
|
||||
states map[uuid.UUID]models.OrgRiskState
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *poolTypeRiskRepo) GetOrgRiskStates(_ context.Context, _ []uuid.UUID) (map[uuid.UUID]models.OrgRiskState, error) {
|
||||
return r.states, r.err
|
||||
}
|
||||
|
||||
// The recipient-capacity count has to be taken against the pool the mailbox
|
||||
// will actually send into, or a restricted mailbox sizes its day off the
|
||||
// premium pool it is no longer in (issue #242).
|
||||
func TestWarmupPoolTypeForAccountFollowsTheOrganizationsPosture(t *testing.T) {
|
||||
org := uuid.New()
|
||||
account := &models.Email{ID: uuid.New(), OrganizationID: &org, WarmupPoolType: "premium"}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
risk *poolTypeRiskRepo
|
||||
want string
|
||||
}{
|
||||
{"restricted", &poolTypeRiskRepo{states: map[uuid.UUID]models.OrgRiskState{org: models.OrgRiskRestricted}}, "free"},
|
||||
{"suspended", &poolTypeRiskRepo{states: map[uuid.UUID]models.OrgRiskState{org: models.OrgRiskSuspended}}, "free"},
|
||||
{"watch", &poolTypeRiskRepo{states: map[uuid.UUID]models.OrgRiskState{org: models.OrgRiskWatch}}, "premium"},
|
||||
{"trusted", &poolTypeRiskRepo{states: map[uuid.UUID]models.OrgRiskState{org: models.OrgRiskTrusted}}, "premium"},
|
||||
{"lookup failure fails open", &poolTypeRiskRepo{err: errors.New("connection reset by peer")}, "premium"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := &schedulerService{orgRiskRepo: tc.risk}
|
||||
if got := s.warmupPoolTypeForAccount(context.Background(), account); got != tc.want {
|
||||
t.Fatalf("resolved %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Without the risk repository wired the stored tier stands, and a mailbox with
|
||||
// no tier recorded keeps the historical premium default.
|
||||
func TestWarmupPoolTypeForAccountWithoutRiskWired(t *testing.T) {
|
||||
s := &schedulerService{}
|
||||
org := uuid.New()
|
||||
|
||||
if got := s.warmupPoolTypeForAccount(context.Background(), &models.Email{OrganizationID: &org, WarmupPoolType: "free"}); got != "free" {
|
||||
t.Fatalf("resolved %q, want free", got)
|
||||
}
|
||||
if got := s.warmupPoolTypeForAccount(context.Background(), &models.Email{OrganizationID: &org}); got != "premium" {
|
||||
t.Fatalf("resolved %q, want premium", got)
|
||||
}
|
||||
if got := s.warmupPoolTypeForAccount(context.Background(), nil); got != "premium" {
|
||||
t.Fatalf("nil account resolved %q, want premium", got)
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,16 @@ func adjustmentFor(state models.WarmupHealthState) healthAdjustment {
|
||||
}
|
||||
}
|
||||
|
||||
func warmupPoolTypeForAccount(account *models.Email) string {
|
||||
if account != nil && account.WarmupPoolType != "" {
|
||||
// warmupPoolTypeForAccount is the pool the mailbox actually warms in: a restricted
|
||||
// organization is held in free whatever tier it carries.
|
||||
func (s *schedulerService) warmupPoolTypeForAccount(ctx context.Context, account *models.Email) string {
|
||||
if account == nil {
|
||||
return "premium"
|
||||
}
|
||||
if s.orgRiskState(ctx, account.OrganizationID).ForcesFreeWarmupPool() {
|
||||
return "free"
|
||||
}
|
||||
if account.WarmupPoolType != "" {
|
||||
return account.WarmupPoolType
|
||||
}
|
||||
return "premium"
|
||||
@@ -232,7 +240,7 @@ func (s *schedulerService) CalculateNextWarmupTime(ctx context.Context, accountI
|
||||
// here, so operators can add inbound capacity without making those
|
||||
// mailboxes warmup senders.
|
||||
if s.warmupRepo != nil {
|
||||
eligibleRecipients, err := s.warmupRepo.CountEligibleRecipients(ctx, warmupPoolTypeForAccount(account), accountID)
|
||||
eligibleRecipients, err := s.warmupRepo.CountEligibleRecipients(ctx, s.warmupPoolTypeForAccount(ctx, account), accountID)
|
||||
if err == nil {
|
||||
if eligibleRecipients <= 0 {
|
||||
return recipientRecheckTime(), nil
|
||||
|
||||
@@ -23,6 +23,12 @@ func (s *tasksService) warnOnWeakContent(ctx context.Context, orgID, campaignID,
|
||||
if s.advanced == nil || s.campaignLogRepo == nil {
|
||||
return
|
||||
}
|
||||
// A clean 100 clears every floor, so the common case never reads settings.
|
||||
res := warmlint.ScoreWithAttachments(subject, bodyHTML, bodyPlain, attachments)
|
||||
if res.Score >= 100 {
|
||||
return
|
||||
}
|
||||
|
||||
// Campaign-effective, not org-only: a campaign that turned the check off or
|
||||
// moved its floor must be honored here as it is at preflight.
|
||||
settings, xerr := s.advanced.EffectiveSettings(ctx, orgID, campaignID)
|
||||
@@ -30,11 +36,10 @@ func (s *tasksService) warnOnWeakContent(ctx context.Context, orgID, campaignID,
|
||||
return
|
||||
}
|
||||
floor := settings.Preflight.MinContentScore
|
||||
if floor <= 0 {
|
||||
// Out of range means a row written before the floor was clamped.
|
||||
if floor <= 0 || floor > 100 {
|
||||
floor = 60
|
||||
}
|
||||
|
||||
res := warmlint.ScoreWithAttachments(subject, bodyHTML, bodyPlain, attachments)
|
||||
if res.Score >= floor {
|
||||
return
|
||||
}
|
||||
@@ -62,7 +67,8 @@ func (s *tasksService) warnOnWeakContent(ctx context.Context, orgID, campaignID,
|
||||
Message: fmt.Sprintf("Step %d's copy scores %d/100 for spam signals as sent (floor %d).%s",
|
||||
step, res.Score, floor, detail),
|
||||
Metadata: map[string]interface{}{
|
||||
"level": "warning",
|
||||
// "warn" is the dashboard's amber tier; anything else reads as info.
|
||||
"level": "warn",
|
||||
"sequence_id": seq,
|
||||
"score": res.Score,
|
||||
"floor": floor,
|
||||
|
||||
@@ -739,20 +739,19 @@ func (s *tasksService) resolveWarmupPoolType(ctx context.Context, account *Email
|
||||
if account == nil {
|
||||
return "premium"
|
||||
}
|
||||
if account.WarmupPoolType != "" {
|
||||
return account.WarmupPoolType
|
||||
}
|
||||
// No organization means no entitlement to check, so the mailbox gets the
|
||||
// lower-trust pool rather than defaulting into the paid one.
|
||||
if account.OrganizationID == nil {
|
||||
return "free"
|
||||
}
|
||||
// A restricted organization leaves the paid pool whatever it pays: the
|
||||
// shared reputation paying customers depend on is not for spending on a
|
||||
// risky tenant.
|
||||
// A restricted organization leaves the paid pool whatever it pays. Checked
|
||||
// before the stored tier, which is never empty and would short-circuit it.
|
||||
if s.orgSuspendedOrRestricted(ctx, *account.OrganizationID) {
|
||||
return "free"
|
||||
}
|
||||
if account.WarmupPoolType != "" {
|
||||
return account.WarmupPoolType
|
||||
}
|
||||
if s.featureGate != nil {
|
||||
isPaid, xerr := s.featureGate.IsPaidOrganization(ctx, *account.OrganizationID)
|
||||
if xerr == nil && !isPaid {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/db"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/encrypt"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// Issue #143 end to end: the per-provider placement signal has to reach the
|
||||
// partner the selector actually returns, not just the query that computes it.
|
||||
// The repository tests prove the numbers; this proves selectWarmupPartner
|
||||
// wires them into the weight. Skipped unless WARMBLY_TEST_DB is set:
|
||||
//
|
||||
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
|
||||
// go test ./internal/tasks/ -run LiveWarmupPartner -v
|
||||
|
||||
// freePoolID is the seeded free pool. It is used here rather than the premium
|
||||
// one because the selector reads EVERY participant of the pool, and the free
|
||||
// pool is the one no fixture or seed puts mailboxes in.
|
||||
const freePoolID = "77777777-aaaa-0000-0000-000000000001"
|
||||
|
||||
type partnerRoutingFixture struct {
|
||||
pool *pgxpool.Pool
|
||||
svc *tasksService
|
||||
sender models.Email
|
||||
user uuid.UUID
|
||||
org uuid.UUID
|
||||
atGoogle uuid.UUID
|
||||
atMS uuid.UUID
|
||||
}
|
||||
|
||||
func newPartnerRoutingFixture(t *testing.T) *partnerRoutingFixture {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("WARMBLY_TEST_DB")
|
||||
if dsn == "" {
|
||||
t.Skip("WARMBLY_TEST_DB not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
handle, err := db.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { handle.Pool.Close() })
|
||||
|
||||
var pools int
|
||||
if err := handle.Pool.QueryRow(ctx, `SELECT count(*) FROM warmup_pools WHERE id = $1`, freePoolID).Scan(&pools); err != nil || pools == 0 {
|
||||
t.Skip("free warmup pool not seeded in this database")
|
||||
}
|
||||
// A pick is weighted across the WHOLE pool, so a stray participant would
|
||||
// dilute the measurement into a meaningless pass.
|
||||
var occupied int
|
||||
if err := handle.Pool.QueryRow(ctx, `SELECT count(*) FROM warmup_pool_participants WHERE pool_id = $1`, freePoolID).Scan(&occupied); err != nil {
|
||||
t.Fatalf("count free pool: %v", err)
|
||||
}
|
||||
if occupied != 0 {
|
||||
t.Skip("free pool already has participants; cannot isolate the measurement")
|
||||
}
|
||||
|
||||
f := &partnerRoutingFixture{
|
||||
pool: handle.Pool, user: uuid.New(), org: uuid.New(),
|
||||
atGoogle: uuid.New(), atMS: uuid.New(),
|
||||
}
|
||||
senderID := uuid.New()
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := handle.Pool.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatalf("fixture %q: %v", sql[:min(60, len(sql))], err)
|
||||
}
|
||||
}
|
||||
exec(`INSERT INTO users (id, email, first_name, last_name) VALUES ($1, $2, 'Pick', 'Test')`,
|
||||
f.user, "pick-"+f.user.String()[:8]+"@test.local")
|
||||
exec(`INSERT INTO organizations (id, name, slug, owner_user_id) VALUES ($1, 'Pick Test', $2, $3)`,
|
||||
f.org, "pick-"+f.org.String()[:8], f.user)
|
||||
for _, m := range []struct {
|
||||
id uuid.UUID
|
||||
domain string
|
||||
}{
|
||||
{senderID, "test.local"},
|
||||
{f.atGoogle, "gmail.com"},
|
||||
{f.atMS, "outlook.com"},
|
||||
} {
|
||||
exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain,
|
||||
signature_html, provider, status, campaign_limit, min_wait_time, timezone)
|
||||
VALUES ($1, $2, $3, $4, 'Pick', '', '', 'smtp_imap', 'active', 50, 600, 'UTC')`,
|
||||
m.id, f.user, f.org, "pick-"+m.id.String()[:8]+"@"+m.domain)
|
||||
}
|
||||
for _, id := range []uuid.UUID{f.atGoogle, f.atMS} {
|
||||
exec(`INSERT INTO warmup_pool_participants (pool_id, email_account_id, participant_role, health_state)
|
||||
VALUES ($1, $2, 'sender_receiver', 'healthy')`, freePoolID, id)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
c := context.Background()
|
||||
for _, step := range []struct {
|
||||
sql string
|
||||
arg any
|
||||
}{
|
||||
{`DELETE FROM warmup_spam_reports WHERE reported_account_id = $1`, senderID},
|
||||
{`DELETE FROM warmup_tokens WHERE sender_account_id = $1`, senderID},
|
||||
{`DELETE FROM tasks WHERE email_account_id = $1`, senderID},
|
||||
{`DELETE FROM warmup_pool_participants WHERE email_account_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)`, f.org},
|
||||
{`DELETE FROM email_accounts WHERE organization_id = $1`, f.org},
|
||||
{`DELETE FROM organizations WHERE id = $1`, f.org},
|
||||
{`DELETE FROM users WHERE id = $1`, f.user},
|
||||
} {
|
||||
if _, err := handle.Pool.Exec(c, step.sql, step.arg); err != nil {
|
||||
t.Errorf("cleanup %q: %v", step.sql, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
enc, err := encrypt.NewEncrypter([]byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatalf("encrypter: %v", err)
|
||||
}
|
||||
f.svc = &tasksService{
|
||||
warmupRepo: repository.NewWarmupRepository(handle.Pool),
|
||||
emailRepo: repository.NewEmailRepostory(handle, enc),
|
||||
}
|
||||
// The stored tier picks the pool outright, and the risk read ahead of it
|
||||
// fails open, so the selector runs with no feature gate or org-risk repo.
|
||||
f.sender = models.Email{ID: senderID, OrganizationID: &f.org, WarmupPoolType: "free"}
|
||||
return f
|
||||
}
|
||||
|
||||
// history writes n completed warmup sends to one partner, backdated two days:
|
||||
// inside the seven-day placement window, but outside the same-day exclusion
|
||||
// that would drop both candidates before weighting ever runs.
|
||||
func (f *partnerRoutingFixture) history(t *testing.T, recipient uuid.UUID, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
taskID := uuid.New()
|
||||
if _, err := f.pool.Exec(context.Background(),
|
||||
`INSERT INTO tasks (id, task_type, email_account_id, status, message_id)
|
||||
VALUES ($1, 'warmup', $2, 'completed', '')`, taskID, f.sender.ID); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
if _, err := f.pool.Exec(context.Background(),
|
||||
`INSERT INTO warmup_tokens (token, task_id, sender_account_id, recipient_account_id, created_at)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, NOW() - INTERVAL '2 days')`,
|
||||
taskID, f.sender.ID, recipient); err != nil {
|
||||
t.Fatalf("insert token: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *partnerRoutingFixture) junked(t *testing.T, domain string, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
if _, err := f.pool.Exec(context.Background(),
|
||||
`INSERT INTO warmup_spam_reports (id, reporter_account_id, reported_account_id, message_id,
|
||||
report_type, recipient_domain, created_at)
|
||||
VALUES (gen_random_uuid(), $1, $1, $2, 'spam_placement', $3, NOW() - INTERVAL '1 day')`,
|
||||
f.sender.ID, "m-"+uuid.New().String(), domain); err != nil {
|
||||
t.Fatalf("insert placement: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// picks runs the real selector n times and reports how often each partner won.
|
||||
func (f *partnerRoutingFixture) picks(t *testing.T, n int) (google, microsoft int) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
for i := 0; i < n; i++ {
|
||||
partner, err := f.svc.selectWarmupPartner(ctx, f.sender)
|
||||
if err != nil {
|
||||
t.Fatalf("selectWarmupPartner: %v", err)
|
||||
}
|
||||
switch partner.ID {
|
||||
case f.atGoogle:
|
||||
google++
|
||||
case f.atMS:
|
||||
microsoft++
|
||||
default:
|
||||
t.Fatalf("selector returned a mailbox outside the fixture: %s", partner.ID)
|
||||
}
|
||||
}
|
||||
return google, microsoft
|
||||
}
|
||||
|
||||
// The whole point of #143: a sender landing in junk only at Microsoft stops
|
||||
// being handed Microsoft partners, without an aggregate health band tripping.
|
||||
func TestLiveWarmupPartnerRoutesAwayFromTheProviderItLandsInJunkAt(t *testing.T) {
|
||||
f := newPartnerRoutingFixture(t)
|
||||
const rounds = 200
|
||||
|
||||
// Equal history on both sides, so the domain-diversity weight cannot be
|
||||
// what moves the split.
|
||||
f.history(t, f.atGoogle, 10)
|
||||
f.history(t, f.atMS, 10)
|
||||
|
||||
baseGoogle, baseMS := f.picks(t, rounds)
|
||||
if baseGoogle < rounds*35/100 || baseGoogle > rounds*65/100 {
|
||||
t.Fatalf("baseline split is not even: google %d, microsoft %d of %d", baseGoogle, baseMS, rounds)
|
||||
}
|
||||
|
||||
// 6 of the 10 Microsoft sends were filtered into junk. Nothing about the
|
||||
// Google side changed.
|
||||
f.junked(t, "outlook.com", 6)
|
||||
|
||||
google, microsoft := f.picks(t, rounds)
|
||||
// weight ratio is 1 : 1/(1+4*0.6), so google should take ~77%.
|
||||
if google <= rounds*60/100 {
|
||||
t.Errorf("placement signal did not reach the selector: google %d, microsoft %d of %d (baseline was %d/%d)",
|
||||
google, microsoft, rounds, baseGoogle, baseMS)
|
||||
}
|
||||
// Downweighted, never excluded: a sender that stops mailing a provider
|
||||
// entirely can never discover it recovered there.
|
||||
if microsoft == 0 {
|
||||
t.Errorf("microsoft was excluded outright over %d picks; the penalty must only downweight", rounds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
type poolTypeRiskRepo struct {
|
||||
repository.OrgRiskRepository
|
||||
states map[uuid.UUID]models.OrgRiskState
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *poolTypeRiskRepo) GetOrgRiskStates(_ context.Context, _ []uuid.UUID) (map[uuid.UUID]models.OrgRiskState, error) {
|
||||
return r.states, r.err
|
||||
}
|
||||
|
||||
type poolTypeGate struct {
|
||||
poolReconcileGate
|
||||
paid map[uuid.UUID]bool
|
||||
}
|
||||
|
||||
func (g *poolTypeGate) IsPaidOrganization(_ context.Context, orgID uuid.UUID) (bool, *errx.Error) {
|
||||
return g.paid[orgID], nil
|
||||
}
|
||||
|
||||
func poolTypeAccount(orgID uuid.UUID, tier string) *Email {
|
||||
return &Email{ID: uuid.New(), OrganizationID: &orgID, Status: "active", WarmupPoolType: tier}
|
||||
}
|
||||
|
||||
// The headline defect (issue #242): the stored tier is always set, so checking
|
||||
// it first meant a restricted organization never left the premium pool.
|
||||
func TestResolveWarmupPoolTypeDemotesARestrictedOrganizationWhateverItsTier(t *testing.T) {
|
||||
for _, state := range []models.OrgRiskState{models.OrgRiskRestricted, models.OrgRiskSuspended} {
|
||||
t.Run(string(state), func(t *testing.T) {
|
||||
org := uuid.New()
|
||||
svc := &tasksService{orgRiskRepo: &poolTypeRiskRepo{states: map[uuid.UUID]models.OrgRiskState{org: state}}}
|
||||
|
||||
if got := svc.resolveWarmupPoolType(context.Background(), poolTypeAccount(org, "premium")); got != "free" {
|
||||
t.Fatalf("resolved %q for a %s organization, want free", got, state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Lifting the restriction hands the mailbox its paid tier back on the next resolution.
|
||||
func TestResolveWarmupPoolTypeKeepsTheStoredTierForATrustedOrganization(t *testing.T) {
|
||||
org := uuid.New()
|
||||
svc := &tasksService{orgRiskRepo: &poolTypeRiskRepo{states: map[uuid.UUID]models.OrgRiskState{org: models.OrgRiskWatch}}}
|
||||
|
||||
if got := svc.resolveWarmupPoolType(context.Background(), poolTypeAccount(org, "premium")); got != "premium" {
|
||||
t.Fatalf("resolved %q, want the stored premium tier", got)
|
||||
}
|
||||
if got := svc.resolveWarmupPoolType(context.Background(), poolTypeAccount(org, "free")); got != "free" {
|
||||
t.Fatalf("resolved %q, want the stored free tier", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An unreadable posture must not demote: the risk read fails open everywhere else too.
|
||||
func TestResolveWarmupPoolTypeFailsOpenWhenRiskCannotBeRead(t *testing.T) {
|
||||
org := uuid.New()
|
||||
svc := &tasksService{orgRiskRepo: &poolTypeRiskRepo{err: errors.New("connection reset by peer")}}
|
||||
|
||||
if got := svc.resolveWarmupPoolType(context.Background(), poolTypeAccount(org, "premium")); got != "premium" {
|
||||
t.Fatalf("resolved %q on a risk lookup failure, want the stored tier", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With no stored tier the subscription decides, and no organization means free.
|
||||
func TestResolveWarmupPoolTypeFallsBackToEntitlementWithoutAStoredTier(t *testing.T) {
|
||||
paid, trial := uuid.New(), uuid.New()
|
||||
svc := &tasksService{featureGate: &poolTypeGate{paid: map[uuid.UUID]bool{paid: true, trial: false}}}
|
||||
|
||||
if got := svc.resolveWarmupPoolType(context.Background(), poolTypeAccount(paid, "")); got != "premium" {
|
||||
t.Fatalf("paid organization resolved %q, want premium", got)
|
||||
}
|
||||
if got := svc.resolveWarmupPoolType(context.Background(), poolTypeAccount(trial, "")); got != "free" {
|
||||
t.Fatalf("trial organization resolved %q, want free", got)
|
||||
}
|
||||
if got := svc.resolveWarmupPoolType(context.Background(), &Email{ID: uuid.New(), WarmupPoolType: "premium"}); got != "free" {
|
||||
t.Fatalf("mailbox with no organization resolved %q, want free", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The reconciler is what moves a mailbox that is not actively warming (a
|
||||
// recipient-only member, say), so restriction has to reach it there too.
|
||||
func TestPoolReconcileMovesARestrictedOrganizationToTheFreePool(t *testing.T) {
|
||||
f := newPoolReconcileFixture()
|
||||
restricted := f.mailbox(f.orgPaid, "premium", "premium")
|
||||
participants := []uuid.UUID{restricted}
|
||||
f.svc = &tasksService{
|
||||
warmupRepo: &poolReconcileWarmupRepo{participants: participants},
|
||||
emailRepo: f.emails,
|
||||
featureGate: f.gate,
|
||||
warmupHealth: f.warmup,
|
||||
orgRiskRepo: &poolTypeRiskRepo{states: map[uuid.UUID]models.OrgRiskState{f.orgPaid: models.OrgRiskRestricted}},
|
||||
}
|
||||
|
||||
moved, removed, err := f.svc.ReconcileWarmupPoolMembership(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("reconcile: %v", err)
|
||||
}
|
||||
if moved != 1 || removed != 0 {
|
||||
t.Fatalf("moved %d removed %d, want 1 and 0", moved, removed)
|
||||
}
|
||||
if f.warmup.moved[restricted] != "free" {
|
||||
t.Fatalf("moved to %q, want free", f.warmup.moved[restricted])
|
||||
}
|
||||
}
|
||||
@@ -222,12 +222,12 @@ function SendingSettings() {
|
||||
description="Copy scoring below this out of 100 is flagged. Higher is stricter."
|
||||
>
|
||||
<NumberInput
|
||||
min={0}
|
||||
min={1}
|
||||
max={100}
|
||||
value={draft.preflight?.min_content_score ?? 60}
|
||||
onChange={(n) =>
|
||||
patchPreflight({
|
||||
min_content_score: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 60,
|
||||
min_content_score: Number.isFinite(n) ? Math.min(100, Math.max(1, n)) : 60,
|
||||
})
|
||||
}
|
||||
className="w-20"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
RiText,
|
||||
RiCodeView,
|
||||
} from "@remixicon/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TextInput } from "@/components/ui/field";
|
||||
import {
|
||||
@@ -53,6 +53,11 @@ export default function EmailEditor({
|
||||
}: EmailEditorProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<"html" | "plain">("html");
|
||||
// Only rewrite innerHTML when the prop diverges from the DOM; rewriting it every render resets the caret.
|
||||
useEffect(() => {
|
||||
const el = editorRef.current;
|
||||
if (el && el.innerHTML !== htmlText) el.innerHTML = htmlText;
|
||||
}, [htmlText, activeTab, code]);
|
||||
const [urlPopover, setUrlPopover] = useState<"link" | "image" | null>(null);
|
||||
const [url, setUrl] = useState("");
|
||||
// The contentEditable selection is lost as soon as the popover's text
|
||||
@@ -263,9 +268,9 @@ export default function EmailEditor({
|
||||
ref={editorRef}
|
||||
id={id}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onInput={(e) => commitHtml(e.currentTarget.innerHTML)}
|
||||
className="min-h-[120px] px-3 py-2.5 text-[13px] text-slate-800 outline-none prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: htmlText }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// Advisory campaign-template content check. A "Check content" button POSTs the
|
||||
// current subject + body to /templates/score and renders a 0-100 score (higher
|
||||
// = safer) plus a list of non-blocking issues. Purely advisory — it never
|
||||
// blocks saving or sending, it just surfaces deliverability hints.
|
||||
// Advisory campaign-template content check: scores the current subject + body
|
||||
// against /templates/score and renders a 0-100 score (higher = safer) plus the
|
||||
// non-blocking issues found, re-scored on the debounce the composer's preview
|
||||
// uses. It never blocks saving or sending.
|
||||
|
||||
import * as React from "react";
|
||||
import { ShieldCheckIcon, AlertTriangleIcon, AlertCircleIcon } from "lucide-react";
|
||||
import useScoreTemplate from "@/lib/api/hooks/app/campaigns/useScoreTemplate";
|
||||
import scoreTemplate from "@/lib/api/client/app/campaigns/scoreTemplate";
|
||||
import type TemplateScore from "@/lib/api/models/app/campaigns/TemplateScore";
|
||||
import type { TemplateScoreIssue } from "@/lib/api/models/app/campaigns/TemplateScore";
|
||||
import { Loading } from "@/components/loader";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -39,11 +41,42 @@ export default function ContentScore({
|
||||
bodyHtml: string;
|
||||
bodyPlain: string;
|
||||
}) {
|
||||
const score = useScoreTemplate();
|
||||
const data = score.data;
|
||||
const [data, setData] = React.useState<TemplateScore | null>(null);
|
||||
const [pending, setPending] = React.useState(false);
|
||||
const [failed, setFailed] = React.useState(false);
|
||||
|
||||
const run = () =>
|
||||
score.mutate({ subject, body_html: bodyHtml, body_plain: bodyPlain });
|
||||
React.useEffect(() => {
|
||||
// A step with nothing written yet is not a content problem, so hold the
|
||||
// panel quiet rather than scoring an empty draft as spam.
|
||||
if (!subject.trim() && !bodyPlain.trim()) {
|
||||
// Clears pending too: a cancelled request can no longer do it.
|
||||
setData(null);
|
||||
setPending(false);
|
||||
setFailed(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
// Set inside the timer so the spinner marks a request, not a keystroke.
|
||||
const t = setTimeout(() => {
|
||||
setPending(true);
|
||||
scoreTemplate({ subject, body_html: bodyHtml, body_plain: bodyPlain })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setData(res);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPending(false);
|
||||
});
|
||||
}, 600);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [subject, bodyHtml, bodyPlain]);
|
||||
|
||||
const tone = data ? scoreTone(data.score) : null;
|
||||
|
||||
@@ -52,21 +85,13 @@ export default function ContentScore({
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Content check</div>
|
||||
<p className="mt-0.5 text-[11px] text-slate-400 leading-relaxed">Advisory deliverability score — never blocks sending.</p>
|
||||
<p className="mt-0.5 text-[11px] text-slate-400 leading-relaxed">Advisory deliverability score. It never blocks sending.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={run}
|
||||
disabled={score.isPending}
|
||||
className="h-8 px-3 rounded-md border border-slate-200 hover:border-slate-300 text-[12px] font-medium text-slate-700 hover:text-slate-900 inline-flex items-center gap-1.5 transition-colors disabled:opacity-60 shrink-0"
|
||||
>
|
||||
{score.isPending ? <Loading className="!w-3.5 h-3.5" /> : <ShieldCheckIcon className="w-3.5 h-3.5" />}
|
||||
{data ? "Re-check" : "Check content"}
|
||||
</button>
|
||||
{pending && <Loading className="!w-3.5 h-3.5 shrink-0" />}
|
||||
</div>
|
||||
|
||||
{score.isError && (
|
||||
<div className="px-3 pb-3 text-[11.5px] text-rose-600">Couldn't score this template. Try again.</div>
|
||||
{failed && (
|
||||
<div className="px-3 pb-3 text-[11.5px] text-rose-600">Couldn't score this template.</div>
|
||||
)}
|
||||
|
||||
{data && tone && (
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import scoreTemplate from "@/lib/api/client/app/campaigns/scoreTemplate";
|
||||
import type { ScoreTemplateRequest } from "@/lib/api/models/app/campaigns/TemplateScore";
|
||||
|
||||
// On-demand advisory content score for a campaign template. A mutation rather
|
||||
// than a query because it's run explicitly via a "Check content" button, not
|
||||
// on every keystroke.
|
||||
export default function useScoreTemplate() {
|
||||
return useMutation({
|
||||
mutationFn: (body: ScoreTemplateRequest) => scoreTemplate(body),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import patchEmailLists from "./patchEmailLists";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
|
||||
const row = (id: string, name = id) => ({ id, name, email: `${id}@x.test` }) as unknown as Inbox;
|
||||
|
||||
describe("patchEmailLists", () => {
|
||||
it("patches the paginated list and the flat directory without crashing on either shape", () => {
|
||||
const qc = new QueryClient();
|
||||
qc.setQueryData(["emails", "list", "", "", 20], {
|
||||
pages: [{ data: [row("a"), row("b")], pagination: { has_more: false } }],
|
||||
pageParams: [null],
|
||||
});
|
||||
qc.setQueryData(["emails", "list", "directory"], [row("a"), row("b")]);
|
||||
|
||||
patchEmailLists(qc, (rows) => rows.map((c) => (c.id === "a" ? row("a", "renamed") : c)));
|
||||
|
||||
const list = qc.getQueryData<{ pages: { data: Inbox[] }[] }>(["emails", "list", "", "", 20]);
|
||||
expect(list?.pages[0].data.map((c) => c.name)).toEqual(["renamed", "b"]);
|
||||
const dir = qc.getQueryData<Inbox[]>(["emails", "list", "directory"]);
|
||||
expect(dir?.map((c) => c.name)).toEqual(["renamed", "b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type GetEmails from "@/lib/api/models/app/emails/GetEmails";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
import type { InfiniteData, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
type EmailListCache = InfiniteData<GetEmails> | Inbox[];
|
||||
|
||||
// ["emails", "list"] holds both the paginated list (InfiniteData) and the flat directory (Inbox[]); patch each by shape.
|
||||
export default function patchEmailLists(queryClient: QueryClient, patch: (rows: Inbox[]) => Inbox[]) {
|
||||
const allLists = queryClient.getQueriesData<EmailListCache>({ queryKey: ["emails", "list"] });
|
||||
|
||||
for (const [key, oldData] of allLists) {
|
||||
if (!oldData) continue;
|
||||
|
||||
if (Array.isArray(oldData)) {
|
||||
queryClient.setQueryData(key, patch(oldData));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(oldData.pages)) continue;
|
||||
|
||||
queryClient.setQueryData(key, {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
data: patch(page.data ?? []),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import removeEmail from "@/lib/api/client/app/emails/removeEmail";
|
||||
import type GetEmails from "@/lib/api/models/app/emails/GetEmails";
|
||||
import { useMutation, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import patchEmailLists from "./patchEmailLists";
|
||||
|
||||
export default function useRemoveEmail(id: string) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -8,21 +8,7 @@ export default function useRemoveEmail(id: string) {
|
||||
return useMutation({
|
||||
mutationFn: () => removeEmail(id),
|
||||
onSuccess: () => {
|
||||
const allLists = queryClient.getQueriesData<InfiniteData<GetEmails>>({
|
||||
queryKey: ["emails", "list"],
|
||||
});
|
||||
|
||||
for (const [key, oldData] of allLists) {
|
||||
if (!oldData) continue;
|
||||
|
||||
queryClient.setQueryData(key, {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
data: page.data.filter((c) => c.id !== id),
|
||||
})),
|
||||
});
|
||||
}
|
||||
patchEmailLists(queryClient, (rows) => rows.filter((c) => c.id !== id));
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["emails", id]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import updateEmail from "@/lib/api/client/app/emails/updateEmail";
|
||||
import type GetEmails from "@/lib/api/models/app/emails/GetEmails";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
import { useMutation, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import patchEmailLists from "./patchEmailLists";
|
||||
|
||||
export default function useUpdateEmail(id: string) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -9,21 +9,7 @@ export default function useUpdateEmail(id: string) {
|
||||
return useMutation({
|
||||
mutationFn: (inbox: Partial<Inbox>) => updateEmail(id, inbox),
|
||||
onSuccess: (data) => {
|
||||
const allLists = queryClient.getQueriesData<InfiniteData<GetEmails>>({
|
||||
queryKey: ["emails", "list"],
|
||||
});
|
||||
|
||||
for (const [key, oldData] of allLists) {
|
||||
if (!oldData) continue;
|
||||
|
||||
queryClient.setQueryData(key, {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
data: page.data.map((c) => c.id === id ? data : c),
|
||||
})),
|
||||
});
|
||||
}
|
||||
patchEmailLists(queryClient, (rows) => rows.map((c) => (c.id === id ? data : c)));
|
||||
|
||||
queryClient.setQueryData<Inbox>(
|
||||
["emails", id],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import updateEmailTrackingDomain from "@/lib/api/client/app/emails/updateEmailTrackingDomain";
|
||||
import type GetEmails from "@/lib/api/models/app/emails/GetEmails";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
import { useMutation, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import patchEmailLists from "./patchEmailLists";
|
||||
|
||||
export default function useUpdateEmailTrackingDomain(id: string) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -9,26 +9,18 @@ export default function useUpdateEmailTrackingDomain(id: string) {
|
||||
return useMutation({
|
||||
mutationFn: (tracking_domain: string) => updateEmailTrackingDomain(id, tracking_domain),
|
||||
onSuccess: (data) => {
|
||||
const allLists = queryClient.getQueriesData<InfiniteData<GetEmails>>({
|
||||
queryKey: ["emails", "list"],
|
||||
});
|
||||
|
||||
for (const [key, oldData] of allLists) {
|
||||
if (!oldData) continue;
|
||||
|
||||
queryClient.setQueryData(key, {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
data: page.data.map((c) => c.id === id ? {
|
||||
...c,
|
||||
tracking_domain: data.tracking_domain,
|
||||
tracking_domain_verified: data.tracking_domain_verified,
|
||||
tracking_domain_verified_at: data.tracking_domain_verified_at,
|
||||
} : c),
|
||||
})),
|
||||
});
|
||||
}
|
||||
patchEmailLists(queryClient, (rows) =>
|
||||
rows.map((c) =>
|
||||
c.id === id
|
||||
? {
|
||||
...c,
|
||||
tracking_domain: data.tracking_domain,
|
||||
tracking_domain_verified: data.tracking_domain_verified,
|
||||
tracking_domain_verified_at: data.tracking_domain_verified_at,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
|
||||
// The card reads its target and diagnostic from this query.
|
||||
queryClient.setQueryData(["emails", id, "tracking-domain"], data);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import warmupLifecycle, { type WarmupAction } from "@/lib/api/client/app/emails/warmupLifecycle";
|
||||
import type GetEmails from "@/lib/api/models/app/emails/GetEmails";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
import { useMutation, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import patchEmailLists from "./patchEmailLists";
|
||||
|
||||
// Drives the flame-icon dropdown + the warmup tab's enable/pause/resume
|
||||
// control. Patches the mailbox into every emails list page and the single
|
||||
@@ -13,21 +13,7 @@ export default function useWarmupLifecycle(id: string) {
|
||||
return useMutation({
|
||||
mutationFn: (action: WarmupAction) => warmupLifecycle(id, action),
|
||||
onSuccess: (data) => {
|
||||
const allLists = queryClient.getQueriesData<InfiniteData<GetEmails>>({
|
||||
queryKey: ["emails", "list"],
|
||||
});
|
||||
|
||||
for (const [key, oldData] of allLists) {
|
||||
if (!oldData) continue;
|
||||
|
||||
queryClient.setQueryData(key, {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
data: page.data.map((c) => (c.id === id ? data : c)),
|
||||
})),
|
||||
});
|
||||
}
|
||||
patchEmailLists(queryClient, (rows) => rows.map((c) => (c.id === id ? data : c)));
|
||||
|
||||
queryClient.setQueryData<Inbox>(["emails", id], data);
|
||||
void queryClient.invalidateQueries({ queryKey: ["analytics", "accounts", id] });
|
||||
|
||||
Reference in New Issue
Block a user