> For the complete documentation index, see [llms.txt](https://docs.layeronecloud.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.layeronecloud.com/platform/email-and-notifications.md).

# Email, campaigns and notifications

through send\_configured\_mail → apps/core/cloudflare\_email.py using the enabled EmailDeliveryConfig row (account ID + Fernet-encrypted cloudflare\_api\_token, synced from…

## 9.1 Transport: Cloudflare only <a href="#id-91-transport-cloudflare-only" id="id-91-transport-cloudflare-only"></a>

**Outbound email is Cloudflare-only; there is no SMTP transport.** All mail goes through `send_configured_mail` → `apps/core/cloudflare_email.py` using the enabled `EmailDeliveryConfig` row (account ID + Fernet-encrypted `cloudflare_api_token`, synced from `EMAIL_CLOUDFLARE_ACCOUNT_ID` / `EMAIL_CLOUDFLARE_API_TOKEN`).

One urllib POST per message to `POST /accounts/{account_id}/email/sending/send` with all recipients in the `to` array. Any `permanent_bounces` in the response raises `CloudflareEmailError`, which flows into the FAILED `OutboundEmailLog` bookkeeping. Log rows carry the backend label `cloudflare.email_service_api`.

**`settings.EMAIL_BACKEND` is&#x20;*****only*****&#x20;the no-enabled-config fallback:** console locally, locmem under tests, **dummy in production** — so a send with no config becomes a FAILED `OutboundEmailLog` row rather than a 500 on password resets or console spew. `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `EMAIL_USE_TLS` and `EMAIL_USE_SSL` **do not exist** — do not reintroduce them to "fix" delivery.

`sync_email_delivery_config_from_settings` syncs only when **both** the account ID and the token are set: half the credentials no longer enables a row that can only fail. The sender domain must be onboarded for Email Sending in the Cloudflare dashboard (Compute → Email Service → Email Sending; requires Cloudflare DNS, which adds SPF/DKIM/DMARC automatically), and the token needs **Email Sending: Edit**. The account ID (not the token) is in the service-env allowlist.

**Cloudflare will not parse `Name <addr@host>`.** `from` and `reply_to` are a bare address or an `{"address", "name"}` object, the key is **`reply_to`** and not `reply-to`, and `reply_to` is a **single value** rather than a list. Anything else fails the *entire* send with HTTP 400 `email.sending.error.invalid_request_schema` — so **a sender display name typed into a settings form silently stopped all marketing mail**, and nothing about "LayerOne" looks like a transport-layer change. `_address_payload` in `apps/core/cloudflare_email.py` is the **only** place that conversion happens, so callers keep writing Django's display form and the fallback backend keeps working unchanged. It **rejects an address with no `@`**, because `parseaddr` returns any bare token as the address rather than reporting failure, so a truthiness check would forward a typo and turn it back into an opaque provider 400.

**`require_delivery=True` is mandatory for any caller that records its own send state.** The quiet zero above is right for a password reset, which must still render its page, but it was being read as success by callers that then wrote SENT — and because their dedupe keys are permanent (`billing-notice:receipt:invoice:{id}` is once per invoice ever, `campaign:{id}:{email}` is once ever), a delivery outage recorded every receipt and suspension warning as delivered **and made them unsendable afterwards**. Passing `require_delivery=True` raises `EmailNotDeliveredError` after the FAILED log row is written, so the caller can record FAILED and retry later. **Add it to any new send that keeps delivery state of its own.**

## 9.2 One settings page <a href="#id-92-one-settings-page" id="id-92-one-settings-page"></a>

**Every email setting is one row and one page: `EmailDeliveryConfig`, under Settings → Email.** Credentials, sender identity and the marketing guardrails used to be two models in two apps behind two tabs with two "Send test email" buttons, and the two buttons built **different payloads** — the simple one passed while every campaign send failed with the schema error above.

`campaigns.MarketingSettings` is gone (its fields were absorbed; the core migration copies the row across and the campaigns migration drops the table, and the drop **depends on** the copy or the race takes the settings with it). There is one `EmailDeliveryConfigForm` and one test-send route (`billing:admin-email-delivery-test`), reachable from Settings and from the Marketing dashboard (`next=marketing` returns there). **Do not add a second email settings surface.**

The two booleans are **not interchangeable**: `is_enabled` is the **transport** (off = nothing sends at all, including password resets); `marketing_enabled` is the **campaign switch** (off = lifecycle mail still sends and campaigns do not). The form rejects `marketing_enabled` without `is_enabled`, which would otherwise render "Enabled" over a transport that delivers nothing. Account ID and token are required **when `is_enabled` is checked**, so an admin can still save the From address with delivery off, and enabling a row that cannot deliver is rejected.

The page is four sections in one form and one save: Delivery, Sender identity, Marketing, Guardrails (collapsed). **The readiness checklist (`apps/campaigns/readiness.py`) sits directly above the fields that fix it** — delivery configured, sender address, marketing switch (required); postal address, automations live, last test-send result (advisory) — and every fix link points at the same tab.

**The test send goes at the saved provider config directly, even while delivery is disabled**, and with no credentials it **fails** instead of quietly passing through the console backend. That is the point: the old marketing test reported success against a locmem/console fallback, which turned the readiness checklist green over a transport that had never delivered anything. It builds the **same payload a campaign builds** — display name in the From, Reply-To attached — which is the only version of this test worth having.

## 9.3 Campaigns: marketing versus lifecycle <a href="#id-93-campaigns-marketing-versus-lifecycle" id="id-93-campaigns-marketing-versus-lifecycle"></a>

`apps/campaigns` owns bulk and automated email. Transactional mail (password resets, ticket replies, invoice receipts, organization invitations) stays where it was and does not pass through it.

**Marketing versus lifecycle is a hard split, not a label:**

|                        | `marketing` | `lifecycle` |
| ---------------------- | ----------- | ----------- |
| Suppression list       | honoured    | ignored     |
| Daily cap              | honoured    | ignored     |
| Per-recipient cooldown | honoured    | ignored     |
| Quiet hours            | honoured    | ignored     |
| Unsubscribe footer     | yes         | no          |

A customer cannot unsubscribe from being told their servers are about to stop. **Never route a billing notice through the marketing category to "respect preferences"** — the unsubscribe page promises service email keeps arriving.

Audiences cover the client base (all / active / no active services / low credit / suspended / new in 30 days / impacted services), plus a `MarketingContact` list for addresses outside the client base (added one at a time or bulk-imported) and a free-text `extra_recipients` field. **Client accounts win collisions**, so a customer on an imported list still gets personalised copy. `_recipients_for_segment` reads `billing_email` first for a client account.

`CampaignSend` is one row per delivery attempt with a unique `dedupe_key` claimed **before** the send. Rules:

* **A send that delivered nothing is FAILED, and its dedupe slot goes back.** Campaign sends pass `require_delivery=True`, and `_deliver` records the row FAILED and **renames** its key (`undelivered:{uuid}:…`), keeping the failed attempt in the history while freeing the real key for a retry. Marking it SENT was the bug: `campaign:{id}:{email}` is once-ever, so every phantom SENT row permanently blocked the message it never delivered **and counted against the daily cap while doing it**. **Only a&#x20;*****certain*****&#x20;zero releases the key** — a raised provider error keeps it, because the message may have been accepted before the error and a retry there would mail the customer twice.
* **A one-off campaign that hits a zero stops the run and stays `SENDING`:** the transport is down for the whole audience, not for that address, so the scheduler retries the batch instead of writing one failed row per recipient.
* **A claimed send slot is not a delivered email.** `_claim_send` creates the row *before* the send, so a worker killed in between leaves it PENDING with the key taken. `_reclaim_abandoned_send` takes such a slot over once it is older than `STALE_PENDING_SEND` (30 minutes — delivery is one blocking call, so nothing live outlives that), and `dedupe_slot_was_delivered` lets a caller that keeps its own log ask whether the slot's send actually reached the customer. **A "duplicate" outcome is not proof the customer was reached:** it carries `delivered`, and a zero-delivery outcome carries `retryable`.

The `campaigns.run_due_campaigns` beat task (`CAMPAIGN_DISPATCH_INTERVAL_SECONDS`, default 300 s) drives scheduled sends and blasts still working through the daily cap. Manual sends do **not** run inside the HTTP request: the view pre-flights, sets SCHEDULED and dispatches the scheduler task, inheriting batching, caps, quiet hours and dedupe.

Quiet hours use the **server** timezone, not per-recipient local time. The daily cap counts marketing sends only.

**Console send review.** One-off sends and the Review campaign's manual full-audience run require a separate review and confirmation. **Review and send submits the whole current editor form, validates and saves it, then opens the review** — this works without JavaScript, so unsaved copy cannot be silently skipped in favour of the previously stored email. A draft's saved schedule is **not** armed by reviewing, and for a campaign that already has scheduled work, reviewing preserves its previously authorized time even if the editor's date field changed; only confirmation moves that run to now.

The review shows saved subject, sender and Reply-To, a personalized message rendered **through the delivery renderer**, current audience, unsubscribe/cooldown/dedupe exclusions, preflight blockers and queue timing. **Preview HTML runs inside a sandboxed iframe**, and a stored preview's document goes into `srcdoc` through `force_escape` — a rendered trusted HTML string placed there unescaped let its quotes and markup into the parent admin page and rendered blank. Confirmation is **signed and bound to the campaign, actor, session digest and the reviewed campaign/configuration/audience fingerprint** (the raw session cookie is never in the readable signed payload), expires after 15 minutes, and rechecks everything; stale or consumed reviews return **409** with a refreshed review rather than queueing. **A conditional database update advances the campaign version when the confirmation is consumed**, so duplicate POSTs cannot queue the same reviewed revision twice. Audience counts include stale pending slots the dispatcher can legitimately reclaim, and dispatch still uses the existing scheduler, preflight, suppression, cooldown and dedupe — **this is not an immutable recipient reservation.**

## 9.4 Rendering and merge fields <a href="#id-94-rendering-and-merge-fields" id="id-94-rendering-and-merge-fields"></a>

**Campaign copy is rendered by `apps/campaigns/rendering.py`, never by the Django template engine.** Bodies are admin-edited in the console, and `Template(body).render()` would turn that editor into a **server-side template injection** surface. The renderer does fixed `{{ name }}` substitution over a known context — no tags, no filters, no attribute traversal — and HTML-escapes values in the HTML body, because account names are customer-controlled. A recipient's own name is not recursively evaluated.

Unknown placeholders **blank out at send time** (better than mailing a literal `{{ frist_name }}`) but are **rejected by the form at authoring time**. Adding a merge field means adding it to `MERGE_FIELDS` — the console help text reads from the same tuple, so the two cannot drift.

**Anything that needs to know&#x20;*****which*****&#x20;fields the copy uses asks the same regex.** `campaign_uses_billing_metrics` intersects `MERGE_FIELD_RE.findall(copy)` with `BILLING_METRIC_FIELDS`, because a substring match on `{{ name` missed `{{ credit_balance }}` — the renderer accepts any whitespace, so that copy passed validation, skipped the ledger scan, and delivered an email with a blank where the balance should be.

Public URL variables (`website_url`, `pricing_url`, `docs_url`, `support_url`, `deploy_url`) use the canonical outbound-email origin from `email_link_base_url()` and existing public routes; local development keeps its localhost origin. `website_url` has no trailing slash and can be used alone or as `{{website_url}}/pricing/`, and normalization preserves it in button fields and rich-text links until delivery. `site_name` comes from the default site configuration unless explicitly overridden.

The standard catalog also includes recipient/account names, recipient email, the client/billing/top-up/save-a-card/unsubscribe URLs and billing metric fields (blank for contacts without an account). Note that the historical `active_services` variable counts **active VPS instances**, whereas the `active_service_count` audience filter includes web hosting too, and the monthly/daily cost and credit-runway variables use VPS, managed-service and floating-IP costs and **exclude web hosting**. The catalog descriptions expose these limits to the AI generator. Event-specific invoice, payment, order, deletion, discount, win-back, review and email-confirmation fields are **not** offered to ordinary AI campaigns; the lifecycle and offer editors retain them.

## 9.5 Templates and automations <a href="#id-95-templates-and-automations" id="id-95-templates-and-automations"></a>

**No email path is allowed to have a missing template.** Packaged copy lives in `apps/campaigns/starter_templates.py`:

* `STARTER_TEMPLATES` — seven complete, sendable layouts a one-off campaign starts from (product announcement, newsletter, offer, welcome, win-back, plain note, plus the review starter), each with subject, preheader, suggested audience segment and a full block layout. **"New campaign" opens on a starter**, so the block builder is never blank; `?template=<key>` switches layout and an unknown or missing key falls back to the default, so the page cannot present a blank canvas.
* `notice_blocks` / `notice_html` — the body a notice or a new automation step starts from.
* `GENERIC_NOTICE_LEAD` — copy for a rung an admin added beyond the packaged ladder, where there is nothing to guess from.
* `email_confirmation_campaign_defaults` — the seeded **Email confirmation** one-off (`purpose=email_confirmation`, lifecycle, all clients). Recipients are narrowed to unconfirmed `User.email` addresses at send time. Deployment creates the draft; copy stays editable; the campaign cannot be deleted. `{{ confirm_email_url }}` is the signed confirmation link.

`ensure_automation_campaign(key)` and `ensure_all_automations()` create a missing campaign then repair missing copy. They are called from the marketing dashboard, the settings enable switch, the automation "create template" buttons, `deploy_release`, and **the head of each send path**. `deploy_release` is what closes the upgrade gap: before it, automations were seeded when an admin enabled marketing, so a release that *added* an automation shipped it with no copy until somebody re-saved the settings form.

**Repair fills blanks only.** It never touches a threshold, a cooldown, a discount, a step's `is_enabled`, or the campaign's status, so it cannot restart a paused automation or re-arm a rung an admin switched off. The one shape it *recreates* rather than repairs is a stepped automation with **zero rungs**: that automation sends nothing at all (`lifecycle_automation_is_live` requires an enabled step), so leaving it empty is a silent outage. **Pausing is how an automation is stopped**, and both pausing and disabling individual rungs survive a repair — which is also what `admin_campaign_delete` already stated when it refused to delete a built-in automation and told the admin to pause it.

**Repair runs before the is-live gate, not after**, so a blanked body produces the default warning rather than falling back to billing's flat built-in text. It does not widen what may send: the marketing master switch and the campaign's status are still checked afterwards, and a marketing automation is still seeded `DRAFT`.

**The empty-body fallback is asymmetric on purpose.** An automation's body is restored; a one-off campaign's is not. A campaign is written over several saves and an author who cleared the canvas to start again would not thank us for filling it back in — and `send_campaign` refuses a body-less send anyway. An automation is the opposite case: it fires on a billing event with nobody watching, and a blank body is only discovered by the customer who did not get their warning.

**Starter blocks must round-trip `normalize_blocks` unchanged.** It *drops* an unknown block type or field name rather than failing, so a misspelt key silently seeds an email with that section missing; `test_every_starter_is_stored_already_normalized` asserts round-trip equality and is the only thing standing between a typo and a section nobody notices is gone. **Starter blocks are handed out deep-copied** — a block list is nested mutable dicts that go straight onto a model instance, so returning the packaged list itself would let the first campaign an admin edits rewrite the default for every campaign created after it in the same process.

`restore_automation_copy(campaign, only_missing=…)` backs a **Restore default copy** action with a confirm dialog. Restore is **copy only**: it does not delete rungs an admin added or move thresholds they tuned. There is no per-step restore button, because restoring the automation covers every rung.

A save that empties an automation's body puts the packaged copy back and says so (`EmailBlockBuilderMixin.fallback_blocks`). A new automation step opens on the packaged copy, threshold and cooldown for its position in the ladder (`step_starter_defaults`).

**Starter templates are code, not database rows** — they are product defaults rather than admin content, and a campaign made from one is fully editable.

**Block-editor assets survive partial console navigation.** Full page loads receive Trix and the builder from `extra_head`; HTMX responses carry the same dependencies in an **inert page-asset manifest**. Those scripts opt out of Cloudflare Rocket Loader, and the console loader reconstructs scripts from a **safe attribute allowlist**, so an edge-injected inert `type` can never be copied onto the executable node.

`LIFECYCLE_AUTOMATIONS` (`apps/campaigns/services.py`) is the registry — `LifecycleAutomation(key, starter subject/body, optional threshold steps)` — with one entry point `send_lifecycle_notice(automation_key, account, dedupe_key, extra_context, threshold_value)` returning None when not live. `MARKETING_AUTOMATIONS` is a **separate** dict beside it, kept separate rather than merged with a category argument because **which dict an automation is in is what decides its gate set** — an automation cannot land in the wrong one by mistyping a parameter.

The billing side is `apps/billing/notifications.py` (`send_lifecycle_billing_notice`): campaigns-first with a built-in flat fallback, so a fresh install still emails customers. **The automation keys are duplicated as constants there** because billing must work without the campaigns app, and `LifecycleNoticeTests.test_billing_key_constants_cannot_drift_from_campaigns` pins them together. Deleting or pausing an automation, or leaving marketing disabled, falls back to billing's built-in single notice.

`live_automation_summary` stays scoped to the **lifecycle** set, because its only consumer is the readiness check for *billing notice* automations, whose point is "these have a built-in fallback and you are using it" — counting an optional promo there would peg readiness at "1 of N missing" forever for anyone who declines to discount.

**Threshold units.** A step threshold's unit comes from `threshold_label` / `threshold_unit` via `step_threshold_label()` and `step_threshold_display()`, so the column heading, the rendered value ("3 days", not "$3.00") and the step form's label cannot disagree. The **days floor** belongs to the low-credit escalation alone: the editor passes `unreachable_step_threshold()` only when `campaign.automation_key == LOW_CREDIT_AUTOMATION_KEY`, because every other stepped automation counts something else (the suspension countdown's thresholds are *days until deletion*, and `send_lifecycle_notice` has no floor at all). The entry gate widens to the **highest enabled step**: an admin who adds a 7-day rung to a 5-day policy means it to fire at 7 days, and widening only produces extra warnings because auto-charge and suspension key off the policy's own dollar thresholds.

Import direction: `apps/campaigns.services` reads billing models to build audiences and `apps/billing/client_services` calls back for the low-credit copy, so **both sides import lazily inside the function that needs it.**

## 9.6 Planned maintenance announcements <a href="#id-96-planned-maintenance-announcements" id="id-96-planned-maintenance-announcements"></a>

**A planned-maintenance notice is a lifecycle send with a node-scoped audience, and both are enforced in `save()`.** It is composed from a form (window, impacted components/nodes, notes) by `apps/campaigns/maintenance.py`, mounted at `/console/marketing/campaigns/maintenance/new/`, with an **Announce maintenance** button on the Emails dashboard.

It replaced a `maintenance` **starter template** full of `[date]`/`[start]`/`[end]` placeholders. The cosmetic problem was that a window typed as free text is a window somebody eventually gets wrong — no timezone, or last month's end time still sitting in the copy under a new date. **The real problem was that the starter was in the `marketing` category**, so every customer who had ever clicked unsubscribe was silently dropped from the one email telling them their server reboots tonight, and quiet hours held a 02:00 window's warning until the morning **after** the work ran. Both are structural, so the replacement fixes them structurally rather than by asking the author to remember.

`MaintenanceAnnouncement` is a one-to-one row beside an ordinary `EmailCampaign`. The campaign still owns the email — subject, blocks, audience, delivery — so nothing about batching, dedupe, the daily cap or the delivery log is special-cased. The announcement holds the operator's **input**:

| Field                   | Meaning                                                                                                                                                                                                    |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `starts_at` / `ends_at` | The window. Entered in Eastern, announced in UTC. A database `CheckConstraint` refuses an end at or before the start, and the form catches it first so the operator gets the field back rather than a 500. |
| `impact`                | One of five choices, not free text: no downtime / brief interruption / servers reboot / intermittent / offline.                                                                                            |
| `components`            | M2M to **visible** `StatusPageComponent` rows on enabled status pages — the names the customer already knows.                                                                                              |
| `nodes`                 | M2M to enabled `ProxmoxNode` rows. Names the hardware **and decides the audience**.                                                                                                                        |
| `other_impacted`        | Comma-separated free text for anything not in either list.                                                                                                                                                 |
| `notes`                 | The extra section. Escaped, not rich text.                                                                                                                                                                 |

The row is kept rather than discarded after composing, so reopening the form to correct an end time does not mean retyping the announcement.

Three decisions worth keeping:

1. **The notice is lifecycle, and `EmailCampaign.save()` enforces it.** For `purpose == MAINTENANCE` the save forces `category=LIFECYCLE`, `trigger=MANUAL`, `audience_segment=IMPACTED_SERVICES`, `include_contacts=False` and `respects_quiet_hours=False`. A contact on an imported list has no server for the work to affect. `extra_recipients` is deliberately left alone: a NOC or reseller address that should hear about the window is a real case.
2. **Naming a node narrows the audience.** `AudienceSegment.IMPACTED_SERVICES` resolves to the client accounts holding an **active** server on the named nodes; with no node named it is every client with an active service. The filter is **one `filter()` call rather than two chained ones**, because a second call joins the reverse relation again — "has an active server" and "has a server on this node" could then be satisfied by two different machines, and the notice would reach an account whose only server on the affected node was destroyed months ago. The segment is **hidden from the audience dropdown on every other campaign**, where it would quietly mean "everyone with an active service".
3. **The window is announced in UTC and the end time is never omitted.** Recipients are worldwide, and "02:00" with no zone on it is a guess. The end time is in the body, in the "What to expect" list, **and in the subject line**, because *until when* is the question the email exists to answer. A window crossing midnight UTC states both dates ("from 23:30 UTC on Wednesday, March 11 to 01:00 UTC on Thursday, March 12"); a same-day one reads "on Thursday, March 12, 2026, from 02:00 to 06:00 UTC", and **the preposition is chosen from the shape of the phrase** — hardcoding "on" produces "maintenance on from 23:30 UTC on Wednesday".

**Saving rewrites subject, preheader, blocks, `body_html` and `body_text` from the details. It does not merge.** Anything else would have to guess which half of a hand-edited body still described the old window, and a half-updated maintenance notice is worse than either version. Polish belongs in the block editor afterwards, once the details are final. `body_text` is regenerated rather than left for the send path to derive, because that fallback turns rendered HTML back into text where a callout heading is a `display:block` `<strong>` that `html_to_text` does not break on, so "What to expect" runs together with the line under it.

**Order inside `_save_maintenance_announcement` is load-bearing:** the affected lists are many-to-many, so the copy cannot be generated until `save_m2m()` has run, or the first save produces an email that says "All services" regardless of what was ticked.

**Once the campaign has left draft the details are locked** and the composer redirects with an explanation. A one-off campaign claims one delivery slot per address for its lifetime, so after a send an edit changes what the console shows and nothing a customer has read. **A revised window is a new announcement.**

Generated blocks, in normalized form: a `richtext` greeting with the window, its duration, the impact sentence and what the customer needs to do (the action line is **per impact**, so "no action is needed" is never printed above an instruction contradicting it); a `callout` with Affected / Window / Expected impact; a `richtext` for the notes when there are any (escaped and split into paragraphs — the field is a plain textarea, so a `<` pasted from a log line must not become markup in an email going to every affected customer); a `cta` to the public status page; and a sign-off.

## 9.7 Review campaign <a href="#id-97-review-campaign" id="id-97-review-campaign"></a>

One **protected** campaign that asks customers to rate LayerOne, with five signed rating links in every delivered email.

* **The rating row is appended by the send engine** and cannot be removed accidentally in the block editor. Test emails use a signed preview token and do not create responses.
* A **4- or 5-star** click records the response and redirects to the public Trustpilot review page. A **1-, 2- or 3-star** click records the rating and opens a **private feedback form** (4,000 characters, visible only in the console).
* `ReviewResponse` is one row per `CampaignSend`; repeated clicks update the same response rather than inflating the count. The dashboard and editor show delivered totals, rating count and average, positive redirects and recent private feedback.

**Automation** (it started as a manual blast): the existing dispatcher checks the campaign every five minutes. A client becomes eligible **14 days after the settlement timestamp of their first paid service order**, provided they still have a provisioning/staged/running/stopped VM. If no rating has been selected, one reminder goes out **every three calendar months** while the account still has an active service; **any rating response permanently stops future reminders.** The initial request keeps the once-per-address delivery key; each reminder uses a key derived from the prior successful send, so retries and overlapping task runs cannot duplicate that quarter's message.

**Timing source.** `Order` has no `paid_at` column, but every settlement path updates `Order.updated_at` when it moves the order to `PAID`, so review age is measured from the earliest paid, non-top-up order whose `updated_at` crossed the 14-day cutoff. **A later touch can conservatively delay an invitation; it cannot cause one to send before the order has been paid for 14 days.** Imported legacy services without an order fall back to the active VM's `provisioned_at` (or `created_at`), so existing customers are not omitted from the rollout. Purchases after the first do not reset the schedule; credit-only top-ups do not start review tenure; an account with no currently active-service VM is not invited even if it purchased in the past.

**Manual run.** **Send to everyone eligible** reaches every client with an active server who has not selected a rating, ignoring the automation's two clocks — but **not** who is eligible at all (closed accounts, accounts with no active server and anyone who has rated stay out). **The button records a&#x20;*****request*****, it does not blast:** `EmailCampaign.manual_run_requested_at` marks the automation for one full-audience pass and the ordinary five-minute tick performs it, so unsubscribes, quiet hours, the daily cap and the cooldown apply exactly as to a scheduled request. A pass cut short resumes on the next tick under the same run, and the run closes as soon as one pass reaches everybody. **Pausing the campaign refuses the button** rather than accepting a run the dispatcher would silently never perform.

**Why the run is stateful.** Resolving the audience and sending inside the request loses whoever the daily cap, batch limit or quiet hours cut off, with no record that they were meant to be reached — and since the cap defaults to 500/day and quiet hours default to 21:00–08:00, an evening press would routinely deliver nothing and still look accepted. Persisting the request makes the run resumable, which is also what makes the completion rule meaningful: `SendResult` reports `truncated` and `cooldown_skipped` **separately** from the `skipped` total, because that total lumps "a slot another tick already claimed" (nothing left to do) together with "the batch limit stopped here" and "on cooldown" (come back for this person). `REVIEW_MANUAL_RUN_WINDOW` (7 days) bounds resumption so a run held open by a cooldown cannot become a standing instruction to keep mailing.

**The delivery key, and the two duplicates it prevents.** During a run every recipient is keyed `review:<campaign>:<email>:run:<requested_at>` — stable for the whole run rather than derived from the newest prior send. Both halves were found by test, not by inspection: a key derived from the newest prior send **moves after each delivery**, so the next tick finds a fresh unclaimed slot and mails the same person again; and a first-time recipient's scheduled key is the once-ever `campaign:<id>:<email>` one, so left on that key they flip into the has-a-prior-send branch between tick one and tick two of the same run and are mailed twice for the first reason. Separately, a request already delivered **at or after** the run was requested is that run's own work, so the recipient drops out — which stops a manual run landing on top of a scheduled request sent moments earlier. A resolution done with the schedule ignored but **no run on record** (the editor does this purely to label the button with a count) falls back to the reminder key the automation would claim anyway, so a preview can never invent a key that lets an extra email through.

**Security and privacy.** Review tokens contain only a signed `CampaignSend` id — **no address in plaintext** — and a response is accepted only for a delivered send belonging to the protected Review campaign. `/email/review/<token>/<rating>/` is a **credential-bearing path**: excluded from analytics, request activity, audit-path storage and session recording, with the token redacted when a path must be reported. The public feedback page never renders the recipient email, admin campaign views stay behind `@admin_required`, and feedback is rendered with normal template escaping — **never `|safe`.**

A second press after a run completes is a second run and will send again to anyone still unrated; that is the point of the button, and the cooldown and daily cap are what keep it from being abused by accident.

## 9.8 AI campaign drafts and audience rules <a href="#id-98-ai-campaign-drafts-and-audience-rules" id="id-98-ai-campaign-drafts-and-audience-rules"></a>

**Create AI Campaign** (`/console/marketing/campaigns/ai/new/`) takes an operator description of the audience, message, tone and call to action, and returns an **editable draft** with a subject, preheader, ordered blocks and structured audience rules, saved as a standard marketing draft that opens in the existing editor.

It uses the OpenAI key and `AssistantConfig.model` already configured for the chat assistant (falling back to `DEFAULT_ASSISTANT_MODEL`), **not** the separate documentation-answer model. The public assistant's enable switch governs the public assistant; generation needs the configured key and an authorized console request. One Responses API call with a strict JSON schema and `store=False`.

**Generation does not send email, schedule delivery, enable an automation, add contacts, create recipients or activate discounts.** Saving leaves the draft's trigger manual, status Draft, category Marketing, purpose Standard and schedule empty. An operator may describe an existing promotion in the prompt; **the generator has no billing or offer mutation authority.**

**Privacy.** The model receives the operator's prompt, the public site name, live VPS catalog facts and up to 100 active public web-hosting plans, the generic filter catalog, the block schema, and variable names with descriptions. It receives **no resolved audience, email addresses, customer names, account identifiers, purchase rows, balances, chat history or customer documents.** Audience matching and personalized values stay in Django. Anything an operator types into the prompt **is** sent, so the form asks operators to describe customers through filters and keep personal information out.

Authorization: `@admin_required`, CSRF, and the console role middleware. Clients cannot generate; Support is default-deny for the campaign console. **GET displays the form without calling OpenAI or creating an assistant settings row.** POST permits one in-flight request per operator and at most **ten attempts in a ten-minute** cache window; the in-flight lock expires after 90 s; the provider call has a 60 s timeout and **no automatic retries.** Success records `campaign.ai_created` with campaign ID, model and rule count. **The prompt and raw provider response are not persisted**, provider failure messages are generic, and exception payloads, credentials and generated copy do not enter the error log.

**Audience rules.** `EmailCampaign.audience_rules` is a JSON object. An empty object means no additional filters and preserves existing behaviour. A nonempty definition has exactly `match` and `rules`; every rule has exactly `field`, `operator` and a correctly typed `value`:

```json
{
  "match": "all",
  "rules": [
    {"field": "has_purchased", "operator": "eq", "value": true},
    {"field": "purchase_recency", "operator": "not_within_days", "value": 30}
  ]
}
```

`match: all` joins with AND, `match: any` with OR. **These rules always narrow the selected client segment** — OR cannot escape that segment or the custom-rule eligibility restrictions. Only one flat group is supported; nested or mixed expressions are not part of the schema, and a group can contain at most 20 rules.

Custom rules apply to **registered, enabled client users with usable passwords**, excluding staff, privileged users, passwordless placeholders and closed billing accounts. Those extra registration restrictions apply **only when custom rules are present**; campaigns with empty rules retain the legacy segment behaviour. Contacts and ad-hoc addresses **cannot** be combined with nonempty client rules, because those addresses have no account data to evaluate — the editor rejects the combination and the delivery resolver returns **no recipients** if invalid combined data was persisted another way.

**Malformed rules never fall back to everyone.** Unsupported fields/operators, extra keys, wrong types, out-of-range values and oversized groups are rejected; invalid persisted rules make audience resolution return no recipients and the send engine refuses the run **without marking it sent.** Rules are selected from an **allowlist of predicates**, not arbitrary Django lookups, SQL or executable expressions. Related-object conditions use account-scoped subqueries to avoid duplicate recipients and cross-account matches. **Separate service predicates may match different services owned by the same account** — they do not imply all predicates describe a single instance. AI-generated plan filters must name a plan in the relevant active public catalog; the ordinary editor can still use historical slugs for manual targeting.

The server catalog in `apps/campaigns/audience_filters.py` drives both the AI schema and the editor's controls — 22 fields:

| Field                       | Meaning                                                                                      |
| --------------------------- | -------------------------------------------------------------------------------------------- |
| `signup_age_days`           | Completed 24-hour periods since billing-account creation                                     |
| `has_purchased`             | A settled plan order or a positive billed web-hosting usage charge                           |
| `purchase_recency`          | A qualifying settled order update or hosting usage period end within/not within a day window |
| `paid_order_count`          | Settled orders with a plan, including zero-upfront hourly orders                             |
| `order_count`               | Orders with a plan in any state, including unpaid, failed and canceled                       |
| `has_logged_in`             | Whether the user has a recorded successful sign-in                                           |
| `login_recency`             | Successful sign-in within/not within a day window                                            |
| `active_service_count`      | Active VPS instances plus Active web-hosting services                                        |
| `active_vps_count`          | VPS in Provisioning, Staged, Running or Stopped                                              |
| `active_hosting_count`      | Web hosting in Active state, including active free trials                                    |
| `service_type`              | At least one active VPS or active hosting; negation means none of that type                  |
| `vps_status`                | At least one owned VPS with the selected persisted status, including historical              |
| `hosting_status`            | At least one owned hosting service with the selected persisted status                        |
| `vps_plan`                  | An active VPS whose plan has the exact public slug                                           |
| `hosting_plan`              | An Active hosting service whose plan has the exact public slug                               |
| `billing_status`            | Current Active or Suspended; Closed accounts are excluded                                    |
| `vps_billing_cadence`       | An active VPS with a linked billing record using that cadence                                |
| `unpaid_invoice_count`      | Open invoices with a positive total and no paid timestamp                                    |
| `overdue_invoice_count`     | Those same unpaid invoices whose due time is before now                                      |
| `hourly_billing_unlocked`   | Whether the account has a persisted hourly-billing unlock time                               |
| `marketing_sent_recency`    | A successfully sent, account-linked marketing email within/not within a day window           |
| `marketing_clicked_recency` | A recorded click on such an email within/not within a day window                             |

Numeric comparisons support equality, inequality, greater/less than and inclusive bounds; boolean/choice/plan-slug fields support equality and inequality; recency fields support `within_days` and `not_within_days` from 1 to 36,500. Signup age is bounded 0–36,500 days and counts 0–1,000,000. **Recency windows include both their cutoff and the current time, and missing sign-in, purchase, send or click history matches the corresponding `not_within_days` condition.**

**Purchase semantics are deliberately explicit.** A settled plan order has status Paid, Provisioning or Active **and** a non-null plan; credit-only top-ups and unpaid orders do not count. Positive `WebHostingUsageRecord` rows count as billed hosting purchases; free trials and zero-value usage do not. **Purchase recency uses `Order.updated_at`** because the order has no dedicated payment timestamp, so later updates can make an older purchase appear more recent. Hosting recency uses the billed usage period end, so an actively billed hosting service continues to have recent qualifying charges. **Paid order counts exclude hosting usage hours**, even though purchase presence and recency include them.

Click targeting uses the existing `CampaignSend.last_clicked_at`, which only links already tracked by existing workflows can populate — **this feature introduces no generic link rewriting and no open tracking.**

The editor supplies visual add/remove/filter controls with an advanced JSON fallback when JavaScript is unavailable; saving refreshes the local audience preview; the send review shows saved rule descriptions and the AND/OR mode; and **audience rules are part of the signed review fingerprint** through the campaign's model fields, so changing rules invalidates that review.

AI may author Hero, Text section, Feature cards, Call to action, Image and Callout blocks. **Custom HTML blocks are not part of the AI schema.** The normal campaign form normalizes blocks, sanitizes rich text and builds HTML and plain text; local validation rechecks structured output, known variables, safe link shapes and nonempty content **before any draft is saved**. Unsupported audiences should return `can_create: false` with an explanation, and the operator must still review the generated interpretation of their prompt.

Bounds: prompts 6,000 characters; output 8,000 tokens, 100,000 response characters, 20 blocks, 20 audience rules, plus per-field/item bounds in the schema. **The integration does not silently switch to a different model** if the configured one cannot produce the required structured output, and incomplete, refused, malformed or invalid results produce a recoverable form error **without saving a campaign.**

No recurring/drip builder, automatic follow-ups, A/B testing, discount issuance, open tracking or AI sending authority.

## 9.9 Unsubscribe and suppression <a href="#id-99-unsubscribe-and-suppression" id="id-99-unsubscribe-and-suppression"></a>

A signed, stateless unsubscribe lives at `/email/unsubscribe/<token>/`, with a resubscribe path. **The page states plainly that service email continues.**

`unsubscribe_token` uses `signing.dumps` (salt `campaigns.unsubscribe.v2`) rather than `Signer.sign`, which put the customer's address in **cleartext in a URL path** — recorded by every access log, proxy and `Referer` header the link passes through. The payload is still recoverable (it must be, to know whom to unsubscribe), so this is defence in depth, not secrecy. `email_from_unsubscribe_token` accepts the legacy `email:signature` form **forever**: those links are already in delivered inboxes, and a customer whose unsubscribe link 404s reports the mail as spam instead of writing in.

`MarketingSuppression` is keyed by address. `suppress_email` is a `get_or_create`, so an address already suppressed for its own reason keeps that reason. See [1.12](/platform/identity/organizations.md#id-112-creating-editing-and-deleting-a-client-account-from-the-console) for why an address change carries the suppression forward.

No open/click tracking beyond the explicitly tracked links, no A/B testing, no public newsletter signup form on the marketing site (contacts are added by an admin, by hand or by paste-import).

## 9.10 Microsoft Teams notifications <a href="#id-910-microsoft-teams-notifications" id="id-910-microsoft-teams-notifications"></a>

The second outbound channel, and the only one aimed at **staff rather than customers**. Nothing a customer sees goes through it and nothing here is suppressible or unsubscribable: it is an operations feed.

**One transport, in `apps/core`, beside the email one.** `apps/core/teams.py` posts a single Adaptive Card per notification, wrapped in the `{"type": "message", "attachments": [...]}` envelope a Teams **Workflows** webhook ("Post to a channel when a webhook request is received") accepts. Microsoft retired the Office 365 connector that `MessageCard` was built for; those URLs still accept this envelope, so an operator who has not migrated is not broken, but **do not add a `MessageCard` path back**. Teams rejects a body over 28 KB outright, so the card clips its own title, facts and body well under that rather than failing a delivery nobody sees.

**Routing is `TeamsChannel`: one row, one channel, one event.** The four events are `TeamsEvent` in `apps/core/models.py`, and their **values are wire keys** — they are stored on the channel rows an admin configured and inside every `dedupe_key`, so add members and never re-letter one. The same event may appear on two channels (fan-out) and a channel may hold several events (a row each). The webhook URL is a **bearer credential for that channel**: it is Fernet encrypted in `secret_values` through `SecretConfigMixin`, write-only in the editor, excluded from Django admin, and HTTPS is enforced when it is pasted.

**Posting is claimed, not fired.** `queue_teams_notification` writes one `TeamsNotification` per channel against a unique `{event}:{subject_key}:{channel_id}` key, inside the caller's transaction, and dispatches on commit. A retried beat run, a Celery redelivery and two racing workers therefore produce **one** card; a rolled-back ticket produces none. A row already SENT is never reposted.

**A notification never breaks what it reports.** Every public function in `apps/core/teams_notifications.py` swallows and logs its own failure, because the callers are `create_ticket` and the live-chat message path. A Teams outage must not cost a customer their ticket. Delivery failures are recorded on the notification *and* denormalized onto the channel (`last_delivery_ok`, `last_error`) so Settings → Microsoft Teams can answer "is this working" without a log page. Transient failures retry three times over fifteen minutes; a revoked webhook answers 4xx and is left failed.

**The accounting summary is an hourly task that decides for itself** (`billing.post_daily_accounting_summary`), not a beat crontab. The send hour is an admin setting, a crontab is frozen at settings load, and a worker down at 08:00 would otherwise skip the day. The date-stamped dedupe key makes the other twenty-three ticks free. Figures come from `apps/billing/reporting.py` for a single-day custom period, so the channel and the console Reports page cannot disagree — including [4.16](/platform/billing/revenue-and-reports.md#id-416-revenue-insights-and-automatic-charges)'s rule that the four measures are different populations and are **never added together**.

Support staff cannot reach any of it: the console routes are absent from `SUPPORT_CONSOLE_ROUTES`, which is default-deny.

***


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.layeronecloud.com/platform/email-and-notifications.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
