diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index 74b2de113..61e615297 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -558,6 +558,8 @@ A mailbox now has a cold-rotation state, separate from its health: | `resting` | Out of cold rotation to recover. Warmup keeps running, so its reputation stays alive. Leads mid-sequence on it move to another mailbox at their next step | | `reserve` | Held back by you. Never entered or left automatically | +The row shows **Resting** or **Reserve** even when no campaign is active on that instance, including Cloud accounts used only for warmup. Warmup can continue while cold sending rests. Each instance owns its own cold rotation and manual holds; linking warmup does not copy a manual hold between instances. A connection problem still takes precedence in the row, with the cold-rotation notice available in the drawer. + A mailbox rests when it is quarantined or blocked from warmup, and returns on its own three days after that lifts. One good hour does not put it back in rotation. Spam placement never rests a mailbox: a `watch` or `throttled` mailbox stays in rotation with its daily cold volume dampened (to 70% and 50%) until placement recovers. Resting only makes sense while the mailbox is in a warmup pool, because pool health is the signal it recovers on. If a resting mailbox leaves its pool (the workspace loses warmup when a trial ends or a plan changes), there is no health signal to wait for, so the mailbox returns on its own three days after its last rest stamp rather than staying out indefinitely. Pausing warmup on the mailbox does not remove it from the pool, so its health keeps being tracked and the normal three days after it lifts still apply. @@ -588,6 +590,8 @@ This is not the same as the risk band that decides which sending worker and IP h The Accounts list groups mailboxes as **Healthy** (sending normally), **Warming** (ramping through warmup), or **Needs attention** (paused, failing, or not sending). Each row shows the mailbox with how it connects and its tags, a **Status** that says what it is doing (**Sending**, **Warming**, **Active** when it does both, **Resting**, or **Idle**, and **Error**, **Off** or **Reconnect** when something stops it), campaign emails sent today against its cap, warmup sent today against the ramp target, the inbox rate, and the health score. Hover a status for the reason, and click any column header to sort. A drop in health between refreshes notifies you rather than waiting to be noticed. +Repeated reports of the same error code count once toward the health score, at their highest unresolved severity, rather than making one problem look like several. The drawer groups identical reports while keeping different error details and sending versus sync failures separate. Connection titles name Gmail, Microsoft Outlook or the mail server, and **Last reported** records the observation time, not proof of an ongoing outage. A later successful operation confirms recovery. Automatic retries continue while the mailbox is on; a connection warning alone is not a reason to reconnect. Authentication and credential errors retain their own instructions, and a switched-off mailbox must be switched back on before it can retry. + The **Inbox** column is the share of the mailbox's warmup mail that reached partners' inboxes at Google, Microsoft and Yahoo over the last seven days (smaller mail hosts run their own filters and are never counted), and the score is capped at it, so a mailbox landing some mail in spam no longer reads `100`. Click it, or open the drawer's **Deliverability** tab, for the day-by-day history and the split per provider. See [seeing where warmup lands](/guides/warmup/#seeing-where-warmup-lands). The detail drawer runs a live `SPF`, `DKIM`, and `DMARC` check on your sending domain. Confirm it is green before sending; major providers require alignment for bulk and cold mail. A domain that stays Failing eventually stops cold sending and warmup from every mailbox on it, so fix the records and press **Check** to clear it straight away; for anyone who can manage mailboxes, the check records its verdict. A lookup DNS did not answer shows the records as Not verified and is never held against the domain. DKIM reads **Not verified** rather than Missing when no key answers, because its selector is not something DNS can be asked for; that never gates sending. See [Deliverability](/guides/deliverability/) for the grace period and exactly what is stopped. diff --git a/internal/app/analytics/account_errors_test.go b/internal/app/analytics/account_errors_test.go new file mode 100644 index 000000000..87d260171 --- /dev/null +++ b/internal/app/analytics/account_errors_test.go @@ -0,0 +1,125 @@ +package analytics + +import ( + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +func TestAccountErrorsGroupIdenticalReportsWithoutHidingDistinctFailures(t *testing.T) { + now := time.Now().UTC() + row := repository.EmailAccountError{ + ID: uuid.New(), ErrorCode: string(errx.MailErrorCodeServerUnreachable), + Severity: "WARNING", Title: "Connection Error", Message: "Connection failed", + Operation: models.MailTransportSync, CreatedAt: now, + } + duplicate := row + duplicate.ID = uuid.New() + duplicate.CreatedAt = now.Add(-time.Hour) + duplicate.Severity = "CRITICAL" + send := row + send.ID = uuid.New() + send.Operation = models.MailTransportSend + different := row + different.ID = uuid.New() + different.Message = "Secure connection failed" + unknown := row + unknown.ID = uuid.New() + unknown.Operation = "" + + errs := toAccountErrors([]repository.EmailAccountError{duplicate, row, send, different, unknown}) + if len(errs) != 4 || errs[0].ID != row.ID || !errs[0].CreatedAt.Equal(now) || errs[0].Severity != "CRITICAL" { + t.Fatalf("identical reports should retain latest identity/time and strongest severity: %+v", errs) + } + if errs[1].Operation != models.MailTransportSend || errs[2].Message != different.Message || errs[3].Operation != models.MailTransportUnknown { + t.Fatalf("distinct operation, detail and legacy unknown scope must remain separate: %+v", errs) + } + if got := toAccountErrors(nil); got == nil || len(got) != 0 { + t.Fatalf("empty errors must be a non-nil list: %+v", got) + } +} + +func TestAccountErrorsKeepWarningWhenNewestIdenticalReportIsInformational(t *testing.T) { + row := repository.EmailAccountError{ID: uuid.New(), ErrorCode: "CODE", Message: "same", Severity: "WARNING", CreatedAt: time.Now()} + newer := row + newer.CreatedAt = row.CreatedAt.Add(time.Minute) + newer.Severity = "INFORMATIONAL" + for _, rows := range [][]repository.EmailAccountError{{row, newer}, {newer, row}} { + errs := toAccountErrors(rows) + if len(errs) != 1 || errs[0].Severity != "WARNING" || !errs[0].CreatedAt.Equal(newer.CreatedAt) { + t.Fatalf("active warning must not be lost: %+v", errs) + } + } +} + +func TestAccountErrorsPreferStoredUserMessage(t *testing.T) { + message := "Provider connection failed" + errs := toAccountErrors([]repository.EmailAccountError{{Message: "technical detail", UserMessage: &message}}) + if errs[0].Message != message { + t.Fatalf("display should honor stored user-facing message: %+v", errs) + } +} + +func TestAccountHealthCountsEachErrorCodeOnceAtStrongestSeverity(t *testing.T) { + for _, provider := range []string{"gmail", "outlook", "smtp_imap"} { + t.Run(provider, func(t *testing.T) { + email := &models.Email{Status: "active", Provider: provider} + errs := []models.AccountError{ + {ErrorCode: string(errx.MailErrorCodeServerUnreachable), Severity: "WARNING", Title: "Connection Error", Operation: models.MailTransportUnknown}, + {ErrorCode: string(errx.MailErrorCodeServerUnreachable), Severity: "WARNING", Title: "Connection Error", Operation: models.MailTransportSend}, + {ErrorCode: string(errx.MailErrorCodeServerUnreachable), Severity: "WARNING", Title: "Connection Error", Operation: models.MailTransportSync}, + } + health := calculateAccountHealth(email, errs) + if health.Score != 90 || health.Status != "warning" || len(health.Issues) != 1 { + t.Fatalf("one failure category must not become three deductions: %+v", health) + } + errs[2].Severity = "CRITICAL" + errs = append(errs, models.AccountError{ErrorCode: string(errx.MailErrorCodeAuthenticationFailed), Severity: "CRITICAL", Title: "Authentication Required"}) + health = calculateAccountHealth(email, errs) + if health.Score != 40 || health.Status != "error" || len(health.Issues) != 2 { + t.Fatalf("strongest severity and independent sign-in safeguards must remain: %+v", health) + } + email.Status = "inactive" + if health := calculateAccountHealth(email, errs); health.Score != 0 || health.Status != "error" { + t.Fatalf("inactive account penalty must remain: %+v", health) + } + }) + } +} + +func TestConnectionErrorGuidanceNamesProviderAndOperationWithoutInventingAnOutage(t *testing.T) { + for provider, name := range map[string]string{"gmail": "Gmail", "outlook": "Microsoft Outlook", "smtp_imap": "Mail server"} { + for _, operation := range []models.MailTransportOperation{models.MailTransportSend, models.MailTransportSync, models.MailTransportUnknown} { + t.Run(provider+"/"+string(operation), func(t *testing.T) { + errs := []models.AccountError{{ErrorCode: string(errx.MailErrorCodeServerUnreachable), Operation: operation, Message: "The last attempt failed", Severity: "WARNING"}} + describeAccountErrors(&models.Email{Status: "active", Provider: provider}, errs) + if !strings.HasPrefix(errs[0].Title, name+" ") || !strings.Contains(errs[0].Title, "failed") || errs[0].Message != "The last attempt failed" || errs[0].Severity != "WARNING" { + t.Fatalf("provider/operation copy must not replace actual evidence or severity: %+v", errs) + } + if operation.Known() && !strings.Contains(errs[0].Title, map[models.MailTransportOperation]string{models.MailTransportSend: "sending", models.MailTransportSync: "sync"}[operation]) { + t.Fatalf("known operation should be visible: %+v", errs) + } + if errs[0].ActionRequired == nil || !strings.Contains(*errs[0].ActionRequired, "not proof") || !strings.Contains(*errs[0].ActionRequired, "automatically") { + t.Fatalf("must explain automatic retries and unverified recovery: %+v", errs) + } + describeAccountErrors(&models.Email{Status: "inactive", Provider: provider}, errs) + if !strings.Contains(*errs[0].ActionRequired, "automatic retries are paused") { + t.Fatalf("a switched-off mailbox cannot retry automatically: %+v", errs) + } + }) + } + } + for _, code := range []errx.MailErrorCode{errx.MailErrorCodeAuthenticationFailed, errx.MailErrorCodeGoogleAuth, errx.MailErrorCodeInvalidCredentials, errx.MailErrorCodeAuthUnsupported, errx.MailErrorCodeRateLimitExceeded, errx.MailErrorCodeQuotaExceeded, errx.MailErrorCodeAccountSuspended} { + action := "Existing specific action" + errs := []models.AccountError{{ErrorCode: string(code), Title: "Specific issue", ActionRequired: &action}} + describeAccountErrors(&models.Email{Status: "active", Provider: "gmail"}, errs) + if errs[0].Title != "Specific issue" || *errs[0].ActionRequired != action { + t.Fatalf("do not relabel %s as a retryable connection issue: %+v", code, errs) + } + } +} diff --git a/internal/app/analytics/linked_status_live_test.go b/internal/app/analytics/linked_status_live_test.go index a5420c995..dcf6500a6 100644 --- a/internal/app/analytics/linked_status_live_test.go +++ b/internal/app/analytics/linked_status_live_test.go @@ -42,6 +42,8 @@ func TestLiveLinkedMailboxStatusBatchPreservesDetails(t *testing.T) { provider := "gmail" if i < 4 { provider = "smtp_imap" + } else if i > 8 { + provider = "outlook" } exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain, signature_html, provider, status, campaign_limit, min_wait_time, timezone, @@ -58,6 +60,12 @@ func TestLiveLinkedMailboxStatusBatchPreservesDetails(t *testing.T) { // A restrictive row and a paused mailbox must retain the same detail in the list. exec(`UPDATE warmup_pool_participants SET health_state='quarantined', blocked_until=NOW()+interval '2 days' WHERE email_account_id=$1`, ids[1]) exec(`UPDATE email_accounts SET warmup_paused_at=NOW() WHERE id=$1`, ids[2]) + exec(`UPDATE email_accounts SET send_lifecycle='resting',send_lifecycle_since=NOW(),send_lifecycle_reason='recovering' WHERE id=$1`, ids[3]) + exec(`UPDATE email_accounts SET send_lifecycle='reserve',send_lifecycle_since=NOW(),send_lifecycle_reason='owner hold' WHERE id=$1`, ids[4]) + for _, operation := range []string{"send", "sync", "unknown"} { + exec(`INSERT INTO email_account_errors (id,email_account_id,user_id,error_code,severity,resolve_method,title,message,operation,observation_source) + VALUES (gen_random_uuid(),$1,$2,'SERVER_UNREACHABLE','WARNING','RETRY','Connection Error','Connection attempt failed',$3,'worker')`, ids[3], f.user, operation) + } counter := &statusQueryCounter{} poolConfig := f.pool.Config() poolConfig.ConnConfig.Tracer = counter @@ -73,6 +81,7 @@ func TestLiveLinkedMailboxStatusBatchPreservesDetails(t *testing.T) { handle := &db.DB{Pool: pool} svc := NewService(repository.NewAnalyticsRepository(handle), repository.NewEmailRepostory(handle, enc), repository.NewCampaignRepostory(handle), repository.NewEmailAccountErrorRepository(handle), repository.NewWarmupRepository(pool)) + svc.(LifecycleAware).WireLifecycle(repository.NewSendLifecycleRepository(handle)) start := time.Now() individual := make(map[uuid.UUID]models.EmailAccountStatus) for _, id := range ids { @@ -82,6 +91,15 @@ func TestLiveLinkedMailboxStatusBatchPreservesDetails(t *testing.T) { } individual[id] = *status } + for id, state := range map[uuid.UUID]models.SendLifecycle{ids[3]: models.SendLifecycleResting, ids[4]: models.SendLifecycleReserve} { + status := individual[id] + if status.InCampaign || status.SendLifecycle == nil || status.SendLifecycle.State != state || status.WarmupStatus == nil || status.WarmupStatus.Paused || status.WarmupStatus.TargetVolume <= 0 { + t.Fatalf("cold hold without a local campaign must stay visible while warmup runs: %+v", status) + } + } + if status := individual[ids[3]]; status.Health.Score != 90 || len(status.Health.Issues) != 1 || len(status.Errors) != 3 { + t.Fatalf("one connection category must count once while retaining operation details: %+v", status) + } oldQueries, oldDuration := counter.queries.Swap(0), time.Since(start) start = time.Now() page, xerr := svc.GetAccountStatusDetailsPage(t.Context(), f.org, ids, "", config.AccountStatusLimitMax) @@ -91,7 +109,7 @@ func TestLiveLinkedMailboxStatusBatchPreservesDetails(t *testing.T) { newQueries, newDuration := counter.queries.Swap(0), time.Since(start) for _, status := range page.Data { want := individual[status.ID] - if !reflect.DeepEqual(status.WarmupStatus, want.WarmupStatus) || !reflect.DeepEqual(status.WarmupHealth, want.WarmupHealth) || !reflect.DeepEqual(status.Errors, want.Errors) || !reflect.DeepEqual(status.DailyUsage, want.DailyUsage) { + if !reflect.DeepEqual(status.WarmupStatus, want.WarmupStatus) || !reflect.DeepEqual(status.WarmupHealth, want.WarmupHealth) || !reflect.DeepEqual(status.Errors, want.Errors) || !reflect.DeepEqual(status.Health, want.Health) || !reflect.DeepEqual(status.SendLifecycle, want.SendLifecycle) || !reflect.DeepEqual(status.DailyUsage, want.DailyUsage) { t.Fatalf("%s: batched=%+v individual=%+v", status.ID, status, want) } } diff --git a/internal/app/analytics/service.go b/internal/app/analytics/service.go index bdbcd4e65..4b110f8f9 100644 --- a/internal/app/analytics/service.go +++ b/internal/app/analytics/service.go @@ -282,20 +282,82 @@ func (s *analyticsService) accountErrors(ctx context.Context, accountID uuid.UUI // toAccountErrors maps stored error rows into the API shape, never nil. func toAccountErrors(dbErrors []repository.EmailAccountError) []models.AccountError { errs := make([]models.AccountError, 0, len(dbErrors)) + type errorKey struct { + code, message, action string + operation models.MailTransportOperation + } + seen := make(map[errorKey]int, len(dbErrors)) for _, e := range dbErrors { - errs = append(errs, models.AccountError{ + message := e.Message + if e.UserMessage != nil && *e.UserMessage != "" { + message = *e.UserMessage + } + action := "" + if e.ActionRequired != nil { + action = *e.ActionRequired + } + key := errorKey{e.ErrorCode, message, action, e.Operation.Conservative()} + record := models.AccountError{ ID: e.ID, ErrorCode: e.ErrorCode, Severity: e.Severity, Title: e.Title, - Message: e.Message, + Message: message, ActionRequired: e.ActionRequired, CreatedAt: e.CreatedAt, - }) + Operation: key.operation, + } + if index, ok := seen[key]; ok { + previous := errs[index] + if record.CreatedAt.After(previous.CreatedAt) { + errs[index] = record + } + if accountErrorPenalty(record.Severity) > accountErrorPenalty(previous.Severity) { + errs[index].Severity = record.Severity + } else { + errs[index].Severity = previous.Severity + } + continue + } + seen[key] = len(errs) + errs = append(errs, record) } return errs } +func connectionProviderName(provider string) string { + switch provider { + case "gmail": + return "Gmail" + case "outlook": + return "Microsoft Outlook" + default: + return "Mail server" + } +} + +func describeAccountErrors(email *models.Email, errs []models.AccountError) { + for i := range errs { + e := &errs[i] + if e.ErrorCode != string(errx.MailErrorCodeServerUnreachable) && e.ErrorCode != string(errx.MailErrorCodeConnectionLost) { + continue + } + operation := "connection attempt failed" + switch e.Operation { + case models.MailTransportSend: + operation = "sending connection failed" + case models.MailTransportSync: + operation = "sync connection failed" + } + e.Title = connectionProviderName(email.Provider) + " " + operation + action := "Warmbly retries automatically while this mailbox is switched on. This is the last reported failure, not proof that it is still occurring. A successful retry for the affected operation confirms recovery. Reconnecting is only needed if a sign-in error is also reported." + if email.Status != "active" { + action = "This mailbox is switched off, so automatic retries are paused. Check any sign-in errors, then switch the mailbox back on to retry." + } + e.ActionRequired = &action + } +} + // assembleAccountStatus turns a mailbox and its already-fetched signals into // the status shape. Everything SQL-bound arrives in reads, so the single // mailbox and the batched list build a status the same way and a list costs no @@ -304,6 +366,7 @@ func (s *analyticsService) assembleAccountStatus(ctx context.Context, email *mod if errs == nil { errs = make([]models.AccountError, 0) } + describeAccountErrors(email, errs) // Calculate health, then fold in warmup-pool reputation so the single // score the user sees reflects spam placement / complaints / throttling. @@ -662,18 +725,37 @@ func calculateAccountHealth(email *models.Email, errors []models.AccountError) m health.Issues = append(health.Issues, "Account is not active") } - // Check for critical errors + penalties := make(map[string]int) for _, e := range errors { + penalty := 0 if e.Severity == "CRITICAL" { health.Status = "error" - health.Score -= 30 - health.Issues = append(health.Issues, e.Title) + penalty = 30 } else if e.Severity == "WARNING" { if health.Status == "healthy" { health.Status = "warning" } - health.Score -= 10 - health.Issues = append(health.Issues, e.Title) + penalty = 10 + } + key := e.ErrorCode + if key == "" { + key = e.Title + } + previous := penalties[key] + if penalty <= previous { + continue + } + penalties[key] = penalty + health.Score -= penalty - previous + if previous == 0 { + title := e.Title + if e.ErrorCode == string(errx.MailErrorCodeServerUnreachable) || e.ErrorCode == string(errx.MailErrorCodeConnectionLost) { + title = connectionProviderName(email.Provider) + " connection attempt failed" + if e.ErrorCode == string(errx.MailErrorCodeConnectionLost) { + title = connectionProviderName(email.Provider) + " connection was interrupted" + } + } + health.Issues = append(health.Issues, title) } } @@ -685,6 +767,17 @@ func calculateAccountHealth(email *models.Email, errors []models.AccountError) m return health } +func accountErrorPenalty(severity string) int { + switch severity { + case "CRITICAL": + return 30 + case "WARNING": + return 10 + default: + return 0 + } +} + // warmupTargetAndHold reports the target the SCHEDULER will act on, plus what // is holding it down. It runs the shared warmupramp policy rather than its own // arithmetic: a drawer saying "target 25" for a mailbox sending 18 is worse diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 7bb01cfc1..1e70cbd28 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -285,13 +285,14 @@ type AccountHealth struct { } type AccountError struct { - ID uuid.UUID `json:"id"` - ErrorCode string `json:"error_code"` - Severity string `json:"severity"` - Title string `json:"title"` - Message string `json:"message"` - ActionRequired *string `json:"action_required,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID uuid.UUID `json:"id"` + ErrorCode string `json:"error_code"` + Severity string `json:"severity"` + Title string `json:"title"` + Message string `json:"message"` + ActionRequired *string `json:"action_required,omitempty"` + CreatedAt time.Time `json:"created_at"` + Operation MailTransportOperation `json:"operation,omitempty"` } type AccountDailyUsage struct { diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index c983916b2..a0e567e98 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -5,6 +5,7 @@ import { browseString, mailboxSort, mailboxTab, type MailboxTab } from "@/lib/br import { useSearchParams } from "@/hooks/useSearchParams"; import useMailboxTagFilter from "@/hooks/useMailboxTagFilter"; import groupMailboxes from "@/lib/groupMailboxes"; +import { mailboxColdActivity } from "@/lib/mailboxStatus"; import { useAppStore } from "@/stores/useAppStore"; import toast from "react-hot-toast/headless"; import { useQueryClient } from "@tanstack/react-query"; @@ -1426,8 +1427,7 @@ function MailboxStatusPill({ box, status, historicalIssue, warming, compact = fa const error = status?.errors?.[0]; const lifecycle = status?.send_lifecycle; const inCampaign = !!status?.in_campaign; - const resting = inCampaign && !!lifecycle && lifecycle.state !== "active"; - const sending = inCampaign && !resting; + const { resting, sending } = mailboxColdActivity(status); let problem: { label: string; text: string; Icon: LucideIcon; title: string } | null = null; if (box.status === "revoked") problem = { label: "Reconnect", text: "text-rose-600", Icon: UnplugIcon, title: "Access was revoked at the provider. Reconnect the mailbox to send and warm again." }; diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index 3172f91d2..7650946c7 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -790,6 +790,7 @@ function OverviewTab({ status, loading, mailbox }: { status?: import("@/lib/api/ {status.errors.map((e: AccountError) => (