Commit Graph
133 Commits
Author SHA1 Message Date
Matthew Meszaros db2a9ba439 feat: stamp the rotating campaign send's real sending mailbox onto tasks.email_account_id before dispatch so budgets, round-robin position, deliverability rates and the contact feed name the mailbox that actually sent, and stop the contact email list and campaign-state steps reporting automated prefetches as recipient opens (issue #392) 2026-09-09 03:55:28 -07:00
Matthew Meszaros a5802b53cf feat: address the review on the unsubscribe-link change by closing the tag scanner over quoted attributes in both the send path and the editor, so a > inside an attribute can no longer split a tag and rewrite the href that follows it into a dead link, and by scanning only the copy a plain-text step actually ships, resolving the campaign's inherited opt-out mode before warning, checking the subject too, and reporting an unreadable step list as a failed check rather than an empty scan 2026-09-07 06:34:45 -07:00
Matthew Meszaros b25897fe67 feat: render a hand-placed {{.UnsubscribeLink}} as a real anchor labelled with the workspace's unsubscribe link text instead of shipping the bare signed API URL in the body, let the composer turn a text selection into that link so the wording stays the author's, and warn in the step editor and at preflight when a plain-text-only campaign carries a body opt-out link that has nowhere to hide its address 2026-09-07 06:34:45 -07:00
Matthew Meszaros 51dedc90ee feat: put every runtime behind one optional error-reporting story: a single internal/observability/errs wrapper that is now the only package importing sentry-go, InitSentry for cmd/forms, release and environment tags on every service from the existing build stamp, optional Sentry in the admin panel and the public forms app, the sentry crate in the Rust tracking service, release tagging in realtime, CI source-map upload that only runs when a Sentry token is configured, and docs covering the DSN for each service 2026-09-07 03:51:06 -07:00
Matthew Meszaros 7b11489c85 feat: keep a campaign running for new leads (issue #336): add a continuous campaign setting (migration 000130, on by default once a segment is linked) under which a campaign that runs out of leads stays active and idle with idle_since set instead of finishing, is startable with no leads, is re-checked by the reconciler every pass and shows as waiting for leads in the dashboard list, header and live activity panel with a CAMPAIGN_IDLE realtime event; make WakeCampaigns restart a finished campaign through the full launch checks so a lead added by the contacts API, a bulk update, an import or the add-to-campaign automation action reopens it exactly like a linked segment does, and write a refused restart to the campaign activity log once an hour per reason instead of only the backend log; expose continuous on the campaign API and preferences page and document it in the campaigns, segments, forms, automations, API reference and realtime docs 2026-09-06 02:50:16 -07:00
Matthew Meszaros 85f3dad215 feat: send the files a step actually carries by honouring campaign_attachments.sequence_id in the send, test-send and preview paths instead of attaching every campaign file to every step, count them per step in the preflight content score, and refuse an upload naming another campaign's step 2026-09-04 06:16:16 -07:00
Matthew Meszaros 483d6c2fe7 Merge remote-tracking branch 'origin/main' into fix/issue-307-test-send-preview-from-name 2026-09-04 03:34:35 -07:00
Matthew Meszaros 915390f42a feat: match the closing body tag case-insensitively when placing the mailbox signature so a pasted document written with </BODY> still gets it inside the body, drop a step preview response whose request has been superseded so an out-of-order reply cannot repaint stale copy, and keep pulling mailbox pages in the preview sender picker while an enabled campaign sender is still unaccounted for 2026-09-04 03:11:53 -07:00
Matthew Meszaros de33b03891 feat: give the HTML mailbox signature its own block with a top margin instead of a hard double line break that stacked against the body's trailing margin and rendered as two to three blank lines in Apple Mail and Outlook, insert it inside the document when the body carries a closing body tag as the tracking pixel and opt-out footer already do, and say so in the mailboxes guide 2026-09-04 03:01:19 -07:00
Matthew Meszaros 8ce331a7ac feat: fix the campaign test-email endpoint answering 404 for every caller by scoping its campaign lookup to the organization instead of the user, let it send from any mailbox of the organization and render a real contact through a new contact_id, attach the campaign's files, and extend the template preview with contact_id, campaign_id and account_id so it applies the signature, opt-out footer and plain-text rule the send path does 2026-09-04 02:58:36 -07:00
Matthew Meszaros f844c14804 feat: carry the mailbox display name in the emsg body blob of every send so a renamed sender reaches the worker on its next message instead of waiting for an inactive-active toggle, read it into the From header of the Gmail, Graph and SMTP clients with the name cached at load time as the fallback, and say so in the mailboxes guide 2026-09-04 02:58:36 -07:00
Matthew Meszaros 1c88c2196b feat: add live tests proving wake-ups leave the daily budget and min-gap untouched, real sends still count, and a capped campaign parks a wake-up with a single daily log line instead of pausing 2026-09-03 20:18:43 -07:00
Matthew Meszaros 6bc6bf4345 feat: reword the no-eligible-mailbox pause on the start endpoint, the task auto-pause, and its test to name the only cause left (a sending behaviour profile with no working days) now that budget and window gates defer 2026-09-03 20:18:43 -07:00
Matthew Meszaros 46f61f6ad8 feat: leave a link whose URL has no host untouched by click tracking, since a ticket for it could never redirect 2026-09-03 04:00:33 -07:00
Matthew Meszaros 6830e3277d feat: keep the tracking-domain exclusion when link tracking falls back to UTM-only tagging, leave links the URL parser rejects untouched instead of minting a dead click ticket, and say in the campaigns guide that the sequence versus one-time choice is fixed at creation 2026-09-03 03:53:39 -07:00
Matthew Meszaros 579b0ac04c feat: merge main into the unsubscribe branch again, carrying the UTM campaign columns alongside unsubscribe_mode in every campaign scanner, keeping unsubscribe links out of the moved link tracker and its UTM tagging, and renumbering the opt-out migration to 000124 because main released 000123 for link clicks 2026-09-03 03:43:47 -07:00
Matthew Meszaros 5fcb0fe882 feat: fold existing suppression rows to lowercase in migration 000123 and compare stored values as equalities so the existing unique key is the single case-insensitive identity and no extra index build is needed, and keep the plain-text alternative when a chosen A/B variant carries HTML only 2026-09-03 03:22:37 -07:00
Matthew Meszaros 78c4021053 Merge remote-tracking branch 'origin/main' into feat/link-click-attribution
# Conflicts:
#	docs/content/docs/api/reference/campaigns.mdx
#	docs/content/docs/guides/campaigns.mdx
#	internal/repository/pg_campaign.go
#	internal/repository/pg_campaign_lifecycle.go
#	web/src/components/app/campaigns/NewCampaignDialog.tsx
2026-09-03 03:14:19 -07:00
Matthew Meszaros 64f2d1637b feat: address the review on the unsubscribe PR: register the suppression family in warmblyctl, require the confirm field on the browser unsubscribe POST and cap its body, render a chosen A/B variant through the template engine so its merge fields and unsubscribe link resolve, fold curly apostrophes before opt-out phrase matching, write pasted suppression lists in one transaction, clamp copy by runes instead of bytes, add the constraints NOT VALID plus a lower(email) index in migration 000123, scope the unsubscribe link type-ahead to email bodies, and document DELETE /suppressions/:id 2026-09-03 02:20:47 -07:00
Matthew Meszaros 9485503e03 feat: merge main into the unsubscribe branch, keeping the new campaigns.kind column alongside unsubscribe_mode in every campaign scanner and renumbering the opt-out migration to 000123 because main took 000122 for campaign kind 2026-09-03 01:56:38 -07:00
Matthew Meszaros 5eb92c601c feat: give every campaign email a working opt-out: a reply-to-opt-out line by default or an unsubscribe link (workspace setting under Settings > Sending with a per-campaign override and a {{.UnsubscribeLink}} variable), signed per-recipient unsubscribe links served on the API origin so the List-Unsubscribe header no longer points at a dead warmbly.com page, a confirm page on GET with RFC 8058 one-click on POST and a resubscribe button, reply opt-out detection through the whole-word compliance lexicon with quoted history stripped, a first-class suppression list (Contacts tab, GET/POST/DELETE /suppressions with address and domain entries, audited removal, contact drawer action), the contact Subscribed flag enforced in campaign routing, migration 000122 with a shared recipient_suppressed() predicate, and docs for all of it 2026-09-03 01:52:40 -07:00
Matthew Meszaros 6b1ddd8bb8 feat: address the CodeRabbit review on the one-time email preset: rate-limit POST /campaigns-estimate as a read, count a mailbox whose sent-today counter fails as having nothing left today instead of untouched, lock the campaign row FOR UPDATE before counting email steps so concurrent inserts cannot give a one-time campaign two messages, keep a plain-text test email free of the HTML signature, and make the wizard's estimate panel say an audience beyond the two-year horizon cannot be projected instead of reading the null as one sending day 2026-09-03 01:42:00 -07:00
Matthew Meszaros 24b7e26fbb feat: add the one-time email campaign preset from #288: campaigns.kind column with sequence and one_time values and a kind field on POST /campaigns, GET /campaigns?kind= and an one_time count on /campaigns-overview, a POST /campaigns-estimate endpoint projecting deduplicated segment recipients against the mailbox pool's per-day capacity and finish date, a wizard type picker whose one-time flow runs Basics, Email, Audience, Sending and Send with send-now or scheduled start and the estimate panel and then creates, links segments and starts the campaign, a guard refusing a second email step on a one-time campaign, draft/scheduled/sending/sent wording with a One-time badge and Type filter in the campaigns list, kind on the AI create_campaign_draft tool, honouring text_only in the send and test-email paths so plain-text campaigns ship no HTML part or tracking, and the campaigns, segments, API reference and endpoint docs 2026-09-03 01:42:00 -07:00
Matthew Meszaros b785d3b86b feat: address review on per-link click attribution: read bare href values and ignore data-href when tracking anchors, compare the destination host instead of substring-matching the tracking domain, tag bare URLs in plain-text bodies when UTM tagging is on, count UTM limits in characters, expose the UTM overrides in the AI campaign tool, dedupe clicks by ticket so two links sharing a destination are two clicks, count machine_clicks only for steps with machine and no human clicks, never withdraw a click stamp that predates per-link logging, keep the coarse timeline click unless a logged click stands for it, resolve the link once per event, give auto-clicks their own tooltip and make the wizard's discard guard notice toggled settings 2026-09-03 01:20:48 -07:00
Matthew Meszaros 99be92f159 feat: attribute every campaign click to the exact link with a per-link click log (email_link_clicks) behind the contact activity timeline, campaign live feed, recent activity and the email_clicked webhook, add per-campaign automatic UTM tagging (utm_tracking with source, medium and campaign overrides, utm_content from the link text) applied at send time to the stored ticket destination, and classify opens and clicks as machine when they arrive within ten seconds of dispatch or when several links of one email are followed within five seconds, so scanner clicks are logged but never count as engagement, fire automations or send webhooks 2026-09-03 01:05:34 -07:00
Matthew Meszaros 05d74dbe9f feat: forms v2, a full redesign of the hosted form and its builder: eight one-click themes, card/wide/split layouts with a brand cover panel, classic paged and Typeform-style focus modes split on a new page_break block, logo/cover/background uploads with size, fit and veil controls, an optional header bar that can span the page or sit with the form, a real HSV colour picker and font preview, per-contact personalized links that prefill and attribute without email verification, a render-token gate so the form JSON cannot be scraped without loading the page, funnel analytics with per-page drop-off and identified visitors, a leads-style forms list and responses table, and an organization custom forms domain verified by CNAME with hourly re-checks 2026-09-01 09:27:28 -07:00
Matthew Meszaros 7a7c6051bc Merge remote-tracking branch 'origin/main' into feature/contact-segments 2026-08-30 02:07:56 -07:00
Matthew Meszaros daf946fc45 feat: add the add_to_segment and remove_from_segment sequence action steps, executed on both the scheduled campaign path and the instant reply path, wired into the backend and consumer 2026-08-29 23:45:05 -07:00
Matthew Meszaros a02ff7c936 feat: address verification overhaul for #264: MillionVerifier as a pay-as-you-go integration plugin with org-sealed key and automatic fallback to the built-in check, built-in prober gains domain cache, Microsoft/Yahoo fingerprinting, MX fallback, disposable/role sub-statuses and a self-check breaker, imports and POST /contacts accept verification results in any known provider vocabulary with auto-detected columns, verdict provenance and expiry columns (migration 000110), campaigns park at paused_undeliverable with re-verify/send-anyway instead of finishing, POST/GET /contacts/verification bulk actions and overview, launch gate override via acknowledge_list_risk, animated verification marks, banner and settings card in the dashboard, and docs 2026-08-29 23:11:22 -07:00
Matthew Meszaros 3bdb0fb82d feat: address the Greptile review on the pool link: require https for the cloud URL (loopback exempt for local development) since the instance token and mailbox passwords travel on it, remove the cloud copy when the local enrollment row cannot be written so a mailbox never warms in both places, delete the local enrollment row before the cloud one and restore it if the cloud call fails so a mailbox is never left with no warmup anywhere, and trim the new multi-line comments to the one-line style 2026-08-29 07:17:10 -07:00
Matthew Meszaros b75fdcf86c Merge remote-tracking branch 'origin/main' into feature/self-hosted-warmup-pool-access
# Conflicts:
#	internal/scheduler/warmup_scheduler.go
2026-08-29 07:10:38 -07:00
Matthew Meszaros 37b60b59d3 feat: let a self-hosted instance warm its mailboxes in the hosted pool: device-code link approved at /connect, instance-token API that enrolls SMTP/IMAP mailboxes as warmup-only accounts (no history import, non-warmup mail dropped), free for 10 mailboxes and unlimited on the seeded $15 pool plan, tier fallback to proven healthy mailboxes when a pool runs thin, local warmup stands down for enrolled mailboxes, Settings > Warmbly Cloud step flow and linked-instances page, docs guide, marketing copy, and fix SetWarmupLifecycle re-reading the row with an org-scoped lookup so every warmup start/pause returned 404 2026-08-29 07:09:04 -07:00
Matthew Meszaros 771bbe04e4 feat: correct the comment on the partner-routing fixture's service wiring, since the org-risk read now runs ahead of the stored warmup tier and it is that read failing open, not the tier short-circuiting, that lets the selector run without an org-risk repository 2026-08-28 22:42:59 -07:00
Matthew Meszaros 6c172daaef Merge remote-tracking branch 'origin/main' into fix/issue-143-investigation 2026-08-28 22:42:25 -07:00
Matthew Meszaros bd126e7c89 Merge remote-tracking branch 'origin/main' into fix/issue-144-resolution 2026-08-28 22:03:23 -07:00
Matthew Meszaros ec7b7bbed3 feat: count anchors and bare URLs together instead of taking the larger of the two, so three labeled links plus three written-out URLs no longer score a clean 100, clear the editor's pending flag when the draft is emptied mid-request since the cancelled request's finally can no longer do it, fail the preflight content check when a campaign's attachments cannot be read rather than scoring as if there were none and passing copy the send path then warns about, and condense the new comments to the one-line form the repo convention asks for 2026-08-28 21:59:53 -07:00
Matthew Meszaros 4ddcb24bcd feat: make the campaign content check actually fire, fixing a link cap that counted zero links on every HTML email because the URLs live in href attributes that tag-stripping discards, a preflight that scored wait and action nodes as copy and so failed every campaign using one at 55/100 because GetSequencesByCampaignID never selected the kind column, a content_warning log level of "warning" that never matched the dashboard's "warn" amber tier, an unvalidated min_content_score an API caller could set to 5000, attachments the send path scored but preflight ignored, and a docs claim of live editor scoring that was really a manual button 2026-08-28 21:54:29 -07:00
Matthew Meszaros 230d80db64 feat: condense the new warmup pool comments in email_task.go, handler.go, service.go, pg_worker.go and warmup_scheduler.go to the one-line form the repo convention asks for, keeping the non-obvious constraints (the stored tier is never empty, the tier column records what is paid for rather than where the mailbox warms) and dropping the narration 2026-08-28 21:31:06 -07:00
Matthew Meszaros f518c950d7 feat: stop an unattributable warmup spam placement demoting every custom-domain partner, key the admin pool rollup to who runs the recipient's mail rather than the connect method, and cover issue #143's routing feedback end to end with a live selectWarmupPartner test that proves the per-provider signal reaches the partner the selector returns 2026-08-28 21:30:52 -07:00
Matthew Meszaros c28ef3e82a feat: keep a restricted or suspended workspace out of the premium warmup pool by checking the organization's risk posture before the stored warmup_pool_type in resolveWarmupPoolType (tasks and email services), which is never empty so the restricted branch below it was dead code, make the scheduler's recipient-capacity count risk-aware the same way, wire the org risk repository into the email service, and refuse the premium membership move in UpdateEmailAccountWarmupPoolType while the owning organization is restricted so a worker rebalance cannot readmit a risky tenant 2026-08-28 21:26:57 -07:00
Matthew Meszaros b019174910 feat: give an organization one fused abuse posture (#233)
* feat: give an organization one fused abuse posture, because every existing control watches a single subject and an actor slightly wrong on several axes sits under all of them: organizations gains risk_state, risk_score, risk_reason and an append-only risk_signals evidence blob, modelled on the warmup participant health machine that already works rather than a second vocabulary for the same idea; restricted cuts per-mailbox cold volume to a quarter and forces the free warmup pool so a risky tenant cannot spend the paid pool's shared reputation, suspended stops sending at the send gate, and watch deliberately changes nothing a customer can feel so evidence accumulates before anything is taken away; an operator's suspension outranks the derived band so a detector clearing cannot release a workspace a human suspended, transitions ride the audit spine to every teammate's dashboard, a banner says which limit is active and why rather than letting volume drop silently, and the posture never travels in a workspace archive since it is one platform's verdict reached from evidence the destination never saw

* feat: make the suspension actually stop sending, and emit the audit transitions the PR claimed: emailsend.SendEmail is only the manual and API path, so campaign and warmup sends went nowhere near the gate and a suspended workspace kept sending on its schedule, while the restricted multiplier floored every mailbox at one a day which quietly turned suspension into a trickle rather than a stop; the campaign scheduler now defers the whole campaign with a reason and the warmup task skips as org-suspended, since warmup is outbound mail from the same domains; separately the band change emitted no audit entry at all despite the entity type and the frontend spine entry both existing, so no banner moved for a teammate and there was no trail of who was restricted when, and only a real transition is logged so a detector re-recording the same finding cannot fill the feed; one of my own live tests also asserted how far out a slot lands, which depends on the hour the suite runs, and now asserts the property it was about

* chore: drop a test that belongs to the signup-risk change and reached this branch by mistake, where the type it exercises does not exist
2026-08-28 11:20:19 -07:00
Matthew Meszaros d47d31b7c4 feat: let a warmup recipient answer the mailbox that wrote to it (#230)
* feat: let a warmup recipient answer the mailbox that just wrote to it, so a thread reads as a conversation rather than two mailboxes monologuing on their own ramps: warmup_tasks.target_account_id was written as nil and never read by anything, so a reply only happened when the recipient's own ramp fired AND the draw happened to land on that partner; a verified receipt now sometimes re-points the recipient's pending warmup task at the sender 25 minutes to 5 hours later inside its own warmup hours, which is a re-pointing rather than new work because only one warmup task may be pending per mailbox, it can never delay a send the mailbox had planned sooner, and it stops before the thread cap so replies cannot answer replies forever; the clock parser also moves into models.ClockMinutes so a second copy of the HH:MM parsing that silently disabled every sending window cannot drift back in

* feat: stop the reply-back drawing the reply rate twice, and stop its jitter escaping a short warmup window: the scheduler drew the recipient's reply rate to decide whether to answer at all, then the task handler drew it again to decide reply-versus-new, so a 30 percent reply rate produced a 9 percent answer rate and a directed task could send a fresh message to the mailbox it was meant to be answering; a directed task now IS the reply, and the opening-time jitter is capped to the window width so a mailbox warming 09:00 to 09:20 is not scheduled past its own close
2026-08-28 10:40:25 -07:00
Matthew Meszaros ca5acb5aaf feat: score campaign copy as sent, not just as written (#229)
* feat: score campaign copy where it actually matters, on the message the recipient receives rather than only the template in the editor: warmlint.Score already ran at save time through /templates/score, but nothing checked the copy after merge fields, spintax, A/B selection and AI blocks resolved, which is exactly where a clean template becomes Hi comma or picks the one spammy spintax branch; the send path now scores the rendered message and writes one campaign-feed warning per step per day rather than one per recipient, RunPreflight gains the same check across every step, warmlint gains image-heavy, many-images and attachment heuristics, and the whole thing is advisory behind Settings > Sending > Content checks; the launch dialog also finally calls POST /campaigns/:id/preflight, whose report had no UI at all and so never reached the person about to send

* feat: fix three defects the review found in the content lint: a preflight run that could not read the campaign's steps left the worst score at 100 and reported the check as PASSED, which is the one outcome a check that did not run must never produce, so it now reports failed with the reason; the send-time path read organization settings while preflight read campaign-effective ones, so a campaign that turned the check off or moved its floor was still warned, and EffectiveSettings is now exposed and used by both; and the check-then-insert dedupe let concurrent recipients of the same low-scoring step each find nothing and each write, so it becomes one advisory-locked conditional insert proven by twelve racers producing exactly one feed entry
2026-08-28 10:29:39 -07:00
Matthew Meszaros bc9978dab3 feat: feed per-provider placement back into warmup partner routing (#228)
* feat: feed each sender's per-provider spam placement back into warmup partner selection, so a mailbox failing only at Microsoft stops being handed Microsoft partners instead of waiting for an aggregate band to trip: pickWeightedPartner weighted on inverse domain frequency and routing rules alone, while PoolSpamPlacementsByProvider's segmentation only ever reached the admin overview; the new per-sender query keys on who RUNS the recipient's mail via models.ClassifyProvider rather than email_accounts.provider, because that enum collapses every custom host into smtp_imap which is exactly the bucket the signal matters in, and the weight multiplies by 1/(1+4*rate) with a five-send floor so a failing provider is downweighted but never excluded, since a sender that stops mailing a provider can never discover it recovered

* feat: stop failed warmup sends flattering a provider's junk rate, and stop a re-admitted partner sidestepping the penalty: a warmup_tokens row is written before the send leaves, so counting every token put failed sends in the denominator and understated the rate exactly when the sender was doing worst; the send count now joins tasks on status = completed, and GetPoolParticipantProviders is read unfiltered because that map only resolves a candidate's provider and never decides eligibility, so a just-unblocked partner missing from it scored an unpenalized 1.0

* feat: move the campaign content-lint work off this branch, where it landed by mistake: the pre-send scoring, its preflight check and the warmlint image and attachment heuristics belong to the content-lint change and are unrelated to per-provider partner routing, so this branch now carries only the routing signal it is about
2026-08-28 10:20:35 -07:00
Matthew Meszaros 4b1ccf0bed feat: keep an indefinite warmup block indefinite when migration 000097 collapses a dual membership: a blocked row carries blocked_until NULL to mean 'until an appeal succeeds', so MAX(blocked_until) skipped it as a missing value and adopted a sibling row's finite expiry, handing a permanently blocked mailbox (tampering) a release date and making it re-evaluatable by UpdateParticipantHealth, which refuses to touch a block only while blocked_until IS NULL; the merge now treats any indefinitely blocked row in the partition as the longest block there is, with a live test that an indefinite block survives a pool move, and the Go comments added by this branch are trimmed to the constraint they state 2026-08-27 03:21:08 -07:00
Matthew Meszaros cbb0b8f662 feat: keep a mailbox in exactly one warmup pool so a plan change moves it instead of leaving it in both: EnsurePoolMembership joined whichever pool the org is entitled to right now while RemovePoolMembership only ever deleted that same pool's row, so a downgraded mailbox kept its premium membership and went on being handed to paying customers as a warmup partner while every spam-score increment was counted once per row by the health evaluator; membership is now a move (one upsert keyed on the account that carries health state, block and spam score across, so changing pool cannot launder a penalty), removal is never pool-scoped, a tier change moves the participant row in the same transaction as email_accounts.warmup_pool_type, a new reconcile pass in the backend's warmup sweep evicts participants whose workspace lost warmup access and repairs pool drift for the mailboxes no warmup task ever revisits, an unreadable subscription no longer counts as "not entitled" anywhere that evicts, GetSpamScore takes the max rather than the sum and IncrementSpamScore clamps to the column ceiling instead of silently failing its CHECK, and migration 000097 merges any existing dual membership worst-wins before making one-pool-per-mailbox a unique index 2026-08-27 03:02:56 -07:00
Matthew Meszaros 0a8c3314d0 feat: stop one undeliverable address from wedging a whole campaign, and stop the pre-send verifier from inventing the verdict that caused it: campaign routing in FindNextRoutedPair now excludes contacts whose address verification refused them (invalid, or risky with the campaign's send-to-risky toggle off) because the pre-send gates in campaign_task.go skip those without recording progress, so the finder re-served the same lead every tick and the campaign never reached the healthy leads behind it; the SMTP prober no longer reads every 5xx reply to RCPT TO as a dead mailbox, since Postfix defers HELO/sender/policy rejections to RCPT time and the old default greeting of localhost made them arrive as 504 5.5.2 Helo command rejected against the recipient, so a 5xx now becomes invalid only when the reply names the recipient and degrades to unknown otherwise; the HELO name falls back to the APP_URL host and the probe is skipped outright without a public FQDN; migration 000095 resets the verdicts the corrected classifier would no longer reach so poisoned contacts are re-verified; and skipped leads now report as undeliverable in the Leads view and in the campaign completion log instead of sitting at Queued forever 2026-08-26 06:03:20 -07:00
Matthew Meszaros 8953b2a132 chore: delete repository junk that was never referenced by anything: the paseo worktree-tool config, the root .astro type output an astro run from the repo root left behind, the empty schema.sql, the stray root package.json plus pnpm-lock.yaml from an accidental pnpm add motion (every frontend tree owns its own manifest and lockfile, and CI only ever reads those), the zero-byte cmd/consumer/envsample, the empty web CampaignSearchProvider.tsx, six Go files holding nothing but a package clause, and models.WMailAdd which had no callers; root .gitignore now covers each of them so they cannot drift back in 2026-08-26 04:55:45 -07:00
Matthew Meszaros b77664cd09 feat: verify warmup mail that arrives without its verify header, because Microsoft Graph strips custom headers in transit and re-stamps the Message-ID, so every warmup email sent from an Outlook or Microsoft 365 mailbox reached its recipient unmarked, consumed no token, earned no engagement and was filed in the recipient's unibox as ordinary mail; the Graph client now creates the message as a draft and sends that so it can read the internetMessageId Exchange assigned, the consumer records that delivered id on the task and its warmup token alongside the sent subject, and the recipient resolves an unmarked warmup email by delivered Message-ID or by the pending sender/recipient/subject pair 2026-08-26 04:31:19 -07:00
Matthew Meszaros d396f53fc4 feat: add Delete and Duplicate campaign actions to the dashboard (issue #185): every campaign row and the detail header get a ⋯ menu (Edit, Duplicate, Start/Pause, Delete) plus a Delete card at the bottom of Settings, all permission-gated with a confirm that spells out what goes; DELETE /campaigns/:id is now organization-scoped instead of user_id-scoped so teammates can delete, runs in one transaction that also deletes the campaign's pending tasks and cancels a wakeup tick claimed at that moment (campaign_tasks only nulls its link, so those rows kept firing), removes attachment objects and publishes CAMPAIGN_DELETED so a teammate's open detail page is sent back to the list; new POST /campaigns/:id/duplicate copies the campaign row as a draft with steps and their branch graph rewired onto new step ids, tags, folders, senders with rotation reset, A/B variants, advanced settings and attachments (quota-checked, blobs undone if the copy fails) and none of the leads, progress, logs, counters, ramp level, guardrail trip or past dates, naming it (copy)/(copy N) inside the 50 byte cap without splitting runes; a claimed campaign tick whose campaign vanished now ends the chain instead of staying active forever; covered by TestLiveCampaignLifecycle* against real SQL, RemapBranchTargets and duplicateName unit tests and a react-query vitest for the list cache, with API reference, endpoint map and campaigns guide updated 2026-08-25 08:54:51 -07:00