mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-26 00:00:41 +00:00
5e6287c920
* 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
347 lines
14 KiB
Go
347 lines
14 KiB
Go
package models
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"math"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// AdvisorSeverity ranks how urgently a finding needs attention. It drives
|
|
// ordering, the nav-tab badge tone, and whether a new finding is worth a toast.
|
|
type AdvisorSeverity string
|
|
|
|
const (
|
|
// AdvisorCritical means reputation or delivery is actively being damaged
|
|
// right now (complaint/bounce rates in the documented hard-block bands, a
|
|
// mailbox sending cold mail while quarantined from the warmup pool).
|
|
AdvisorCritical AdvisorSeverity = "critical"
|
|
// AdvisorHigh means a real problem that will become critical if ignored.
|
|
AdvisorHigh AdvisorSeverity = "high"
|
|
// AdvisorMedium means a meaningful improvement with no immediate risk.
|
|
AdvisorMedium AdvisorSeverity = "medium"
|
|
// AdvisorLow means a polish-level suggestion.
|
|
AdvisorLow AdvisorSeverity = "low"
|
|
)
|
|
|
|
// severityRank orders severities most-urgent-first for sorting and threshold
|
|
// comparisons.
|
|
var severityRank = map[AdvisorSeverity]int{
|
|
AdvisorCritical: 4,
|
|
AdvisorHigh: 3,
|
|
AdvisorMedium: 2,
|
|
AdvisorLow: 1,
|
|
}
|
|
|
|
// Rank returns the numeric urgency of a severity (higher is more urgent, 0 for
|
|
// an unknown value).
|
|
func (s AdvisorSeverity) Rank() int { return severityRank[s] }
|
|
|
|
// AtLeast reports whether s is at least as urgent as min.
|
|
func (s AdvisorSeverity) AtLeast(min AdvisorSeverity) bool { return s.Rank() >= min.Rank() }
|
|
|
|
// AdvisorCategory groups findings by the kind of problem they describe. Orgs
|
|
// can mute a whole category.
|
|
type AdvisorCategory string
|
|
|
|
const (
|
|
AdvisorCategoryDeliverability AdvisorCategory = "deliverability"
|
|
AdvisorCategoryMailbox AdvisorCategory = "mailbox"
|
|
AdvisorCategoryWarmup AdvisorCategory = "warmup"
|
|
AdvisorCategoryCampaign AdvisorCategory = "campaign"
|
|
AdvisorCategoryCopy AdvisorCategory = "copy"
|
|
AdvisorCategoryList AdvisorCategory = "list"
|
|
)
|
|
|
|
// AdvisorSurface is the dashboard nav tab a finding belongs to. The badge count
|
|
// and the inline strip both key off it, so advice always appears where the fix
|
|
// is made rather than in a separate inbox.
|
|
type AdvisorSurface string
|
|
|
|
const (
|
|
AdvisorSurfaceCampaigns AdvisorSurface = "campaigns"
|
|
AdvisorSurfaceMailboxes AdvisorSurface = "emails"
|
|
AdvisorSurfaceDeliverability AdvisorSurface = "deliverability"
|
|
AdvisorSurfaceContacts AdvisorSurface = "contacts"
|
|
AdvisorSurfaceAnalytics AdvisorSurface = "analytics"
|
|
AdvisorSurfaceSettings AdvisorSurface = "settings"
|
|
)
|
|
|
|
// AdvisorStatus is a finding's lifecycle state.
|
|
type AdvisorStatus string
|
|
|
|
const (
|
|
// AdvisorStatusOpen is an active, unaddressed finding.
|
|
AdvisorStatusOpen AdvisorStatus = "open"
|
|
// AdvisorStatusSnoozed hides the finding until SnoozedUntil passes. The
|
|
// detector keeps re-confirming it in the background.
|
|
AdvisorStatusSnoozed AdvisorStatus = "snoozed"
|
|
// AdvisorStatusDismissed means a member said this isn't a problem for them.
|
|
// It stays dismissed until the underlying condition clears and recurs.
|
|
AdvisorStatusDismissed AdvisorStatus = "dismissed"
|
|
// AdvisorStatusApplied means the one-click fix ran. It stays in this state
|
|
// until the next evaluation confirms the condition is gone (-> resolved) or
|
|
// still present (-> reopened).
|
|
AdvisorStatusApplied AdvisorStatus = "applied"
|
|
// AdvisorStatusResolved means the detector no longer fires.
|
|
AdvisorStatusResolved AdvisorStatus = "resolved"
|
|
)
|
|
|
|
// AdvisorPreviewChange is one line of the before/after preview shown in the fix
|
|
// drawer. Every one-click fix renders its full effect this way before the user
|
|
// confirms; nothing is ever applied sight-unseen.
|
|
type AdvisorPreviewChange struct {
|
|
Field string `json:"field"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
}
|
|
|
|
// AdvisorAction is the one-click remedy attached to a finding. It executes
|
|
// through the shared AI tool registry, which runs the tool AS the invoking user
|
|
// with their permission bits enforced, so the Advisor can never apply a change
|
|
// the member could not have made by hand.
|
|
type AdvisorAction struct {
|
|
// Tool is the aitools registry name (e.g. "update_mailbox").
|
|
Tool string `json:"tool"`
|
|
// Args is the tool's JSON payload, fully materialized at detection time.
|
|
Args json.RawMessage `json:"args"`
|
|
// Label is the button text ("Lower the daily cap to 35").
|
|
Label string `json:"label"`
|
|
// Auto marks a fix that autopilot may apply unattended. It is true only for
|
|
// a bounded settings change that moves in the safe direction and can be
|
|
// undone: lowering a cap, widening a gap, resuming warmup. Anything that
|
|
// stops sending, edits copy, or cannot be reverted stays false and waits
|
|
// for a person, however obvious the fix looks.
|
|
Auto bool `json:"auto,omitempty"`
|
|
// Preview is the exact before/after the drawer renders.
|
|
Preview []AdvisorPreviewChange `json:"preview,omitempty"`
|
|
// Undo, when set, is the tool call that reverts this one. Surfaced as
|
|
// "Undo" on the applied card.
|
|
Undo *AdvisorUndo `json:"undo,omitempty"`
|
|
}
|
|
|
|
// AdvisorUndo reverts an applied action.
|
|
type AdvisorUndo struct {
|
|
Tool string `json:"tool"`
|
|
Args json.RawMessage `json:"args"`
|
|
}
|
|
|
|
// AdvisorFinding is one piece of advice about one entity.
|
|
type AdvisorFinding struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
|
|
Fingerprint string `json:"-"`
|
|
DetectorKey string `json:"detector_key"`
|
|
Category AdvisorCategory `json:"category"`
|
|
Severity AdvisorSeverity `json:"severity"`
|
|
Surface AdvisorSurface `json:"surface"`
|
|
|
|
EntityType string `json:"entity_type,omitempty"`
|
|
EntityID *uuid.UUID `json:"entity_id,omitempty"`
|
|
EntityLabel string `json:"entity_label,omitempty"`
|
|
|
|
// ParentType / ParentID name the entity this finding belongs to when it
|
|
// differs from the subject: a step's copy problem belongs to its campaign,
|
|
// and the campaign page is where someone goes looking for it.
|
|
ParentType string `json:"parent_type,omitempty"`
|
|
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
|
|
|
Status AdvisorStatus `json:"status"`
|
|
Impact int `json:"impact"`
|
|
|
|
Title string `json:"title"`
|
|
// GroupTitle names the finding when several of its kind are listed
|
|
// together, with a {count} placeholder ("{count} mailboxes are capped above
|
|
// the safe band"). Empty means this finding is always shown on its own.
|
|
GroupTitle string `json:"group_title,omitempty"`
|
|
Detail string `json:"detail"`
|
|
Remedy string `json:"remedy"`
|
|
// Steps is the ordered manual how-to, set only by checks with no one-click
|
|
// fix. Empty means the remedy prose is the whole answer.
|
|
Steps []string `json:"steps,omitempty"`
|
|
// AgentFixable is true when an agent can resolve this by editing something
|
|
// the platform owns. False for anything that lives outside it, like a DNS
|
|
// record, so the client shows the steps rather than a button that cannot
|
|
// succeed. Computed per request, never stored.
|
|
AgentFixable bool `json:"agent_fixable"`
|
|
// Snippets are the exact values to paste somewhere the platform cannot
|
|
// reach: a DNS record, a CNAME target. Prose telling somebody to "add an
|
|
// SPF record" is where most people stop; the record itself is the fix.
|
|
Snippets []AdvisorSnippet `json:"snippets,omitempty"`
|
|
// Narrated is false while the card still shows the built-in fallback copy
|
|
// (AI unconfigured, or narration not run yet). The card is fully usable
|
|
// either way; this only tells the client whether to offer "rewrite".
|
|
Narrated bool `json:"narrated"`
|
|
|
|
Evidence json.RawMessage `json:"evidence,omitempty"`
|
|
Action *AdvisorAction `json:"action,omitempty"`
|
|
|
|
FirstSeenAt time.Time `json:"first_seen_at"`
|
|
LastSeenAt time.Time `json:"last_seen_at"`
|
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
|
|
|
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
|
|
DismissedAt *time.Time `json:"dismissed_at,omitempty"`
|
|
DismissReason string `json:"dismiss_reason,omitempty"`
|
|
|
|
AppliedAt *time.Time `json:"applied_at,omitempty"`
|
|
AppliedBy *uuid.UUID `json:"applied_by,omitempty"`
|
|
AppliedResult string `json:"applied_result,omitempty"`
|
|
}
|
|
|
|
// EvidenceHash fingerprints the finding's evidence so a re-run can tell
|
|
// "same problem, same numbers" (keep the narration) from "same problem, the
|
|
// numbers moved" (re-narrate). Detectors round the values they put in Evidence
|
|
// into bands, so ordinary drift does not churn the copy.
|
|
func (f *AdvisorFinding) EvidenceHash() string {
|
|
sum := sha256.Sum256(f.Evidence)
|
|
return hex.EncodeToString(sum[:8])
|
|
}
|
|
|
|
// AdvisorSurfaceCount is the per-nav-tab badge payload.
|
|
type AdvisorSurfaceCount struct {
|
|
Surface AdvisorSurface `json:"surface"`
|
|
// Total is every open finding on this surface; Critical/High drive the
|
|
// badge tone so a tab never screams about a low-severity nit.
|
|
Total int `json:"total"`
|
|
Critical int `json:"critical"`
|
|
High int `json:"high"`
|
|
}
|
|
|
|
// AdvisorSummary is the org-wide rollup: what the badges show, plus a single
|
|
// health score so the trend is legible at a glance.
|
|
type AdvisorSummary struct {
|
|
// Score is 100 minus the weighted cost of every open finding, floored at 0.
|
|
Score int `json:"score"`
|
|
Total int `json:"total"`
|
|
Critical int `json:"critical"`
|
|
High int `json:"high"`
|
|
Medium int `json:"medium"`
|
|
Low int `json:"low"`
|
|
Surfaces []AdvisorSurfaceCount `json:"surfaces"`
|
|
// LastRunAt is when the engine last evaluated this org (nil before the
|
|
// first run).
|
|
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
|
}
|
|
|
|
// advisorSeverityCost is how much each open finding weighs against the org's
|
|
// Advisor score. Weighted so one critical finding matters more than a dozen
|
|
// low-severity nits, which is the ordering a sender actually cares about.
|
|
var advisorSeverityCost = map[AdvisorSeverity]float64{
|
|
AdvisorCritical: 25,
|
|
AdvisorHigh: 10,
|
|
AdvisorMedium: 4,
|
|
AdvisorLow: 1,
|
|
}
|
|
|
|
// advisorScoreScale tunes how fast the score falls. A single critical finding
|
|
// lands around 78; the curve then flattens, so the twentieth medium-severity
|
|
// suggestion moves the number far less than the first critical one did.
|
|
const advisorScoreScale = 100.0
|
|
|
|
// AdvisorScore folds open-finding counts into a 0-100 health score.
|
|
//
|
|
// The penalty saturates rather than accumulating linearly. A linear score hits
|
|
// zero at around four high-severity findings, after which a workspace with four
|
|
// problems and a workspace with forty look identical, and fixing three of them
|
|
// changes nothing on screen. An exponential decay keeps the number honest
|
|
// across the range a workspace actually lives in: it never reaches zero, and
|
|
// every fix moves it until the tail flattens out.
|
|
func AdvisorScore(critical, high, medium, low int) int {
|
|
cost := float64(critical)*advisorSeverityCost[AdvisorCritical] +
|
|
float64(high)*advisorSeverityCost[AdvisorHigh] +
|
|
float64(medium)*advisorSeverityCost[AdvisorMedium] +
|
|
float64(low)*advisorSeverityCost[AdvisorLow]
|
|
if cost <= 0 {
|
|
return 100
|
|
}
|
|
score := int(math.Round(100 * math.Exp(-cost/advisorScoreScale)))
|
|
if score < 1 {
|
|
return 1
|
|
}
|
|
return score
|
|
}
|
|
|
|
// AdvisorSettings holds the org's advisor controls.
|
|
type AdvisorSettings struct {
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
Enabled bool `json:"enabled"`
|
|
MutedCategories []string `json:"muted_categories"`
|
|
MutedDetectors []string `json:"muted_detectors"`
|
|
MinSeverity AdvisorSeverity `json:"min_severity"`
|
|
// Autopilot applies auto-safe fixes on its own. Off by default.
|
|
Autopilot bool `json:"autopilot"`
|
|
// AutopilotActorID is the member autopilot acts as. Set automatically to
|
|
// whoever switches it on; autopilot stops if they leave the org.
|
|
AutopilotActorID *uuid.UUID `json:"autopilot_actor_id,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// AutopilotMaxPerRun bounds how many fixes autopilot may apply in one
|
|
// evaluation. A misconfiguration should cost a handful of reversible changes,
|
|
// not a whole workspace rewritten while nobody was looking.
|
|
const AutopilotMaxPerRun = 10
|
|
|
|
// DefaultAdvisorSettings is what an org gets before it has ever written a
|
|
// settings row: everything on, nothing muted.
|
|
func DefaultAdvisorSettings(orgID uuid.UUID) *AdvisorSettings {
|
|
return &AdvisorSettings{
|
|
OrganizationID: orgID,
|
|
Enabled: true,
|
|
MutedCategories: []string{},
|
|
MutedDetectors: []string{},
|
|
MinSeverity: AdvisorLow,
|
|
}
|
|
}
|
|
|
|
// AdvisorSnippet is one copy-pasteable value, rendered as a labelled field with
|
|
// a copy button. Used for DNS records, where the whole difficulty is knowing
|
|
// what to type.
|
|
type AdvisorSnippet struct {
|
|
// Label names the field in the DNS provider's own words ("Type", "Name",
|
|
// "Value") or the thing being pasted.
|
|
Label string `json:"label"`
|
|
Value string `json:"value"`
|
|
// Note is the one caveat that trips people up on this specific field, such
|
|
// as a host some providers write as @ and others leave blank.
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// AdvisorAgentResult is what an agent fix reports back.
|
|
type AdvisorAgentResult struct {
|
|
FindingID uuid.UUID `json:"finding_id"`
|
|
// Applied is true only when the agent called a tool that changed something.
|
|
// A run that read the campaign and decided nothing was wrong reports false,
|
|
// so the finding stays open.
|
|
Applied bool `json:"applied"`
|
|
// Summary is the agent's own account of what it did.
|
|
Summary string `json:"summary"`
|
|
// Steps are the tool calls it made, in order. This is the part the member
|
|
// can check against the audit log.
|
|
Steps []string `json:"steps,omitempty"`
|
|
}
|
|
|
|
// AdvisorFeedback is a member's verdict on one finding.
|
|
type AdvisorFeedback struct {
|
|
FindingID uuid.UUID `json:"finding_id"`
|
|
Helpful bool `json:"helpful"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
// AdvisorSnoozeRequest snoozes a finding for a bounded window.
|
|
type AdvisorSnoozeRequest struct {
|
|
// Days must be 1-90. Anything else is rejected: an unbounded snooze is a
|
|
// dismissal wearing a disguise, and the two should stay distinguishable.
|
|
Days int `json:"days" binding:"required,min=1,max=90"`
|
|
}
|
|
|
|
// AdvisorDismissRequest records why a member rejected the advice. The reason is
|
|
// optional but feeds detector tuning.
|
|
type AdvisorDismissRequest struct {
|
|
Reason string `json:"reason"`
|
|
}
|