@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.
|
|
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
|
|