docs(site): the engagement system — cutover 7 of 7 (edge → main)
#28
11
PLAN.md
11
PLAN.md
@@ -1622,6 +1622,17 @@ a mechanism rather than diligence:
|
|||||||
replaceable by a file copy, and that promise survives exactly as long as nobody types the address
|
replaceable by a file copy, and that promise survives exactly as long as nobody types the address
|
||||||
into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the
|
into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the
|
||||||
mechanism, diligence is not.
|
mechanism, diligence is not.
|
||||||
|
|
||||||
|
**All three network checks read a file through Gitea's `contents` endpoint, never `raw`** —
|
||||||
|
`checkFacts.mjs`, `checkQuickstart.mjs`, `checkReference.mjs`. Phase 12b found the reason.
|
||||||
|
`raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea keeps
|
||||||
|
a copy for six hours: on cutover day this check read `website`'s `version.js` from a fortnight
|
||||||
|
earlier and failed the site for saying Module API 1.9.0 when `main` said 1.6.0 — except that
|
||||||
|
`main` said 1.9.0, and nothing anyone could edit here would have made it pass. `contents`
|
||||||
|
answers `private, must-revalidate` and is not cached, at the cost of a base64 decode. Same
|
||||||
|
argument as `checkLinks.mjs` fetching nothing: a check that goes red on someone else's
|
||||||
|
infrastructure is a check people learn to ignore, and one that goes red on a stale copy is
|
||||||
|
worse — it is indistinguishable from the failure it exists to report.
|
||||||
- **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a
|
- **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a
|
||||||
`RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23),
|
`RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23),
|
||||||
and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from
|
and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ Where the console offers free text about security practices, two things are wort
|
|||||||
| App info and performance | Other app data | No | No | Not collected by us. Stored on the device only. |
|
| App info and performance | Other app data | No | No | Not collected by us. Stored on the device only. |
|
||||||
| Messages | Other in-app messages | No | No | Not collected by us. Declare the relay hop in the console’s free-text security section if it asks. |
|
| Messages | Other in-app messages | No | No | Not collected by us. Declare the relay hop in the console’s free-text security section if it asks. |
|
||||||
| Messages | Other user-generated content | No | No | Not collected by us. |
|
| Messages | Other user-generated content | No | No | Not collected by us. |
|
||||||
|
| Messages | Other in-app messages | No | No | Not collected by us. Stored on the device only. |
|
||||||
| Device or other IDs | Device or other IDs | No | No | Not collected. |
|
| Device or other IDs | Device or other IDs | No | No | Not collected. |
|
||||||
|
|
||||||
## Each answer, and why it is the truthful one
|
## Each answer, and why it is the truthful one
|
||||||
@@ -83,12 +84,23 @@ Push is off until you enable it. When you do, the app mints a random, unguessabl
|
|||||||
|
|
||||||
**Messages → Other user-generated content.** Not collected by us.
|
**Messages → Other user-generated content.** Not collected by us.
|
||||||
|
|
||||||
Forum posts, Team activity, character and shard information, notification preferences: all of it is a live read or write against the deployment. Nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with no content, which is a limitation and also an accurate description of where the data lives.
|
Forum posts, Team activity, character and shard information, notification preferences: all of it is a live read or write against the deployment. Apart from the notification snapshot described in the next entry, nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with almost no content, which is a limitation and also an accurate description of where the data lives.
|
||||||
|
|
||||||
- **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one.
|
- **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one.
|
||||||
- **Retention:** Held by the deployment, under its operator’s policy
|
- **Retention:** Held by the deployment, under its operator’s policy
|
||||||
- **Read from:** `PLAN.md §9 section 2`
|
- **Read from:** `PLAN.md §9 section 2`
|
||||||
|
|
||||||
|
### A snapshot of your notifications, so the inbox opens without a signal
|
||||||
|
|
||||||
|
**Messages → Other in-app messages.** Not collected by us. Stored on the device only.
|
||||||
|
|
||||||
|
The app keeps the most recent notifications it has already fetched — at most thirty, and only the first page — on the device, so opening the inbox shows you what you had rather than a spinner. It is a copy of what the deployment already sent you and it is refreshed from there; nothing is written here that was not read from your own account. It is scoped to the account that fetched it, so a second person signing in on the same phone is never shown the first one’s messages.
|
||||||
|
|
||||||
|
- **Why that answer:** The snapshot is written on the phone from data the deployment had already delivered. It is not uploaded anywhere, and no server we operate is on either end of it.
|
||||||
|
- **Retention:** Until you sign out, or the thirty are pushed out by newer ones
|
||||||
|
- **In detail:** Signing out deletes the snapshot outright. It lives in the app’s ordinary preference store rather than the encrypted one — sign-in tokens are the thing that store is for — which is worth stating plainly: on a device where someone has root, these are readable, and they are notification bodies rather than credentials.
|
||||||
|
- **Read from:** `core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt`
|
||||||
|
|
||||||
### No analytics, no crash reporting, no advertising
|
### No analytics, no crash reporting, no advertising
|
||||||
|
|
||||||
**Device or other IDs → Device or other IDs.** Not collected.
|
**Device or other IDs → Device or other IDs.** Not collected.
|
||||||
@@ -116,4 +128,4 @@ Not part of the Data Safety form — that form is about the app — but a review
|
|||||||
- **Your browser’s user-agent string, truncated** — With the row; blanked on removal.
|
- **Your browser’s user-agent string, truncated** — With the row; blanked on removal.
|
||||||
- **The web server’s access log** — Short-term operational retention, then rotated away.
|
- **The web server’s access log** — Short-term operational retention, then rotated away.
|
||||||
|
|
||||||
Last generated from data dated 2026-08-24. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
Last generated from data dated 2026-09-01. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
||||||
|
|||||||
@@ -60,8 +60,28 @@ async function api(pathname) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = async (repo, filePath, ref) =>
|
/**
|
||||||
(await api(`${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`)).text();
|
* A file's bytes, read through the `contents` endpoint rather than `raw`.
|
||||||
|
*
|
||||||
|
* `raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea
|
||||||
|
* serves a copy for six hours and this check can read a blob most of a working day old.
|
||||||
|
* That is not theoretical: on the day of the engagement cutover it reported website's
|
||||||
|
* MODULE_API_VERSION as 1.6.0 -- the value from two weeks earlier -- and failed a site
|
||||||
|
* whose number was right. A check that goes red on stale data is a check people learn to
|
||||||
|
* ignore, which is the one failure mode this file exists to avoid.
|
||||||
|
*
|
||||||
|
* `contents` answers `private, must-revalidate`, which the CDN does not cache, so it is
|
||||||
|
* always the ref's current blob. The cost is a JSON parse and a base64 decode.
|
||||||
|
*/
|
||||||
|
async function raw(repo, filePath, ref) {
|
||||||
|
const meta = await json(`${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`);
|
||||||
|
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
|
||||||
|
throw new Error(
|
||||||
|
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Buffer.from(meta.content, 'base64').toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
const json = async (pathname) => (await api(pathname)).json();
|
const json = async (pathname) => (await api(pathname)).json();
|
||||||
|
|
||||||
|
|||||||
@@ -55,12 +55,18 @@ const checked = [];
|
|||||||
const ok = (what) => checked.push(what);
|
const ok = (what) => checked.push(what);
|
||||||
const fail = (what, detail) => failures.push({ what, detail });
|
const fail = (what, detail) => failures.push({ what, detail });
|
||||||
|
|
||||||
/** Same raw-file accessor checkFacts.mjs uses, and for the same reason. */
|
/** Same file accessor checkFacts.mjs uses, and for the same reason -- including the CDN one. */
|
||||||
async function raw(repo, filePath, ref) {
|
async function raw(repo, filePath, ref) {
|
||||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`;
|
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||||
return res.text();
|
const meta = await res.json();
|
||||||
|
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
|
||||||
|
throw new Error(
|
||||||
|
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Buffer.from(meta.content, 'base64').toString('utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -53,12 +53,18 @@ const checked = [];
|
|||||||
const ok = (what) => checked.push(what);
|
const ok = (what) => checked.push(what);
|
||||||
const fail = (what, detail) => failures.push({ what, detail });
|
const fail = (what, detail) => failures.push({ what, detail });
|
||||||
|
|
||||||
/** Same raw-file accessor checkFacts.mjs and checkQuickstart.mjs use. */
|
/** Same file accessor checkFacts.mjs and checkQuickstart.mjs use, CDN caveat included. */
|
||||||
async function raw(repo, filePath, ref = 'main') {
|
async function raw(repo, filePath, ref = 'main') {
|
||||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`;
|
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||||
return res.text();
|
const meta = await res.json();
|
||||||
|
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
|
||||||
|
throw new Error(
|
||||||
|
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Buffer.from(meta.content, 'base64').toString('utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export const docsSidebar = [
|
|||||||
{ label: 'Teams', slug: 'docs/administration/teams' },
|
{ label: 'Teams', slug: 'docs/administration/teams' },
|
||||||
{ label: 'Moderation', slug: 'docs/administration/moderation' },
|
{ label: 'Moderation', slug: 'docs/administration/moderation' },
|
||||||
{ label: 'Notifications and email', slug: 'docs/administration/notifications-and-email' },
|
{ label: 'Notifications and email', slug: 'docs/administration/notifications-and-email' },
|
||||||
|
{ label: 'Engagement rules', slug: 'docs/administration/engagement-rules' },
|
||||||
|
{ label: 'Message templates', slug: 'docs/administration/message-templates' },
|
||||||
{ label: 'Managing modules', slug: 'docs/administration/managing-modules' },
|
{ label: 'Managing modules', slug: 'docs/administration/managing-modules' },
|
||||||
{ label: 'The shard connection', slug: 'docs/administration/the-shard-connection' },
|
{ label: 'The shard connection', slug: 'docs/administration/the-shard-connection' },
|
||||||
{ label: 'Maintenance and upgrades', slug: 'docs/administration/maintenance-and-upgrades' },
|
{ label: 'Maintenance and upgrades', slug: 'docs/administration/maintenance-and-upgrades' },
|
||||||
@@ -113,6 +115,8 @@ export const plannedSidebar = {
|
|||||||
'Teams',
|
'Teams',
|
||||||
'Moderation',
|
'Moderation',
|
||||||
'Notifications and email',
|
'Notifications and email',
|
||||||
|
'Engagement rules',
|
||||||
|
'Message templates',
|
||||||
'Managing modules',
|
'Managing modules',
|
||||||
'The shard connection',
|
'The shard connection',
|
||||||
'Maintenance and upgrades',
|
'Maintenance and upgrades',
|
||||||
|
|||||||
@@ -59,9 +59,10 @@ per-Team settings:
|
|||||||
## Email
|
## Email
|
||||||
|
|
||||||
Configured on the same screen and covered in
|
Configured on the same screen and covered in
|
||||||
[Notifications and email](/docs/administration/notifications-and-email/): it is Gmail over
|
[Notifications and email](/docs/administration/notifications-and-email/): pick a mail
|
||||||
OAuth2, it reuses the Google authentication client, and it must be set up on the
|
transport, enter its host, port and credentials, and send a test. It depends on nothing
|
||||||
[Authentication](/docs/administration/authentication/) page first.
|
else on the site — a relay is the recommended posture, a mailbox provider over SMTP the
|
||||||
|
simplest, and your own MTA needs no credentials at all.
|
||||||
|
|
||||||
<Aside type="note" title="Until email is connected, the contact form is a mailto: link">
|
<Aside type="note" title="Until email is connected, the contact form is a mailto: link">
|
||||||
That is a deliberate fallback rather than a failure — but it does mean the *Contact email*
|
That is a deliberate fallback rather than a failure — but it does mean the *Contact email*
|
||||||
|
|||||||
175
src/content/docs/docs/administration/engagement-rules.mdx
Normal file
175
src/content/docs/docs/administration/engagement-rules.mdx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
title: Engagement rules
|
||||||
|
description: Decide what your site mails and shows people — the rule editor, saved audiences, the trigger catalog and the send log that answers "did they actually get it".
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
[Notifications and email](/docs/administration/notifications-and-email/) is about where a
|
||||||
|
message goes. [Message templates](/docs/administration/message-templates/) is about what it
|
||||||
|
says. This page is the part in between: **what makes one get sent at all.**
|
||||||
|
|
||||||
|
A **rule** is four decisions — *when* (a trigger), *to whom* (an audience), *by what*
|
||||||
|
(channels), and *how often* (timing). **Admin → Engagement → Rules.**
|
||||||
|
|
||||||
|
## Nothing sends until you turn it on
|
||||||
|
|
||||||
|
Every rule arrives switched **off**. That is true of the ones you make, and it is true of
|
||||||
|
the ones your modules ship with them: install a game module and you get a shelf of ready
|
||||||
|
rules, all dark, none of them mailing anybody. Turning one on is a deliberate, separate
|
||||||
|
act.
|
||||||
|
|
||||||
|
The same caution runs through the rest of the screen. Every rule carries a **hard ceiling
|
||||||
|
on sends per hour** — you cannot save one without a number — because the failure mode of an
|
||||||
|
automated mailer is not a wrong message, it is ten thousand of them at four in the morning.
|
||||||
|
|
||||||
|
<Aside type="caution" title="Upgrading? Your Team emails are here now">
|
||||||
|
Team notification email used to be its own pipeline. It is engagement rules now, and — like
|
||||||
|
every other seeded rule — those four rules arrive **disabled**. If your members were getting
|
||||||
|
Team mail before an upgrade, it stops until you turn them on. The admin dashboard says so
|
||||||
|
while it is true.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## What a rule is made of
|
||||||
|
|
||||||
|
**The trigger** is the event that fires it: a house falling into disrepair, a post being
|
||||||
|
published, a login failing. Pick it from what is registered — see
|
||||||
|
[the catalog](#the-trigger-catalog) below. A rule's trigger is **fixed once the rule
|
||||||
|
exists**: its cooldowns, its pending messages and its whole send history are about one
|
||||||
|
event, so changing it would silently be a different rule wearing the same name. Make a new
|
||||||
|
one instead.
|
||||||
|
|
||||||
|
**The audience** is who hears about it. Some are built in — the person the event is about,
|
||||||
|
everyone subscribed to it, staff. Others come from your modules and are named in their own
|
||||||
|
vocabulary. You can also point a rule at a **saved audience** you composed yourself; see
|
||||||
|
[Audiences](#audiences).
|
||||||
|
|
||||||
|
**The channels** are how it reaches them: on the site, by email, by push. A rule can name
|
||||||
|
more than one, and each channel picks its own template — the same event can be a sentence
|
||||||
|
in the inbox and a properly laid-out letter in the mail.
|
||||||
|
|
||||||
|
**The timing** is the part worth reading twice.
|
||||||
|
|
||||||
|
- A **delay** holds the message before it goes, so a situation that resolves itself never
|
||||||
|
produces a message at all.
|
||||||
|
- **Cancel on** names the events that call it back. A warning that a house is about to
|
||||||
|
collapse waits fifteen minutes and is cancelled outright if the owner turns up and
|
||||||
|
repairs it — nobody is told their house was in danger after it stopped being in danger.
|
||||||
|
- A **cooldown** is the "not again for a while" limit, counted **per person, per subject
|
||||||
|
and per channel**. Per subject, so a cooldown about one house says nothing about another.
|
||||||
|
Per channel, so "one a day about this house" means one email *and* one inbox item, which
|
||||||
|
is what an operator setting that limit means.
|
||||||
|
|
||||||
|
## Audiences
|
||||||
|
|
||||||
|
**Admin → Engagement → Audiences** is where you build a named set of people out of the ones
|
||||||
|
your modules declare — *members of this Team*, *the sitting governors* — and combine them:
|
||||||
|
all of these, any of these, none of these.
|
||||||
|
|
||||||
|
One rule governs the whole screen: **composition narrows and never widens.**
|
||||||
|
|
||||||
|
- The ceiling of a saved audience is **derived** from the tightest thing in it, never
|
||||||
|
chosen. That is true of "any of" too, where the intuitive answer — the widest of the two —
|
||||||
|
is the wrong one. A ceiling says what an expression is *allowed* to reach, not what it
|
||||||
|
happens to resolve to today.
|
||||||
|
- **"None of" is only offered inside an "all of" group.** Alone it would have to mean
|
||||||
|
"everybody except these", which is a broadcast built out of a short list, and it is not
|
||||||
|
offered anywhere it would mean that.
|
||||||
|
- Two audiences with no relationship between them — staff and "the person this is about",
|
||||||
|
say — have no honest combined ceiling, so the save is refused rather than guessing which
|
||||||
|
side to take.
|
||||||
|
|
||||||
|
Before you save a rule, the editor shows you a **reach preview**: a number, never a list of
|
||||||
|
names. It will also tell you when a number is a floor rather than an answer, and when an
|
||||||
|
audience resolves to nobody at all and why.
|
||||||
|
|
||||||
|
## The ceiling, and why a rule will not offer the audience you expected
|
||||||
|
|
||||||
|
Every trigger declares the **widest audience a rule may ever give it**. It is the security
|
||||||
|
boundary of the whole system, and it is set in code by whoever declared the event, not in
|
||||||
|
the admin panel. Staff-only events cannot be widened into public ones by anybody, including
|
||||||
|
you.
|
||||||
|
|
||||||
|
Seven values, and they are a **tree, not a ladder**:
|
||||||
|
|
||||||
|
| Ceiling | Who that is |
|
||||||
|
| --- | --- |
|
||||||
|
| `everyone` | Everyone, including signed-out visitors |
|
||||||
|
| `authenticated` | Any signed-in user |
|
||||||
|
| `subscribers` | Signed-in users subscribed to this event |
|
||||||
|
| `members` | Members of a module-declared list |
|
||||||
|
| `staff` | Staff only — admins, editors and moderators |
|
||||||
|
| `admin` | Administrators only |
|
||||||
|
| `owner` | Only the user the event is about |
|
||||||
|
|
||||||
|
<Aside type="note" title="Fewer people is not less exposure">
|
||||||
|
The tempting reading is a ladder — that a staff-only event could obviously also go to just
|
||||||
|
one person. It cannot, and the example is the whole argument: cheat detection is a
|
||||||
|
staff-only event, and "just one person" would be *the player it was detected on*. The
|
||||||
|
question a ceiling answers is never how many, it is **which**.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
So `staff` does not permit `owner`, `members` does not permit `subscribers`, and the editor
|
||||||
|
simply does not offer you the audiences the trigger forbids. The one exception proves the
|
||||||
|
rule: `admin` sits under `staff`, because every administrator really is staff.
|
||||||
|
|
||||||
|
## The trigger catalog
|
||||||
|
|
||||||
|
**Admin → Engagement → Triggers** lists every event a rule can be built on, and it is
|
||||||
|
read-only on purpose — **there is no table behind it**. A trigger is declared in code, by
|
||||||
|
the site or by an installed module, so what you are looking at is whatever registered on
|
||||||
|
this boot. Uninstall a module and its triggers stop appearing; nothing was deleted.
|
||||||
|
|
||||||
|
Two things it shows that are invisible everywhere else:
|
||||||
|
|
||||||
|
- **The variables** each event carries, with an example of each. This is the list a template
|
||||||
|
is allowed to reference — when a message comes out with a hole in it, this is the screen
|
||||||
|
that says why.
|
||||||
|
- **The ceiling**, so when the rule editor offers you a narrower set of audiences than you
|
||||||
|
expected, you can see the number it is obeying.
|
||||||
|
|
||||||
|
### Dormant rules
|
||||||
|
|
||||||
|
A rule can be switched on and still be unable to fire — most often because the module that
|
||||||
|
declared its trigger, or the audience it points at, is no longer installed. Those are
|
||||||
|
badged **dormant** in the list, with the reason, because "this rule cannot fire" is a
|
||||||
|
different fact from "this rule is off" and you need both. The on/off switch keeps working
|
||||||
|
on a dormant rule, deliberately: a rule whose module has gone is exactly the rule you most
|
||||||
|
want to be able to stop.
|
||||||
|
|
||||||
|
## The send log
|
||||||
|
|
||||||
|
**Admin → Engagement → Send Log** answers one question: *did that person get that message,
|
||||||
|
and if not, why not?* Every attempt is a row — when, what fired it, which user, which
|
||||||
|
channel, and the result. Filter by result to go straight to what failed.
|
||||||
|
|
||||||
|
| Result | What it means |
|
||||||
|
| --- | --- |
|
||||||
|
| **Sent** | Handed to the channel successfully |
|
||||||
|
| **Failed** | The attempt errored — the reason is on the row, not hidden in a tooltip |
|
||||||
|
| **Not sent** | Suppressed before it was attempted: unsubscribed, unverified, or on the [suppression list](/docs/administration/troubleshooting/) |
|
||||||
|
| **Bounced** | The receiving server rejected it after the fact |
|
||||||
|
| **Marked as spam** | The recipient reported it |
|
||||||
|
|
||||||
|
Test sends from the template editor land here too, labelled as such, so you can confirm
|
||||||
|
your own test arrived before turning a rule on for real.
|
||||||
|
|
||||||
|
<Aside type="tip" title="Two things it will not show you, on purpose">
|
||||||
|
**The email address.** The log stores a one-way hash of it — enough to tie a bounce back to
|
||||||
|
a delivery, not enough to become a second address book.
|
||||||
|
|
||||||
|
**A name.** It holds the user id, and that is deliberate: joining the account list in would
|
||||||
|
quietly turn a delivery log into a staff-readable directory. Paste the id into Moderation,
|
||||||
|
which is where a person's record belongs.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## What a game module brings
|
||||||
|
|
||||||
|
A module declares its own triggers and its own audiences, in its own vocabulary, and it may
|
||||||
|
ship rules and message bodies to go with them. The Ultima Online module ships a large family
|
||||||
|
of them — houses falling to ruin, vendors running out of gold, a governor being seated, a
|
||||||
|
guild's fortunes — written in the voice of an in-world office rather than a system alert.
|
||||||
|
|
||||||
|
All of them arrive **disabled**, like every other seeded rule. Read the list in
|
||||||
|
**Admin → Engagement → Triggers**, turn on the ones your shard should send, and check the
|
||||||
|
send log the first time each one fires.
|
||||||
133
src/content/docs/docs/administration/message-templates.mdx
Normal file
133
src/content/docs/docs/administration/message-templates.mdx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
title: Message templates
|
||||||
|
description: Edit what your site's email actually says — the block editor, the variable palette, the preview, test sends, and the send log that tells you whether a message arrived.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
Every message your site sends — password resets, invitations, notifications — is a
|
||||||
|
**template** you can edit. They ship working, so a fresh site mails correctly before you
|
||||||
|
open this screen at all. You come here when you want it to sound like your shard.
|
||||||
|
|
||||||
|
**Admin → Engagement → Templates.**
|
||||||
|
|
||||||
|
## What is in the list
|
||||||
|
|
||||||
|
Each row is one message. The ones marked **system** are the ones the site itself depends
|
||||||
|
on: the password reset, the invitation, the address-confirmation mail. You can edit every
|
||||||
|
word of those, but you cannot delete them — a site with no password-reset body is a site
|
||||||
|
where nobody can get back in.
|
||||||
|
|
||||||
|
The rest are the general-purpose bodies that rules send. Those you can delete, as long as
|
||||||
|
no rule is currently pointing at one.
|
||||||
|
|
||||||
|
<Aside type="tip" title="Your edits survive upgrades">
|
||||||
|
When you edit a shipped template, the site remembers that a person changed it. Later
|
||||||
|
versions may ship an improved default for the same message — and it will **not** be applied
|
||||||
|
over your words. You will see a note on the row telling you a newer default exists, and it
|
||||||
|
is up to you whether to look at it.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Editing a message
|
||||||
|
|
||||||
|
The editor has the message on the left and a live preview on the right.
|
||||||
|
|
||||||
|
### The body is blocks, not HTML
|
||||||
|
|
||||||
|
You build a message out of pieces: a heading, a paragraph, a button, a divider, an image,
|
||||||
|
or an item list. Add one from the row of buttons, click it to edit it, and use the arrows
|
||||||
|
to move it. There is no HTML to write, which is deliberate — email HTML is a genuinely
|
||||||
|
horrible format, and the blocks already produce something that survives Outlook.
|
||||||
|
|
||||||
|
### Variables are chosen, never typed
|
||||||
|
|
||||||
|
Under most text fields is a row of small grey names: `siteName`, `resetUrl`, `title`. Those
|
||||||
|
are the **variables** this particular message is given when it is sent. Click one and it is
|
||||||
|
inserted as a token; the message that goes out has the real value in its place.
|
||||||
|
|
||||||
|
You cannot invent a variable. If you type one the message is not given — a typo, or a name
|
||||||
|
you remembered from a different message — the save is refused and the error names the
|
||||||
|
variable. That is on purpose: a variable that does not exist renders as *nothing*, so
|
||||||
|
without the check the mistake would be invisible until it reached somebody's inbox as a
|
||||||
|
sentence with a hole in it.
|
||||||
|
|
||||||
|
To see every variable a given event provides, with an example of each, look at
|
||||||
|
**Admin → Engagement → Triggers**.
|
||||||
|
|
||||||
|
### Both halves of the message
|
||||||
|
|
||||||
|
Every email goes out in two forms: the designed HTML one, and a plain-text one for clients
|
||||||
|
that will not show HTML. The plain-text half is generated from your blocks automatically,
|
||||||
|
and you can see it under the **Plain text** tab.
|
||||||
|
|
||||||
|
If the generated version is not good enough, write your own in **Plain-text part** at the
|
||||||
|
bottom of the editor. Whatever you write there replaces the generated text completely.
|
||||||
|
|
||||||
|
A published message must have *something* in its text part. If every block you used
|
||||||
|
contributes nothing to it — a message made only of dividers and images, say — the save is
|
||||||
|
refused.
|
||||||
|
|
||||||
|
### Draft and published
|
||||||
|
|
||||||
|
A **draft** is not what goes out. While a message is a draft, the site sends the shipped
|
||||||
|
default in its place, so you can leave something half-finished without breaking anything.
|
||||||
|
Switch it to **Published** when you want your version to be the one people receive.
|
||||||
|
|
||||||
|
## The preview
|
||||||
|
|
||||||
|
The preview is rendered by the server using the same code that renders the real message, so
|
||||||
|
what you see is what will arrive — not an approximation drawn by the browser.
|
||||||
|
|
||||||
|
It fills the variables in with example values, so you never need to trigger a real event to
|
||||||
|
see what a message looks like.
|
||||||
|
|
||||||
|
Three controls are worth knowing:
|
||||||
|
|
||||||
|
- **Desktop / Mobile** — the same body at a reading-pane width and a phone width.
|
||||||
|
- **Dark mode** — an approximation of what mail clients that invert light messages will do
|
||||||
|
to yours. Worth a glance: a design that relies on a light background can come out as
|
||||||
|
dark-on-dark for a large minority of readers.
|
||||||
|
- **Plain text** — the other half of the message, as described above.
|
||||||
|
|
||||||
|
## Sending yourself a test
|
||||||
|
|
||||||
|
The **Send a test** box sends the message to any address you type, through whatever mail
|
||||||
|
transport the site is configured with (**Admin → Settings → Email delivery** — see
|
||||||
|
[Notifications and email](/docs/administration/notifications-and-email/)).
|
||||||
|
|
||||||
|
It sends **what is on screen**, saved or not. That is the point of it: try a wording, send
|
||||||
|
it to yourself, look at it in a real inbox, and only then decide whether to save.
|
||||||
|
|
||||||
|
Test sends are recorded in the send log like any other message, including when they fail.
|
||||||
|
|
||||||
|
## Making a new template
|
||||||
|
|
||||||
|
You do not start from a blank page. Pick a message that is close to what you want, press
|
||||||
|
**Duplicate**, and give the copy a key.
|
||||||
|
|
||||||
|
The **key** is how a rule refers to the template — `notify.house-idoc`, say. Lowercase
|
||||||
|
letters, digits, dots and dashes, and it cannot be changed later, so pick one that will
|
||||||
|
still make sense in a year.
|
||||||
|
|
||||||
|
The copy always starts as a draft. Once you are happy with it, publish it and point a rule
|
||||||
|
at it in **Admin → Engagement → Rules**.
|
||||||
|
|
||||||
|
<Aside type="caution" title="A template a rule is using cannot be deleted">
|
||||||
|
If you try, the site tells you which rules are still pointing at it. Repoint or delete
|
||||||
|
those first. The alternative — letting the delete through — would leave a rule that quietly
|
||||||
|
stops producing mail, and nothing on screen would say why.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Did it arrive?
|
||||||
|
|
||||||
|
**Admin → Engagement → Send Log** lists every message the site tried to deliver, newest
|
||||||
|
first, successes and failures alike. When mail is not arriving, this is the screen that
|
||||||
|
tells you whether the site tried and the relay refused, or whether it never tried at all.
|
||||||
|
|
||||||
|
Failures carry the reason the mail server gave, which is usually the actual answer — a
|
||||||
|
rejected sender address, a bad password, a relay that will not accept your domain.
|
||||||
|
|
||||||
|
The log does not store anybody's email address. It keeps a one-way fingerprint instead, so
|
||||||
|
that a bounce can be matched back to a delivery without the log itself becoming a second
|
||||||
|
copy of your members' addresses. The rest of that screen — and the rules that decide a
|
||||||
|
message is sent at all — is [Engagement rules](/docs/administration/engagement-rules/).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Notifications and email
|
title: Notifications and email
|
||||||
description: Email over Gmail OAuth2, the announcement pipeline and its legs, the Discord bot, and opt-in push to the mobile app.
|
description: Email over SMTP, the announcement pipeline and its legs, the Discord bot, and opt-in push to the mobile app.
|
||||||
---
|
---
|
||||||
|
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
@@ -9,21 +9,67 @@ Four separate delivery paths, each optional, each off until you configure it. A
|
|||||||
configures none of them still works — it just never reaches anyone who is not looking at
|
configures none of them still works — it just never reaches anyone who is not looking at
|
||||||
it.
|
it.
|
||||||
|
|
||||||
|
This page is about the paths themselves. What decides that a particular message gets sent
|
||||||
|
down one of them is a rule — see [Engagement rules](/docs/administration/engagement-rules/).
|
||||||
|
|
||||||
## Email
|
## Email
|
||||||
|
|
||||||
**Admin → Settings → Email delivery.** The site sends contact-form messages (and test
|
**Admin → Settings → Email delivery.** The site sends contact-form messages, invitations,
|
||||||
messages) through **Gmail over OAuth2**, delivered to the *Contact email* setting.
|
password resets, team notifications and test messages through **SMTP**. Contact-form mail
|
||||||
|
goes to the *Contact email* setting.
|
||||||
|
|
||||||
It reuses the **Google authentication client**, so the order is fixed: configure Google on
|
You pick a mail transport and fill in the fields it asks for. There is no consent flow and
|
||||||
the [Authentication](/docs/administration/authentication/) page first, then press **Connect
|
no redirect to bounce through — it is a form, and the credentials go straight into the
|
||||||
Gmail** here. Until then the panel reads *Unconfigured* and says exactly that.
|
database encrypted at rest, write-only: the panel will tell you a password is *set*, and
|
||||||
|
will never show it to you again.
|
||||||
|
|
||||||
The refresh token it stores is encrypted at rest like every other secret.
|
### Three ways to point it somewhere
|
||||||
|
|
||||||
<Aside type="note" title="There is no SMTP option">
|
Any SMTP server works. Which one you should use depends on how much mail you expect to send.
|
||||||
Gmail over OAuth2 is the only supported delivery path today. Until it is connected, the
|
|
||||||
contact form falls back to a `mailto:` link to the contact address — which works, and puts
|
**A relay — the recommended one.** Mailgun, SES, Postmark or equivalent: their host, port
|
||||||
the message in the visitor's own mail client rather than in your logs.
|
`587`, *Implicit TLS* **off**, and your API key as the password. Deliverability is the hard
|
||||||
|
part of sending mail — reputation, DKIM, bounce handling — and this is the option where
|
||||||
|
somebody else owns it. Use this for anything with real volume.
|
||||||
|
|
||||||
|
**A mailbox provider over SMTP — the simplest.** For example `smtp.gmail.com`, port `587`,
|
||||||
|
*Implicit TLS* **off**, your address as the username, and an
|
||||||
|
[app password](https://support.google.com/accounts/answer/185833) — not your account
|
||||||
|
password, and it requires 2-Step Verification to be on. Fine for a small site; subject to
|
||||||
|
the provider's daily send caps.
|
||||||
|
|
||||||
|
**Your own MTA.** If you already run mail on the same host: its address, port `25`,
|
||||||
|
*Implicit TLS* **off**, username and password blank. The site treats a username with no
|
||||||
|
password as incomplete, since that authenticates as nobody.
|
||||||
|
|
||||||
|
<Aside type="caution" title="The two fields that cause most failures">
|
||||||
|
**Implicit TLS** belongs *on* only for port **465**. On port `587` leave it **off** — the
|
||||||
|
connection still upgrades to TLS, using STARTTLS. Port 587 with it on does not report an
|
||||||
|
error; it hangs.
|
||||||
|
|
||||||
|
**Send from** must be an address the account is allowed to send as. Unlike a username, this
|
||||||
|
is not verified when you save it — a server that refuses your sender rejects the mail for
|
||||||
|
SPF/DMARC reasons that look like nothing at all from the outside. **Send test** is what
|
||||||
|
proves it, and it names this specifically when it happens.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
Until a transport is configured, the contact form falls back to a `mailto:` link to the
|
||||||
|
contact address — which works, and puts the message in the visitor's own mail client rather
|
||||||
|
than in your logs. Invitations surface a copyable accept link instead, and password resets
|
||||||
|
still answer normally.
|
||||||
|
|
||||||
|
<Aside type="note" title="Upgrading from the Gmail connect flow">
|
||||||
|
Earlier versions authorised a mailbox with a **Connect Gmail** consent flow that borrowed
|
||||||
|
the Google authentication client. That flow has been removed.
|
||||||
|
|
||||||
|
If your site used it, mail **stops** on upgrade until you enter SMTP credentials — and
|
||||||
|
nothing errors when it does, because every sender degrades politely. The admin dashboard
|
||||||
|
warns you while it is true. `smtp.gmail.com` port 587 with an app password is the shortest
|
||||||
|
route back.
|
||||||
|
|
||||||
|
Single sign-on is unaffected: the Google provider exists for SSO in its own right, and email
|
||||||
|
merely borrowed its credentials. Removing the borrow also removes a trap — rotating the SSO
|
||||||
|
secret used to break outbound mail silently.
|
||||||
</Aside>
|
</Aside>
|
||||||
|
|
||||||
## Announcements
|
## Announcements
|
||||||
@@ -75,14 +121,45 @@ Two properties matter for what you have to trust:
|
|||||||
Without `NTFY_PUBLIC_URL` / `NTFY_ALLOWED_ORIGINS`, the app simply shows push as
|
Without `NTFY_PUBLIC_URL` / `NTFY_ALLOWED_ORIGINS`, the app simply shows push as
|
||||||
unavailable for your instance — nothing breaks.
|
unavailable for your instance — nothing breaks.
|
||||||
|
|
||||||
|
A tickle raised by an engagement rule carries a pointer to the matching item in the
|
||||||
|
[on-site inbox](#on-site-notifications) where there is one, so the app opens on the thing
|
||||||
|
that happened rather than on a list. It is still only a pointer: the content is fetched, not
|
||||||
|
delivered.
|
||||||
|
|
||||||
|
## On-site notifications
|
||||||
|
|
||||||
|
The third way to reach somebody, and the only one that needs no relay, no mailbox and no
|
||||||
|
app: an item in their **notification inbox** on the site itself. A bell in the header
|
||||||
|
carries the unread count; the list lives at **Account → Notifications**.
|
||||||
|
|
||||||
|
Two things are worth knowing before you enable a rule that uses it:
|
||||||
|
|
||||||
|
- **It is the one channel that is on by default.** Push and email are opt-in — both reach
|
||||||
|
somebody somewhere else, so both have to be asked for. An inbox item is a row on a page
|
||||||
|
the person chose to open, so it is opt-*out*: they switch it off per notification under
|
||||||
|
Account → Notifications → Settings.
|
||||||
|
- **The body is plain text, always.** The in-app template renders through the same block
|
||||||
|
editor as your mail, but only the text of each block is stored, so nothing an operator
|
||||||
|
writes can become markup on somebody else's page. Links are site-relative or dropped.
|
||||||
|
|
||||||
|
Old, read items are pruned nightly (90 days by default). **Unread items are never pruned** —
|
||||||
|
an inbox that quietly deleted things nobody had seen would make the unread badge meaningless.
|
||||||
|
|
||||||
## Who receives what
|
## Who receives what
|
||||||
|
|
||||||
The per-person side of this lives in the player portal, not the admin panel: each member
|
The per-person side of this lives in the player portal, not the admin panel: **Account →
|
||||||
chooses which Team and forum notifications they want, and how. Two defaults are worth
|
Notifications → Settings** is a grid of every notification against every channel, and each
|
||||||
knowing because they are not symmetrical:
|
member sets their own. The defaults are not symmetrical, and the asymmetry is deliberate:
|
||||||
|
|
||||||
- **Push is opt-out** once a device is registered.
|
|
||||||
- **Email is opt-in.**
|
- **Email is opt-in.**
|
||||||
|
- **Push is opt-in.**
|
||||||
|
- **On the site is opt-out** — see above.
|
||||||
|
- **Muting a Team silences all three for that Team**, whatever the grid says, without
|
||||||
|
touching any of their other Teams.
|
||||||
|
|
||||||
|
The operator's side of the same question — which events exist, and how wide an audience each
|
||||||
|
one may ever be given — is [Engagement rules](/docs/administration/engagement-rules/). When
|
||||||
|
a message went nowhere and you want to know why, the send log there is the screen that says.
|
||||||
|
|
||||||
<Aside type="caution" title="Nothing here retries">
|
<Aside type="caution" title="Nothing here retries">
|
||||||
The announcement dispatcher sends once, and the Team notification bridge states plainly that
|
The announcement dispatcher sends once, and the Team notification bridge states plainly that
|
||||||
|
|||||||
@@ -48,6 +48,52 @@ Two rules are structural rather than settings:
|
|||||||
about a leader has to reach someone above them. See
|
about a leader has to reach someone above them. See
|
||||||
[Moderation](/docs/administration/moderation/).
|
[Moderation](/docs/administration/moderation/).
|
||||||
|
|
||||||
|
## Team notification emails
|
||||||
|
|
||||||
|
**Team emails are sent by the engagement rules, and they arrive switched off.**
|
||||||
|
|
||||||
|
Someone posting in a Team forum used to send mail with no configuration at all. It now goes
|
||||||
|
through the same engine as everything else the site sends: the forum post raises an event,
|
||||||
|
an **engagement rule** decides who is told and through which message template, and the
|
||||||
|
outbox delivers it. Push notifications to the app and the Discord bridge below are
|
||||||
|
unaffected — only the email moved.
|
||||||
|
|
||||||
|
The practical consequence on an existing site: **nobody gets Team email until you turn a
|
||||||
|
rule on.** Open **Admin → Engagement → Rules**. Four rules are waiting there, one per Team
|
||||||
|
event, all switched off, and the screen says so at the top for as long as they all are.
|
||||||
|
Switch on the ones your site wants.
|
||||||
|
|
||||||
|
| Rule | Sends when |
|
||||||
|
|---|---|
|
||||||
|
| **Team forum posts** | someone posts a new thread or reply |
|
||||||
|
| **Team announcements** | a leader posts an announcement |
|
||||||
|
| **Team — new member** | someone joins, at most once an hour per person |
|
||||||
|
| **Team — leadership change** | a new leader is named, at most once an hour per person |
|
||||||
|
|
||||||
|
The first two are the ones most sites want. The last two describe things that already show
|
||||||
|
up on the Team's activity feed and arrive from a sweep rather than from a person doing
|
||||||
|
something — which is why they ship off and with a cooldown.
|
||||||
|
|
||||||
|
<Aside type="note" title="Members still control their own mail">
|
||||||
|
A rule decides whether the site sends at all. Each member still chooses, per Team, between
|
||||||
|
no email, one message per post, and a daily digest — on their own notifications screen or
|
||||||
|
through the unsubscribe link in any Team email. Turning a rule on does not sign anybody up.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
**Digests are re-read at the moment they are sent**, not assembled as posts arrive. A site
|
||||||
|
that was down for two days sends one digest rather than two days of backlog, a post a
|
||||||
|
moderator hid is not in it, and somebody who lost access to a forum between the post and the
|
||||||
|
send does not receive it.
|
||||||
|
|
||||||
|
**Unsubscribe links keep working.** A link in mail sent before this change still does what
|
||||||
|
it says. What changed is that it is now precise: it stops the emails it came with and leaves
|
||||||
|
that Team's push notifications alone, where before it silenced both.
|
||||||
|
|
||||||
|
You can change what any of these messages say — see
|
||||||
|
[Message templates](/docs/administration/message-templates/) — decide which of them are sent
|
||||||
|
at all under [Engagement rules](/docs/administration/engagement-rules/), and see who was
|
||||||
|
actually sent what in **Admin → Engagement → Send log**.
|
||||||
|
|
||||||
## The Discord bridges
|
## The Discord bridges
|
||||||
|
|
||||||
Two integrations, both optional, both configured from **Admin → Teams**.
|
Two integrations, both optional, both configured from **Admin → Teams**.
|
||||||
|
|||||||
@@ -97,14 +97,71 @@ a Team. See [Teams](/docs/administration/teams/).
|
|||||||
|
|
||||||
## Email and announcements never arrive
|
## Email and announcements never arrive
|
||||||
|
|
||||||
- **The contact form opens a mail client.** Email delivery is not connected; that is the
|
- **The contact form opens a mail client.** Email delivery is not configured; that is the
|
||||||
documented fallback. Connect Gmail in **Settings → Email delivery** — after configuring
|
documented fallback. Enter SMTP credentials in **Settings → Email delivery**. If this site
|
||||||
the Google provider, which it reuses.
|
used to send mail and stopped, the Gmail connect flow was removed — the admin dashboard
|
||||||
|
says so, and [Notifications and email](/docs/administration/notifications-and-email/) has
|
||||||
|
the migration.
|
||||||
|
- **Mail is configured but nothing arrives, and there is no error.** Two usual causes, both
|
||||||
|
invisible without a test send. *Implicit TLS* left on for port 587 hangs rather than
|
||||||
|
failing; and a **Send from** address the server will not let you send as is rejected for
|
||||||
|
SPF/DMARC reasons. Press **Send test** — its failure message names both cases.
|
||||||
|
- **Email was working and the toggle is still on.** *Enable email sending* now gates every
|
||||||
|
message, not just some of them. If it is off, nothing is sent, including the contact
|
||||||
|
form.
|
||||||
- **A published post announced nothing.** The Discord bot is a separate container. If the
|
- **A published post announced nothing.** The Discord bot is a separate container. If the
|
||||||
Discord Bot screen says *bot unreachable*, it is not running.
|
Discord Bot screen says *bot unreachable*, it is not running.
|
||||||
- **A missed announcement does not come back.** Nothing retries; the post itself is still
|
- **A missed announcement does not come back.** Nothing retries; the post itself is still
|
||||||
on the site.
|
on the site.
|
||||||
|
|
||||||
|
## Nothing is sent for one particular event
|
||||||
|
|
||||||
|
Mail works, other notifications arrive, but this one thing never produces anything. The
|
||||||
|
answer is almost always in **Engagement → Rules**, and it is one of four:
|
||||||
|
|
||||||
|
- **The rule is off.** Every rule ships disabled, including the ones your modules bring
|
||||||
|
with them, so "installed" is not "on".
|
||||||
|
- **The rule is badged *dormant*.** It is switched on but cannot fire — usually because the
|
||||||
|
module that declared its trigger, or the audience it points at, is no longer installed.
|
||||||
|
- **It fired and was held back by its own cooldown**, which is per person, per subject and
|
||||||
|
per channel. The Send Log shows nothing for a message that was never queued.
|
||||||
|
- **The audience resolves to nobody.** The rule editor's reach preview is the fastest way
|
||||||
|
to find that out — it will tell you the count is zero and why.
|
||||||
|
|
||||||
|
[Engagement rules](/docs/administration/engagement-rules/) walks through all four.
|
||||||
|
|
||||||
|
## One person stopped receiving email
|
||||||
|
|
||||||
|
Everyone else is getting mail, so the transport is fine. Check
|
||||||
|
**Engagement → Suppressions**, then **Engagement → Send Log**.
|
||||||
|
|
||||||
|
- **They are on the suppression list.** The site stops mailing an address once the
|
||||||
|
receiving server says the mailbox does not exist. Addresses are stored one way and
|
||||||
|
shown masked (`d***@example.com`), so search by their domain to find the row. If they
|
||||||
|
have since fixed their mailbox, press **Lift a suppression** and type the full
|
||||||
|
address — the screen genuinely does not have it, which is why you are asked.
|
||||||
|
- **Suppression only affects engagement rules.** Password resets, invites and address
|
||||||
|
verification still go out to a suppressed address, because those are things the person
|
||||||
|
asked for themselves. So "they can reset their password but get no notifications" is
|
||||||
|
the expected shape of this problem, not a contradiction.
|
||||||
|
- **The Send Log says *Not sent*.** That is a suppression: nothing was sent to the mail
|
||||||
|
server at all. *Bounced* means it was sent and the mailbox does not exist. *Failed*
|
||||||
|
means the relay refused it for some other reason — that one is about your
|
||||||
|
configuration, not about them.
|
||||||
|
- **The Send Log has no row for them at all.** They were excluded before anything was
|
||||||
|
queued. Either they have not opted in on **Notifications** for that stream, or
|
||||||
|
*Require a verified email address* is on in **Settings** and they have not confirmed
|
||||||
|
theirs. The rule editor's audience preview shows how many people each of those removes.
|
||||||
|
|
||||||
|
## Everyone stopped receiving email at once
|
||||||
|
|
||||||
|
Do **not** start clearing the suppression list — it is almost certainly not the cause.
|
||||||
|
A whole-deployment stop is a transport problem: an expired password, a relay that has
|
||||||
|
started refusing you, or *Enable email sending* switched off. The Send Log will show
|
||||||
|
*Failed* rather than *Bounced* or *Not sent*, and **Settings → Email delivery** shows the
|
||||||
|
last error. A wrong password never suppresses anybody; only the receiving server saying a
|
||||||
|
specific mailbox does not exist does that.
|
||||||
|
|
||||||
## Uploads and modules fail with permission errors
|
## Uploads and modules fail with permission errors
|
||||||
|
|
||||||
Docker created a bind-mount source that the container user cannot write — usually because
|
Docker created a bind-mount source that the container user cannot write — usually because
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ description: One number, declared in three repositories, that decides whether a
|
|||||||
---
|
---
|
||||||
|
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
import platform from '../../../../data/platform.json';
|
||||||
|
|
||||||
The loopback wire protocol between the game plugin and the sidecar is a **versioned
|
The loopback wire protocol between the game plugin and the sidecar is a **versioned
|
||||||
compatibility contract**, not a build dependency. Nothing compiles the three sides together,
|
compatibility contract**, not a build dependency. Nothing compiles the three sides together,
|
||||||
so the number is what stops a mismatch from being discovered as corrupted data.
|
so the number is what stops a mismatch from being discovered as corrupted data.
|
||||||
|
|
||||||
The current protocol is **4**.
|
The current protocol is **{platform.protocol}**.
|
||||||
|
|
||||||
## Three declaration sites
|
## Three declaration sites
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@ The same number is written down in three places, and they must move together.
|
|||||||
|
|
||||||
| Where | What declares it |
|
| Where | What declares it |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `link/sidecar/src/main.rs` | `pub const PROTOCOL_VERSION: u32 = 4` — what the sidecar speaks |
|
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION`, currently {platform.protocol} — what the sidecar speaks |
|
||||||
| `servuo-plugins/overlay.toml` | `protocol = 4` — what the plugin overlay speaks |
|
| `servuo-plugins/overlay.toml` | `protocol`, currently {platform.protocol} — what the plugin overlay speaks |
|
||||||
| The bundle manifest | Copied from `overlay.toml` by CI, so a released pair carries its own claim |
|
| The bundle manifest | Copied from `overlay.toml` by CI, so a released pair carries its own claim |
|
||||||
|
|
||||||
<Aside type="caution" title="Bump the overlay in the same PR as the emitters">
|
<Aside type="caution" title="Bump the overlay in the same PR as the emitters">
|
||||||
@@ -44,19 +45,19 @@ allowed to be chosen independently.
|
|||||||
|
|
||||||
## What a bump obliges
|
## What a bump obliges
|
||||||
|
|
||||||
Changing a message shape means editing every side plus the specification. A protocol-4
|
Changing a message shape means editing every side plus the specification. The most recent
|
||||||
change touched:
|
bump touched:
|
||||||
|
|
||||||
| Repository | What had to change |
|
| Repository | What had to change |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `servuo-plugins` | The emitters, the config keys, and `overlay.toml` |
|
| `servuo-plugins` | The emitters, the config keys, and `overlay.toml` |
|
||||||
| `link` | `PROTOCOL_VERSION`, a store migration, and the projections |
|
| `link` | `PROTOCOL_VERSION`, and the projections |
|
||||||
| `module-uo` | The tables, the ingest, and the kind-to-feature map |
|
| `module-uo` | The tables, the ingest, and the kind-to-feature map |
|
||||||
| `docs` | The protocol document and the integration guide |
|
| `docs` | The protocol document and the integration guide |
|
||||||
|
|
||||||
Note `link`'s entry: **a protocol bump can require a store migration**, because the sidecar
|
**A protocol bump can also require a store migration**, because the sidecar persists what it
|
||||||
persists what it forwards. That is not automatic, and version 4 was the first bump that
|
forwards. That is not automatic — version 4 needed one and version 5 did not, because
|
||||||
needed one.
|
version 5 only widened frames the store already keeps whole.
|
||||||
|
|
||||||
## This is not the module API version
|
## This is not the module API version
|
||||||
|
|
||||||
@@ -92,8 +93,10 @@ What is worth inheriting is the **shape**:
|
|||||||
|
|
||||||
## Canonical documents
|
## Canonical documents
|
||||||
|
|
||||||
|
[`link/v5.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v5.md)
|
||||||
|
is the current protocol's record, including its cross-repository obligations, and
|
||||||
[`link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md)
|
[`link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md)
|
||||||
is the protocol-4 record, including its cross-repository obligations;
|
the one before it;
|
||||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||||
§7 is the wire protocol, and
|
§7 is the wire protocol, and
|
||||||
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
|
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
|
||||||
|
|||||||
@@ -114,9 +114,10 @@ lifecycle](/docs/modules/module-lifecycle/#failure-is-contained-by-construction)
|
|||||||
|
|
||||||
### Secrets are encrypted at rest
|
### Secrets are encrypted at rest
|
||||||
|
|
||||||
OAuth client secrets, the sidecar token and the Gmail refresh token are AES-256-GCM
|
OAuth client secrets, the sidecar token, the Discord bot token and the mail transport's
|
||||||
encrypted, keyed by `SECRET_ENC_KEY`. **The sidecar token is write-only in the API** — it is
|
credentials are AES-256-GCM encrypted, keyed by `SECRET_ENC_KEY`. **The sidecar token and the
|
||||||
never returned to any client.
|
mail credentials are write-only in the API** — neither is ever returned to any client; the
|
||||||
|
email panel reports only that a password is *set*.
|
||||||
|
|
||||||
<Aside type="caution" title="Rotating that key orphans every stored secret">
|
<Aside type="caution" title="Rotating that key orphans every stored secret">
|
||||||
Nothing re-encrypts. What was stored under the old key can no longer be read, and every
|
Nothing re-encrypts. What was stored under the old key can no longer be read, and every
|
||||||
|
|||||||
@@ -139,9 +139,9 @@ const community = {
|
|||||||
{
|
{
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
detail:
|
detail:
|
||||||
'Web, push and email, chosen per stream by each person rather than per person by ' +
|
'On the site, by push and by email, chosen per notification by each person rather ' +
|
||||||
'you. Push arrives by default and can be switched off; email only ever arrives if ' +
|
'than per person by you. The on-site inbox arrives by default and can be switched ' +
|
||||||
'it was asked for.',
|
'off; push and email only ever arrive if they were asked for.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Wiki',
|
label: 'Wiki',
|
||||||
|
|||||||
@@ -285,9 +285,10 @@ export const collected = [
|
|||||||
title: 'Everything you read and post in the app',
|
title: 'Everything you read and post in the app',
|
||||||
body:
|
body:
|
||||||
'Forum posts, Team activity, character and shard information, notification ' +
|
'Forum posts, Team activity, character and shard information, notification ' +
|
||||||
'preferences: all of it is a live read or write against the deployment. Nothing is ' +
|
'preferences: all of it is a live read or write against the deployment. Apart from ' +
|
||||||
'cached for offline use and nothing is duplicated anywhere else — the app with no ' +
|
'the notification snapshot described in the next entry, nothing is cached for ' +
|
||||||
'signal is an app with no content, which is a limitation and also an accurate ' +
|
'offline use and nothing is duplicated anywhere else — the app with no signal is ' +
|
||||||
|
'an app with almost no content, which is a limitation and also an accurate ' +
|
||||||
'description of where the data lives.',
|
'description of where the data lives.',
|
||||||
retention: {
|
retention: {
|
||||||
summary: 'Held by the deployment, under its operator’s policy',
|
summary: 'Held by the deployment, under its operator’s policy',
|
||||||
@@ -304,6 +305,40 @@ export const collected = [
|
|||||||
'access and no way to obtain one.',
|
'access and no way to obtain one.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'app-inbox-cache',
|
||||||
|
scope: 'app',
|
||||||
|
title: 'A snapshot of your notifications, so the inbox opens without a signal',
|
||||||
|
body:
|
||||||
|
'The app keeps the most recent notifications it has already fetched — at most ' +
|
||||||
|
'thirty, and only the first page — on the device, so opening the inbox shows you ' +
|
||||||
|
'what you had rather than a spinner. It is a copy of what the deployment already ' +
|
||||||
|
'sent you and it is refreshed from there; nothing is written here that was not ' +
|
||||||
|
'read from your own account. It is scoped to the account that fetched it, so a ' +
|
||||||
|
'second person signing in on the same phone is never shown the first one’s ' +
|
||||||
|
'messages.',
|
||||||
|
retention: {
|
||||||
|
summary: 'Until you sign out, or the thirty are pushed out by newer ones',
|
||||||
|
detail:
|
||||||
|
'Signing out deletes the snapshot outright. It lives in the app’s ordinary ' +
|
||||||
|
'preference store rather than the encrypted one — sign-in tokens are the thing ' +
|
||||||
|
'that store is for — which is worth stating plainly: on a device where someone ' +
|
||||||
|
'has root, these are readable, and they are notification bodies rather than ' +
|
||||||
|
'credentials.',
|
||||||
|
},
|
||||||
|
source: 'core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt',
|
||||||
|
play: {
|
||||||
|
category: 'Messages',
|
||||||
|
type: 'Other in-app messages',
|
||||||
|
collected: false,
|
||||||
|
shared: false,
|
||||||
|
answer: 'Not collected by us. Stored on the device only.',
|
||||||
|
because:
|
||||||
|
'The snapshot is written on the phone from data the deployment had already ' +
|
||||||
|
'delivered. It is not uploaded anywhere, and no server we operate is on either ' +
|
||||||
|
'end of it.',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'app-no-analytics',
|
id: 'app-no-analytics',
|
||||||
scope: 'app',
|
scope: 'app',
|
||||||
@@ -401,6 +436,34 @@ export const collected = [
|
|||||||
},
|
},
|
||||||
source: 'website server/db/schema.sql — team_forum_*, mod_actions, content_reports',
|
source: 'website server/db/schema.sql — team_forum_*, mod_actions, content_reports',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'deploy-engagement',
|
||||||
|
scope: 'deployment',
|
||||||
|
title: 'Notifications, and the record of what was sent',
|
||||||
|
body:
|
||||||
|
'An operator can have the site notify people about things that happen on it — on ' +
|
||||||
|
'the site, by email, by push — so an address is now used for more than getting ' +
|
||||||
|
'into an account. Each member chooses this per notification and per channel, and ' +
|
||||||
|
'email and push are both off until they ask for them. Alongside that the site ' +
|
||||||
|
'keeps a delivery log: what fired, which account, which channel, whether it ' +
|
||||||
|
'arrived, and a one-way hash of the address rather than the address. Addresses ' +
|
||||||
|
'that bounce or are reported as spam go on a suppression list, which stores the ' +
|
||||||
|
'same hash plus a masked form (`d***@example.com`, never the local part) so an ' +
|
||||||
|
'operator can see what was suppressed without the list becoming a second address ' +
|
||||||
|
'book.',
|
||||||
|
retention: {
|
||||||
|
summary: 'Kept until the operator removes them; nothing here expires on its own',
|
||||||
|
detail:
|
||||||
|
'Stated plainly because it is the answer people assume the other way round: ' +
|
||||||
|
'the delivery log, the suppression list and the per-person rate limits have no ' +
|
||||||
|
'retention sweep, so they are as long as the site is old. Deleting an account ' +
|
||||||
|
'detaches its rows from it rather than deleting them — a delivery history stops ' +
|
||||||
|
'naming a person, and a suppressed address stays suppressed.',
|
||||||
|
},
|
||||||
|
source:
|
||||||
|
'website server/db/schema.sql — engagement_sends, engagement_suppressions, ' +
|
||||||
|
'notification_channel_prefs',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'deploy-game-data',
|
id: 'deploy-game-data',
|
||||||
scope: 'deployment',
|
scope: 'deployment',
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const legal = {
|
|||||||
* from git: a build timestamp would move on every rebuild and tell a reader nothing,
|
* from git: a build timestamp would move on every rebuild and tell a reader nothing,
|
||||||
* and a commit date would move when a stylesheet changed.
|
* and a commit date would move when a stylesheet changed.
|
||||||
*/
|
*/
|
||||||
lastUpdated: '2026-08-24',
|
lastUpdated: '2026-09-01',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31).
|
* The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31).
|
||||||
|
|||||||
@@ -12,23 +12,23 @@
|
|||||||
"wrong protocol number in the first place."
|
"wrong protocol number in the first place."
|
||||||
],
|
],
|
||||||
|
|
||||||
"verifiedOn": "2026-08-19",
|
"verifiedOn": "2026-09-01",
|
||||||
|
|
||||||
"protocol": 4,
|
"protocol": 5,
|
||||||
|
|
||||||
"moduleApi": "1.6.0",
|
"moduleApi": "1.9.0",
|
||||||
|
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"tag": "2026.08.19",
|
"tag": "2026.09.01",
|
||||||
"sidecar": "v2.0.0",
|
"sidecar": "v2.1.0",
|
||||||
"overlay": "v1.0.0",
|
"overlay": "v1.1.0",
|
||||||
"servuoMin": "57.4"
|
"servuoMin": "57.4"
|
||||||
},
|
},
|
||||||
|
|
||||||
"releases": {
|
"releases": {
|
||||||
"link": "v2.0.0",
|
"link": "v2.1.0",
|
||||||
"installer": "v0.1.1",
|
"installer": "v0.1.1",
|
||||||
"Module-uo": "v1.0.2",
|
"Module-uo": "v1.1.0",
|
||||||
"Android-app": "v0.5.0"
|
"Android-app": "v0.5.0"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user