From f11115b642cf7f898764880aaf4572fe45939233 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 31 Aug 2026 12:12:14 +0200 Subject: [PATCH 01/14] Add greptile config.json to stop automatic pr reviews --- .greptile/config.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .greptile/config.json diff --git a/.greptile/config.json b/.greptile/config.json new file mode 100644 index 00000000..34c493dc --- /dev/null +++ b/.greptile/config.json @@ -0,0 +1,3 @@ +{ + "skipReview": "AUTOMATIC" +} From 3fc4fd0bb01b88fa5f0ab24c9dfa339f93dd2e2b Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 31 Aug 2026 03:41:27 -0700 Subject: [PATCH 02/14] feat: redesign the sidebar LivePanel around visuals instead of text rows: a hero sent-today number that scrubs day-by-day when hovering the new smooth 14-day area sparkline (replacing the bar strip), a sky capacity meter for today vs the derived daily cap, icon glance chips for mailboxes, active senders and unread inbox, a zero-filled continuous trend axis so quiet days are not squeezed out, and no LIVE status row since realtime updates already read as live --- web/src/components/layout/AppNav.tsx | 395 +++++++++++++++++---------- 1 file changed, 254 insertions(+), 141 deletions(-) diff --git a/web/src/components/layout/AppNav.tsx b/web/src/components/layout/AppNav.tsx index 8c082410..95408614 100644 --- a/web/src/components/layout/AppNav.tsx +++ b/web/src/components/layout/AppNav.tsx @@ -605,18 +605,20 @@ function Section({ section, first = false }: { section: NavSection; first?: bool * Anatomy: * * ┌──────────────────────────────────┐ - * │ ● LIVE 42 of 50 / day │ ← status dot + label + cap pace - * │ 8 mailboxes sending now │ ← mailbox composition - * │ ▂▃▅▇▆▄▂ ▂▃▅▇▆▄▂ │ ← optional 24h sparkline + * │ 128 of 400 sent today │ ← hero number (scrubs on hover) + * │ ━━━━━━━───────── │ ← capacity meter (today vs cap) + * │ ∿∿∿∿∿∿ │ ← 14-day area sparkline + * │ ✉ 8 ● 5 ⬇ 3 │ ← mailboxes · active · unread * └──────────────────────────────────┘ * - * Reads as ambient telemetry: even when idle, it tells you "system is - * up, n mailboxes ready." Clicking jumps to analytics. The dot pulses - * when at least one mailbox is actively warming or sending. + * Reads as ambient telemetry: even when idle, it tells you "n mailboxes, + * n sent today." Clicking jumps to analytics; hovering a day on the + * sparkline swaps the hero number to that day. There is deliberately no + * LIVE/OFFLINE status row: the numbers ticking realtime already say the + * system is up, so the panel spends its pixels on the data instead. * * Data sources at this layer: * - useAppStore.emails → mailbox count, active count - * - useAppStore.connectionStatus → online/offline state * - useDashboard("30d") daily_trend → today's sent volume + the sparkline * (shares the dashboard page's query cache; realtime invalidation keeps * it current) @@ -626,10 +628,9 @@ function Section({ section, first = false }: { section: NavSection; first?: bool */ function LivePanel() { const emails = useAppStore((s) => s.emails); - const connection = useAppStore((s) => s.connectionStatus); - const latencyMs = useAppStore((s) => s.wsLatencyMs); const unseenCount = useAppStore((s) => s.unseenCount); const dash = useDashboard("30d"); + const [hovered, setHovered] = useState(null); const { active, mailboxes, capacity } = useMemo(() => { const m = emails.length; @@ -641,163 +642,275 @@ function LivePanel() { }, [emails]); const { sentToday, trend } = useMemo(() => { - const days = dash.data?.daily_trend ?? []; - const todayKey = new Date().toISOString().slice(0, 10); - const today = days.find((d) => d.date?.slice(0, 10) === todayKey); - return { - sentToday: today?.sent ?? 0, - trend: days.slice(-14).map((d) => d.sent), - }; + // daily_trend only contains days that had sends; rebuild a continuous + // last-14-days axis (zero-filling the gaps) so the sparkline's x + // spacing is honest — otherwise a quiet week would be silently + // squeezed out and two distant days would read as adjacent. + const byDate = new Map( + (dash.data?.daily_trend ?? []).map((d) => [d.date?.slice(0, 10), d.sent]), + ); + const out: { date: string; sent: number }[] = []; + const now = new Date(); + for (let i = 13; i >= 0; i--) { + const d = new Date(now); + d.setUTCDate(now.getUTCDate() - i); + const key = d.toISOString().slice(0, 10); + out.push({ date: key, sent: byDate.get(key) ?? 0 }); + } + return { sentToday: out[out.length - 1].sent, trend: out }; }, [dash.data]); - const live = connection === "connected"; - // Connected == green, always. When quiet we say READY (not the old "IDLE", - // which with a gray dot read as "not connected"); when a mailbox is warming - // or sending we say LIVE and pulse. Only a real disconnect is gray. - const label = - connection === "disconnected" - ? "OFFLINE" - : connection === "connecting" - ? "CONNECTING" - : active > 0 - ? "LIVE" - : "READY"; - const dotClass = - connection === "disconnected" - ? "bg-slate-300" - : connection === "connecting" - ? "bg-amber-500" - : "bg-emerald-500"; - const labelTone = - connection === "disconnected" - ? "text-slate-400" - : connection === "connecting" - ? "text-amber-600" - : "text-emerald-600"; - - // Latency bucketing: <100ms great, <300ms okay, ≥300ms poor. - const latencyTone = - latencyMs == null - ? "text-slate-400" - : latencyMs < 100 - ? "text-emerald-600" - : latencyMs < 300 - ? "text-amber-600" - : "text-red-500"; + const scrub = hovered != null ? trend[hovered] : undefined; + const pct = capacity > 0 ? Math.min(100, (sentToday / capacity) * 100) : 0; return ( -
- - - {/* Active mailboxes ping; a quiet-but-connected workspace gets a - slow breathing glow so "READY" reads alive, not stuck. */} - {live && active > 0 ? ( - - ) : live ? ( - - ) : null} - - - {label} - - - {latencyMs != null ? `${latencyMs}ms` : "—"} - -
- -
- - {mailboxes} - - - {mailboxes === 1 ? "mailbox" : "mailboxes"} - - {active > 0 && ( - - {active} active - + {/* Hero: today's sends against the derived daily cap. While the + sparkline is being scrubbed it shows the hovered day instead. */} +
+ {scrub ? ( + <> + + {scrub.sent.toLocaleString()} + + + sent {formatTrendDay(scrub.date)} + + + ) : ( + <> + + + {capacity > 0 + ? `of ${capacity.toLocaleString()} sent today` + : "sent today"} + + )}
-
- Inbox + {/* Capacity meter: same-ramp track so the unfilled part still reads + as "room left today", not as a broken bar. */} +
0 + ? `${sentToday} of ${capacity} daily capacity used` + : "Connect a mailbox to start sending" + } + > +
+
+
+
+ + + + {/* Glance chips: mailboxes · active senders · unread inbox. Icons + carry the labels (title attrs spell them out) so this stays one + quiet row instead of two label/value text lines. */} +
+ + + {mailboxes} + + {active > 0 && ( + + + {active} + + )} 0 ? "text-sky-600" : "text-slate-400", )} + title={`${unseenCount} unread in inbox`} > - {unseenCount > 99 ? "99+" : unseenCount} unread + + + {unseenCount > 99 ? "99+" : unseenCount} +
- -
- Today - 0 ? "text-slate-600" : "text-slate-400", - )} - > - {sentToday}/{capacity || "—"} - -
- - ); } +/** "2026-08-30" → "Aug 30" for the sparkline scrub readout. */ +function formatTrendDay(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? iso + : d.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" }); +} + +// Sparkline geometry. Width matches the card's inner width (sidebar w-64 +// minus mx-2 and borders) so preserveAspectRatio="none" barely distorts +// the dots; side padding keeps markers clear of the overflow-hidden edges. +const SPARK_W = 238; +const SPARK_H = 34; +const SPARK_PAD_X = 6; +const SPARK_PAD_TOP = 6; +const SPARK_PAD_BOTTOM = 3; + /** - * Sparkline — 14 thin vertical bars, the last two weeks of send volume - * from the dashboard daily trend, normalized to the busiest day. Days - * with volume render sky; empty days stay a faint slate baseline. + * Sparkline — the last two weeks of send volume as a smooth area line + * (Catmull-Rom smoothing, gradient wash under the stroke, end-of-series + * dot with a surface ring). Full-bleed across the card; the chips row's + * top border underneath doubles as the baseline. Invisible per-day hit + * columns report the hovered day via onHover so the hero number above + * scrubs with the cursor. */ -function Sparkline({ values }: { values: number[] }) { - const bars = useMemo(() => { - const padded = - values.length >= 14 - ? values.slice(-14) - : [...Array.from({ length: 14 - values.length }, () => 0), ...values]; - const max = Math.max(...padded, 1); - return padded.map((v) => Math.round((v / max) * 100)); - }, [values]); +function Sparkline({ + points, + hovered, + onHover, +}: { + points: { date: string; sent: number }[]; + hovered: number | null; + onHover: (i: number | null) => void; +}) { + const { linePath, areaPath, dots, hasVolume } = useMemo(() => { + const n = points.length; + const baseY = SPARK_H - SPARK_PAD_BOTTOM; + if (n < 2) { + return { + linePath: "", + areaPath: "", + dots: [] as { x: number; y: number }[], + hasVolume: false, + }; + } + const max = Math.max(...points.map((p) => p.sent), 1); + const span = SPARK_W - SPARK_PAD_X * 2; + const usable = baseY - SPARK_PAD_TOP; + const pts = points.map((p, i) => ({ + x: SPARK_PAD_X + (i / (n - 1)) * span, + y: baseY - (p.sent / max) * usable, + })); + // Catmull-Rom → cubic bezier; control ys are clamped so a spike next + // to a flat run never overshoots the frame. + const clamp = (y: number) => + Math.min(baseY, Math.max(SPARK_PAD_TOP, y)); + let d = `M ${pts[0].x} ${pts[0].y}`; + for (let i = 0; i < n - 1; i++) { + const p0 = pts[i - 1] ?? pts[i]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[i + 2] ?? p2; + const c1x = p1.x + (p2.x - p0.x) / 6; + const c1y = clamp(p1.y + (p2.y - p0.y) / 6); + const c2x = p2.x - (p3.x - p1.x) / 6; + const c2y = clamp(p2.y - (p3.y - p1.y) / 6); + d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`; + } + return { + linePath: d, + areaPath: `${d} L ${pts[n - 1].x} ${baseY} L ${pts[0].x} ${baseY} Z`, + dots: pts, + hasVolume: points.some((p) => p.sent > 0), + }; + }, [points]); + + const n = points.length; + const step = n > 1 ? (SPARK_W - SPARK_PAD_X * 2) / (n - 1) : 0; + const hoverDot = hovered != null ? dots[hovered] : undefined; + const endDot = dots[dots.length - 1]; + return ( -
- {bars.map((v, i) => ( -
0 - ? "bg-sky-300 group-hover:bg-sky-400" - : "bg-slate-200 group-hover:bg-slate-300", - )} - style={{ height: `${Math.max(8, v)}%`, minHeight: "2px" }} + onHover(null)} + > + + + + + + + {linePath && hasVolume && ( + + )} + {linePath && ( + - ))} -
+ )} + {/* Hover scrub: hairline + marker on the hovered day. */} + {hoverDot && ( + <> + + + + )} + {/* End-of-series marker (today), ringed in the surface color. */} + {endDot && hovered == null && ( + + )} + {/* Invisible per-day hit columns driving the scrub. */} + {n >= 2 && + points.map((_, i) => ( + onHover(i)} + /> + ))} + ); } From 55156a89645940a5838bca6f265f30322fb24a61 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 31 Aug 2026 03:47:12 -0700 Subject: [PATCH 03/14] feat: surface the segment ID for API integrators: a click-to-copy ID chip in the segment page header, a Copy segment ID entry in the segments list row menu, a Segments in the API section in the segments guide pointing at the CRUD, members and enrolment endpoints, and a note in the API reference on where the dashboard shows the ID --- docs/content/docs/api/reference/contacts.mdx | 2 +- docs/content/docs/guides/segments.mdx | 7 +++++ .../app/app/contacts/segments/[id]/page.tsx | 30 ++++++++++++++++++- web/src/app/app/contacts/segments/page.tsx | 11 +++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/api/reference/contacts.mdx b/docs/content/docs/api/reference/contacts.mdx index e98f6d84..fffe6cd8 100644 --- a/docs/content/docs/api/reference/contacts.mdx +++ b/docs/content/docs/api/reference/contacts.mdx @@ -982,7 +982,7 @@ Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` ## Segments -Segments are saved contact audiences: a list of conditions plus per-contact manual overrides. Membership is evaluated live on every read, so a segment never needs rebuilding. Every segment endpoint takes the contact scopes, except enrolling into a campaign, which writes leads and takes `WRITE_CAMPAIGNS`. +Segments are saved contact audiences: a list of conditions plus per-contact manual overrides. Membership is evaluated live on every read, so a segment never needs rebuilding. Every segment endpoint takes the contact scopes, except enrolling into a campaign, which writes leads and takes `WRITE_CAMPAIGNS`. Every endpoint here addresses a segment by its `id`; besides `GET /segments`, the dashboard shows it on the segment page header (click to copy) and in the row menu of the Segments tab. A segment object: diff --git a/docs/content/docs/guides/segments.mdx b/docs/content/docs/guides/segments.mdx index c274c237..ba84cdbc 100644 --- a/docs/content/docs/guides/segments.mdx +++ b/docs/content/docs/guides/segments.mdx @@ -52,6 +52,12 @@ Sequences can pin as well: the **Add to segment** and **Remove from segment** ac - **Duplicate**: the segment menu copies a definition to start a variation from. - **Search and export**: the contact search and export accept `segment_ids`, so anything that takes a contact filter can be scoped to a segment. +## Segments in the API + +Everything above can be driven from the [API](/api/reference/contacts/#segments): list, create, update and delete segments, pin contacts in or out, look up a contact's segments, and enrol a segment into a campaign. Contact search and export take `segment_ids` to scope any contact query to a segment, so an external system (a signup form, a CRM sync) can keep a segment current and let campaigns pick it up from there. + +API calls address a segment by its ID. It is shown at the bottom of the segment page header (click it to copy), in the **Copy segment ID** entry of a segment's row menu on the Segments tab, and in every segment the API returns. Reads take the `READ_CONTACTS` key scope, writes `WRITE_CONTACTS`, and enrolling into a campaign `WRITE_CAMPAIGNS`. + Categories are labels you put on a contact. Segments are rules that read those labels (and everything else) to decide who belongs. Use a category to mark a fact about a contact, and a segment to describe an audience. @@ -68,4 +74,5 @@ Categories are labels you put on a contact. Segments are rules that read those l + diff --git a/web/src/app/app/contacts/segments/[id]/page.tsx b/web/src/app/app/contacts/segments/[id]/page.tsx index 827e829b..978382dc 100644 --- a/web/src/app/app/contacts/segments/[id]/page.tsx +++ b/web/src/app/app/contacts/segments/[id]/page.tsx @@ -3,7 +3,7 @@ import React from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; -import { ArrowLeftIcon, ChevronDownIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from "lucide-react"; +import { ArrowLeftIcon, CheckIcon, ChevronDownIcon, CopyIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from "lucide-react"; import toast from "react-hot-toast"; import ContactsTable from "@/components/app/contacts/ContactsTable"; @@ -94,6 +94,7 @@ function SegmentDetail() {
{s.description &&

{s.description}

} +
+ ); +} + // Pinned contacts. Excluded ones never show in the member list, so this is // the only place they can be seen and released. function OverridesPanel({ segment }: { segment: Segment }) { diff --git a/web/src/app/app/contacts/segments/page.tsx b/web/src/app/app/contacts/segments/page.tsx index d16cd008..8160cb09 100644 --- a/web/src/app/app/contacts/segments/page.tsx +++ b/web/src/app/app/contacts/segments/page.tsx @@ -92,6 +92,16 @@ function SegmentsList() { setEditorOpen(true); } + // The ID is what the segments API takes; copying needs no write permission. + async function copyId(s: Segment) { + try { + await navigator.clipboard.writeText(s.id); + toast.success("Segment ID copied"); + } catch { + toast.error("Could not copy"); + } + } + function askDelete(s: Segment) { confirm.show(`Delete the segment "${s.name}"? Contacts themselves are kept.`, async () => { try { @@ -202,6 +212,7 @@ function SegmentsList() { openEdit(s))}>Edit conditions setCampaignFor(s))}>Add to campaign duplicate(s))}>Duplicate + copyId(s)}>Copy segment ID askDelete(s))}>Delete From d7a17a0149048423dc131e5c5730aa9c74c57ce5 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 31 Aug 2026 03:50:36 -0700 Subject: [PATCH 04/14] feat: make the per-mailbox daily campaign cap configurable up to 5000 (issue #276): raise campaign_limit, campaign daily_limit and ramp start/ceiling validation to config.LimitMax, warn in the dashboard above 100/day, and update aitools, zapier and docs copy to match --- AGENTS.md | 2 +- docs/content/docs/api/reference/mailboxes.mdx | 2 +- docs/content/docs/guides/campaigns.mdx | 2 +- docs/content/docs/guides/mailboxes.mdx | 6 ++++-- docs/content/docs/learn/deliverability.mdx | 6 +++--- integrations/zapier/src/resources/mailboxes.ts | 2 +- internal/app/aitools/tools_mailboxes.go | 2 +- internal/config/constants.go | 10 ++++++++-- internal/errx/common.go | 6 +++--- internal/repository/pg_campaign.go | 8 ++++---- internal/repository/pg_email.go | 2 +- internal/utils/validate/campaign.go | 12 +++++++----- .../app/app/campaigns/[id]/preferences/page.tsx | 2 +- .../app/campaigns/NewCampaignDialog.tsx | 4 ++-- .../campaigns/preferences/CampaignAppearance.tsx | 9 ++++++--- .../app/campaigns/preferences/CampaignEmails.tsx | 4 ++-- web/src/components/app/emails/InboxDetails.tsx | 16 ++++++++++++---- 17 files changed, 58 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 74753941..7a4b9d70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -313,7 +313,7 @@ These are the current built-in defaults and guardrails: - default warmup start per mailbox: `10` emails/day - default warmup ceiling per mailbox: `40` emails/day - default warmup ramp: `+1` email/day -- `campaign_limit` updates are validated up to `100` max +- `campaign_limit` updates are validated up to `config.LimitMax` (`5000`); the dashboard warns above `100` Relevant code: diff --git a/docs/content/docs/api/reference/mailboxes.mdx b/docs/content/docs/api/reference/mailboxes.mdx index 624e2b5a..5c25f118 100644 --- a/docs/content/docs/api/reference/mailboxes.mdx +++ b/docs/content/docs/api/reference/mailboxes.mdx @@ -146,7 +146,7 @@ Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` | `signature_sync` | boolean | no | Keep the signature synced from the provider. | | `signature_code` | boolean | no | Treat the HTML signature as raw code. | | `status` | string | no | `active`, `inactive`, or `revoked`. | -| `campaign_limit` | integer | no | Daily cold-campaign cap for this mailbox (validated up to `100`). | +| `campaign_limit` | integer | no | Daily cold-campaign cap for this mailbox, `0` to `5000`. Default `50`; `30` to `50`/day is the safe cold-outreach band. | | `min_wait_time` | integer | no | Minimum seconds between sends. | | `reply_to` | string | no | Reply-to address. | | `timezone` | string | no | The mailbox's own IANA zone, such as `America/Denver`. Its sending behaviour and business-hours window are evaluated in this zone. Send an empty string to clear it, which leaves only the campaign's own window applying. | diff --git a/docs/content/docs/guides/campaigns.mdx b/docs/content/docs/guides/campaigns.mdx index 3bf04f83..acedd373 100644 --- a/docs/content/docs/guides/campaigns.mdx +++ b/docs/content/docs/guides/campaigns.mdx @@ -45,7 +45,7 @@ Warmbly is mailbox-first: safe volume is the sum of each mailbox's budget, not o | --- | --- | --- | | Per-mailbox cold cap | `50`/day | Hard ceiling for cold mail from one mailbox | | Minimum gap | `600s` | Shortest spacing between two sends from one mailbox, always enforced | -| Campaign daily limit | Per campaign | A per-mailbox cap for this campaign, validated `3` to `100` | +| Campaign daily limit | Per campaign | A per-mailbox cap for this campaign, validated `3` to `5000` | The campaign daily limit applies as a minimum against the cold cap, so it can lower a mailbox's volume but never raise it above `50`/day. diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index dfba1a92..b4e98e83 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -52,13 +52,15 @@ Set these on the mailbox's **Settings** tab. They apply to the next scheduled se | Control | Default | Range | |---------|---------|-------| -| Daily campaign cap | `50`/day | `0` to `100` | +| Daily campaign cap | `50`/day | `0` to `5000` | | Minimum gap between sends | `600s` (10 minutes) | A hard floor, with jitter added on top | The default of `50` is deliberately conservative. `30` to `50`/day is the normal safe band; a fresh mailbox should start at `10` to `20` and ramp. Raise the cap only for a mailbox with proven reputation and low complaint and bounce rates. +The range goes up to `5000` so a high-capacity mailbox (a warmed Google Workspace account allows `2000`/day, Microsoft 365 more) is not artificially blocked, and the dashboard shows a warning on anything above `100`. A high cap only raises the ceiling: the campaign's own daily limit, the ramp, sending behaviour, and your workspace's daily send limit all still apply, and the smallest one wins. + - A new mailbox has no reputation. Sudden high volume from a cold inbox is one of the fastest ways to land in spam. + A new mailbox has no reputation. Sudden high volume from a cold inbox is one of the fastest ways to land in spam. Scaling cold outreach means adding mailboxes, not cranking one mailbox's cap. ### Keeping a copy of sent mail diff --git a/docs/content/docs/learn/deliverability.mdx b/docs/content/docs/learn/deliverability.mdx index a9d9db5c..aa74ea63 100644 --- a/docs/content/docs/learn/deliverability.mdx +++ b/docs/content/docs/learn/deliverability.mdx @@ -76,7 +76,7 @@ Microsoft does not publish a complaint threshold; their position is that Exchang Reputation does not live at the worker. It lives at the IP, the domain, and the From mailbox. A worker that holds 200 mailboxes is 200 reputations, not one. Reasoning about a worker as a unit (`worker.cap = 5000`) ignores where the actual reputation signal accrues. -Warmbly's defaults reflect this: the cold-send cap is per mailbox (50/day by default, raisable to at most 100/day with positive reputation evidence) and the per-send gap is per mailbox (600 seconds). A worker's outbound budget is computed as `Σ mailbox.coldBudget` over its assigned mailboxes. The system surfaces a concentration warning when a shared worker holds more than ~10 actively-sending cold mailboxes at default settings (~500 cold sends/day), because past that point a single bad mailbox poisons the worker's IP for everyone else on it. +Warmbly's defaults reflect this: the cold-send cap is per mailbox (50/day by default; the setting accepts up to 5,000/day for genuinely high-capacity mailboxes, but anything past 100 warrants positive reputation evidence) and the per-send gap is per mailbox (600 seconds). A worker's outbound budget is computed as `Σ mailbox.coldBudget` over its assigned mailboxes. The system surfaces a concentration warning when a shared worker holds more than ~10 actively-sending cold mailboxes at default settings (~500 cold sends/day), because past that point a single bad mailbox poisons the worker's IP for everyone else on it. Scaling volume up means adding mailboxes, not raising per-mailbox caps. A campaign that wants 5,000 cold sends/day should be assigned to 100+ healthy mailboxes at the 50/day default, not 5 mailboxes pushed to 1,000/day. The first plan is invisible to any anomaly heuristic; the second is the textbook example. @@ -90,7 +90,7 @@ Scaling volume up means adding mailboxes, not raising per-mailbox caps. A campai 1. Use a sub-domain for cold sending (outreach.acme.com). Reputation issues stay scoped. 2. Configure SPF, DKIM and DMARC on the sub-domain. Start at `p=none`, then quarantine after 2 weeks. 3. Warm every cold sending mailbox for at least 3 weeks before campaigns start. -4. Cap each mailbox at 50 cold emails per day (up to 100 only with proven reputation) with a 10-minute minimum gap. +4. Cap each mailbox at 50 cold emails per day (higher only with proven reputation) with a 10-minute minimum gap. 5. Spread campaign sends across multiple mailboxes. Do not concentrate volume. 6. Plain text by default. No tracking pixels unless you need opens. 7. Two or three follow-ups, not eight. @@ -102,7 +102,7 @@ The sub-domain isolation matters because reputation cascades up the organisation The DMARC ramp matters: start at `p=none; rua=mailto:dmarc@yourdomain`, watch reports for 14 days to confirm no third-party services were quietly relying on your domain (this is the most common surprise), then move to `p=quarantine; pct=10` for a week, then increase pct to 100, then `p=reject`. Skipping `pct` ramping is how legitimate mail gets quarantined by an aggressive DMARC change. -The 50/day default per-mailbox cap (raisable to 100/day only with positive reputation evidence) and the 600-second gap are not magic numbers. They are derived from observing that mailboxes which send a new message every 10+ minutes look indistinguishable from human typing cadence to the receiver, and from the empirical observation that Gmail's anomaly model starts flagging mailboxes sending more than ~150/day from a previously low baseline. +The 50/day default per-mailbox cap (raisable only with positive reputation evidence) and the 600-second gap are not magic numbers. They are derived from observing that mailboxes which send a new message every 10+ minutes look indistinguishable from human typing cadence to the receiver, and from the empirical observation that Gmail's anomaly model starts flagging mailboxes sending more than ~150/day from a previously low baseline. ## What to measure diff --git a/integrations/zapier/src/resources/mailboxes.ts b/integrations/zapier/src/resources/mailboxes.ts index cd901880..ffa9142e 100644 --- a/integrations/zapier/src/resources/mailboxes.ts +++ b/integrations/zapier/src/resources/mailboxes.ts @@ -142,7 +142,7 @@ const updateMailbox = { inputFields: [ mailboxField, { key: 'name', label: 'Display name', type: 'string' }, - { key: 'campaign_limit', label: 'Daily campaign cap', type: 'integer', helpText: '3 to 100.' }, + { key: 'campaign_limit', label: 'Daily campaign cap', type: 'integer', helpText: '0 to 5000. Default 50; 30 to 50 per day is the safe cold-outreach band.' }, { key: 'min_wait_time', label: 'Minimum gap between sends (seconds)', type: 'integer' }, { key: 'reply_to', label: 'Reply-to address', type: 'string' }, ], diff --git a/internal/app/aitools/tools_mailboxes.go b/internal/app/aitools/tools_mailboxes.go index f9da56e6..77a467e7 100644 --- a/internal/app/aitools/tools_mailboxes.go +++ b/internal/app/aitools/tools_mailboxes.go @@ -37,7 +37,7 @@ func (d Deps) registerMailboxTools(r *Registry) { "name": strProp("Display name."), "reply_to": strProp("Reply-to address."), "status": enumProp("Mailbox status.", "active", "inactive"), - "campaign_limit": intProp("Max cold-campaign emails per day for this mailbox."), + "campaign_limit": intProp("Max cold-campaign emails per day for this mailbox, 0 to 5000. Default 50; 30-50/day is the safe cold-outreach band."), "min_wait_time": intProp("Minimum seconds between sends."), "warmup": boolProp("Enable or disable warmup."), "warmup_base": intProp("Warmup starting emails/day."), diff --git a/internal/config/constants.go b/internal/config/constants.go index 215deeb6..2e79faf3 100644 --- a/internal/config/constants.go +++ b/internal/config/constants.go @@ -3,8 +3,14 @@ package config const ( DefaultColor = "#c4c8cf" Domain = "warmbly.com" - LimitMin = 10 - LimitMax = 200 + // LimitMin/LimitMax bound every per-mailbox and per-campaign daily send + // cap the API will store. 5000 covers real provider ceilings (Google + // Workspace 2000/day, M365 10000 recipients/day); the safe cold band + // stays 30-50/day and is steered by defaults, warnings and the advisor. + LimitMin = 0 + LimitMax = 5000 + + CampaignDailyLimitMin = 3 CampaignLimitDefault = 50 MinWaitTimeDefault = 600 diff --git a/internal/errx/common.go b/internal/errx/common.go index f1b91891..f8e549a4 100644 --- a/internal/errx/common.go +++ b/internal/errx/common.go @@ -131,8 +131,8 @@ var ( ErrEmailName = New(BadRequest, "Invalid name. Must be 2–100 characters and contain only letters, numbers, spaces, '-', '.', or '’'.") ErrEmailSignaturePlain = New(BadRequest, "Plain email signature is too long.") ErrEmailSignatureHTML = New(BadRequest, "HTML email signature is too long.") - ErrEmailMinWaitTime = New(BadRequest, "Minimum time gap between emails must be between 0 and 86400 minutes.") - ErrEmailCampaignLimit = New(BadRequest, "Campaign limit must be between 0 and 100.") + ErrEmailMinWaitTime = New(BadRequest, "Minimum time gap between emails must be between 0 and 86400 seconds.") + ErrEmailCampaignLimit = New(BadRequest, fmt.Sprintf("Campaign limit must be between %d and %d.", config.LimitMin, config.LimitMax)) ErrEmailTimezone = New(BadRequest, "Invalid timezone. Use an IANA name such as Europe/London or America/Denver, or leave it empty to follow the campaign.") ErrEmailWarmupBase = New(BadRequest, "Warmup base must be between 0 and 100.") ErrEmailWarmupMax = New(BadRequest, "Warmup max amount must be between 0 and 100.") @@ -148,7 +148,7 @@ var ( // Campaign ErrCampaignName = New(BadRequest, "Campaign name length must be between 3 and 50 characters.") ErrCampaignDescription = New(BadRequest, "Campaign description length must be below 300 characters.") - ErrCampaignDailyLimit = New(BadRequest, "Daily limit must be between 3 and 10000000.") + ErrCampaignDailyLimit = New(BadRequest, fmt.Sprintf("Daily limit must be between %d and %d.", config.CampaignDailyLimitMin, config.LimitMax)) ErrCampaignStartDate = New(BadRequest, "Start date cannot be in the past. Pick today or later, or clear it (null) to start right away.") ErrCampaignEndDate = New(BadRequest, "End date must be in the future.") ErrCampaignLimit = New(BadRequest, "You reached your limit for campaigns, please try again later.") diff --git a/internal/repository/pg_campaign.go b/internal/repository/pg_campaign.go index b9e318fe..bb1015dc 100644 --- a/internal/repository/pg_campaign.go +++ b/internal/repository/pg_campaign.go @@ -1013,8 +1013,8 @@ func (r *campaignRepository) Update(ctx context.Context, userID, campaignID stri argPos++ } if data.RampStart != nil { - if *data.RampStart < 1 || *data.RampStart > 100 { - return nil, errx.New(errx.BadRequest, "ramp start must be between 1 and 100") + if *data.RampStart < 1 || *data.RampStart > config.LimitMax { + return nil, errx.New(errx.BadRequest, fmt.Sprintf("ramp start must be between 1 and %d", config.LimitMax)) } setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "ramp_start", argPos)) args = append(args, *data.RampStart) @@ -1029,8 +1029,8 @@ func (r *campaignRepository) Update(ctx context.Context, userID, campaignID stri argPos++ } if data.RampCeiling != nil { - if *data.RampCeiling < 1 || *data.RampCeiling > 100 { - return nil, errx.New(errx.BadRequest, "ramp ceiling must be between 1 and 100") + if *data.RampCeiling < 1 || *data.RampCeiling > config.LimitMax { + return nil, errx.New(errx.BadRequest, fmt.Sprintf("ramp ceiling must be between 1 and %d", config.LimitMax)) } setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "ramp_ceiling", argPos)) args = append(args, *data.RampCeiling) diff --git a/internal/repository/pg_email.go b/internal/repository/pg_email.go index a4409234..0e12b07d 100644 --- a/internal/repository/pg_email.go +++ b/internal/repository/pg_email.go @@ -845,7 +845,7 @@ func (r *emailRepository) Update(ctx context.Context, userID, emailAccountID str } } if udata.CampaignLimit != nil { - if *udata.CampaignLimit < 0 || *udata.CampaignLimit > 100 { + if *udata.CampaignLimit < config.LimitMin || *udata.CampaignLimit > config.LimitMax { return nil, errx.ErrEmailCampaignLimit } setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "campaign_limit", argPos)) diff --git a/internal/utils/validate/campaign.go b/internal/utils/validate/campaign.go index 754fdcec..8da82b60 100644 --- a/internal/utils/validate/campaign.go +++ b/internal/utils/validate/campaign.go @@ -1,9 +1,11 @@ package validate import ( + "fmt" "time" "github.com/warmbly/warmbly/internal/bitmask" + "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" ) @@ -24,7 +26,7 @@ func CampaignDescription(description string) *errx.Error { } func CampaignDailyLimit(val int) *errx.Error { - if val < 3 || val > 100 { + if val < config.CampaignDailyLimitMin || val > config.LimitMax { return errx.ErrCampaignDailyLimit } return nil @@ -113,14 +115,14 @@ func CampaignSenderWeight(w int) *errx.Error { // min(daily_limit, ramp_ceiling, per-mailbox cap), so a ceiling above the // daily limit can only be clamped down, never over-send. func CampaignRamp(start, increment, ceiling int) *errx.Error { - if start < 1 || start > 100 { - return errx.New(errx.BadRequest, "ramp start must be between 1 and 100") + if start < 1 || start > config.LimitMax { + return errx.New(errx.BadRequest, fmt.Sprintf("ramp start must be between 1 and %d", config.LimitMax)) } if increment < 0 || increment > 100 { return errx.New(errx.BadRequest, "ramp increment must be between 0 and 100") } - if ceiling < 1 || ceiling > 100 { - return errx.New(errx.BadRequest, "ramp ceiling must be between 1 and 100") + if ceiling < 1 || ceiling > config.LimitMax { + return errx.New(errx.BadRequest, fmt.Sprintf("ramp ceiling must be between 1 and %d", config.LimitMax)) } if start > ceiling { return errx.New(errx.BadRequest, "ramp start cannot exceed ramp ceiling") diff --git a/web/src/app/app/campaigns/[id]/preferences/page.tsx b/web/src/app/app/campaigns/[id]/preferences/page.tsx index 4234c49b..6f20c11f 100644 --- a/web/src/app/app/campaigns/[id]/preferences/page.tsx +++ b/web/src/app/app/campaigns/[id]/preferences/page.tsx @@ -28,7 +28,7 @@ import useCampaignSenders from "@/lib/api/hooks/app/campaigns/useCampaignSenders import useReplaceCampaignSenders from "@/lib/api/hooks/app/campaigns/useReplaceCampaignSenders"; const DAILY_MIN = 3; -const DAILY_MAX = 100; +const DAILY_MAX = 5000; // One scrolling page — every section stacks in order and the left nav is a // scrollspy over these ids. diff --git a/web/src/components/app/campaigns/NewCampaignDialog.tsx b/web/src/components/app/campaigns/NewCampaignDialog.tsx index f5627912..9d5d469d 100644 --- a/web/src/components/app/campaigns/NewCampaignDialog.tsx +++ b/web/src/components/app/campaigns/NewCampaignDialog.tsx @@ -716,13 +716,13 @@ function SendingStep({ draft, patch }: { draft: Draft; patch: (p: Partial

