Files
warmbly/internal/app/advisor/detect_mailbox.go
T
Matthew Meszaros 5e6287c920 feat: add the Advisor, continuous sending checks surfaced on the row they are about (#86)
* feat: index advisor findings by subject and parent entity so a list page fetches its whole surface once and every row resolves its own advice from the shared cache instead of firing a request per row

* feat: rebuild the advisor fix drawer as a three-screen resolution flow (why it fired with the measured evidence, the exact before and after, then an animated outcome with undo) with a progress rail and direction-aware transitions, and deep-link manual fixes to the screen where they are made

* feat: add AdvisorRowFlag, the inline per-row advisor indicator that renders on the mailbox or campaign the problem is about and opens that row's findings in an anchored panel instead of making the reader join a card list against a table

* feat: add AdvisorSummaryBar, a one-line collapsible page summary that replaces the stack of advisor cards above a list, counts the distinct rows implicated rather than the findings, and forces itself open only for critical or workspace-level advice no row flag can carry

* feat: put advisor advice on the mailbox row it is about in the accounts list, replace the card stack above the table with the collapsible summary bar, and support ?mailbox=<id> so a finding can deep-link straight to the mailbox detail instead of the top of the list

* feat: flag advisor findings on the campaign row in the campaigns list, including step-level copy problems which index onto their parent campaign since a step has no row of its own, and add the collapsible summary bar above the list

* feat: move the deliverability and contacts pages onto the collapsible advisor summary bar so their findings stop pushing the numbers they describe below the fold

* feat: add an ordered Steps field to advisor findings, persisted as text[] and always refreshed from the current build, and write real how-to steps for the deliverability checks that have no one-click fix (bounce rate, spam placement, tracking domain, and per-record SPF/DKIM/DMARC instructions)

* feat: write ordered how-to steps for the manual advisor findings where the remedy alone leaves someone stuck (broken template syntax, missing first-name fallback, unsubscribed contacts still enrolled, a campaign with no resolvable sender, and a mailbox that lost warmup pool standing) and correct the personalization detail that named a merge syntax this product does not use

* feat: show a mailbox's advisor findings at the top of its detail drawer, which is where both the row flag and the ?mailbox deep link now land

* feat: open the resolution flow from findings that have no one-click fix too, since the ordered how-to lives there and a card with no Fix button previously left the steps unreachable

* docs: document the per-row advisor flags, the collapsible page summary, the three-screen resolution flow, and the ordered manual steps for findings with no one-click fix

* feat: align the advisor summary bar to the px-5 page gutter used by SectionBar and the list rows on all four surfaces, instead of sitting flush against the edge while the table it describes is indented

* fix: stop the resolution drawer collapsing to zero height between screens by switching the step transition to popLayout with a layout-animated container, so the dialog resizes into the next screen instead of snapping shut and reopening

* feat: wire the advisor repository, narrator, service, tool registration, and background runner into the backend boot path so findings evaluate on a schedule and the assistant can read them

* docs: register the advisor guide in the sidebar, add its endpoint scope table to the API reference, and document the sandbox advisor showcase

* fix: darken the advisor nav badge to solid orange-600 on white instead of a pale amber-100 chip that read as a disabled control beside the sidebar's saturated indicators, and drop the critical badge to rose-600 so the two stay in the same weight class

* fix: use orange-500 for the advisor nav badge, matching the high-severity dot on the row it points at, rather than the darker orange-600

* feat: add an Auto safety class to advisor actions and mark the seven fixes autopilot may apply unattended (the cap cuts, the send-gap widen, the campaign limit matches, and the unsubscribe header), with a test pinning the boundary so nothing that halts sending or generates new outbound mail can drift into it

* feat: add advisor autopilot, which applies the auto-safe fixes unattended as the member who switched it on, resolving their live permissions each run so it fails closed when they leave the org, bounded to 10 changes per evaluation and audited per fix like any hand-made change

* feat: add the advisor agent fix, a bounded per-finding agent run that resolves the problems a settings change cannot (broken template syntax, bulk-reading copy, shared-inbox lists) as the calling member inside a tool allowlist scoped to the finding's category, metered per iteration and marked applied only when it actually called a write tool

* feat: surface autopilot and the agent fix in the dashboard, adding the workspace toggle that names exactly which changes it may make, an Auto chip on the findings it is allowed to take, and an agent-fix path in the resolution drawer that reports the tools it actually called rather than only its own account of them

* docs: document the agent fix and autopilot, naming the exact set of changes autopilot may make, that it acts as the member who enabled it and stops when they leave, and why the agent-fix endpoint is JWT only

* fix: gate the agent fix per detector instead of per category, so a missing DMARC record no longer offers a Fix-with-agent button it can never satisfy and then reports failure; findings whose fix lives in DNS or a provider console now show their manual steps, and the client is told which is which via agent_fixable

* feat: soften the advisor surfaces to translucent washes, replacing the filled nav badge with a tinted pill that carries its colour in the text, frosting the row panel and the resolution drawer, and turning the severity chips and cards into layers the page shows through

* docs: correct the agent-fix scope to name the findings it cannot resolve, and why a DNS record shows steps instead of a button

* feat: ship the actual DNS records for the findings that live outside the platform, with the provider's SPF include resolved, the DMARC record scoped to the sending domain and starting at p=none, the DKIM host plus the console that generates its value, and a tracking CNAME pointing at this install's own tracking host

* feat: render advisor snippets as labelled copy-button rows so a DNS record is one click per field rather than a text-selection exercise, with no copy affordance on a value the server could not supply

* docs: document the pasteable DNS records and the guarantee that every check offers a fix, an agent, or ordered steps

* fix: bump golang.org/x/text to 0.39.0 to clear CVE-2026-56852, a HIGH-severity infinite loop in norm.Iter that Trivy started failing the security scan on
2026-07-30 17:15:09 +02:00

345 lines
15 KiB
Go

package advisor
import (
"fmt"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
// mailboxDetectors cover per-mailbox sending configuration: how much a mailbox
// is allowed to send, how fast, and whether it is healthy enough to be sending
// at all.
func mailboxDetectors() []Detector {
return []Detector{
{
Key: "mailbox_cap_too_high",
Category: models.AdvisorCategoryMailbox,
About: "A mailbox's daily cold-send cap relative to the platform's safe band. The documented default is 50/day, and anything above it should require positive reputation signals and explicit review rather than being raised casually.",
Run: detectCapTooHigh,
},
{
Key: "mailbox_new_ramping_fast",
Category: models.AdvisorCategoryMailbox,
About: "A recently connected mailbox sending at full volume. Deliverability guidance is consistent that a new sending identity should start around 10-20/day and ramp only while performance stays healthy; jumping straight to full volume is the most common way to burn a new mailbox.",
Run: detectNewMailboxRampingFast,
},
{
Key: "mailbox_gap_too_short",
Category: models.AdvisorCategoryMailbox,
About: "The minimum gap between sends from one mailbox. Bursty sending from a single mailbox is a pattern filters recognise even when the daily total is modest.",
Run: detectGapTooShort,
},
{
Key: "mailbox_errors_unresolved",
Category: models.AdvisorCategoryMailbox,
About: "Unresolved connection or send errors on a mailbox that campaigns still route through. A mailbox erroring silently drops volume from the campaigns depending on it.",
Run: detectMailboxErrors,
},
{
Key: "mailbox_concentration",
Category: models.AdvisorCategoryMailbox,
About: "How many mailboxes carry the org's cold volume. Spreading sending across more mailboxes and more sending identities is the core safety property of the platform; concentrating it into one or two mailboxes gives up that protection.",
Run: detectMailboxConcentration,
},
{
Key: "mailbox_inactive_in_campaign",
Category: models.AdvisorCategoryMailbox,
About: "A mailbox that is inactive or erroring but still attached to a running campaign, so the campaign quietly sends less than it is configured to.",
Run: detectInactiveMailboxInCampaign,
},
}
}
func detectCapTooHigh(s *repository.AdvisorSnapshot) []Finding {
out := []Finding{}
for _, m := range s.Mailboxes {
if m.CampaignLimit <= safeColdCapBand {
continue
}
// A high cap on a mailbox with clean signals is a judgement call the
// user is entitled to make; a high cap on a mailbox that is already
// bouncing or complaining is not.
bounceRate := rate(m.Bounces30d, m.ColdSent30d)
complaintRate := rate(m.Complaints30d, m.ColdSent30d)
proven := m.ColdSent30d >= minSendsForComplaintRate &&
bounceRate < bounceRateWarn && complaintRate < complaintRateWarn
severity := models.AdvisorMedium
detail := fmt.Sprintf(
"%s is capped at %d cold emails a day, above the %d/day safe band. Volume above the default is meant to follow positive reputation signals, not lead them.",
m.Email, m.CampaignLimit, safeColdCapBand)
if proven {
// Proven mailbox: this is a note, not an alarm.
severity = models.AdvisorLow
detail = fmt.Sprintf(
"%s is capped at %d cold emails a day, above the %d/day safe band. Its bounce and complaint rates are clean over %d sends, so the volume is currently holding, but it leaves no headroom if the list quality slips.",
m.Email, m.CampaignLimit, safeColdCapBand, m.ColdSent30d)
}
out = append(out, Finding{
Key: "mailbox_cap_too_high",
GroupTitle: "{count} mailboxes are capped above the safe band",
Category: models.AdvisorCategoryMailbox,
Severity: severity,
Surface: models.AdvisorSurfaceMailboxes,
EntityType: "email_account",
EntityID: ref(m.ID),
EntityLabel: m.Email,
Impact: clampImpact(20 + (m.CampaignLimit - safeColdCapBand)),
Title: fmt.Sprintf("%s is capped above the safe band", m.Email),
Detail: detail,
Remedy: fmt.Sprintf("Bring the cap back to %d/day unless you have reviewed this mailbox's complaint and placement numbers specifically and decided to carry the risk.", defaultColdCap),
Evidence: map[string]any{
"mailbox": m.Email,
"daily_cap": m.CampaignLimit,
"safe_band": safeColdCapBand,
"sends_30d": m.ColdSent30d,
"bounce_rate_percent": band(bounceRate),
"proven": proven,
},
Action: auto(withUndo(mailboxAction(m.ID,
fmt.Sprintf("Set the cap to %d/day", defaultColdCap),
map[string]any{"campaign_limit": defaultColdCap},
change("Daily cold cap", fmt.Sprintf("%d/day", m.CampaignLimit), fmt.Sprintf("%d/day", defaultColdCap)),
), map[string]any{"email_account_id": m.ID.String(), "campaign_limit": m.CampaignLimit})),
})
}
return out
}
func detectNewMailboxRampingFast(s *repository.AdvisorSnapshot) []Finding {
out := []Finding{}
for _, m := range s.Mailboxes {
if m.AgeDays >= newMailboxDays || !m.InActiveCampaign {
continue
}
if m.CampaignLimit <= newMailboxSafeCap {
continue
}
out = append(out, Finding{
Key: "mailbox_new_ramping_fast",
GroupTitle: "{count} new mailboxes are sending at full volume",
Category: models.AdvisorCategoryMailbox,
Severity: models.AdvisorHigh,
Surface: models.AdvisorSurfaceMailboxes,
EntityType: "email_account",
EntityID: ref(m.ID),
EntityLabel: m.Email,
Impact: clampImpact(70 - m.AgeDays),
Title: fmt.Sprintf("%s is sending at full volume after %s", m.Email, plural(m.AgeDays, "day", "days")),
Detail: fmt.Sprintf(
"%s was connected %s ago and is already capped at %d cold emails a day. A new sending identity has no reputation to spend yet; the safe start is 10-20/day, ramping only while bounce and complaint rates stay clean.",
m.Email, plural(m.AgeDays, "day", "days"), m.CampaignLimit),
Remedy: fmt.Sprintf("Drop this mailbox to %d/day and raise it gradually over the next few weeks. Warmup should be running the whole time.", newMailboxSafeCap),
Evidence: map[string]any{
"mailbox": m.Email,
"age_days": m.AgeDays,
"daily_cap": m.CampaignLimit,
"safe_start_cap": newMailboxSafeCap,
"warmup_running": m.WarmupActive,
"cold_sends_7d": m.ColdSent7d,
},
Action: auto(withUndo(mailboxAction(m.ID,
fmt.Sprintf("Start at %d/day instead", newMailboxSafeCap),
map[string]any{"campaign_limit": newMailboxSafeCap},
change("Daily cold cap", fmt.Sprintf("%d/day", m.CampaignLimit), fmt.Sprintf("%d/day", newMailboxSafeCap)),
), map[string]any{"email_account_id": m.ID.String(), "campaign_limit": m.CampaignLimit})),
})
}
return out
}
func detectGapTooShort(s *repository.AdvisorSnapshot) []Finding {
out := []Finding{}
for _, m := range s.Mailboxes {
if !m.InActiveCampaign || m.MinWaitTime >= minSafeGapSeconds {
continue
}
out = append(out, Finding{
Key: "mailbox_gap_too_short",
GroupTitle: "{count} mailboxes are sending in bursts",
Category: models.AdvisorCategoryMailbox,
Severity: models.AdvisorMedium,
Surface: models.AdvisorSurfaceMailboxes,
EntityType: "email_account",
EntityID: ref(m.ID),
EntityLabel: m.Email,
Impact: clampImpact(40 + (minSafeGapSeconds-m.MinWaitTime)/10),
Title: fmt.Sprintf("%s sends in bursts", m.Email),
Detail: fmt.Sprintf(
"%s waits only %s between sends, against a platform default of %d minutes. Bursty sending from one mailbox is a pattern filters recognise even when the daily total is modest, because real people do not send twenty emails in ten minutes.",
m.Email, humanSeconds(m.MinWaitTime), defaultMinGap/60),
Remedy: fmt.Sprintf("Widen the gap to at least %d minutes. The daily total stays the same; it just spreads out across the sending window.", defaultMinGap/60),
Evidence: map[string]any{
"mailbox": m.Email,
"min_gap_seconds": m.MinWaitTime,
"recommended_seconds": defaultMinGap,
"daily_cap": m.CampaignLimit,
},
Action: auto(withUndo(mailboxAction(m.ID,
fmt.Sprintf("Widen the gap to %d minutes", defaultMinGap/60),
map[string]any{"min_wait_time": defaultMinGap},
change("Minimum gap between sends", humanSeconds(m.MinWaitTime), humanSeconds(defaultMinGap)),
), map[string]any{"email_account_id": m.ID.String(), "min_wait_time": m.MinWaitTime})),
})
}
return out
}
func detectMailboxErrors(s *repository.AdvisorSnapshot) []Finding {
out := []Finding{}
for _, m := range s.Mailboxes {
if m.UnresolvedErrs == 0 {
continue
}
severity := models.AdvisorMedium
if m.InActiveCampaign {
severity = models.AdvisorHigh
}
out = append(out, Finding{
Key: "mailbox_errors_unresolved",
GroupTitle: "{count} mailboxes have errors nobody has cleared",
Category: models.AdvisorCategoryMailbox,
Severity: severity,
Surface: models.AdvisorSurfaceMailboxes,
EntityType: "email_account",
EntityID: ref(m.ID),
EntityLabel: m.Email,
Impact: clampImpact(30 + m.UnresolvedErrs*5),
Title: fmt.Sprintf("%s has %s that nobody has cleared", m.Email, plural(m.UnresolvedErrs, "error", "errors")),
Detail: fmt.Sprintf(
"%s has %s from the last 7 days still unresolved%s. Send and sync errors do not surface as failures in campaign reporting, so a mailbox in this state quietly delivers less than the campaign asked for.",
m.Email, plural(m.UnresolvedErrs, "error", "errors"),
map[bool]string{true: " while it is attached to a running campaign", false: ""}[m.InActiveCampaign]),
Remedy: "Open the mailbox and work through the errors. Authentication failures usually mean a reconnect; rate-limit errors mean the provider is pushing back and the cap should come down.",
Steps: []string{
"Open the mailbox and read the errors. They are grouped by kind, and the kind tells you the fix.",
"Authentication or credential errors mean a reconnect. An expired OAuth token or a changed password will not recover on its own.",
"Rate-limit or throttling errors mean the provider is pushing back. Lower the daily cap and widen the send gap rather than retrying into it.",
"Connection or timeout errors are usually the host or port. Check them against your provider's current documented values.",
"Once the cause is fixed, the errors stop accumulating and this clears on the next check.",
},
Evidence: map[string]any{
"mailbox": m.Email,
"unresolved_errors_7d": m.UnresolvedErrs,
"currently_sending_cold": m.InActiveCampaign,
"status": m.Status,
},
})
}
return out
}
func detectMailboxConcentration(s *repository.AdvisorSnapshot) []Finding {
// Only meaningful once the org is actually sending at volume.
sending := 0
totalVolume := 0
for _, m := range s.Mailboxes {
if m.ColdSent7d > 0 {
sending++
totalVolume += m.ColdSent7d
}
}
if s.Org.RunningCampaigns == 0 || totalVolume < minSendsForEngagement {
return nil
}
// The planning heuristic in the sending policy: a mailbox at the default
// cap carries ~50/day, so weekly volume divided by the safe per-mailbox
// weekly total is how many mailboxes this volume really wants.
wantMailboxes := totalVolume / (defaultColdCap * 7)
if wantMailboxes < 1 {
wantMailboxes = 1
}
if sending >= wantMailboxes {
return nil
}
perMailbox := totalVolume / maxInt(sending, 1) / 7
return []Finding{{
Key: "mailbox_concentration",
Category: models.AdvisorCategoryMailbox,
Severity: models.AdvisorHigh,
Surface: models.AdvisorSurfaceMailboxes,
EntityType: "",
Impact: clampImpact(40 + (wantMailboxes-sending)*10),
Title: fmt.Sprintf("%s carrying all your cold volume", plural(sending, "mailbox is", "mailboxes are")),
Detail: fmt.Sprintf(
"You sent %d cold emails in the last week across %s, about %d per mailbox per day. Spreading volume across more mailboxes and more sending identities is the whole safety model here: concentrating it means one reputation problem takes out all of your sending at once.",
totalVolume, plural(sending, "mailbox", "mailboxes"), perMailbox),
Remedy: fmt.Sprintf("Connect more mailboxes and let the campaign rotate across them. At this volume you want around %s, each staying near the default cap.", plural(wantMailboxes, "mailbox", "mailboxes")),
Steps: []string{
fmt.Sprintf("Connect more mailboxes until you have around %s carrying this volume.", plural(wantMailboxes, "mailbox", "mailboxes")),
"Spread them across more than one domain if you can. Several mailboxes on one domain still concentrate the risk on that domain's reputation.",
"Put each new mailbox through warmup before it takes campaign traffic, and start it at 10 to 20 a day.",
"Add them to the campaign, or give them the tag it selects senders by, and it starts rotating across them automatically.",
fmt.Sprintf("Bring the existing mailboxes back down to about %d/day as the new ones take the load.", defaultColdCap),
},
Evidence: map[string]any{
"sending_mailboxes": sending,
"cold_sends_7d": totalVolume,
"per_mailbox_per_day": perMailbox,
"recommended_mailboxes": wantMailboxes,
"safe_per_mailbox_cap": defaultColdCap,
},
}}
}
func detectInactiveMailboxInCampaign(s *repository.AdvisorSnapshot) []Finding {
out := []Finding{}
for _, m := range s.Mailboxes {
if !m.InActiveCampaign || m.Status == "active" {
continue
}
out = append(out, Finding{
Key: "mailbox_inactive_in_campaign",
GroupTitle: "{count} inactive mailboxes are attached to running campaigns",
Category: models.AdvisorCategoryMailbox,
Severity: models.AdvisorHigh,
Surface: models.AdvisorSurfaceCampaigns,
EntityType: "email_account",
EntityID: ref(m.ID),
EntityLabel: m.Email,
Impact: 60,
Title: fmt.Sprintf("%s is attached to a running campaign but is %s", m.Email, m.Status),
Detail: fmt.Sprintf(
"A running campaign routes through %s, but the mailbox is %s so nothing sends from it. The campaign is delivering less than it is configured to, and the shortfall does not show up as an error anywhere.",
m.Email, m.Status),
Remedy: "Either reactivate the mailbox or take it off the campaign, so the campaign's real capacity matches what it reports.",
Steps: []string{
"Decide which you want: the mailbox sending again, or the campaign no longer counting on it.",
"To bring it back, open the mailbox and resolve whatever deactivated it, usually a reconnect, then set it active.",
"To let it go, open the campaign's sender settings and remove it, or take off the tag that selects it.",
"Either way the campaign's reported capacity starts matching what it can actually send, which is what makes its daily limit meaningful.",
},
Evidence: map[string]any{
"mailbox": m.Email,
"status": m.Status,
"unresolved_errors_7d": m.UnresolvedErrs,
},
})
}
return out
}
// humanSeconds renders a gap as minutes when it divides cleanly, which is how
// the setting is presented in the dashboard.
func humanSeconds(sec int) string {
if sec >= 60 && sec%60 == 0 {
return fmt.Sprintf("%d minutes", sec/60)
}
return fmt.Sprintf("%d seconds", sec)
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}