Merge remote-tracking branch 'origin/main' into fix/issue-143-investigation

This commit is contained in:
Matthew Meszaros
2026-08-28 22:42:25 -07:00
31 changed files with 865 additions and 174 deletions
+9
View File
@@ -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>
+3 -3
View File
@@ -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.
+9 -8
View File
@@ -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)
}
}
+74 -15
View File
@@ -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)
+29 -12
View File
@@ -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 {
+18 -2
View File
@@ -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)
}
}
+15 -1
View File
@@ -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"`
+33
View File
@@ -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)
}
}
+33 -7
View File
@@ -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{}{}
+76
View File
@@ -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)
}
}
+2 -2
View File
@@ -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")
+10 -1
View File
@@ -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)
}
}
}
@@ -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)
}
}
+11 -3
View File
@@ -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
+10 -4
View File
@@ -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,
+5 -6
View File
@@ -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 {
+116
View File
@@ -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])
}
}
+2 -2
View File
@@ -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 -2
View File
@@ -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&apos;t score this template. Try again.</div>
{failed && (
<div className="px-3 pb-3 text-[11.5px] text-rose-600">Couldn&apos;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] });