@reflagged/shell 1.8.1 → 1.9.1

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,107 @@ 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?` | actions — https on the booking's or tenant's domain (like `actionUrl`), or `tel:`/`mailto:` |
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 and
200
+ button links only on the booking's or tenant's domains (inline span links may
201
+ point to any https page, e.g. a source), at most 200 top-level blocks, 100 kB, and
202
+ `section`/`row` nested three levels deep. The base also writes a plain-text
203
+ part from the blocks and shows the full mail in the platform inbox. Without
204
+ `blocks`, `body` is rendered as paragraphs as before. Types:
205
+ `@reflagged/shell/notifications/blocks`.
206
+
207
+ #### Email: modules do not send mail themselves
208
+
209
+ Every mail a module sends to a person goes through `notify()`. A module
210
+ configures **no SMTP server, no mail adapter and no mail credentials** — no
211
+ `@payloadcms/email-*`, no `nodemailer`, no `SMTP_*` variables in the manifest
212
+ or compose file. The base owns the sender per tenant (own SMTP server,
213
+ Microsoft 365 or the platform's `app@rfl.gd` with SPF/DKIM), the branding,
214
+ the user's preferences, unsubscribe links and the delivery log.
215
+
216
+ - `category: 'transactional'` for mails that must arrive (cannot be opted
217
+ out of), `'activity'` for everything else.
218
+ - Only people with a platform account in the booking's organization can be
219
+ reached. Mail to outsiders (a tenant's own customers, newsletters) is not
220
+ supported — raise it with the platform team instead of adding a transport.
221
+ - **Passwords are not a module concern.** Users sign in through the
222
+ platform's SSO; password reset, invitations and 2FA live in the base. The
223
+ local Payload login is a bootstrap fallback for the first admin only —
224
+ never hand out local module credentials to customers, because the module
225
+ cannot send them a reset mail.
226
+
126
227
  The bell shows the unread count (from `/api/shell-info`) and links to the
127
228
  central inbox:
128
229
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reflagged/shell",
3
- "version": "1.8.1",
3
+ "version": "1.9.1",
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
  },
@@ -7,6 +7,7 @@ import {
7
7
  Brain,
8
8
  Contact,
9
9
  Factory,
10
+ Gauge,
10
11
  ListMusic,
11
12
  MailPlus,
12
13
  MessageSquareText,
@@ -41,6 +42,7 @@ const ICONS: Record<string, LucideIcon> = {
41
42
  Brain,
42
43
  Contact,
43
44
  Factory,
45
+ Gauge,
44
46
  ListMusic,
45
47
  MailPlus,
46
48
  MessageSquareText,
@@ -0,0 +1,111 @@
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 and button links:
15
+ * https on a domain of the booking or the tenant (a foreign image is a tracking
16
+ * pixel, a foreign button a phishing lure); inline span links may go anywhere.
17
+ */
18
+
19
+ /** A run of text with optional emphasis or a link. */
20
+ export type Span = {
21
+ text: string
22
+ bold?: boolean
23
+ italic?: boolean
24
+ strike?: boolean
25
+ code?: boolean
26
+ /** https://…, tel:+49…, mailto:… */
27
+ href?: string
28
+ }
29
+
30
+ /** Plain text, or spans for emphasis and links. Line breaks are kept. */
31
+ export type RichText = string | Span[]
32
+
33
+ export type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
34
+ export type Align = 'left' | 'center' | 'right'
35
+
36
+ export type HeadingBlock = { type: 'heading'; text: RichText; level?: 1 | 2 | 3 }
37
+ export type TextBlock = {
38
+ type: 'text'
39
+ text: RichText
40
+ size?: 'sm' | 'md' | 'lg'
41
+ muted?: boolean
42
+ align?: Align
43
+ }
44
+ export type ButtonBlock = {
45
+ type: 'button'
46
+ label: string
47
+ /** https on a domain of the booking or the tenant, or tel:/mailto:. */
48
+ url: string
49
+ variant?: 'primary' | 'secondary'
50
+ align?: Align
51
+ }
52
+ export type ImageBlock = {
53
+ type: 'image'
54
+ /** https on a domain of the booking or the tenant. PNG/JPEG/GIF render everywhere. */
55
+ src: string
56
+ alt: string
57
+ /** CSS pixels, 16–600; scales down on narrow screens. */
58
+ width?: number
59
+ href?: string
60
+ align?: Align
61
+ }
62
+ export type DividerBlock = { type: 'divider' }
63
+ export type SpacerBlock = { type: 'spacer'; size?: 'sm' | 'md' | 'lg' }
64
+ /** Status banner: "Rückruf nötig", "Freigabe abgelehnt", … */
65
+ export type CalloutBlock = { type: 'callout'; tone: Tone; title?: string; text?: RichText }
66
+ /** Label/value pairs, e.g. caller, number, duration. */
67
+ export type FactsBlock = { type: 'facts'; items: { label: string; value: RichText }[] }
68
+ export type ListBlock = { type: 'list'; items: RichText[]; ordered?: boolean }
69
+ export type TableBlock = {
70
+ type: 'table'
71
+ columns: { label: string; align?: Align }[]
72
+ rows: RichText[][]
73
+ }
74
+ export type QuoteBlock = { type: 'quote'; text: RichText; cite?: string }
75
+ export type ConversationBlock = {
76
+ type: 'conversation'
77
+ messages: {
78
+ speaker: string
79
+ text: RichText
80
+ /** `end` marks the module's own side (agent, bot, colleague). */
81
+ side?: 'start' | 'end'
82
+ time?: string
83
+ }[]
84
+ }
85
+ export type CodeBlock = { type: 'code'; code: string; language?: string }
86
+ /** Groups blocks under an optional title, plain, on a muted ground or outlined. */
87
+ export type SectionBlock = {
88
+ type: 'section'
89
+ title?: string
90
+ tone?: 'plain' | 'muted' | 'outlined'
91
+ blocks: Block[]
92
+ }
93
+ /** 2–4 columns side by side; `width` in percent. */
94
+ export type RowBlock = { type: 'row'; columns: { width?: number; blocks: Block[] }[] }
95
+
96
+ export type Block =
97
+ | HeadingBlock
98
+ | TextBlock
99
+ | ButtonBlock
100
+ | ImageBlock
101
+ | DividerBlock
102
+ | SpacerBlock
103
+ | CalloutBlock
104
+ | FactsBlock
105
+ | ListBlock
106
+ | TableBlock
107
+ | QuoteBlock
108
+ | ConversationBlock
109
+ | CodeBlock
110
+ | SectionBlock
111
+ | 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