Commit Graph
87 Commits
Author SHA1 Message Date
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
Matthew Meszaros efa914025c feat: stop an active campaign sitting at "Queued / Not started" with nothing sending: a campaign is one self-perpetuating task, so a tick that found nothing due parked its successor at the literal next-due moment (three days out for a "wait 3 days" step) and that parked task was also the next time anything re-read the campaign, so leads imported meanwhile stayed invisible until it fired and the reconciler never noticed because it only re-seeds chains with no pending task; deferral parks are now capped at config.CampaignMaxDeferMinutes via scheduler.DeferSlot at all three enqueue sites (a tick that actually sent still parks at its paced interval, so send spacing is untouched), the reconciler re-checks any active campaign parked beyond CampaignStaleParkHours and pulls its wakeup forward when the real next slot is CampaignReparkMarginMinutes sooner, attaching leads to a running campaign wakes it immediately through one CampaignWaker seam in the contact service that covers add/update/bulk-edit/import/Sheets-sync, even distribution now paces across the whole sender pool via poolRemainingOn instead of the one mailbox the tick picked (a three-mailbox campaign was sending at one mailbox's rate), the flat +/-20 minute jitter that was wider than the interval it perturbed is scaled to half the distance to the slot so it stops landing slots in the past where notBefore collapsed them onto the min-gap, and on the dashboard a full-day window renders "12am-midnight" instead of "12am-12pm", the campaign lead strip uses the server's campaign-wide lead_counts instead of counting the 50 loaded rows, and channel state moves out of a ref into React state so a live campaign's panel stops reading "Disconnected" forever 2026-08-25 07:39:18 -07:00
Matthew Meszaros 222c9d2554 feat: scope campaign sender resolution to the campaign's organization instead of its owner so a multi-org user can no longer send organization A's campaign from an organization B mailbox: GetByTags/GetAllActiveInScope/GetByCampaignSenders now take a repository.AccountScope keyed on organization_id where a scope with no organization resolves to no mailboxes rather than widening to the owner (tags carry no organization of their own, so one user's tag legitimately spans workspaces and the predicate is the only thing holding the boundary), the campaign scheduler and the preflight tracking-domain check build that scope from campaign.OrganizationID, unibox compose scores only the current workspace's mailboxes, the 'all' lanes of AccountHasActiveCampaign/CountActiveCampaignsForAccount join ea.organization_id = c.organization_id instead of ea.user_id = c.user_id, dead and broken PauseAllByUserID (which wrote the reason string into status and had no callers) is removed, the campaigns/unibox guides now state that senders resolve inside the campaign's workspace, and TestLiveOrglessCampaignDoesNotSendToSuppressedRecipient is updated because an orgless campaign now finds no senders before routing is consulted while the send gate it covers still refuses; live-tested in TestLiveSenderResolutionStaysInsideTheCampaignOrg, TestLiveSenderSchedulerNeverPicksAnotherOrgMailbox, TestLiveSenderSchedulerPicksTheCampaignOrgMailbox, TestLiveSenderScopeWithoutAnOrganizationReachesNothing and TestLiveActiveCampaignLookupIsOrgScoped 2026-08-24 09:20:41 -07:00
Matthew Meszaros 15e139e15d feat: stop a campaign email going out twice when the progress write after dispatch is lost: a step is now RESERVED before its SEND_EMAIL reaches the bus (migration 000093 adds campaign_contact_progress.dispatched_at + dispatch_task_id, and ReserveSend takes the claim and the day's counters in one transaction) and routing treats a step as attempted on sent_at OR dispatched_at, so a crash or a failed stamp in the dispatch window can no longer read as "never sent" and email the same person again; the ON CONFLICT claim is exactly-once so two ticks racing the same pair cannot both send (the loser ends skipped_duplicate), the stamp is retried and escalated to the campaign feed instead of warned and swallowed, HandleEmailSent repairs a lost stamp from the worker's own confirmation, ReleaseSend gives a reservation back only when the command provably never left (a publish failure is ambiguous via ErrSendDispatchUnknown and keeps it), and StartStuckSendReclaimer walks back a reservation nobody answered after 30 minutes so a worker that died mid-send cannot park a lead in flight forever; live-tested in TestLiveLostProgressWriteDoesNotResend, TestLiveDispatchedSendIsNeverOfferedTwice, TestLiveConcurrentTicksSendOnce, TestLiveStuckDispatchIsReclaimed, TestLiveReclaimBelievesADeliveredSend and TestLiveInFlightSendIsNotOfferedAgain 2026-08-24 09:15:06 -07:00
Matthew Meszaros cbf4190f42 feat: make a custom tracking domain verifiable instead of permanently "Pending DNS": the CNAME target is now this install's TRACKING_DOMAIN rather than a hardcoded t.warmbly.com that resolves nowhere, matching is exact on the label boundary (or on shared addresses, so a provider-flattened CNAME stops reading as no record at all) instead of strings.Contains, and every outcome carries the reason plus what DNS actually returned, including when the tracking host the customer is told to point at has no record of its own; a pasted URL is normalized to its host and a malformed one is rejected up front instead of saved and left pending forever; only a VERIFIED mailbox domain is used at send time with the shared host as the fallback and a campaign-feed entry saying why; pixels and click tickets are built from the configured host, and with none configured mail ships untracked rather than carrying links to another deployment's tracking service; adds GET /emails/:id/track and POST /emails/:id/track/verify plus an hourly re-resolution sweep so a record that propagates later starts being used and one that breaks stops routing links; and scopes the tracking-domain write by organization like the read, which also fixes GET /emails/:id passing a user id to an org-scoped query and 404ing for every caller 2026-08-24 09:02:29 -07:00
Matthew Meszaros 1018068942 feat: stop recipient suppression and the entitlement gate being skipped when a campaign has no organization: the send path now fails closed (an orgless campaign is paused with the reason in its activity feed instead of mailing an unsubscribed, bounced or complained address, since routing's own suppression filter joins on the campaign's organization_id and matches nothing when it is NULL), the same tenancy gate covers warmup and unibox sends and an orgless mailbox resolves to the free pool rather than defaulting into the paid one, the state is no longer creatable (sequenceRepository.Create inherits organization_id from its campaign instead of inserting NULL, campaign creation and mailbox onboarding refuse without a workspace via errx.ErrNoOrganization, guardInboxLimit / guardMailboxThrottle / the campaign creation throttle no longer treat a missing org as exempt, and a new session resolves the user's default workspace so the orgless session that produced these rows cannot exist), and migration 000092 backfills then sets organization_id NOT NULL on campaigns, contacts, email_accounts and sequences plus sessions.current_organization_id, provisioning a recovery workspace for any user with none so no row is deleted to satisfy the constraint (live-tested in TestLiveOrglessCampaignDoesNotSendToSuppressedRecipient, TestLiveSuppressedRecipientIsSkipped, TestLiveCampaignRequiresAnOrganization and TestLiveHealthyCampaignStillSends) 2026-08-24 08:47:08 -07:00
Matthew Meszaros 6c17f109fd feat: stop one waiting lead from parking a whole campaign: FindNextRoutedPair now returns only a DUE pair (new leads now, follow-ups at last sent + wait_after days, plus a wait node's minutes) and skips not-yet-due contacts so other leads' first emails and due follow-ups keep sending, handing back the soonest due moment when nothing is due so the scheduler defers exactly until then (min'd with the next-day new-lead-cap deferral); drop send-time optimization from the successor wakeup, which by default pushed the next lead to 09:00 UTC tomorrow after any send past 17:00 UTC (and to a past time when the hour matched, defeating pacing); document that send_time_optimization is stored but not applied and that waits are per contact; live-tested in TestLiveWaitingFollowUpDoesNotBlockOtherLeads and TestLiveWaitNodeGatesTheStepAfterIt 2026-08-24 07:49:54 -07:00
Matthew Meszaros 6b614ad4bb feat: never hand a send to a worker that is not heartbeating (registry row plus the Redis heartbeat key, which the worker now sets at boot instead of 90 seconds later), because a command queued for a dead worker is neither executed nor answered; reload a worker's mailboxes the moment its boot heartbeat arrives instead of waiting for the reconciler's republish window, make the publisher fail instead of silently succeeding when no bus or object storage is configured, and log a send that could not reach a worker to the campaign feed as a red, retrying entry 2026-08-23 10:18:50 -07:00
Matthew Meszaros 3739a36b67 feat: enforce the persisted SPF/DKIM/DMARC state as a real cold-send and warmup gate behind a 72h grace clock and an operator toggle, after first fixing the DMARC organizational-domain fallback in dnsauth so a dedicated sending subdomain covered by its parent's record stops reading as unauthenticated, adding auth_state to the four mailbox loaders that never selected it (which would have made the gate dead code), stamping auth_failing_since on entry to failing so a resolver hiccup can never stop a campaign, notifying the org on that transition, and reporting an all-gated pool as ErrDomainAuthFailing instead of a message about sending windows (#160) 2026-08-22 09:37:26 -07:00
Matthew Meszaros ec4cd3160b feat: thread Unibox dashboard replies into the conversation they answer, by carrying the composer's thread_id all the way from email_tasks to the provider (EmailMessage had no ThreadID field at all, so the column was read from the database and silently dropped in user_email_task, and Gmail only appends to an existing thread when threadId is set on the outbound message since a matching Subject and In-Reply-To do not do it), populating the models.SendEmail.Parent field that already existed with an avro tag and that the worker already read but nothing ever set, replacing the worker's gate that required InReplyTo to be non-empty before it would look at Parent (a dashboard reply never sets that header, so a perfectly valid ThreadID was discarded and the provider opened a new conversation) with a parentReference helper shared by the Gmail and Graph send paths that resolves the two genuinely independent handles separately, and backfilling the RFC In-Reply-To header server-side in UniboxReply from the newest Message-ID in the thread via a new org-scoped LatestMessageIDInThread query, because a provider thread id is meaningless outside the mailbox that issued it and the recipient's mail client can only thread on References and In-Reply-To (#122) 2026-08-16 07:46:16 +02:00
Matthew Meszaros 50f50e680d feat: make the inbound mail pipeline work end to end by never publishing the eventbus partition key as Nats-Msg-Id (JetStream deduped every event after the first per mailbox), fetching IMAP message bodies after the outer FETCH closes instead of nesting one inside it (which deadlocked sync on the first message), wrapping NEW_EMAIL in JobEventNewEmail across all three providers so the consumer stops nil-derefing, coalescing nil arrays before the NOT NULL unibox columns, sealing validation credentials on a copy so stored SMTP/IMAP passwords are not double encrypted, routing the email task type to the user email handler, and returning false instead of closing a nil conn in VerifySMTP (#88) 2026-07-31 09:41:36 +02:00
Matthew Meszaros 443dcbf4b5 Merge pull request #83 from warmbly/ai-content-blocks
AI content blocks and a much more capable dashboard assistant
2026-07-22 17:59:36 +02:00
Matthew Meszaros f2eddb0c4b feat: resolve per-recipient AI variables in the campaign task before tracking, threading surrounding email context and caching results per contact and step 2026-07-22 17:05:08 +02:00
Matthew Meszaros c1c9a31b1f Merge pull request #82 from warmbly/warmup/messaging-automation
Automate warmup content and replies
2026-07-22 12:44:04 +02:00
Matthew Meszaros f768029ec0 feat: automate warmup conversation generation, coherent replies, adaptive rotation, and admin observability 2026-07-22 12:11:19 +02:00
Matthew Meszaros bb040828b1 feat: rework automation AI steps to mirror campaign step types with one agentic AI step plus an AI switch, per-case switch handles on the canvas, a Stop node, and a step-type add menu 2026-07-22 10:58:35 +02:00
Matthew Meszaros 66aaaa6584 feat: add an ai agent step to campaign sequences 2026-07-21 17:35:11 +02:00
Matthew Meszaros e8de52ce30 refactor: put the task client behind a scheduler interface 2026-07-20 09:56:16 +02:00
Matthew Meszaros 4f9e6e6282 feat: full attribution on every AI credit charge — new actor_user_id + context jsonb columns on the transaction log (migration 000070), a typed models.CreditContext carried via request context (models.WithCreditMeta) so base charges, usage settles, web-search fees, and refunds all inherit it without signature churn, wired at every spend site (campaign switches record campaign/step/contact, automation nodes and Ask AI record automation/node/run and the question asked, reply drafts and inbox agent record the thread, the dashboard agent records the session, research records contact/run, and manual features record the triggering user), rendered as a detail line with token counts in the billing transaction log with corrected reason labels, and documented in the AI credits guide 2026-07-18 08:52:16 +02:00
Matthew Meszaros 163d3057de feat: add web-search and extended-thinking capabilities to the campaign switch AI decider — a per-step web lookup about the contact's company (query derived from contact fields only, results fenced as untrusted, +1 credit charged only when results land, wired through a new tasks SetAISearch) and a thinking toggle that routes to the stronger model tier with a 2048-token budget priced through usage metering, with editor toggles under Capabilities and sequences/ai-credits docs 2026-07-18 08:42:08 +02:00
Matthew Meszaros 053ca6a71d feat: usage-based AI credit system — every AI call reserves its flat minimum then settles the real token cost per model (light 1500/standard 400 tokens per credit, drain-to-zero overage settle wired into writing assistant, reply drafts, dashboard agent, inbox agent, research, automation nodes, and campaign switches), org spend controls in a new org_ai_settings table (day/week/month hard limits enforced in Consume, low-balance alert threshold, auto top-up config), a credit-watch monitor hook that fires BILLING_CREDITS_LOW realtime alerts once per day and buys the configured pack off-session via a new Stripe AutoTopUpCredits (idempotent on the PaymentIntent, bounded per month, Redis-locked), GET /subscription/credits/usage + GET/PATCH settings endpoints, and an AI usage & spend controls billing card (spend vs limits, 30-day chart, per-feature and per-model breakdowns, limits/reminder/auto-top-up form) with a realtime low-credit toast 2026-07-18 08:39:41 +02:00
Matthew Meszaros 30d4277bba feat: harden switch value matching and restyle the decider picker — normalized comparison ignores casing and collapses whitespace, /pattern/ case names become case-insensitive regexes (validated at write time, plain cases checked before regex cases, first match wins, no substring fuzziness so 'not interested' never lands on 'interested'), and the Decided by control becomes two icon cards with descriptions matching the step-type switcher 2026-07-18 08:06:26 +02:00
Matthew Meszaros f36c1a5001 feat: replace the campaign AI step with a Switch step — a multi-way router whose cases are rows on the canvas node, each with its own draggable source dot (per-case ai_label branches wired by handle, retarget on re-drag, purple case edges with no IF boxes, bottom dot as the otherwise fallback), decided per contact by an AI prompt (1 credit, fenced context, same credit lifecycle) or a free deterministic template-value match, with a mode toggle + case list editor (stale case paths healed on save), drag-to-empty-canvas creating the target and wiring the case, and sequences/ai-credits/automation docs rewritten for the switch 2026-07-18 05:42:50 +02:00
Matthew Meszaros 4399bb8e82 feat: collapse the campaign AI step to a prompt plus draggable named outcome paths — the connections drawn out of the step are the choice set (outcome names live on the ai_label branch conditions, deduped per step and read at run time), drop the ai_labels/ai_output_fields/ai_actions config and the whole AI action palette (side effects are ordinary action steps placed on the chosen path), and fence the contact's email, profile fields, and automation event data as untrusted content in every campaign and automation AI prompt so injected instructions inside replies are ignored 2026-07-18 05:18:24 +02:00
Matthew Meszaros d18c5ebe4a feat: execute campaign AI steps in the scheduler path — one metered completion per contact (SetAI-wired provider + credits with per-step idempotency and refund-on-failure) that labels progress, fills contact custom fields which feed the chosen actions' templates, runs AI-picked pre-configured actions with per-decision campaign logging and tag/label choice resolution, grounds decisions in campaign history and the newest inbound reply via LatestInboundFromContact, and hands instant chains back to the scheduler at AI nodes 2026-07-17 16:26:23 +00:00
Matthew Meszaros 9d7e3aa61f feat: make scheduled sending self-healing - dispatch Cloud Tasks callbacks by the task row type (all enqueues share one webhook URL, so campaign callbacks were run through the warmup handler), cancel pending tasks stranded past their slot so the reconcilers re-seed chains after a lost callback or emulator restart, and cast the status enum in the two UPDATE ... CASE queries that failed parameter type deduction on every callback 2026-07-11 17:31:23 +02:00
Matthew Meszaros 03acdb87e3 fix: thread organization_id through contact, CRM, and unibox reads and writes so non-owner org members see and edit their workspace data, and backfill contacts.organization_id for single-membership users (migration 000049) 2026-06-15 08:11:53 +02:00
Matthew Meszaros 173736a004 feat: add a Stripe-grade webhook platform with a typed event catalog, HMAC-signed delivery, retries with backoff, endpoint ownership verification, per-endpoint throttling, an audit-spine event bridge, and firehose emit sites (migration 000048) 2026-06-15 08:11:20 +02:00
Matthew Meszaros fcafac4195 refactor: remove the campaign 'notify (webhook)' step type now that the HTTP request step covers outbound calls and there is no webhook setup surface 2026-06-14 10:48:06 +02:00
Matthew Meszaros ab24d8bbc8 feat: add a 'Fire event' action and campaign step that publish custom events to the realtime gateway (no public URL), an HTTP-request campaign step, a configurable automation dry-run test with per-step toggles, and fix the false 'updated by a teammate' toast on your own save 2026-06-14 09:52:49 +02:00
Matthew Meszaros 71abb12a38 feat: stop campaigns silently stalling on a transient scheduler error by retrying instead of completing the task, end past-end-date campaigns cleanly, record scheduler failures to the campaign log, add a campaign-chain reconciler, and surface failures live in the dashboard activity panel 2026-06-13 06:27:01 +02:00
Matthew Meszaros ba89a932cd feat: run the label_email action on reply — campaign instant and scheduled paths via advanced LabelThread/LabelLatestThreadForContact, automations via execNativeAction reading thread_id and _user_id from the reply payload — and move the native-actions adapter to a shared package wired into the consumer too so reply/bounce/warmup automation actions stop silently failing 2026-06-12 16:44:16 +02:00
Matthew Meszaros 8732805934 feat: replace signed click redirects with server-side link tickets (tracked_links store, internal resolver API, opaque /c/<id> URLs, layered anti-probe caches with miss budget and circuit breaker) removing TRACKING_LINK_SECRET entirely 2026-06-11 09:30:21 +02:00
Matthew Meszaros 2c5e8b2cbd feat: make TRACKING_LINK_SECRET a required boot-time secret on backend and tracking service with no unsigned mode and no rotation grace, so rotating the key revokes old links immediately 2026-06-11 09:00:04 +02:00
Matthew Meszaros 8b9277dabf feat: harden tracking service against abuse with per-IP rate limiting, prefetch/scanner filtering, URL length caps, and HMAC-signed click redirects (TRACKING_LINK_SECRET) closing the open-redirect hole 2026-06-11 08:11:05 +02:00
Matthew Meszaros abdfd05c34 feat: org-scope realtime events (inbox, campaign, tracking, account health) and emit EMAIL_SENT/EMAIL_REPLIED/EMAIL_DELETED pulses so the whole team's dashboard updates live 2026-06-11 07:43:57 +02:00
Matthew Meszaros ac3c11bf9a feat: seal outbound email content with the organization DEK
The send pipeline (publisher subject/body encryption, the S3 emsg blob,
and the worker-side decrypt) now keys off models.SendEmail.OrgID
instead of UserID. EmailMessage loses its UserID field entirely:
emailSender.Send derives the cipher identity from the email account's
OrganizationID and refuses to send for an account without one.

This also fixes two latent bugs. Emails sent through user_email_task
never set UserID, so they were silently encrypted under the zero-UUID
platform key. campaign_task's discarded Encrypt() pair is replaced with
an explicit DEK warm that fails fast when KMS is unavailable.
2026-06-10 17:16:42 +02:00
Matthew Meszaros 5954ac53c2 feat: add automation execution safeguards
Support chained automation execution from native actions, guard automation recursion depth, carry idempotency context through campaign-launched automations, allow team task assignment in backend action execution, and reject deletes while campaign steps still reference an automation.
2026-06-09 10:56:56 +02:00
Matthew Meszaros 3236ffc459 feat: expand product docs and automation references
Restructure the docs navigation into product-focused pages, add automation expression reference content, and refresh related template and personalization surfaces.
2026-06-09 09:07:56 +02:00
Matthew Meszaros b2c5117eca feat: preview campaign templates
Adds the shared template helper function map, send-path spintax expansion, server-side template preview validation, and the campaign template preview endpoint.
2026-06-08 15:04:53 +02:00
Matthew Meszaros 5f7ac801ba feat: execute automation graphs 2026-06-08 12:03:11 +02:00
Matthew Meszaros add075581c feat: route stop on reply through reply flows 2026-06-07 17:23:39 +02:00
Matthew Meszaros f5abb8cbb6 feat: add CRM deal sequence actions 2026-06-07 11:48:03 +02:00
Matthew Meszaros 9885e8b952 feat: create CRM tasks from campaign actions 2026-06-07 06:18:22 +02:00
Matthew Meszaros da82993846 feat: add campaign workflow backend support
Add sequence action-node storage and execution, template conditional rendering, lead progress state, profile updates, webhook fan-out throttling, and supporting repository fixes.
2026-06-06 07:49:52 +02:00
Matthew Meszaros 77de336d78 feat: unify campaign sender pools
Resolve campaign senders as the union of explicit accounts and tag-selected mailboxes, with all active mailboxes as the empty-selection fallback. Update readiness checks, active-campaign counts, and rotation cursor handling for the unified pool.
2026-06-05 14:35:52 +02:00
Matthew Meszaros b701e4efab feat: send campaign email attachments
Carry attachment references inside the stored email body blob, resolve bytes on workers, and encode attachments for Gmail API and SMTP sends without changing the Kafka send-email contract.
2026-06-05 06:03:21 +02:00