feat: enforce shared outbound capacity and directional diagnostic consent across campaign manual warmup and seed dispatch with transactional result outbox bounded Gmail Graph DKIM audited repair holds and legacy upgrade gates

This commit is contained in:
Devin AI
2026-10-07 14:02:11 +00:00
parent 8e53fc7027
commit 8a45508e79
94 changed files with 3152 additions and 426 deletions
+5
View File
@@ -1714,6 +1714,11 @@ func main() {
advancedService.WireBounceJudge(typeSafeClient)
}
emailSender := tasks.NewEmailSender(emailRepostory, eventsPublisher)
if aware, ok := emailSender.(interface {
WireSendAdmission(repository.OutboundAdmissionRepository)
}); ok {
aware.WireSendAdmission(taskRepository.(repository.OutboundAdmissionRepository))
}
// Never hand a send to a worker that stopped heartbeating: nothing
// would execute it and nothing would report it, so the step would
// look sent forever. The worker reconciler re-places the mailbox and
+1 -1
View File
@@ -240,7 +240,7 @@ func newNodeAgent(workerID uuid.UUID, bindIP string) *nodeagent.Agent {
reportedIP = bindIP
}
return nodeagent.New(nodeagent.Config{
WarmupSendProtocol: 1,
WarmupSendProtocol: 2,
NodeID: workerID,
Role: models.NodeRoleWorker,
Name: os.Getenv("WARMBLY_NODE_NAME"),
+4
View File
@@ -262,6 +262,8 @@ The `/unibox/drafts` endpoints hold autosaved compose drafts, scoped to the call
`PATCH /emails/:id` also accepts `relay_folder_moves` (boolean, default true), on every provider: when true, filing a conversation with `PATCH /unibox/folder` moves it in the mailbox too. See [filing in the mailbox](/guides/mailboxes/#filing-in-the-mailbox).
The same `WRITE_EMAILS` mailbox PATCH accepts `test_mode` (`diagnostic`, `off`, `legacy`), independent `test_send_enabled`/`test_receive_enabled`, positive `shared_daily_limit`/`rolling_recipient_limit`, and `send_recovery_resolution`. Repair resolution binds `held_task_id`/`held_reason` and requires either `authentication_repaired` evidence from a newer sync or `operator_provider_confirmation` with a same-mailbox definitive `evidence_task_id` and non-empty `confirmation_reference`. Unknown sends cannot be cleared. Existing NULL participation remains legacy; new mailboxes default off. See [send safety and exact resolution requirements](/development/send-safety/).
`DELETE /emails/:id` releases the mailbox on Warmbly Cloud before removing the local mailbox. An enrolled mailbox's stored credential comes out of the pool, and a cloud-managed mirror's claim is released so the mailbox returns to the cloud workspace and can be adopted again. If the cloud cannot confirm the release, the delete returns `409 mailbox_cloud_unenroll_failed` and keeps the mailbox record so the request can be retried safely. Warmbly attempts to restore the mailbox onto its worker immediately, and the worker reconciler may restore it later if that attempt fails. See [mailboxes](/guides/mailboxes/).
`GET /unibox` and `GET /unibox/thread` return message previews: each row carries `snippet`, a one-line summary, not the message body. Read a full message with `GET /unibox/:id`, which returns `body_plain` plus `body_html`. The HTML is sanitized before it leaves the API (scripts, event handlers, embedded frames, and unsafe URL schemes are removed), so it is safe to render, and links carry `target="_blank"` with `rel="noopener"`. Warmbly open-tracking pixels are also removed from this display copy, including quoted history, so rendering it does not record a campaign open. Stored and delivered copies keep their pixels. `body_truncated` is `true` on the rare message whose stored body could not be read, where `body_plain` falls back to the snippet. The same response carries the full envelope (`from`, `to`, `cc`, `bcc`, `ReplyTo`, `date`, `internal_date`, `message_id`, `in_reply_to`, `size`), the mailbox it belongs to (`email_id`) and its canonical `folder`.
@@ -618,6 +620,8 @@ Cloud-managed OAuth additionally exposes `POST /pool-link/instance/oauth/start-c
`POST /internal/worker/warmup-dispatch` is an execution-plane internal-token route, not a public JWT/API-key scope. It inspects, begins and durably finishes a task/mailbox/worker/nonce-bound command. Start repeats authoritative policy checks; unknown or denied authority cannot permit native execution. Receipt/header/model text supplies no authorization.
`POST /internal/worker/outbound-dispatch`, `/internal/worker/warmup-actions`, and `/internal/worker/diagnostic-auth` use the existing execution-plane internal token, never customer API keys. They bind the current mailbox/worker and require protocol 2. Outbound start additionally rechecks task/organization/provider/nonce/recipients and current campaign or diagnostic policy. Diagnostic authorization/result submission binds one exact already-owned warmup receipt and short-lived nonce; it neither ingests unrelated inbox messages nor trusts event/header claims. No public scope or permission was added. Details and limits: [send safety](/development/send-safety/).
### Referrals and discounts
The referral program and billing discount history are part of `/subscription/*`, so they are JWT only and never accept an API key. There is no API permission scope for them; browser callers are gated by the org permission below. The discount validation and checkout endpoints are listed under [Subscription and billing](/api/reference/account-org/) in the reference.
@@ -59,6 +59,8 @@ The landing page. Instance-wide counters and their trends, acquisition by channe
| **Warmup** | The pools, the blocked list, and the block and unblock history |
| **Warmup appeals** | Appeals from blocked mailboxes, to approve or reject |
| **Warmup content** | The generated and reviewed [diagnostic conversation bank](/development/warmup-generation/), effective generation controls, library and generation jobs. Changing effective controls requires `manage_settings`; existing library reads still require `view_warmup_pool` |
Mailbox diagnostic participation, sender/recipient flags and shared/rolling limits are organization-scoped mailbox fields, not new admin-panel switches. Use the existing authorized mailbox PATCH; no new UI is implied. An operator-reviewed `send_recovery_resolution` binds the current held task/reason and definite repair/provider evidence, never an unknown send. See [send safety](/development/send-safety/).
| **Seed panel** | The instance's placement seed inboxes by provider, adding and removing them, every workspace's recent [placement tests](/guides/placement-tests/) with their results, and an operator test that counts against no allowance |
| **Campaigns** | Every campaign on the instance with its state and owner |
| **Sends** | In-flight reservations no worker has answered, and the reclaimer that resolves them after 30 minutes; dead letters with replay; task failures; customer webhook delivery health, with reclaim |
@@ -645,6 +645,8 @@ Automatic inbox tagging is read by the backend, consumer, and `warmblyctl`. Both
Warmup replies use the same persisted SQL task identity with either queue provider. They wait at least 45 minutes after the exact parent receipt and recheck mailbox windows, roles, consent, standing, suppression and remaining budgets before native dispatch. Queue callbacks do not grant sending authority. See [diagnostic generation and rollout](/development/warmup-generation/).
Per-mailbox `test_mode`, `test_send_enabled` and `test_receive_enabled` separate diagnostic sending from receiving; new mailboxes default off and legacy NULL values keep prior behavior. Positive `shared_daily_limit` and `rolling_recipient_limit` optionally constrain all outbound lanes above their separate cold/diagnostic allocations. These are database-backed mailbox PATCH fields, not environment variables or global admin settings. Current participation, calendars, suppression, provider holds and durable reservations are rechecked by protocol-2 executors. See [defaults, repair evidence and rollout](/development/send-safety/).
| Variable | What it does | Default |
|---|---|---|
| `TASKS_PROVIDER` | `local` (an in-process Postgres poller) or `gcloud` (Cloud Tasks) | `local` |
@@ -347,6 +347,10 @@ The hosted service at warmbly.com does measure its own marketing site, dashboard
If you do want product analytics on your own instance, the variables are on [configuration](/development/configuration/#product-analytics-and-session-replay) and they accept a self-hosted PostHog. The server-side `POSTHOG_KEY` carries the backend's error tracking and its product events; the browser-side `WARMBLY_POSTHOG_KEY` is what turns on the dashboard's and the admin panel's analytics, session replay and browser errors, and `WARMBLY_POSTHOG_ERROR_TRACKING` and `WARMBLY_POSTHOG_SESSION_REPLAY` switch those last two off separately. That is your decision to make, and nothing about it points at us.
## Diagnostic evidence and sending restrictions
Diagnostic participation, shared limits, durable recovery evidence and operator repair history are organization data. Raw MIME is never persisted by the bounded Gmail/Graph DKIM verifier. Minimal verifier/version/time results can attach to historical receipt evidence; source-worker/message authorization nonces are excluded from workspace moves. The SQL reconciliation outbox is source-instance-local and retained by full database backups, not organization archives. Drain it before moving a workspace; pending effects survive restart, delivered rows expire after seven days, and organization/task deletion cascades their removal. Repair audit history lasts with the organization. Rollback refuses pending effects, unknown sends and retained restrictive state. See [send safety](/development/send-safety/).
## See also
- [Install](/development/install/): the wizard that asks all of this up front
+2
View File
@@ -14,6 +14,8 @@ The bus is an abstraction (`internal/infrastructure/eventbus/`) with two provide
Message encoding is orthogonal to transport, selected by `CODEC_PROVIDER`:
Shared send reservations and action/diagnostic authority are SQL/control-plane capabilities, not new queue command shapes. Existing JSON and Avro SEND_EMAIL payloads remain readable. Protocol `warmup_send_protocol=2` gates the new executor checks; protocol 1 is only the previous lineage capability. Upgrade all control-plane/result consumers, pause/drain accepted old work, then upgrade workers. Unknown sends retain reservations and do not become blind queue retries. Result reconciliation writes bounce webhooks/notifications to a durable outbox before commit; consumers drain leased rows with stable downstream IDs. External integration/realtime delivery is not exactly once. See [send safety and rollout](/development/send-safety/).
- `json`: plain JSON, no external dependencies, and the default for the self-host stack.
- `avro`: Avro with Confluent Schema Registry (`SCHEMA_REGISTRY_URL` plus optional key/secret), also behind the `kafka` build tag. The subject is the topic name plus `-value`, Confluent's default naming strategy, so `warmup-events` registers under `warmup-events-value`.
+1
View File
@@ -15,6 +15,7 @@
"data-control",
"configuration",
"warmup-generation",
"send-safety",
"whole-domain-connect",
"slack-app",
"instance-health",
@@ -0,0 +1,87 @@
---
title: Send safety and diagnostic controls
description: Shared admission, explicit diagnostic participation, actual-message DKIM and conservative upgrade behavior.
---
This control system restricts sending using current authority and observed evidence. It does not manufacture engagement, promise inbox placement or establish a reputation benefit from local tests.
## Explicit participation and complete stop
`PATCH /emails/:id` keeps its existing `WRITE_EMAILS` scope and organization boundary. The additive fields are:
| Field | Behavior |
|---|---|
| `test_mode` | `diagnostic`, `off` or explicit `legacy`. Existing NULL values retain legacy behavior; newly connected mailboxes default to `off` |
| `test_send_enabled` | Enables diagnostic sending only in `diagnostic` mode |
| `test_receive_enabled` | Separately enables diagnostic receiving only in `diagnostic` mode |
| `shared_daily_limit` | Positive mailbox-wide calendar-day ceiling across campaign, manual, warmup and placement reservations |
| `rolling_recipient_limit` | Positive rolling 24-hour ceiling counting each To/CC/BCC recipient occurrence, not just messages |
Choose sender-only, recipient-only or both explicitly. Recipient-only does not authorize a reply. `off` excludes diagnostic starts, active-campaign health checks, reply-back and new recipient selection. It does not stop legitimate campaign/manual traffic by itself: pause the campaign or mailbox for that. Diagnostic mode disables synthetic read, star, importance and spam-rescue actions. Authorized filing and deletion remain housekeeping, not positive deliverability evidence. Cleanup can remove an already received diagnostic after participation stops.
The control plane checks participation before publication; capable executors recheck it immediately before native execution. A queued command is not consent. Queue callbacks, received text and model output cannot authorize sending. Revoke receiving before a queued diagnostic starts and it is denied; revoke sending and a recipient-only mailbox cannot reply. No software can retroactively cancel a provider submission that already began.
`warmup_generation.generation_enabled=false` is independent. It blocks new generation submissions, edits/replacements and generated source use; an already submitted provider job can drain. Preserve the configured credentials while it does. See [generation controls](/development/warmup-generation/).
### Local aliases versus Cloud mailboxes
Local participation and unlinking restrict local use of an alias. They do not revoke an independently Cloud-owned native mailbox, its provider OAuth grant, or accepted Cloud-native work. Pause/delete or change participation on that Cloud mailbox separately if that is the intended outcome. Enrolled SMTP mirrors also have Cloud-side participation; a newly created Cloud mailbox is off until its owner explicitly enables it. Do not treat healthy standing as participation consent. Existing lifecycle enrollment and account IDs remain unchanged.
## Shared send-time admission
Every new outbound lane goes through a mailbox/task advisory lock and one durable SQL reservation: campaigns, manual/Unibox sends, normal warmup, campaign-backed health checks/reply-back, and placement/seed probes. Campaign progress already reserved for the same task is not counted twice. Cold/manual and diagnostic allocations remain distinct constraints, with optional shared and rolling ceilings layered above them. Calendar days use the mailbox/organization timezone, including daylight-saving changes; rolling recipient totals do not reset at midnight.
Reservation requires an active organization-owned mailbox, its current configured provider and worker, current suppression and recovery state, and the exact recipient envelope. Campaign sends additionally require active campaign/lead/progress state and current calendar/pacing policy. Diagnostic partners retain their real pool, role, tier, trust, standing and inbound capacity constraints. Seed mailboxes stay measurements, not conversation partners. Future, missing or stale positive Cloud standing is unavailable evidence; restrictive holds are not erased.
The executor rechecks organization, mailbox, worker, provider, durable task/nonce and exact recipients before starting. A command cannot broaden its CC/BCC or switch tenants after reservation. Worker silence, publication uncertainty, provider timeout and executor restart are unknown outcomes, not unsent proof. Their reservations and mailbox hold remain until a correlated definite result resolves them. Terminal replay returns the saved result without resending. A definite native throttle records only the evidence and scope actually observed; legacy codes preserve conservative mailbox/provider cooldowns without invented HTTP or SMTP codes.
Result reconciliation commits task, campaign progress, variant, suppression and warmup-health changes together. Bounce webhooks and notifications enter an idempotent durable outbox in that transaction, and result consumers drain it after commit. Failed delivery retains a leased retry row; a consumer restart does not lose the effect. Stable downstream webhook delivery and notification-feed IDs prevent duplicate stored rows. External integration, Slack/push and realtime delivery remain at least once/best effort, not exactly-once delivery. Core accounting and unknown holds remain durable.
## Evidence-based repair holds
Authentication, permanent and conflicting-result holds do not clear merely because time passed, a reconnect occurred or an unrelated send succeeded. An organization-authorized operator can use `send_recovery_resolution` in the existing mailbox PATCH after investigating the current held task and reason:
```json
{
"send_recovery_resolution": {
"held_task_id": "00000000-0000-0000-0000-000000000001",
"held_reason": "authentication",
"evidence_type": "authentication_repaired"
}
}
```
`authentication_repaired` requires a newer successful mailbox sync after the held task. `operator_provider_confirmation` instead requires `evidence_task_id` naming a definitive, applied result on that same mailbox and a non-empty `confirmation_reference`. Include the exact `held_task_id` and `held_reason` (`authentication`, `permanent`, `conflict`) inspected by the operator. A changed hold, inactive mailbox, mismatched configured provider, cross-organization request or any remaining `send_result_state=unknown` refuses resolution. Investigate unknown tasks first; never label them failed merely to free capacity. The history and previous reason are inserted atomically before clearing only the repair hold. Cooldowns, suppression and other restrictions remain. References must not contain credentials, message bodies or raw provider payloads.
## Actual-message authentication
An updated worker can retrieve bounded raw MIME for exactly one already authorized application-owned warmup diagnostic received through Gmail or Microsoft Graph. The control plane first binds the immutable diagnostic token/task, exact sender and recipient, provider message ID, current mailbox/worker and participation. A short-lived nonce binds the result submission. The worker retrieves only that named message, not an unrelated inbox corpus. Gmail uses raw format; Graph uses the named message's MIME `$value`.
Cryptographic verification uses `go-msgauth/dkim-v0.7.0`, at most 1 MiB of MIME and four signatures, with context-bounded key lookup. A valid DKIM signature is pass; exact From-domain/signer equality establishes only a conservative alignment-pass subset. Body mutation fails verification. Forged or copied Authentication-Results cannot become proof. The stored result includes verifier/version and observation time, not raw MIME or DNS responses. The exact receipt receives the minimal proof; duplicate results cannot replace it with unrelated evidence.
SPF, DMARC and receiving-hop TLS remain unknown absent independent evidence. IMAP/SMTP diagnostics, seed/placement messages outside this authorized warmup context, legacy workers, unavailable MIME, transient key/DNS failures, size/signature bounds and absent authorization remain unknown. Parsed provider Authentication-Results are kept separately as unverified claims. DNS configuration discovery is useful readiness input, not proof of a received message authenticating. Generated prose cannot claim a provider/client check actually passed.
## Unsubscribe and negative feedback
The existing signed unsubscribe URL provides scanner-safe GET and a context-independent POST that durably suppresses future matching traffic. HTTPS messages emit `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`; the visible body link and suppression checks remain. A connected transport's DKIM signer must cover both headers for full RFC 8058 signing compliance. Warmbly cannot assert that arbitrary external SMTP/API signers covered them without inspecting a genuine delivered message. The scoped diagnostic DKIM verifier does not establish that broader signing requirement.
Hard bounce/complaint/suppression and authentication failures remain negative evidence. Spam placement can slow sending but is not alone a quarantine reason. First-folder observation, native outcome, Cloud trust and content provenance are distinct measurements, not a synthetic engagement score. Coherent disclosed hypothetical diagnostic conversations preserve immutable subjects, versions, facts and exact-parent closure; they do not invent real customer relationships or actions.
## Upgrade, rollback and portability
1. Back up and preserve released migrations 1 through 265 byte-for-byte. Apply the contiguous additive 266 through 272 sequence; no new environment variable is required.
2. Pause dispatch during a mixed control-plane rollout. Upgrade every backend/result consumer, including Cloud replicas, before relying on generation stop, exact-parent resolution, current participation or correlated managed consent.
3. Drain/reconcile previously accepted work, then upgrade execution workers. Protocol `warmup_send_protocol=2` is required for shared reservations, execution-time action authority and cryptographic diagnostics. Protocol 1 supports only its previous lineage capability. Old JSON and Avro payloads remain readable; old capability is not stronger cancellation/admission support. Use matching Kafka-tagged binaries where applicable.
4. Recheck holds, actual configured provider, worker liveness, schedules, directional participation, shared/rolling limits and Cloud-side settings before resuming. New mailboxes default off; existing NULL participation stays legacy rather than silently relabeling historical consent. No migration rewrites legacy AI provenance as reviewed. Legacy NULL-provenance sources do not count toward new versioned selection/readiness; existing exact-parent ambiguity stays closed.
Organization export retains participation, ceilings, restrictive task/account evidence and audited repair history. Executor worker/nonce/start/result handles are reset on import. Exact provider-message diagnostic authorization and nonces are instance-local and excluded; minimal first-observation proof is portable historical evidence, never a reusable execution grant. See [workspace moves](/guides/workspace-export-import/).
The source-instance reconciliation outbox is excluded from organization archives so an import cannot replay customer notifications. Drain pending effects before moving; whole-instance backups retain the queue. Delivered outbox rows expire after seven days; pending rows remain until delivery or organization/task deletion. They contain event/notification metadata, not raw MIME, provider responses or credentials. Repair history lasts with the organization and must not contain sensitive message content.
Rollback refuses unresolved reservations, explicit participation state, diagnostic authorization/proof, pending outbox effects and repair history instead of discarding them. Earlier component migrations also refuse restrictive dispatch or managed consent history. Restore or complete a reviewed reconciliation; do not delete negative evidence simply to make downgrade succeed. Legacy deployments remain readable, but do not resume a mixed-old executor fleet assuming new guarantees.
## Verification and limits
R1 stop/consent, R2 shared budgets, R3 recovery and transactional hooks, R5 bounded actual-message DKIM, R7 disclosed coherent content, and R11 calendar/pacing controls have separate deterministic and live PostgreSQL fixtures. Upgrade fixtures start at the released 265 schema and preserve legacy IDs/state. Mixed campaign/warmup/placement/manual contenders prove shared capacity and unknown reservation retention; worker fixtures exercise replay, envelope binding, current consent and old JSON/Avro readability. Provider MIME fixtures test mutation, forged headers, timeout and bounds without provider access.
These checks establish local engineering behavior, not production efficacy, provider consent, real delivery, all-provider authentication, signed RFC 8058 headers or exactly-once external notification delivery. They perform no live sends, browser proof or production reads.
@@ -44,7 +44,7 @@ Vetted static scenarios use a new stable UUID namespace; historical static sourc
Live continuation binds the exact task, token, recipient receipt and received Message-ID to the provider-reported sent Message-ID. It preserves subject, immutable source/version, ordered References and the original maximum of one opening plus the requested one to five replies, including the final closure. One parent has at most one successor. An unrelated mailbox pair, an expired/retired token or unversioned legacy lineage cannot substitute for that parent. Trusted configured display names, including Unicode and punctuation, remain intact in plaintext signatures and MIME headers; CR/LF/NUL are rejected.
This application-owned correspondence proof is not cryptographic message authentication. Copied `Authentication-Results` remains unverified metadata. There is no bounded authorized raw-message DKIM verification path or trusted receiver authentication attestation in this implementation; SPF receiving IP, delivered alignment/DMARC and delivery TLS are not available and stay unknown. DNS configuration and diagnostic prose cannot turn any of them into a pass. Received text and model output do not authorize recipients, permissions or actions.
This application-owned correspondence proof is not itself cryptographic authentication. Updated protocol-2 Gmail/Graph workers can additionally retrieve one exact authorized warmup diagnostic's bounded MIME and verify DKIM cryptographically. IMAP, legacy workers, unrelated seed/placement contexts, unavailable MIME and transient key lookup remain unknown. Copied `Authentication-Results` remains unverified metadata; SPF, DMARC and receiving-hop TLS remain unknown absent independent evidence. See [actual-message authentication and limits](/development/send-safety/#actual-message-authentication). DNS discovery and prose cannot establish pass. Received text and model output do not authorize recipients, permissions or actions.
The vetted fallback bank includes eight small causal scenarios: the original fictional clock and sample-count examples, hypothetical document review, summary correction, fixed-offset date/time continuity, plaintext label review, conditional wording and a Hungarian summary. Each preserves its own facts, alternating sender identities and explicit closure. The Hungarian body retains the same English subject-prefix and per-turn diagnostic disclosure; it does not change language controls or translate existing scenarios. Existing scenario IDs and text are immutable; a revised scenario needs a new versioned ID.
@@ -54,12 +54,12 @@ These examples review hypothetical wording, not customer documents, relationship
Apply the additive content-provenance migration before starting the new backend. Deploy every backend instance before relying on `generation_enabled=false`; mixed old backends do not honor the new stop or resolve the new static scenario namespace. Pause warmup dispatch through existing controls during a mixed-version rollout, and resume only after all control-plane replicas understand exact-parent resolution. Deploy the matching admin frontend after the backend. Keep provider credentials while existing jobs drain. No migration replaces saved models, existing job IDs, source IDs or customer credentials, and no live efficacy claim follows from local tests.
For the integrated release, preserve released migrations through 265 and apply additive migrations 266 through 271 in order. Upgrade all result consumers and control-plane replicas, then execution workers. New warmup dispatch requires an active worker reporting `warmup_send_protocol=1`; absent capability is not support. Duplicate commands replay a durable terminal result rather than executing another native send. An unresolved native outcome holds the mailbox until reconciliation, not a timed blind reset. Known legacy throttle codes produce a conservative mailbox/provider cooldown without fabricated HTTP/SMTP evidence. Authentication, permanent and conflict holds need confirmed repair/operator evidence, not elapsed time or an unrelated success.
For the integrated release, preserve released migrations through 265 and apply additive migrations 266 through 272 in order. Upgrade all result consumers and control-plane replicas, then execution workers. New shared dispatch requires an active worker reporting `warmup_send_protocol=2`; protocol 1 remains only its earlier lineage capability. Duplicate commands replay a durable terminal result rather than executing another native send. An unresolved native outcome holds the mailbox until reconciliation, not a timed blind reset. Known legacy throttle codes produce a conservative mailbox/provider cooldown without fabricated HTTP/SMTP evidence. Authentication, permanent and conflict holds need confirmed repair/operator evidence, not elapsed time or an unrelated success.
### Resolving a repair hold
There is no automatic hold reset or end-user override. Pause dispatch while an instance operator investigates the exact stored organization, mailbox, configured provider, held task and reason. An unknown send must first receive a correlated definite provider result; do not change its accounting or call it failed merely to release traffic. For an authentication hold, complete the normal authenticated credential repair and independently confirm the provider now accepts it. For permanent/configuration or conflicting results, reconcile that specific provider evidence and accounting first. Record the operator, time and evidence reference in the incident audit record without credentials or message bodies.
Only then may an instance operator clear that single repair hold in a reviewed database transaction. The update must match the organization/mailbox/provider, the previously inspected held task and reason (`authentication`, `permanent` or `conflict`), require an active mailbox and refuse if any task for that mailbox still has `send_result_state='unknown'`. Preserve cooldown timestamps, source evidence, task results and all separate reputation/consent restrictions. Re-read admission before resuming. A changed task/reason, pending unknown result, failed repair or absent evidence leaves the hold in place. On hosted Warmbly, ask the platform operator to perform this review; reconnecting, sleeping or sending through another identity is not a quota/hold bypass.
Only then use the existing organization-scoped mailbox PATCH with `send_recovery_resolution`, the exact inspected `held_task_id` and `held_reason`, and typed repair/provider evidence. A newer successful sync is required for authentication repair; operator provider confirmation requires a same-mailbox definitive evidence task and reference. The guarded transaction refuses inactive/mismatched authority and any unknown task, records durable history, and preserves cooldowns and separate restrictions. See [the resolution contract](/development/send-safety/#evidence-based-repair-holds). Re-read admission before resuming. A changed hold or missing evidence stays restrictive. On hosted Warmbly, ask the platform operator to review; reconnecting, sleeping or another identity is not a quota/hold bypass.
Old executors remain compatible with old commands but cannot retroactively cancel work already accepted before upgrade. Pause and drain/reconcile that work before resuming new dispatch; no stronger mixed-old-worker guarantee is claimed. Correlated managed OAuth requires upgrading every Cloud control-plane replica before linked instances, as described in [Warmbly Cloud](/guides/warmbly-cloud/#managed-sign-in-recovery-and-consent). No new environment variable is required. Rollback refuses unresolved dispatches, successors, recovery restrictions or durable managed consent history rather than deleting negative state.
+4
View File
@@ -5,6 +5,10 @@ description: "Connect sender accounts, providers, and how they are assigned to w
A mailbox is a sender account you connect to Warmbly. Every warmup message and campaign email goes out through one you own.
### Diagnostic participation and stopping
New mailboxes default to diagnostic participation **off**. The organization-authorized mailbox PATCH exposes `test_mode` (`off`, `diagnostic`, `legacy`), separate `test_send_enabled` and `test_receive_enabled` flags, and optional positive `shared_daily_limit`/`rolling_recipient_limit`. These are API controls, not new dashboard switches. Diagnostic mode sends disclosed tests but does not manufacture reads, stars, importance or spam rescue. Existing upgraded mailboxes with NULL participation retain legacy behavior until explicitly changed. Set `test_mode=off` to stop future diagnostic send/receive admission; current protocol-2 workers also recheck queued sends/actions. Already accepted old-worker commands or a provider send underway cannot be recalled. A local Cloud alias unlink stops local authority, not an independently cloud-owned mailbox or provider grant: pause/remove that mailbox on Cloud separately. See [send safety, repair evidence and rollout](/development/send-safety/).
## Connecting an account
Open **Accounts** and choose **Add account**. Google and Microsoft each have one entry, and choosing it asks how to connect:
@@ -5,6 +5,8 @@ description: "Move a whole workspace between Warmbly instances, including mailbo
A workspace archive is a single file holding everything one workspace owns. It exists so you can move between instances: a self-hosted install to the cloud, the cloud back to self-hosted, or one self-host to another.
Diagnostic participation, shared/rolling limits, restrictive send evidence and audited repair-resolution history travel with their registered mailbox/task/organization data. Source-worker executor handles reset; exact provider-message DKIM authorization/nonces are excluded. Portable minimal receipt proof remains historical evidence, never an execution grant. The reconciliation webhook/notification outbox is source-instance-local and excluded to prevent replay on import; drain pending effects before moving. Whole-instance database backups retain it. See [send-safety retention and rollback](/development/send-safety/#upgrade-rollback-and-portability).
<Callout title="Moving a whole self-hosted instance is a different tool">
This page moves **one workspace** between two running instances, re-sealing its secrets for the destination's keys. To move an entire self-hosted install (every workspace, its users, its platform admins) use `warmblyctl backup` and `warmblyctl restore`, which carry the database, the blob root and the encryption keys as one bundle. See [data control](/development/data-control/#backups). The two are not interchangeable: a bundle cannot be applied to a single workspace, and this archive cannot restore an instance.
</Callout>
+1
View File
@@ -137,6 +137,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/emersion/go-msgauth v0.7.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
+2
View File
@@ -271,6 +271,8 @@ github.com/emersion/go-imap/v2 v2.0.0-beta.5 h1:H3858DNmBuXyMK1++YrQIRdpKE1MwBc+
github.com/emersion/go-imap/v2 v2.0.0-beta.5/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
@@ -0,0 +1,28 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
func (h *Handler) InternalDiagnosticAuth(c *gin.Context) {
var request models.DiagnosticAuthRequest
if c.ShouldBindJSON(&request) != nil {
c.Status(http.StatusBadRequest)
return
}
authority, ok := h.WarmupDispatch.(repository.DiagnosticAuthAuthority)
if !ok {
c.Status(http.StatusServiceUnavailable)
return
}
grant, err := authority.DiagnosticAuth(c.Request.Context(), request)
if err != nil {
c.Status(http.StatusForbidden)
return
}
c.JSON(http.StatusOK, grant)
}
@@ -0,0 +1,31 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/repository"
"net/http"
)
func (h *Handler) InternalWarmupActions(c *gin.Context) {
var req struct {
MailboxID uuid.UUID `json:"mailbox_id"`
WorkerID uuid.UUID `json:"worker_id"`
Actions []string `json:"actions"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.MailboxID == uuid.Nil || req.WorkerID == uuid.Nil || len(req.Actions) > 16 {
c.Status(http.StatusBadRequest)
return
}
r, ok := h.WarmupDispatch.(repository.WarmupActionAdmission)
if !ok {
c.Status(http.StatusServiceUnavailable)
return
}
actions, err := r.PermittedWarmupActions(c.Request.Context(), req.MailboxID, req.WorkerID, req.Actions)
if err != nil {
c.Status(http.StatusServiceUnavailable)
return
}
c.JSON(http.StatusOK, gin.H{"actions": actions})
}
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
func (h *Handler) InternalWarmupDispatch(c *gin.Context) {
@@ -28,6 +29,55 @@ func (h *Handler) InternalWarmupDispatch(c *gin.Context) {
return
}
ctx := c.Request.Context()
if admission, ok := h.WarmupDispatch.(repository.OutboundAdmissionRepository); ok {
state, err := admission.InspectOutbound(ctx, req.TaskID, req.MailboxID, req.WorkerID)
if err != nil {
c.Status(http.StatusServiceUnavailable)
return
}
if state.State != "legacy" {
if req.Result != nil {
if req.Start || req.Result.TaskID != req.TaskID {
c.Status(http.StatusBadRequest)
return
}
if err = admission.FinishOutbound(ctx, req.TaskID, req.MailboxID, req.WorkerID, *req.Result); err != nil {
c.Status(http.StatusConflict)
return
}
c.Status(http.StatusNoContent)
return
}
if req.Start && state.State == "authorized" {
if req.Nonce == uuid.Nil {
c.Status(http.StatusBadRequest)
return
}
validator, ok := h.TasksService.(interface {
ValidateOutboundExecution(context.Context, uuid.UUID) error
})
if !ok {
c.Status(http.StatusServiceUnavailable)
return
}
if err = validator.ValidateOutboundExecution(ctx, req.TaskID); err != nil {
if err = admission.CancelOutbound(ctx, req.TaskID, req.MailboxID, req.WorkerID, req.Nonce); err != nil {
c.Status(http.StatusServiceUnavailable)
return
}
c.JSON(http.StatusOK, gin.H{"state": "denied"})
return
}
state, err = admission.BeginOutbound(ctx, req.TaskID, req.MailboxID, req.WorkerID, req.Nonce)
if err != nil {
c.Status(http.StatusServiceUnavailable)
return
}
}
c.JSON(http.StatusOK, state)
return
}
}
if req.Result != nil {
if req.Start || req.Result.TaskID != req.TaskID {
c.Status(http.StatusBadRequest)
+2
View File
@@ -209,6 +209,8 @@ func Run(
// Worker runtime config.
node.GET("/worker/config", h.InternalWorkerConfig)
node.POST("/worker/warmup-dispatch", h.InternalWarmupDispatch)
node.POST("/worker/warmup-actions", h.InternalWarmupActions)
node.POST("/worker/diagnostic-auth", h.InternalDiagnosticAuth)
}
// The role-agnostic node heartbeat. A node still sending INTERNAL_API_TOKEN
+4 -1
View File
@@ -46,7 +46,10 @@ func (s *service) emit(ctx context.Context, orgID uuid.UUID, eventType models.We
if s.dispatcher == nil || orgID == uuid.Nil {
return
}
_, _ = s.dispatcher.Dispatch(ctx, orgID, eventType, data)
if repository.QueueSendResultEffect(ctx, "webhook:"+string(eventType), repository.SendResultEffect{Kind: "webhook", OrganizationID: orgID, EventType: eventType, Data: data}) {
return
}
repository.AfterSendResultCommit(ctx, func(ctx context.Context) { _, _ = s.dispatcher.Dispatch(ctx, orgID, eventType, data) })
}
// EmitCampaignEvent dispatches a campaign event (e.g. from a sequence "notify"
@@ -0,0 +1,40 @@
package advanced
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
func (s *service) DeliverSendResultEffects(ctx context.Context) error {
r, ok := s.repo.(repository.SendResultEffectOutbox)
if !ok {
return errors.New("send result outbox unavailable")
}
return r.DeliverSendResultEffects(ctx, func(ctx context.Context, e repository.SendResultEffect) error {
switch e.Kind {
case "webhook":
if s.dispatcher == nil {
return errors.New("send result dispatcher unavailable")
}
_, err := s.dispatcher.Dispatch(ctx, e.OrganizationID, e.EventType, e.Data)
return err
case "notification":
if s.notifier == nil {
return errors.New("send result notifier unavailable")
}
durable, ok := s.notifier.(interface {
NotifyDurable(context.Context, uuid.UUID, *uuid.UUID, models.NotificationCategory, string, string, string, map[string]any, string) error
})
if !ok {
return errors.New("durable notifier unavailable")
}
return durable.NotifyDurable(ctx, e.UserID, &e.OrganizationID, e.Category, e.Title, e.Body, e.Link, e.Data, e.ID.String())
default:
return errors.New("unknown send result effect")
}
})
}
+26 -9
View File
@@ -1578,7 +1578,7 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
if settings.ReplyIntent.AutoSuppressOnUnsubWord &&
replyOptOutEligible(verdict, referencesCampaignThread, contactID != nil, buildReplyHeaders(msg)) &&
replyclassify.IsOptOut(msg.Subject, firstNonEmpty(msg.BodyText, msg.Snippet)) {
_ = s.repo.UpsertSuppressedRecipient(ctx, &models.SuppressedRecipient{
if err := s.repo.UpsertSuppressedRecipient(ctx, &models.SuppressedRecipient{
OrganizationID: *account.OrganizationID,
Email: sender,
Kind: models.SuppressionKindEmail,
@@ -1588,7 +1588,9 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
Metadata: map[string]interface{}{
"via": "reply",
},
})
}); err != nil {
return toErrx(err)
}
if err := s.contactRepo.SetSubscribedByEmail(ctx, *account.OrganizationID, sender, false); err != nil {
log.Warn().Err(err).Msg("reply opt-out: could not clear the contact's subscription flag")
}
@@ -1953,18 +1955,22 @@ func (s *service) IngestDeliverabilityEvent(ctx context.Context, organizationID
}
if shouldSuppress {
_ = s.repo.UpsertSuppressedRecipient(ctx, &models.SuppressedRecipient{
if err := s.repo.UpsertSuppressedRecipient(ctx, &models.SuppressedRecipient{
OrganizationID: organizationID,
Email: req.RecipientEmail,
Reason: fmt.Sprintf("%s: %s", eventType, req.Reason),
Source: eventType,
CampaignID: req.CampaignID,
Metadata: req.Metadata,
})
}); err != nil {
return toErrx(err)
}
}
if req.CampaignID != nil && req.ContactID != nil {
_ = s.repo.MarkVariantEvent(ctx, *req.CampaignID, *req.ContactID, string(eventType))
if err := s.repo.MarkVariantEvent(ctx, *req.CampaignID, *req.ContactID, string(eventType)); err != nil {
return toErrx(err)
}
}
// Record bounces + complaints in campaign progress so analytics and the
@@ -1979,7 +1985,9 @@ func (s *service) IngestDeliverabilityEvent(ctx context.Context, organizationID
// A bounce that was not about the address does not drop the
// lead: the step is offered again once the mailbox recovers.
if !addressFine {
_ = s.campaignProgressRepo.RecordEmailBounced(ctx, *req.CampaignID, *req.ContactID, *campaignTask.SequenceID)
if err := s.campaignProgressRepo.RecordEmailBounced(ctx, *req.CampaignID, *req.ContactID, *campaignTask.SequenceID); err != nil {
return toErrx(err)
}
}
// Only a bounce that names the recipient is evidence against
// the address; a full mailbox or a policy block is not.
@@ -1994,7 +2002,9 @@ func (s *service) IngestDeliverabilityEvent(ctx context.Context, organizationID
s.evidence.RecordEvidence(ctx, *req.ContactID, models.Step(campaignTask.CampaignID, campaignTask.SequenceID), kind, req.IdempotencyKey, req.Reason)
}
case models.DeliverabilityEventComplaint:
_ = s.campaignProgressRepo.RecordEmailComplained(ctx, *req.CampaignID, *req.ContactID, *campaignTask.SequenceID)
if err := s.campaignProgressRepo.RecordEmailComplained(ctx, *req.CampaignID, *req.ContactID, *campaignTask.SequenceID); err != nil {
return toErrx(err)
}
}
}
}
@@ -2010,7 +2020,9 @@ func (s *service) IngestDeliverabilityEvent(ctx context.Context, organizationID
(eventType == models.DeliverabilityEventBounce || eventType == models.DeliverabilityEventComplaint) {
task, tErr := s.taskRepo.GetTask(ctx, *req.TaskID)
if tErr == nil && task != nil {
_, _ = s.warmupService.ApplySpamReport(ctx, uuid.Nil, task.EmailAccountID, req.IdempotencyKey, string(eventType))
if _, err := s.warmupService.ApplySpamReport(ctx, task.EmailAccountID, task.EmailAccountID, req.IdempotencyKey, string(eventType)); err != nil {
return err
}
}
}
@@ -2059,7 +2071,12 @@ func (s *service) IngestDeliverabilityEvent(ctx context.Context, organizationID
title = "Spam complaint: " + req.RecipientEmail
}
org := organizationID
s.notify(uid, &org, cat, title, req.Reason, "/app/deliverability", map[string]any{"provider": provider})
if s.notifier != nil && repository.QueueSendResultEffect(ctx, "notification:"+uid.String()+":"+string(cat), repository.SendResultEffect{Kind: "notification", OrganizationID: org, UserID: uid, Category: cat, Title: title, Body: req.Reason, Link: "/app/deliverability", Data: map[string]any{"provider": provider}}) {
return nil
}
repository.AfterSendResultCommit(ctx, func(context.Context) {
s.notify(uid, &org, cat, title, req.Reason, "/app/deliverability", map[string]any{"provider": provider})
})
}
}
}
+2 -2
View File
@@ -447,8 +447,8 @@ func (s *service) ListMailboxes(ctx context.Context, orgID uuid.UUID) ([]models.
row.StandingObservedAt = e.StandingObservedAt
row.Cloud = cloudByRemote[e.RemoteID]
// The recorded standing covers a mailbox the cloud holds out of its pool.
if row.Cloud != nil && row.Cloud.Health == nil && e.Standing != nil {
row.Cloud.Health = e.Standing
if row.Cloud != nil {
row.Cloud.Health = e.EffectiveStanding(time.Now())
}
}
rows = append(rows, row)
@@ -0,0 +1,135 @@
package jobs
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/app/advanced"
"github.com/warmbly/warmbly/internal/app/notification"
"github.com/warmbly/warmbly/internal/app/warmup"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type resultDispatcher struct{ calls atomic.Int32 }
func (d *resultDispatcher) Dispatch(context.Context, uuid.UUID, models.WebhookEventType, any) (uuid.UUID, error) {
d.calls.Add(1)
return uuid.New(), nil
}
func TestLiveAdvancedBounceReconciliationRollbackAndReplayWithOneConnection(t *testing.T) {
h := liveDB(t)
f := newSendResultFixture(t, h)
s := liveJobsService(h)
task := f.stampSend(t, s)
cfg := h.Pool.Config()
cfg.MaxConns = 1
pool, err := pgxpool.NewWithConfig(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
one := &db.DB{Pool: pool}
r := repository.NewTaskRepository(pool).(repository.SendResultRecovery)
a := repository.NewAdvancedOutreachRepository(pool)
v, err := a.CreateABVariant(t.Context(), f.campaign, &models.CreateCampaignABVariantRequest{Name: "Variant", SequenceID: &f.step, Subject: "Diagnostic", BodyPlain: "Text"})
if err != nil {
t.Fatal(err)
}
if err = a.AssignVariant(t.Context(), f.campaign, f.contact, v.ID); err != nil {
t.Fatal(err)
}
svc := advanced.NewService(a, repository.NewCampaignRepostory(one), repository.NewEmailRepostory(one, nil), repository.NewTaskRepository(pool), repository.NewContactRepostory(one), repository.NewCampaignProgressRepository(pool), nil, nil, nil, nil, warmup.NewService(repository.NewWarmupRepository(pool)))
dispatcher := &resultDispatcher{}
svc.(interface {
WireDispatcher(advanced.EventDispatcher)
}).WireDispatcher(dispatcher)
svc.WireNotifier(notification.NewService(repository.NewNotificationRepository(pool), nil))
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
request := &models.IngestDeliverabilityEventRequest{CampaignID: &f.campaign, TaskID: &task, ContactID: &f.contact, EventType: models.DeliverabilityEventBounce, Provider: "smtp_reject", RecipientEmail: "lead-" + f.contact.String()[:8] + "@test.local", Reason: "unknown recipient", IdempotencyKey: "refusal-" + task.String()}
result := models.SendEmailResult{TaskID: task, Error: &models.EmailSendError{Code: "RECIPIENT_REJECTED"}}
fault := errors.New("fault after advanced DB hooks")
callback := func(ctx context.Context) error {
if err := svc.IngestDeliverabilityEvent(ctx, f.org, request); err != nil {
return err
}
return nil
}
if err = r.ApplySendResult(ctx, result, func(ctx context.Context) error {
if err := callback(ctx); err != nil {
return err
}
if dispatcher.calls.Load() != 0 {
return errors.New("external effects escaped transaction")
}
return fault
}); !errors.Is(err, fault) {
t.Fatalf("callback: %v", err)
}
var events, suppressed int
var bounced *time.Time
if err = h.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM deliverability_events WHERE organization_id=$1`, f.org).Scan(&events); err != nil {
t.Fatal(err)
}
if err = h.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM suppressed_recipients WHERE organization_id=$1`, f.org).Scan(&suppressed); err != nil {
t.Fatal(err)
}
if err = h.Pool.QueryRow(ctx, `SELECT bounced_at FROM campaign_ab_assignments WHERE campaign_id=$1 AND contact_id=$2`, f.campaign, f.contact).Scan(&bounced); err != nil {
t.Fatal(err)
}
if events != 0 || suppressed != 0 || bounced != nil || dispatcher.calls.Load() != 0 {
t.Fatal("advanced hooks escaped rollback", events, suppressed, bounced, dispatcher.calls.Load())
}
if err = r.ApplySendResult(ctx, result, callback); err != nil {
t.Fatal(err)
}
if err = r.ApplySendResult(ctx, result, callback); err != nil {
t.Fatal(err)
}
if err = h.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM deliverability_events WHERE organization_id=$1`, f.org).Scan(&events); err != nil {
t.Fatal(err)
}
if err = h.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM suppressed_recipients WHERE organization_id=$1`, f.org).Scan(&suppressed); err != nil {
t.Fatal(err)
}
if events != 1 || suppressed != 1 || dispatcher.calls.Load() != 0 {
t.Fatal("advanced replay duplicated durable or external effects", events, suppressed, dispatcher.calls.Load())
}
var queued int
if err = h.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM send_result_effects WHERE task_id=$1 AND delivered_at IS NULL`, task).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 3 {
t.Fatal("replay duplicated durable outbox", queued)
}
if err = svc.(interface{ DeliverSendResultEffects(context.Context) error }).DeliverSendResultEffects(ctx); err != nil {
t.Fatal(err)
}
if err = svc.(interface{ DeliverSendResultEffects(context.Context) error }).DeliverSendResultEffects(ctx); err != nil {
t.Fatal(err)
}
if dispatcher.calls.Load() != 2 {
t.Fatal("outbox did not deliver committed effects once", dispatcher.calls.Load())
}
if _, err = h.Pool.Exec(ctx, `UPDATE send_result_effects SET delivered_at=NULL,locked_until=NULL WHERE task_id=$1 AND kind='notification'`, task); err != nil {
t.Fatal(err)
}
if err = svc.(interface{ DeliverSendResultEffects(context.Context) error }).DeliverSendResultEffects(ctx); err != nil {
t.Fatal(err)
}
var notices int
if err = h.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM notifications WHERE organization_id=$1 AND category=$2`, f.org, models.NotifHealthBounce).Scan(&notices); err != nil {
t.Fatal(err)
}
if notices != 1 {
t.Fatal("notification outbox restart duplicated or lost feed", notices)
}
}
+1
View File
@@ -591,6 +591,7 @@ func (s *JobsService) performWarmupActions(ctx context.Context, e *models.JobEve
workerID = recipient.WorkerID
recipientTZ = recipient.ClockTimezone()
base.Placement, base.TargetFolder = recipient.WarmupFiling()
actions = recipient.PermittedWarmupActions(actions)
}
// A mailbox whose owner wants warmup left in the inbox is not foldered.
// Spam-rescue still runs: that is the reputation signal warmup exists for,
+39 -24
View File
@@ -266,7 +266,13 @@ func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Tas
// A refused copy that the retry will leave off costs the lead no attempt;
// one that could not be recorded is counted, so it cannot loop forever.
copyExcluded := copyRefused && s.recordRefusedCopy(ctx, task, ct, campaign, refused, reason)
copyExcluded := false
if copyRefused {
copyExcluded, err = s.recordRefusedCopy(ctx, task, ct, campaign, refused, reason)
if err != nil {
return err
}
}
attempts, exhausted, rolledBack := 0, false, false
if ct.ContactID != nil && ct.SequenceID != nil && s.CampaignProgressRepo != nil {
@@ -305,7 +311,11 @@ func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Tas
// suppression, guardrails, warmup health, webhooks) and the lead is
// dropped as bounced instead of being offered again.
if rolledBack && !copyRefused && code == string(errx.MailErrorCodeRecipientRejected) {
if s.recordSynchronousBounce(ctx, task, ct, campaign, recipient, reason) {
recorded, err := s.recordSynchronousBounce(ctx, task, ct, campaign, recipient, reason)
if err != nil {
return err
}
if recorded {
s.logCampaignSendFailure(ctx, campaignID, ct, recipient, reason, code, attempts, false, false, false)
s.publishCampaignUpdated(ctx, campaign, campaignID, "")
log.Info().Str("task_id", task.ID.String()).Str("campaign_id", campaignID.String()).Msg("campaign send refused at RCPT; recorded as bounce")
@@ -346,9 +356,9 @@ func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Tas
// pipeline as a bounce. Returns false when the bounce could not be attributed
// (no org, no recipient), in which case the caller falls back to the retry
// path.
func (s *JobsService) recordSynchronousBounce(ctx context.Context, task *repository.Task, ct *repository.CampaignTask, campaign *models.Campaign, recipient, reason string) bool {
func (s *JobsService) recordSynchronousBounce(ctx context.Context, task *repository.Task, ct *repository.CampaignTask, campaign *models.Campaign, recipient, reason string) (bool, error) {
if s.AdvancedService == nil || campaign == nil || campaign.OrganizationID == nil || recipient == "" {
return false
return false, nil
}
taskID := task.ID
req := &models.IngestDeliverabilityEventRequest{
@@ -363,18 +373,18 @@ func (s *JobsService) recordSynchronousBounce(ctx context.Context, task *reposit
}
if xerr := s.AdvancedService.IngestDeliverabilityEvent(ctx, *campaign.OrganizationID, req); xerr != nil {
log.Warn().Str("task_id", taskID.String()).Str("error", xerr.Message).Msg("could not record refused recipient as a bounce")
return false
return false, xerr
}
return true
return true, nil
}
// recordRefusedCopy feeds a copied address the server refused at RCPT into
// the bounce pipeline under its own name, and reports whether the next send
// is sure to leave it off: a lead's copy through its bounced mark, a
// campaign-wide one through the recorded bounce the send path reads.
func (s *JobsService) recordRefusedCopy(ctx context.Context, task *repository.Task, ct *repository.CampaignTask, campaign *models.Campaign, refused, reason string) bool {
func (s *JobsService) recordRefusedCopy(ctx context.Context, task *repository.Task, ct *repository.CampaignTask, campaign *models.Campaign, refused, reason string) (bool, error) {
if campaign == nil || campaign.OrganizationID == nil || ct.CampaignID == nil || ct.ContactID == nil {
return false
return false, nil
}
address := strings.ToLower(mailhdr.Bare(refused))
var owner *uuid.UUID
@@ -382,11 +392,12 @@ func (s *JobsService) recordRefusedCopy(ctx context.Context, task *repository.Ta
id, err := s.CampaignProgressRepo.MarkLeadCCBounced(ctx, *ct.CampaignID, *ct.ContactID, address)
if err != nil {
log.Warn().Err(err).Str("task_id", task.ID.String()).Msg("could not mark a refused copy bounced")
return false, err
}
owner = id
}
if s.AdvancedService == nil {
return owner != nil
return owner != nil, nil
}
taskID := task.ID
req := &models.IngestDeliverabilityEventRequest{
@@ -401,9 +412,9 @@ func (s *JobsService) recordRefusedCopy(ctx context.Context, task *repository.Ta
}
if xerr := s.AdvancedService.IngestDeliverabilityEvent(ctx, *campaign.OrganizationID, req); xerr != nil {
log.Warn().Str("task_id", taskID.String()).Str("error", xerr.Message).Msg("could not record a refused copy as a bounce")
return owner != nil
return false, xerr
}
return true
return true, nil
}
// refusedRecipient is the address the server refused, when the worker knew.
@@ -420,12 +431,14 @@ func (s *JobsService) publishCampaignUpdated(ctx context.Context, campaign *mode
if s.StreamingPublisher == nil || campaign == nil {
return
}
s.StreamingPublisher.PublishCampaignEvent(ctx, &pubsub.CampaignEvent{
BaseEvent: pubsub.BaseEvent{EventType: pubsub.EventCampaignUpdated, UserID: campaign.UserID},
OrgID: campaignOrgID(campaign),
CampaignID: campaignID.String(),
Name: campaign.Name,
Status: status,
repository.AfterSendResultCommit(ctx, func(ctx context.Context) {
s.StreamingPublisher.PublishCampaignEvent(ctx, &pubsub.CampaignEvent{
BaseEvent: pubsub.BaseEvent{EventType: pubsub.EventCampaignUpdated, UserID: campaign.UserID},
OrgID: campaignOrgID(campaign),
CampaignID: campaignID.String(),
Name: campaign.Name,
Status: status,
})
})
}
@@ -487,13 +500,15 @@ func (s *JobsService) notifyUserSendFailed(ctx context.Context, task *repository
if s.StreamingPublisher == nil || s.EmailRepository == nil {
return
}
account, xerr := s.EmailRepository.GetByID(ctx, task.EmailAccountID)
if xerr != nil || account == nil {
return
}
s.StreamingPublisher.PublishEmailError(ctx, account.UserID, account.ID, task.ID,
"Email could not be sent",
fmt.Sprintf("%s could not send your email: %s", account.Email, reason))
accountID, taskID := task.EmailAccountID, task.ID
repository.AfterSendResultCommit(ctx, func(ctx context.Context) {
account, xerr := s.EmailRepository.GetByID(ctx, accountID)
if xerr != nil || account == nil {
return
}
s.StreamingPublisher.PublishEmailError(ctx, account.UserID, account.ID, taskID,
"Email could not be sent", fmt.Sprintf("%s could not send your email: %s", account.Email, reason))
})
}
// sendFailureReason picks the most useful human-readable reason and the
@@ -0,0 +1,29 @@
package jobs
import (
"context"
"time"
"github.com/rs/zerolog/log"
)
func (s *JobsService) runSendResultEffects(ctx context.Context) {
outbox, ok := s.AdvancedService.(interface{ DeliverSendResultEffects(context.Context) error })
if !ok {
return
}
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
pass, cancel := context.WithTimeout(ctx, 30*time.Second)
if err := outbox.DeliverSendResultEffects(pass); err != nil {
log.Warn().Msg("send result outbox delivery deferred")
}
cancel()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
+1
View File
@@ -125,6 +125,7 @@ func (s *JobsService) Start(ctx context.Context) {
// on a schedule instead. Free: no model call, just arithmetic over stored
// timestamps.
go s.sweepFollowUps(ctx)
go s.runSendResultEffects(ctx)
if err := s.Bus.Subscribe(ctx, []string{kafka.TopicWorkerEvents}, "consumer-group", s.receive); err != nil {
log.Error().Err(err).Msg("consumer: worker-events subscription ended")
@@ -49,6 +49,13 @@ func (s *JobsService) drainDueEngagements(ctx context.Context) error {
// Mailbox now unassigned — drop (best-effort low-stakes engagement).
continue
}
if account.Status != "active" {
continue
}
action.Actions = account.PermittedWarmupActions(action.Actions)
if len(action.Actions) == 0 {
continue
}
action.DelaySeconds = 0 // dwell already elapsed; run immediately
s.Publisher.PublishWarmupAction(cctx, *account.WorkerID, &action)
+2 -2
View File
@@ -473,7 +473,7 @@ func (s *emailService) syncWarmupPoolMembership(ctx context.Context, account *mo
}
}
if !s.canUseWarmupPool(ctx, account) {
if !s.canUseWarmupPool(ctx, account) || (!account.TestSendingAllowed() && !account.TestReceivingAllowed()) {
s.removeFromAllWarmupPools(ctx, account)
return
}
@@ -486,7 +486,7 @@ func (s *emailService) syncWarmupPoolMembership(ctx context.Context, account *mo
}
role := "recipient_only"
if account.Warmup != nil {
if account.Warmup != nil && account.TestSendingAllowed() {
role = "sender_receiver"
}
if xerr := s.warmupService.EnsurePoolMembershipWithRole(ctx, account.ID, s.resolveWarmupPoolType(ctx, account), role); xerr != nil {
@@ -1019,6 +1019,78 @@
"null",
{
"fields": [
{
"default": null,
"name": "failure",
"type": [
"null",
{
"fields": [
{
"default": "",
"name": "provider",
"type": "string"
},
{
"default": "",
"name": "protocol",
"type": "string"
},
{
"default": 0,
"name": "status",
"type": "int"
},
{
"default": "",
"name": "enhanced_status",
"type": "string"
},
{
"default": "",
"name": "cause",
"type": "string"
},
{
"default": "",
"name": "stage",
"type": "string"
},
{
"default": "",
"name": "disposition",
"type": "string"
},
{
"default": "",
"name": "scope",
"type": "string"
},
{
"default": 0,
"name": "observed_at",
"type": {
"logicalType": "timestamp-millis",
"type": "long"
}
},
{
"default": null,
"name": "retry_at",
"type": [
"null",
{
"logicalType": "timestamp-millis",
"type": "long"
}
]
}
],
"name": "warmbly.events.SendFailure",
"type": "record"
}
]
},
{
"default": "",
"name": "code",
+20 -7
View File
@@ -225,19 +225,29 @@ func (s *service) notifyMembers(ctx context.Context, orgID uuid.UUID, perm model
// the flush loop bundles them later (see email.go). Returns whether the Slack
// channel fired, so org fan-outs post to the shared workspace only once.
func (s *service) notifyOne(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, uniboxEmailID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string, suppressSlack bool) bool {
fired, _ := s.notifyOneWithError(ctx, userID, orgID, uniboxEmailID, category, title, body, link, meta, groupKey, suppressSlack)
return fired
}
func (s *service) NotifyDurable(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string) error {
_, err := s.notifyOneWithError(ctx, userID, orgID, nil, category, title, body, link, meta, groupKey, false)
return err
}
func (s *service) notifyOneWithError(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, uniboxEmailID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string, suppressSlack bool) (bool, error) {
if userID == uuid.Nil {
return false
return false, nil
}
prefs, err := s.repo.GetPreferences(ctx, userID)
if err != nil || prefs == nil {
return false
return false, err
}
cat := prefs.CategoryPref(category)
if !cat.Enabled {
return false // category off — no channel fires
return false, nil
}
if !s.canNotifyMessage(ctx, category, uniboxEmailID) {
return false
return false, nil
}
emailOn := cat.Channels.Email && s.email != nil && s.users != nil
@@ -263,10 +273,13 @@ func (s *service) notifyOne(ctx context.Context, userID uuid.UUID, orgID *uuid.U
}
created, cerr := s.repo.Create(ctx, n)
if errors.Is(cerr, repository.ErrNotificationMessageGone) || errors.Is(cerr, repository.ErrNotificationMessageAutomated) {
return false // the message left the unibox first; nothing to announce
return false, nil
}
if cerr != nil {
return false, cerr
}
if cerr == nil && created != nil && created.MessageSeen {
return false // already read where it arrived; the row is the record
return false, nil
}
if cerr == nil && created != nil && cat.Channels.InApp && s.publisher != nil {
s.publisher.PublishNotificationCreated(ctx, userID.String(), created.ID.String(), string(category), title, link)
@@ -301,7 +314,7 @@ func (s *service) notifyOne(ctx context.Context, userID uuid.UUID, orgID *uuid.U
if cat.Channels.Push && s.push != nil && s.deviceTokens != nil && s.pushRedis != nil {
go s.deliverPush(userID, category, pendingPush{Title: title, Body: body, Link: link, MessageID: uniboxEmailID})
}
return slackFired
return slackFired, nil
}
func (s *service) canNotifyMessage(ctx context.Context, category models.NotificationCategory, messageID *uuid.UUID) bool {
+53 -45
View File
@@ -769,7 +769,13 @@ var Tables = []Table{
Scope: `email_account_id IN ` + orgMailboxes + ` AND task_type <> 'placement'`,
Owner: `email_account_id IN ` + orgMailboxes,
// The handle belongs to the source instance's queue.
ResetOnImport: []string{"cloud_task_name"},
ResetOnImport: []string{"cloud_task_name", "send_executor_nonce", "send_executor_worker", "send_executor_started_at", "send_executor_result"},
},
{
Name: "send_recovery_resolutions", Group: models.OrgDataGroupSending,
Scope: scopeOrg,
Owner: scopeOrg,
Note: "Evidence and operator confirmation used to lift a durable mailbox send hold. Unknown sends cannot be resolved through this history and remain restrictive after import.",
},
{
// An AI-group table, but it sits here because task_id points at tasks.
@@ -984,50 +990,52 @@ var Tables = []Table{
// with the reason. Kept as data so the docs page and the coverage test both
// read from one list instead of restating it.
var ExcludedTables = map[string]string{
"warmup_received": "Raw receiving-mailbox observations and provider thread handles are source-instance evidence, not portable send authority. Aggregated warmup placement history travels; imported threads cannot continue without new verified observations.",
"warmup_pending_filings": "Provider filing awaiting acknowledgement on this instance. The destination resyncs mailbox messages.",
"unibox_pending_emails": "Unverified mailbox-sync events awaiting this instance's warmup checks. The destination resyncs provider mail with its own warmup and cloud-link state.",
"organization_encrypted_keys": "The organization's data key, wrapped by the source instance's KMS. The destination cannot unwrap it, and shipping it would put every org secret behind one exported blob.",
"api_idempotency_keys": "A short-lived replay cache for in-flight API requests.",
"realtime_events": "The websocket outbox. Every row is already delivered or expired.",
"integration_oauth_states": "In-flight OAuth handshakes, valid for minutes and bound to the source instance's redirect URL.",
"crm_sync_jobs": "The outbox of pending CRM writes on this instance; the destination's own events feed its outbox.",
"crm_sync_cursors": "Pull checkpoints for this instance; the destination starts its own pull.",
"salesforce_record_links": "Which Salesforce record each contact is, with a cached copy of it. The destination links contacts again by address the first time it syncs or shows them, and reads the record fresh.",
"salesforce_activity_queue": "Activity waiting to be logged in Salesforce, and the recent outcome of what was. What was logged is already in Salesforce; what was waiting belongs to this instance's drain.",
"salesforce_sync_state": "Where this instance's pull loop got to in each Salesforce org, and the API calls it counted today. The destination starts its own cursor when the connection first syncs.",
"oauth_authorization_codes": "Single-use authorization codes, valid for seconds.",
"oauth_developer_blocks": "An operator's decision on the source instance about who may build apps there; the destination's operators decide for theirs.",
"app_directory_listings": "A publication on the source instance's community directory, featured or hidden by its team. Publish again on the destination, where its own team decides.",
"scheduled_deletions": "Instance lifecycle state. Importing a pending deletion would schedule the destination workspace for destruction.",
"dedicated_worker_assignments": "Worker topology, which is a property of the instance rather than the workspace.",
"warmup_spam_moves": "Per-message attribution evidence for warmup mail this instance synced, kept only to decide recent tampering; the destination judges its own.",
"mailbox_owner_activity": "Five-minute buckets of sync-observed owner activity on this instance, read only to attribute recent spam moves.",
"warmup_pools": "Instance-global pool definitions shared by every workspace on the instance.",
"pool_link_codes": "In-flight link handshakes between a self-hosted instance and this cloud, valid for minutes.",
"cli_auth_codes": "In-flight `warmbly auth login` handshakes, valid for minutes. The API key an approval mints does travel, with the api_keys rows.",
"pool_link_instances": "Self-hosted instances linked to this workspace's pool allowance. The token hash only authenticates against this instance, and the enrolled mailboxes are mirrors of mailboxes that live elsewhere.",
"pool_link_mailboxes": "Which mailbox rows are warmup-only mirrors for a linked instance. They follow pool_link_instances, which does not travel.",
"cloud_link": "This instance's own link to Warmbly Cloud: an instance property, not workspace data, and its token would be wrong on any other instance.",
"cloud_link_mailboxes": "Which local mailboxes Warmbly Cloud warms for this instance. The enrollment belongs to the link, which does not travel.",
"warmup_conversations": "The instance's shared warmup content library, not workspace data.",
"copy_judgments": "A cache of copy judgments keyed by the hash of the words judged. The destination re-reads a step the first time its Advisor runs.",
"warmup_thread_messages": "Message identifiers this instance recognised as turns of a warmup conversation, so the reply to each is recognised too. The destination syncs provider mail afresh and rebuilds it from the warmup tokens, which do travel.",
"sessions": "Live login sessions. They are bound to the source instance's signing key and must not survive a move.",
"mailbox_erasures": "Erasure still owed for a mailbox this instance deleted: a grant to revoke at the provider, and message bodies to remove from this instance's blob store. Both name work on the instance that wrote the row, and the mailboxes are already gone.",
"login_history": "Where people signed in from, kept only to compare a new sign-in against recent ones. It belongs to the person rather than the workspace, and a destination must build its own baseline before it can call anything anomalous.",
"mailbox_imports": "Mailbox imports in progress or recently finished. They are work this instance is doing, and their rows hold credentials in flight, which live on only as the mailboxes they created.",
"mailbox_import_rows": "The rows of a mailbox import, with credentials sealed until each row is connected. They follow mailbox_imports, which does not travel.",
"contact_imports": "Contact imports in progress or recently finished. They are work this instance is doing; the contacts they created travel with the contacts group.",
"contact_import_rows": "The uploaded rows of a contact import and what became of each. They follow contact_imports, which does not travel.",
"placement_renders": "The copy a tracking comparison is sending to each seed, sealed so both halves send the same words. It lives only while the comparison runs, and a copy that had not been sent stays behind with its task.",
"inbox_follow_up_sweeps": "This instance's hourly follow-up sweep state for the workspace: where its cycle stopped (by this instance's mailbox and message row ids), how far it has checked changed conversations, and which walker holds it. The destination starts its own cycle at the newest conversation.",
"slack_user_links": "Which Slack member speaks for which Warmbly member. Slack delivers that member's messages to the instance whose Slack app the workspace installed, so each member links again after the workspace reconnects Slack on the destination.",
"slack_link_codes": "In-flight Slack account links, valid for minutes.",
"slack_agent_threads": "Which Slack thread the assistant answers in for which conversation. The Slack install they belong to does not travel; the conversations themselves do, with agent_sessions.",
"slack_inbox_threads": "Which Slack thread mirrors which inbox conversation. The Slack install and its channel do not travel; the conversations themselves do, with the unified inbox.",
"user_view_preferences": "Each member's own column layout and sort for the dashboard's lists, and their unibox scope rail arrangement. It belongs to the person rather than the workspace: members are matched by account on import and a layout names custom fields the destination may not hold yet, so everyone starts from the default view and picks their columns again.",
"campaign_send_plan_snapshots": "Today's precomputed send plan for a campaign, derived from the campaign, its leads, its mailboxes and this instance's limits, which all travel. Keyed to this instance's budget day, and naming mailboxes and workers. The destination's own background snapshotter recomputes it.",
"send_result_effects": "Source-instance reconciliation delivery queue. Pending webhook and notification effects must drain on the source; imports must not replay customer notifications. Full instance backups retain the durable outbox.",
"warmup_received": "Raw receiving-mailbox observations and provider thread handles are source-instance evidence, not portable send authority. Aggregated warmup placement history travels; imported threads cannot continue without new verified observations.",
"diagnostic_auth_verifications": "Bounded DKIM observations and grants bind source-instance workers, provider messages and cross-workspace diagnostic parents. They cannot authorize imported mail; destination verification starts unknown. Full instance database backups retain them.",
"warmup_pending_filings": "Provider filing awaiting acknowledgement on this instance. The destination resyncs mailbox messages.",
"unibox_pending_emails": "Unverified mailbox-sync events awaiting this instance's warmup checks. The destination resyncs provider mail with its own warmup and cloud-link state.",
"organization_encrypted_keys": "The organization's data key, wrapped by the source instance's KMS. The destination cannot unwrap it, and shipping it would put every org secret behind one exported blob.",
"api_idempotency_keys": "A short-lived replay cache for in-flight API requests.",
"realtime_events": "The websocket outbox. Every row is already delivered or expired.",
"integration_oauth_states": "In-flight OAuth handshakes, valid for minutes and bound to the source instance's redirect URL.",
"crm_sync_jobs": "The outbox of pending CRM writes on this instance; the destination's own events feed its outbox.",
"crm_sync_cursors": "Pull checkpoints for this instance; the destination starts its own pull.",
"salesforce_record_links": "Which Salesforce record each contact is, with a cached copy of it. The destination links contacts again by address the first time it syncs or shows them, and reads the record fresh.",
"salesforce_activity_queue": "Activity waiting to be logged in Salesforce, and the recent outcome of what was. What was logged is already in Salesforce; what was waiting belongs to this instance's drain.",
"salesforce_sync_state": "Where this instance's pull loop got to in each Salesforce org, and the API calls it counted today. The destination starts its own cursor when the connection first syncs.",
"oauth_authorization_codes": "Single-use authorization codes, valid for seconds.",
"oauth_developer_blocks": "An operator's decision on the source instance about who may build apps there; the destination's operators decide for theirs.",
"app_directory_listings": "A publication on the source instance's community directory, featured or hidden by its team. Publish again on the destination, where its own team decides.",
"scheduled_deletions": "Instance lifecycle state. Importing a pending deletion would schedule the destination workspace for destruction.",
"dedicated_worker_assignments": "Worker topology, which is a property of the instance rather than the workspace.",
"warmup_spam_moves": "Per-message attribution evidence for warmup mail this instance synced, kept only to decide recent tampering; the destination judges its own.",
"mailbox_owner_activity": "Five-minute buckets of sync-observed owner activity on this instance, read only to attribute recent spam moves.",
"warmup_pools": "Instance-global pool definitions shared by every workspace on the instance.",
"pool_link_codes": "In-flight link handshakes between a self-hosted instance and this cloud, valid for minutes.",
"cli_auth_codes": "In-flight `warmbly auth login` handshakes, valid for minutes. The API key an approval mints does travel, with the api_keys rows.",
"pool_link_instances": "Self-hosted instances linked to this workspace's pool allowance. The token hash only authenticates against this instance, and the enrolled mailboxes are mirrors of mailboxes that live elsewhere.",
"pool_link_mailboxes": "Which mailbox rows are warmup-only mirrors for a linked instance. They follow pool_link_instances, which does not travel.",
"cloud_link": "This instance's own link to Warmbly Cloud: an instance property, not workspace data, and its token would be wrong on any other instance.",
"cloud_link_mailboxes": "Which local mailboxes Warmbly Cloud warms for this instance. The enrollment belongs to the link, which does not travel.",
"warmup_conversations": "The instance's shared warmup content library, not workspace data.",
"copy_judgments": "A cache of copy judgments keyed by the hash of the words judged. The destination re-reads a step the first time its Advisor runs.",
"warmup_thread_messages": "Message identifiers this instance recognised as turns of a warmup conversation, so the reply to each is recognised too. The destination syncs provider mail afresh and rebuilds it from the warmup tokens, which do travel.",
"sessions": "Live login sessions. They are bound to the source instance's signing key and must not survive a move.",
"mailbox_erasures": "Erasure still owed for a mailbox this instance deleted: a grant to revoke at the provider, and message bodies to remove from this instance's blob store. Both name work on the instance that wrote the row, and the mailboxes are already gone.",
"login_history": "Where people signed in from, kept only to compare a new sign-in against recent ones. It belongs to the person rather than the workspace, and a destination must build its own baseline before it can call anything anomalous.",
"mailbox_imports": "Mailbox imports in progress or recently finished. They are work this instance is doing, and their rows hold credentials in flight, which live on only as the mailboxes they created.",
"mailbox_import_rows": "The rows of a mailbox import, with credentials sealed until each row is connected. They follow mailbox_imports, which does not travel.",
"contact_imports": "Contact imports in progress or recently finished. They are work this instance is doing; the contacts they created travel with the contacts group.",
"contact_import_rows": "The uploaded rows of a contact import and what became of each. They follow contact_imports, which does not travel.",
"placement_renders": "The copy a tracking comparison is sending to each seed, sealed so both halves send the same words. It lives only while the comparison runs, and a copy that had not been sent stays behind with its task.",
"inbox_follow_up_sweeps": "This instance's hourly follow-up sweep state for the workspace: where its cycle stopped (by this instance's mailbox and message row ids), how far it has checked changed conversations, and which walker holds it. The destination starts its own cycle at the newest conversation.",
"slack_user_links": "Which Slack member speaks for which Warmbly member. Slack delivers that member's messages to the instance whose Slack app the workspace installed, so each member links again after the workspace reconnects Slack on the destination.",
"slack_link_codes": "In-flight Slack account links, valid for minutes.",
"slack_agent_threads": "Which Slack thread the assistant answers in for which conversation. The Slack install they belong to does not travel; the conversations themselves do, with agent_sessions.",
"slack_inbox_threads": "Which Slack thread mirrors which inbox conversation. The Slack install and its channel do not travel; the conversations themselves do, with the unified inbox.",
"user_view_preferences": "Each member's own column layout and sort for the dashboard's lists, and their unibox scope rail arrangement. It belongs to the person rather than the workspace: members are matched by account on import and a layout names custom fields the destination may not hold yet, so everyone starts from the default view and picks their columns again.",
"campaign_send_plan_snapshots": "Today's precomputed send plan for a campaign, derived from the campaign, its leads, its mailboxes and this instance's limits, which all travel. Keyed to this instance's budget day, and naming mailboxes and workers. The destination's own background snapshotter recomputes it.",
}
// TableByName indexes Tables for lookup during import.
+4
View File
@@ -180,6 +180,10 @@ func (s *service) PublishHealthTransition(ctx context.Context, accountID uuid.UU
// fires when webhooks aren't wired (e.g. in the consumer). No-op on a
// no-change transition or when the account can't be resolved.
func (s *service) dispatchHealthEvent(ctx context.Context, accountID uuid.UUID, oldState, newState models.WarmupHealthState, reason string) {
repository.AfterSendResultCommit(ctx, func(ctx context.Context) { s.publishHealthEvent(ctx, accountID, oldState, newState, reason) })
}
func (s *service) publishHealthEvent(ctx context.Context, accountID uuid.UUID, oldState, newState models.WarmupHealthState, reason string) {
if s.emailRepo == nil || oldState == newState {
return
}
+4
View File
@@ -229,6 +229,9 @@ func (s *service) throttled(ctx context.Context, orgID uuid.UUID, eventType mode
func (s *service) Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) (uuid.UUID, error) {
eventID := uuid.New()
if durableID := repository.SendResultEffectEventID(ctx); durableID != uuid.Nil {
eventID = durableID
}
for _, sink := range s.recordSinks {
sink(ctx, orgID, eventType, data)
@@ -278,6 +281,7 @@ func (s *service) Dispatch(ctx context.Context, orgID uuid.UUID, eventType model
}
if err := s.repo.EnqueueDelivery(ctx, delivery); err != nil {
log.Warn().Err(err).Str("endpoint_id", endpoints[i].ID.String()).Msg("Failed to enqueue webhook delivery")
return eventID, err
}
}
return eventID, nil
+11 -2
View File
@@ -24,7 +24,7 @@ func (w *WorkerService) HandleSendEmail(ctx context.Context, sendEmail models.Se
workerID, workerIDErr := uuid.Parse(w.ID)
var dispatch repository.WorkerWarmupDispatch
var requiresNonce bool
if sendEmail.IsWarmup {
{
if d, ok := w.SyncContextRepository.(repository.WorkerWarmupDispatch); ok {
if workerIDErr != nil || workerID == uuid.Nil {
return errors.New("invalid warmup worker identity")
@@ -38,6 +38,12 @@ func (w *WorkerService) HandleSendEmail(ctx context.Context, sendEmail models.Se
return errors.New("warmup dispatch authority unavailable")
}
requiresNonce = state.State == "authorized"
if requiresNonce && state.Recipients != nil {
recipients := append(append(append([]string{}, sendEmail.To...), sendEmail.Cc...), sendEmail.Bcc...)
if state.OrganizationID == nil || *state.OrganizationID != sendEmail.OrgID || !repository.MatchOutboundRecipients(state.Recipients, recipients) {
return errors.New("send envelope does not match durable authorization")
}
}
if state.State == "started" || state.State == "denied" {
return nil
}
@@ -52,7 +58,6 @@ func (w *WorkerService) HandleSendEmail(ctx context.Context, sendEmail models.Se
log.Info().
Str("task_id", sendEmail.TaskID.String()).
Str("email_id", sendEmail.EmailID.String()).
Strs("to", sendEmail.To).
Bool("is_warmup", sendEmail.IsWarmup).
Msg("Processing send email event")
@@ -68,6 +73,10 @@ func (w *WorkerService) HandleSendEmail(ctx context.Context, sendEmail models.Se
return w.failSend(ctx, sendEmail, errMailboxNotLoaded, true)
}
if sendEmail.OrgID != uuid.Nil && (mail.OrgID == nil || *mail.OrgID != sendEmail.OrgID) {
return errors.New("send organization does not own loaded mailbox")
}
// Decrypt subject
subject := sendEmail.Subject
if w.CipherService != nil {
@@ -27,6 +27,18 @@ import (
// DelaySeconds=0. That makes the dwell survive a worker restart, which the old
// in-process time.AfterFunc here could not.
func (w *WorkerService) HandleWarmupAction(ctx context.Context, action models.WarmupEmailAction) error {
if gate, ok := w.SyncContextRepository.(repository.WarmupActionAdmission); ok {
worker, err := uuid.Parse(w.ID)
if err != nil {
return err
}
action.Actions, err = gate.PermittedWarmupActions(ctx, action.EmailID, worker, action.Actions)
if err != nil {
return err
}
} else if w.SyncContextRepository != nil {
return errors.New("warmup action authority unavailable")
}
log.Info().
Str("email_id", action.EmailID.String()).
Str("gmail_id", action.GmailID).
@@ -53,6 +53,7 @@ func (m *MailManager) AddWMail(
return nil
}
m.Emails[data.ID] = newMail
newMail.ExecutorID = m.ExecutorID
return nil
}
@@ -16,6 +16,7 @@ import (
type MailManager struct {
sync.RWMutex
ExecutorID uuid.UUID
Emails map[uuid.UUID]*wmail.WMail
OnEvent func(eventType models.JobEventType, key string, body any) error
cache *cache.Cache
+3
View File
@@ -4,6 +4,8 @@ import (
"context"
"sync"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/cipher"
"github.com/warmbly/warmbly/internal/app/worker/mailmanager"
"github.com/warmbly/warmbly/internal/config"
@@ -74,6 +76,7 @@ func (s *WorkerService) Init() error {
if s.TokenBroker != nil {
s.mailManager.WireTokenBroker(s.TokenBroker)
}
s.mailManager.ExecutorID, _ = uuid.Parse(s.ID)
return nil
}
+1
View File
@@ -149,6 +149,7 @@ func (w *WMail) endTick(stats *tickStats) {
// events. mapKey is the id the provider reports on later remove/flag events
// (RFC Message-ID for IMAP, provider message id for Gmail and Graph).
func (w *WMail) storeNew(ctx context.Context, msg *models.EmailMessageData, data *models.EmailMessageStoreData, mapKey string) error {
w.verifyDiagnostic(ctx, msg)
evidence := models.EvidenceFromFlags(data.Flags)
if evidence.Source != "unavailable" {
evidence.ObservedAt = time.Now().UTC()
@@ -0,0 +1,59 @@
package wmail
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/diagnosticauth"
"github.com/warmbly/warmbly/internal/repository"
)
func (w *WMail) verifyDiagnostic(ctx context.Context, msg *models.EmailMessageData) {
if w.ExecutorID == uuid.Nil || w.EmailType == models.InboxProviderSMTPIMAP {
return
}
authority, ok := w.SyncContext.(repository.DiagnosticAuthAuthority)
if !ok {
return
}
var token uuid.UUID
for _, flag := range msg.Flags {
if name, value, ok := strings.Cut(flag, ":"); ok && strings.EqualFold(strings.TrimSpace(name), config.WarmupVerifyHeader) {
token, _ = uuid.Parse(strings.TrimSpace(value))
break
}
}
if token == uuid.Nil {
return
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
request := models.DiagnosticAuthRequest{Token: token, MailboxID: w.ID, WorkerID: w.ExecutorID, MessageID: msg.MessageID}
grant, err := authority.DiagnosticAuth(ctx, request)
if err != nil || grant == nil {
return
}
var raw []byte
switch w.EmailType {
case models.InboxProviderGoogle:
if w.GoogleData != nil && w.GoogleData.Client != nil {
raw, err = w.GoogleData.Client.DiagnosticRawMessage(ctx, msg.GmailID, diagnosticauth.MaxMIMEBytes)
}
case models.InboxProviderOutlook:
if w.GraphData != nil && w.GraphData.Client != nil {
raw, err = w.GraphData.Client.DiagnosticRawMessage(ctx, msg.GmailID, diagnosticauth.MaxMIMEBytes)
}
}
if err != nil || len(raw) == 0 {
return
}
result := diagnosticauth.Verify(ctx, raw, *grant, nil)
clear(raw)
request.Nonce = grant.Nonce
request.Result = &result
_, _ = authority.DiagnosticAuth(ctx, request)
}
+3 -2
View File
@@ -58,8 +58,9 @@ type SmtpImapData struct {
}
type WMail struct {
UserID uuid.UUID
ID uuid.UUID
ExecutorID uuid.UUID
UserID uuid.UUID
ID uuid.UUID
// OrgID scopes the organization-wide sync budget; nil for a legacy
// personal mailbox.
OrgID *uuid.UUID
+50
View File
@@ -0,0 +1,50 @@
package goog
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
)
func (c *Client) DiagnosticRawMessage(ctx context.Context, id string, maxBytes int) ([]byte, error) {
if c.srv == nil || c.rawClient == nil || id == "" || maxBytes <= 0 {
return nil, errors.New("diagnostic MIME unavailable")
}
endpoint := strings.TrimRight(c.srv.BasePath, "/") + "/gmail/v1/users/me/messages/" + url.PathEscape(id) + "?format=raw&fields=raw"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, errors.New("diagnostic MIME unavailable")
}
resp, err := c.rawClient.Do(req)
if err != nil {
return nil, errors.New("diagnostic MIME unavailable")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("diagnostic MIME unavailable")
}
limit := maxBytes*4/3 + 65536
data, err := io.ReadAll(io.LimitReader(resp.Body, int64(limit+1)))
if err != nil || len(data) > limit {
return nil, errors.New("diagnostic MIME unavailable")
}
var encoded struct {
Raw string `json:"raw"`
}
if json.Unmarshal(data, &encoded) != nil {
return nil, errors.New("diagnostic MIME unavailable")
}
if len(encoded.Raw) > ((maxBytes+2)/3)*4 {
return nil, errors.New("diagnostic MIME unavailable")
}
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(encoded.Raw, "="))
if err != nil || len(raw) > maxBytes {
return nil, errors.New("diagnostic MIME unavailable")
}
return raw, nil
}
@@ -0,0 +1,43 @@
package goog
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/gmail/v1"
)
func TestDiagnosticRawMessageFetchesOnlyNamedBoundedMessage(t *testing.T) {
want := []byte("From: sender@example.test\r\n\r\nBounded diagnostic")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.EscapedPath() != "/gmail/v1/users/me/messages/provider%2Fid" || r.URL.Query().Get("format") != "raw" {
t.Fatalf("request=%s", r.URL.String())
}
_, _ = fmt.Fprintf(w, `{"raw":%q}`, base64.RawURLEncoding.EncodeToString(want))
}))
defer srv.Close()
c := &Client{srv: &gmail.Service{BasePath: srv.URL}, rawClient: srv.Client()}
got, err := c.DiagnosticRawMessage(context.Background(), "provider/id", len(want))
if err != nil || string(got) != string(want) {
t.Fatalf("got=%q err=%v", got, err)
}
if _, err = c.DiagnosticRawMessage(context.Background(), "provider/id", len(want)-1); err == nil {
t.Fatal("oversize raw MIME accepted")
}
}
func TestDiagnosticRawMessageDoesNotReturnProviderErrors(t *testing.T) {
secret := "provider-secret-error"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, secret, http.StatusForbidden) }))
defer srv.Close()
c := &Client{srv: &gmail.Service{BasePath: srv.URL}, rawClient: srv.Client()}
_, err := c.DiagnosticRawMessage(context.Background(), "id", 1024)
if err == nil || strings.Contains(err.Error(), secret) {
t.Fatal("provider response escaped", err)
}
}
+5 -2
View File
@@ -2,6 +2,7 @@ package goog
import (
"context"
"net/http"
"sync"
"sync/atomic"
@@ -18,8 +19,9 @@ type Client struct {
FirstName string
LastName string
srv *gmail.Service
Cache *cache.Cache
srv *gmail.Service
rawClient *http.Client
Cache *cache.Cache
// sentLabelStuck records that this mailbox's Gmail refused to remove the
// SENT label, so FileWarmup stops asking. Gmail documents INBOX as
@@ -62,6 +64,7 @@ func (c *Client) Init(ctx context.Context, token *oauth2.Token, cfg oauth2.Confi
// tokens from Warmbly Cloud); nothing is persisted from it.
func (c *Client) InitWithSource(ctx context.Context, ts oauth2.TokenSource) *errx.MailError {
httpClient := oauth2.NewClient(ctx, ts)
c.rawClient = httpClient
var err error
c.srv, err = gmail.NewService(ctx, option.WithHTTPClient(httpClient))
if err != nil {
+28
View File
@@ -0,0 +1,28 @@
package msgraph
import (
"context"
"errors"
"io"
"net/http"
"net/url"
)
func (c *Client) DiagnosticRawMessage(ctx context.Context, id string, maxBytes int) ([]byte, error) {
if id == "" || maxBytes <= 0 || c.hc == nil {
return nil, errors.New("diagnostic MIME unavailable")
}
resp, err := c.do(ctx, http.MethodGet, c.root()+"/messages/"+url.PathEscape(id)+"/$value", "", nil)
if err != nil {
return nil, errors.New("diagnostic MIME unavailable")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("diagnostic MIME unavailable")
}
data, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxBytes+1)))
if err != nil || len(data) > maxBytes {
return nil, errors.New("diagnostic MIME unavailable")
}
return data, nil
}
@@ -0,0 +1,43 @@
package msgraph
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
)
type diagnosticRawRT struct {
body []byte
status int
path string
}
func (r *diagnosticRawRT) RoundTrip(req *http.Request) (*http.Response, error) {
r.path = req.URL.Path
return &http.Response{StatusCode: r.status, Body: io.NopCloser(bytes.NewReader(r.body)), Header: http.Header{}}, nil
}
func TestDiagnosticRawMessageFetchesOnlyNamedBoundedMessage(t *testing.T) {
want := []byte("From: sender@example.test\r\n\r\nBounded diagnostic")
rt := &diagnosticRawRT{body: want, status: http.StatusOK}
c := &Client{hc: &http.Client{Transport: rt}}
got, err := c.DiagnosticRawMessage(context.Background(), "provider/id", len(want))
if err != nil || string(got) != string(want) || !strings.HasSuffix(rt.path, "/messages/provider/id/$value") {
t.Fatalf("got=%q path=%s err=%v", got, rt.path, err)
}
if _, err = c.DiagnosticRawMessage(context.Background(), "provider/id", len(want)-1); err == nil {
t.Fatal("oversize raw MIME accepted")
}
}
func TestDiagnosticRawMessageDoesNotReturnProviderErrors(t *testing.T) {
secret := "provider-secret-error"
c := &Client{hc: &http.Client{Transport: &diagnosticRawRT{body: []byte(secret), status: http.StatusForbidden}}}
_, err := c.DiagnosticRawMessage(context.Background(), "id", 1024)
if err == nil || strings.Contains(err.Error(), secret) {
t.Fatal("provider response escaped", err)
}
}
@@ -0,0 +1,16 @@
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM tasks WHERE send_reserved_at IS NOT NULL AND send_released_at IS NULL AND send_result_applied_at IS NULL)
OR EXISTS (SELECT 1 FROM email_accounts WHERE test_mode IS NOT NULL OR shared_daily_limit IS NOT NULL OR rolling_recipient_limit IS NOT NULL)
OR EXISTS (SELECT 1 FROM diagnostic_auth_verifications)
OR EXISTS (SELECT 1 FROM send_recovery_resolutions)
OR EXISTS (SELECT 1 FROM send_result_effects WHERE delivered_at IS NULL) THEN
RAISE EXCEPTION 'shared admission or explicit participation state requires retention';
END IF;
END $$;
DROP TABLE send_result_effects;
DROP TABLE send_recovery_resolutions;
DROP TABLE diagnostic_auth_verifications;
DROP INDEX tasks_send_reservations;
ALTER TABLE tasks DROP COLUMN send_executor_result,DROP COLUMN send_executor_started_at,DROP COLUMN send_executor_worker,
DROP COLUMN send_executor_nonce,DROP COLUMN send_released_at,DROP COLUMN send_recipients,DROP COLUMN send_business_day,DROP COLUMN send_reserved_at;
ALTER TABLE email_accounts DROP COLUMN rolling_recipient_limit,DROP COLUMN shared_daily_limit,DROP COLUMN test_receive_enabled,DROP COLUMN test_send_enabled,DROP COLUMN test_mode;
@@ -0,0 +1,58 @@
ALTER TABLE email_accounts
ADD COLUMN test_mode text CHECK (test_mode IN ('legacy','diagnostic','off')),
ADD COLUMN test_send_enabled boolean NOT NULL DEFAULT false,
ADD COLUMN test_receive_enabled boolean NOT NULL DEFAULT false,
ADD COLUMN shared_daily_limit integer CHECK (shared_daily_limit > 0),
ADD COLUMN rolling_recipient_limit integer CHECK (rolling_recipient_limit > 0);
ALTER TABLE email_accounts ALTER COLUMN test_mode SET DEFAULT 'off';
ALTER TABLE tasks
ADD COLUMN send_reserved_at timestamptz,
ADD COLUMN send_business_day date,
ADD COLUMN send_recipients text[],
ADD COLUMN send_released_at timestamptz,
ADD COLUMN send_executor_nonce uuid,
ADD COLUMN send_executor_worker uuid REFERENCES fleet_nodes(id) ON DELETE SET NULL,
ADD COLUMN send_executor_started_at timestamptz,
ADD COLUMN send_executor_result jsonb;
CREATE INDEX tasks_send_reservations ON tasks(email_account_id,send_reserved_at) WHERE send_reserved_at IS NOT NULL;
CREATE TABLE diagnostic_auth_verifications (
task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
email_account_id uuid NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
worker_id uuid REFERENCES fleet_nodes(id) ON DELETE SET NULL,
nonce uuid NOT NULL,
message_id text NOT NULL,
authorized_at timestamptz NOT NULL DEFAULT NOW(),
result jsonb CHECK (result IS NULL OR result->>'dkim' IN ('unknown','pass','fail')),
verified_at timestamptz,
PRIMARY KEY(task_id,email_account_id)
);
CREATE TABLE send_recovery_resolutions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email_account_id uuid NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
recovery_task_id uuid REFERENCES tasks(id) ON DELETE SET NULL,
evidence_task_id uuid REFERENCES tasks(id) ON DELETE SET NULL,
previous_reason text NOT NULL CHECK(previous_reason IN ('authentication','permanent','conflict')),
evidence_type text NOT NULL CHECK(evidence_type IN ('authentication_repaired','operator_provider_confirmation')),
confirmation_reference text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT NOW()
);
CREATE TABLE send_result_effects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
effect_key text NOT NULL,
organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
kind text NOT NULL CHECK(kind IN ('webhook','notification')),
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
delivered_at timestamptz,
lease uuid,
locked_until timestamptz,
attempts integer NOT NULL DEFAULT 0,
UNIQUE(task_id,effect_key)
);
CREATE INDEX send_result_effects_pending ON send_result_effects(created_at) WHERE delivered_at IS NULL;
@@ -145,6 +145,31 @@ func TestLiveSendRecoveryUpgradesReleased265WithoutInventingHistory(t *testing.T
t.Fatal(err)
}
assertLegacy()
var legacyMode *string
if err := conn.QueryRow(ctx, `SELECT test_mode FROM email_accounts WHERE id=$1`, mailbox).Scan(&legacyMode); err != nil || legacyMode != nil {
t.Fatal("upgrade changed legacy consent", legacyMode, err)
}
admissionDown, err := migrationsFS.ReadFile("migrations/000272_shared_send_admission.down.sql")
if err != nil {
t.Fatal(err)
}
for _, restriction := range []struct{ set, reset string }{
{`UPDATE email_accounts SET test_mode='off' WHERE id=$1`, `UPDATE email_accounts SET test_mode=NULL WHERE id=$1`},
{`UPDATE tasks SET send_reserved_at=NOW() WHERE email_account_id=$1`, `UPDATE tasks SET send_reserved_at=NULL WHERE email_account_id=$1`},
{`INSERT INTO send_result_effects(task_id,effect_key,organization_id,kind,payload) SELECT t.id,'rollback',ea.organization_id,'webhook','{}' FROM tasks t JOIN email_accounts ea ON ea.id=t.email_account_id WHERE ea.id=$1 LIMIT 1`, `DELETE FROM send_result_effects WHERE task_id IN(SELECT id FROM tasks WHERE email_account_id=$1)`},
{`INSERT INTO diagnostic_auth_verifications(task_id,email_account_id,message_id,nonce)SELECT t.id,t.email_account_id,'rollback',gen_random_uuid() FROM tasks t WHERE t.email_account_id=$1 LIMIT 1`, `DELETE FROM diagnostic_auth_verifications WHERE email_account_id=$1`},
{`INSERT INTO send_recovery_resolutions(organization_id,email_account_id,recovery_task_id,previous_reason,evidence_type,confirmation_reference)SELECT ea.organization_id,ea.id,t.id,'authentication','operator_provider_confirmation','fixture' FROM tasks t JOIN email_accounts ea ON ea.id=t.email_account_id WHERE ea.id=$1 LIMIT 1`, `DELETE FROM send_recovery_resolutions WHERE email_account_id=$1`},
} {
if _, err = conn.Exec(ctx, restriction.set, mailbox); err != nil {
t.Fatal(err)
}
if _, err = conn.Exec(ctx, string(admissionDown)); err == nil || !strings.Contains(err.Error(), "shared admission") {
t.Fatal("admission rollback discarded retained state", err)
}
if _, err = conn.Exec(ctx, restriction.reset, mailbox); err != nil {
t.Fatal(err)
}
}
if err := m.Migrate(265); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatal(err)
}
+13
View File
@@ -0,0 +1,13 @@
package models
import "time"
func (m CloudLinkMailbox) EffectiveStanding(now time.Time) *WarmupHealthInfo {
if h := m.Standing; h != nil && (h.State == string(WarmupHealthBlocked) || h.State == string(WarmupHealthQuarantined)) && (h.BlockedUntil == nil || h.BlockedUntil.After(now)) {
return h
}
if m.EnrollmentState != "active" || m.Standing == nil || m.StandingObservedAt == nil || m.StandingObservedAt.After(now) || !m.StandingObservedAt.After(now.Add(-15*time.Minute)) {
return &WarmupHealthInfo{State: string(WarmupHealthBlocked), Reason: "cloud_evidence_unavailable"}
}
return m.Standing
}
+19
View File
@@ -0,0 +1,19 @@
package models
import (
"testing"
"time"
)
func TestCloudStandingRejectsFuturePositiveWithoutClearingNegative(t *testing.T) {
now := time.Now()
future := now.Add(time.Minute)
m := CloudLinkMailbox{EnrollmentState: "active", StandingObservedAt: &future, Standing: &WarmupHealthInfo{State: string(WarmupHealthHealthy)}}
if got := m.EffectiveStanding(now); got.State != string(WarmupHealthBlocked) || got.Reason != "cloud_evidence_unavailable" || m.Standing.State != string(WarmupHealthHealthy) {
t.Fatalf("future positive trusted or stored evidence rewritten: %+v", got)
}
m.Standing = &WarmupHealthInfo{State: string(WarmupHealthQuarantined), Reason: "observed_refusal"}
if got := m.EffectiveStanding(now); got != m.Standing {
t.Fatal("negative evidence discarded")
}
}
+58
View File
@@ -0,0 +1,58 @@
package models
import (
"strings"
"time"
"github.com/google/uuid"
)
const DiagnosticDKIMVerifier = "go-msgauth/dkim-v0.7.0"
type DiagnosticDKIMResult struct {
DKIM string `json:"dkim"`
Alignment string `json:"alignment"`
SigningDomain string `json:"signing_domain,omitempty"`
Verifier string `json:"verifier"`
ObservedAt time.Time `json:"observed_at"`
}
func (r DiagnosticDKIMResult) Valid(now time.Time) bool {
if r.Verifier != DiagnosticDKIMVerifier || r.ObservedAt.Before(now.Add(-5*time.Minute)) || r.ObservedAt.After(now.Add(time.Minute)) {
return false
}
if r.DKIM != "unknown" && r.DKIM != "pass" && r.DKIM != "fail" {
return false
}
if r.Alignment != "unknown" && r.Alignment != "pass" {
return false
}
if r.DKIM != "pass" {
return r.SigningDomain == "" && r.Alignment == "unknown"
}
if len(r.SigningDomain) == 0 || len(r.SigningDomain) > 253 || strings.ContainsAny(r.SigningDomain, "\r\n") {
return false
}
for _, c := range r.SigningDomain {
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '-') {
return false
}
}
return true
}
type DiagnosticAuthRequest struct {
Token uuid.UUID `json:"token"`
MailboxID uuid.UUID `json:"mailbox_id"`
WorkerID uuid.UUID `json:"worker_id"`
MessageID string `json:"message_id"`
Nonce uuid.UUID `json:"nonce"`
Result *DiagnosticDKIMResult `json:"result,omitempty"`
}
type DiagnosticAuthGrant struct {
Nonce uuid.UUID `json:"nonce"`
MessageID string `json:"message_id"`
From string `json:"from"`
To string `json:"to"`
}
+25 -6
View File
@@ -74,9 +74,14 @@ type Email struct {
LastSyncedAt time.Time `json:"last_synced_at"`
LastID *int64 `json:"last_id"`
CampaignLimit int `json:"campaign_limit"`
MinWaitTime int `json:"min_wait_time"`
ReplyTo string `json:"reply_to"`
CampaignLimit int `json:"campaign_limit"`
TestMode *TestParticipationMode `json:"test_mode"`
TestSendEnabled bool `json:"test_send_enabled"`
TestReceiveEnabled bool `json:"test_receive_enabled"`
SharedDailyLimit *int `json:"shared_daily_limit"`
RollingRecipientLimit *int `json:"rolling_recipient_limit"`
MinWaitTime int `json:"min_wait_time"`
ReplyTo string `json:"reply_to"`
TrackingDomain string `json:"tracking_domain"`
TrackingDomainVerified bool `json:"tracking_domain_verified"`
@@ -697,9 +702,15 @@ type UpdateEmail struct {
Status *string `json:"status"` // active, inactive, revoked
CampaignLimit *int `json:"campaign_limit"`
MinWaitTime *int `json:"min_wait_time"`
ReplyTo *string `json:"reply_to"`
CampaignLimit *int `json:"campaign_limit"`
TestMode *TestParticipationMode `json:"test_mode"`
TestSendEnabled *bool `json:"test_send_enabled"`
TestReceiveEnabled *bool `json:"test_receive_enabled"`
SharedDailyLimit *int `json:"shared_daily_limit"`
RollingRecipientLimit *int `json:"rolling_recipient_limit"`
SendRecoveryResolution *SendRecoveryResolution `json:"send_recovery_resolution"`
MinWaitTime *int `json:"min_wait_time"`
ReplyTo *string `json:"reply_to"`
Warmup *bool `json:"warmup"`
WarmupBase *int `json:"warmup_base"`
@@ -736,6 +747,14 @@ type UpdateEmail struct {
Tags []string `json:"tags"`
}
type SendRecoveryResolution struct {
HeldTaskID uuid.UUID `json:"held_task_id"`
HeldReason string `json:"held_reason"`
EvidenceType string `json:"evidence_type"`
EvidenceTaskID *uuid.UUID `json:"evidence_task_id,omitempty"`
ConfirmationReference string `json:"confirmation_reference,omitempty"`
}
// BulkEmailTags adds and removes tags across many mailboxes in one call (the
// mailboxes list bulk bar). Mailbox ids the caller doesn't own and tag ids
// they haven't defined are ignored rather than erroring, so a stale
+19 -17
View File
@@ -13,23 +13,24 @@ const ObservationUnknownFolderFlag = "Warmbly-Unknown-Folder"
// ReceivedEvidence separates header claims from verified receiver verdicts.
type ReceivedEvidence struct {
Version string `json:"version"`
Source string `json:"source"`
Trust string `json:"trust"`
ObservedAt time.Time `json:"observed_at"`
SPF string `json:"spf"`
DKIM string `json:"dkim"`
DMARC string `json:"dmarc"`
Alignment string `json:"alignment"`
TLS string `json:"tls"`
FromDomain string `json:"from_domain,omitempty"`
EnvelopeDomain string `json:"envelope_domain_claim,omitempty"`
SigningDomain string `json:"signing_domain_claim,omitempty"`
Selector string `json:"selector_claim,omitempty"`
AuthenticationResultsPresent bool `json:"authentication_results_present"`
OneClickHeaders bool `json:"one_click_headers_present"`
OneClickSigningClaim bool `json:"one_click_signing_claim"`
OneClickCompliance string `json:"one_click_compliance"`
DKIMVerification *DiagnosticDKIMResult `json:"dkim_verification,omitempty"`
Version string `json:"version"`
Source string `json:"source"`
Trust string `json:"trust"`
ObservedAt time.Time `json:"observed_at"`
SPF string `json:"spf"`
DKIM string `json:"dkim"`
DMARC string `json:"dmarc"`
Alignment string `json:"alignment"`
TLS string `json:"tls"`
FromDomain string `json:"from_domain,omitempty"`
EnvelopeDomain string `json:"envelope_domain_claim,omitempty"`
SigningDomain string `json:"signing_domain_claim,omitempty"`
Selector string `json:"selector_claim,omitempty"`
AuthenticationResultsPresent bool `json:"authentication_results_present"`
OneClickHeaders bool `json:"one_click_headers_present"`
OneClickSigningClaim bool `json:"one_click_signing_claim"`
OneClickCompliance string `json:"one_click_compliance"`
}
func UnknownReceivedEvidence() *ReceivedEvidence {
@@ -77,6 +78,7 @@ func EvidenceFromFlags(flags []string) *ReceivedEvidence {
continue
}
e.Trust, e.SPF, e.DKIM, e.DMARC, e.Alignment, e.TLS, e.OneClickCompliance = "unverified_headers", "unknown", "unknown", "unknown", "unknown", "unknown", "unknown"
e.DKIMVerification = nil
return e
}
return UnknownReceivedEvidence()
+44
View File
@@ -0,0 +1,44 @@
package models
type TestParticipationMode string
const (
TestParticipationLegacy TestParticipationMode = "legacy"
TestParticipationDiagnostic TestParticipationMode = "diagnostic"
TestParticipationOff TestParticipationMode = "off"
)
func (e *Email) LegacyTestParticipation() bool {
return e.TestMode == nil || *e.TestMode == TestParticipationLegacy
}
func (e *Email) TestSendingAllowed() bool {
return e.LegacyTestParticipation() || *e.TestMode == TestParticipationDiagnostic && e.TestSendEnabled
}
func (e *Email) TestReceivingAllowed() bool {
return e.LegacyTestParticipation() || *e.TestMode == TestParticipationDiagnostic && e.TestReceiveEnabled
}
func (e *Email) SyntheticActionsAllowed() bool {
return e.LegacyTestParticipation()
}
func (e *Email) PermittedWarmupActions(actions []string) []string {
var out []string
for _, a := range actions {
switch a {
case WarmupActionDelete, WarmupActionVerifyRemoval:
out = append(out, a)
case WarmupActionFile:
if e.TestReceivingAllowed() {
out = append(out, a)
}
case WarmupActionMarkRead, WarmupActionRescueFromSpam, WarmupActionMarkImportant, WarmupActionStar:
if e.SyntheticActionsAllowed() && e.TestReceivingAllowed() {
out = append(out, a)
}
}
}
return out
}
+98
View File
@@ -0,0 +1,98 @@
package diagnosticauth
import (
"bytes"
"context"
"net"
"net/mail"
"strings"
"time"
"github.com/emersion/go-msgauth/dkim"
"github.com/warmbly/warmbly/internal/models"
)
const MaxMIMEBytes = 1 << 20
const MaxSignatures = 4
func Verify(ctx context.Context, raw []byte, grant models.DiagnosticAuthGrant, lookup func(context.Context, string) ([]string, error)) models.DiagnosticDKIMResult {
result := models.DiagnosticDKIMResult{DKIM: "unknown", Alignment: "unknown", Verifier: models.DiagnosticDKIMVerifier, ObservedAt: time.Now().UTC()}
if len(raw) > MaxMIMEBytes || ctx.Err() != nil {
return result
}
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return result
}
for _, field := range []string{"From", "To", "Message-Id"} {
if len(msg.Header[field]) != 1 {
return result
}
}
from, err := mail.ParseAddress(msg.Header.Get("From"))
if err != nil || !strings.EqualFold(from.Address, grant.From) {
return result
}
to, err := mail.ParseAddress(msg.Header.Get("To"))
if err != nil || !strings.EqualFold(to.Address, grant.To) {
return result
}
if strings.Trim(msg.Header.Get("Message-Id"), "<> \t") != strings.Trim(grant.MessageID, "<> \t") {
return result
}
count := len(msg.Header["Dkim-Signature"])
if count == 0 || count > MaxSignatures {
return result
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if lookup == nil {
lookup = net.DefaultResolver.LookupTXT
}
dnsFailure := false
verified, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{MaxVerifications: MaxSignatures, LookupTXT: func(name string) ([]string, error) {
rows, err := lookup(ctx, name)
if err != nil {
dnsFailure = true
return nil, err
}
if len(rows) > 16 {
dnsFailure = true
return nil, context.DeadlineExceeded
}
for _, row := range rows {
if len(row) > 2048 {
dnsFailure = true
return nil, context.DeadlineExceeded
}
}
return rows, nil
}})
if err != nil || ctx.Err() != nil {
return result
}
unboundSignature := false
for _, v := range verified {
if v.Err != nil {
continue
}
signed := make(map[string]bool)
for _, h := range v.HeaderKeys {
signed[strings.ToLower(h)] = true
}
if !signed["from"] || !signed["to"] || !signed["message-id"] {
unboundSignature = true
continue
}
result.DKIM = "pass"
result.SigningDomain = strings.ToLower(v.Domain)
if at := strings.LastIndex(from.Address, "@"); at >= 0 && strings.EqualFold(from.Address[at+1:], v.Domain) {
result.Alignment = "pass"
return result
}
}
if result.DKIM != "pass" && !dnsFailure && !unboundSignature {
result.DKIM = "fail"
}
return result
}
+80
View File
@@ -0,0 +1,80 @@
package diagnosticauth
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"strings"
"testing"
"time"
"github.com/emersion/go-msgauth/dkim"
"github.com/warmbly/warmbly/internal/models"
)
func TestSignedDiagnosticProofIsBoundedAndContextBound(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
pub, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
t.Fatal(err)
}
dns := func(context.Context, string) ([]string, error) {
return []string{"v=DKIM1; k=rsa; p=" + base64.StdEncoding.EncodeToString(pub)}, nil
}
grant := models.DiagnosticAuthGrant{MessageID: "probe@example.test", From: "sender@example.test", To: "recipient@example.test"}
plain := "From: sender@example.test\r\nTo: recipient@example.test\r\nMessage-ID: <probe@example.test>\r\nSubject: Disclosed diagnostic\r\n\r\nDiagnostic fixture only.\r\n"
var signed bytes.Buffer
if err = dkim.Sign(&signed, strings.NewReader(plain), &dkim.SignOptions{Domain: "example.test", Selector: "diagnostic", Signer: key, HeaderKeys: []string{"From", "To", "Message-ID", "Subject"}}); err != nil {
t.Fatal(err)
}
raw := signed.Bytes()
for _, tt := range []struct {
name string
raw []byte
grant models.DiagnosticAuthGrant
dkim, align string
}{
{"signed", raw, grant, "pass", "pass"},
{"body mutation", bytes.Replace(raw, []byte("fixture only"), []byte("forged claim"), 1), grant, "fail", "unknown"},
{"unverified copied headers", []byte("Authentication-Results: receiver.test; dkim=pass; spf=pass; dmarc=pass\r\n" + plain), grant, "unknown", "unknown"},
{"wrong exact parent", raw, models.DiagnosticAuthGrant{MessageID: "unrelated@example.test", From: grant.From, To: grant.To}, "unknown", "unknown"},
{"wrong receiver", raw, models.DiagnosticAuthGrant{MessageID: grant.MessageID, From: grant.From, To: "other@example.test"}, "unknown", "unknown"},
{"oversize", bytes.Repeat([]byte("x"), MaxMIMEBytes+1), grant, "unknown", "unknown"},
{"signature cap", []byte(strings.Repeat("DKIM-Signature: v=1; d=example.test; s=diagnostic; b=bad\r\n", MaxSignatures) + string(raw)), grant, "unknown", "unknown"},
} {
t.Run(tt.name, func(t *testing.T) {
result := Verify(t.Context(), tt.raw, tt.grant, dns)
if result.DKIM != tt.dkim || result.Alignment != tt.align || !result.Valid(time.Now()) {
t.Fatalf("evidence=%+v", result)
}
})
}
ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond)
defer cancel()
result := Verify(ctx, raw, grant, func(ctx context.Context, _ string) ([]string, error) { <-ctx.Done(); return nil, ctx.Err() })
if result.DKIM != "unknown" || result.Alignment != "unknown" {
t.Fatal("DNS timeout upgraded unknown", result)
}
var unaligned bytes.Buffer
if err = dkim.Sign(&unaligned, strings.NewReader(plain), &dkim.SignOptions{Domain: "provider.test", Selector: "diagnostic", Signer: key, HeaderKeys: []string{"From", "To", "Message-ID"}}); err != nil {
t.Fatal(err)
}
result = Verify(t.Context(), unaligned.Bytes(), grant, dns)
if result.DKIM != "pass" || result.Alignment != "unknown" {
t.Fatal("unaligned signature became alignment proof", result)
}
var unbound bytes.Buffer
if err = dkim.Sign(&unbound, strings.NewReader(plain), &dkim.SignOptions{Domain: "example.test", Selector: "diagnostic", Signer: key, HeaderKeys: []string{"From"}}); err != nil {
t.Fatal(err)
}
result = Verify(t.Context(), unbound.Bytes(), grant, dns)
if result.DKIM != "unknown" || result.Alignment != "unknown" {
t.Fatal("unbound valid signature became diagnostic pass/fail", result)
}
}
@@ -94,6 +94,10 @@ func TestLiveCloudStandingFreshnessIsBoundedWithoutInventingAProviderBlock(t *te
t.Fatal(err)
}
assertState(models.WarmupHealthHealthy, "")
if _, err := f.pool.Exec(ctx, `UPDATE cloud_link_mailboxes SET standing_observed_at=NOW()+INTERVAL '1 minute' WHERE email_account_id=$1`, f.sender); err != nil {
t.Fatal(err)
}
assertState(models.WarmupHealthBlocked, "cloud_evidence_unavailable")
if _, err := f.pool.Exec(ctx, `UPDATE cloud_link_mailboxes SET standing_observed_at = NOW() - INTERVAL '16 minutes' WHERE email_account_id = $1`, f.sender); err != nil {
t.Fatal(err)
}
@@ -0,0 +1,41 @@
package repository
import (
"testing"
"github.com/google/uuid"
)
func TestLiveLegacyBankCannotStarveVersionedOpeningSupply(t *testing.T) {
f, _ := lineageFixture(t)
ctx := t.Context()
segment := "fixture-" + uuid.NewString()
legacy, fresh := uuid.New(), uuid.New()
t.Cleanup(func() { _, _ = f.pool.Exec(ctx, `DELETE FROM warmup_conversations WHERE segment=$1`, segment) })
_, err := f.pool.Exec(ctx, `INSERT INTO warmup_conversations(id,pool_type,segment,source,subject,description,messages,status) VALUES($1,'premium',$2,'ai','Legacy','Historical','["Reply","Closure"]','active')`, legacy, segment)
if err != nil {
t.Fatal(err)
}
r := NewWarmupContentRepository(f.pool)
if n, err := r.CountActiveConversations(ctx, "premium", segment); err != nil || n != 0 {
t.Fatalf("legacy bank falsely ready: %d, %v", n, err)
}
// A legacy bank remains visible but cannot consume a new-opening draw.
_, err = f.pool.Exec(ctx, `INSERT INTO warmup_conversations(id,pool_type,segment,source,subject,description,messages,status,scenario_version,rendering_version,reply_eligible,lint_passed,semantic_review) VALUES($1,'premium',$2,'ai','[Warmbly diagnostic] Fixture','Disclosed fixture','["Reply","Closure"]','active','diagnostic-v1','canonical-v1',true,true,'passed')`, fresh, segment)
if err != nil {
t.Fatal(err)
}
if n, err := r.CountActiveConversations(ctx, "premium", segment); err != nil || n != 1 {
t.Fatalf("usable supply missing: %d, %v", n, err)
}
for range 8 {
c, err := r.PickConversation(ctx, segment)
if err != nil || c == nil || c.ID != fresh {
t.Fatalf("unusable legacy source selected: %+v, %v", c, err)
}
}
c, err := r.GetConversation(ctx, legacy)
if err != nil || c == nil || c.UsageCount != 0 || c.Status != "active" || c.ScenarioVersion != nil {
t.Fatalf("legacy state changed: %+v, %v", c, err)
}
}
+132
View File
@@ -0,0 +1,132 @@
package repository
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/warmbly/warmbly/internal/models"
)
type DiagnosticAuthAuthority interface {
DiagnosticAuth(context.Context, models.DiagnosticAuthRequest) (*models.DiagnosticAuthGrant, error)
}
func (r *taskRepository) DiagnosticAuth(ctx context.Context, req models.DiagnosticAuthRequest) (*models.DiagnosticAuthGrant, error) {
if req.Token == uuid.Nil || req.MailboxID == uuid.Nil || req.WorkerID == uuid.Nil || len(req.MessageID) > 998 || strings.ContainsAny(req.MessageID, "\r\n") {
return nil, ErrSendAdmissionDenied
}
tx, err := r.db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
grant := &models.DiagnosticAuthGrant{}
var task uuid.UUID
err = tx.QueryRow(ctx, `SELECT wt.task_id,sender.email,ea.email,wt.sent_message_id
FROM warmup_tokens wt JOIN warmup_tasks w ON w.task_id=wt.task_id
JOIN email_accounts sender ON sender.id=wt.sender_account_id
JOIN email_accounts ea ON ea.id=wt.recipient_account_id
JOIN warmup_pool_participants wpp ON wpp.email_account_id=ea.id
JOIN warmup_pools wp ON wp.id=wpp.pool_id JOIN fleet_nodes n ON n.id=ea.worker_id
WHERE wt.token=$1 AND ea.id=$2 AND ea.worker_id=$3 AND n.role='worker' AND n.active AND n.warmup_send_protocol>=2 AND n.last_seen_at>NOW()-INTERVAL '10 minutes'
AND w.lineage_version=1 AND w.scenario_version='diagnostic-v1' AND w.rendering_version='canonical-v1'
AND wt.expires_at>NOW() AND wt.sent_message_id<>'' AND btrim(wt.sent_message_id,'<>')=$4
AND sender.status='active' AND (sender.test_mode IS NULL OR sender.test_mode='legacy' OR sender.test_mode='diagnostic' AND sender.test_send_enabled)
AND NOT EXISTS(SELECT 1 FROM cloud_link_mailboxes clm WHERE clm.email_account_id=ea.id)
AND `+partnerEligibleSQL, req.Token, req.MailboxID, req.WorkerID, strings.Trim(req.MessageID, "<> \t")).Scan(&task, &grant.From, &grant.To, &grant.MessageID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSendAdmissionDenied
}
if err != nil {
return nil, err
}
if req.Result == nil {
grant.Nonce = uuid.New()
err = tx.QueryRow(ctx, `INSERT INTO diagnostic_auth_verifications(task_id,email_account_id,worker_id,nonce,message_id)VALUES($1,$2,$3,$4,$5)
ON CONFLICT(task_id,email_account_id) DO UPDATE SET worker_id=EXCLUDED.worker_id,nonce=EXCLUDED.nonce,message_id=EXCLUDED.message_id,authorized_at=NOW()
WHERE diagnostic_auth_verifications.result IS NULL RETURNING nonce`, task, req.MailboxID, req.WorkerID, grant.Nonce, grant.MessageID).Scan(&grant.Nonce)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return grant, tx.Commit(ctx)
}
if req.Nonce == uuid.Nil || !req.Result.Valid(time.Now()) {
return nil, ErrSendAdmissionDenied
}
result := *req.Result
result.Alignment = "unknown"
if result.DKIM == "pass" {
if at := strings.LastIndex(grant.From, "@"); at >= 0 && strings.EqualFold(grant.From[at+1:], result.SigningDomain) {
result.Alignment = "pass"
}
}
raw, err := json.Marshal(result)
if err != nil {
return nil, err
}
var prior []byte
err = tx.QueryRow(ctx, `SELECT result FROM diagnostic_auth_verifications WHERE task_id=$1 AND email_account_id=$2 AND worker_id=$3 AND nonce=$4 AND message_id=$5 AND authorized_at>NOW()-INTERVAL '5 minutes' FOR UPDATE`, task, req.MailboxID, req.WorkerID, req.Nonce, grant.MessageID).Scan(&prior)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSendAdmissionDenied
}
if err != nil {
return nil, err
}
if len(prior) > 0 {
var existing models.DiagnosticDKIMResult
if json.Unmarshal(prior, &existing) != nil || existing.DKIM != result.DKIM || existing.SigningDomain != result.SigningDomain || existing.Alignment != result.Alignment {
return nil, ErrSendAdmissionDenied
}
return nil, nil
}
if _, err = tx.Exec(ctx, `UPDATE diagnostic_auth_verifications SET result=$3,verified_at=NOW() WHERE task_id=$1 AND email_account_id=$2`, task, req.MailboxID, raw); err != nil {
return nil, err
}
if err = attachDiagnosticAuth(ctx, tx, task, req.MailboxID); err != nil {
return nil, err
}
return nil, tx.Commit(ctx)
}
func attachDiagnosticAuth(ctx context.Context, q sendResultDB, task, recipient uuid.UUID) error {
_, err := q.Exec(ctx, `UPDATE warmup_received wr SET evidence=jsonb_set(jsonb_set(jsonb_set(jsonb_set(COALESCE(wr.evidence,'{}'::jsonb),'{dkim}',d.result->'dkim'),'{alignment}',d.result->'alignment'),'{trust}','"worker_cryptographic"'::jsonb),'{dkim_verification}',d.result)
FROM diagnostic_auth_verifications d WHERE wr.task_id=$1 AND wr.email_account_id=$2
AND d.task_id=wr.task_id AND d.email_account_id=wr.email_account_id AND d.result IS NOT NULL AND btrim(d.message_id,'<>')=btrim(wr.message_id,'<>')`, task, recipient)
return err
}
func (r *httpSyncContextRepository) DiagnosticAuth(ctx context.Context, in models.DiagnosticAuthRequest) (*models.DiagnosticAuthGrant, error) {
raw, err := json.Marshal(in)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.baseURL+"/api/v1/internal/worker/diagnostic-auth", bytes.NewReader(raw))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+r.token)
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("diagnostic verification authority unavailable")
}
var out *models.DiagnosticAuthGrant
if err = json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out, nil
}
@@ -0,0 +1,106 @@
package repository
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
func TestLiveDiagnosticAuthBindsWorkerContextAndRetainsOnlyMinimalProof(t *testing.T) {
f, r := lineageFixture(t)
ctx := t.Context()
worker := uuid.New()
token := uuid.New()
exec := func(query string, args ...any) {
t.Helper()
if _, err := f.pool.Exec(ctx, query, args...); err != nil {
t.Fatal(err)
}
}
exec(`INSERT INTO fleet_nodes(id,role,active,last_seen_at,warmup_send_protocol)VALUES($1,'worker',true,NOW(),2)`, worker)
exec(`INSERT INTO workers(id)VALUES($1)`, worker)
t.Cleanup(func() { _, _ = f.pool.Exec(context.Background(), `DELETE FROM fleet_nodes WHERE id=$1`, worker) })
exec(`UPDATE email_accounts SET worker_id=$1,provider=$3 WHERE id=$2`, worker, f.recipient, models.InboxProviderGoogle)
exec(`UPDATE email_accounts SET send_as_email='unused-alias@example.test' WHERE id=$1`, f.sender)
exec(`INSERT INTO warmup_pool_participants(pool_id,email_account_id)SELECT id,$1 FROM warmup_pools WHERE pool_type='free'`, f.recipient)
exec(`INSERT INTO warmup_tasks(task_id,lineage_version,subject,scenario_version,rendering_version,max_turns)VALUES($1,1,'Diagnostic','diagnostic-v1','canonical-v1',1)`, f.task)
exec(`INSERT INTO warmup_tokens(token,task_id,sender_account_id,recipient_account_id,sent_message_id)VALUES($1,$2,$3,$4,'<probe@example.test>')`, token, f.task, f.sender, f.recipient)
request := models.DiagnosticAuthRequest{Token: token, MailboxID: f.recipient, WorkerID: worker, MessageID: "probe@example.test"}
for _, bad := range []models.DiagnosticAuthRequest{
{Token: token, MailboxID: f.recipient, WorkerID: uuid.New(), MessageID: request.MessageID},
{Token: token, MailboxID: f.sender, WorkerID: worker, MessageID: request.MessageID},
{Token: uuid.New(), MailboxID: f.recipient, WorkerID: worker, MessageID: request.MessageID},
{Token: token, MailboxID: f.recipient, WorkerID: worker, MessageID: "unrelated@example.test"},
} {
if grant, err := r.DiagnosticAuth(ctx, bad); err == nil || grant != nil {
t.Fatal("unrelated context authorized", bad, grant)
}
}
grant, err := r.DiagnosticAuth(ctx, request)
if err != nil || grant == nil || grant.Nonce == uuid.Nil {
t.Fatalf("grant=%+v %v", grant, err)
}
if grant.From != "pl-"+f.sender.String()[:8]+"@test.local" {
t.Fatal("diagnostic authorized unused cold-mail alias", grant.From)
}
request.Nonce = grant.Nonce
request.Result = &models.DiagnosticDKIMResult{DKIM: "pass", Alignment: "pass", SigningDomain: "test.local", Verifier: models.DiagnosticDKIMVerifier, ObservedAt: time.Now()}
exec(`UPDATE email_accounts SET test_mode='off' WHERE id=$1`, f.recipient)
if _, err = r.DiagnosticAuth(ctx, request); err == nil {
t.Fatal("revocation did not fence proof completion")
}
exec(`UPDATE email_accounts SET test_mode='diagnostic',test_receive_enabled=true WHERE id=$1`, f.recipient)
wrong := request
wrong.Nonce = uuid.New()
if _, err = r.DiagnosticAuth(ctx, wrong); err == nil {
t.Fatal("wrong grant nonce accepted")
}
if _, err = r.DiagnosticAuth(ctx, request); err != nil {
t.Fatal(err)
}
if _, err = r.DiagnosticAuth(ctx, request); err != nil {
t.Fatal("duplicate proof rejected", err)
}
var stored string
var rows int
if err = f.pool.QueryRow(ctx, `SELECT result::text FROM diagnostic_auth_verifications WHERE task_id=$1`, f.task).Scan(&stored); err != nil {
t.Fatal(err)
}
if err = f.pool.QueryRow(ctx, `SELECT COUNT(*) FROM diagnostic_auth_verifications WHERE task_id=$1`, f.task).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 1 || strings.Contains(stored, "From:") || strings.Contains(stored, "Body") || !strings.Contains(stored, models.DiagnosticDKIMVerifier) {
t.Fatal("nonminimal or duplicate proof", stored)
}
request.Result.DKIM = "fail"
request.Result.Alignment = "unknown"
request.Result.SigningDomain = ""
if _, err = r.DiagnosticAuth(ctx, request); err == nil {
t.Fatal("late conflicting proof overwrote immutable first result")
}
request.Result = nil
if next, err := r.DiagnosticAuth(ctx, request); err != nil || next != nil {
t.Fatal("completed diagnostic fetched again", next, err)
}
receipt := uuid.New()
exec(`INSERT INTO warmup_received(email_account_id,internal_id,sender_account_id,message_id,evidence)VALUES($1,$2,$3,'probe@example.test','{"spf":"unknown","dkim":"unknown","dmarc":"unknown","tls":"unknown","alignment":"unknown","trust":"unverified_headers"}')`, f.recipient, receipt, f.sender)
if err = r.RecordVerifiedWarmupParent(ctx, f.task, f.recipient, receipt, ""); err != nil {
t.Fatal(err)
}
var evidence models.ReceivedEvidence
var data []byte
if err = f.pool.QueryRow(ctx, `SELECT evidence FROM warmup_received WHERE email_account_id=$1 AND internal_id=$2`, f.recipient, receipt).Scan(&data); err != nil {
t.Fatal(err)
}
if err = json.Unmarshal(data, &evidence); err != nil {
t.Fatal(err)
}
if evidence.DKIM != "pass" || evidence.Alignment != "pass" || evidence.DKIMVerification == nil || evidence.SPF != "unknown" || evidence.DMARC != "unknown" || evidence.TLS != "unknown" {
t.Fatal("late exact receipt lost crypto proof or fabricated other auth", evidence)
}
}
+1
View File
@@ -106,6 +106,7 @@ func (r *cloudLinkRepository) CanBrokerManagedToken(ctx context.Context, account
WHERE ea.id = $1 AND ea.status = 'active' AND o.risk_state IN ('trusted','watch')
AND clm.managed AND clm.enrollment_state = 'active' AND NOT l.disconnect_pending
AND clm.standing_observed_at > NOW() - INTERVAL '15 minutes'
AND clm.standing_observed_at <= NOW()
AND (standing.health_state NOT IN ('blocked','quarantined') OR standing.blocked_until <= NOW())
AND NOT EXISTS (SELECT 1 FROM cloud_managed_consents c WHERE c.instance_id = l.instance_id
AND (c.email_account_id = ea.id OR c.planned_account_id = ea.id) AND c.consent_state <> 'active'))`, accountID).Scan(&allowed)
+52 -52
View File
@@ -99,7 +99,7 @@ func unmarshalJSON[T any](b []byte, out *T) error {
func (r *advancedOutreachRepository) GetOutreachSettings(ctx context.Context, organizationID uuid.UUID) (*models.AdvancedOutreachSettings, error) {
query := `SELECT settings FROM outreach_settings WHERE organization_id = $1`
var raw []byte
if err := r.db.QueryRow(ctx, query, organizationID).Scan(&raw); err != nil {
if err := resultDB(ctx, r.db).QueryRow(ctx, query, organizationID).Scan(&raw); err != nil {
if err == pgx.ErrNoRows {
def := models.DefaultAdvancedOutreachSettings()
return &def, nil
@@ -125,7 +125,7 @@ func (r *advancedOutreachRepository) UpsertOutreachSettings(ctx context.Context,
ON CONFLICT (organization_id)
DO UPDATE SET settings = EXCLUDED.settings, updated_by = EXCLUDED.updated_by, updated_at = NOW()
`
_, err = r.db.Exec(ctx, query, organizationID, raw, updatedBy)
_, err = resultDB(ctx, r.db).Exec(ctx, query, organizationID, raw, updatedBy)
return err
}
@@ -133,7 +133,7 @@ func (r *advancedOutreachRepository) GetCampaignAdvancedSettings(ctx context.Con
query := `SELECT settings, updated_at FROM campaign_advanced_settings WHERE campaign_id = $1`
var raw []byte
var updatedAt time.Time
if err := r.db.QueryRow(ctx, query, campaignID).Scan(&raw, &updatedAt); err != nil {
if err := resultDB(ctx, r.db).QueryRow(ctx, query, campaignID).Scan(&raw, &updatedAt); err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
@@ -161,7 +161,7 @@ func (r *advancedOutreachRepository) UpsertCampaignAdvancedSettings(ctx context.
ON CONFLICT (campaign_id)
DO UPDATE SET settings = EXCLUDED.settings, updated_at = NOW()
`
_, err = r.db.Exec(ctx, query, campaignID, raw)
_, err = resultDB(ctx, r.db).Exec(ctx, query, campaignID, raw)
return err
}
@@ -202,7 +202,7 @@ func (r *advancedOutreachRepository) ListABVariants(ctx context.Context, campaig
WHERE campaign_id = $1
ORDER BY is_control DESC, created_at ASC
`
rows, err := r.db.Query(ctx, query, campaignID)
rows, err := resultDB(ctx, r.db).Query(ctx, query, campaignID)
if err != nil {
return nil, err
}
@@ -234,7 +234,7 @@ func (r *advancedOutreachRepository) CreateABVariant(ctx context.Context, campai
}
if req.SequenceID != nil {
var ok bool
if err := r.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM sequences WHERE id = $1 AND campaign_id = $2)`,
if err := resultDB(ctx, r.db).QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM sequences WHERE id = $1 AND campaign_id = $2)`,
*req.SequenceID, campaignID).Scan(&ok); err != nil {
return nil, err
}
@@ -250,7 +250,7 @@ func (r *advancedOutreachRepository) CreateABVariant(ctx context.Context, campai
RETURNING id, campaign_id, sequence_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at
`
var out models.CampaignABVariant
if err := scanABVariant(r.db.QueryRow(ctx, query,
if err := scanABVariant(resultDB(ctx, r.db).QueryRow(ctx, query,
campaignID,
req.SequenceID,
req.Name,
@@ -330,7 +330,7 @@ func (r *advancedOutreachRepository) UpdateABVariant(ctx context.Context, campai
`, strings.Join(sets, ", "))
var out models.CampaignABVariant
if err := scanABVariant(r.db.QueryRow(ctx, query, args...), &out); err != nil {
if err := scanABVariant(resultDB(ctx, r.db).QueryRow(ctx, query, args...), &out); err != nil {
return nil, err
}
return &out, nil
@@ -338,7 +338,7 @@ func (r *advancedOutreachRepository) UpdateABVariant(ctx context.Context, campai
func (r *advancedOutreachRepository) DeleteABVariant(ctx context.Context, campaignID, variantID uuid.UUID) error {
query := `DELETE FROM campaign_ab_variants WHERE campaign_id = $1 AND id = $2`
cmd, err := r.db.Exec(ctx, query, campaignID, variantID)
cmd, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, variantID)
if err != nil {
return err
}
@@ -356,7 +356,7 @@ func (r *advancedOutreachRepository) GetAssignedVariant(ctx context.Context, cam
WHERE a.campaign_id = $1 AND a.contact_id = $2
`
var out models.CampaignABVariant
if err := scanABVariant(r.db.QueryRow(ctx, query, campaignID, contactID), &out); err != nil {
if err := scanABVariant(resultDB(ctx, r.db).QueryRow(ctx, query, campaignID, contactID), &out); err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
@@ -372,7 +372,7 @@ func (r *advancedOutreachRepository) AssignVariant(ctx context.Context, campaign
ON CONFLICT (campaign_id, contact_id)
DO UPDATE SET variant_id = EXCLUDED.variant_id, assigned_at = NOW()
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, variantID)
_, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID, variantID)
return err
}
@@ -391,7 +391,7 @@ func (r *advancedOutreachRepository) MarkVariantEvent(ctx context.Context, campa
return nil
}
query := fmt.Sprintf(`UPDATE campaign_ab_assignments SET %s WHERE campaign_id = $1 AND contact_id = $2`, setClause)
_, err := r.db.Exec(ctx, query, campaignID, contactID)
_, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID)
return err
}
@@ -440,7 +440,7 @@ func (r *advancedOutreachRepository) IsRecipientSuppressed(ctx context.Context,
ORDER BY (kind = 'email') DESC
LIMIT 1
`
out, err := scanSuppressedRecipient(r.db.QueryRow(ctx, query, organizationID, email))
out, err := scanSuppressedRecipient(resultDB(ctx, r.db).QueryRow(ctx, query, organizationID, email))
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
@@ -472,7 +472,7 @@ func (r *advancedOutreachRepository) UpsertSuppressedRecipient(ctx context.Conte
metadata = EXCLUDED.metadata,
updated_at = NOW()
`
_, err = r.db.Exec(ctx, query, entry.OrganizationID, strings.ToLower(strings.TrimSpace(entry.Email)), kind, entry.Reason, entry.Source, entry.CampaignID, entry.ExpiresAt, metadata)
_, err = resultDB(ctx, r.db).Exec(ctx, query, entry.OrganizationID, strings.ToLower(strings.TrimSpace(entry.Email)), kind, entry.Reason, entry.Source, entry.CampaignID, entry.ExpiresAt, metadata)
return err
}
@@ -494,7 +494,7 @@ func (r *advancedOutreachRepository) UpsertSuppressedRecipients(ctx context.Cont
if len(entries) == 0 {
return nil
}
tx, err := r.db.Begin(ctx)
tx, err := beginResultTx(ctx, r.db)
if err != nil {
return err
}
@@ -536,7 +536,7 @@ func (r *advancedOutreachRepository) ListSuppressedRecipients(ctx context.Contex
args = append(args, *beforeAt, *beforeID)
where += fmt.Sprintf(` AND (created_at, id) < ($%d, $%d)`, len(args)-1, len(args))
}
rows, err := r.db.Query(ctx, `SELECT `+suppressedRecipientColumns+` FROM suppressed_recipients WHERE `+where+` ORDER BY created_at DESC, id DESC LIMIT $2`, args...)
rows, err := resultDB(ctx, r.db).Query(ctx, `SELECT `+suppressedRecipientColumns+` FROM suppressed_recipients WHERE `+where+` ORDER BY created_at DESC, id DESC LIMIT $2`, args...)
if err != nil {
return nil, err
}
@@ -553,7 +553,7 @@ func (r *advancedOutreachRepository) ListSuppressedRecipients(ctx context.Contex
}
func (r *advancedOutreachRepository) GetSuppressedRecipient(ctx context.Context, organizationID, id uuid.UUID) (*models.SuppressedRecipient, error) {
out, err := scanSuppressedRecipient(r.db.QueryRow(ctx, `SELECT `+suppressedRecipientColumns+` FROM suppressed_recipients WHERE organization_id = $1 AND id = $2`, organizationID, id))
out, err := scanSuppressedRecipient(resultDB(ctx, r.db).QueryRow(ctx, `SELECT `+suppressedRecipientColumns+` FROM suppressed_recipients WHERE organization_id = $1 AND id = $2`, organizationID, id))
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
@@ -564,7 +564,7 @@ func (r *advancedOutreachRepository) GetSuppressedRecipient(ctx context.Context,
}
func (r *advancedOutreachRepository) DeleteSuppressedRecipient(ctx context.Context, organizationID, id uuid.UUID) (bool, error) {
tag, err := r.db.Exec(ctx, `DELETE FROM suppressed_recipients WHERE organization_id = $1 AND id = $2`, organizationID, id)
tag, err := resultDB(ctx, r.db).Exec(ctx, `DELETE FROM suppressed_recipients WHERE organization_id = $1 AND id = $2`, organizationID, id)
if err != nil {
return false, err
}
@@ -572,7 +572,7 @@ func (r *advancedOutreachRepository) DeleteSuppressedRecipient(ctx context.Conte
}
func (r *advancedOutreachRepository) DeleteSuppressionByEmail(ctx context.Context, organizationID uuid.UUID, email string, source models.DeliverabilityEventType) (bool, error) {
tag, err := r.db.Exec(ctx, `DELETE FROM suppressed_recipients WHERE organization_id = $1 AND kind = 'email' AND email = LOWER($2) AND source = $3`, organizationID, strings.TrimSpace(email), source)
tag, err := resultDB(ctx, r.db).Exec(ctx, `DELETE FROM suppressed_recipients WHERE organization_id = $1 AND kind = 'email' AND email = LOWER($2) AND source = $3`, organizationID, strings.TrimSpace(email), source)
if err != nil {
return false, err
}
@@ -583,7 +583,7 @@ func (r *advancedOutreachRepository) DeleteSuppressionByEmail(ctx context.Contex
const replyOptOutCheckKey = "reply_optout_recheck"
func (r *advancedOutreachRepository) ListUncheckedReplyOptOuts(ctx context.Context, afterID uuid.UUID, limit int) ([]models.SuppressedRecipient, error) {
rows, err := r.db.Query(ctx, `
rows, err := resultDB(ctx, r.db).Query(ctx, `
SELECT `+suppressedRecipientColumns+`
FROM suppressed_recipients
WHERE id > $1
@@ -610,7 +610,7 @@ func (r *advancedOutreachRepository) ListUncheckedReplyOptOuts(ctx context.Conte
}
func (r *advancedOutreachRepository) DeleteReplyOptOut(ctx context.Context, organizationID, id uuid.UUID, updatedAt time.Time) (bool, error) {
tag, err := r.db.Exec(ctx, `
tag, err := resultDB(ctx, r.db).Exec(ctx, `
DELETE FROM suppressed_recipients
WHERE organization_id = $1 AND id = $2
AND metadata->>'via' = 'reply' AND updated_at = $3`, organizationID, id, updatedAt)
@@ -621,7 +621,7 @@ func (r *advancedOutreachRepository) DeleteReplyOptOut(ctx context.Context, orga
}
func (r *advancedOutreachRepository) MarkReplyOptOutChecked(ctx context.Context, id uuid.UUID, outcome string) error {
_, err := r.db.Exec(ctx, `
_, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE suppressed_recipients
SET metadata = metadata || jsonb_build_object('`+replyOptOutCheckKey+`', $2::text)
WHERE id = $1`, id, outcome)
@@ -641,7 +641,7 @@ func (r *advancedOutreachRepository) CreateDeliverabilityEvent(ctx context.Conte
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
ON CONFLICT (organization_id, idempotency_key) DO NOTHING
`
_, err = r.db.Exec(ctx, query,
_, err = resultDB(ctx, r.db).Exec(ctx, query,
event.OrganizationID,
event.CampaignID,
event.TaskID,
@@ -676,7 +676,7 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont
AND created_at >= $2
AND created_at <= $3
`
if err := r.db.QueryRow(ctx, queryEvents, organizationID, from, to).Scan(
if err := resultDB(ctx, r.db).QueryRow(ctx, queryEvents, organizationID, from, to).Scan(
&out.EventsTotal,
&out.BounceCount,
&out.ComplaintCount,
@@ -689,7 +689,7 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont
}
querySuppressed := `SELECT COUNT(*) FROM suppressed_recipients WHERE organization_id = $1 AND (expires_at IS NULL OR expires_at > NOW())`
if err := r.db.QueryRow(ctx, querySuppressed, organizationID).Scan(&out.SuppressedRecipients); err != nil {
if err := resultDB(ctx, r.db).QueryRow(ctx, querySuppressed, organizationID).Scan(&out.SuppressedRecipients); err != nil {
return nil, err
}
@@ -701,7 +701,7 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont
WHERE ea.organization_id = $1
AND d.status = 'pending'
`
if err := r.db.QueryRow(ctx, queryDLQ, organizationID).Scan(&out.DLQPending); err != nil {
if err := resultDB(ctx, r.db).QueryRow(ctx, queryDLQ, organizationID).Scan(&out.DLQPending); err != nil {
return nil, err
}
@@ -718,7 +718,7 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont
AND created_at >= $2
AND created_at <= $3
`
if err := r.db.QueryRow(ctx, queryIntents, organizationID, from, to).Scan(
if err := resultDB(ctx, r.db).QueryRow(ctx, queryIntents, organizationID, from, to).Scan(
&out.IntentPositive,
&out.IntentNegative,
&out.IntentOOO,
@@ -737,7 +737,7 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont
WHERE ea.organization_id = $1 AND t.task_type = 'campaign' AND t.status = 'completed'
AND t.completed_at >= $2 AND t.completed_at <= $3
AND ` + taskDispatchedEmail
_ = r.db.QueryRow(ctx, sentQuery, organizationID, from, to).Scan(&out.EmailsSent)
_ = resultDB(ctx, r.db).QueryRow(ctx, sentQuery, organizationID, from, to).Scan(&out.EmailsSent)
out.BounceRate = models.Rate(out.BounceCount, out.EmailsSent)
out.ComplaintRate = models.Rate(out.ComplaintCount, out.EmailsSent)
out.OpenRate = models.Rate(out.OpenCount, out.EmailsSent)
@@ -759,7 +759,7 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont
AND pr.folder IN ('inbox', 'promotions', 'other', 'spam', 'missing')
AND pt.origin <> 'remote'
GROUP BY pr.provider, pr.folder`
if rows, perr := r.db.Query(ctx, placementQuery, organizationID, from, to); perr == nil {
if rows, perr := resultDB(ctx, r.db).Query(ctx, placementQuery, organizationID, from, to); perr == nil {
for rows.Next() {
var provider, folder string
var n int
@@ -832,7 +832,7 @@ func (r *advancedOutreachRepository) warmupPlacementByHost(ctx context.Context,
JOIN email_accounts snd ON snd.id = p.sender_account_id
WHERE snd.organization_id = $1 AND p.date >= $2::date AND p.date <= $3::date
GROUP BY 1, 2`
rows, err := r.db.Query(ctx, query, orgID, from, to)
rows, err := resultDB(ctx, r.db).Query(ctx, query, orgID, from, to)
if err != nil {
return out
}
@@ -887,7 +887,7 @@ func (r *advancedOutreachRepository) deliverabilityTimeseries(ctx context.Contex
FROM deliverability_events
WHERE organization_id=$1 AND created_at >= $2 AND created_at <= $3
GROUP BY 1`
if rows, err := r.db.Query(ctx, evQ, orgID, from, to); err == nil {
if rows, err := resultDB(ctx, r.db).Query(ctx, evQ, orgID, from, to); err == nil {
for rows.Next() {
var d time.Time
var b, c, o, cl, rep, u int
@@ -904,7 +904,7 @@ func (r *advancedOutreachRepository) deliverabilityTimeseries(ctx context.Contex
WHERE ea.organization_id=$1 AND t.task_type='campaign' AND t.status='completed'
AND t.completed_at >= $2 AND t.completed_at <= $3
GROUP BY 1`
if rows, err := r.db.Query(ctx, sentQ, orgID, from, to); err == nil {
if rows, err := resultDB(ctx, r.db).Query(ctx, sentQ, orgID, from, to); err == nil {
for rows.Next() {
var d time.Time
var s int
@@ -944,7 +944,7 @@ func (r *advancedOutreachRepository) deliverabilityByCampaign(ctx context.Contex
GROUP BY de.campaign_id, c.name
ORDER BY (COUNT(*) FILTER (WHERE de.event_type='bounce') + COUNT(*) FILTER (WHERE de.event_type='complaint')) DESC
LIMIT 20`
rows, err := r.db.Query(ctx, q, orgID, from, to)
rows, err := resultDB(ctx, r.db).Query(ctx, q, orgID, from, to)
if err != nil {
return out
}
@@ -971,7 +971,7 @@ func (r *advancedOutreachRepository) deliverabilityByCampaign(ctx context.Contex
WHERE c.organization_id=$1 AND ccp.sent_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3
AND ` + progressIsEmailStep("ccp") + `
GROUP BY ccp.campaign_id`
if srows, serr := r.db.Query(ctx, sq, orgID, from, to); serr == nil {
if srows, serr := resultDB(ctx, r.db).Query(ctx, sq, orgID, from, to); serr == nil {
for srows.Next() {
var id uuid.UUID
var n int
@@ -1005,7 +1005,7 @@ func (r *advancedOutreachRepository) deliverabilityByMailbox(ctx context.Context
GROUP BY ea.id, ea.email
ORDER BY (COUNT(*) FILTER (WHERE de.event_type='bounce') + COUNT(*) FILTER (WHERE de.event_type='complaint')) DESC
LIMIT 50`
rows, err := r.db.Query(ctx, q, orgID, from, to)
rows, err := resultDB(ctx, r.db).Query(ctx, q, orgID, from, to)
if err != nil {
return out
}
@@ -1032,7 +1032,7 @@ func (r *advancedOutreachRepository) deliverabilityByMailbox(ctx context.Context
WHERE ea.organization_id=$1 AND t.task_type='campaign' AND t.status='completed'
AND t.completed_at >= $2 AND t.completed_at <= $3
GROUP BY t.email_account_id`
if srows, serr := r.db.Query(ctx, sq, orgID, from, to); serr == nil {
if srows, serr := resultDB(ctx, r.db).Query(ctx, sq, orgID, from, to); serr == nil {
for srows.Next() {
var id uuid.UUID
var n int
@@ -1058,32 +1058,32 @@ const executionLockTTL = 5 * time.Minute
func (r *advancedOutreachRepository) StartTaskExecution(ctx context.Context, taskID uuid.UUID, executionKey string, metadata map[string]interface{}) (bool, error) {
var existingStatus string
var lastSeenAt time.Time
err := r.db.QueryRow(ctx,
err := resultDB(ctx, r.db).QueryRow(ctx,
`SELECT status, last_seen_at FROM task_execution_keys WHERE task_id = $1 AND execution_key = $2`,
taskID, executionKey,
).Scan(&existingStatus, &lastSeenAt)
if err == nil {
switch existingStatus {
case "completed":
_, _ = r.db.Exec(ctx, `UPDATE task_execution_keys SET attempts = attempts + 1, last_seen_at = NOW() WHERE task_id = $1 AND execution_key = $2`, taskID, executionKey)
_, _ = resultDB(ctx, r.db).Exec(ctx, `UPDATE task_execution_keys SET attempts = attempts + 1, last_seen_at = NOW() WHERE task_id = $1 AND execution_key = $2`, taskID, executionKey)
return true, nil
case "in_progress":
// Check if the lock has expired (worker likely crashed)
if time.Since(lastSeenAt) > executionLockTTL {
// Expired lock - reclaim it
meta, _ := marshalJSON(metadata)
_, err := r.db.Exec(ctx, `
_, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE task_execution_keys
SET attempts = attempts + 1, last_seen_at = NOW(), status = 'in_progress', metadata = $3
WHERE task_id = $1 AND execution_key = $2
`, taskID, executionKey, meta)
return false, err
}
_, _ = r.db.Exec(ctx, `UPDATE task_execution_keys SET attempts = attempts + 1, last_seen_at = NOW() WHERE task_id = $1 AND execution_key = $2`, taskID, executionKey)
_, _ = resultDB(ctx, r.db).Exec(ctx, `UPDATE task_execution_keys SET attempts = attempts + 1, last_seen_at = NOW() WHERE task_id = $1 AND execution_key = $2`, taskID, executionKey)
return true, nil
default:
meta, _ := marshalJSON(metadata)
_, err := r.db.Exec(ctx, `
_, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE task_execution_keys
SET attempts = attempts + 1, last_seen_at = NOW(), status = 'in_progress', metadata = $3
WHERE task_id = $1 AND execution_key = $2
@@ -1096,7 +1096,7 @@ func (r *advancedOutreachRepository) StartTaskExecution(ctx context.Context, tas
}
meta, _ := marshalJSON(metadata)
_, err = r.db.Exec(ctx, `
_, err = resultDB(ctx, r.db).Exec(ctx, `
INSERT INTO task_execution_keys (task_id, execution_key, status, metadata, first_seen_at, last_seen_at, attempts)
VALUES ($1, $2, 'in_progress', $3, NOW(), NOW(), 1)
`, taskID, executionKey, meta)
@@ -1105,7 +1105,7 @@ func (r *advancedOutreachRepository) StartTaskExecution(ctx context.Context, tas
func (r *advancedOutreachRepository) CompleteTaskExecution(ctx context.Context, taskID uuid.UUID, executionKey, status string, metadata map[string]interface{}) error {
meta, _ := marshalJSON(metadata)
_, err := r.db.Exec(ctx, `
_, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE task_execution_keys
SET status = $3, metadata = $4, last_seen_at = NOW()
WHERE task_id = $1 AND execution_key = $2
@@ -1128,7 +1128,7 @@ func (r *advancedOutreachRepository) CreateTaskDeadLetter(ctx context.Context, i
if item.Status == "" {
item.Status = "pending"
}
_, err = r.db.Exec(ctx, query, item.TaskID, item.TaskType, payload, item.LastError, item.Attempts, item.MaxAttempts, item.Status, item.NextRetryAt)
_, err = resultDB(ctx, r.db).Exec(ctx, query, item.TaskID, item.TaskType, payload, item.LastError, item.Attempts, item.MaxAttempts, item.Status, item.NextRetryAt)
return err
}
@@ -1146,7 +1146,7 @@ func (r *advancedOutreachRepository) ListTaskDeadLetters(ctx context.Context, or
ORDER BY d.updated_at DESC
LIMIT $3
`
rows, err := r.db.Query(ctx, query, organizationID, status, limit)
rows, err := resultDB(ctx, r.db).Query(ctx, query, organizationID, status, limit)
if err != nil {
return nil, err
}
@@ -1193,7 +1193,7 @@ func (r *advancedOutreachRepository) GetTaskDeadLetter(ctx context.Context, id u
`
var d models.TaskDeadLetter
var payload []byte
if err := r.db.QueryRow(ctx, query, id, organizationID).Scan(
if err := resultDB(ctx, r.db).QueryRow(ctx, query, id, organizationID).Scan(
&d.ID,
&d.TaskID,
&d.TaskType,
@@ -1222,7 +1222,7 @@ func (r *advancedOutreachRepository) GetTaskDeadLetter(ctx context.Context, id u
}
func (r *advancedOutreachRepository) MarkTaskDeadLetterReplayed(ctx context.Context, id uuid.UUID) error {
_, err := r.db.Exec(ctx, `
_, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE task_dead_letters
SET status = 'replayed', replayed_at = NOW(), updated_at = NOW()
WHERE id = $1
@@ -1241,7 +1241,7 @@ func (r *advancedOutreachRepository) CreateReplyIntent(ctx context.Context, reco
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
`
_, err = r.db.Exec(ctx, query,
_, err = resultDB(ctx, r.db).Exec(ctx, query,
record.OrganizationID,
strings.ToLower(strings.TrimSpace(record.ContactEmail)),
record.CampaignID,
@@ -1267,7 +1267,7 @@ func (r *advancedOutreachRepository) CreatePreflightReport(ctx context.Context,
INSERT INTO preflight_reports (organization_id, campaign_id, passed, score, checks, recommendations, created_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`
_, err = r.db.Exec(ctx, query, report.OrganizationID, report.CampaignID, report.Passed, report.Score, checks, recommendations)
_, err = resultDB(ctx, r.db).Exec(ctx, query, report.OrganizationID, report.CampaignID, report.Passed, report.Score, checks, recommendations)
return err
}
@@ -1286,7 +1286,7 @@ func (r *advancedOutreachRepository) GetABVariantStats(ctx context.Context, camp
GROUP BY v.id, v.name
ORDER BY v.created_at
`
rows, err := r.db.Query(ctx, query, campaignID)
rows, err := resultDB(ctx, r.db).Query(ctx, query, campaignID)
if err != nil {
return nil, err
}
@@ -1323,7 +1323,7 @@ func (r *advancedOutreachRepository) ListRetryableDeadLetters(ctx context.Contex
ORDER BY next_retry_at ASC
LIMIT $1
`
rows, err := r.db.Query(ctx, query, limit)
rows, err := resultDB(ctx, r.db).Query(ctx, query, limit)
if err != nil {
return nil, err
}
@@ -1345,7 +1345,7 @@ func (r *advancedOutreachRepository) ListRetryableDeadLetters(ctx context.Contex
}
func (r *advancedOutreachRepository) IncrementDeadLetterAttempt(ctx context.Context, id uuid.UUID, nextRetryAt *time.Time) error {
_, err := r.db.Exec(ctx, `
_, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE task_dead_letters
SET attempts = attempts + 1, next_retry_at = $2, updated_at = NOW()
WHERE id = $1
+42 -42
View File
@@ -232,7 +232,7 @@ func getCampaignFull(rows db.Scannable, campaign *models.Campaign) error {
// the workspace timezone, else UTC.
func (r *campaignRepository) WorkspaceTimezone(ctx context.Context, orgID uuid.UUID) string {
var zone string
if err := r.DB.QueryRow(ctx, `SELECT timezone FROM organizations WHERE id = $1`, orgID).Scan(&zone); err == nil && zone != "" {
if err := resultDB(ctx, r.DB).QueryRow(ctx, `SELECT timezone FROM organizations WHERE id = $1`, orgID).Scan(&zone); err == nil && zone != "" {
return zone
}
return "UTC"
@@ -476,7 +476,7 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u
return nil, errx.New(errx.BadRequest, "explicit sender strategy requires at least one sender")
}
tx, err := r.DB.Begin(ctx)
tx, err := beginResultTx(ctx, r.DB)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
@@ -771,7 +771,7 @@ func (r *campaignRepository) Get(ctx context.Context, orgID, id string) (*models
}
func (r *campaignRepository) Search(ctx context.Context, orgID, query string, cursor, folder *string, status string, limit int32) (*models.CampaignsResult, error) {
tx, err := r.DB.Begin(ctx)
tx, err := beginResultTx(ctx, r.DB)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, err
@@ -901,7 +901,7 @@ func (r *campaignRepository) Overview(ctx context.Context, orgID string) (*model
COUNT(*) FILTER (WHERE status = 'completed')
FROM campaigns
WHERE organization_id = $1`
err := r.DB.QueryRow(ctx, countsSQL, orgID).Scan(
err := resultDB(ctx, r.DB).QueryRow(ctx, countsSQL, orgID).Scan(
&overview.Total,
&overview.Active,
&overview.Paused,
@@ -919,7 +919,7 @@ func (r *campaignRepository) Overview(ctx context.Context, orgID string) (*model
JOIN campaigns c ON c.id = cf.campaign_id
WHERE c.organization_id = $1
GROUP BY cf.folder_id`
rows, err := r.DB.Query(ctx, foldersSQL, orgID)
rows, err := resultDB(ctx, r.DB).Query(ctx, foldersSQL, orgID)
if err != nil {
db.CaptureError(err, foldersSQL, []any{orgID}, "query")
return nil, err
@@ -1182,7 +1182,7 @@ func (r *campaignRepository) Update(ctx context.Context, orgID, campaignID strin
if data.RampStart != nil || data.RampCeiling != nil {
start, ceiling := 0, 0
if data.RampStart == nil || data.RampCeiling == nil {
err := r.DB.QueryRow(ctx,
err := resultDB(ctx, r.DB).QueryRow(ctx,
"SELECT ramp_start, ramp_ceiling FROM campaigns WHERE organization_id = $1 AND id = $2",
orgID, campaignID).Scan(&start, &ceiling)
if err != nil {
@@ -1328,7 +1328,7 @@ func (r *campaignRepository) Update(ctx context.Context, orgID, campaignID strin
setClauses = append(setClauses, "updated_at = now()")
tx, err := r.DB.Begin(ctx)
tx, err := beginResultTx(ctx, r.DB)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
@@ -1429,7 +1429,7 @@ func (r *campaignRepository) GetByID(ctx context.Context, campaignID uuid.UUID)
CAMPAIGN_SELECT_FULL,
)
row := r.DB.QueryRow(ctx, query, campaignID)
row := resultDB(ctx, r.DB).QueryRow(ctx, query, campaignID)
err := row.Scan(
&campaign.UserID, &campaign.OrganizationID,
&campaign.ID, &campaign.Name, &campaign.Description, &campaign.Status,
@@ -1472,7 +1472,7 @@ func (r *campaignRepository) GetSequenceByID(ctx context.Context, sequenceID uui
`
var seq models.Sequence
err := r.DB.QueryRow(ctx, query, sequenceID).Scan(
err := resultDB(ctx, r.DB).QueryRow(ctx, query, sequenceID).Scan(
&seq.ID, &seq.Name, &seq.Subject, &seq.BodyPlain, &seq.BodyHTML,
&seq.BodySync, &seq.BodyCode, &seq.WaitAfter, &seq.ThreadReply, &seq.Kind, &seq.Action, &seq.UpdatedAt, &seq.CreatedAt,
)
@@ -1496,7 +1496,7 @@ func (r *campaignRepository) GetSequencesByCampaignID(ctx context.Context, campa
ORDER BY position ASC, created_at ASC
`
rows, err := r.DB.Query(ctx, query, campaignID)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, campaignID)
if err != nil {
db.CaptureError(err, query, []any{campaignID}, "query")
return nil, err
@@ -1533,7 +1533,7 @@ func (r *campaignRepository) GetSequencesRoutingByCampaignID(ctx context.Context
ORDER BY position ASC, created_at ASC
`
rows, err := r.DB.Query(ctx, query, campaignID)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, campaignID)
if err != nil {
db.CaptureError(err, query, []any{campaignID}, "query")
return nil, err
@@ -1576,7 +1576,7 @@ func (r *campaignRepository) UpdateStatus(ctx context.Context, campaignID uuid.U
// Validate that the transition is allowed
var currentStatus string
if err := r.DB.QueryRow(ctx, `SELECT status FROM campaigns WHERE id = $1`, campaignID).Scan(&currentStatus); err != nil {
if err := resultDB(ctx, r.DB).QueryRow(ctx, `SELECT status FROM campaigns WHERE id = $1`, campaignID).Scan(&currentStatus); err != nil {
return err
}
allowed, ok := validCampaignTransitions[currentStatus]
@@ -1584,7 +1584,7 @@ func (r *campaignRepository) UpdateStatus(ctx context.Context, campaignID uuid.U
return fmt.Errorf("invalid campaign transition from %q to %q", currentStatus, status)
}
_, err := r.DB.Exec(ctx, query, status, campaignID)
_, err := resultDB(ctx, r.DB).Exec(ctx, query, status, campaignID)
return err
}
@@ -1608,14 +1608,14 @@ func (r *campaignRepository) StartCampaign(ctx context.Context, campaignID uuid.
guardrail_tripped_at = NULL,
guardrail_reason = ''
WHERE id = $1`
_, err := r.DB.Exec(ctx, query, campaignID)
_, err := resultDB(ctx, r.DB).Exec(ctx, query, campaignID)
return err
}
// StopCampaign sets campaign status to paused and updates last_status_change_at
func (r *campaignRepository) StopCampaign(ctx context.Context, campaignID uuid.UUID) error {
query := `UPDATE campaigns SET status = 'paused', idle_since = NULL, last_status_change_at = NOW(), updated_at = NOW() WHERE id = $1`
_, err := r.DB.Exec(ctx, query, campaignID)
_, err := resultDB(ctx, r.DB).Exec(ctx, query, campaignID)
return err
}
@@ -1623,7 +1623,7 @@ func (r *campaignRepository) StopCampaign(ctx context.Context, campaignID uuid.U
func (r *campaignRepository) ValidateCampaignReady(ctx context.Context, campaignID uuid.UUID) error {
// Check sequences
var seqCount int
err := r.DB.QueryRow(ctx, `SELECT COUNT(*) FROM sequences WHERE campaign_id = $1`, campaignID).Scan(&seqCount)
err := resultDB(ctx, r.DB).QueryRow(ctx, `SELECT COUNT(*) FROM sequences WHERE campaign_id = $1`, campaignID).Scan(&seqCount)
if err != nil {
return err
}
@@ -1635,7 +1635,7 @@ func (r *campaignRepository) ValidateCampaignReady(ctx context.Context, campaign
// leads instead of needing them up front.
var contactCount int
var continuous bool
err = r.DB.QueryRow(ctx, `
err = resultDB(ctx, r.DB).QueryRow(ctx, `
SELECT (SELECT COUNT(*) FROM campaign_leads WHERE campaign_id = $1),
(SELECT continuous FROM campaigns WHERE id = $1)`, campaignID).Scan(&contactCount, &continuous)
if err != nil {
@@ -1650,11 +1650,11 @@ func (r *campaignRepository) ValidateCampaignReady(ctx context.Context, campaign
// email tag OR — when neither is selected ("all") — at least one active
// mailbox in the campaign's organization to fall back to.
var senderCount int
if err := r.DB.QueryRow(ctx, `SELECT COUNT(*) FROM campaign_senders WHERE campaign_id = $1 AND enabled`, campaignID).Scan(&senderCount); err != nil {
if err := resultDB(ctx, r.DB).QueryRow(ctx, `SELECT COUNT(*) FROM campaign_senders WHERE campaign_id = $1 AND enabled`, campaignID).Scan(&senderCount); err != nil {
return err
}
var tagCount int
if err := r.DB.QueryRow(ctx, `SELECT COUNT(*) FROM campaign_email_tags WHERE campaign_id = $1`, campaignID).Scan(&tagCount); err != nil {
if err := resultDB(ctx, r.DB).QueryRow(ctx, `SELECT COUNT(*) FROM campaign_email_tags WHERE campaign_id = $1`, campaignID).Scan(&tagCount); err != nil {
return err
}
if senderCount > 0 || tagCount > 0 {
@@ -1666,7 +1666,7 @@ func (r *campaignRepository) ValidateCampaignReady(ctx context.Context, campaign
// campaign starts and then parks itself on its first tick (issue #340 is
// the same mistake in the other direction).
var strategy string
if err := r.DB.QueryRow(ctx, `SELECT sender_strategy FROM campaigns WHERE id = $1`, campaignID).Scan(&strategy); err != nil {
if err := resultDB(ctx, r.DB).QueryRow(ctx, `SELECT sender_strategy FROM campaigns WHERE id = $1`, campaignID).Scan(&strategy); err != nil {
return err
}
if strategy == CampaignSenderStrategyExplicit {
@@ -1674,7 +1674,7 @@ func (r *campaignRepository) ValidateCampaignReady(ctx context.Context, campaign
"this campaign sends from mailboxes picked by hand and none are left; pick its sending accounts again, or switch it back to selecting by tag")
}
var activeMailboxes int
if err := r.DB.QueryRow(ctx, `
if err := resultDB(ctx, r.DB).QueryRow(ctx, `
SELECT COUNT(*) FROM email_accounts
WHERE organization_id = (SELECT organization_id FROM campaigns WHERE id = $1) AND status = 'active'
`, campaignID).Scan(&activeMailboxes); err != nil {
@@ -1691,7 +1691,7 @@ func (r *campaignRepository) ValidateCampaignReady(ctx context.Context, campaign
// created later than that (a reconciler re-seed) is not its successor.
func (r *campaignRepository) IsPacedSuccessor(ctx context.Context, campaignID uuid.UUID, pending Task) (bool, error) {
var paced bool
err := r.DB.QueryRow(ctx, `
err := resultDB(ctx, r.DB).QueryRow(ctx, `
SELECT `+taskDispatchedEmail+` AND t.completed_at >= $2::timestamptz - interval '1 minute'
FROM tasks t
JOIN campaign_tasks ct ON ct.task_id = t.id
@@ -1716,7 +1716,7 @@ func (r *campaignRepository) GetPendingCampaignTasks(ctx context.Context, campai
WHERE ct.campaign_id = $1 AND t.status = 'pending'
`
rows, err := r.DB.Query(ctx, query, campaignID)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, campaignID)
if err != nil {
return nil, err
}
@@ -1756,7 +1756,7 @@ func (r *campaignRepository) ListCampaignScheduleCandidates(ctx context.Context,
ORDER BY c.idle_since ASC NULLS FIRST
LIMIT $1`
rows, err := r.DB.Query(ctx, query, limit)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, limit)
if err != nil {
return nil, err
}
@@ -1780,7 +1780,7 @@ func (r *campaignRepository) ListActiveCampaignIDs(ctx context.Context, afterID
if limit <= 0 {
limit = 500
}
rows, err := r.DB.Query(ctx, `
rows, err := resultDB(ctx, r.DB).Query(ctx, `
SELECT id FROM campaigns
WHERE status = 'active' AND id > $1
ORDER BY id
@@ -1834,7 +1834,7 @@ func (r *campaignRepository) ListStaleParkedCampaigns(ctx context.Context, stale
WHERE p.scheduled_at > NOW() + $1::interval
LIMIT $2`
rows, err := r.DB.Query(ctx, query, staleAfter.String(), limit)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, staleAfter.String(), limit)
if err != nil {
return nil, err
}
@@ -1854,13 +1854,13 @@ func (r *campaignRepository) ListStaleParkedCampaigns(ctx context.Context, stale
func (r *campaignRepository) CountActiveForOrganization(ctx context.Context, orgID uuid.UUID) (int, error) {
query := `SELECT COUNT(*) FROM campaigns WHERE organization_id = $1 AND status = 'active'`
var count int
err := r.DB.QueryRow(ctx, query, orgID).Scan(&count)
err := resultDB(ctx, r.DB).QueryRow(ctx, query, orgID).Scan(&count)
return count, err
}
// ListIDsByStatus lists the org's campaigns parked at one status.
func (r *campaignRepository) ListIDsByStatus(ctx context.Context, orgID uuid.UUID, status string) ([]uuid.UUID, error) {
rows, err := r.DB.Query(ctx, `SELECT id FROM campaigns WHERE organization_id = $1 AND status = $2`, orgID, status)
rows, err := resultDB(ctx, r.DB).Query(ctx, `SELECT id FROM campaigns WHERE organization_id = $1 AND status = $2`, orgID, status)
if err != nil {
return nil, err
}
@@ -1919,7 +1919,7 @@ func (r *campaignRepository) AccountHasActiveCampaign(ctx context.Context, accou
)
`
var exists bool
err := r.DB.QueryRow(ctx, query, accountID).Scan(&exists)
err := resultDB(ctx, r.DB).QueryRow(ctx, query, accountID).Scan(&exists)
return exists, err
}
@@ -1957,7 +1957,7 @@ func (r *campaignRepository) CountActiveCampaignsForAccount(ctx context.Context,
AND NOT EXISTS (SELECT 1 FROM campaign_senders cs2 WHERE cs2.campaign_id = c.id AND cs2.enabled)
) AS active_campaigns`
var count int
err := r.DB.QueryRow(ctx, query, accountID).Scan(&count)
err := resultDB(ctx, r.DB).QueryRow(ctx, query, accountID).Scan(&count)
return count, err
}
@@ -1999,7 +1999,7 @@ func (r *campaignRepository) CountActiveCampaignsForAccounts(ctx context.Context
)
GROUP BY a.id`
rows, err := r.DB.Query(ctx, query, accountIDs)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, accountIDs)
if err != nil {
return nil, err
}
@@ -2158,7 +2158,7 @@ func (r *campaignRepository) ReplaceCampaignSenders(ctx context.Context, campaig
// ownership against the org (the senders route is org-scoped).
var userID string
var orgID *uuid.UUID
if err := r.DB.QueryRow(ctx, `SELECT user_id, organization_id FROM campaigns WHERE id = $1`, campaignID).Scan(&userID, &orgID); err != nil {
if err := resultDB(ctx, r.DB).QueryRow(ctx, `SELECT user_id, organization_id FROM campaigns WHERE id = $1`, campaignID).Scan(&userID, &orgID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errx.ErrNotFound
}
@@ -2170,7 +2170,7 @@ func (r *campaignRepository) ReplaceCampaignSenders(ctx context.Context, campaig
orgStr = orgID.String()
}
tx, err := r.DB.Begin(ctx)
tx, err := beginResultTx(ctx, r.DB)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
@@ -2205,7 +2205,7 @@ func (r *campaignRepository) ReplaceCampaignSenders(ctx context.Context, campaig
// a single atomic UPDATE (no read-modify-write), keeping cursors coherent when
// multiple campaign tasks for the same campaign run concurrently.
func (r *campaignRepository) AdvanceCampaignSender(ctx context.Context, campaignID, accountID uuid.UUID) error {
_, err := r.DB.Exec(ctx, `
_, err := resultDB(ctx, r.DB).Exec(ctx, `
UPDATE campaign_senders
SET rotation_position = rotation_position + 1, last_sent_at = NOW()
WHERE campaign_id = $1 AND email_account_id = $2
@@ -2217,7 +2217,7 @@ func (r *campaignRepository) AdvanceCampaignSender(ctx context.Context, campaign
// no-op when ramp is off or already advanced today. Applied via min() in the
// scheduler so it can only LOWER the effective per-mailbox cap.
func (r *campaignRepository) AdvanceRampLevel(ctx context.Context, campaignID uuid.UUID) error {
_, err := r.DB.Exec(ctx, `
_, err := resultDB(ctx, r.DB).Exec(ctx, `
UPDATE campaigns
SET ramp_level = LEAST(ramp_ceiling, GREATEST(ramp_level, ramp_start) + ramp_increment),
ramp_level_date = CURRENT_DATE
@@ -2263,7 +2263,7 @@ func (r *campaignRepository) ReopenAfterSendFailure(ctx context.Context, campaig
// waiting for more. Returns true only on the transition, so the caller logs
// and broadcasts it once rather than on every pass that finds nothing.
func (r *campaignRepository) MarkIdle(ctx context.Context, campaignID uuid.UUID) (bool, error) {
tag, err := r.DB.Exec(ctx, `
tag, err := resultDB(ctx, r.DB).Exec(ctx, `
UPDATE campaigns
SET idle_since = NOW(), updated_at = NOW()
WHERE id = $1 AND status = 'active' AND continuous AND idle_since IS NULL
@@ -2278,7 +2278,7 @@ func (r *campaignRepository) MarkIdle(ctx context.Context, campaignID uuid.UUID)
// to the organization so a foreign campaign id cannot be reached.
func (r *campaignRepository) KeepRunning(ctx context.Context, orgID, campaignID uuid.UUID) (bool, error) {
var continuous bool
err := r.DB.QueryRow(ctx, `
err := resultDB(ctx, r.DB).QueryRow(ctx, `
SELECT continuous FROM campaigns WHERE id = $1 AND organization_id = $2
`, campaignID, orgID).Scan(&continuous)
if errors.Is(err, pgx.ErrNoRows) {
@@ -2290,7 +2290,7 @@ func (r *campaignRepository) KeepRunning(ctx context.Context, orgID, campaignID
if continuous {
return false, nil
}
tag, err := r.DB.Exec(ctx, `
tag, err := resultDB(ctx, r.DB).Exec(ctx, `
UPDATE campaigns SET continuous = true, updated_at = NOW()
WHERE id = $1 AND organization_id = $2 AND NOT continuous
`, campaignID, orgID)
@@ -2302,7 +2302,7 @@ func (r *campaignRepository) KeepRunning(ctx context.Context, orgID, campaignID
// ClearIdle ends the wait once the campaign has something to send again.
func (r *campaignRepository) ClearIdle(ctx context.Context, campaignID uuid.UUID) error {
_, err := r.DB.Exec(ctx, `
_, err := resultDB(ctx, r.DB).Exec(ctx, `
UPDATE campaigns SET idle_since = NULL, updated_at = NOW()
WHERE id = $1 AND idle_since IS NOT NULL
`, campaignID)
@@ -2312,7 +2312,7 @@ func (r *campaignRepository) ClearIdle(ctx context.Context, campaignID uuid.UUID
// CountNewLeadsStartedToday returns new_leads_started for the current UTC day.
func (r *campaignRepository) CountNewLeadsStartedToday(ctx context.Context, campaignID uuid.UUID) (int, error) {
var n int
err := r.DB.QueryRow(ctx, `
err := resultDB(ctx, r.DB).QueryRow(ctx, `
SELECT COALESCE(new_leads_started, 0)
FROM campaign_daily_sends
WHERE campaign_id = $1 AND send_date = CURRENT_DATE
@@ -2326,7 +2326,7 @@ func (r *campaignRepository) CountNewLeadsStartedToday(ctx context.Context, camp
// SetCampaignTrackingDomainVerified flips the verified flag/timestamp on the
// campaign-scoped tracking-domain override.
func (r *campaignRepository) SetCampaignTrackingDomainVerified(ctx context.Context, campaignID uuid.UUID, verified bool, at *time.Time) error {
_, err := r.DB.Exec(ctx, `
_, err := resultDB(ctx, r.DB).Exec(ctx, `
UPDATE campaigns
SET tracking_domain_verified = $2, tracking_domain_verified_at = $3, updated_at = NOW()
WHERE id = $1
@@ -2340,7 +2340,7 @@ func (r *campaignRepository) SetCampaignTrackingDomainVerified(ctx context.Conte
// UpdateStatusWithLock updates campaign status using a PostgreSQL advisory lock to prevent concurrent updates.
// The WHERE clause guards against races: only updates if the campaign is currently 'active'.
func (r *campaignRepository) UpdateStatusWithLock(ctx context.Context, campaignID uuid.UUID, status string) error {
tx, err := r.DB.Begin(ctx)
tx, err := beginResultTx(ctx, r.DB)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return err
+41 -41
View File
@@ -382,7 +382,7 @@ func NewCampaignProgressRepository(db *pgxpool.Pool) CampaignProgressRepository
// row, so two ticks that picked the same pair cannot both dispatch. A step
// walked back after a worker failure has both cleared and is claimable again.
func (r *campaignProgressRepository) ReserveSend(ctx context.Context, campaignID, contactID, sequenceID, taskID, senderID uuid.UUID, newLead bool) (bool, error) {
tx, err := r.db.Begin(ctx)
tx, err := beginResultTx(ctx, r.db)
if err != nil {
return false, err
}
@@ -537,7 +537,7 @@ func (r *campaignProgressRepository) RecordEmailSent(ctx context.Context, campai
failed_at = NULL, failure_reason = ''
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID)
_, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID, sequenceID)
return err
}
@@ -565,7 +565,7 @@ func (r *campaignProgressRepository) ListStuckDispatches(ctx context.Context, ol
if limit <= 0 {
limit = 100
}
rows, err := r.db.Query(ctx, `
rows, err := resultDB(ctx, r.db).Query(ctx, `
SELECT campaign_id, contact_id, sequence_id, dispatch_task_id, dispatched_at
FROM campaign_contact_progress
WHERE sent_at IS NULL
@@ -654,7 +654,7 @@ func (r *campaignProgressRepository) WalkBackSend(ctx context.Context, campaignI
// set of sends the contact actually received.
func (r *campaignProgressRepository) LastSenderForLead(ctx context.Context, campaignID, contactID uuid.UUID) (*uuid.UUID, error) {
var id uuid.UUID
err := r.db.QueryRow(ctx, `
err := resultDB(ctx, r.db).QueryRow(ctx, `
SELECT t.email_account_id
FROM campaign_tasks ct
JOIN tasks t ON t.id = ct.task_id
@@ -691,7 +691,7 @@ const threadParentScan = 50
// The conversation's subject is read off the sends themselves where they
// recorded it, and walked from the steps only for sends from before they did.
func (r *campaignProgressRepository) ThreadParentForLead(ctx context.Context, campaignID, contactID uuid.UUID) (*ThreadParent, error) {
rows, err := r.db.Query(ctx, `
rows, err := resultDB(ctx, r.db).Query(ctx, `
SELECT t.message_id, t.thread_id, t.email_account_id, ct.subject,
s.id IS NOT NULL, COALESCE(s.subject, ''), COALESCE(s.thread_reply, true)
FROM campaign_tasks ct
@@ -786,7 +786,7 @@ func (r *campaignProgressRepository) RecordEmailOpened(ctx context.Context, camp
AND (opened_at IS NULL OR (opened_machine = true AND $4 = false))
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, machine)
_, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID, sequenceID, machine)
return err
}
@@ -808,7 +808,7 @@ func (r *campaignProgressRepository) RecordEmailClicked(ctx context.Context, cam
AND clicked_at IS NULL
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID)
_, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID, sequenceID)
return err
}
@@ -842,7 +842,7 @@ func (r *campaignProgressRepository) UnrecordEmailClicked(ctx context.Context, c
)
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID)
_, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID, sequenceID)
return err
}
@@ -855,7 +855,7 @@ func (r *campaignProgressRepository) GetStepSentAt(ctx context.Context, campaign
`
var sentAt *time.Time
err := r.db.QueryRow(ctx, query, campaignID, contactID, sequenceID).Scan(&sentAt)
err := resultDB(ctx, r.db).QueryRow(ctx, query, campaignID, contactID, sequenceID).Scan(&sentAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
@@ -868,7 +868,7 @@ func (r *campaignProgressRepository) GetStepSentAt(ctx context.Context, campaign
// IsInboundReplySource verifies direction from the row stored by the consumer.
func (r *campaignProgressRepository) IsInboundReplySource(ctx context.Context, emailAccountID, messageID uuid.UUID) (bool, error) {
var inbound bool
err := r.db.QueryRow(ctx, `
err := resultDB(ctx, r.db).QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM unibox_emails
@@ -884,7 +884,7 @@ func (r *campaignProgressRepository) IsInboundReplySource(ctx context.Context, e
// CampaignContactSentFromAccount proves a cross-mailbox reply arrived at a sender used for this lead.
func (r *campaignProgressRepository) CampaignContactSentFromAccount(ctx context.Context, campaignID, contactID, emailAccountID uuid.UUID) (bool, error) {
var sent bool
err := r.db.QueryRow(ctx, `
err := resultDB(ctx, r.db).QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM campaign_tasks campaign_task
@@ -904,7 +904,7 @@ const incomingReplyClaimLease = "10 minutes"
// ClaimIncomingReply leases one inbound message so duplicate events cannot repeat its effects.
func (r *campaignProgressRepository) ClaimIncomingReply(ctx context.Context, emailAccountID, messageID uuid.UUID) (uuid.UUID, error) {
claimToken := uuid.New()
result, err := r.db.Exec(ctx, `
result, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE unibox_emails
SET campaign_reply_claimed_at = NOW(),
campaign_reply_claim_token = $3
@@ -929,7 +929,7 @@ func (r *campaignProgressRepository) ClaimIncomingReply(ctx context.Context, ema
// CompleteIncomingReply marks a claimed message as processed.
func (r *campaignProgressRepository) CompleteIncomingReply(ctx context.Context, emailAccountID, messageID, claimToken uuid.UUID) error {
result, err := r.db.Exec(ctx, `
result, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE unibox_emails
SET campaign_reply_processed_at = NOW()
WHERE id = $1
@@ -964,7 +964,7 @@ func (r *campaignProgressRepository) RecordEmailReplied(ctx context.Context, cam
)
`
result, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, messageID, emailAccountID)
result, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID, sequenceID, messageID, emailAccountID)
if err != nil {
return false, err
}
@@ -997,7 +997,7 @@ func (r *campaignProgressRepository) RecordEmailComplained(ctx context.Context,
AND complained_at IS NULL
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID)
_, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID, sequenceID)
return err
}
@@ -1015,7 +1015,7 @@ func (r *campaignProgressRepository) RecordReplyClassification(ctx context.Conte
reply_confidence = EXCLUDED.reply_confidence,
reply_source = EXCLUDED.reply_source
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, class, confidence, source)
_, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID, sequenceID, class, confidence, source)
return err
}
@@ -1027,7 +1027,7 @@ func (r *campaignProgressRepository) RecordAILabel(ctx context.Context, campaign
ON CONFLICT (campaign_id, contact_id, sequence_id)
DO UPDATE SET ai_label = EXCLUDED.ai_label
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, label)
_, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID, sequenceID, label)
return err
}
@@ -1039,7 +1039,7 @@ func (r *campaignProgressRepository) RecordReplyIntent(ctx context.Context, camp
ON CONFLICT (campaign_id, contact_id, sequence_id)
DO UPDATE SET reply_intent = EXCLUDED.reply_intent
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, intent)
_, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID, sequenceID, intent)
return err
}
@@ -1053,7 +1053,7 @@ func (r *campaignProgressRepository) GetResolvedAIVariables(ctx context.Context,
WHERE campaign_id = $1 AND contact_id = $2 AND sequence_id = $3
`
var raw []byte
err := r.db.QueryRow(ctx, query, campaignID, contactID, sequenceID).Scan(&raw)
err := resultDB(ctx, r.db).QueryRow(ctx, query, campaignID, contactID, sequenceID).Scan(&raw)
if errors.Is(err, sql.ErrNoRows) {
return map[string]string{}, nil
}
@@ -1082,7 +1082,7 @@ func (r *campaignProgressRepository) SaveResolvedAIVariable(ctx context.Context,
COALESCE(campaign_contact_progress.ai_variables_resolved, '{}'::jsonb)
|| jsonb_build_object($4::text, $5::text)
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, varID, text)
_, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID, sequenceID, varID, text)
return err
}
@@ -1097,7 +1097,7 @@ func (r *campaignProgressRepository) GetLatestReplyClass(ctx context.Context, co
LIMIT 1
`
var class string
err := r.db.QueryRow(ctx, query, contactID, campaignID).Scan(&class)
err := resultDB(ctx, r.db).QueryRow(ctx, query, contactID, campaignID).Scan(&class)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
@@ -1119,7 +1119,7 @@ func (r *campaignProgressRepository) ClaimInstantFire(ctx context.Context, campa
AND sequence_id = $3
AND NOT ($4 = ANY(instant_fired))
`
tag, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, eventKind)
tag, err := resultDB(ctx, r.db).Exec(ctx, query, campaignID, contactID, sequenceID, eventKind)
if err != nil {
return false, err
}
@@ -1156,7 +1156,7 @@ func (r *campaignProgressRepository) GetCampaignProgress(ctx context.Context, ca
`
progress := &CampaignProgress{}
err := r.db.QueryRow(ctx, query, campaignID).Scan(
err := resultDB(ctx, r.db).QueryRow(ctx, query, campaignID).Scan(
&progress.TotalContacts,
&progress.TotalSequences,
&progress.EmailsSent,
@@ -1188,7 +1188,7 @@ func (r *campaignProgressRepository) GetCampaignRollingRates(ctx context.Context
WHERE campaign_id = $1 AND ` + progressIsEmailStep("p") + `
`
out := &CampaignRollingRates{}
err := r.db.QueryRow(ctx, query, campaignID, since).Scan(&out.Sent, &out.Bounced, &out.Complained)
err := resultDB(ctx, r.db).QueryRow(ctx, query, campaignID, since).Scan(&out.Sent, &out.Bounced, &out.Complained)
if errors.Is(err, sql.ErrNoRows) {
return &CampaignRollingRates{}, nil
}
@@ -1209,7 +1209,7 @@ func (r *campaignProgressRepository) GetContactProgress(ctx context.Context, cam
ORDER BY sent_at ASC
`
rows, err := r.db.Query(ctx, query, campaignID, contactID)
rows, err := resultDB(ctx, r.db).Query(ctx, query, campaignID, contactID)
if err != nil {
return nil, err
}
@@ -1250,7 +1250,7 @@ func (r *campaignProgressRepository) GetContactLastSequenceTime(ctx context.Cont
`
var lastTime *time.Time
err := r.db.QueryRow(ctx, query, contactID, campaignID).Scan(&lastTime)
err := resultDB(ctx, r.db).QueryRow(ctx, query, contactID, campaignID).Scan(&lastTime)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
@@ -1272,7 +1272,7 @@ func (r *campaignProgressRepository) CheckContactHasReplied(ctx context.Context,
`
var hasReplied bool
err := r.db.QueryRow(ctx, query, contactID, campaignID).Scan(&hasReplied)
err := resultDB(ctx, r.db).QueryRow(ctx, query, contactID, campaignID).Scan(&hasReplied)
return hasReplied, err
}
@@ -1291,7 +1291,7 @@ func (r *campaignProgressRepository) CountEmailsSentTodayByOrganization(ctx cont
`
var count int
err := r.db.QueryRow(ctx, query, organizationID).Scan(&count)
err := resultDB(ctx, r.db).QueryRow(ctx, query, organizationID).Scan(&count)
return count, err
}
@@ -1306,7 +1306,7 @@ func (r *campaignProgressRepository) GetLatestCampaignSequenceForContact(ctx con
LIMIT 1
`
out := &CampaignSequencePair{}
if err := r.db.QueryRow(ctx, query, contactID).Scan(&out.CampaignID, &out.SequenceID); err != nil {
if err := resultDB(ctx, r.db).QueryRow(ctx, query, contactID).Scan(&out.CampaignID, &out.SequenceID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -1559,7 +1559,7 @@ func (r *campaignProgressRepository) orderedCandidateIDs(ctx context.Context, or
JOIN contacts c ON c.id = cl.contact_id` + newLeadJoin + `
WHERE cl.campaign_id = $1
ORDER BY ` + orderExpr
rows, err := r.db.Query(ctx, query, args...)
rows, err := resultDB(ctx, r.db).Query(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -1579,7 +1579,7 @@ func (r *campaignProgressRepository) orderedCandidateIDs(ctx context.Context, or
// already ordered candidate ids, returning the routing input keyed by contact.
// A candidate a pre-send gate excludes is simply absent from the result.
func (r *campaignProgressRepository) hydrateRoutedLeads(ctx context.Context, query string, campaignID uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]routeInput, error) {
rows, err := r.db.Query(ctx, query, campaignID, config.CampaignSendMaxAttempts, ids)
rows, err := resultDB(ctx, r.db).Query(ctx, query, campaignID, config.CampaignSendMaxAttempts, ids)
if err != nil {
return nil, err
}
@@ -1680,7 +1680,7 @@ func (r *campaignProgressRepository) RouteContact(ctx context.Context, campaignI
`
var in routeInput
var bounced, failed, suppressed, undeliverable bool
err = r.db.QueryRow(ctx, query, campaignID, config.CampaignSendMaxAttempts, contactID).Scan(
err = resultDB(ctx, r.db).QueryRow(ctx, query, campaignID, config.CampaignSendMaxAttempts, contactID).Scan(
&in.addedAt, &in.sender, &in.lastSeq, &in.sentAt, &in.openedAt, &in.clickedAt, &in.repliedAt, &in.replyClass, &in.aiLabel, &in.replyIntent, &in.sentIDs,
&in.pausedAt, &in.pausedUntil, &in.pauseReason, &in.pauseSource,
&in.hasReplied, &bounced, &failed, &suppressed, &undeliverable,
@@ -1831,7 +1831,7 @@ type routeResult struct {
// position, and precomputes the reply-flow step set for route-aware
// stop_on_reply. Returns nil when the campaign has no steps.
func (r *campaignProgressRepository) loadRouter(ctx context.Context, campaignID uuid.UUID) (*campaignRouter, error) {
srows, err := r.db.Query(ctx, `SELECT id, conditions, wait_after, kind, action FROM sequences WHERE campaign_id = $1 ORDER BY position ASC, created_at ASC`, campaignID)
srows, err := resultDB(ctx, r.db).Query(ctx, `SELECT id, conditions, wait_after, kind, action FROM sequences WHERE campaign_id = $1 ORDER BY position ASC, created_at ASC`, campaignID)
if err != nil {
return nil, err
}
@@ -1874,7 +1874,7 @@ func (r *campaignProgressRepository) loadRouter(ctx context.Context, campaignID
// reply branch's path (its actions AND any follow-up emails) runs to
// completion. Compute the reply-flow step set once and load the flag.
var entryDelayMinutes int
if serr := r.db.QueryRow(ctx,
if serr := resultDB(ctx, r.db).QueryRow(ctx,
`SELECT stop_on_reply, entry_delay_minutes, created_at FROM campaigns WHERE id = $1`,
campaignID).Scan(&cr.stopOnReply, &entryDelayMinutes, &cr.campaignCreatedAt); serr != nil {
return nil, serr
@@ -2229,7 +2229,7 @@ func (r *campaignProgressRepository) CountUndeliverableLeads(ctx context.Context
AND c.subscribed IS NOT FALSE
AND ` + undeliverableClause("$1") + `
`
rows, err := r.db.Query(ctx, query, campaignID, config.CampaignSendMaxAttempts)
rows, err := resultDB(ctx, r.db).Query(ctx, query, campaignID, config.CampaignSendMaxAttempts)
if err != nil {
return 0, err
}
@@ -2322,7 +2322,7 @@ func (r *campaignProgressRepository) HoldLead(ctx context.Context, campaignID, c
guard = crmHoldGuard("campaign_leads")
}
now := time.Now()
hold, err := scanHold(r.db.QueryRow(ctx, `
hold, err := scanHold(resultDB(ctx, r.db).QueryRow(ctx, `
UPDATE campaign_leads
SET paused_at = CASE WHEN `+liveHold("campaign_leads")+` THEN paused_at ELSE NOW() END,
paused_until = $3,
@@ -2362,7 +2362,7 @@ func (r *campaignProgressRepository) HoldLeadEverywhere(ctx context.Context, con
} else if source == models.LeadHoldSourceCRM {
guard = crmHoldGuard("cl")
}
rows, err := r.db.Query(ctx, `
rows, err := resultDB(ctx, r.db).Query(ctx, `
UPDATE campaign_leads cl
SET paused_at = CASE WHEN `+liveHold("cl")+` THEN cl.paused_at ELSE NOW() END,
paused_until = $2,
@@ -2399,7 +2399,7 @@ func (r *campaignProgressRepository) ResumeLead(ctx context.Context, campaignID,
// RETURNING sees the row AFTER the update, so whether there was a hold to
// lift is read from the pre-update snapshot the CTE holds.
var held bool
err := r.db.QueryRow(ctx, `
err := resultDB(ctx, r.db).QueryRow(ctx, `
WITH prev AS (
SELECT paused_at FROM campaign_leads WHERE campaign_id = $1 AND contact_id = $2
), lifted AS (
@@ -2423,7 +2423,7 @@ func (r *campaignProgressRepository) ResumeLead(ctx context.Context, campaignID,
// so the caller can answer 404 rather than 200 for a contact that was never in
// the campaign.
func (r *campaignProgressRepository) GetLeadHold(ctx context.Context, campaignID, contactID uuid.UUID) (*models.LeadHold, error) {
hold, err := scanHold(r.db.QueryRow(ctx,
hold, err := scanHold(resultDB(ctx, r.db).QueryRow(ctx,
`SELECT `+holdColumns+` FROM campaign_leads WHERE campaign_id = $1 AND contact_id = $2`,
campaignID, contactID), time.Now())
if errors.Is(err, pgx.ErrNoRows) {
@@ -2438,7 +2438,7 @@ func (r *campaignProgressRepository) GetLeadHold(ctx context.Context, campaignID
// that as "everything sent" and close the campaign (issue #470).
func (r *campaignProgressRepository) CountHeldLeads(ctx context.Context, campaignID uuid.UUID) (int, error) {
var n int
err := r.db.QueryRow(ctx, `
err := resultDB(ctx, r.db).QueryRow(ctx, `
SELECT COUNT(*) FROM campaign_leads cl
WHERE cl.campaign_id = $1 AND `+liveHold("cl")+`
-- Reached in another lead's thread: nothing is left to wait for.
@@ -2545,7 +2545,7 @@ func (r *campaignProgressRepository) LeadSupply(ctx context.Context, campaignID
if router == nil {
return out, nil
}
rows, err := r.db.Query(ctx, routedLeadsQuery("c.created_at ASC", ""), campaignID, config.CampaignSendMaxAttempts)
rows, err := resultDB(ctx, r.db).Query(ctx, routedLeadsQuery("c.created_at ASC", ""), campaignID, config.CampaignSendMaxAttempts)
if err != nil {
return nil, err
}
+47 -47
View File
@@ -315,7 +315,7 @@ func (r *contactRepository) Add(ctx context.Context, userID string, orgID uuid.U
segmentIDs = append(segmentIDs, segs)
}
tx, err := r.DB.Begin(ctx)
tx, err := beginResultTx(ctx, r.DB)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
@@ -621,7 +621,7 @@ func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (*
`
var contact models.Contact
err := r.DB.QueryRow(ctx, query, contactID).Scan(
err := resultDB(ctx, r.DB).QueryRow(ctx, query, contactID).Scan(
&contact.ID, &contact.FirstName, &contact.LastName, &contact.Email,
&contact.Company, &contact.Phone, &contact.CustomFields, &contact.Subscribed,
&contact.UpdatedAt, &contact.CreatedAt,
@@ -668,7 +668,7 @@ func (r *contactRepository) ListMailHostPending(ctx context.Context, limit int)
ORDER BY esp_resolved_at NULLS FIRST
LIMIT $1
`
rows, err := r.DB.Query(ctx, query, limit, config.ContactMailHostRecheckDays)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, limit, config.ContactMailHostRecheckDays)
if err != nil {
db.CaptureError(err, query, []any{limit}, "query")
return nil, err
@@ -718,7 +718,7 @@ func (r *contactRepository) SetContactMailHosts(ctx context.Context, results []C
WHERE found AND organization_id IS NOT NULL
`
args := []any{ids, emails, hosts, esps, transient, config.ContactMailHostRecheckDays, config.ContactMailHostRetryMinutes}
rows, err := r.DB.Query(ctx, query, args...)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, args...)
if err != nil {
db.CaptureError(err, query, nil, "query")
return nil, err
@@ -739,7 +739,7 @@ func (r *contactRepository) SetContactMailHosts(ctx context.Context, results []C
// contact. It is keyed only by contact id (the verifier runs in the control
// plane, not in a user request) and is a no-op-safe single UPDATE.
func (r *contactRepository) SetSubscribedByEmail(ctx context.Context, orgID uuid.UUID, email string, subscribed bool) error {
_, err := r.DB.Exec(ctx,
_, err := resultDB(ctx, r.DB).Exec(ctx,
`UPDATE contacts SET subscribed = $3, updated_at = NOW()
WHERE organization_id = $1 AND LOWER(email) = LOWER($2) AND subscribed IS DISTINCT FROM $3`,
orgID, email, subscribed)
@@ -788,7 +788,7 @@ func (r *contactRepository) UpdateContactVerification(ctx context.Context, conta
AND (verification_source <> 'manual' OR ($11::timestamptz IS NOT NULL AND verification_requested_at IS NOT NULL))
`
params := []any{contactID, status, res.Reason, res.IsCatchAll, checkedAt, source, provider, string(res.SubStatus), res.Confidence, string(checkStatus), requestedAt}
cmd, err := r.DB.Exec(ctx, query, params...)
cmd, err := resultDB(ctx, r.DB).Exec(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "exec")
return errx.InternalError()
@@ -891,7 +891,7 @@ func (r *contactRepository) ListVerificationCandidates(ctx context.Context, limi
}
func (r *contactRepository) scanVerificationCandidates(ctx context.Context, query string, params []any, out *[]VerificationCandidate) *errx.Error {
rows, err := r.DB.Query(ctx, query, params...)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "query")
return errx.InternalError()
@@ -933,7 +933,7 @@ func (r *contactRepository) SetContactsVerification(ctx context.Context, orgID u
WHERE organization_id = $1 AND id = ANY($2)
`
params := []any{orgID, ids, w.Status, w.SubStatus, w.Reason, w.Provider, w.Source}
cmd, err := r.DB.Exec(ctx, query, params...)
cmd, err := resultDB(ctx, r.DB).Exec(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "exec")
return 0, errx.InternalError()
@@ -955,7 +955,7 @@ func (r *contactRepository) RequestContactsVerification(ctx context.Context, org
WHERE organization_id = $1 AND id = ANY($2)
`
params := []any{orgID, ids}
cmd, err := r.DB.Exec(ctx, query, params...)
cmd, err := resultDB(ctx, r.DB).Exec(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "exec")
return 0, errx.InternalError()
@@ -975,7 +975,7 @@ func (r *contactRepository) UndeliverableLeadIDs(ctx context.Context, orgID, cam
AND (c.verification_status = 'invalid' OR (c.verification_status = 'risky' AND NOT cp.risky_emails))
`
params := []any{campaignID, orgID}
rows, err := r.DB.Query(ctx, query, params...)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "query")
return nil, errx.InternalError()
@@ -1010,7 +1010,7 @@ func (r *contactRepository) VerificationCounts(ctx context.Context, orgID uuid.U
FROM contacts
WHERE organization_id = $1
`
if err := r.DB.QueryRow(ctx, query, orgID).Scan(&c.Valid, &c.Risky, &c.Invalid, &c.Unknown, &c.Pending); err != nil {
if err := resultDB(ctx, r.DB).QueryRow(ctx, query, orgID).Scan(&c.Valid, &c.Risky, &c.Invalid, &c.Unknown, &c.Pending); err != nil {
db.CaptureError(err, query, []any{orgID}, "queryrow")
return c, errx.InternalError()
}
@@ -1023,7 +1023,7 @@ const lookupContactColumns = `c.id, c.first_name, c.last_name, c.email, c.compan
func (r *contactRepository) scanLookupContact(ctx context.Context, query string, args ...any) (*models.Contact, *errx.Error) {
var contact models.Contact
err := r.DB.QueryRow(ctx, query, args...).Scan(
err := resultDB(ctx, r.DB).QueryRow(ctx, query, args...).Scan(
&contact.ID, &contact.FirstName, &contact.LastName, &contact.Email,
&contact.Company, &contact.Phone, &contact.CustomFields, &contact.Subscribed,
&contact.UpdatedAt, &contact.CreatedAt,
@@ -1104,7 +1104,7 @@ func (r *contactRepository) GetByThreadAndOrganization(ctx context.Context, orga
func (r *contactRepository) OwnerUserID(ctx context.Context, organizationID, contactID uuid.UUID) (*uuid.UUID, error) {
var userID uuid.UUID
err := r.DB.QueryRow(ctx,
err := resultDB(ctx, r.DB).QueryRow(ctx,
`SELECT user_id FROM contacts WHERE id = $1 AND organization_id = $2`,
contactID, organizationID,
).Scan(&userID)
@@ -1128,7 +1128,7 @@ func (r *contactRepository) GetByIDsAndOrganization(ctx context.Context, organiz
FROM contacts c
WHERE c.organization_id = $1 AND c.id = ANY($2)
`
rows, err := r.DB.Query(ctx, query, organizationID, ids)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, organizationID, ids)
if err != nil {
db.CaptureError(err, query, []any{organizationID, ids}, "query")
return nil, errx.InternalError()
@@ -1776,7 +1776,7 @@ func (r *contactRepository) Search(
// the WHERE, so passing it would leave Postgres a placeholder it cannot
// type.
var tmp int64
if err := r.DB.QueryRow(ctx, countQuery, fq.args...).Scan(&tmp); err != nil {
if err := resultDB(ctx, r.DB).QueryRow(ctx, countQuery, fq.args...).Scan(&tmp); err != nil {
db.CaptureError(err, "countQuery", args, "queryrow")
return nil, errx.InternalError()
}
@@ -1786,7 +1786,7 @@ func (r *contactRepository) Search(
// -----------------------------
// Execute query
// -----------------------------
rows, err := r.DB.Query(ctx, query, args...)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, args...)
if err != nil {
db.CaptureError(err, query, args, "query")
return nil, errx.InternalError()
@@ -2011,7 +2011,7 @@ func (r *contactRepository) SearchIDs(ctx context.Context, orgID string, filters
`, campaignCountJoin, whereSQL, spec.expr, direction, nulls, direction, argIndex)
args = append(args, max+1)
rows, err := r.DB.Query(ctx, query, args...)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, args...)
if err != nil {
db.CaptureError(err, query, args, "query")
return nil, errx.InternalError()
@@ -2052,7 +2052,7 @@ func (r *contactRepository) SearchCounts(ctx context.Context, orgID string) (*mo
) cl ON c.id = cl.contact_id
WHERE c.organization_id = $1
`
if err := r.DB.QueryRow(ctx, scalarQuery, orgID).Scan(
if err := resultDB(ctx, r.DB).QueryRow(ctx, scalarQuery, orgID).Scan(
&counts.Total, &counts.Subscribed, &counts.Unsubscribed,
&counts.InCampaign, &counts.NotContacted,
); err != nil {
@@ -2067,7 +2067,7 @@ func (r *contactRepository) SearchCounts(ctx context.Context, orgID string) (*mo
WHERE c.organization_id = $1
GROUP BY cc.category_id
`
rows, err := r.DB.Query(ctx, categoryQuery, orgID)
rows, err := resultDB(ctx, r.DB).Query(ctx, categoryQuery, orgID)
if err != nil {
db.CaptureError(err, categoryQuery, []any{orgID}, "query")
return nil, errx.InternalError()
@@ -2097,7 +2097,7 @@ func (r *contactRepository) DistinctCustomFieldKeys(ctx context.Context, orgID u
ORDER BY count(*) DESC, key ASC
LIMIT 200
`
rows, err := r.DB.Query(ctx, query, orgID)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, orgID)
if err != nil {
db.CaptureError(err, query, []any{orgID}, "query")
return nil, err
@@ -2285,7 +2285,7 @@ func (r *contactRepository) CampaignLeadCounts(ctx context.Context, orgID, campa
WHERE cl.campaign_id = $1
`, done, live, undeliverableClause("$1"), held, progressIsEmailStep("p"))
out := &models.CampaignLeadCounts{}
if err := r.DB.QueryRow(ctx, query, campaignID, orgID, config.CampaignSendMaxAttempts).Scan(
if err := resultDB(ctx, r.DB).QueryRow(ctx, query, campaignID, orgID, config.CampaignSendMaxAttempts).Scan(
&out.Total, &out.Unsubscribed, &out.Bounced, &out.Replied, &out.Failed, &out.Completed, &out.Paused, &out.Processing, &out.Undeliverable, &out.Queued,
&out.Contacted, &out.Opened, &out.Clicked, &out.RepliedAny,
&out.Providers.Google, &out.Providers.Microsoft, &out.Providers.Other, &out.Providers.Undetected,
@@ -2300,7 +2300,7 @@ func (r *contactRepository) CampaignLeadCounts(ctx context.Context, orgID, campa
}
func (r *contactRepository) Update(ctx context.Context, userID, contactID string, orgID uuid.UUID, data *models.UpdateContact) (*models.Contact, *errx.Error) {
tx, err := r.DB.Begin(ctx)
tx, err := beginResultTx(ctx, r.DB)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
@@ -2822,7 +2822,7 @@ func (r *contactRepository) Update(ctx context.Context, userID, contactID string
}
func (r *contactRepository) BulkUpdate(ctx context.Context, userID string, orgID uuid.UUID, data *models.BulkEditContactsData) ([]models.Contact, *errx.Error) {
tx, err := r.DB.Begin(ctx)
tx, err := beginResultTx(ctx, r.DB)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
@@ -3211,7 +3211,7 @@ func (r *contactRepository) ResolveCategoryNames(ctx context.Context, orgID, use
// workspace-wide deliberately did not merge two members' identically named
// categories, so a title can resolve to more than one row. Without an order
// an import would file the same name under a different category run to run.
rows, err := r.DB.Query(ctx, `
rows, err := resultDB(ctx, r.DB).Query(ctx, `
SELECT id, LOWER(title) FROM categories
WHERE organization_id = $1 AND LOWER(title) = ANY($2::text[])
ORDER BY "position" ASC, created_at ASC, id ASC
@@ -3251,7 +3251,7 @@ func (r *contactRepository) ResolveCategoryNames(ctx context.Context, orgID, use
// Positions continue after whatever the workspace already has, so the new
// categories land at the end of the list instead of colliding.
var nextPos int32
if err := r.DB.QueryRow(ctx,
if err := resultDB(ctx, r.DB).QueryRow(ctx,
`SELECT COALESCE(MAX(position), -1) + 1 FROM categories WHERE organization_id = $1`,
orgID).Scan(&nextPos); err != nil {
db.CaptureError(err, "", nil, "ResolveCategoryNames position")
@@ -3259,7 +3259,7 @@ func (r *contactRepository) ResolveCategoryNames(ctx context.Context, orgID, use
}
for _, lower := range missing {
id := uuid.New()
if _, err := r.DB.Exec(ctx, `
if _, err := resultDB(ctx, r.DB).Exec(ctx, `
INSERT INTO categories (id, organization_id, user_id, title, color, position)
VALUES ($1, $2, $3, $4, $5, $6)
`, id, orgID, userID, seen[lower], defaultGroupColor(nextPos), nextPos); err != nil {
@@ -3289,7 +3289,7 @@ func (r *contactRepository) GetByEmailsAndUser(ctx context.Context, userID uuid.
return out, nil
}
rows, err := r.DB.Query(ctx, `
rows, err := resultDB(ctx, r.DB).Query(ctx, `
SELECT id, first_name, last_name, email, company, phone, custom_fields, subscribed, updated_at, created_at
FROM contacts
WHERE user_id = $1 AND LOWER(email) = ANY($2)
@@ -3335,7 +3335,7 @@ func (r *contactRepository) ImportLookup(ctx context.Context, orgID, userID uuid
FROM contacts
WHERE LOWER(email) = ANY($3::text[]) AND (organization_id = $1 OR user_id = $2)
ORDER BY LOWER(email), (organization_id = $1) DESC, (user_id = $2) DESC, created_at ASC, id ASC`
rows, err := r.DB.Query(ctx, query, orgID, userID, norm)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, orgID, userID, norm)
if err != nil {
db.CaptureError(err, query, nil, "ImportLookup query")
return nil, nil, errx.InternalError()
@@ -3423,7 +3423,7 @@ func (r *contactRepository) ImportUpdate(ctx context.Context, orgID uuid.UUID, r
)
}
tx, err := r.DB.Begin(ctx)
tx, err := beginResultTx(ctx, r.DB)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
@@ -3527,7 +3527,7 @@ func (r *contactRepository) ExportAll(ctx context.Context, orgID string, filters
func (r *contactRepository) GetContactCount(ctx context.Context, userID string) (int, *errx.Error) {
query := `SELECT COUNT(*) FROM contacts WHERE user_id = $1`
var count int
err := r.DB.QueryRow(ctx, query, userID).Scan(&count)
err := resultDB(ctx, r.DB).QueryRow(ctx, query, userID).Scan(&count)
if err != nil {
db.CaptureError(err, query, []any{userID}, "queryrow")
return 0, errx.InternalError()
@@ -3587,7 +3587,7 @@ func (r *contactRepository) GetDetail(ctx context.Context, userID uuid.UUID, org
WHERE c.id = $1 AND %s
`, campScope, catScope, rowScope)
mainArgs := []any{contactID, scopeArg}
err := r.DB.QueryRow(ctx, mainQuery, mainArgs...).Scan(
err := resultDB(ctx, r.DB).QueryRow(ctx, mainQuery, mainArgs...).Scan(
&detail.ID, &detail.FirstName, &detail.LastName, &detail.Email,
&detail.Company, &detail.Phone, &detail.CustomFields, &detail.Subscribed,
&detail.UpdatedAt, &detail.CreatedAt,
@@ -3643,7 +3643,7 @@ func (r *contactRepository) GetDetail(ctx context.Context, userID uuid.UUID, org
FROM campaign_contact_progress p
WHERE contact_id = $1
`
if err := r.DB.QueryRow(ctx, engQuery, contactID).Scan(
if err := resultDB(ctx, r.DB).QueryRow(ctx, engQuery, contactID).Scan(
&detail.Engagement.TotalSent, &detail.Engagement.TotalOpened,
&detail.Engagement.TotalClicked, &detail.Engagement.TotalReplied,
&detail.Engagement.TotalBounced,
@@ -3670,7 +3670,7 @@ func (r *contactRepository) GetDetail(ctx context.Context, userID uuid.UUID, org
ORDER BY MAX(o.opened_at) DESC
LIMIT 4
`
rrows, qerr := r.DB.Query(ctx, readsQuery, contactID, *orgID)
rrows, qerr := resultDB(ctx, r.DB).Query(ctx, readsQuery, contactID, *orgID)
if qerr != nil {
db.CaptureError(qerr, readsQuery, []any{contactID, *orgID}, "GetDetail reads on")
return nil, errx.InternalError()
@@ -3702,7 +3702,7 @@ func (r *contactRepository) GetDetail(ctx context.Context, userID uuid.UUID, org
AND event_type = 'complaint'
AND (contact_id = $2 OR LOWER(recipient_email) = LOWER($3))
`
if err := r.DB.QueryRow(ctx, complaintQuery, *orgID, contactID, detail.Email).Scan(
if err := resultDB(ctx, r.DB).QueryRow(ctx, complaintQuery, *orgID, contactID, detail.Email).Scan(
&detail.Engagement.TotalComplained,
); err != nil {
db.CaptureError(err, complaintQuery, []any{*orgID, contactID, detail.Email}, "GetDetail complaints")
@@ -3722,7 +3722,7 @@ func (r *contactRepository) GetDetail(ctx context.Context, userID uuid.UUID, org
LIMIT 1
`
var s models.ContactSuppression
err := r.DB.QueryRow(ctx, suppQuery, *orgID, detail.Email).Scan(
err := resultDB(ctx, r.DB).QueryRow(ctx, suppQuery, *orgID, detail.Email).Scan(
&s.ID, &s.Kind, &s.Value, &s.Reason, &s.Source, &s.ExpiresAt, &s.CreatedAt,
)
switch {
@@ -3794,7 +3794,7 @@ func (r *contactRepository) ListSentEmails(ctx context.Context, orgID, contactID
LIMIT $%d
`, cursorClause, len(args))
rows, err := r.DB.Query(ctx, query, args...)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, args...)
if err != nil {
db.CaptureError(err, query, args, "ListSentEmails")
return nil, errx.InternalError()
@@ -3884,7 +3884,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, orgID, contactID u
// joins (suppression, deliverability fallback, reply_intents) key
// off email rather than contact_id.
var contactEmail string
if err := r.DB.QueryRow(ctx,
if err := resultDB(ctx, r.DB).QueryRow(ctx,
`SELECT email FROM contacts WHERE id = $1 AND organization_id = $2`,
contactID, orgID,
).Scan(&contactEmail); err != nil {
@@ -3968,7 +3968,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, orgID, contactID u
models.TimelineSourceProgressReplied,
models.TimelineSourceProgressBounced,
)
prows, err := r.DB.Query(ctx, progressQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
prows, err := resultDB(ctx, r.DB).Query(ctx, progressQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, progressQuery, []any{contactID, orgID, after.At, afterSource, after.ID, fetch}, "ListTimeline progress")
return nil, errx.InternalError()
@@ -4051,7 +4051,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, orgID, contactID u
ORDER BY lc.clicked_at DESC, lc.id DESC
LIMIT $6
`
crows, err := r.DB.Query(ctx, clickQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
crows, err := resultDB(ctx, r.DB).Query(ctx, clickQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, clickQuery, []any{contactID, orgID, after.At, afterSource, after.ID, fetch}, "ListTimeline link clicks")
return nil, errx.InternalError()
@@ -4136,7 +4136,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, orgID, contactID u
ORDER BY o.opened_at DESC, o.id DESC
LIMIT $6
`
orows, err := r.DB.Query(ctx, openQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
orows, err := resultDB(ctx, r.DB).Query(ctx, openQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, openQuery, []any{contactID, orgID, after.At, afterSource, after.ID, fetch}, "ListTimeline opens")
return nil, errx.InternalError()
@@ -4206,7 +4206,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, orgID, contactID u
ORDER BY ri.created_at DESC, ri.id DESC
LIMIT $6
`
rrows, err := r.DB.Query(ctx, replyQuery, orgID, contactEmail, after.At, afterSource, after.ID, fetch)
rrows, err := resultDB(ctx, r.DB).Query(ctx, replyQuery, orgID, contactEmail, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, replyQuery, nil, "ListTimeline replies")
return nil, errx.InternalError()
@@ -4243,7 +4243,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, orgID, contactID u
ORDER BY de.created_at DESC, de.id DESC
LIMIT $7
`
drows, err := r.DB.Query(ctx, delivQuery, orgID, contactID, contactEmail, after.At, afterSource, after.ID, fetch)
drows, err := resultDB(ctx, r.DB).Query(ctx, delivQuery, orgID, contactID, contactEmail, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, delivQuery, nil, "ListTimeline deliv")
return nil, errx.InternalError()
@@ -4284,7 +4284,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, orgID, contactID u
ORDER BY created_at DESC, id DESC
LIMIT $6
`
srows, err := r.DB.Query(ctx, suppQuery, orgID, contactEmail, after.At, afterSource, after.ID, fetch)
srows, err := resultDB(ctx, r.DB).Query(ctx, suppQuery, orgID, contactEmail, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, suppQuery, nil, "ListTimeline suppression")
return nil, errx.InternalError()
@@ -4325,7 +4325,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, orgID, contactID u
ORDER BY created_at DESC, id DESC
LIMIT $6
`
nrows, err := r.DB.Query(ctx, notesQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
nrows, err := resultDB(ctx, r.DB).Query(ctx, notesQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, notesQuery, nil, "ListTimeline notes")
return nil, errx.InternalError()
@@ -4363,7 +4363,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, orgID, contactID u
ORDER BY created_at DESC, id DESC
LIMIT $6
`
mrows, err := r.DB.Query(ctx, meetingQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
mrows, err := resultDB(ctx, r.DB).Query(ctx, meetingQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, meetingQuery, nil, "ListTimeline meetings")
return nil, errx.InternalError()
@@ -4424,7 +4424,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, orgID, contactID u
ORDER BY created_at DESC, id DESC
LIMIT $6
`
lrows, err := r.DB.Query(ctx, lifeQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
lrows, err := resultDB(ctx, r.DB).Query(ctx, lifeQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, lifeQuery, nil, "ListTimeline lifecycle")
return nil, errx.InternalError()
@@ -4493,7 +4493,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, orgID, contactID u
ORDER BY h.occurred_at DESC, h.id DESC
LIMIT $6
`
hrows, err := r.DB.Query(ctx, hitQuery, orgID, contactID, after.At, afterSource, after.ID, fetch)
hrows, err := resultDB(ctx, r.DB).Query(ctx, hitQuery, orgID, contactID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, hitQuery, nil, "ListTimeline page hits")
return nil, errx.InternalError()
+82 -16
View File
@@ -671,6 +671,7 @@ func (r *emailRepository) ListWarmupScheduleCandidates(ctx context.Context, limi
FROM email_accounts ea
WHERE ea.status = 'active'
AND ea.worker_id IS NOT NULL
AND (ea.test_mode IS NULL OR ea.test_mode='legacy' OR ea.test_mode='diagnostic' AND ea.test_send_enabled)
AND (
(ea.warmup IS NOT NULL AND ea.warmup_paused_at IS NULL)
OR EXISTS (
@@ -1153,7 +1154,7 @@ func (r *emailRepository) Search(ctx context.Context, orgID, search string, curs
ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.track_direct_mail,
ea.auth_state, ea.auth_spf, ea.auth_dkim, ea.auth_dmarc, ea.auth_dmarc_policy, ea.auth_reason, ea.auth_checked_at, ea.auth_failing_since,
ea.warmup, ea.warmup_paused_at, ea.warmup_base,
ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag, COALESCE(ea.warmup_pool_type, 'free') AS warmup_pool_type, ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.warmup_placement, ea.warmup_folder, COALESCE(ea.warmup_retention_days, 0) AS warmup_retention_days, ea.timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = ea.organization_id), '') AS org_timezone, ea.save_to_sent, ea.relay_folder_moves,
ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag, COALESCE(ea.warmup_pool_type, 'free') AS warmup_pool_type, ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.test_mode, ea.test_send_enabled, ea.test_receive_enabled, ea.shared_daily_limit, ea.rolling_recipient_limit, ea.warmup_placement, ea.warmup_folder, COALESCE(ea.warmup_retention_days, 0) AS warmup_retention_days, ea.timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = ea.organization_id), '') AS org_timezone, ea.save_to_sent, ea.relay_folder_moves,
ea.created_at, ea.updated_at,
COALESCE(
array_agg(eat.tag_id) FILTER (WHERE eat.tag_id IS NOT NULL), '{}'
@@ -1204,7 +1205,7 @@ func (r *emailRepository) Search(ctx context.Context, orgID, search string, curs
&i.LastSyncedAt, &i.LastID, &i.CampaignLimit, &i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.TrackDirectMail,
&i.AuthState, &i.AuthSPF, &i.AuthDKIM, &i.AuthDMARC, &i.AuthDMARCPolicy, &i.AuthReason, &i.AuthCheckedAt, &i.AuthFailingSince,
&i.Warmup, &i.WarmupPausedAt, &i.WarmupBase, &i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag, &i.WarmupPoolType,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.WarmupPlacement, &i.WarmupFolder, &i.WarmupRetentionDays, &i.Timezone, &i.OrgTimezone, &i.SaveToSent, &i.RelayFolderMoves,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.TestMode, &i.TestSendEnabled, &i.TestReceiveEnabled, &i.SharedDailyLimit, &i.RollingRecipientLimit, &i.WarmupPlacement, &i.WarmupFolder, &i.WarmupRetentionDays, &i.Timezone, &i.OrgTimezone, &i.SaveToSent, &i.RelayFolderMoves,
&i.CreatedAt, &i.UpdatedAt, &i.Tags,
)
if err != nil {
@@ -1280,7 +1281,7 @@ func (r *emailRepository) Get(ctx context.Context, orgID, emailAccountID string)
ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.track_direct_mail,
ea.auth_state, ea.auth_spf, ea.auth_dkim, ea.auth_dmarc, ea.auth_dmarc_policy, ea.auth_reason, ea.auth_checked_at, ea.auth_failing_since,
ea.warmup, ea.warmup_paused_at, ea.warmup_base,
ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag, COALESCE(ea.warmup_pool_type, 'free') AS warmup_pool_type, ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.warmup_placement, ea.warmup_folder, COALESCE(ea.warmup_retention_days, 0) AS warmup_retention_days, ea.timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = ea.organization_id), '') AS org_timezone, ea.save_to_sent, ea.relay_folder_moves,
ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag, COALESCE(ea.warmup_pool_type, 'free') AS warmup_pool_type, ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.test_mode, ea.test_send_enabled, ea.test_receive_enabled, ea.shared_daily_limit, ea.rolling_recipient_limit, ea.warmup_placement, ea.warmup_folder, COALESCE(ea.warmup_retention_days, 0) AS warmup_retention_days, ea.timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = ea.organization_id), '') AS org_timezone, ea.save_to_sent, ea.relay_folder_moves,
ea.created_at, ea.updated_at,
COALESCE(array_agg(eat.tag_id) FILTER (WHERE eat.tag_id IS NOT NULL), '{}') AS tags
FROM email_accounts ea
@@ -1304,7 +1305,7 @@ func (r *emailRepository) Get(ctx context.Context, orgID, emailAccountID string)
&i.LastSyncedAt, &i.LastID, &i.CampaignLimit, &i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.TrackDirectMail,
&i.AuthState, &i.AuthSPF, &i.AuthDKIM, &i.AuthDMARC, &i.AuthDMARCPolicy, &i.AuthReason, &i.AuthCheckedAt, &i.AuthFailingSince,
&i.Warmup, &i.WarmupPausedAt, &i.WarmupBase, &i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag, &i.WarmupPoolType,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.WarmupPlacement, &i.WarmupFolder, &i.WarmupRetentionDays, &i.Timezone, &i.OrgTimezone, &i.SaveToSent, &i.RelayFolderMoves,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.TestMode, &i.TestSendEnabled, &i.TestReceiveEnabled, &i.SharedDailyLimit, &i.RollingRecipientLimit, &i.WarmupPlacement, &i.WarmupFolder, &i.WarmupRetentionDays, &i.Timezone, &i.OrgTimezone, &i.SaveToSent, &i.RelayFolderMoves,
&i.CreatedAt, &i.UpdatedAt, &i.Tags,
)
if err != nil {
@@ -1579,11 +1580,47 @@ func (r *emailRepository) Update(ctx context.Context, orgID, emailAccountID stri
argPos++
}
if udata.TestMode != nil {
if *udata.TestMode != "legacy" && *udata.TestMode != "diagnostic" && *udata.TestMode != "off" {
return nil, errx.ErrNotEnough
}
setClauses = append(setClauses, fmt.Sprintf("test_mode = $%d", argPos))
args = append(args, *udata.TestMode)
argPos++
}
for _, setting := range []struct {
column string
value *bool
}{
{"test_send_enabled", udata.TestSendEnabled}, {"test_receive_enabled", udata.TestReceiveEnabled},
} {
if setting.value != nil {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", setting.column, argPos))
args = append(args, *setting.value)
argPos++
}
}
for _, setting := range []struct {
column string
value *int
}{
{"shared_daily_limit", udata.SharedDailyLimit}, {"rolling_recipient_limit", udata.RollingRecipientLimit},
} {
if setting.value != nil {
if *setting.value < 0 {
return nil, errx.ErrNotEnough
}
setClauses = append(setClauses, fmt.Sprintf("%s = NULLIF($%d, 0)", setting.column, argPos))
args = append(args, *setting.value)
argPos++
}
}
// Tags are not a column on the row, so a patch that only moves them still
// leaves setClauses empty. Refusing it made the mailbox drawer's tag
// picker unable to save on its own, which is how the dashboard sends it:
// only the fields that actually changed.
if argPos == 3 && udata.Tags == nil {
if argPos == 3 && udata.Tags == nil && udata.SendRecoveryResolution == nil {
return nil, errx.ErrNotEnough
}
@@ -1595,7 +1632,36 @@ func (r *emailRepository) Update(ctx context.Context, orgID, emailAccountID stri
return nil, errx.InternalError()
}
defer tx.Rollback(ctx)
if udata.SendRecoveryResolution != nil {
resolution := udata.SendRecoveryResolution
if strings.ContainsAny(resolution.ConfirmationReference, "\r\n") || len(resolution.ConfirmationReference) > 256 {
return nil, errx.ErrInvalid
}
var evidenceTask any
if resolution.EvidenceTaskID != nil {
evidenceTask = *resolution.EvidenceTaskID
}
tag, resolveErr := tx.Exec(ctx, `WITH held AS(
SELECT ea.organization_id,ea.id,ea.send_recovery_task_id,ea.send_recovery_reason,t.completed_at,ea.last_synced_at
FROM email_accounts ea LEFT JOIN tasks t ON t.id=ea.send_recovery_task_id
WHERE ea.organization_id=$1 AND ea.id=$2 AND ea.status='active' AND ea.send_recovery_hold AND ea.send_recovery_task_id=$6 AND ea.send_recovery_reason=$7 AND ea.send_recovery_reason IN('authentication','permanent','conflict')
AND (ea.send_cooldown_provider IS NULL OR ea.send_cooldown_provider=ea.provider::text) FOR UPDATE OF ea),
valid AS(SELECT * FROM held WHERE NOT EXISTS(SELECT 1 FROM tasks u WHERE u.email_account_id=held.id AND u.send_result_state='unknown' AND u.send_result_applied_at IS NULL)
AND (($3='authentication_repaired' AND held.send_recovery_reason='authentication' AND held.last_synced_at>held.completed_at)
OR ($3='operator_provider_confirmation' AND held.send_recovery_reason IN('permanent','conflict') AND length($5)>0 AND $4::uuid IS NOT NULL
AND EXISTS(SELECT 1 FROM tasks e WHERE e.id=$4 AND e.email_account_id=held.id AND e.send_result_state IN('sent','failed') AND e.send_result_applied_at IS NOT NULL)))),
history AS(INSERT INTO send_recovery_resolutions(organization_id,email_account_id,recovery_task_id,evidence_task_id,previous_reason,evidence_type,confirmation_reference)
SELECT organization_id,id,send_recovery_task_id,$4,send_recovery_reason,$3,$5 FROM valid RETURNING email_account_id)
UPDATE email_accounts ea SET send_recovery_hold=false,send_recovery_reason=NULL,send_recovery_task_id=NULL
FROM history WHERE ea.id=history.email_account_id`, orgID, emailAccountID, resolution.EvidenceType, evidenceTask, strings.TrimSpace(resolution.ConfirmationReference), resolution.HeldTaskID, resolution.HeldReason)
if resolveErr != nil {
db.CaptureError(resolveErr, "resolve send recovery", nil, "exec")
return nil, errx.InternalError()
}
if tag.RowsAffected() != 1 {
return nil, errx.ErrInvalid
}
}
query := fmt.Sprintf(`
UPDATE email_accounts
SET %s
@@ -1604,7 +1670,7 @@ func (r *emailRepository) Update(ctx context.Context, orgID, emailAccountID stri
COALESCE(last_synced_at, created_at) AS last_synced_at, last_id, campaign_limit, min_wait_time, reply_to, tracking_domain, tracking_domain_verified, tracking_domain_verified_at, track_direct_mail,
auth_state, auth_spf, auth_dkim, auth_dmarc, auth_dmarc_policy, auth_reason, auth_checked_at, auth_failing_since,
warmup, warmup_paused_at, warmup_base, warmup_max, warmup_increase, warmup_reply_rate, warmup_tag, warmup_pool_type,
warmup_start_time, warmup_end_time, warmup_days, warmup_placement, warmup_folder, COALESCE(warmup_retention_days, 0) AS warmup_retention_days, save_to_sent, relay_folder_moves, created_at, updated_at,
warmup_start_time, warmup_end_time, warmup_days, test_mode, test_send_enabled, test_receive_enabled, shared_daily_limit, rolling_recipient_limit, warmup_placement, warmup_folder, COALESCE(warmup_retention_days, 0) AS warmup_retention_days, save_to_sent, relay_folder_moves, created_at, updated_at,
timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = email_accounts.organization_id), '') AS org_timezone
`, strings.Join(setClauses, ", "))
@@ -1617,7 +1683,7 @@ func (r *emailRepository) Update(ctx context.Context, orgID, emailAccountID stri
// dashboard on every unrelated edit.
&i.AuthState, &i.AuthSPF, &i.AuthDKIM, &i.AuthDMARC, &i.AuthDMARCPolicy, &i.AuthReason, &i.AuthCheckedAt, &i.AuthFailingSince,
&i.Warmup, &i.WarmupPausedAt, &i.WarmupBase, &i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag, &i.WarmupPoolType,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.WarmupPlacement, &i.WarmupFolder, &i.WarmupRetentionDays, &i.SaveToSent, &i.RelayFolderMoves,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.TestMode, &i.TestSendEnabled, &i.TestReceiveEnabled, &i.SharedDailyLimit, &i.RollingRecipientLimit, &i.WarmupPlacement, &i.WarmupFolder, &i.WarmupRetentionDays, &i.SaveToSent, &i.RelayFolderMoves,
&i.CreatedAt, &i.UpdatedAt,
&i.Timezone, &i.OrgTimezone,
)
@@ -2109,7 +2175,7 @@ func (r *emailRepository) GetByID(ctx context.Context, emailAccountID uuid.UUID)
ea.provider, ea.mail_host, ea.auth_method, ea.domain_grant_id, ea.vendor_connection_id, COALESCE((SELECT vc.vendor FROM mailbox_vendor_connections vc WHERE vc.id = ea.vendor_connection_id), ''), ea.avatar_url, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit,
ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.track_direct_mail, ea.warmup, ea.warmup_paused_at, ea.warmup_base,
ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag, ea.warmup_pool_type,
ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.warmup_placement, ea.warmup_folder, COALESCE(ea.warmup_retention_days, 0) AS warmup_retention_days, ea.timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = ea.organization_id), '') AS org_timezone, ea.save_to_sent, ea.relay_folder_moves,
ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.test_mode, ea.test_send_enabled, ea.test_receive_enabled, ea.shared_daily_limit, ea.rolling_recipient_limit, ea.warmup_placement, ea.warmup_folder, COALESCE(ea.warmup_retention_days, 0) AS warmup_retention_days, ea.timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = ea.organization_id), '') AS org_timezone, ea.save_to_sent, ea.relay_folder_moves,
ea.auth_state, ea.auth_failing_since,
ea.created_at, ea.updated_at,
COALESCE(array_agg(eat.tag_id) FILTER (WHERE eat.tag_id IS NOT NULL), '{}') AS tags
@@ -2125,7 +2191,7 @@ func (r *emailRepository) GetByID(ctx context.Context, emailAccountID uuid.UUID)
&i.Provider, &i.MailHost, &i.AuthMethod, &i.DomainGrantID, &i.VendorConnectionID, &i.Vendor, &i.AvatarURL, &i.Status, &i.LastSyncedAt, &i.LastID, &i.CampaignLimit,
&i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.TrackDirectMail, &i.Warmup, &i.WarmupPausedAt, &i.WarmupBase,
&i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag, &i.WarmupPoolType,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.WarmupPlacement, &i.WarmupFolder, &i.WarmupRetentionDays, &i.Timezone, &i.OrgTimezone, &i.SaveToSent, &i.RelayFolderMoves,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.TestMode, &i.TestSendEnabled, &i.TestReceiveEnabled, &i.SharedDailyLimit, &i.RollingRecipientLimit, &i.WarmupPlacement, &i.WarmupFolder, &i.WarmupRetentionDays, &i.Timezone, &i.OrgTimezone, &i.SaveToSent, &i.RelayFolderMoves,
&i.AuthState, &i.AuthFailingSince,
&i.CreatedAt, &i.UpdatedAt, &i.Tags,
)
@@ -2246,7 +2312,7 @@ func (r *emailRepository) GetByTags(ctx context.Context, scope AccountScope, tag
ea.provider, ea.mail_host, ea.auth_method, ea.domain_grant_id, ea.vendor_connection_id, COALESCE((SELECT vc.vendor FROM mailbox_vendor_connections vc WHERE vc.id = ea.vendor_connection_id), ''), ea.avatar_url, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit,
ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.track_direct_mail, ea.warmup, ea.warmup_paused_at, ea.warmup_base,
ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag,
ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = ea.organization_id), '') AS org_timezone,
ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.test_mode, ea.test_send_enabled, ea.test_receive_enabled, ea.shared_daily_limit, ea.rolling_recipient_limit, ea.timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = ea.organization_id), '') AS org_timezone,
ea.auth_state, ea.auth_failing_since, ea.worker_id,
ea.created_at, ea.updated_at
FROM email_accounts ea
@@ -2274,7 +2340,7 @@ func (r *emailRepository) GetByTags(ctx context.Context, scope AccountScope, tag
&i.Provider, &i.MailHost, &i.AuthMethod, &i.DomainGrantID, &i.VendorConnectionID, &i.Vendor, &i.AvatarURL, &i.Status, &i.LastSyncedAt, &i.LastID, &i.CampaignLimit,
&i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.TrackDirectMail, &i.Warmup, &i.WarmupPausedAt, &i.WarmupBase,
&i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.Timezone, &i.OrgTimezone,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.TestMode, &i.TestSendEnabled, &i.TestReceiveEnabled, &i.SharedDailyLimit, &i.RollingRecipientLimit, &i.Timezone, &i.OrgTimezone,
&i.AuthState, &i.AuthFailingSince, &i.WorkerID,
&i.CreatedAt, &i.UpdatedAt,
)
@@ -2303,7 +2369,7 @@ func (r *emailRepository) GetAllActiveInScope(ctx context.Context, scope Account
ea.provider, ea.mail_host, ea.auth_method, ea.domain_grant_id, ea.vendor_connection_id, COALESCE((SELECT vc.vendor FROM mailbox_vendor_connections vc WHERE vc.id = ea.vendor_connection_id), ''), ea.avatar_url, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit,
ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.track_direct_mail, ea.warmup, ea.warmup_paused_at, ea.warmup_base,
ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag,
ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = ea.organization_id), '') AS org_timezone,
ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.test_mode, ea.test_send_enabled, ea.test_receive_enabled, ea.shared_daily_limit, ea.rolling_recipient_limit, ea.timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = ea.organization_id), '') AS org_timezone,
ea.auth_state, ea.auth_failing_since, ea.worker_id,
ea.created_at, ea.updated_at
FROM email_accounts ea
@@ -2329,7 +2395,7 @@ func (r *emailRepository) GetAllActiveInScope(ctx context.Context, scope Account
&i.Provider, &i.MailHost, &i.AuthMethod, &i.DomainGrantID, &i.VendorConnectionID, &i.Vendor, &i.AvatarURL, &i.Status, &i.LastSyncedAt, &i.LastID, &i.CampaignLimit,
&i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.TrackDirectMail, &i.Warmup, &i.WarmupPausedAt, &i.WarmupBase,
&i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.Timezone, &i.OrgTimezone,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.TestMode, &i.TestSendEnabled, &i.TestReceiveEnabled, &i.SharedDailyLimit, &i.RollingRecipientLimit, &i.Timezone, &i.OrgTimezone,
&i.AuthState, &i.AuthFailingSince, &i.WorkerID,
&i.CreatedAt, &i.UpdatedAt,
)
@@ -2370,7 +2436,7 @@ func (r *emailRepository) GetByCampaignSenders(ctx context.Context, scope Accoun
ea.provider, ea.mail_host, ea.auth_method, ea.domain_grant_id, ea.vendor_connection_id, COALESCE((SELECT vc.vendor FROM mailbox_vendor_connections vc WHERE vc.id = ea.vendor_connection_id), ''), ea.avatar_url, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit,
ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.track_direct_mail, ea.warmup, ea.warmup_paused_at, ea.warmup_base,
ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag,
ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = ea.organization_id), '') AS org_timezone,
ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.test_mode, ea.test_send_enabled, ea.test_receive_enabled, ea.shared_daily_limit, ea.rolling_recipient_limit, ea.timezone, COALESCE((SELECT o.timezone FROM organizations o WHERE o.id = ea.organization_id), '') AS org_timezone,
ea.auth_state, ea.auth_failing_since, ea.worker_id,
ea.created_at, ea.updated_at,
cs.weight, cs.rotation_position, cs.last_sent_at
@@ -2401,7 +2467,7 @@ func (r *emailRepository) GetByCampaignSenders(ctx context.Context, scope Accoun
&i.Provider, &i.MailHost, &i.AuthMethod, &i.DomainGrantID, &i.VendorConnectionID, &i.Vendor, &i.AvatarURL, &i.Status, &i.LastSyncedAt, &i.LastID, &i.CampaignLimit,
&i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.TrackDirectMail, &i.Warmup, &i.WarmupPausedAt, &i.WarmupBase,
&i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.Timezone, &i.OrgTimezone,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.TestMode, &i.TestSendEnabled, &i.TestReceiveEnabled, &i.SharedDailyLimit, &i.RollingRecipientLimit, &i.Timezone, &i.OrgTimezone,
&i.AuthState, &i.AuthFailingSince, &i.WorkerID,
&i.CreatedAt, &i.UpdatedAt,
&sender.Weight, &sender.RotationPosition, &sender.LastSentAt,
+4
View File
@@ -91,6 +91,9 @@ func (r *notificationRepository) UpdatePreferences(ctx context.Context, userID u
}
func (r *notificationRepository) Create(ctx context.Context, n *models.Notification) (*models.Notification, error) {
if id := SendResultEffectEventID(ctx); id != uuid.Nil {
n.ID = uuid.NewSHA1(id, []byte(n.UserID.String()))
}
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
@@ -120,6 +123,7 @@ func (r *notificationRepository) Create(ctx context.Context, n *models.Notificat
CASE WHEN $12 OR seen.v THEN now() END,
$13
FROM seen, candidate n WHERE `+notificationReplyVisibleSQL+`
ON CONFLICT(id) DO UPDATE SET id=EXCLUDED.id
RETURNING created_at, (SELECT v FROM seen)`,
n.ID, n.UserID, n.OrganizationID, n.Category, n.Title, n.Body, n.Link, meta,
groupKey, n.EmailState, n.EmailDueAt, n.PreRead, n.UniboxEmailID).Scan(&n.CreatedAt, &n.MessageSeen)
+27 -5
View File
@@ -15,13 +15,24 @@ import (
type sendResultKey struct{}
type sendResultContext struct {
tx pgx.Tx
taskID uuid.UUID
tx pgx.Tx
taskID uuid.UUID
effects *[]func(context.Context)
effectError *error
}
func AfterSendResultCommit(ctx context.Context, effect func(context.Context)) {
if state, ok := ctx.Value(sendResultKey{}).(sendResultContext); ok && state.effects != nil {
*state.effects = append(*state.effects, effect)
return
}
effect(ctx)
}
type sendResultDB interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
QueryRow(context.Context, string, ...any) pgx.Row
Query(context.Context, string, ...any) (pgx.Rows, error)
}
type sendResultBeginner interface {
@@ -160,19 +171,30 @@ func (r *taskRepository) ApplySendResult(ctx context.Context, result models.Send
if _, err = tx.Exec(ctx, `UPDATE tasks SET send_result_state = $2, send_result_evidence = $3 WHERE id = $1`, result.TaskID, state, evidence); err != nil {
return err
}
var effects []func(context.Context)
var effectError error
if state != "unknown" {
inner := context.WithValue(ctx, sendResultKey{}, sendResultContext{tx: tx, taskID: result.TaskID})
inner := context.WithValue(ctx, sendResultKey{}, sendResultContext{tx: tx, taskID: result.TaskID, effects: &effects, effectError: &effectError})
if err = apply(inner); err != nil {
return err
}
if _, err = tx.Exec(ctx, `UPDATE tasks SET send_result_applied_at = NOW() WHERE id = $1`, result.TaskID); err != nil {
if effectError != nil {
return effectError
}
if _, err = tx.Exec(ctx, `UPDATE tasks SET send_result_applied_at = NOW(), send_released_at=CASE WHEN send_result_state='failed' THEN NOW() ELSE send_released_at END WHERE id = $1`, result.TaskID); err != nil {
return err
}
}
if err = persistSendHold(ctx, tx, mailboxID, result, state); err != nil {
return err
}
return tx.Commit(ctx)
if err = tx.Commit(ctx); err != nil {
return err
}
for _, effect := range effects {
effect(ctx)
}
return nil
}
func persistSendHold(ctx context.Context, tx pgx.Tx, mailboxID uuid.UUID, result models.SendEmailResult, state string) error {
@@ -73,7 +73,7 @@ func (r *verificationEvidenceRepository) Record(ctx context.Context, contactID u
ON CONFLICT (contact_id, kind, ref) DO NOTHING
`
params := []any{contactID, kind, ref, detail, observedAt, step.CampaignID, step.SequenceID}
cmd, err := r.DB.Exec(ctx, query, params...)
cmd, err := resultDB(ctx, r.DB).Exec(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "exec")
return false, err
@@ -89,7 +89,7 @@ func (r *verificationEvidenceRepository) ListForContact(ctx context.Context, con
ORDER BY observed_at DESC
LIMIT 50
`
rows, err := r.DB.Query(ctx, query, contactID)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, contactID)
if err != nil {
db.CaptureError(err, query, []any{contactID}, "query")
return nil, err
@@ -118,7 +118,7 @@ func (r *verificationEvidenceRepository) Verdict(ctx context.Context, contactID
THEN verification_check_status ELSE verification_status END,
verification_source, verification_provider, verification_checked_at, verification_requested_at
FROM contacts WHERE id = $1`
if err := r.DB.QueryRow(ctx, query, contactID).Scan(&stored, &status, &source, &provider, &checked, &v.RequestedAt); err != nil {
if err := resultDB(ctx, r.DB).QueryRow(ctx, query, contactID).Scan(&stored, &status, &source, &provider, &checked, &v.RequestedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return v, errx.ErrNotFound
}
@@ -154,7 +154,7 @@ func (r *verificationEvidenceRepository) SetScore(ctx context.Context, contactID
WHERE id = $1
`
params := []any{contactID, confidence, lp, status, decisive, reason}
if _, err := r.DB.Exec(ctx, query, params...); err != nil {
if _, err := resultDB(ctx, r.DB).Exec(ctx, query, params...); err != nil {
db.CaptureError(err, query, params, "exec")
return err
}
@@ -201,7 +201,7 @@ func (r *verificationEvidenceRepository) CreditCleanDeliveries(ctx context.Conte
SELECT DISTINCT contact_id FROM ins
`
params := []any{window.Seconds(), limit}
rows, err := r.DB.Query(ctx, query, params...)
rows, err := resultDB(ctx, r.DB).Query(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "query")
return nil, err
+81 -75
View File
@@ -346,7 +346,7 @@ func (r *warmupRepository) GetPoolParticipants(ctx context.Context, poolType str
`
}
rows, err := r.db.Query(ctx, query, poolType)
rows, err := resultDB(ctx, r.db).Query(ctx, query, poolType)
if err != nil {
return nil, err
}
@@ -368,7 +368,7 @@ func (r *warmupRepository) GetPoolParticipants(ctx context.Context, poolType str
// mailbox in the other pool moves and keeps every reputation column: changing pool cannot
// launder a penalty, and no mailbox can hold two memberships.
func (r *warmupRepository) MoveToPool(ctx context.Context, poolID, accountID uuid.UUID, role string) error {
tx, err := r.db.Begin(ctx)
tx, err := beginResultTx(ctx, r.db)
if err != nil {
return err
}
@@ -376,6 +376,7 @@ func (r *warmupRepository) MoveToPool(ctx context.Context, poolID, accountID uui
var admitted bool
err = tx.QueryRow(ctx, `SELECT ea.status = 'active' AND ea.seed_scope IS NULL
AND (ea.test_mode IS NULL OR ea.test_mode='legacy' OR ea.test_mode='diagnostic' AND (ea.test_receive_enabled OR ea.test_send_enabled))
AND EXISTS (SELECT 1 FROM organizations o WHERE o.id = ea.organization_id AND o.risk_state IN ('trusted', 'watch'))
AND NOT EXISTS (SELECT 1 FROM cloud_link_mailboxes clm WHERE clm.email_account_id = ea.id)
FROM email_accounts ea WHERE ea.id = $1 FOR UPDATE`, accountID).Scan(&admitted)
@@ -430,7 +431,7 @@ func (r *warmupRepository) MoveToPool(ctx context.Context, poolID, accountID uui
func (r *warmupRepository) PurgeExpiredReputationLedger(ctx context.Context) (int64, error) {
// A standing that requires review (standing_until NULL) never lapses, and
// nothing is forgotten while a live pool row still backs it.
tag, err := r.db.Exec(ctx, `
tag, err := resultDB(ctx, r.db).Exec(ctx, `
DELETE FROM warmup_reputation_ledger l
WHERE l.standing_until IS NOT NULL
AND (l.cloud_health_state IS NULL OR (l.cloud_blocked_until IS NOT NULL AND l.cloud_blocked_until + ($1::int * interval '1 day') <= now()))
@@ -458,7 +459,7 @@ func (r *warmupRepository) MoveExistingToPool(ctx context.Context, poolID, accou
AND pool_id <> $1::uuid
`
cmd, err := r.db.Exec(ctx, query, poolID, accountID)
cmd, err := resultDB(ctx, r.db).Exec(ctx, query, poolID, accountID)
if err != nil {
return false, err
}
@@ -483,7 +484,7 @@ func (r *warmupRepository) LeaveAllPools(ctx context.Context, accountID uuid.UUI
WHERE email_account_id = $1
`
_, err := r.db.Exec(ctx, query, accountID)
_, err := resultDB(ctx, r.db).Exec(ctx, query, accountID)
return err
}
@@ -501,7 +502,7 @@ func (r *warmupRepository) BlockFromPool(ctx context.Context, accountID uuid.UUI
AND blocked_at IS NULL
`
_, err := r.db.Exec(ctx, query, reason, accountID)
_, err := resultDB(ctx, r.db).Exec(ctx, query, reason, accountID)
return err
}
@@ -514,13 +515,13 @@ type WarmupHealthRead struct {
// IsPoolEligible checks current role, authority and health independently of price.
func (r *warmupRepository) IsPoolEligible(ctx context.Context, accountID uuid.UUID, poolType string, sending bool) (bool, error) {
var ok bool
err := r.db.QueryRow(ctx, `SELECT EXISTS (
err := resultDB(ctx, r.db).QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM warmup_pool_participants wpp
JOIN warmup_pools wp ON wp.id = wpp.pool_id
JOIN email_accounts ea ON ea.id = wpp.email_account_id
WHERE ea.id = $1 AND wp.pool_type = $2
AND (NOT $3 OR wpp.participant_role = 'sender_receiver')
AND `+partnerEligibleSQL+`)`, accountID, poolType, sending).Scan(&ok)
AND CASE WHEN $3 THEN wpp.participant_role='sender_receiver' AND `+testSenderSQL+` ELSE `+testRecipientSQL+` END
AND `+poolEligibleSQL+`)`, accountID, poolType, sending).Scan(&ok)
return ok, err
}
@@ -540,7 +541,7 @@ func (r *warmupRepository) GetHealthStates(ctx context.Context, accountIDs []uui
CROSS JOIN LATERAL (` + warmupStandingSQL("a.id") + `
) h
`
rows, err := r.db.Query(ctx, query, accountIDs)
rows, err := resultDB(ctx, r.db).Query(ctx, query, accountIDs)
if err != nil {
return nil, err
}
@@ -564,7 +565,7 @@ func (r *warmupRepository) GetHealthState(ctx context.Context, accountID uuid.UU
) h`
var state string
var blockedUntil *time.Time
err := r.db.QueryRow(ctx, query, accountID).Scan(&state, &blockedUntil)
err := resultDB(ctx, r.db).QueryRow(ctx, query, accountID).Scan(&state, &blockedUntil)
if errors.Is(err, sql.ErrNoRows) {
return models.WarmupHealthHealthy, nil, nil
}
@@ -588,7 +589,7 @@ func (r *warmupRepository) UnblockFromPool(ctx context.Context, accountID uuid.U
WHERE email_account_id = $1
`
_, err := r.db.Exec(ctx, query, accountID)
_, err := resultDB(ctx, r.db).Exec(ctx, query, accountID)
return err
}
@@ -617,7 +618,7 @@ func (r *warmupRepository) IsInPool(ctx context.Context, accountID uuid.UUID, po
`
var exists bool
err := r.db.QueryRow(ctx, query, accountID, poolType).Scan(&exists)
err := resultDB(ctx, r.db).QueryRow(ctx, query, accountID, poolType).Scan(&exists)
return exists, err
}
@@ -629,7 +630,7 @@ func (r *warmupRepository) RecordSpamReport(ctx context.Context, report *SpamRep
ON CONFLICT (reporter_account_id, message_id) DO NOTHING
`
cmd, err := r.db.Exec(ctx, query,
cmd, err := resultDB(ctx, r.db).Exec(ctx, query,
report.ID,
report.ReporterAccountID,
report.ReportedAccountID,
@@ -670,7 +671,7 @@ const participantHealthSelect = participantHealthColumns + `
WHERE wpp.email_account_id = $1`
func (r *warmupRepository) GetCloudStanding(ctx context.Context, accountID uuid.UUID) (*models.WarmupHealthInfo, error) {
m, err := scanCloudLinkMailbox(r.db.QueryRow(ctx,
m, err := scanCloudLinkMailbox(resultDB(ctx, r.db).QueryRow(ctx,
`SELECT `+cloudLinkMailboxColumns+` FROM cloud_link_mailboxes WHERE email_account_id = $1`, accountID))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
@@ -680,7 +681,7 @@ func (r *warmupRepository) GetCloudStanding(ctx context.Context, accountID uuid.
}
var state, reason string
var until *time.Time
if err := r.db.QueryRow(ctx, `SELECT health_state, blocked_until, COALESCE(last_health_reason, '') FROM (`+warmupStandingSQL("$1")+`) standing`, accountID).Scan(&state, &until, &reason); err != nil {
if err := resultDB(ctx, r.db).QueryRow(ctx, `SELECT health_state, blocked_until, COALESCE(last_health_reason, '') FROM (`+warmupStandingSQL("$1")+`) standing`, accountID).Scan(&state, &until, &reason); err != nil {
return nil, err
}
if state == string(models.WarmupHealthBlocked) && reason == "cloud_evidence_unavailable" {
@@ -692,7 +693,7 @@ func (r *warmupRepository) GetCloudStanding(ctx context.Context, accountID uuid.
// GetParticipantHealthForAccount returns the participant row from whichever pool the mailbox
// is in. Exact because a mailbox is in at most one (migration 000097).
func (r *warmupRepository) GetParticipantHealthForAccount(ctx context.Context, accountID uuid.UUID) (*models.WarmupParticipantHealth, error) {
return r.scanParticipantHealth(r.db.QueryRow(ctx, participantHealthSelect, accountID))
return r.scanParticipantHealth(resultDB(ctx, r.db).QueryRow(ctx, participantHealthSelect, accountID))
}
// GetParticipantHealthForAccounts is the batched form of
@@ -707,7 +708,7 @@ func (r *warmupRepository) GetParticipantHealthForAccounts(ctx context.Context,
query := participantHealthColumns + `
WHERE wpp.email_account_id = ANY($1::uuid[])`
rows, err := r.db.Query(ctx, query, accountIDs)
rows, err := resultDB(ctx, r.db).Query(ctx, query, accountIDs)
if err != nil {
return nil, err
}
@@ -731,7 +732,7 @@ func (r *warmupRepository) GetParticipantHealth(ctx context.Context, accountID u
LIMIT 1
`
return r.scanParticipantHealth(r.db.QueryRow(ctx, query, accountID, poolType))
return r.scanParticipantHealth(resultDB(ctx, r.db).QueryRow(ctx, query, accountID, poolType))
}
func (r *warmupRepository) scanParticipantHealth(row pgx.Row) (*models.WarmupParticipantHealth, error) {
@@ -834,12 +835,12 @@ func (r *warmupRepository) UpdateParticipantHealth(ctx context.Context, accountI
`
// The RETURNING list is participantHealthSelect's shape with an empty pool
// type, so the standing the floor decided comes back in the write's trip.
return r.scanParticipantHealth(r.db.QueryRow(ctx, query, state, blockedUntil, reason, score, accountID))
return r.scanParticipantHealth(resultDB(ctx, r.db).QueryRow(ctx, query, state, blockedUntil, reason, score, accountID))
}
// ListParticipantHealth: stalest first, so a deadline is pacing, not a blind spot.
func (r *warmupRepository) ListParticipantHealth(ctx context.Context) ([]models.WarmupParticipantHealth, error) {
rows, err := r.db.Query(ctx, participantHealthColumns+`
rows, err := resultDB(ctx, r.db).Query(ctx, participantHealthColumns+`
ORDER BY wpp.last_health_evaluated_at ASC NULLS FIRST, wpp.email_account_id`)
if err != nil {
return nil, err
@@ -885,7 +886,7 @@ func (r *warmupRepository) HealthMetricCounts(ctx context.Context, accountID uui
`
var c models.WarmupHealthCounts
p := &c.Placement
err := r.db.QueryRow(ctx, query, accountID, since7d, since30d).Scan(
err := resultDB(ctx, r.db).QueryRow(ctx, query, accountID, since7d, since30d).Scan(
&c.SentLast7d, &c.SpamPlacementsLast7d, &c.UserComplaintsLast7d,
&c.ComplaintsLast30d, &c.BouncesLast30d, &c.DeliveredLast30d,
&c.DeletionsLast7d, &c.SpamFlagsLast7d,
@@ -904,7 +905,7 @@ func (r *warmupRepository) CountWarmupSpamReportsSince(ctx context.Context, acco
AND created_at >= $2
AND report_type IN ('spam_placement', 'user_complaint', 'spam', 'spam_folder')
`
err = r.db.QueryRow(ctx, query, accountID, since).Scan(&placements, &complaints)
err = resultDB(ctx, r.db).QueryRow(ctx, query, accountID, since).Scan(&placements, &complaints)
return placements, complaints, err
}
@@ -921,7 +922,7 @@ func (r *warmupRepository) ColdRampStateForAccounts(ctx context.Context, account
return out, nil
}
rows, err := r.db.Query(ctx, `
rows, err := resultDB(ctx, r.db).Query(ctx, `
SELECT id, warmup, cold_ramp_started_at
FROM email_accounts
WHERE id = ANY($1::uuid[])
@@ -942,7 +943,7 @@ func (r *warmupRepository) ColdRampStateForAccounts(ctx context.Context, account
return nil, err
}
placementRows, err := r.db.Query(ctx, `
placementRows, err := resultDB(ctx, r.db).Query(ctx, `
SELECT reported_account_id, created_at
FROM warmup_spam_reports sr
WHERE reported_account_id = ANY($1::uuid[])
@@ -978,7 +979,7 @@ func (r *warmupRepository) StampColdRampStart(ctx context.Context, accountID uui
}
func (r *warmupRepository) SpamPlacementsSince(ctx context.Context, accountID uuid.UUID, since time.Time) ([]time.Time, error) {
rows, err := r.db.Query(ctx, `
rows, err := resultDB(ctx, r.db).Query(ctx, `
SELECT created_at
FROM warmup_spam_reports sr
WHERE reported_account_id = $1
@@ -1011,7 +1012,7 @@ func (r *warmupRepository) SumWarmupSentSince(ctx context.Context, accountID uui
AND date >= DATE($2)
`
var total int
err := r.db.QueryRow(ctx, query, accountID, since).Scan(&total)
err := resultDB(ctx, r.db).QueryRow(ctx, query, accountID, since).Scan(&total)
return total, err
}
@@ -1024,7 +1025,7 @@ func (r *warmupRepository) IncrementDailyCount(ctx context.Context, accountID uu
DO UPDATE SET emails_sent = warmup_statistics.emails_sent + 1
`
_, err := r.db.Exec(ctx, query, accountID, date)
_, err := resultDB(ctx, r.db).Exec(ctx, query, accountID, date)
return err
}
@@ -1037,7 +1038,7 @@ func (r *warmupRepository) IncrementReplyCount(ctx context.Context, accountID uu
ON CONFLICT (email_account_id, date)
DO UPDATE SET emails_replied = warmup_statistics.emails_replied + 1
`
_, err := r.db.Exec(ctx, query, accountID, date)
_, err := resultDB(ctx, r.db).Exec(ctx, query, accountID, date)
return err
}
@@ -1099,7 +1100,7 @@ func (r *warmupRepository) LastWarmupSendFailure(ctx context.Context, accountID
// Only failures the worker answered with (status failed); a dead-lettered
// dispatch is the platform's own problem and says nothing about the server.
f := &models.WarmupSendFailure{}
err := r.db.QueryRow(ctx, `
err := resultDB(ctx, r.db).QueryRow(ctx, `
SELECT tf.message, t.updated_at
FROM tasks t
JOIN task_failures tf ON tf.task_id = t.id
@@ -1137,7 +1138,7 @@ func (r *warmupRepository) PoolSpamPlacementRate(ctx context.Context, since time
(SELECT COALESCE(SUM(emails_sent), 0) FROM warmup_statistics WHERE date >= DATE($1)) AS sent
`
var placements, sent int
if err := r.db.QueryRow(ctx, query, since).Scan(&placements, &sent); err != nil {
if err := resultDB(ctx, r.db).QueryRow(ctx, query, since).Scan(&placements, &sent); err != nil {
return 0, err
}
if sent == 0 {
@@ -1155,7 +1156,7 @@ func (r *warmupRepository) PoolSpamPlacementsByProvider(ctx context.Context, sin
WHERE report_type = 'spam_placement' AND created_at >= $1
GROUP BY 1
`
rows, err := r.db.Query(ctx, query, since)
rows, err := resultDB(ctx, r.db).Query(ctx, query, since)
if err != nil {
return nil, err
}
@@ -1196,7 +1197,7 @@ func (p HostPlacementStat) Rate() float64 {
func (r *warmupRepository) SenderPlacementByHost(ctx context.Context, senderAccountID uuid.UUID, since time.Time) (map[string]HostPlacementStat, error) {
// Read from the rollup the placement dashboard reads, so the selector and
// the charts cannot disagree. A row with no host is unattributable.
rows, err := r.db.Query(ctx, `
rows, err := resultDB(ctx, r.db).Query(ctx, `
SELECT recipient_host, SUM(inbox + tabs + spam)::int, SUM(spam)::int
FROM warmup_placement_daily
WHERE sender_account_id = $1 AND date >= ($2::timestamptz AT TIME ZONE 'UTC')::date
@@ -1241,7 +1242,12 @@ const poolAuthoritySQL = `
AND l.cloud_health_state IN ('quarantined', 'blocked')
AND (l.cloud_blocked_until IS NULL OR l.cloud_blocked_until > NOW()))`
var partnerEligibleSQL = poolAuthoritySQL + ` AND
const testSenderSQL = `(ea.test_mode IS NULL OR ea.test_mode='legacy' OR ea.test_mode='diagnostic' AND ea.test_send_enabled)`
const testRecipientSQL = `(ea.test_mode IS NULL OR ea.test_mode='legacy' OR ea.test_mode='diagnostic' AND ea.test_receive_enabled)`
var partnerEligibleSQL = poolEligibleSQL + ` AND ` + testRecipientSQL
var poolEligibleSQL = poolAuthoritySQL + ` AND
wpp.participant_role IN ('sender_receiver', 'recipient_only')
AND ea.status = 'active'
AND (
@@ -1356,7 +1362,7 @@ func (r *warmupRepository) WarmupPartnerCandidates(ctx context.Context, poolType
}
var senderOrg *uuid.UUID
var senderMax int
err = r.db.QueryRow(ctx, `SELECT organization_id, warmup_max FROM email_accounts WHERE id = $1`, senderID).
err = resultDB(ctx, r.db).QueryRow(ctx, `SELECT organization_id, warmup_max FROM email_accounts WHERE id = $1`, senderID).
Scan(&senderOrg, &senderMax)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
@@ -1464,7 +1470,7 @@ func (r *warmupRepository) queryPartnerCandidates(ctx context.Context, candidate
args = append(args, task)
suffix = strings.Replace(suffix, "AND wt.recipient_account_id IN (SELECT id FROM cand)", "AND wt.task_id <> $"+strconv.Itoa(len(args))+" AND wt.recipient_account_id IN (SELECT id FROM cand)", 1)
}
rows, err := r.db.Query(ctx, partnerCandidateSelectPrefix+candidateSQL+suffix+tail, args...)
rows, err := resultDB(ctx, r.db).Query(ctx, partnerCandidateSelectPrefix+candidateSQL+suffix+tail, args...)
if err != nil {
return nil, err
}
@@ -1498,7 +1504,7 @@ func (r *warmupRepository) GetPoolParticipantProviders(ctx context.Context, pool
}
query += " AND wpp.participant_role IN ('sender_receiver', 'recipient_only')"
rows, err := r.db.Query(ctx, query, poolType)
rows, err := resultDB(ctx, r.db).Query(ctx, query, poolType)
if err != nil {
return nil, err
}
@@ -1527,7 +1533,7 @@ func (r *warmupRepository) GetWarmupStatistics(ctx context.Context, accountID uu
ORDER BY date ASC
`
rows, err := r.db.Query(ctx, query, accountID, from, to)
rows, err := resultDB(ctx, r.db).Query(ctx, query, accountID, from, to)
if err != nil {
return nil, err
}
@@ -1563,7 +1569,7 @@ func (r *warmupRepository) GetOrCreateDailyStats(ctx context.Context, accountID
`
stat := &WarmupStatistic{}
err := r.db.QueryRow(ctx, query, accountID, date, targetVolume).Scan(
err := resultDB(ctx, r.db).QueryRow(ctx, query, accountID, date, targetVolume).Scan(
&stat.EmailAccountID,
&stat.Date,
&stat.EmailsSent,
@@ -1580,7 +1586,7 @@ func (r *warmupRepository) CreateWarmupToken(ctx context.Context, token *models.
INSERT INTO warmup_tokens (token, task_id, sender_account_id, recipient_account_id, conversation_theme, content_source, conversation_id, conversation_turn, subject, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
_, err := r.db.Exec(ctx, query,
_, err := resultDB(ctx, r.db).Exec(ctx, query,
token.Token,
token.TaskID,
token.SenderAccountID,
@@ -1600,20 +1606,20 @@ func (r *warmupRepository) GetWarmupToken(ctx context.Context, tokenID uuid.UUID
query := `SELECT ` + warmupTokenColumns + `
FROM warmup_tokens
WHERE token = $1 AND consumed_at IS NULL AND expires_at > NOW()`
return scanWarmupToken(r.db.QueryRow(ctx, query, tokenID))
return scanWarmupToken(resultDB(ctx, r.db).QueryRow(ctx, query, tokenID))
}
func (r *warmupRepository) FindWarmupToken(ctx context.Context, tokenID uuid.UUID) (*models.WarmupToken, error) {
query := `SELECT ` + warmupTokenColumns + `
FROM warmup_tokens
WHERE token = $1`
return scanWarmupToken(r.db.QueryRow(ctx, query, tokenID))
return scanWarmupToken(resultDB(ctx, r.db).QueryRow(ctx, query, tokenID))
}
// ConsumeWarmupToken marks a warmup token as consumed
func (r *warmupRepository) ConsumeWarmupToken(ctx context.Context, tokenID uuid.UUID) error {
query := `UPDATE warmup_tokens SET consumed_at = NOW() WHERE token = $1`
_, err := r.db.Exec(ctx, query, tokenID)
_, err := resultDB(ctx, r.db).Exec(ctx, query, tokenID)
return err
}
@@ -1706,7 +1712,7 @@ func (r *warmupRepository) FindDeliveredWarmupToken(ctx context.Context, recipie
)
ORDER BY ` + matchesMessageID + ` DESC, wt.created_at DESC
LIMIT 1`
return scanWarmupToken(r.db.QueryRow(ctx, query, recipientAccountID, messageID, senderAddress, subject))
return scanWarmupToken(resultDB(ctx, r.db).QueryRow(ctx, query, recipientAccountID, messageID, senderAddress, subject))
}
var ErrWarmupDeliveryPending = errors.New("warmup send is awaiting its provider message identifier")
@@ -1765,7 +1771,7 @@ func (r *warmupRepository) IsWarmupDelivery(ctx context.Context, accountID uuid.
)`
// One snapshot ensures a send confirmation cannot fall between known and pending checks.
var known, pending bool
if err := r.db.QueryRow(ctx, query, accountID, messageID, senderAddress, subject).Scan(&known, &pending); err != nil {
if err := resultDB(ctx, r.db).QueryRow(ctx, query, accountID, messageID, senderAddress, subject).Scan(&known, &pending); err != nil {
return false, err
}
if !known && pending {
@@ -1802,7 +1808,7 @@ func (r *warmupRepository) IsWarmupThreadReply(ctx context.Context, accountID uu
SELECT 1 FROM warmup_thread_messages m WHERE m.message_id = ANY($2)
)`
var known bool
if err := r.db.QueryRow(ctx, query, accountID, parents).Scan(&known); err != nil {
if err := resultDB(ctx, r.db).QueryRow(ctx, query, accountID, parents).Scan(&known); err != nil {
return false, err
}
return known, nil
@@ -1814,7 +1820,7 @@ func (r *warmupRepository) RecordWarmupThreadMessage(ctx context.Context, accoun
if len(ids) == 0 || accountID == uuid.Nil {
return nil
}
_, err := r.db.Exec(ctx,
_, err := resultDB(ctx, r.db).Exec(ctx,
`INSERT INTO warmup_thread_messages (message_id, email_account_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
ids[0], accountID)
@@ -1842,7 +1848,7 @@ func (r *warmupRepository) GetRecentlyUsedPartners(ctx context.Context, accountI
AND created_at >= $2
`
rows, err := r.db.Query(ctx, query, accountID, since)
rows, err := resultDB(ctx, r.db).Query(ctx, query, accountID, since)
if err != nil {
return nil, err
}
@@ -1873,7 +1879,7 @@ func (r *warmupRepository) GetRecentPartnerCounts(ctx context.Context, accountID
GROUP BY recipient_account_id
`
rows, err := r.db.Query(ctx, query, accountID, since)
rows, err := resultDB(ctx, r.db).Query(ctx, query, accountID, since)
if err != nil {
return nil, err
}
@@ -1900,7 +1906,7 @@ func (r *warmupRepository) RecordWarmupReceived(ctx context.Context, accountID,
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (email_account_id, internal_id) DO NOTHING
`
_, err := r.db.Exec(ctx, query, accountID, internalID, messageID, senderAccountID, landedSpam)
_, err := resultDB(ctx, r.db).Exec(ctx, query, accountID, internalID, messageID, senderAccountID, landedSpam)
return err
}
@@ -1913,7 +1919,7 @@ func (r *warmupRepository) GetWarmupReceived(ctx context.Context, accountID, int
WHERE email_account_id = $1 AND internal_id = $2
`
var w WarmupReceived
err := r.db.QueryRow(ctx, query, accountID, internalID).Scan(
err := resultDB(ctx, r.db).QueryRow(ctx, query, accountID, internalID).Scan(
&w.EmailAccountID, &w.InternalID, &w.MessageID, &w.SenderAccountID, &w.CreatedAt, &w.RetiredAt, &w.LandedSpam,
)
if errors.Is(err, sql.ErrNoRows) {
@@ -1979,7 +1985,7 @@ func (r *warmupRepository) ListWarmupSentCopiesToRetire(ctx context.Context, def
}
func (r *warmupRepository) scanMailToRetire(ctx context.Context, query string, defaultDays, limit int, sent bool) ([]WarmupMailToRetire, error) {
rows, err := r.db.Query(ctx, query, defaultDays, limit)
rows, err := resultDB(ctx, r.db).Query(ctx, query, defaultDays, limit)
if err != nil {
return nil, err
}
@@ -2003,7 +2009,7 @@ func (r *warmupRepository) scanMailToRetire(ctx context.Context, query string, d
// RetireWarmupReceived stamps the receipt once its deletion is on the bus.
func (r *warmupRepository) RetireWarmupReceived(ctx context.Context, accountID, internalID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
_, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE warmup_received SET retired_at = NOW()
WHERE email_account_id = $1 AND internal_id = $2 AND retired_at IS NULL`, accountID, internalID)
return err
@@ -2012,7 +2018,7 @@ func (r *warmupRepository) RetireWarmupReceived(ctx context.Context, accountID,
// RetireWarmupSentCopy stamps the token once the deletion of the sender's own
// copy is on the bus.
func (r *warmupRepository) RetireWarmupSentCopy(ctx context.Context, token uuid.UUID) error {
_, err := r.db.Exec(ctx, `
_, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE warmup_tokens SET sent_retired_at = NOW()
WHERE token = $1 AND sent_retired_at IS NULL`, token)
return err
@@ -2043,7 +2049,7 @@ func (r *warmupRepository) PruneWarmupEventsBefore(ctx context.Context, before t
AND (sent_message_id = '' OR sent_retired_at IS NOT NULL
OR sender_account_id IN (SELECT id FROM email_accounts WHERE provider = 'smtp_imap'))`, []any{before}},
} {
cmd, err := r.db.Exec(ctx, st.query, st.args...)
cmd, err := resultDB(ctx, r.db).Exec(ctx, st.query, st.args...)
if err != nil {
return total, err
}
@@ -2060,7 +2066,7 @@ func (r *warmupRepository) RecordWarmupTampering(ctx context.Context, accountID
VALUES ($1, $2, $3)
ON CONFLICT (email_account_id, message_id, kind) DO NOTHING
`
cmd, err := r.db.Exec(ctx, query, accountID, messageID, kind)
cmd, err := resultDB(ctx, r.db).Exec(ctx, query, accountID, messageID, kind)
if err != nil {
return false, err
}
@@ -2068,7 +2074,7 @@ func (r *warmupRepository) RecordWarmupTampering(ctx context.Context, accountID
}
func (r *warmupRepository) WithdrawWarmupTampering(ctx context.Context, accountID uuid.UUID, messageID, kind string) (bool, error) {
cmd, err := r.db.Exec(ctx, `
cmd, err := resultDB(ctx, r.db).Exec(ctx, `
DELETE FROM warmup_tampering_events
WHERE email_account_id = $1 AND message_id = $2 AND kind = $3`,
accountID, messageID, kind)
@@ -2080,7 +2086,7 @@ func (r *warmupRepository) WithdrawWarmupTampering(ctx context.Context, accountI
func (r *warmupRepository) HasWarmupTampering(ctx context.Context, accountID uuid.UUID, messageID, kind string) (bool, error) {
var exists bool
err := r.db.QueryRow(ctx, `
err := resultDB(ctx, r.db).QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM warmup_tampering_events
WHERE email_account_id = $1 AND message_id = $2 AND kind = $3)`,
accountID, messageID, kind).Scan(&exists)
@@ -2089,7 +2095,7 @@ func (r *warmupRepository) HasWarmupTampering(ctx context.Context, accountID uui
func (r *warmupRepository) CountWarmupTamperingBetween(ctx context.Context, accountID uuid.UUID, from, to time.Time, excludeMessageID string, byAddress bool) (int, int, error) {
var deletions, spamFlags int
err := r.db.QueryRow(ctx, `
err := resultDB(ctx, r.db).QueryRow(ctx, `
SELECT COUNT(*) FILTER (WHERE kind = 'deletion'), COUNT(*) FILTER (WHERE kind = 'spam_flag')
FROM warmup_tampering_events
WHERE email_account_id IN (
@@ -2107,7 +2113,7 @@ func (r *warmupRepository) CountWarmupTamperingBetween(ctx context.Context, acco
func (r *warmupRepository) WarmupReceiptRetired(ctx context.Context, accountID uuid.UUID, messageID string) (bool, error) {
var retired bool
err := r.db.QueryRow(ctx, `
err := resultDB(ctx, r.db).QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM warmup_received
WHERE email_account_id = $1 AND message_id = $2 AND retired_at IS NOT NULL)`,
accountID, messageID).Scan(&retired)
@@ -2123,7 +2129,7 @@ type WarmupTamperingToVerify struct {
}
func (r *warmupRepository) ListUnverifiedDeletions(ctx context.Context, since time.Time, retryAfter time.Duration, limit int) ([]WarmupTamperingToVerify, error) {
rows, err := r.db.Query(ctx, `
rows, err := resultDB(ctx, r.db).Query(ctx, `
SELECT t.email_account_id, ea.user_id, ea.worker_id, t.message_id
FROM warmup_tampering_events t
JOIN email_accounts ea ON ea.id = t.email_account_id
@@ -2152,7 +2158,7 @@ func (r *warmupRepository) ListUnverifiedDeletions(ctx context.Context, since ti
}
func (r *warmupRepository) MarkTamperingVerifyRequested(ctx context.Context, accountID uuid.UUID, messageID string) error {
_, err := r.db.Exec(ctx, `
_, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE warmup_tampering_events SET verify_requested_at = NOW()
WHERE email_account_id = $1 AND message_id = $2 AND kind = 'deletion' AND verified_at IS NULL`,
accountID, messageID)
@@ -2160,7 +2166,7 @@ func (r *warmupRepository) MarkTamperingVerifyRequested(ctx context.Context, acc
}
func (r *warmupRepository) MarkTamperingVerified(ctx context.Context, accountID uuid.UUID, messageID, kind string) error {
_, err := r.db.Exec(ctx, `
_, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE warmup_tampering_events SET verified_at = NOW()
WHERE email_account_id = $1 AND message_id = $2 AND kind = $3 AND verified_at IS NULL`,
accountID, messageID, kind)
@@ -2187,7 +2193,7 @@ const noSiblingInPoolSQL = `NOT EXISTS (
func (r *warmupRepository) GetWarmupHold(ctx context.Context, accountID uuid.UUID) (*WarmupHold, error) {
var h WarmupHold
var state string
err := r.db.QueryRow(ctx, `
err := resultDB(ctx, r.db).QueryRow(ctx, `
SELECT health_state, blocked_at, blocked_until, COALESCE(blocked_reason, ''), true
FROM warmup_pool_participants WHERE email_account_id = $1
UNION ALL
@@ -2215,7 +2221,7 @@ func (r *warmupRepository) ReviseWarmupHold(ctx context.Context, accountID uuid.
until, newReason = nil, ""
}
if hold.InPool {
cmd, err := r.db.Exec(ctx, `
cmd, err := resultDB(ctx, r.db).Exec(ctx, `
UPDATE warmup_pool_participants
SET health_state = $4::text,
blocked_until = $5::timestamptz,
@@ -2246,7 +2252,7 @@ func (r *warmupRepository) ReviseWarmupHold(ctx context.Context, accountID uuid.
WHERE a.id = $1 AND l.organization_id = a.organization_id AND l.email = lower(btrim(a.email))
AND l.blocked_reason = $2 AND l.blocked_until = $3 AND ` + noSiblingInPoolSQL
}
cmd, err := r.db.Exec(ctx, sqlText, args...)
cmd, err := resultDB(ctx, r.db).Exec(ctx, sqlText, args...)
if err != nil {
return false, err
}
@@ -2257,7 +2263,7 @@ func (r *warmupRepository) ReviseWarmupHold(ctx context.Context, accountID uuid.
func (r *warmupRepository) CountWarmupTamperingSince(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error) {
query := `SELECT COUNT(*) FROM warmup_tampering_events WHERE email_account_id = $1 AND created_at >= $2`
var n int
err := r.db.QueryRow(ctx, query, accountID, since).Scan(&n)
err := resultDB(ctx, r.db).QueryRow(ctx, query, accountID, since).Scan(&n)
return n, err
}
@@ -2268,7 +2274,7 @@ func (r *warmupRepository) CreateWarmupAppeal(ctx context.Context, accountID, us
INSERT INTO warmup_appeals (id, email_account_id, user_id, reason, status)
VALUES ($1, $2, $3, $4, 'pending')
`
_, err := r.db.Exec(ctx, query, id, accountID, userID, reason)
_, err := resultDB(ctx, r.db).Exec(ctx, query, id, accountID, userID, reason)
return id, err
}
@@ -2276,7 +2282,7 @@ func (r *warmupRepository) CreateWarmupAppeal(ctx context.Context, accountID, us
func (r *warmupRepository) HasPendingWarmupAppeal(ctx context.Context, accountID uuid.UUID) (bool, error) {
query := `SELECT EXISTS(SELECT 1 FROM warmup_appeals WHERE email_account_id = $1 AND status = 'pending')`
var exists bool
err := r.db.QueryRow(ctx, query, accountID).Scan(&exists)
err := resultDB(ctx, r.db).QueryRow(ctx, query, accountID).Scan(&exists)
return exists, err
}
@@ -2292,7 +2298,7 @@ func (r *warmupRepository) GetRecentPartnerDomainCounts(ctx context.Context, acc
AND wt.created_at >= $2
GROUP BY domain
`
rows, err := r.db.Query(ctx, query, accountID, since)
rows, err := resultDB(ctx, r.db).Query(ctx, query, accountID, since)
if err != nil {
return nil, err
}
@@ -2338,12 +2344,12 @@ func (r *warmupRepository) GetPartnerDiversity(ctx context.Context, accountID uu
AND wt.sent_message_id <> ''
`
var out WarmupPartnerDiversity
if err := r.db.QueryRow(ctx, query, accountID, since).Scan(&out.Mailboxes, &out.Domains, &out.Organizations); err != nil {
if err := resultDB(ctx, r.db).QueryRow(ctx, query, accountID, since).Scan(&out.Mailboxes, &out.Domains, &out.Organizations); err != nil {
return out, err
}
// Arrivals are what the recipient's own sync verified, so a partner whose
// mail never landed does not count as one heard from.
err := r.db.QueryRow(ctx, `
err := resultDB(ctx, r.db).QueryRow(ctx, `
SELECT COUNT(*), COUNT(DISTINCT sender_account_id)
FROM warmup_received
WHERE email_account_id = $1
@@ -2386,7 +2392,7 @@ func (r *warmupRepository) GetLatestReplyCandidate(ctx context.Context, senderAc
`
candidate := &WarmupReplyCandidate{}
err := r.db.QueryRow(ctx, query, senderAccountID, recipientAccountID).Scan(
err := resultDB(ctx, r.db).QueryRow(ctx, query, senderAccountID, recipientAccountID).Scan(
&candidate.MessageID,
&candidate.Subject,
&candidate.ThreadID,
@@ -2407,7 +2413,7 @@ func (r *warmupRepository) GetLatestReplyCandidate(ctx context.Context, senderAc
// GetAllParticipantAccountIDs returns all unique account IDs across all warmup pools
func (r *warmupRepository) GetAllParticipantAccountIDs(ctx context.Context) ([]uuid.UUID, error) {
rows, err := r.db.Query(ctx, `SELECT DISTINCT email_account_id FROM warmup_pool_participants`)
rows, err := resultDB(ctx, r.db).Query(ctx, `SELECT DISTINCT email_account_id FROM warmup_pool_participants`)
if err != nil {
return nil, err
}
@@ -2431,7 +2437,7 @@ func (r *warmupRepository) GetPoolHealthCounts(ctx context.Context) (map[string]
FROM warmup_pool_participants
GROUP BY health_state
`
rows, err := r.db.Query(ctx, query)
rows, err := resultDB(ctx, r.db).Query(ctx, query)
if err != nil {
return nil, 0, err
}
+6 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/generation"
)
// ConversationFilter narrows a warmup_conversations listing.
@@ -140,12 +141,15 @@ const qualifiedConversationCols = `c.id, c.pool_type, c.segment, c.source, c.the
// accounting atomic prevents hot threads and removes one database round trip
// from every warmup send. A small random tie-break keeps concurrent senders from
// marching through the bank in the same order.
const openingConversationSQL = `status='active' AND reply_eligible AND lint_passed
AND scenario_version='` + generation.DiagnosticScenarioVersion + `' AND rendering_version='` + generation.CanonicalRenderingVersion + `'`
func (r *warmupContentRepository) PickConversation(ctx context.Context, segment string) (*models.WarmupConversation, error) {
query := `
WITH picked AS (
SELECT id
FROM warmup_conversations
WHERE status = 'active' AND (segment = $1 OR segment = '')
WHERE ` + openingConversationSQL + ` AND (segment = $1 OR segment = '')
ORDER BY (segment = $1) DESC, usage_count ASC, random()
LIMIT 1
FOR UPDATE SKIP LOCKED
@@ -300,7 +304,7 @@ func (r *warmupContentRepository) DeleteConversation(ctx context.Context, id uui
func (r *warmupContentRepository) CountActiveConversations(ctx context.Context, poolType, segment string) (int, error) {
var n int
err := r.db.QueryRow(ctx,
`SELECT COUNT(*) FROM warmup_conversations WHERE pool_type = $1 AND segment = $2 AND status = 'active'`,
`SELECT COUNT(*) FROM warmup_conversations WHERE pool_type = $1 AND segment = $2 AND `+openingConversationSQL,
poolType, segment).Scan(&n)
return n, err
}
+4 -1
View File
@@ -489,6 +489,9 @@ func (r *webhookRepository) MatchingEndpoints(ctx context.Context, orgID uuid.UU
}
func (r *webhookRepository) EnqueueDelivery(ctx context.Context, delivery *models.WebhookDelivery) error {
if id := SendResultEffectEventID(ctx); id != uuid.Nil {
delivery.ID = uuid.NewSHA1(id, []byte(delivery.EndpointID.String()))
}
if delivery.ID == uuid.Nil {
delivery.ID = uuid.New()
}
@@ -505,7 +508,7 @@ func (r *webhookRepository) EnqueueDelivery(ctx context.Context, delivery *model
INSERT INTO webhook_deliveries (
id, endpoint_id, organization_id, event_type, event_id, payload,
status, attempt_count, max_attempts, next_attempt_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) ON CONFLICT(id) DO NOTHING
`,
delivery.ID, delivery.EndpointID, delivery.OrganizationID,
delivery.EventType, delivery.EventID, json.RawMessage(delivery.Payload),
+2 -2
View File
@@ -50,8 +50,8 @@ func newPoolLinkFixture(t *testing.T) *poolLinkFixture {
for _, id := range []uuid.UUID{f.recipient, f.sender} {
addr := "pl-" + id.String()[:8] + "@test.local"
exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain,
signature_html, provider, status, campaign_limit, min_wait_time, timezone)
VALUES ($1, $2, $3, $4, 'PL', '', '', 'smtp_imap', 'active', 50, 600, 'UTC')`,
signature_html, provider, status, campaign_limit, min_wait_time, timezone,test_mode)
VALUES ($1, $2, $3, $4, 'PL', '', '', 'smtp_imap', 'active', 50, 600, 'UTC',NULL)`,
id, f.user, f.org, addr)
if id == f.sender {
f.senderTo = addr
+443
View File
@@ -0,0 +1,443 @@
package repository
import (
"context"
"encoding/json"
"errors"
"net/mail"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/models"
)
var ErrSendAdmissionDenied = errors.New("current send authority or capacity unavailable")
type OutboundReservation struct {
TaskID uuid.UUID
MailboxID uuid.UUID
OrganizationID uuid.UUID
WorkerID uuid.UUID
Provider models.InboxProvider
Recipients []string
}
type OutboundAdmissionRepository interface {
ReserveOutbound(context.Context, OutboundReservation) (uuid.UUID, error)
InspectOutbound(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (*WarmupDispatchState, error)
BeginOutbound(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, uuid.UUID) (*WarmupDispatchState, error)
FinishOutbound(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, models.SendEmailResult) error
CancelOutbound(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, uuid.UUID) error
}
func recipientOccurrences(in []string) ([]string, error) {
if len(in) == 0 || len(in) > 1000 {
return nil, ErrSendAdmissionDenied
}
out := make([]string, 0, len(in))
for _, raw := range in {
if strings.ContainsAny(raw, "\r\n\x00") {
return nil, ErrSendAdmissionDenied
}
a, err := mail.ParseAddress(raw)
if err != nil {
return nil, ErrSendAdmissionDenied
}
out = append(out, strings.ToLower(strings.TrimSpace(a.Address)))
}
return out, nil
}
func MatchOutboundRecipients(expected, actual []string) bool {
want, err := recipientOccurrences(expected)
if err != nil {
return false
}
got, err := recipientOccurrences(actual)
if err != nil {
return false
}
sort.Strings(want)
sort.Strings(got)
return strings.Join(want, "\x00") == strings.Join(got, "\x00")
}
func sendAuthority(ctx context.Context, tx pgx.Tx, task, mailbox, org, worker uuid.UUID, provider string, ownReservation bool) (string, error) {
var lane string
var allowed bool
err := tx.QueryRow(ctx, `SELECT t.task_type, ea.status='active' AND ea.organization_id=$3 AND ea.provider::text=$5
AND ea.worker_id=$4 AND o.risk_state<>'suspended'
AND (NOT ea.send_recovery_hold OR ($6 AND ea.send_recovery_reason='unknown' AND ea.send_recovery_task_id=t.id))
AND (ea.send_cooldown_until IS NULL OR ea.send_cooldown_until<=NOW())
AND EXISTS(SELECT 1 FROM fleet_nodes n WHERE n.id=$4 AND n.warmup_send_protocol>=2 AND n.active AND n.last_seen_at>NOW()-INTERVAL '10 minutes')
AND (t.task_type NOT IN ('warmup','placement') OR ea.test_mode IS NULL OR ea.test_mode='legacy' OR ea.test_mode='diagnostic' AND ea.test_send_enabled)
AND t.send_result_applied_at IS NULL AND ((NOT $6 AND t.status='active') OR ($6 AND t.status='completed'))
FROM tasks t JOIN email_accounts ea ON ea.id=t.email_account_id JOIN organizations o ON o.id=ea.organization_id
WHERE t.id=$1 AND ea.id=$2`, task, mailbox, org, worker, provider, ownReservation).Scan(&lane, &allowed)
if err != nil {
return "", err
}
if !allowed {
return "", ErrSendAdmissionDenied
}
return lane, nil
}
func lockSend(ctx context.Context, tx pgx.Tx, task, mailbox uuid.UUID) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,268))`, task.String()); err != nil {
return err
}
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,269))`, mailbox.String())
return err
}
func (r *taskRepository) ReserveOutbound(ctx context.Context, in OutboundReservation) (uuid.UUID, error) {
recipients, err := recipientOccurrences(in.Recipients)
if err != nil {
return uuid.Nil, err
}
tx, err := r.db.Begin(ctx)
if err != nil {
return uuid.Nil, err
}
defer tx.Rollback(ctx)
if err = lockSend(ctx, tx, in.TaskID, in.MailboxID); err != nil {
return uuid.Nil, err
}
var existing *uuid.UUID
var released *time.Time
err = tx.QueryRow(ctx, `SELECT send_executor_nonce,send_released_at FROM tasks WHERE id=$1 AND email_account_id=$2`, in.TaskID, in.MailboxID).Scan(&existing, &released)
if err != nil {
return uuid.Nil, err
}
if existing != nil && released == nil {
return uuid.Nil, ErrSendAdmissionDenied
}
var warmupNonce *uuid.UUID
err = tx.QueryRow(ctx, `SELECT dispatch_nonce FROM warmup_tasks WHERE task_id=$1`, in.TaskID).Scan(&warmupNonce)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, err
}
lane, err := sendAuthority(ctx, tx, in.TaskID, in.MailboxID, in.OrganizationID, in.WorkerID, string(in.Provider), warmupNonce != nil)
if err != nil {
return uuid.Nil, err
}
if lane != "warmup" && lane != "campaign" && lane != "email" && lane != "placement" {
return uuid.Nil, ErrSendAdmissionDenied
}
if err = checkSendRecipients(ctx, tx, in.OrganizationID, recipients); err != nil {
return uuid.Nil, err
}
var timezone string
var campaignCap, warmupCap int
var shared, rolling *int
err = tx.QueryRow(ctx, `SELECT COALESCE(NULLIF(ea.timezone,''),NULLIF(o.timezone,''),'UTC'),ea.campaign_limit,ea.warmup_max,ea.shared_daily_limit,ea.rolling_recipient_limit
FROM email_accounts ea JOIN organizations o ON o.id=ea.organization_id WHERE ea.id=$1 FOR UPDATE OF ea`, in.MailboxID).Scan(&timezone, &campaignCap, &warmupCap, &shared, &rolling)
if err != nil {
return uuid.Nil, err
}
var occurrences int
err = tx.QueryRow(ctx, `SELECT COALESCE(SUM(COALESCE(cardinality(send_recipients),GREATEST(1,cardinality(et.to_addrs)+COALESCE(cardinality(et.cc),0)+COALESCE(cardinality(et.bcc),0)))) FILTER(WHERE COALESCE(send_reserved_at,completed_at)>NOW()-INTERVAL '24 hours'),0)
FROM tasks t LEFT JOIN email_tasks et ON et.task_id=t.id
WHERE t.email_account_id=$1 AND t.id<>$2 AND task_type IN ('campaign','email','warmup','placement')
AND ((send_reserved_at IS NOT NULL AND send_released_at IS NULL) OR (send_reserved_at IS NULL AND status='completed' AND completed_at IS NOT NULL
AND (task_type<>'campaign' OR EXISTS(SELECT 1 FROM campaign_tasks ct WHERE ct.task_id=t.id AND ct.sequence_id IS NOT NULL))))
AND COALESCE(send_reserved_at,completed_at)>NOW()-INTERVAL '24 hours'`, in.MailboxID, in.TaskID).Scan(&occurrences)
if err != nil {
return uuid.Nil, err
}
// Calendar counters and rolling recipient occurrences are independent constraints.
var dayTotal, dayCold, dayDiagnostic int
err = tx.QueryRow(ctx, `SELECT COUNT(*),COUNT(*) FILTER(WHERE task_type IN ('campaign','email')),COUNT(*) FILTER(WHERE task_type IN ('warmup','placement')) FROM tasks t
WHERE email_account_id=$1 AND id<>$2 AND task_type IN ('campaign','email','warmup','placement')
AND COALESCE(send_business_day,(completed_at AT TIME ZONE $3)::date)=(NOW() AT TIME ZONE $3)::date
AND ((send_reserved_at IS NOT NULL AND send_released_at IS NULL) OR (send_reserved_at IS NULL AND status='completed' AND completed_at IS NOT NULL
AND (task_type<>'campaign' OR EXISTS(SELECT 1 FROM campaign_tasks ct WHERE ct.task_id=t.id AND ct.sequence_id IS NOT NULL))))`, in.MailboxID, in.TaskID, timezone).Scan(&dayTotal, &dayCold, &dayDiagnostic)
if err != nil {
return uuid.Nil, err
}
if shared != nil && dayTotal >= *shared || rolling != nil && occurrences+len(recipients) > *rolling || (lane == "campaign" || lane == "email") && dayCold >= campaignCap || (lane == "warmup" || lane == "placement") && dayDiagnostic >= warmupCap {
return uuid.Nil, ErrSendAdmissionDenied
}
if lane == "campaign" {
if err = checkCampaignReservation(ctx, tx, in.TaskID); err != nil {
return uuid.Nil, err
}
}
if lane == "warmup" && warmupNonce == nil {
return uuid.Nil, ErrSendAdmissionDenied
}
if lane == "warmup" {
if err = checkWarmupRecipientReservation(ctx, tx, in.TaskID, in.MailboxID); err != nil {
return uuid.Nil, err
}
}
nonce := uuid.New()
if warmupNonce != nil {
nonce = *warmupNonce
}
_, err = tx.Exec(ctx, `UPDATE tasks SET send_reserved_at=NOW(),send_business_day=(NOW() AT TIME ZONE $2)::date,send_recipients=$3,
send_released_at=NULL,send_executor_nonce=$4,send_executor_worker=$5,send_executor_started_at=NULL,send_executor_result=NULL,
send_result_state='unknown',status='completed',completed_at=NOW() WHERE id=$1`, in.TaskID, timezone, recipients, nonce, in.WorkerID)
if err != nil {
return uuid.Nil, err
}
_, err = tx.Exec(ctx, `UPDATE email_accounts SET send_recovery_hold=true,send_recovery_reason='unknown',send_recovery_task_id=$2 WHERE id=$1`, in.MailboxID, in.TaskID)
if err != nil {
return uuid.Nil, err
}
if err = tx.Commit(ctx); err != nil {
return uuid.Nil, err
}
return nonce, nil
}
func checkSendRecipients(ctx context.Context, tx pgx.Tx, org uuid.UUID, recipients []string) error {
var denied bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM unnest($2::text[]) recipient WHERE recipient_suppressed($1,recipient))`, org, recipients).Scan(&denied)
if err != nil {
return err
}
if denied {
return ErrSendAdmissionDenied
}
return nil
}
func checkCampaignReservation(ctx context.Context, tx pgx.Tx, task uuid.UUID) error {
var campaign uuid.UUID
if err := tx.QueryRow(ctx, `SELECT campaign_id FROM campaign_tasks WHERE task_id=$1`, task).Scan(&campaign); err != nil {
return err
}
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,272))`, campaign.String()); err != nil {
return err
}
var allowed bool
err := tx.QueryRow(ctx, `SELECT c.status='active' AND (c.end_date IS NULL OR c.end_date>=NOW())
AND NOT recipient_suppressed(c.organization_id,contact.email) AND contact.subscribed IS DISTINCT FROM false
AND NOT EXISTS(SELECT 1 FROM campaign_leads h WHERE h.campaign_id=c.id AND h.contact_id=ct.contact_id AND h.paused_at IS NOT NULL AND (h.paused_until IS NULL OR h.paused_until>NOW()))
AND (c.daily_limit<=0 OR (SELECT COUNT(*) FROM campaign_contact_progress p WHERE p.campaign_id=c.id
AND COALESCE(p.sent_at,p.dispatched_at)>=date_trunc('day',NOW() AT TIME ZONE COALESCE(NULLIF(c.timezone,''),NULLIF(o.timezone,''),'UTC')) AT TIME ZONE COALESCE(NULLIF(c.timezone,''),NULLIF(o.timezone,''),'UTC'))<=c.daily_limit)
AND c.organization_id=ea.organization_id AND contact.organization_id=c.organization_id
AND EXISTS(SELECT 1 FROM campaign_contact_progress p WHERE p.campaign_id=c.id AND p.contact_id=ct.contact_id AND p.sequence_id=ct.sequence_id AND p.dispatch_task_id=t.id)
FROM campaign_tasks ct JOIN tasks t ON t.id=ct.task_id JOIN email_accounts ea ON ea.id=t.email_account_id JOIN campaigns c ON c.id=ct.campaign_id JOIN organizations o ON o.id=c.organization_id JOIN contacts contact ON contact.id=ct.contact_id WHERE ct.task_id=$1`, task).Scan(&allowed)
if err != nil {
return err
}
if !allowed {
return ErrSendAdmissionDenied
}
return nil
}
func (r *taskRepository) InspectOutbound(ctx context.Context, task, mailbox, worker uuid.UUID) (*WarmupDispatchState, error) {
var nonce *uuid.UUID
var started *time.Time
var released bool
var raw []byte
var org *uuid.UUID
var recipients []string
err := r.db.QueryRow(ctx, `SELECT t.send_executor_nonce,t.send_executor_started_at,t.send_executor_result,t.send_released_at IS NOT NULL,ea.organization_id,t.send_recipients FROM tasks t JOIN email_accounts ea ON ea.id=t.email_account_id WHERE t.id=$1 AND t.email_account_id=$2 AND (t.send_executor_nonce IS NULL OR t.send_executor_worker=$3)`, task, mailbox, worker).Scan(&nonce, &started, &raw, &released, &org, &recipients)
if errors.Is(err, pgx.ErrNoRows) {
return &WarmupDispatchState{State: "denied"}, nil
}
if err != nil {
return nil, err
}
if nonce == nil {
return &WarmupDispatchState{State: "legacy"}, nil
}
if released {
return &WarmupDispatchState{State: "denied"}, nil
}
if len(raw) > 0 {
var result models.SendEmailResult
if err = json.Unmarshal(raw, &result); err != nil {
return nil, err
}
return &WarmupDispatchState{State: "finished", Result: &result}, nil
}
if started != nil {
return &WarmupDispatchState{State: "started"}, nil
}
return &WarmupDispatchState{State: "authorized", OrganizationID: org, Recipients: recipients}, nil
}
func (r *taskRepository) BeginOutbound(ctx context.Context, task, mailbox, worker, nonce uuid.UUID) (*WarmupDispatchState, error) {
tx, err := r.db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err = lockSend(ctx, tx, task, mailbox); err != nil {
return nil, err
}
var org uuid.UUID
var provider string
var recipients []string
var current *uuid.UUID
var started *time.Time
err = tx.QueryRow(ctx, `SELECT ea.organization_id,ea.provider::text,t.send_recipients,t.send_executor_nonce,t.send_executor_started_at FROM tasks t JOIN email_accounts ea ON ea.id=t.email_account_id
WHERE t.id=$1 AND ea.id=$2 AND t.send_executor_worker=$3 AND t.send_released_at IS NULL`, task, mailbox, worker).Scan(&org, &provider, &recipients, &current, &started)
if err != nil {
return nil, err
}
if current == nil || *current != nonce {
return &WarmupDispatchState{State: "denied"}, nil
}
if started != nil {
if err = tx.Commit(ctx); err != nil {
return nil, err
}
return r.InspectOutbound(ctx, task, mailbox, worker)
}
lane, err := sendAuthority(ctx, tx, task, mailbox, org, worker, provider, true)
if err == nil {
err = checkSendRecipients(ctx, tx, org, recipients)
}
if err == nil && lane == "campaign" {
err = checkCampaignReservation(ctx, tx, task)
}
if err != nil {
if !errors.Is(err, ErrSendAdmissionDenied) {
return nil, err
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
if err = r.CancelOutbound(ctx, task, mailbox, worker, nonce); err != nil {
return nil, err
}
return &WarmupDispatchState{State: "denied"}, nil
}
if lane == "warmup" {
var allowed bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM warmup_pool_participants wpp JOIN email_accounts ea ON ea.id=wpp.email_account_id WHERE ea.id=$2 AND wpp.participant_role='sender_receiver' AND `+testSenderSQL+` AND `+poolEligibleSQL+`)
AND EXISTS(SELECT 1 FROM warmup_tokens wt JOIN email_accounts ea ON ea.id=wt.recipient_account_id JOIN warmup_pool_participants wpp ON wpp.email_account_id=ea.id WHERE wt.task_id=$1 AND `+partnerEligibleSQL+`)`, task, mailbox).Scan(&allowed)
if err != nil {
return nil, err
}
if !allowed {
if err = tx.Commit(ctx); err != nil {
return nil, err
}
if err = r.CancelOutbound(ctx, task, mailbox, worker, nonce); err != nil {
return nil, err
}
return &WarmupDispatchState{State: "denied"}, nil
}
if _, err = tx.Exec(ctx, `UPDATE warmup_tasks SET dispatch_started_at=NOW() WHERE task_id=$1 AND dispatch_nonce=$2`, task, nonce); err != nil {
return nil, err
}
}
_, err = tx.Exec(ctx, `UPDATE tasks SET send_executor_started_at=NOW() WHERE id=$1`, task)
if err != nil {
return nil, err
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
return &WarmupDispatchState{State: "execute"}, nil
}
func (r *taskRepository) FinishOutbound(ctx context.Context, task, mailbox, worker uuid.UUID, result models.SendEmailResult) error {
if result.TaskID != task {
return errors.New("dispatch task mismatch")
}
raw, err := json.Marshal(result)
if err != nil {
return err
}
tag, err := r.db.Exec(ctx, `UPDATE tasks SET send_executor_result=$4 WHERE id=$1 AND email_account_id=$2 AND send_executor_worker=$3 AND send_executor_started_at IS NOT NULL AND send_executor_result IS NULL`, task, mailbox, worker, raw)
if err != nil {
return err
}
if tag.RowsAffected() > 0 {
return nil
}
state, err := r.InspectOutbound(ctx, task, mailbox, worker)
if err != nil {
return err
}
previous, _ := json.Marshal(state.Result)
if state.State != "finished" || string(previous) != string(raw) {
return errors.New("conflicting or unstarted dispatch result")
}
return nil
}
func (r *taskRepository) CancelOutbound(ctx context.Context, task, mailbox, worker, nonce uuid.UUID) error {
tx, err := r.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if err = lockSend(ctx, tx, task, mailbox); err != nil {
return err
}
var lane string
err = tx.QueryRow(ctx, `UPDATE tasks SET send_released_at=NOW(),send_result_state='failed',send_result_applied_at=NOW(),status='cancelled'
WHERE id=$1 AND email_account_id=$2 AND send_executor_worker=$3 AND send_executor_nonce=$4 AND send_executor_started_at IS NULL AND send_released_at IS NULL RETURNING task_type`, task, mailbox, worker, nonce).Scan(&lane)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
if lane == "campaign" {
var c, contact, sequence uuid.UUID
var newLead bool
err = tx.QueryRow(ctx, `SELECT ct.campaign_id,ct.contact_id,ct.sequence_id,NOT EXISTS(SELECT 1 FROM campaign_contact_progress p WHERE p.campaign_id=ct.campaign_id AND p.contact_id=ct.contact_id AND p.sent_at IS NOT NULL) FROM campaign_tasks ct WHERE ct.task_id=$1`, task).Scan(&c, &contact, &sequence, &newLead)
if err != nil {
return err
}
inner := context.WithValue(ctx, sendResultKey{}, sendResultContext{tx: tx, taskID: task})
if err = NewCampaignProgressRepository(r.db).ReleaseSend(inner, c, contact, sequence, newLead); err != nil {
return err
}
}
if lane == "warmup" {
if _, err = tx.Exec(ctx, `UPDATE warmup_statistics SET emails_sent=GREATEST(emails_sent-1,0),emails_replied=GREATEST(emails_replied-CASE WHEN w.parent_task_id IS NULL THEN 0 ELSE 1 END,0) FROM warmup_tasks w JOIN tasks t ON t.id=w.task_id WHERE w.task_id=$2 AND warmup_statistics.email_account_id=$1 AND date=DATE(t.completed_at)`, mailbox, task); err != nil {
return err
}
if _, err = tx.Exec(ctx, `DELETE FROM warmup_tokens WHERE task_id=$1`, task); err != nil {
return err
}
}
if _, err = tx.Exec(ctx, `UPDATE email_accounts SET send_recovery_hold=false,send_recovery_task_id=NULL,send_recovery_reason=NULL WHERE id=$1 AND send_recovery_reason='unknown' AND send_recovery_task_id=$2 AND NOT EXISTS(SELECT 1 FROM tasks WHERE email_account_id=$1 AND send_result_state='unknown')`, mailbox, task); err != nil {
return err
}
return tx.Commit(ctx)
}
func checkWarmupRecipientReservation(ctx context.Context, tx pgx.Tx, task, sender uuid.UUID) error {
var recipient uuid.UUID
var pool string
if err := tx.QueryRow(ctx, `SELECT wt.recipient_account_id,wp.pool_type FROM warmup_tokens wt JOIN warmup_pool_participants wpp ON wpp.email_account_id=wt.sender_account_id JOIN warmup_pools wp ON wp.id=wpp.pool_id WHERE wt.task_id=$1 AND wt.sender_account_id=$2`, task, sender).Scan(&recipient, &pool); err != nil {
return err
}
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,273))`, recipient.String()); err != nil {
return err
}
share := 100
if pool != "premium" {
share = config.WarmupFreeInboundSharePercent
}
var allowed bool
err := tx.QueryRow(ctx, `SELECT GREATEST(
(SELECT COUNT(*) FROM warmup_received wr WHERE wr.email_account_id=ea.id AND wr.created_at >= date_trunc('day',NOW() AT TIME ZONE COALESCE(NULLIF(ea.timezone,''),NULLIF(o.timezone,''),'UTC')) AT TIME ZONE COALESCE(NULLIF(ea.timezone,''),NULLIF(o.timezone,''),'UTC')),
(SELECT COUNT(*) FROM warmup_tokens wt WHERE wt.recipient_account_id=ea.id AND wt.created_at >= date_trunc('day',NOW() AT TIME ZONE COALESCE(NULLIF(ea.timezone,''),NULLIF(o.timezone,''),'UTC')) AT TIME ZONE COALESCE(NULLIF(ea.timezone,''),NULLIF(o.timezone,''),'UTC')))
<= LEAST(GREATEST((((SELECT COUNT(*) FROM warmup_tokens wt WHERE wt.sender_account_id=ea.id AND wt.sent_message_id<>'' AND wt.created_at>=NOW()-INTERVAL '7 days')+6)/7)*`+inboundDailyMultipleSQL+`,`+inboundDailyFloorSQL+`),`+inboundDailyCeilingSQL+`)*$2/100
FROM email_accounts ea JOIN organizations o ON o.id=ea.organization_id WHERE ea.id=$1`, recipient, share).Scan(&allowed)
if err != nil {
return err
}
if !allowed {
return ErrSendAdmissionDenied
}
return nil
}
@@ -0,0 +1,14 @@
package repository
import "testing"
func TestMatchOutboundRecipientsNormalizesWithoutBroadening(t *testing.T) {
if !MatchOutboundRecipients([]string{"Alice <A@example.test>", "b@example.test"}, []string{"B@example.test", "a@example.test"}) {
t.Fatal("equivalent recipient envelope rejected")
}
for _, actual := range [][]string{{"a@example.test"}, {"a@example.test", "b@example.test", "c@example.test"}, {"a@example.test\r\nBcc: hidden@example.test"}, {"not an address"}} {
if MatchOutboundRecipients([]string{"a@example.test", "b@example.test"}, actual) {
t.Fatal("broadened or malformed recipient envelope accepted", actual)
}
}
}
@@ -0,0 +1,72 @@
package repository
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/models"
)
func TestLiveSendRecoveryResolutionRequiresEvidenceAndRetainsUnknown(t *testing.T) {
f, _ := lineageFixture(t)
ctx := t.Context()
repo := NewEmailRepostory(&db.DB{Pool: f.pool}, nil)
held := f.task
if _, err := f.pool.Exec(ctx, `UPDATE tasks SET status='failed',send_result_state='failed',send_result_applied_at=NOW(),completed_at=NOW()-INTERVAL '2 minutes' WHERE id=$1`, held); err != nil {
t.Fatal(err)
}
if _, err := f.pool.Exec(ctx, `UPDATE email_accounts SET send_recovery_hold=true,send_recovery_reason='authentication',send_recovery_task_id=$2,last_synced_at=NOW()-INTERVAL '3 minutes' WHERE id=$1`, f.sender, held); err != nil {
t.Fatal(err)
}
resolve := &models.UpdateEmail{SendRecoveryResolution: &models.SendRecoveryResolution{HeldTaskID: held, HeldReason: "authentication", EvidenceType: "authentication_repaired"}}
if _, xerr := repo.Update(ctx, f.org.String(), f.sender.String(), resolve); xerr == nil {
t.Fatal("stale sync cleared authentication hold")
}
unknown := uuid.New()
if _, err := f.pool.Exec(ctx, `INSERT INTO tasks(id,task_type,email_account_id,status,message_id,send_result_state)VALUES($1,'email',$2,'active','','unknown')`, unknown, f.sender); err != nil {
t.Fatal(err)
}
if _, err := f.pool.Exec(ctx, `UPDATE email_accounts SET last_synced_at=NOW() WHERE id=$1`, f.sender); err != nil {
t.Fatal(err)
}
if _, xerr := repo.Update(ctx, f.org.String(), f.sender.String(), resolve); xerr == nil {
t.Fatal("unknown send was discarded by authentication repair")
}
if _, err := f.pool.Exec(ctx, `DELETE FROM tasks WHERE id=$1`, unknown); err != nil {
t.Fatal(err)
}
if _, xerr := repo.Update(ctx, f.org.String(), f.sender.String(), resolve); xerr != nil {
t.Fatal(xerr)
}
var heldNow bool
var history int
if err := f.pool.QueryRow(ctx, `SELECT send_recovery_hold FROM email_accounts WHERE id=$1`, f.sender).Scan(&heldNow); err != nil {
t.Fatal(err)
}
if err := f.pool.QueryRow(ctx, `SELECT COUNT(*) FROM send_recovery_resolutions WHERE email_account_id=$1 AND recovery_task_id=$2 AND evidence_type='authentication_repaired'`, f.sender, held).Scan(&history); err != nil {
t.Fatal(err)
}
if heldNow || history != 1 {
t.Fatal("repair was not atomically audited", heldNow, history)
}
if _, err := f.pool.Exec(ctx, `UPDATE email_accounts SET send_recovery_hold=true,send_recovery_reason='conflict',send_recovery_task_id=$2 WHERE id=$1`, f.sender, held); err != nil {
t.Fatal(err)
}
reference := "provider trace reviewed at " + time.Now().UTC().Format(time.RFC3339)
confirmation := &models.UpdateEmail{SendRecoveryResolution: &models.SendRecoveryResolution{HeldTaskID: held, HeldReason: "conflict", EvidenceType: "operator_provider_confirmation", EvidenceTaskID: &held, ConfirmationReference: reference}}
if _, xerr := repo.Update(ctx, uuid.New().String(), f.sender.String(), confirmation); xerr == nil {
t.Fatal("cross-organization resolution accepted")
}
if _, xerr := repo.Update(ctx, f.org.String(), f.sender.String(), confirmation); xerr != nil {
t.Fatal(xerr)
}
if err := f.pool.QueryRow(ctx, `SELECT COUNT(*) FROM send_recovery_resolutions WHERE email_account_id=$1 AND previous_reason='conflict' AND confirmation_reference=$2`, f.sender, reference).Scan(&history); err != nil {
t.Fatal(err)
}
if history != 1 {
t.Fatal("operator evidence reference was not retained", history)
}
}
@@ -0,0 +1,78 @@
package repository
import (
"context"
"encoding/json"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/warmbly/warmbly/internal/models"
)
type SendResultEffect struct {
ID uuid.UUID `json:"-"`
Kind string `json:"kind"`
OrganizationID uuid.UUID `json:"organization_id"`
EventType models.WebhookEventType `json:"event_type,omitempty"`
Data map[string]any `json:"data,omitempty"`
UserID uuid.UUID `json:"user_id,omitempty"`
Category models.NotificationCategory `json:"category,omitempty"`
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
Link string `json:"link,omitempty"`
}
func QueueSendResultEffect(ctx context.Context, key string, effect SendResultEffect) bool {
state, ok := ctx.Value(sendResultKey{}).(sendResultContext)
if !ok {
return false
}
raw, err := json.Marshal(effect)
if err == nil {
_, err = state.tx.Exec(ctx, `INSERT INTO send_result_effects(task_id,effect_key,organization_id,kind,payload)VALUES($1,$2,$3,$4,$5)ON CONFLICT(task_id,effect_key)DO NOTHING`, state.taskID, key, effect.OrganizationID, effect.Kind, raw)
}
if err != nil && state.effectError != nil {
*state.effectError = err
}
return true
}
type SendResultEffectOutbox interface {
DeliverSendResultEffects(context.Context, func(context.Context, SendResultEffect) error) error
}
type sendResultEffectIDKey struct{}
func SendResultEffectEventID(ctx context.Context) uuid.UUID {
id, _ := ctx.Value(sendResultEffectIDKey{}).(uuid.UUID)
return id
}
func (r *advancedOutreachRepository) DeliverSendResultEffects(ctx context.Context, deliver func(context.Context, SendResultEffect) error) error {
for range 10 {
var id, lease uuid.UUID
var raw []byte
err := r.db.QueryRow(ctx, `WITH candidate AS(SELECT id FROM send_result_effects WHERE delivered_at IS NULL AND (locked_until IS NULL OR locked_until<NOW()) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED)
UPDATE send_result_effects e SET lease=gen_random_uuid(),locked_until=NOW()+INTERVAL '1 minute',attempts=attempts+1 FROM candidate c WHERE e.id=c.id RETURNING e.id,e.lease,e.payload`).Scan(&id, &lease, &raw)
if errors.Is(err, pgx.ErrNoRows) {
break
}
if err != nil {
return err
}
var effect SendResultEffect
if err = json.Unmarshal(raw, &effect); err != nil {
return err
}
effect.ID = id
if err = deliver(context.WithValue(ctx, sendResultEffectIDKey{}, id), effect); err != nil {
return err
}
if _, err = r.db.Exec(ctx, `UPDATE send_result_effects SET delivered_at=NOW(),locked_until=NULL WHERE id=$1 AND lease=$2`, id, lease); err != nil {
return err
}
}
_, err := r.db.Exec(ctx, `DELETE FROM send_result_effects WHERE delivered_at<NOW()-INTERVAL '7 days'`)
return err
}
@@ -0,0 +1,136 @@
package repository
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/warmbly/warmbly/internal/models"
)
func TestLiveSharedAdmissionMixedLanesRetainsUnknownAndDoesNotDoubleCountCampaign(t *testing.T) {
for _, first := range []string{"campaign", "warmup", "placement", "email"} {
t.Run(first, func(t *testing.T) {
f, r := lineageFixture(t)
ctx := t.Context()
worker, campaign, step, contact := uuid.New(), uuid.New(), uuid.New(), uuid.New()
address := "pl-" + f.recipient.String()[:8] + "@test.local"
exec := func(sql string, args ...any) {
t.Helper()
if _, err := f.pool.Exec(ctx, sql, args...); err != nil {
t.Fatal(err)
}
}
exec(`INSERT INTO fleet_nodes(id,role,active,last_seen_at,warmup_send_protocol)VALUES($1,'worker',true,NOW(),2)`, worker)
exec(`INSERT INTO workers(id)VALUES($1)`, worker)
t.Cleanup(func() { _, _ = f.pool.Exec(context.Background(), `DELETE FROM fleet_nodes WHERE id=$1`, worker) })
exec(`UPDATE email_accounts SET worker_id=$1,shared_daily_limit=2,rolling_recipient_limit=3,warmup_max=50 WHERE id=$2`, worker, f.sender)
exec(`INSERT INTO warmup_pool_participants(pool_id,email_account_id)SELECT wp.id,ea.id FROM warmup_pools wp CROSS JOIN email_accounts ea WHERE wp.pool_type='free' AND ea.id IN($1,$2) ON CONFLICT DO NOTHING`, f.sender, f.recipient)
exec(`INSERT INTO campaigns(id,user_id,organization_id,name,description,status,daily_limit,timezone,days,created_at,updated_at)VALUES($1,$2,$3,'Shared','','active',50,'UTC',127,NOW(),NOW())`, campaign, f.user, f.org)
exec(`INSERT INTO sequences(id,campaign_id,organization_id,name,subject,body_plain,body_html,wait_after,position)VALUES($1,$2,$3,'Step','Diagnostic','Text','',0,1)`, step, campaign, f.org)
exec(`INSERT INTO contacts(id,user_id,organization_id,email,first_name,last_name,company,phone,custom_fields)VALUES($1,$2,$3,$4,'Test','','','','{}')`, contact, f.user, f.org, address)
t.Cleanup(func() {
c := context.Background()
_, _ = f.pool.Exec(c, `DELETE FROM campaigns WHERE id=$1`, campaign)
_, _ = f.pool.Exec(c, `DELETE FROM contacts WHERE id=$1`, contact)
})
newTask := func(lane string) OutboundReservation {
t.Helper()
id := uuid.New()
exec(`INSERT INTO tasks(id,task_type,email_account_id,status,message_id)VALUES($1,$2,$3,'active','')`, id, lane, f.sender)
switch lane {
case "campaign":
exec(`INSERT INTO campaign_tasks(task_id,campaign_id,sequence_id,contact_id)VALUES($1,$2,$3,$4)`, id, campaign, step, contact)
exec(`INSERT INTO campaign_contact_progress(campaign_id,contact_id,sequence_id,dispatch_task_id,dispatched_at)VALUES($1,$2,$3,$4,NOW()) ON CONFLICT(campaign_id,contact_id,sequence_id)DO UPDATE SET dispatch_task_id=$4,dispatched_at=NOW()`, campaign, contact, step, id)
case "warmup":
exec(`INSERT INTO warmup_tasks(task_id,target_account_id,lineage_version,subject,scenario_version,rendering_version,max_turns)VALUES($1,$2,1,'Pinned diagnostic','diagnostic-v1','canonical-v1',3)`, id, f.recipient)
exec(`INSERT INTO warmup_tokens(token,task_id,sender_account_id,recipient_account_id,subject)VALUES($1,$2,$3,$4,'Pinned diagnostic')`, uuid.New(), id, f.sender, f.recipient)
}
return OutboundReservation{TaskID: id, MailboxID: f.sender, OrganizationID: f.org, WorkerID: worker, Provider: models.InboxProviderSMTPIMAP, Recipients: []string{address}}
}
reserve := func(in OutboundReservation, lane string) (uuid.UUID, error) {
if lane == "warmup" {
if _, err := r.AuthorizeWarmupDispatch(ctx, in.TaskID, in.MailboxID, in.WorkerID); err != nil {
return uuid.Nil, err
}
}
return r.ReserveOutbound(ctx, in)
}
in := newTask(first)
nonce, err := reserve(in, first)
if err != nil {
t.Fatal(err)
}
state, err := r.BeginOutbound(ctx, in.TaskID, in.MailboxID, in.WorkerID, nonce)
if err != nil || state.State != "execute" {
t.Fatalf("execution: %+v %v", state, err)
}
result := models.SendEmailResult{TaskID: in.TaskID, Success: true, MessageID: "<confirmed@example.test>"}
if err = r.FinishOutbound(ctx, in.TaskID, in.MailboxID, in.WorkerID, result); err != nil {
t.Fatal(err)
}
if err = r.ApplySendResult(ctx, result, func(context.Context) error { return nil }); err != nil {
t.Fatal(err)
}
var count int
if err = f.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tasks WHERE email_account_id=$1 AND send_reserved_at IS NOT NULL`, f.sender).Scan(&count); err != nil || count != 1 {
t.Fatal("campaign reservation was counted twice", count, err)
}
var requests []OutboundReservation
var lanes []string
for i := 0; i < 12; i++ {
lane := []string{"campaign", "warmup", "placement", "email"}[i%4]
requests = append(requests, newTask(lane))
lanes = append(lanes, lane)
}
start := make(chan struct{})
wins := make(chan OutboundReservation, 12)
errs := make(chan error, 12)
var wg sync.WaitGroup
for i, request := range requests {
wg.Add(1)
go func(in OutboundReservation, lane string) {
defer wg.Done()
<-start
if _, err := reserve(in, lane); err == nil {
wins <- in
} else if !errors.Is(err, ErrSendAdmissionDenied) && !errors.Is(err, pgx.ErrNoRows) {
errs <- err
}
}(request, lanes[i])
}
close(start)
wg.Wait()
close(wins)
close(errs)
for err := range errs {
t.Fatalf("concurrent SQL: %v", err)
}
if len(wins) != 1 {
t.Fatalf("mixed lanes admitted %d rather than one remaining slot", len(wins))
}
winner := <-wins
if err = r.ApplySendResult(ctx, models.SendEmailResult{TaskID: winner.TaskID}, func(context.Context) error { return errors.New("unknown must not reconcile") }); err != nil {
t.Fatal(err)
}
admission, err := r.GetSendAdmission(ctx, f.org, f.sender, models.InboxProviderSMTPIMAP, time.Now())
if err != nil || admission.Allowed || !admission.RecoveryHold {
t.Fatal("unknown reservation lost its hold", admission, err)
}
if _, err = r.ReserveOutbound(ctx, newTask("email")); !errors.Is(err, ErrSendAdmissionDenied) {
t.Fatal("unknown admitted another lane", err)
}
if err = f.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tasks WHERE email_account_id=$1 AND send_reserved_at IS NOT NULL AND send_released_at IS NULL`, f.sender).Scan(&count); err != nil || count != 2 {
t.Fatal("unknown capacity was refunded", count, err)
}
state, err = r.InspectOutbound(ctx, in.TaskID, in.MailboxID, in.WorkerID)
if err != nil || state.State != "finished" {
t.Fatal("durable replay unavailable", state, err)
}
})
}
}
@@ -0,0 +1,77 @@
package repository
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
func TestLiveDiagnosticOffStopsQueuedSendAndActions(t *testing.T) {
f, r := lineageFixture(t)
ctx := t.Context()
worker := uuid.New()
exec := func(sql string, args ...any) {
t.Helper()
if _, err := f.pool.Exec(ctx, sql, args...); err != nil {
t.Fatal(err)
}
}
exec(`INSERT INTO fleet_nodes(id,role,active,last_seen_at,warmup_send_protocol)VALUES($1,'worker',true,NOW(),2)`, worker)
exec(`INSERT INTO workers(id)VALUES($1)`, worker)
t.Cleanup(func() { _, _ = f.pool.Exec(context.Background(), `DELETE FROM fleet_nodes WHERE id=$1`, worker) })
exec(`UPDATE email_accounts SET worker_id=$1,test_mode='diagnostic',test_send_enabled=true,test_receive_enabled=true WHERE id IN($2,$3)`, worker, f.sender, f.recipient)
exec(`INSERT INTO warmup_pool_participants(pool_id,email_account_id)SELECT wp.id,ea.id FROM warmup_pools wp CROSS JOIN email_accounts ea WHERE wp.pool_type='free' AND ea.id IN($1,$2) ON CONFLICT DO NOTHING`, f.sender, f.recipient)
reserve := func() (uuid.UUID, uuid.UUID) {
t.Helper()
id := uuid.New()
exec(`INSERT INTO tasks(id,task_type,email_account_id,status,message_id)VALUES($1,'warmup',$2,'active','')`, id, f.sender)
exec(`INSERT INTO warmup_tasks(task_id,target_account_id,lineage_version,subject,scenario_version,rendering_version,max_turns)VALUES($1,$2,1,'Pinned diagnostic','diagnostic-v1','canonical-v1',3)`, id, f.recipient)
exec(`INSERT INTO warmup_tokens(token,task_id,sender_account_id,recipient_account_id,subject)VALUES($1,$2,$3,$4,'Pinned diagnostic')`, uuid.New(), id, f.sender, f.recipient)
if _, err := r.AuthorizeWarmupDispatch(ctx, id, f.sender, worker); err != nil {
t.Fatal(err)
}
nonce, err := r.ReserveOutbound(ctx, OutboundReservation{TaskID: id, MailboxID: f.sender, OrganizationID: f.org, WorkerID: worker, Provider: models.InboxProviderSMTPIMAP, Recipients: []string{"pl-" + f.recipient.String()[:8] + "@test.local"}})
if err != nil {
t.Fatal(err)
}
return id, nonce
}
id, nonce := reserve()
exec(`UPDATE email_accounts SET test_mode='off' WHERE id=$1`, f.recipient)
if state, err := r.BeginOutbound(ctx, id, f.sender, worker, nonce); err != nil || state.State != "denied" {
t.Fatal("queued send ignored recipient revocation")
}
exec(`UPDATE email_accounts SET test_mode='diagnostic',test_receive_enabled=true,test_send_enabled=false WHERE id=$1`, f.recipient)
id, nonce = reserve()
exec(`UPDATE email_accounts SET test_send_enabled=false WHERE id=$1`, f.sender)
if state, err := r.BeginOutbound(ctx, id, f.sender, worker, nonce); err != nil || state.State != "denied" {
t.Fatal("recipient-only mailbox was allowed to send")
}
exec(`UPDATE email_accounts SET test_send_enabled=true WHERE id=$1`, f.sender)
id, nonce = reserve()
if state, err := r.BeginOutbound(ctx, id, f.sender, worker, nonce); err != nil || state.State != "execute" {
t.Fatal("valid sender/recipient consent unavailable", err)
}
actions := []string{models.WarmupActionMarkRead, models.WarmupActionRescueFromSpam, models.WarmupActionStar, models.WarmupActionFile, models.WarmupActionDelete}
got, err := r.PermittedWarmupActions(ctx, f.recipient, worker, actions)
if err != nil || len(got) != 2 || got[0] != models.WarmupActionFile || got[1] != models.WarmupActionDelete {
t.Fatal("diagnostic mode permitted synthetic engagement", got, err)
}
exec(`UPDATE email_accounts SET test_mode='legacy' WHERE id=$1`, f.recipient)
got, err = r.PermittedWarmupActions(ctx, f.recipient, worker, actions)
if err != nil || len(got) != len(actions) {
t.Fatal("explicit legacy behavior changed", got, err)
}
exec(`UPDATE email_accounts SET test_mode='off' WHERE id=$1`, f.recipient)
got, err = r.PermittedWarmupActions(ctx, f.recipient, worker, actions)
if err != nil || len(got) != 1 || got[0] != models.WarmupActionDelete {
t.Fatal("queued actions ignored stop", got, err)
}
exec(`UPDATE fleet_nodes SET warmup_send_protocol=1 WHERE id=$1`, worker)
got, err = r.PermittedWarmupActions(ctx, f.recipient, worker, actions)
if err != nil || len(got) != 0 {
t.Fatal("old executor gained capable action authority", got, err)
}
}
@@ -0,0 +1,64 @@
package repository
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
type WarmupActionAdmission interface {
PermittedWarmupActions(context.Context, uuid.UUID, uuid.UUID, []string) ([]string, error)
}
func (r *taskRepository) PermittedWarmupActions(ctx context.Context, mailbox, worker uuid.UUID, actions []string) ([]string, error) {
if len(actions) > 16 {
return nil, ErrSendAdmissionDenied
}
var a models.Email
var active bool
err := r.db.QueryRow(ctx, `SELECT ea.status='active' AND ea.worker_id=$2 AND n.role='worker' AND n.active AND n.last_seen_at>NOW()-INTERVAL '10 minutes' AND n.warmup_send_protocol>=2
AND o.risk_state IN ('trusted','watch') AND NOT EXISTS(SELECT 1 FROM cloud_link_mailboxes c WHERE c.email_account_id=ea.id),ea.test_mode,ea.test_send_enabled,ea.test_receive_enabled
FROM email_accounts ea JOIN organizations o ON o.id=ea.organization_id JOIN fleet_nodes n ON n.id=ea.worker_id WHERE ea.id=$1`, mailbox, worker).Scan(&active, &a.TestMode, &a.TestSendEnabled, &a.TestReceiveEnabled)
if err != nil {
return nil, err
}
if !active {
return nil, nil
}
return a.PermittedWarmupActions(actions), nil
}
func (r *httpSyncContextRepository) PermittedWarmupActions(ctx context.Context, mailbox, worker uuid.UUID, actions []string) ([]string, error) {
raw, err := json.Marshal(struct {
MailboxID uuid.UUID `json:"mailbox_id"`
WorkerID uuid.UUID `json:"worker_id"`
Actions []string `json:"actions"`
}{mailbox, worker, actions})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.baseURL+"/api/v1/internal/worker/warmup-actions", bytes.NewReader(raw))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+r.token)
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("warmup action authority unavailable")
}
var out struct {
Actions []string `json:"actions"`
}
err = json.NewDecoder(resp.Body).Decode(&out)
return out.Actions, err
}
+8 -6
View File
@@ -13,8 +13,10 @@ import (
)
type WarmupDispatchState struct {
State string `json:"state"`
Result *models.SendEmailResult `json:"result,omitempty"`
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
Recipients []string `json:"recipients,omitempty"`
State string `json:"state"`
Result *models.SendEmailResult `json:"result,omitempty"`
}
type WarmupDispatchRepository interface {
@@ -27,7 +29,7 @@ type WarmupDispatchRepository interface {
func (r *workerRepository) SupportsWarmupSendProtocol(ctx context.Context, worker uuid.UUID) (bool, error) {
var supported bool
err := r.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fleet_nodes WHERE id=$1 AND role='worker' AND active AND last_seen_at>NOW()-$2::interval AND warmup_send_protocol=1)`, worker, WorkerLivenessWindow).Scan(&supported)
err := r.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fleet_nodes WHERE id=$1 AND role='worker' AND active AND last_seen_at>NOW()-$2::interval AND warmup_send_protocol>=1)`, worker, WorkerLivenessWindow).Scan(&supported)
return supported, err
}
@@ -50,7 +52,7 @@ func (r *taskRepository) AuthorizeWarmupDispatch(ctx context.Context, task, mail
WHERE w.task_id=t.id AND t.id=$1 AND t.email_account_id=$2 AND ea.worker_id=$3 AND t.status='active'
AND w.lineage_version=1 AND w.dispatch_nonce IS NULL AND ea.status='active'
AND NOT ea.send_recovery_hold AND (ea.send_cooldown_until IS NULL OR ea.send_cooldown_until<=NOW())
AND n.role='worker' AND n.active AND n.last_seen_at>NOW()-$5::interval AND n.warmup_send_protocol=1
AND n.role='worker' AND n.active AND n.last_seen_at>NOW()-$5::interval AND n.warmup_send_protocol>=1
RETURNING w.dispatch_nonce`, task, mailbox, worker, nonce, WorkerLivenessWindow).Scan(&saved)
if err != nil {
return uuid.Nil, err
@@ -116,13 +118,13 @@ func (r *taskRepository) BeginWarmupDispatch(ctx context.Context, task, mailbox,
WHERE w.task_id=t.id AND t.id=$1 AND ea.id=$2 AND w.dispatch_worker_id=$3 AND ea.worker_id=$3
AND w.dispatch_nonce=$4 AND w.dispatch_started_at IS NULL AND w.dispatch_result IS NULL
AND t.status='completed' AND t.send_result_applied_at IS NULL AND t.send_result_state='unknown'
AND n.active AND n.warmup_send_protocol=1 AND ea.status='active'
AND n.active AND n.warmup_send_protocol>=1 AND ea.status='active'
AND ea.send_recovery_hold AND ea.send_recovery_reason='unknown' AND ea.send_recovery_task_id=t.id AND (ea.send_cooldown_until IS NULL OR ea.send_cooldown_until<=NOW())
AND (ea.warmup IS NOT NULL AND ea.warmup_paused_at IS NULL OR EXISTS(SELECT 1 FROM campaigns c WHERE c.organization_id=ea.organization_id AND c.status='active' AND (
EXISTS(SELECT 1 FROM campaign_email_tags ct JOIN email_tags et ON et.tag_id=ct.tag_id WHERE ct.campaign_id=c.id AND et.email_id=ea.id)
OR EXISTS(SELECT 1 FROM campaign_senders cs WHERE cs.campaign_id=c.id AND cs.email_account_id=ea.id AND cs.enabled)
OR (NOT EXISTS(SELECT 1 FROM campaign_email_tags ct WHERE ct.campaign_id=c.id) AND NOT EXISTS(SELECT 1 FROM campaign_senders cs WHERE cs.campaign_id=c.id AND cs.enabled)))))
AND EXISTS(SELECT 1 FROM warmup_pool_participants wpp JOIN email_accounts ea ON ea.id=wpp.email_account_id WHERE ea.id=$2 AND wpp.participant_role='sender_receiver' AND `+partnerEligibleSQL+`)
AND EXISTS(SELECT 1 FROM warmup_pool_participants wpp JOIN email_accounts ea ON ea.id=wpp.email_account_id WHERE ea.id=$2 AND wpp.participant_role='sender_receiver' AND `+testSenderSQL+` AND `+poolEligibleSQL+`)
AND EXISTS(SELECT 1 FROM warmup_tokens wt JOIN email_accounts ea ON ea.id=wt.recipient_account_id JOIN warmup_pool_participants wpp ON wpp.email_account_id=ea.id WHERE wt.task_id=t.id AND `+partnerEligibleSQL+`)`, task, mailbox, worker, nonce)
if err != nil {
return nil, err
+1 -1
View File
@@ -65,7 +65,7 @@ func (r *taskRepository) RecordVerifiedWarmupParent(ctx context.Context, parent,
return errors.New("verified warmup receipt context unavailable")
}
}
return nil
return attachDiagnosticAuth(ctx, r.db, parent, recipient)
}
func (r *taskRepository) BindWarmupSuccessor(ctx context.Context, parent, recipient, internalID uuid.UUID, at time.Time) (bool, error) {
@@ -77,6 +77,7 @@ func newPartnerOrgFixture(t *testing.T) *partnerOrgFixture {
for _, org := range []uuid.UUID{f.org, f.other} {
f.exec(`INSERT INTO organizations (id, name, slug, owner_user_id) VALUES ($1, 'Org pairing', $2, $3)`,
org, "org-pair-"+org.String()[:8], f.user)
f.exec(`UPDATE organizations SET risk_state='trusted' WHERE id=$1`, org)
}
for _, m := range []struct {
id uuid.UUID
@@ -91,6 +92,7 @@ func newPartnerOrgFixture(t *testing.T) *partnerOrgFixture {
signature_html, provider, status, campaign_limit, min_wait_time, timezone)
VALUES ($1, $2, $3, $4, 'Org pairing', '', '', 'smtp_imap', 'active', 50, 600, 'UTC')`,
m.id, f.user, m.org, "box-"+m.id.String()[:8]+"@"+m.domain)
f.exec(`UPDATE email_accounts SET test_mode=NULL WHERE id=$1`, m.id)
}
for _, id := range []uuid.UUID{f.sender, f.sibling, f.outside} {
f.exec(`INSERT INTO warmup_pool_participants (pool_id, email_account_id, participant_role, health_state)
@@ -447,6 +449,7 @@ func (f *partnerOrgFixture) addMember(t *testing.T, org, poolID uuid.UUID, provi
signature_html, provider, status, campaign_limit, min_wait_time, timezone)
VALUES ($1, $2, $3, $4, 'Pool member', '', '', $5, 'active', 50, 600, 'UTC')`,
id, f.user, org, "m-"+id.String()[:8]+"@"+id.String()[:8]+".test", provider)
f.exec(`UPDATE email_accounts SET test_mode=NULL WHERE id=$1`, id)
f.exec(`INSERT INTO warmup_pool_participants (pool_id, email_account_id, participant_role, health_state, joined_at)
VALUES ($1, $2, 'sender_receiver', 'healthy', NOW() - make_interval(days => $3))`, poolID, id, memberForDays)
return id
@@ -40,7 +40,7 @@ func newLedgerFixture(t *testing.T) *ledgerFixture {
f.address = "Ledger-" + f.org.String()[:8] + "@Test.Local"
f.exec(t, `INSERT INTO users (id, email, first_name, last_name) VALUES ($1, $2, 'Ledger', 'Test')`,
f.user, "ledger-"+f.user.String()[:8]+"@test.local")
f.exec(t, `INSERT INTO organizations (id, name, slug, owner_user_id) VALUES ($1, 'Ledger Test', $2, $3)`,
f.exec(t, `INSERT INTO organizations (id, name, slug, owner_user_id,risk_state) VALUES ($1, 'Ledger Test', $2, $3,'trusted')`,
f.org, "ledger-"+f.org.String()[:8], f.user)
t.Cleanup(func() {
@@ -74,8 +74,8 @@ func (f *ledgerFixture) addMailbox(t *testing.T, user uuid.UUID) uuid.UUID {
t.Helper()
id := uuid.New()
f.exec(t, `INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain,
signature_html, provider, status, campaign_limit, min_wait_time, timezone)
VALUES ($1, $2, $3, $4, 'Ledger', '', '', 'smtp_imap', 'active', 50, 600, 'UTC')`,
signature_html, provider, status, campaign_limit, min_wait_time, timezone,test_mode)
VALUES ($1, $2, $3, $4, 'Ledger', '', '', 'smtp_imap', 'active', 50, 600, 'UTC',NULL)`,
id, user, f.org, f.address)
return id
}
@@ -98,7 +98,7 @@ func (f *ledgerFixture) penalise(t *testing.T, id uuid.UUID, score float64, stat
func (f *ledgerFixture) remove(t *testing.T, user, id uuid.UUID) {
t.Helper()
if xerr := f.emails.Delete(context.Background(), user.String(), id.String(), 0); xerr != nil {
if xerr := f.emails.Delete(context.Background(), f.org.String(), id.String(), 0); xerr != nil {
t.Fatalf("Delete: %v", xerr)
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ func warmupStandingRankSQL(col string) string {
func warmupStandingSQL(accountExpr string) string {
unknown := `(clm.enrollment_state <> 'active' OR EXISTS (SELECT 1 FROM cloud_link WHERE disconnect_pending)
OR clm.health_state IS NULL OR clm.health_state NOT IN ('healthy', 'watch', 'throttled', 'quarantined', 'blocked') OR clm.standing_observed_at IS NULL
OR clm.standing_observed_at <= NOW() - INTERVAL '15 minutes')
OR clm.standing_observed_at <= NOW() - INTERVAL '15 minutes' OR clm.standing_observed_at > NOW())
AND NOT (COALESCE(clm.health_state IN ('quarantined', 'blocked'), false)
AND (clm.blocked_until IS NULL OR clm.blocked_until > NOW()))`
return `
+65
View File
@@ -0,0 +1,65 @@
package scheduler
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/behavior"
)
func (s *schedulerService) OutboundExecutionNotBefore(ctx context.Context, mailbox, campaign, task uuid.UUID, now time.Time) (time.Time, error) {
a, xerr := s.emailRepo.GetByID(ctx, mailbox)
if xerr != nil {
return time.Time{}, xerr
}
if a == nil {
return time.Time{}, errors.New("mailbox unavailable")
}
c, err := s.campaignRepo.GetByID(ctx, campaign)
if err != nil {
return time.Time{}, err
}
if c == nil || c.Status != "active" || c.OrganizationID == nil || a.OrganizationID == nil || *c.OrganizationID != *a.OrganizationID {
return time.Time{}, errors.New("campaign unavailable")
}
at := nextScheduleSlot(now, effectiveWindows(c), loadLocation(c.EffectiveTimezone))
if at.After(now) {
return at, nil
}
bhv := s.behaviorFor(ctx, a)
if open, ok := behaviorWindow(bhv, now); !ok {
return time.Time{}, errors.New("mailbox has no working days")
} else if open.After(now) {
return open, nil
}
if bhv.Enabled {
loc := bhv.Loc
start := behavior.PlanDateFor(now, loc)
hStart, hEnd := behavior.HourWindow(now, loc)
counter, ok := s.taskRepo.(interface {
WarmupDispatchUsage(context.Context, uuid.UUID, uuid.UUID, time.Time, time.Time, time.Time, time.Time) (int, int, int, *time.Time, error)
})
if !ok {
return time.Time{}, errors.New("send usage unavailable")
}
_, daily, hourly, last, err := counter.WarmupDispatchUsage(ctx, mailbox, task, start, start.AddDate(0, 0, 1), hStart, hEnd)
if err != nil {
return time.Time{}, err
}
plan := bhv.PlanOn(start)
if daily >= plan.DailyLimit {
return start.AddDate(0, 0, 1), nil
}
if hourly >= plan.HourlyLimit {
return hEnd, nil
}
if last != nil {
if next := last.Add(time.Duration(s.behaviorGapFloor(bhv, now, a.MinWaitTime)) * time.Second); next.After(now) {
return next, nil
}
}
}
return now, nil
}
+1 -1
View File
@@ -901,7 +901,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) (result *errx
// number of tries instead of being retried every minute for ever
switch {
case errors.Is(err, ErrSendDispatchUnknown):
case errors.Is(err, ErrWorkerOffline), errors.Is(err, ErrWorkerUnconfirmed):
case errors.Is(err, ErrWorkerOffline), errors.Is(err, ErrWorkerUnconfirmed) || errors.Is(err, repository.ErrSendAdmissionDenied):
if relErr := s.campaignProgressRepo.ReleaseSend(ctx, campaign.ID, contact.ID, sequence.ID, nextPair.IsNewLead); relErr != nil {
errs.CaptureException(relErr)
log.Error().Err(relErr).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to release the reservation for a send that never left; the reclaimer will retry it")
+26
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
@@ -75,6 +76,7 @@ type emailSender struct {
emailRepo repository.EmailRepository
publisher events.Publisher
liveness WorkerLiveness
admission repository.OutboundAdmissionRepository
}
// NewEmailSender creates a new email sender
@@ -101,6 +103,8 @@ func (s *emailSender) WarmupWorkerReady(ctx context.Context, account models.Emai
return capable.SupportsWarmupSendProtocol(ctx, *account.WorkerID)
}
func (s *emailSender) WireSendAdmission(r repository.OutboundAdmissionRepository) { s.admission = r }
// Send publishes an email to the worker service for sending
func (s *emailSender) Send(ctx context.Context, taskID uuid.UUID, msg EmailMessage, account models.Email) error {
// Get worker ID for this email account
@@ -175,6 +179,28 @@ func (s *emailSender) Send(ctx context.Context, taskID uuid.UUID, msg EmailMessa
params.ReplyTo = account.ReplyToHeader()
}
if s.admission == nil {
return repository.ErrSendAdmissionDenied
}
recipients := append(append(append([]string{}, msg.To...), msg.CC...), msg.BCC...)
nonce, err := s.admission.ReserveOutbound(ctx, repository.OutboundReservation{
TaskID: taskID, MailboxID: account.ID, OrganizationID: *account.OrganizationID, WorkerID: *workerID,
Provider: models.InboxProvider(account.Provider), Recipients: recipients,
})
if err != nil {
if msg.IsWarmup && msg.DispatchNonce != "" {
if r, ok := s.admission.(repository.WarmupDispatchRepository); ok {
if n, nerr := uuid.Parse(msg.DispatchNonce); nerr == nil {
if derr := r.DeferWarmupDispatch(ctx, taskID, account.ID, *workerID, n, time.Now().Add(5*time.Minute)); derr != nil {
return derr
}
}
}
}
return err
}
params.DispatchNonce = nonce.String()
// Publish send email event to worker
if err := s.publisher.PublishSendEmail(ctx, *workerID, params); err != nil {
return fmt.Errorf("%w: %v", ErrSendDispatchUnknown, err)
+17 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/events"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type capturingPublisher struct {
@@ -15,6 +16,20 @@ type capturingPublisher struct {
params *events.SendEmailParams
}
type payloadAdmission struct {
repository.OutboundAdmissionRepository
}
func (payloadAdmission) ReserveOutbound(context.Context, repository.OutboundReservation) (uuid.UUID, error) {
return uuid.New(), nil
}
func payloadSender(pub *capturingPublisher) EmailSender {
sender := NewEmailSender(nil, pub)
sender.(*emailSender).WireSendAdmission(payloadAdmission{})
return sender
}
func (p *capturingPublisher) PublishSendEmail(_ context.Context, _ uuid.UUID, params *events.SendEmailParams) error {
p.params = params
return nil
@@ -34,7 +49,7 @@ func TestSendCarriesReplyToOnlyOutsideWarmup(t *testing.T) {
} {
t.Run(tt.name, func(t *testing.T) {
pub := &capturingPublisher{}
sender := NewEmailSender(nil, pub)
sender := payloadSender(pub)
if err := sender.Send(context.Background(), uuid.New(), EmailMessage{To: []string{"x@y.test"}, IsWarmup: tt.warmup}, account); err != nil {
t.Fatal(err)
}
@@ -56,7 +71,7 @@ func TestWarmupSendPreservesConfiguredIdentity(t *testing.T) {
t.Fatal(err)
}
pub := &capturingPublisher{}
sender := NewEmailSender(nil, pub)
sender := payloadSender(pub)
if err := sender.Send(t.Context(), uuid.New(), EmailMessage{IsWarmup: true, BodyPlain: body, To: []string{"recipient@example.test"}}, account); err != nil {
t.Fatal(err)
}
+5
View File
@@ -73,6 +73,11 @@ func (s *tasksService) HandleEmailTask(task *proto.ProcessTask) *errx.Error {
return errx.ErrNotFound
}
// A mailbox enrolled in Warmbly Cloud is warmed there; the local chain ends here.
if !account.TestSendingAllowed() {
_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled")
executionStatus = "skipped_diagnostic_stopped"
return nil
}
if s.cloudLink != nil && s.cloudLink.IsEnrolled(ctx, account.ID) {
_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled")
executionStatus = "skipped_cloud_warmup"
+69
View File
@@ -0,0 +1,69 @@
package tasks
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
func (s *tasksService) ValidateOutboundExecution(ctx context.Context, taskID uuid.UUID) error {
task, err := s.taskRepo.GetTask(ctx, taskID)
if err != nil {
return err
}
if task == nil {
return errors.New("send task unavailable")
}
a, xerr := s.emailRepo.GetByID(ctx, task.EmailAccountID)
if xerr != nil {
return xerr
}
if a == nil || a.Status != "active" || a.OrganizationID == nil {
return errors.New("mailbox unavailable")
}
if s.orgRiskRepo != nil {
states, err := s.orgRiskRepo.GetOrgRiskStates(ctx, []uuid.UUID{*a.OrganizationID})
if err != nil {
return err
}
if states[*a.OrganizationID] == models.OrgRiskSuspended {
return errors.New("organization sending suspended")
}
}
if s.domainAuthBlocked(ctx, a) {
return errors.New("sending domain authentication unavailable")
}
if task.TaskType == "warmup" {
return s.ValidateWarmupExecution(ctx, taskID)
}
if task.TaskType == "placement" && !a.TestSendingAllowed() {
return errors.New("diagnostic sending stopped")
}
if task.TaskType == "campaign" {
ct, err := s.taskRepo.GetCampaignTask(ctx, taskID)
if err != nil {
return err
}
if ct == nil || ct.CampaignID == nil {
return errors.New("campaign unavailable")
}
gate, ok := s.scheduler.(interface {
OutboundExecutionNotBefore(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, time.Time) (time.Time, error)
})
if !ok {
return errors.New("campaign execution policy unavailable")
}
now := time.Now()
at, err := gate.OutboundExecutionNotBefore(ctx, a.ID, *ct.CampaignID, taskID, now)
if err != nil {
return err
}
if at.After(now) {
return errors.New("send outside current calendar or pacing")
}
}
return nil
}
+6
View File
@@ -110,6 +110,9 @@ func (s *tasksService) sendAdmission(ctx context.Context, account *Email) error
}
func (s *tasksService) validateWarmupPair(ctx context.Context, sender, recipient *Email, pool string, reply bool) error {
if !sender.TestSendingAllowed() || !recipient.TestReceivingAllowed() {
return errors.New("diagnostic participation unavailable")
}
if reply && !sender.IsWarmingActive() {
return errors.New("reply consent unavailable")
}
@@ -180,6 +183,9 @@ func (s *tasksService) ValidateWarmupExecution(ctx context.Context, taskID uuid.
if account == nil {
return errors.New("warmup mailbox unavailable")
}
if !account.TestSendingAllowed() {
return errors.New("diagnostic sending stopped")
}
source, err := s.diagnosticSource(ctx, current)
if err != nil {
return err