News and announcements
Categories, and what each one costs
A post carries acategory and a class. Four categories exist, and the category
is what the delivery machinery treats as the notification kind:
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.
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.
What makes a post visible
A post is visible to a customer when all of the following hold: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 explicitlocale 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
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-wideAPP_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.
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
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
- 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. - The kind’s preference is off — refuse,
kind_disabled. - Marketing class with no consent — refuse,
no_marketing_consent. - Marketing class already over the frequency cap — refuse,
frequency_cap. - A retry-after in the future — defer until then.
- Not urgent and inside quiet hours — defer to the end of the window.
- Otherwise, allow.
Quiet hours
Quiet hours are a whole-hour local-time window evaluated againstusers.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.
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
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.
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
paymentdelivery row keyeddunning:<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_atinstead. 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 aconsent_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.
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 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.
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.
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
Sending a message to a chosen audience, and the suppression list.
Notifications and consent
The same machinery from the customer’s side.
Support desk
Where attachments arrive and how replies are delivered.
Configuration
Every environment variable named on this page.