> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# News and customer communication

> Publish announcements, understand how every customer notification is classified, and know exactly why one was suppressed or held

Everything Omniflow sends a customer passes through two mechanisms described
here: the **news inbox**, which is where announcements live durably, and the
**delivery policy**, which is the single place that decides whether a customer may
be messaged at all.

Both exist for the same reason. A customer needs to be told their subscription
ends on Thursday whether or not they want marketing, and an operator needs a
record of every message that was not sent and why. Folding those into one
"notifications" switch would make one of the two impossible.

This page is the operator and system view. For what a customer sees and controls,
see [Notifications and consent](https://omniflow-vpn.mintlify.site/customer/notifications.md).

## News and announcements

### Categories, and what each one costs

A post carries a `category` and a `class`. Four categories exist, and the category
is what the delivery machinery treats as the notification kind:

| Category | Class it is delivered under | Consent required | Deferred by quiet hours |
| - | - | - | - |
| `news` | marketing | Yes — marketing consent, and it consumes the frequency cap | Yes |
| `announcement` | transactional | No | Yes |
| `incident` | transactional | No | **No** — incidents are urgent |
| `maintenance` | transactional | No | Yes |

All four are additionally governed by the customer's single **news** preference
switch, so a customer who turns news off stops receiving announcements and
incident notices too.

> [!NOTE]
>
> `news_posts.class` is a separate column carried through to the customer inbox
> payload, where the panel uses it to tone a badge. It has no effect on delivery
> — the category decides that.

### The lifecycle, and what can actually move a post through it

`news_posts.status` is one of `draft`, `scheduled`, `published`, `unpublished`, or
`archived`, with `scheduled_for`, `published_at`, `unpublished_at`, and
`archived_at` beside it.

A publication timestamp alone cannot express "written but not ready", "scheduled
for Tuesday", or "taken down because it was wrong" — and the last is the one an
operator needs most urgently, which is why unpublishing exists as a state rather
than as a deletion.

`published_at` is set with `COALESCE(published_at, now())`, so republishing does
not rewrite when customers first saw a post. A `CHECK` requires `scheduled_for`
whenever the status is `scheduled`.

> [!WARNING]
>
> **There is no operator surface for news.** The only state transition any code
> makes is the worker's `scheduled → published`: the `campaign_expansion` pass
> runs every minute, takes up to 50 posts whose `scheduled_for` has passed, and publishes
> them. Creating a post, writing its Russian and English localisations,
> scheduling it, unpublishing it, or archiving it can only be done by writing
> `news_posts` and `news_post_localizations` directly.
>
> Slugs match `^[a-z0-9][a-z0-9_-]{2,63}$`, titles are capped at 200 characters,
> bodies at 3500, and locales are `ru` or `en` only.

### What makes a post visible

A post is visible to a customer when all of the following hold:

```
status = 'published'
AND published_at IS NOT NULL
AND published_at <= now()
AND (expires_at IS NULL OR expires_at > now())
```

The status check matters on its own. Unpublishing keeps `published_at` set on
purpose — that is when customers first saw it — so a timestamp window alone would
keep showing a post an operator had taken down. Both the web panel and the bot
apply the identical predicate, so withdrawing a post removes it from both
surfaces at once.

### Locale resolution

A post exists per locale, and a post with no translation in the customer's
resolved locale is **absent** rather than shown in the other language.

Resolution runs in order: an explicit `locale` request parameter when it names
`ru` or `en`, then the customer's bot preference unless it is `auto`, then the
account's locale, then `en`. The resolved locale is returned in the response, so
the panel can say it was not given the language it asked for rather than
pretending.

### The inbox

| Route | Notes |
| - | - |
| `GET /v1/account/news?locale&cursor&limit` | Default 20, maximum 50; returns `{items, unreadCount, locale, nextCursor}` |
| `POST /v1/account/news/{postID}/read` | 204 |

Items are ordered newest first on `(published_at, id)` on both surfaces, so a
batch published in the same instant reads in the same order everywhere.
`unreadCount` counts every visible post with a localisation in the resolved
locale that has no read row, and is not bounded by the page.

Marking a post read checks visibility first and answers 404 for an invisible one;
the insert is `ON CONFLICT DO NOTHING`, so reading twice is not an error.

Customers reach it at `/account/news` in the web panel, or the **News** menu entry
in the bot, each carrying an unread badge.

### The push

The inbox is pull. The push is what makes an outage notice arrive.

The bot's notifier looks for posts published in the last seven days, for active
customers with a Telegram recipient, that have a localisation in the customer's
resolved locale and no delivery row for that post that is still waiting or
already final. It sends one message per customer per post, 200 at a time, and
reads the next batch as soon as the last is sent, at the bot-wide
`APP_TELEGRAM_DELIVERY_RATE`.

A customer whose chat is known to be unreachable — `blocked`, `deactivated`, or
`not_started` — is not read at all, and a customer who stops being reachable
between the read and the send gets a `suppressed` delivery row with the reason
`unreachable`. Either way the queue moves past them; before, they were skipped
without a record, stayed at the head of every batch for the whole seven days,
and two hundred of them stopped announcements for everybody. A delivery deferred
to the end of quiet hours, or failed with retries left, is read again once it
can move, so a deferral is a delay rather than a drop.

A post that is already older than seven days when the worker first sees it is
never announced. Publishing an old post backdated will populate the inbox and
send nothing.

## How every notification is classified

Every customer notification passes through one decision function. An unclassified
kind is **refused rather than delivered**, so adding a notification without
deciding its class is impossible.

| Class | Kinds |
| - | - |
| Transactional | `expiry`, `traffic`, `renewal`, `grace`, `recovery`, `payment`, `fulfillment`, `support`, `incident`, `maintenance`, `announcement`, `referral`, `trial` |
| Marketing | `news`, `marketing` |

Urgent kinds are never deferred by quiet hours: `payment`, `fulfillment`,
`support`, and `incident`. Everything else waits for the window to end.

### Which preference governs which kind

| Preference | Kinds it governs |
| - | - |
| `expiry_notifications` | `expiry` |
| `traffic_notifications` | `traffic` |
| `renewal_notifications` | `renewal`, `grace`, `recovery`, `trial` |
| `news_notifications` | `news`, `announcement`, `incident`, `maintenance` |
| `marketing_enabled` | `marketing` |
| No opt-out | `payment`, `fulfillment`, `support`, `referral` |

The four kinds with no opt-out carry facts a customer needs whether or not they
want to hear from the business. A preference model that could silence a payment
failure would be a way to lose access without warning.

### The decision order

1. The customer blocked the bot — refuse, `bot_blocked`. Their account is
   deactivated — refuse, `user_deactivated`. They never started the bot —
   refuse, `not_started`. None is retried, and all three end when the customer
   next writes to or taps in the bot.
2. The kind's preference is off — refuse, `kind_disabled`.
3. Marketing class with no consent — refuse, `no_marketing_consent`.
4. Marketing class already over the frequency cap — refuse, `frequency_cap`.
5. A retry-after in the future — defer until then.
6. Not urgent and inside quiet hours — defer to the end of the window.
7. Otherwise, allow.

### Quiet hours

Quiet hours are a whole-hour local-time window evaluated against `users.timezone`.
The window wraps past midnight when the end hour is at or before the start hour,
and `start == end` means there is no window at all — which is how turning quiet
hours off is expressed on both surfaces.

> [!NOTE]
>
> The timezone the window is evaluated in is the account's, and the customer
> preferences screen neither shows nor changes it. A customer setting 23:00–08:00
> in the browser is setting it in whatever timezone their account already carries.

### The frequency cap

| Variable | Default | Purpose |
| - | - | - |
| `APP_MARKETING_FREQUENCY_CAP` | `3` | Marketing messages allowed per window; `0` disables the cap |
| `APP_MARKETING_WINDOW_HOURS` | `168` | Window length in hours — seven days |

Both are read by the bot process. The count is the number of delivery rows with
class `marketing` and status `sent` inside the window, so a suppressed or
deferred marketing message does not spend a customer's allowance. The bot's
`/settings` screen shows the cap to the customer when one is configured.

## The durable record

`notification_deliveries` is the record of every decision, unique on
`(user_id, kind, subscription_id, dedupe_key)` with `NULLS NOT DISTINCT`.

| Status | Meaning |
| - | - |
| `pending` | Claimed and being evaluated |
| `deferred` | Held until quiet hours end or a retry-after expires |
| `sent` | Accepted by Telegram |
| `failed` | Telegram rejected it, with a classified error code and a bounded retry count |
| `suppressed` | Refused by policy, with the reason in `error_code` |

A row is re-claimed only when it is `failed` with fewer than three failures, or
`deferred` with an elapsed deferral. That is what turns a deferral into a delay
rather than a drop.

The key includes the subscription, so lifecycle alerts are per subscription and a
busy subscription cannot suppress a quiet one's expiry notice.

Telegram failures are classified into a durable delivery state on the account:
`bot_blocked` becomes `blocked` with no retry, `user_deactivated` and
`chat_not_found` become `deactivated` with no retry, "bot can't initiate
conversation" becomes `not_started` with no retry, and flood waits, timeouts,
and Telegram outages become `failing`, which is retryable. Any update from the
customer's private chat, and Telegram's `my_chat_member` report of a restart,
clears the three final states; a `my_chat_member` block records `blocked`.

### Three deliberate exceptions

* **Dunning notices bypass the policy entirely.** A customer about to lose access
  because a card was declined is not served by holding the message until morning,
  and there is no opt-out for it in the preference model either. Each notice
  still leaves a `payment` delivery row keyed `dunning:<attempt>`, which bounds
  it: a failed send is retried on the next two passes and then stops, a refusal
  Telegram calls final stops at once, a customer whose chat is known to be
  unreachable is not tried, and a notice older than seven days is not sent.
* **Support replies bypass it too.** The reply delivery pass sends directly and
  writes no delivery row; deduplication comes from the message's own
  `delivered_at` instead. A support reply has no suppression record and no retry
  accounting as a result. It would be classified urgent in any case.
* **A customer with no Telegram identity receives no push at all.** Recipients are
  drawn from active Telegram identities and Remnawave Telegram ids. A customer who
  signed in only by magic link or OIDC sees replies and announcements in the web
  panel and nothing is pushed anywhere.

## Consent is appended, never edited

Every marketing decision appends a `consent_records` row carrying the purpose,
whether it was granted, the policy version, the source surface, and the request
id. The current answer is the newest row. A history that can be rewritten is not
evidence of anything.

| Writer | Source | Policy version |
| - | - | - |
| Customer web preferences and unsubscribe | `customer_web` | `web-v0.10` |
| Customer web contact-channel changes | `account_web` | `account-web-v0.10` |
| Telegram bot | `telegram_bot` | The bot's own constant |

A preference update appends only when the value actually changes relative to the
latest record. An **unsubscribe appends unconditionally**, because "they had
already opted out" is not a reason for their unsubscribe to leave no trace.

The `marketing_enabled` column on the preferences row is a fallback for
installations that predate the consent history. Where the two disagree, the record
wins.

This is also what makes the customer-facing evidence line possible: "you opted in"
is not a useful thing to tell somebody who does not remember doing so, and "you
opted in on this date, from this surface, under this policy version" is.

### What unsubscribing does

One button on `/account/preferences` turns off marketing and the news preference,
clears the marketing flag on every live contact channel, appends a consent
withdrawal, and writes a `communication_suppressions` row with reason
`customer_request`.

It deliberately does not touch expiry, traffic, or renewal. Those carry facts a
customer needs whether or not they want to hear from the business, and an
unsubscribe that silenced them would turn a marketing preference into a way to
lose access without warning.

Turning marketing back on later deletes only the `customer_request` suppression —
see [the suppression list](https://omniflow-vpn.mintlify.site/commerce/campaigns.md#the-suppression-list) for why an
operator-recorded suppression survives that.

## Support attachments and retention

A support attachment is stored one of two ways, and the difference matters
operationally.

A **Telegram** attachment stores only Telegram's file identifier. Telegram is the
custodian and Omniflow holds no bytes. A **web** upload has no such custodian, so
the bytes are written to disk under a content-addressed key.

Which of the two a row is, is a column: `origin` is `telegram` or `local`, and
the reference sits in `telegram_file_id` or in `storage_key` accordingly. The
table enforces that pairing, so a row cannot claim one origin and carry the
other's reference. Anything sending an attachment back to Telegram skips a
`local` row — a content digest means nothing to Telegram — and the download route
refuses a `telegram` row, because it does not hold those bytes.

| Variable | Default | Purpose |
| - | - | - |
| `APP_SUPPORT_ATTACHMENT_DIR` | `/var/lib/omniflow/support-attachments` | Where web-uploaded bytes are stored |
| `APP_RETENTION_INTERVAL_HOURS` | `1` | How often the retention sweep runs |

> [!WARNING]
>
> **The API and the worker must point at the same directory.** The API writes the
> bytes and the worker's sweep is what removes them. If they disagree, retention
> deletes the rows while the files stay, and the disk grows without bound.

Attachment retention itself is the column default of 90 days and is not
configurable. The hourly sweep deletes every expired row, then removes only those
web files that no surviving row still references — two customers who uploaded
identical bytes share one content-addressed file, so deleting per row would delete
somebody else's attachment. Files are removed only after the transaction commits,
and a file that cannot be removed is logged and skipped rather than failing the
whole sweep.

> [!NOTE]
>
> The worker's sweep is the only thing that deletes an expired attachment. The
> bot used to purge rows at the end of every notification pass as well, and that
> path reclaimed no files — so whenever it ran first, the row was gone, the sweep
> found nothing to reclaim, and the bytes stayed on disk for good. It has been
> removed: one deleter, and it is the one that also frees the disk.

## Related

> **[Audience segments and campaigns](https://omniflow-vpn.mintlify.site/commerce/campaigns.md)**
>
> Sending a message to a chosen audience, and the suppression list.

> **[Notifications and consent](https://omniflow-vpn.mintlify.site/customer/notifications.md)**
>
> The same machinery from the customer's side.

> **[Support desk](https://omniflow-vpn.mintlify.site/operations/support-desk.md)**
>
> Where attachments arrive and how replies are delivered.

> **[Configuration](https://omniflow-vpn.mintlify.site/getting-started/configuration.md)**
>
> Every environment variable named on this page.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.