Daily limit per mailbox

- 3 to 100. Stay near 50 until the mailboxes have proven their reputation. + 3 to 5,000. Stay near 50 until the mailboxes have proven their reputation.

patch({ dailyLimit: v })} className="w-24 shrink-0" /> diff --git a/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx b/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx index 3a050142..4f181973 100644 --- a/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx @@ -10,7 +10,7 @@ import SenderSelector from "./SenderSelector"; import { SettingRow, Toggle } from "./components/CampaignPreferenceBoolBox"; const DAILY_MIN = 3; -const DAILY_MAX = 100; +const DAILY_MAX = 5000; type SetCampaign = React.Dispatch>; @@ -61,6 +61,7 @@ export function SendingAccountsSection({ setExplicitAccounts: React.Dispatch>; }) { const dailyInvalid = newCampaign.daily_limit < DAILY_MIN || newCampaign.daily_limit > DAILY_MAX; + const dailyHigh = !dailyInvalid && newCampaign.daily_limit > 100; return (
@@ -86,10 +87,12 @@ export function SendingAccountsSection({ suffix="emails / day" className="w-48" /> -

+

{dailyInvalid ? `Must be between ${DAILY_MIN} and ${DAILY_MAX}.` - : `${DAILY_MIN}–${DAILY_MAX}. Default 50 — stay conservative until reputation is proven.`} + : dailyHigh + ? "Well above the 30–50/day safe cold-outreach band. Every mailbox in the pool needs the reputation and provider capacity to carry this." + : `${DAILY_MIN}–${DAILY_MAX}. Default 50 — stay conservative until reputation is proven.`}

diff --git a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx index fdf5a570..21b29cd2 100644 --- a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx @@ -140,7 +140,7 @@ export function RotationRampSection({ setNewCampaign((bef) => ({ ...bef, ramp_start: v }))} suffix="/ day" className="w-36" @@ -162,7 +162,7 @@ export function RotationRampSection({ setNewCampaign((bef) => ({ ...bef, ramp_ceiling: v }))} suffix="/ day" className="w-36" diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index 914ba12b..3287797f 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -264,7 +264,7 @@ function FieldShell({ label, hint, children }: { label: string; hint?: string; c ); } -function NumField({ value, onChange, suffix }: { value: number; onChange: (v: number) => void; suffix?: string }) { +function NumField({ value, onChange, suffix, max }: { value: number; onChange: (v: number) => void; suffix?: string; max?: number }) { // Themed number field with our own steppers, no native spinner. return ( @@ -1481,14 +1482,21 @@ function SettingsTab({ form, update, mailbox }: { form: Inbox; update: (p: Parti
Sending limits - - update({ campaign_limit: v })} suffix="emails / day" /> + + update({ campaign_limit: v })} suffix="emails / day" max={5000} /> + {form.campaign_limit > 100 && ( +

+ Well above the 30–50/day safe band for cold outreach. Caps this high need a warmed, + established mailbox and a provider that allows the volume (Google Workspace tops out at + 2,000/day). Deliverability damage shows up as spam placement, not as errors. +

+ )}
- update({ min_wait_time: v })} suffix="seconds" /> + update({ min_wait_time: v })} suffix="seconds" max={86400} />
From ec1ac7080a9a6f86f2f132e3bf815551128c413b Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 31 Aug 2026 03:52:38 -0700 Subject: [PATCH 05/14] feat: derive the LivePanel capacity denominator from the sum of each mailbox's configured campaign_limit (falling back to the 50/day default) instead of a flat mailbox count times 50, so tuned per-mailbox limits move the meter --- web/src/components/layout/AppNav.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/components/layout/AppNav.tsx b/web/src/components/layout/AppNav.tsx index 95408614..6779b333 100644 --- a/web/src/components/layout/AppNav.tsx +++ b/web/src/components/layout/AppNav.tsx @@ -623,8 +623,8 @@ function Section({ section, first = false }: { section: NavSection; first?: bool * (shares the dashboard page's query cache; realtime invalidation keeps * it current) * - * The capacity denominator is a derived cap based on mailbox count × 50 - * (default cold cap from internal/config/constants.go). + * The capacity denominator sums each mailbox's configured campaign_limit + * (default 50/day, from internal/config/constants.go). */ function LivePanel() { const emails = useAppStore((s) => s.emails); @@ -638,7 +638,11 @@ function LivePanel() { const st = mailboxDisplayStatus(e); return st === "healthy" || st === "warming"; }).length; - return { active: a, mailboxes: m, capacity: m * 50 }; + // Capacity = the sum of each mailbox's configured daily campaign + // limit (default 50/day), not a flat count × 50 — a tuned-down or + // raised mailbox should move the meter's denominator. + const cap = emails.reduce((sum, e) => sum + (e.campaign_limit ?? 50), 0); + return { active: a, mailboxes: m, capacity: cap }; }, [emails]); const { sentToday, trend } = useMemo(() => { From 760cb5651cbcb88f8149fe54b8685a368f4b64a5 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 31 Aug 2026 03:54:59 -0700 Subject: [PATCH 06/14] feat: fix issue 273: read unibox message bodies from the key the worker actually writes (users//emails//.emsg via config.StorageEndpointEmailBody) instead of the never-written emails// key, so opened messages render the full stored plain+HTML body with its line breaks instead of degrading to the collapsed one-line snippet with the truncated-message notice; thread the mailbox account id through GetBody, GetByID and the body-text search backfill, drop the dead PutBody/GetEmailKey, and in the contact activity detail stop content escaping the drawer: measured left/right alignment for the date-range popover (new useFlipAlignment hook) and wrap-anywhere on expanded detail values so long URLs cannot widen the grid past the card --- internal/app/unibox/backfill_body_text.go | 2 +- internal/app/unibox/email.go | 16 ++++--- internal/app/unibox/storage.go | 37 ++++---------- internal/repository/pg_unibox.go | 17 ++++--- .../app/contacts/contact-edit/ActivityTab.tsx | 11 ++++- web/src/hooks/useFlipPlacement.tsx | 48 +++++++++++++++++++ 6 files changed, 85 insertions(+), 46 deletions(-) diff --git a/internal/app/unibox/backfill_body_text.go b/internal/app/unibox/backfill_body_text.go index 950bd00b..0ad67408 100644 --- a/internal/app/unibox/backfill_body_text.go +++ b/internal/app/unibox/backfill_body_text.go @@ -53,7 +53,7 @@ func (s *uniboxService) StartBodyTextBackfill(ctx context.Context) { for _, t := range targets { cursor = t.ID - body, gerr := s.GetBody(ctx, t.UserID, t.ID) + body, gerr := s.GetBody(ctx, t.UserID, t.EmailID, t.ID) if gerr != nil { // A missing blob is expected here: fixtures and mail synced // before body storage existed have no object to read. diff --git a/internal/app/unibox/email.go b/internal/app/unibox/email.go index 7833d41e..ff140e4d 100644 --- a/internal/app/unibox/email.go +++ b/internal/app/unibox/email.go @@ -19,10 +19,11 @@ func (s *uniboxService) GetByID( var snippet string var fixtureMessage bool - // ownerID is the mailbox owner's user_id. The S3 body key is built from it - // (emails//), so the body must be fetched under the owner even - // when a different teammate opens the message via the org-scoped read. - var ownerID uuid.UUID + // ownerID is the mailbox owner's user_id and accountID the mailbox the + // message belongs to. The body's object-storage key is built from both, so + // the body must be fetched under the owner even when a different teammate + // opens the message via the org-scoped read. + var ownerID, accountID uuid.UUID // Fetch email data by id index { @@ -32,6 +33,7 @@ func (s *uniboxService) GetByID( return nil, errx.InternalError() } ownerID = owner + accountID = msg.EmailID resp.ID = msg.ID resp.GmailID = msg.GmailID resp.UID = msg.UID @@ -58,10 +60,10 @@ func (s *uniboxService) GetByID( fixtureMessage = isFixtureMessage(msg.MessageID) } - // Fetch body from s3 storage. Keyed by the mailbox OWNER's user_id, not the - // caller's: the key is emails//. + // Fetch body from object storage. Keyed by the mailbox OWNER's user_id and + // the mailbox account id, not the caller's identity. { - out, err := s.GetBody(ctx, ownerID, id) + out, err := s.GetBody(ctx, ownerID, accountID, id) if err != nil { // A missing blob is a degraded read, not a broken endpoint: mail // synced before body storage existed, or a blob that never landed, diff --git a/internal/app/unibox/storage.go b/internal/app/unibox/storage.go index f059c42d..77401569 100644 --- a/internal/app/unibox/storage.go +++ b/internal/app/unibox/storage.go @@ -1,23 +1,23 @@ package unibox import ( - "bytes" "context" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/pkg/emsg" ) -func GetEmailKey(userID, id uuid.UUID) string { - return "emails/" + userID.String() + "/" + id.String() - -} - +// GetBody reads a message's full body blob. The key must match what the +// worker's StoreBody writes (users//emails//.emsg), +// which is why the mailbox account id travels here alongside the owner: +// reading under any other key finds nothing and the message degrades to its +// one-line snippet. func (s *uniboxService) GetBody( ctx context.Context, - userID, id uuid.UUID, + userID, emailID, id uuid.UUID, ) (*emsg.EmailBlob, error) { - key := GetEmailKey(userID, id) + key := config.StorageEndpointEmailBody(userID, emailID, id) body, err := s.blob.Get(ctx, key) if err != nil { return nil, err @@ -31,24 +31,3 @@ func (s *uniboxService) GetBody( return obj, nil } - -func (s *uniboxService) PutBody( - ctx context.Context, - userID, id uuid.UUID, - plainText string, - htmlText string, -) error { - key := GetEmailKey(userID, id) - - blob := &emsg.EmailBlob{ - PlainText: []byte(plainText), - HTMLBody: []byte(htmlText), - } - - body, err := blob.EncodeBinary() - if err != nil { - return err - } - - return s.blob.Put(ctx, key, bytes.NewReader(body), "") -} diff --git a/internal/repository/pg_unibox.go b/internal/repository/pg_unibox.go index d985be88..33a9351f 100644 --- a/internal/repository/pg_unibox.go +++ b/internal/repository/pg_unibox.go @@ -84,10 +84,12 @@ type UniboxRepository interface { } // UniboxBodyTarget is one message awaiting a search-text backfill. UserID is -// the mailbox owner, which is what the body's object-storage key is built from. +// the mailbox owner and EmailID the mailbox account; the body's object-storage +// key is built from both. type UniboxBodyTarget struct { - ID uuid.UUID - UserID uuid.UUID + ID uuid.UUID + UserID uuid.UUID + EmailID uuid.UUID } type uniboxRepository struct { @@ -253,8 +255,9 @@ func (r *uniboxRepository) GetByID(ctx context.Context, userID, id uuid.UUID) (* // GetByIDForOrg reads a single message scoped to the org's mailboxes (not the // caller's user_id), mirroring GetByThread, so a non-owner teammate who sees a // message in the org-scoped list can open it. It also returns the row's owner -// user_id: the S3 body key is built from the owner (emails//), so -// the caller must fetch the body under the owner, not under itself. +// user_id: the body's object-storage key is built from the owner (and the +// row's email_id), so the caller must fetch the body under the owner, not +// under itself. func (r *uniboxRepository) GetByIDForOrg(ctx context.Context, orgID, id uuid.UUID) (*models.EmailMessageStoreData, uuid.UUID, error) { query := fmt.Sprintf(` SELECT user_id, %s @@ -1148,7 +1151,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode // out to be empty and stays that way. func (r *uniboxRepository) ListMissingBodyText(ctx context.Context, afterID uuid.UUID, limit int) ([]UniboxBodyTarget, error) { query := ` - SELECT id, user_id + SELECT id, user_id, email_id FROM unibox_emails WHERE body_text = '' AND id > $1 ORDER BY id @@ -1163,7 +1166,7 @@ func (r *uniboxRepository) ListMissingBodyText(ctx context.Context, afterID uuid out := make([]UniboxBodyTarget, 0, limit) for rows.Next() { var t UniboxBodyTarget - if err := rows.Scan(&t.ID, &t.UserID); err != nil { + if err := rows.Scan(&t.ID, &t.UserID, &t.EmailID); err != nil { return nil, err } out = append(out, t) diff --git a/web/src/components/app/contacts/contact-edit/ActivityTab.tsx b/web/src/components/app/contacts/contact-edit/ActivityTab.tsx index c1408e20..08a6bcb8 100644 --- a/web/src/components/app/contacts/contact-edit/ActivityTab.tsx +++ b/web/src/components/app/contacts/contact-edit/ActivityTab.tsx @@ -57,6 +57,7 @@ import type { } from "@/lib/api/models/app/contacts/ContactCampaignState"; import type { LeadStatus } from "@/lib/api/models/app/contacts/Contact"; import useClickOutside from "@/hooks/useClickOutside"; +import { useFlipAlignment } from "@/hooks/useFlipPlacement"; import { fmtAbsolute, fmtRelative } from "./format"; type FilterId = @@ -755,6 +756,9 @@ function DateRange({ const [open, setOpen] = React.useState(false); const ref = React.useRef(null); useClickOutside(ref, () => setOpen(false)); + // The trigger wraps anywhere along the toolbar row, so the panel side is + // measured, not fixed: a fixed right-0 clipped it against the drawer edge. + const align = useFlipAlignment(ref, open, 256); const active = !!from || !!to; const label = active @@ -784,7 +788,9 @@ function DateRange({ {label} {open && ( -
+
))}
diff --git a/web/src/components/app/emails/UpdateCredentialsDialog.tsx b/web/src/components/app/emails/UpdateCredentialsDialog.tsx new file mode 100644 index 00000000..fc329c1f --- /dev/null +++ b/web/src/components/app/emails/UpdateCredentialsDialog.tsx @@ -0,0 +1,274 @@ +// Replacement-credentials dialog for an SMTP/IMAP mailbox whose password +// changed (issue #274). Same fields and validation as the connect form in +// AddEmailModal, minus name/email, which a reconnect never changes. The +// backend verifies the credentials against a live worker before storing, then +// reactivates the mailbox and clears its credential errors. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, InboxIcon, KeyRoundIcon, Loader2Icon, SendIcon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import { useQueryClient } from "@tanstack/react-query"; + +import { TextInput } from "@/components/ui/field"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; +import updateEmailCredentials from "@/lib/api/client/app/emails/updateEmailCredentials"; + +export default function UpdateCredentialsDialog({ + mailboxId, + mailboxEmail, + open, + onClose, +}: { + mailboxId: string; + mailboxEmail: string; + open: boolean; + onClose: () => void; +}) { + const qc = useQueryClient(); + + const [imapHost, setImapHost] = React.useState(""); + const [imapPort, setImapPort] = React.useState("993"); + const [imapUser, setImapUser] = React.useState(mailboxEmail); + const [imapPass, setImapPass] = React.useState(""); + + const [smtpHost, setSmtpHost] = React.useState(""); + const [smtpPort, setSmtpPort] = React.useState("587"); + const [smtpUser, setSmtpUser] = React.useState(mailboxEmail); + const [smtpPass, setSmtpPass] = React.useState(""); + + const [sameCreds, setSameCreds] = React.useState(true); + const [submitting, setSubmitting] = React.useState(false); + + // Reset when reopened so a cancelled attempt never leaks a typed password. + React.useEffect(() => { + if (open) { + setImapHost(""); + setImapPort("993"); + setImapUser(mailboxEmail); + setImapPass(""); + setSmtpHost(""); + setSmtpPort("587"); + setSmtpUser(mailboxEmail); + setSmtpPass(""); + setSameCreds(true); + setSubmitting(false); + } + }, [open, mailboxEmail]); + + React.useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + function valid() { + if (!imapHost.trim() || !imapPort.trim() || !imapUser.trim() || !imapPass) return false; + if (!smtpHost.trim() || !smtpPort.trim()) return false; + if (!sameCreds && (!smtpUser.trim() || !smtpPass)) return false; + const p = Number(smtpPort); + if (p !== 465 && p !== 587) return false; + return true; + } + + async function submit() { + if (submitting || !valid()) return; + setSubmitting(true); + const smtp = sameCreds + ? { username: imapUser.trim(), password: imapPass, host: smtpHost.trim(), port: Number(smtpPort) } + : { username: smtpUser.trim(), password: smtpPass, host: smtpHost.trim(), port: Number(smtpPort) }; + try { + await toast.promise( + updateEmailCredentials(mailboxId, smtp, { + username: imapUser.trim(), + password: imapPass, + host: imapHost.trim(), + port: Number(imapPort), + }), + { + loading: "Verifying credentials…", + success: "Credentials updated. The mailbox is back online.", + error: (e: AppError) => buildError(e), + }, + ); + qc.invalidateQueries({ queryKey: ["emails", "list"] }); + qc.invalidateQueries({ queryKey: ["analytics", "accounts"] }); + onClose(); + } catch { + /* surfaced by toast */ + } finally { + setSubmitting(false); + } + } + + return ( + + {open && ( + + e.stopPropagation()} + className="w-full max-w-[480px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[88dvh]" + > +
+ Mailbox +
+ Update credentials for {mailboxEmail} + +
+ +
+
}> + + + + + + + + + +
+ +
}> + + + + + + {!sameCreds && ( + +
+ + + + + + +
+
+ )} +
+
+
+ +
+
+ + Verified against your server before saving. +
+ + {submitting ? : } + Update credentials + +
+ + + )} + + ); +} + +function Section({ title, sub, icon, children }: { title: string; sub: string; icon: React.ReactNode; children: React.ReactNode }) { + return ( +
+
+ {icon} + {title} +
+ {sub} +
+
{children}
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +// One bordered field holding host (flex) and port (fixed) with a hairline +// divider, same as the connect form's server rows. +function HostPortInput({ + host, + onHost, + hostPlaceholder, + port, + onPort, + portPlaceholder, +}: { + host: string; + onHost: (v: string) => void; + hostPlaceholder: string; + port: string; + onPort: (v: string) => void; + portPlaceholder: string; +}) { + return ( +
+ onHost(e.target.value)} + placeholder={hostPlaceholder} + className="flex-1 min-w-0 px-2.5 bg-transparent outline-none text-[12.5px] text-slate-900 placeholder:text-slate-400" + /> +
+ onPort(e.target.value)} + placeholder={portPlaceholder} + inputMode="numeric" + className="w-14 shrink-0 px-2 bg-slate-50/60 outline-none text-[12.5px] text-slate-900 placeholder:text-slate-400 tabular-nums text-center" + /> +
+ ); +} diff --git a/web/src/lib/api/client/app/emails/reauthEmailOAuth.ts b/web/src/lib/api/client/app/emails/reauthEmailOAuth.ts new file mode 100644 index 00000000..141f9070 --- /dev/null +++ b/web/src/lib/api/client/app/emails/reauthEmailOAuth.ts @@ -0,0 +1,13 @@ +import Request from "../../Request"; +import type { OAuthStartResponse } from "./onboardOAuthStart"; + +// Starts an OAuth round trip that renews an existing mailbox's tokens after +// the provider invalidated them (password change, revoked grant). The finish +// leg is the ordinary onboardOAuthFinish with the returned state. +export default async function reauthEmailOAuth(mailboxId: string): Promise { + return await Request({ + method: "POST", + url: `/emails/onboarding/oauth/reauth/${mailboxId}`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/emails/updateEmailCredentials.ts b/web/src/lib/api/client/app/emails/updateEmailCredentials.ts new file mode 100644 index 00000000..aba2bcd6 --- /dev/null +++ b/web/src/lib/api/client/app/emails/updateEmailCredentials.ts @@ -0,0 +1,19 @@ +import Request from "../../Request"; +import type Inbox from "@/lib/api/models/app/emails/Inbox"; +import type Service from "@/lib/api/models/app/emails/Service"; + +// Replaces an SMTP/IMAP mailbox's credentials after a password change. The +// backend validates them against a live worker before storing, then +// reactivates the mailbox and clears its credential errors. +export default async function updateEmailCredentials( + mailboxId: string, + smtp: Service, + imap: Service, +): Promise { + return await Request({ + method: "PUT", + url: `/emails/onboarding/smtp-imap/${mailboxId}`, + data: { smtp, imap }, + authorization: true, + }); +} diff --git a/web/src/lib/emails/emailOAuthPopup.ts b/web/src/lib/emails/emailOAuthPopup.ts new file mode 100644 index 00000000..0d621ec9 --- /dev/null +++ b/web/src/lib/emails/emailOAuthPopup.ts @@ -0,0 +1,98 @@ +// Drives the mailbox OAuth popup outside AddEmailModal (the reconnect flow in +// the mailbox drawer). Opens the provider authorization URL in a centered +// popup; the backend's /addresses//callback page postMessages +// {type:"email_oauth_callback", code, state} back to this opener; we resolve +// with them so the caller can finish the handshake. + +import { API_URL, APP_URL } from "@/lib/information"; + +export interface EmailOAuthPopupResult { + code: string; + state: string; +} + +interface EmailOAuthCallbackMessage { + type: "email_oauth_callback"; + provider: string; + code: string; + state: string; + error: string; +} + +// originOf normalises a configured base URL to a bare origin. APP_URL and +// API_URL may carry a trailing slash or a path; event.origin never does. +function originOf(value: string | undefined): string | null { + if (!value) return null; + try { + return new URL(value, window.location.href).origin; + } catch { + return null; + } +} + +// The bridge page is served by the API so the registered redirect_uri stays +// stable, which means event.origin can be API_URL's origin on split-domain +// deployments. The real replay protection is the single-use state match. +function allowedCallbackOrigins(): string[] { + return [originOf(APP_URL), originOf(API_URL), window.location.origin].filter( + (o): o is string => Boolean(o), + ); +} + +export function openEmailOAuthPopup(authUrl: string, expectedState: string): Promise { + return new Promise((resolve, reject) => { + const width = 520; + const height = 640; + const left = window.screenX + Math.max(0, (window.outerWidth - width) / 2); + const top = window.screenY + Math.max(0, (window.outerHeight - height) / 2); + const popup = window.open( + authUrl, + "warmbly_email_oauth", + `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=yes`, + ); + if (!popup) { + reject(new Error("Popup blocked. Allow popups for this site and try again.")); + return; + } + popup.focus(); + + let settled = false; + const cleanup = () => { + window.removeEventListener("message", onMessage); + window.clearInterval(closedTimer); + }; + + const onMessage = (event: MessageEvent) => { + if (event.origin && !allowedCallbackOrigins().includes(event.origin)) return; + const data = event.data as EmailOAuthCallbackMessage | undefined; + if (!data || data.type !== "email_oauth_callback") return; + if (data.state !== expectedState) return; + settled = true; + cleanup(); + try { + popup.close(); + } catch { + /* ignore */ + } + if (data.error) { + reject(new Error(data.error === "access_denied" ? "Authorization was cancelled." : `Provider error: ${data.error}`)); + return; + } + if (data.code) { + resolve({ code: data.code, state: data.state }); + return; + } + reject(new Error("Authorization was cancelled.")); + }; + + window.addEventListener("message", onMessage); + + // Detect a manually-closed popup so the caller's promise doesn't hang. + const closedTimer = window.setInterval(() => { + if (popup.closed && !settled) { + cleanup(); + reject(new Error("Authorization window was closed before finishing.")); + } + }, 600); + }); +} From c28179e61fb1482a6daceea2747cc054980c29a6 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 31 Aug 2026 05:31:13 -0700 Subject: [PATCH 09/14] feat: raise the campaigns ramp_start/ramp_ceiling DB CHECK constraints to 5000 (migration 000113) so raised API bounds cannot 500 on the constraint, validate the effective ramp pair against stored values on partial updates, and fix the campaign/mailbox guides on the effective cap and the 600s gap throughput bound --- docs/content/docs/guides/campaigns.mdx | 2 +- docs/content/docs/guides/mailboxes.mdx | 2 +- .../000113_raise_ramp_caps.down.sql | 11 ++++++++ .../migrations/000113_raise_ramp_caps.up.sql | 7 +++++ internal/repository/pg_campaign.go | 27 +++++++++++++++++-- 5 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 internal/infrastructure/db/migrations/000113_raise_ramp_caps.down.sql create mode 100644 internal/infrastructure/db/migrations/000113_raise_ramp_caps.up.sql diff --git a/docs/content/docs/guides/campaigns.mdx b/docs/content/docs/guides/campaigns.mdx index acedd373..a8ba120d 100644 --- a/docs/content/docs/guides/campaigns.mdx +++ b/docs/content/docs/guides/campaigns.mdx @@ -47,7 +47,7 @@ Warmbly is mailbox-first: safe volume is the sum of each mailbox's budget, not o | Minimum gap | `600s` | Shortest spacing between two sends from one mailbox, always enforced | | Campaign daily limit | Per campaign | A per-mailbox cap for this campaign, validated `3` to `5000` | -The campaign daily limit applies as a minimum against the cold cap, so it can lower a mailbox's volume but never raise it above `50`/day. +The campaign daily limit applies as a minimum against the cold cap, so it can lower a mailbox's volume but never raise it above the mailbox's own daily cap (default `50`/day). Anything above `50`/day per cold mailbox needs positive reputation signals and a low complaint rate behind it. Adding mailboxes is safer than forcing a few to send more. diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index b4e98e83..a278c9bc 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -57,7 +57,7 @@ Set these on the mailbox's **Settings** tab. They apply to the next scheduled se The default of `50` is deliberately conservative. `30` to `50`/day is the normal safe band; a fresh mailbox should start at `10` to `20` and ramp. Raise the cap only for a mailbox with proven reputation and low complaint and bounce rates. -The range goes up to `5000` so a high-capacity mailbox (a warmed Google Workspace account allows `2000`/day, Microsoft 365 more) is not artificially blocked, and the dashboard shows a warning on anything above `100`. A high cap only raises the ceiling: the campaign's own daily limit, the ramp, sending behaviour, and your workspace's daily send limit all still apply, and the smallest one wins. +The range goes up to `5000` so a high-capacity mailbox (a warmed Google Workspace account allows `2000`/day, Microsoft 365 more) is not artificially blocked, and the dashboard shows a warning on anything above `100`. A high cap only raises the ceiling: the campaign's own daily limit, the ramp, sending behaviour, and your workspace's daily send limit all still apply, and the smallest one wins. The minimum gap is a throughput bound of its own: at the default `600s` a mailbox tops out around `144` sends in a `24` hour window, so a cap above that only takes effect together with a shorter gap. A new mailbox has no reputation. Sudden high volume from a cold inbox is one of the fastest ways to land in spam. Scaling cold outreach means adding mailboxes, not cranking one mailbox's cap. diff --git a/internal/infrastructure/db/migrations/000113_raise_ramp_caps.down.sql b/internal/infrastructure/db/migrations/000113_raise_ramp_caps.down.sql new file mode 100644 index 00000000..6af9dce2 --- /dev/null +++ b/internal/infrastructure/db/migrations/000113_raise_ramp_caps.down.sql @@ -0,0 +1,11 @@ +-- Clamp any rows above the old ceiling before restoring the tighter checks. +UPDATE campaigns +SET ramp_start = LEAST(ramp_start, 100), + ramp_ceiling = LEAST(ramp_ceiling, 100) +WHERE ramp_start > 100 OR ramp_ceiling > 100; +ALTER TABLE campaigns DROP CONSTRAINT campaigns_ramp_start_check; +ALTER TABLE campaigns + ADD CONSTRAINT campaigns_ramp_start_check CHECK (ramp_start >= 1 AND ramp_start <= 100); +ALTER TABLE campaigns DROP CONSTRAINT campaigns_ramp_ceiling_check; +ALTER TABLE campaigns + ADD CONSTRAINT campaigns_ramp_ceiling_check CHECK (ramp_ceiling >= 1 AND ramp_ceiling <= 100); diff --git a/internal/infrastructure/db/migrations/000113_raise_ramp_caps.up.sql b/internal/infrastructure/db/migrations/000113_raise_ramp_caps.up.sql new file mode 100644 index 00000000..88fbdda6 --- /dev/null +++ b/internal/infrastructure/db/migrations/000113_raise_ramp_caps.up.sql @@ -0,0 +1,7 @@ +-- Ramp start/ceiling follow the raised send-cap ceiling (config.LimitMax = 5000). +ALTER TABLE campaigns DROP CONSTRAINT campaigns_ramp_start_check; +ALTER TABLE campaigns + ADD CONSTRAINT campaigns_ramp_start_check CHECK (ramp_start >= 1 AND ramp_start <= 5000); +ALTER TABLE campaigns DROP CONSTRAINT campaigns_ramp_ceiling_check; +ALTER TABLE campaigns + ADD CONSTRAINT campaigns_ramp_ceiling_check CHECK (ramp_ceiling >= 1 AND ramp_ceiling <= 5000); diff --git a/internal/repository/pg_campaign.go b/internal/repository/pg_campaign.go index bb1015dc..ec8f72e7 100644 --- a/internal/repository/pg_campaign.go +++ b/internal/repository/pg_campaign.go @@ -1036,8 +1036,31 @@ func (r *campaignRepository) Update(ctx context.Context, userID, campaignID stri args = append(args, *data.RampCeiling) argPos++ } - if data.RampStart != nil && data.RampCeiling != nil && *data.RampStart > *data.RampCeiling { - return nil, errx.New(errx.BadRequest, "ramp start cannot exceed ramp ceiling") + // start <= ceiling must hold on the EFFECTIVE pair: a partial update is + // checked against the stored counterpart or it could persist an invalid pair. + if data.RampStart != nil || data.RampCeiling != nil { + start, ceiling := 0, 0 + if data.RampStart == nil || data.RampCeiling == nil { + err := r.DB.QueryRow(ctx, + "SELECT ramp_start, ramp_ceiling FROM campaigns WHERE user_id = $1 AND id = $2", + userID, campaignID).Scan(&start, &ceiling) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errx.ErrNotFound + } + db.CaptureError(err, "", nil, "queryrow") + return nil, errx.InternalError() + } + } + if data.RampStart != nil { + start = *data.RampStart + } + if data.RampCeiling != nil { + ceiling = *data.RampCeiling + } + if start > ceiling { + return nil, errx.New(errx.BadRequest, "ramp start cannot exceed ramp ceiling") + } } if data.ESPMatchMode != nil { if err := validate.CampaignESPMatchMode(*data.ESPMatchMode); err != nil { From 5abfb6988206793107e75009c49ae9532b46e9d3 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 31 Aug 2026 05:36:24 -0700 Subject: [PATCH 10/14] feat: address Greptile review on #282: re-measure useFlipPlacement/useFlipAlignment on window resize so an open popover keeps a valid side when the toolbar reflows, and trim the unibox body-key and hook comments down to their one-line invariants --- internal/app/unibox/email.go | 9 +-- internal/app/unibox/storage.go | 6 +- web/src/hooks/useFlipPlacement.tsx | 115 ++++++++++++++++------------- 3 files changed, 69 insertions(+), 61 deletions(-) diff --git a/internal/app/unibox/email.go b/internal/app/unibox/email.go index ff140e4d..9f25d4dc 100644 --- a/internal/app/unibox/email.go +++ b/internal/app/unibox/email.go @@ -19,10 +19,8 @@ func (s *uniboxService) GetByID( var snippet string var fixtureMessage bool - // ownerID is the mailbox owner's user_id and accountID the mailbox the - // message belongs to. The body's object-storage key is built from both, so - // the body must be fetched under the owner even when a different teammate - // opens the message via the org-scoped read. + // The body's object-storage key is built from the mailbox owner and + // account, not the caller, who may be any teammate on the org-scoped read. var ownerID, accountID uuid.UUID // Fetch email data by id index @@ -60,8 +58,7 @@ func (s *uniboxService) GetByID( fixtureMessage = isFixtureMessage(msg.MessageID) } - // Fetch body from object storage. Keyed by the mailbox OWNER's user_id and - // the mailbox account id, not the caller's identity. + // Fetch body from object storage under the mailbox owner and account. { out, err := s.GetBody(ctx, ownerID, accountID, id) if err != nil { diff --git a/internal/app/unibox/storage.go b/internal/app/unibox/storage.go index 77401569..8dd88df3 100644 --- a/internal/app/unibox/storage.go +++ b/internal/app/unibox/storage.go @@ -9,10 +9,8 @@ import ( ) // GetBody reads a message's full body blob. The key must match what the -// worker's StoreBody writes (users//emails//.emsg), -// which is why the mailbox account id travels here alongside the owner: -// reading under any other key finds nothing and the message degrades to its -// one-line snippet. +// worker's StoreBody writes; any other key finds nothing and the message +// degrades to its one-line snippet. func (s *uniboxService) GetBody( ctx context.Context, userID, emailID, id uuid.UUID, diff --git a/web/src/hooks/useFlipPlacement.tsx b/web/src/hooks/useFlipPlacement.tsx index b2ca43fa..4220a5ad 100644 --- a/web/src/hooks/useFlipPlacement.tsx +++ b/web/src/hooks/useFlipPlacement.tsx @@ -18,42 +18,47 @@ export default function useFlipPlacement( React.useLayoutEffect(() => { if (!open) return; - const trigger = triggerRef.current; - if (!trigger) return; - const rect = trigger.getBoundingClientRect(); - let clipBottom = window.innerHeight; - let clipTop = 0; - let el: HTMLElement | null = trigger.parentElement; - while (el) { - const s = getComputedStyle(el); - const overflow = `${s.overflow} ${s.overflowY} ${s.overflowX}`; - if (/(auto|scroll|hidden)/.test(overflow)) { - const ar = el.getBoundingClientRect(); - if (ar.bottom < clipBottom) clipBottom = ar.bottom; - if (ar.top > clipTop) clipTop = ar.top; + const measure = () => { + const trigger = triggerRef.current; + if (!trigger) return; + + const rect = trigger.getBoundingClientRect(); + let clipBottom = window.innerHeight; + let clipTop = 0; + let el: HTMLElement | null = trigger.parentElement; + while (el) { + const s = getComputedStyle(el); + const overflow = `${s.overflow} ${s.overflowY} ${s.overflowX}`; + if (/(auto|scroll|hidden)/.test(overflow)) { + const ar = el.getBoundingClientRect(); + if (ar.bottom < clipBottom) clipBottom = ar.bottom; + if (ar.top > clipTop) clipTop = ar.top; + } + el = el.parentElement; } - el = el.parentElement; - } - const spaceBelow = clipBottom - rect.bottom; - const spaceAbove = rect.top - clipTop; - if (spaceBelow < estimatedHeight && spaceAbove > spaceBelow) { - setPlacement("top"); - } else { - setPlacement("bottom"); - } + const spaceBelow = clipBottom - rect.bottom; + const spaceAbove = rect.top - clipTop; + if (spaceBelow < estimatedHeight && spaceAbove > spaceBelow) { + setPlacement("top"); + } else { + setPlacement("bottom"); + } + }; + + measure(); + // A resize reflows the trigger while the popup stays open. + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); }, [open, triggerRef, estimatedHeight]); return placement; } /** - * Horizontal counterpart: decides whether a popup should hang from the - * trigger's left or right edge, based on room inside the nearest - * clipping ancestor. Use when the trigger can land anywhere along a - * wrapping toolbar row, where a fixed alignment pushes the popup out of - * the container on one side or the other. + * Horizontal counterpart: picks the trigger edge (left or right) the + * popup hangs from, based on room inside the nearest clipping ancestor. */ export function useFlipAlignment( triggerRef: React.RefObject, @@ -64,33 +69,41 @@ export function useFlipAlignment( React.useLayoutEffect(() => { if (!open) return; - const trigger = triggerRef.current; - if (!trigger) return; - const rect = trigger.getBoundingClientRect(); - let clipRight = window.innerWidth; - let clipLeft = 0; - let el: HTMLElement | null = trigger.parentElement; - while (el) { - const s = getComputedStyle(el); - const overflow = `${s.overflow} ${s.overflowY} ${s.overflowX}`; - if (/(auto|scroll|hidden)/.test(overflow)) { - const ar = el.getBoundingClientRect(); - if (ar.right < clipRight) clipRight = ar.right; - if (ar.left > clipLeft) clipLeft = ar.left; + const measure = () => { + const trigger = triggerRef.current; + if (!trigger) return; + + const rect = trigger.getBoundingClientRect(); + let clipRight = window.innerWidth; + let clipLeft = 0; + let el: HTMLElement | null = trigger.parentElement; + while (el) { + const s = getComputedStyle(el); + const overflow = `${s.overflow} ${s.overflowY} ${s.overflowX}`; + if (/(auto|scroll|hidden)/.test(overflow)) { + const ar = el.getBoundingClientRect(); + if (ar.right < clipRight) clipRight = ar.right; + if (ar.left > clipLeft) clipLeft = ar.left; + } + el = el.parentElement; } - el = el.parentElement; - } - // left-aligned grows rightward from the trigger's left edge; - // right-aligned grows leftward from its right edge. - const spaceRight = clipRight - rect.left; - const spaceLeft = rect.right - clipLeft; - if (spaceRight < estimatedWidth && spaceLeft > spaceRight) { - setAlignment("right"); - } else { - setAlignment("left"); - } + // left-aligned grows rightward from the trigger's left edge; + // right-aligned grows leftward from its right edge. + const spaceRight = clipRight - rect.left; + const spaceLeft = rect.right - clipLeft; + if (spaceRight < estimatedWidth && spaceLeft > spaceRight) { + setAlignment("right"); + } else { + setAlignment("left"); + } + }; + + measure(); + // A resize reflows the wrapping toolbar while the popup stays open. + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); }, [open, triggerRef, estimatedWidth]); return alignment; From 9d9c126b76d379c02b5d0952680641433cb6bdaa Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 31 Aug 2026 05:39:53 -0700 Subject: [PATCH 11/14] feat: hyphenate the 24-hour window compound in the mailboxes guide gap-throughput note --- docs/content/docs/guides/mailboxes.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index a278c9bc..7f070030 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -57,7 +57,7 @@ Set these on the mailbox's **Settings** tab. They apply to the next scheduled se The default of `50` is deliberately conservative. `30` to `50`/day is the normal safe band; a fresh mailbox should start at `10` to `20` and ramp. Raise the cap only for a mailbox with proven reputation and low complaint and bounce rates. -The range goes up to `5000` so a high-capacity mailbox (a warmed Google Workspace account allows `2000`/day, Microsoft 365 more) is not artificially blocked, and the dashboard shows a warning on anything above `100`. A high cap only raises the ceiling: the campaign's own daily limit, the ramp, sending behaviour, and your workspace's daily send limit all still apply, and the smallest one wins. The minimum gap is a throughput bound of its own: at the default `600s` a mailbox tops out around `144` sends in a `24` hour window, so a cap above that only takes effect together with a shorter gap. +The range goes up to `5000` so a high-capacity mailbox (a warmed Google Workspace account allows `2000`/day, Microsoft 365 more) is not artificially blocked, and the dashboard shows a warning on anything above `100`. A high cap only raises the ceiling: the campaign's own daily limit, the ramp, sending behaviour, and your workspace's daily send limit all still apply, and the smallest one wins. The minimum gap is a throughput bound of its own: at the default `600s` a mailbox tops out around `144` sends in a `24`-hour window, so a cap above that only takes effect together with a shorter gap. A new mailbox has no reputation. Sudden high volume from a cold inbox is one of the fastest ways to land in spam. Scaling cold outreach means adding mailboxes, not cranking one mailbox's cap. From 70ca90a3e0878cf6c8f825186f3354a69a15bb7d Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 31 Aug 2026 05:53:12 -0700 Subject: [PATCH 12/14] feat: fix the reconnect reactivation 500 found in live testing: reconnectAccount now loads the row via GetByID itself because the org-scoped Get selects no user_id and the owner-scoped Update was handed an empty uuid; UpdateSMTPIMAPCredentials switches to GetByID with an explicit tenancy check, and the test stub's Get now mimics the real partial row --- internal/app/email/reauth.go | 21 ++++++++++++++++----- internal/app/email/reauth_test.go | 7 ++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/internal/app/email/reauth.go b/internal/app/email/reauth.go index b580dea8..57c3b854 100644 --- a/internal/app/email/reauth.go +++ b/internal/app/email/reauth.go @@ -111,7 +111,7 @@ func (s *emailService) finishReauth(ctx context.Context, sess *models.EmailOnboa return nil, errx.InternalError() } - return s.reconnectAccount(ctx, account) + return s.reconnectAccount(ctx, account.ID) } // UpdateSMTPIMAPCredentials is the SMTP/IMAP counterpart of the OAuth reauth: @@ -122,11 +122,13 @@ func (s *emailService) UpdateSMTPIMAPCredentials(ctx context.Context, orgID *uui return nil, errx.ErrNoOrganization } - account, xerr := s.emailRepository.Get(ctx, orgID.String(), accountID.String()) + // GetByID, not the org-scoped Get: the reconnect tail needs the owner's + // user id, which Get does not select. Tenancy is enforced right below. + account, xerr := s.emailRepository.GetByID(ctx, accountID) if xerr != nil { return nil, xerr } - if account == nil { + if account == nil || account.OrganizationID == nil || *account.OrganizationID != *orgID { return nil, errx.ErrNotFound } if models.InboxProvider(account.Provider) != models.InboxProviderSMTPIMAP { @@ -157,13 +159,22 @@ func (s *emailService) UpdateSMTPIMAPCredentials(ctx context.Context, orgID *uui return nil, errx.InternalError() } - return s.reconnectAccount(ctx, account) + return s.reconnectAccount(ctx, accountID) } // reconnectAccount is the shared tail of both reconnect flows: resolve the // credential errors the new secret just fixed, then reactivate — Update carries // the status through pool membership, the worker, and the realtime fanout. -func (s *emailService) reconnectAccount(ctx context.Context, account *models.Email) (*models.Email, *errx.Error) { +// It loads the row itself because the owner-scoped Update needs user_id, which +// not every caller's read path selects. +func (s *emailService) reconnectAccount(ctx context.Context, accountID uuid.UUID) (*models.Email, *errx.Error) { + account, xerr := s.emailRepository.GetByID(ctx, accountID) + if xerr != nil { + return nil, xerr + } + if account == nil { + return nil, errx.ErrNotFound + } s.resolveCredentialErrors(ctx, account.ID) status := "active" return s.Update(ctx, account.UserID, account.ID.String(), &models.UpdateEmail{Status: &status}) diff --git a/internal/app/email/reauth_test.go b/internal/app/email/reauth_test.go index d01a8320..5993e158 100644 --- a/internal/app/email/reauth_test.go +++ b/internal/app/email/reauth_test.go @@ -29,7 +29,12 @@ func (s *stubReauthRepo) GetByID(ctx context.Context, emailAccountID uuid.UUID) } func (s *stubReauthRepo) Get(ctx context.Context, orgID, emailAccountID string) (*models.Email, *errx.Error) { - return s.account, nil + // The real org-scoped Get does not select user_id or organization_id; + // mimic that so a caller depending on them fails here too (it did once). + partial := *s.account + partial.UserID = "" + partial.OrganizationID = nil + return &partial, nil } func (s *stubReauthRepo) GetOAuthCredentials(ctx context.Context, emailAccountID uuid.UUID) (*repository.OAuthCredentials, *errx.Error) { From acec534b6ba31051fb390cb20ebc288d01a84a77 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 31 Aug 2026 06:16:15 -0700 Subject: [PATCH 13/14] feat: give the mailbox drawer's ramp-hold, lifecycle and cold-ramp notice cards top padding (px-5 py-4 like every other Overview section) so they no longer touch the section divider above them --- web/src/components/app/emails/InboxDetails.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index aad158de..df7e0da0 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -95,7 +95,7 @@ function RampHoldNotice({ hold }: { hold: import("@/lib/api/models/app/analytics const hours = Math.max(0, Math.round((new Date(hold.resumes_at).getTime() - Date.now()) / 3_600_000)); const resumesIn = hours > 0 ? ` for about ${hours} more ${hours === 1 ? "hour" : "hours"}` : ""; return ( -
+
@@ -152,7 +152,7 @@ function LifecycleNotice({ onError: (e) => toast.error(buildError(e as unknown as AppError)), }); return ( -
+
@@ -213,7 +213,7 @@ function SendHoldControl({ mailboxId, state }: { mailboxId: string; state?: impo // A cold cap below the configured one reads as a bug unless it says why. function ColdRampNotice({ ramp }: { ramp: import("@/lib/api/models/app/analytics/AccountStatus").ColdRampInfo }) { return ( -
+
From c2f5cc4e9e3921fff80f52698047d4276537919a Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 31 Aug 2026 06:26:39 -0700 Subject: [PATCH 14/14] feat: address review on the reconnect flow: a reauth whose repeat consent omits the refresh token now refuses (typed error) when the stored one cannot be read instead of sealing an empty string over it, and credential errors resolve only after the reactivation Update succeeds so a failed reactivation keeps the banner and its reconnect button --- internal/app/email/reauth.go | 33 +++++++++++++++++++++---------- internal/app/email/reauth_test.go | 33 +++++++++++++++++++++++++++++++ internal/errx/common.go | 1 + 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/internal/app/email/reauth.go b/internal/app/email/reauth.go index 57c3b854..23660230 100644 --- a/internal/app/email/reauth.go +++ b/internal/app/email/reauth.go @@ -99,12 +99,19 @@ func (s *emailService) finishReauth(ctx context.Context, sess *models.EmailOnboa } // A repeat consent may omit the refresh token; keep the stored one rather - // than blanking the row. + // than blanking the row. Writing without one would seal an empty string + // over the stored token and end all future access-token refreshes, so a + // failed fallback read refuses the reauth instead. refresh := tok.RefreshToken if refresh == "" { - if creds, cerr := s.emailRepository.GetOAuthCredentials(ctx, account.ID); cerr == nil && creds != nil { - refresh = creds.RefreshToken + creds, cerr := s.emailRepository.GetOAuthCredentials(ctx, account.ID) + if cerr != nil { + return nil, cerr } + if creds == nil || creds.RefreshToken == "" { + return nil, errx.ErrEmailReauthNoRefreshToken + } + refresh = creds.RefreshToken } if err := s.emailRepository.RefreshBoxToken(ctx, account.ID, tok.AccessToken, refresh, tok.Expiry); err != nil { @@ -162,11 +169,13 @@ func (s *emailService) UpdateSMTPIMAPCredentials(ctx context.Context, orgID *uui return s.reconnectAccount(ctx, accountID) } -// reconnectAccount is the shared tail of both reconnect flows: resolve the -// credential errors the new secret just fixed, then reactivate — Update carries -// the status through pool membership, the worker, and the realtime fanout. -// It loads the row itself because the owner-scoped Update needs user_id, which -// not every caller's read path selects. +// reconnectAccount is the shared tail of both reconnect flows: reactivate, +// then resolve the credential errors the new secret just fixed — Update +// carries the status through pool membership, the worker, and the realtime +// fanout. Errors resolve only after a successful reactivation, or a failed +// Update would clear the banner (and its reconnect button) while the mailbox +// stays broken. It loads the row itself because the owner-scoped Update needs +// user_id, which not every caller's read path selects. func (s *emailService) reconnectAccount(ctx context.Context, accountID uuid.UUID) (*models.Email, *errx.Error) { account, xerr := s.emailRepository.GetByID(ctx, accountID) if xerr != nil { @@ -175,9 +184,13 @@ func (s *emailService) reconnectAccount(ctx context.Context, accountID uuid.UUID if account == nil { return nil, errx.ErrNotFound } - s.resolveCredentialErrors(ctx, account.ID) status := "active" - return s.Update(ctx, account.UserID, account.ID.String(), &models.UpdateEmail{Status: &status}) + updated, xerr := s.Update(ctx, account.UserID, account.ID.String(), &models.UpdateEmail{Status: &status}) + if xerr != nil { + return nil, xerr + } + s.resolveCredentialErrors(ctx, account.ID) + return updated, nil } // resolveCredentialErrors clears the credential-class error rows; unrelated diff --git a/internal/app/email/reauth_test.go b/internal/app/email/reauth_test.go index 5993e158..242c6de0 100644 --- a/internal/app/email/reauth_test.go +++ b/internal/app/email/reauth_test.go @@ -18,6 +18,7 @@ type stubReauthRepo struct { account *models.Email storedRefresh string + updateErr *errx.Error wroteAccess string wroteRefresh string @@ -48,6 +49,9 @@ func (s *stubReauthRepo) RefreshBoxToken(ctx context.Context, id uuid.UUID, acce } func (s *stubReauthRepo) Update(ctx context.Context, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error) { + if s.updateErr != nil { + return nil, s.updateErr + } s.updated = udata return s.account, nil } @@ -140,6 +144,35 @@ func TestFinishReauth_KeepsStoredRefreshTokenWhenProviderOmitsIt(t *testing.T) { } } +func TestFinishReauth_RefusesWhenNoRefreshTokenAnywhere(t *testing.T) { + svc, repo, _, sess := reauthFixture("gmail", "owner@example.com") + repo.storedRefresh = "" + + tok := &oauth2.Token{AccessToken: "new-access"} // provider omitted it, nothing stored + _, xerr := svc.finishReauth(context.Background(), sess, models.InboxProviderGoogle, tok, &inboxOwner{Email: "owner@example.com"}) + if xerr != errx.ErrEmailReauthNoRefreshToken { + t.Fatalf("expected ErrEmailReauthNoRefreshToken, got %v", xerr) + } + if repo.wroteAccess != "" { + t.Fatalf("must not seal an empty refresh token over the stored row") + } +} + +func TestFinishReauth_KeepsErrorsWhenReactivationFails(t *testing.T) { + svc, repo, errs, sess := reauthFixture("gmail", "owner@example.com") + repo.updateErr = errx.InternalError() + + tok := &oauth2.Token{AccessToken: "new-access", RefreshToken: "new-refresh"} + _, xerr := svc.finishReauth(context.Background(), sess, models.InboxProviderGoogle, tok, &inboxOwner{Email: "owner@example.com"}) + if xerr == nil { + t.Fatal("expected the failed reactivation to surface") + } + // The banner (and its reconnect button) must survive a failed reactivation. + if len(errs.resolved) != 0 { + t.Fatalf("errors must stay unresolved when reactivation fails, resolved %v", errs.resolved) + } +} + func TestOAuthReauth_RefusesSMTPIMAPMailboxes(t *testing.T) { svc, repo, _, _ := reauthFixture("smtp_imap", "owner@example.com") diff --git a/internal/errx/common.go b/internal/errx/common.go index 4234626e..f2296ddd 100644 --- a/internal/errx/common.go +++ b/internal/errx/common.go @@ -125,6 +125,7 @@ var ( ErrEmailReauthOAuthOnly = New(BadRequest, "This mailbox signs in with OAuth. Re-authorize it instead of entering credentials.") ErrEmailReauthWrongAccount = New(Conflict, "The account you signed in with is not this mailbox's address. Sign in with the mailbox's own account and try again.") ErrEmailReauthCloudManaged = New(Conflict, "Warmbly Cloud holds this mailbox's sign-in. Reconnect it from your cloud workspace instead.") + ErrEmailReauthNoRefreshToken = New(BadRequest, "The provider did not return a refresh token and none is stored. Please try re-authorizing again.") ErrEmailSMTPHost = New(BadRequest, "SMTP host is required.") ErrEmailSMTPPort = New(BadRequest, "SMTP port must be 465 or 587.") ErrEmailIMAPHost = New(BadRequest, "IMAP host is required.")