mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-09 16:02:28 +00:00
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:
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬ices); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notices != 1 {
|
||||
t.Fatal("notification outbox restart duplicated or lost feed", notices)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¤tStatus); err != nil {
|
||||
if err := resultDB(ctx, r.DB).QueryRow(ctx, `SELECT status FROM campaigns WHERE id = $1`, campaignID).Scan(¤tStatus); 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¤t, &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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user