@reflagged/shell 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,6 +123,106 @@ addresses without a platform account are skipped. The token comes from the
123
123
  module client of the platform may request. Errors throw `NotifyError` with
124
124
  the HTTP `status` and the base's error `code`.
125
125
 
126
+ #### Rich mails (`blocks`, since 1.9.0)
127
+
128
+ `title`, `body` and one button are enough for a short note. For anything with
129
+ structure — a status, facts, a table, a transcript — send `blocks`. A module
130
+ chooses the building blocks; how they look (colours, fonts, logo, spacing)
131
+ comes from the tenant's branding, rendered by the base with React Email. There
132
+ is no HTML and no style property, so every module's mail looks like it
133
+ belongs to the tenant and renders in Outlook, Gmail and Apple Mail alike.
134
+
135
+ ```ts
136
+ import { createNotifier, type Block } from '@reflagged/shell/notifications/notifier'
137
+
138
+ const blocks: Block[] = [
139
+ { type: 'callout', tone: 'warning', title: 'Rückruf nötig', text: 'Weitergabe zugesagt' },
140
+ {
141
+ type: 'facts',
142
+ items: [
143
+ { label: 'Anliegen', value: 'Angebot für einen Onlineshop' },
144
+ { label: 'Rückruf an', value: [{ text: '0721 952 795 78', href: 'tel:+4972195279578' }] },
145
+ { label: 'Dauer', value: '1:05 Min.' },
146
+ ],
147
+ },
148
+ {
149
+ type: 'section',
150
+ title: 'Gesprächsverlauf',
151
+ tone: 'outlined',
152
+ blocks: [
153
+ {
154
+ type: 'conversation',
155
+ messages: [
156
+ { speaker: 'Agent', side: 'end', text: 'Hallo, hier ist die digitale Assistenz.' },
157
+ { speaker: 'Anrufer', text: 'Ich möchte bitte zurückgerufen werden.' },
158
+ ],
159
+ },
160
+ ],
161
+ },
162
+ ]
163
+
164
+ await createNotifier().notify({
165
+ idempotencyKey: `call-${call.id}-callback`,
166
+ topic: 'voice.call.callback',
167
+ category: 'transactional',
168
+ title: 'Rückrufwunsch – Angebot Onlineshop', // subject and heading
169
+ body: 'Angebot für einen Onlineshop', // short text for the inbox list
170
+ blocks,
171
+ actionUrl: `https://voice.acme.rfl.gd/calls/${call.id}`,
172
+ actionLabel: 'Anruf öffnen', // rendered as the last button
173
+ recipients: [{ role: 'org:admins' }],
174
+ })
175
+ ```
176
+
177
+ | Block | Fields | Use for |
178
+ |---|---|---|
179
+ | `heading` | `text`, `level?` 1–3 | sub-headings inside the mail |
180
+ | `text` | `text`, `size?` sm/md/lg, `muted?`, `align?` | paragraphs; line breaks are kept |
181
+ | `button` | `label`, `url`, `variant?` primary/secondary, `align?` | links as buttons (`tel:`/`mailto:` too) |
182
+ | `image` | `src`, `alt`, `width?`, `href?`, `align?` | charts, screenshots — https on the booking's or tenant's domain |
183
+ | `callout` | `tone` neutral/info/success/warning/danger, `title?`, `text?` | status banners; tone colours are fixed, not branded |
184
+ | `facts` | `items[{ label, value }]` | label/value pairs |
185
+ | `list` | `items[]`, `ordered?` | bullet or numbered lists |
186
+ | `table` | `columns[{ label, align? }]`, `rows[][]` | tabular data, up to 8 columns / 200 rows |
187
+ | `quote` | `text`, `cite?` | quoted statements |
188
+ | `conversation` | `messages[{ speaker, text, side?, time? }]` | transcripts, chats; `side: 'end'` = the module's own side |
189
+ | `code` | `code`, `language?` | logs, snippets (monospace) |
190
+ | `divider`, `spacer` | `size?` | separation |
191
+ | `section` | `blocks`, `title?`, `tone?` plain/muted/outlined | grouping |
192
+ | `row` | `columns[{ blocks, width? }]` (2–4) | side-by-side content, e.g. key figures |
193
+
194
+ Every `text`/`value`/cell is a `RichText`: a plain string, or spans for
195
+ emphasis and links — `[{ text: 'fett', bold: true }, { text: ' und ' },
196
+ { text: 'Link', href: 'https://…' }]`. Markdown is **not** interpreted.
197
+
198
+ Rules the base enforces (400 with the failing path otherwise): only the
199
+ blocks above, no extra properties, links https/`tel:`/`mailto:`, images from
200
+ the booking's or tenant's domains, at most 200 top-level blocks, 100 kB, and
201
+ `section`/`row` nested three levels deep. The base also writes a plain-text
202
+ part from the blocks and shows the full mail in the platform inbox. Without
203
+ `blocks`, `body` is rendered as paragraphs as before. Types:
204
+ `@reflagged/shell/notifications/blocks`.
205
+
206
+ #### Email: modules do not send mail themselves
207
+
208
+ Every mail a module sends to a person goes through `notify()`. A module
209
+ configures **no SMTP server, no mail adapter and no mail credentials** — no
210
+ `@payloadcms/email-*`, no `nodemailer`, no `SMTP_*` variables in the manifest
211
+ or compose file. The base owns the sender per tenant (own SMTP server,
212
+ Microsoft 365 or the platform's `app@rfl.gd` with SPF/DKIM), the branding,
213
+ the user's preferences, unsubscribe links and the delivery log.
214
+
215
+ - `category: 'transactional'` for mails that must arrive (cannot be opted
216
+ out of), `'activity'` for everything else.
217
+ - Only people with a platform account in the booking's organization can be
218
+ reached. Mail to outsiders (a tenant's own customers, newsletters) is not
219
+ supported — raise it with the platform team instead of adding a transport.
220
+ - **Passwords are not a module concern.** Users sign in through the
221
+ platform's SSO; password reset, invitations and 2FA live in the base. The
222
+ local Payload login is a bootstrap fallback for the first admin only —
223
+ never hand out local module credentials to customers, because the module
224
+ cannot send them a reset mail.
225
+
126
226
  The bell shows the unread count (from `/api/shell-info`) and links to the
127
227
  central inbox:
128
228
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reflagged/shell",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Shared app shell for Reflagged services — OIDC auth, SSO, app switcher",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -30,6 +30,7 @@
30
30
  "./package.json": "./package.json",
31
31
  "./components/ServiceIcon": "./src/components/ServiceIcon.tsx",
32
32
  "./notifications/notifier": "./src/lib/notifications/notifier.ts",
33
+ "./notifications/blocks": "./src/lib/notifications/blocks.ts",
33
34
  "./components/NotificationBell": "./src/components/NotificationBell.tsx",
34
35
  "./components/ImpersonationCapsule": "./src/components/ImpersonationCapsule.tsx"
35
36
  },
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Content blocks for platform mails (`notify({ blocks })`).
3
+ *
4
+ * A module describes WHAT a mail says — a status, facts, a conversation, a
5
+ * table — and the base decides HOW it looks: colours, fonts, logo and layout
6
+ * come from the tenant's branding. There is no HTML and no style property;
7
+ * text is always literal (`**x**` stays `**x**`), emphasis and links go into
8
+ * spans.
9
+ *
10
+ * Validated by the base (rflgd-base `src/lib/notifications/blocks/schema.ts`);
11
+ * an invalid block answers 400 with the path of the offending field.
12
+ *
13
+ * Limits: 200 top-level blocks, 100 kB as JSON, `section`/`row` nested at most
14
+ * three levels. Links: https, `tel:` or `mailto:`. Images: https on a domain of
15
+ * the booking or the tenant (anything else would be a tracking pixel).
16
+ */
17
+
18
+ /** A run of text with optional emphasis or a link. */
19
+ export type Span = {
20
+ text: string
21
+ bold?: boolean
22
+ italic?: boolean
23
+ strike?: boolean
24
+ code?: boolean
25
+ /** https://…, tel:+49…, mailto:… */
26
+ href?: string
27
+ }
28
+
29
+ /** Plain text, or spans for emphasis and links. Line breaks are kept. */
30
+ export type RichText = string | Span[]
31
+
32
+ export type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
33
+ export type Align = 'left' | 'center' | 'right'
34
+
35
+ export type HeadingBlock = { type: 'heading'; text: RichText; level?: 1 | 2 | 3 }
36
+ export type TextBlock = {
37
+ type: 'text'
38
+ text: RichText
39
+ size?: 'sm' | 'md' | 'lg'
40
+ muted?: boolean
41
+ align?: Align
42
+ }
43
+ export type ButtonBlock = {
44
+ type: 'button'
45
+ label: string
46
+ url: string
47
+ variant?: 'primary' | 'secondary'
48
+ align?: Align
49
+ }
50
+ export type ImageBlock = {
51
+ type: 'image'
52
+ /** https on a domain of the booking or the tenant. PNG/JPEG/GIF render everywhere. */
53
+ src: string
54
+ alt: string
55
+ /** CSS pixels, 16–600; scales down on narrow screens. */
56
+ width?: number
57
+ href?: string
58
+ align?: Align
59
+ }
60
+ export type DividerBlock = { type: 'divider' }
61
+ export type SpacerBlock = { type: 'spacer'; size?: 'sm' | 'md' | 'lg' }
62
+ /** Status banner: "Rückruf nötig", "Freigabe abgelehnt", … */
63
+ export type CalloutBlock = { type: 'callout'; tone: Tone; title?: string; text?: RichText }
64
+ /** Label/value pairs, e.g. caller, number, duration. */
65
+ export type FactsBlock = { type: 'facts'; items: { label: string; value: RichText }[] }
66
+ export type ListBlock = { type: 'list'; items: RichText[]; ordered?: boolean }
67
+ export type TableBlock = {
68
+ type: 'table'
69
+ columns: { label: string; align?: Align }[]
70
+ rows: RichText[][]
71
+ }
72
+ export type QuoteBlock = { type: 'quote'; text: RichText; cite?: string }
73
+ export type ConversationBlock = {
74
+ type: 'conversation'
75
+ messages: {
76
+ speaker: string
77
+ text: RichText
78
+ /** `end` marks the module's own side (agent, bot, colleague). */
79
+ side?: 'start' | 'end'
80
+ time?: string
81
+ }[]
82
+ }
83
+ export type CodeBlock = { type: 'code'; code: string; language?: string }
84
+ /** Groups blocks under an optional title, plain, on a muted ground or outlined. */
85
+ export type SectionBlock = {
86
+ type: 'section'
87
+ title?: string
88
+ tone?: 'plain' | 'muted' | 'outlined'
89
+ blocks: Block[]
90
+ }
91
+ /** 2–4 columns side by side; `width` in percent. */
92
+ export type RowBlock = { type: 'row'; columns: { width?: number; blocks: Block[] }[] }
93
+
94
+ export type Block =
95
+ | HeadingBlock
96
+ | TextBlock
97
+ | ButtonBlock
98
+ | ImageBlock
99
+ | DividerBlock
100
+ | SpacerBlock
101
+ | CalloutBlock
102
+ | FactsBlock
103
+ | ListBlock
104
+ | TableBlock
105
+ | QuoteBlock
106
+ | ConversationBlock
107
+ | CodeBlock
108
+ | SectionBlock
109
+ | RowBlock
@@ -16,6 +16,10 @@
16
16
  * Modules send data, never HTML; the base renders and brands every mail.
17
17
  */
18
18
 
19
+ import type { Block } from './blocks'
20
+
21
+ export type { Block } from './blocks'
22
+
19
23
  export type NotificationRecipient =
20
24
  | { userId: number }
21
25
  | { email: string }
@@ -32,6 +36,11 @@ export type NotifyInput = {
32
36
  actionUrl?: string
33
37
  actionLabel?: string
34
38
  data?: Record<string, unknown>
39
+ /**
40
+ * Structured mail content (status, facts, tables, conversations, …). The
41
+ * base renders it in the tenant's branding; `body` stays the short inbox text.
42
+ */
43
+ blocks?: Block[]
35
44
  recipients: NotificationRecipient[]
36
45
  }
37
46