@broberg/mail-templates 0.0.0-stage → 0.2.2

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.
@@ -0,0 +1,281 @@
1
+ import { z } from 'zod';
2
+
3
+ /** Escape text for HTML. Covers quotes too, so the same function is safe in an attribute. */
4
+ declare function escapeHtml(s: string): string;
5
+ /** Escape an attribute value. Same job as escapeHtml here; named so a call site says which it means. */
6
+ declare function escapeAttr(s: string): string;
7
+ /** A body BLOCK. The template supplies structure; the shell supplies markup.
8
+ *
9
+ * vn-leker, reviewing their own v1: a body field that takes `<p class style>`
10
+ * works while one person writes both halves — a foreign filler either loses all
11
+ * formatting (plain text) or breaks the layout (HTML). */
12
+ declare const MailBlock: z.ZodDiscriminatedUnion<[z.ZodObject<{
13
+ type: z.ZodLiteral<"paragraph">;
14
+ text: z.ZodString;
15
+ }, z.core.$strip>, z.ZodObject<{
16
+ type: z.ZodLiteral<"heading">;
17
+ text: z.ZodString;
18
+ }, z.core.$strip>, z.ZodObject<{
19
+ type: z.ZodLiteral<"note">;
20
+ text: z.ZodString;
21
+ }, z.core.$strip>, z.ZodObject<{
22
+ type: z.ZodLiteral<"button">;
23
+ text: z.ZodString;
24
+ url: z.ZodString;
25
+ }, z.core.$strip>, z.ZodObject<{
26
+ type: z.ZodLiteral<"divider">;
27
+ }, z.core.$strip>, z.ZodObject<{
28
+ type: z.ZodLiteral<"html">;
29
+ text: z.ZodString;
30
+ }, z.core.$strip>], "type">;
31
+ type MailBlock = z.infer<typeof MailBlock>;
32
+ /** A brand COLOUR — REFUSED, not escaped, when it is not one.
33
+ *
34
+ * Proved against the built shell: an accent colour of
35
+ * `#fff;"></td></tr></table><a href="https://phish.example">Log ind her</a>…`
36
+ * put that anchor into the mail. An escaped non-colour still leaves the building
37
+ * as a broken mail nobody sees an error for; a refusal is an error the project
38
+ * reads while it still has the file open. */
39
+ declare function isBrandColor(v: string): boolean;
40
+ /** A font STACK may hold quotes and commas, never a tag delimiter or a quote that closes its attribute. */
41
+ declare function isFontStack(v: string): boolean;
42
+ /** Ceilings on what a stored override may hold — far above any real template, low
43
+ * enough that a hostile body costs one issue, not one per element. */
44
+ declare const MAX_BLOCKS = 100;
45
+ declare const MAX_FOOTER_LINES = 20;
46
+ declare const MAX_COPY_KEYS = 200;
47
+ /** Brand values, ALL optional — and EMPTY is a value, not an absence (fd-sundhed
48
+ * renders different, correct copy for an empty partner name). */
49
+ declare const MailBrand: z.ZodObject<{
50
+ partner: z.ZodOptional<z.ZodString>;
51
+ senderName: z.ZodOptional<z.ZodString>;
52
+ senderTitle: z.ZodOptional<z.ZodString>;
53
+ senderEmail: z.ZodOptional<z.ZodString>;
54
+ logoUrl: z.ZodOptional<z.ZodString>;
55
+ logoWidth: z.ZodOptional<z.ZodNumber>;
56
+ logoCid: z.ZodOptional<z.ZodString>;
57
+ colorAccent: z.ZodOptional<z.ZodString>;
58
+ colorInk: z.ZodOptional<z.ZodString>;
59
+ fontSans: z.ZodOptional<z.ZodString>;
60
+ fontSerif: z.ZodOptional<z.ZodString>;
61
+ webfontHref: z.ZodOptional<z.ZodString>;
62
+ footerLines: z.ZodOptional<z.ZodArray<z.ZodString>>;
63
+ }, z.core.$loose>;
64
+ type MailBrand = z.infer<typeof MailBrand>;
65
+ /** Greeting, sign-off and other fixed phrases are DATA, per language (vn-leker runs da/no/sv). */
66
+ declare const MailCopy: z.ZodPipe<z.ZodUnknown, z.ZodRecord<z.ZodString, z.ZodString>>;
67
+ type MailCopy = z.infer<typeof MailCopy>;
68
+ /** One template, keyed on its PURPOSE (fd-sport's model: a project has a set, not one file). */
69
+ declare const MailTemplate: z.ZodObject<{
70
+ purpose: z.ZodString;
71
+ subject: z.ZodString;
72
+ blocks: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
73
+ type: z.ZodLiteral<"paragraph">;
74
+ text: z.ZodString;
75
+ }, z.core.$strip>, z.ZodObject<{
76
+ type: z.ZodLiteral<"heading">;
77
+ text: z.ZodString;
78
+ }, z.core.$strip>, z.ZodObject<{
79
+ type: z.ZodLiteral<"note">;
80
+ text: z.ZodString;
81
+ }, z.core.$strip>, z.ZodObject<{
82
+ type: z.ZodLiteral<"button">;
83
+ text: z.ZodString;
84
+ url: z.ZodString;
85
+ }, z.core.$strip>, z.ZodObject<{
86
+ type: z.ZodLiteral<"divider">;
87
+ }, z.core.$strip>, z.ZodObject<{
88
+ type: z.ZodLiteral<"html">;
89
+ text: z.ZodString;
90
+ }, z.core.$strip>], "type">>;
91
+ preheader: z.ZodOptional<z.ZodString>;
92
+ brand: z.ZodOptional<z.ZodObject<{
93
+ partner: z.ZodOptional<z.ZodString>;
94
+ senderName: z.ZodOptional<z.ZodString>;
95
+ senderTitle: z.ZodOptional<z.ZodString>;
96
+ senderEmail: z.ZodOptional<z.ZodString>;
97
+ logoUrl: z.ZodOptional<z.ZodString>;
98
+ logoWidth: z.ZodOptional<z.ZodNumber>;
99
+ logoCid: z.ZodOptional<z.ZodString>;
100
+ colorAccent: z.ZodOptional<z.ZodString>;
101
+ colorInk: z.ZodOptional<z.ZodString>;
102
+ fontSans: z.ZodOptional<z.ZodString>;
103
+ fontSerif: z.ZodOptional<z.ZodString>;
104
+ webfontHref: z.ZodOptional<z.ZodString>;
105
+ footerLines: z.ZodOptional<z.ZodArray<z.ZodString>>;
106
+ }, z.core.$loose>>;
107
+ copy: z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodRecord<z.ZodString, z.ZodString>>>;
108
+ }, z.core.$strip>;
109
+ type MailTemplate = z.infer<typeof MailTemplate>;
110
+ /** What a tenant stores to change a coded default. Every field optional; `purpose` is the key, not a field. */
111
+ declare const MailTemplateOverride: z.ZodObject<{
112
+ subject: z.ZodOptional<z.ZodString>;
113
+ blocks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
114
+ type: z.ZodLiteral<"paragraph">;
115
+ text: z.ZodString;
116
+ }, z.core.$strip>, z.ZodObject<{
117
+ type: z.ZodLiteral<"heading">;
118
+ text: z.ZodString;
119
+ }, z.core.$strip>, z.ZodObject<{
120
+ type: z.ZodLiteral<"note">;
121
+ text: z.ZodString;
122
+ }, z.core.$strip>, z.ZodObject<{
123
+ type: z.ZodLiteral<"button">;
124
+ text: z.ZodString;
125
+ url: z.ZodString;
126
+ }, z.core.$strip>, z.ZodObject<{
127
+ type: z.ZodLiteral<"divider">;
128
+ }, z.core.$strip>, z.ZodObject<{
129
+ type: z.ZodLiteral<"html">;
130
+ text: z.ZodString;
131
+ }, z.core.$strip>], "type">>>;
132
+ preheader: z.ZodOptional<z.ZodString>;
133
+ brand: z.ZodOptional<z.ZodObject<{
134
+ partner: z.ZodOptional<z.ZodString>;
135
+ senderName: z.ZodOptional<z.ZodString>;
136
+ senderTitle: z.ZodOptional<z.ZodString>;
137
+ senderEmail: z.ZodOptional<z.ZodString>;
138
+ logoUrl: z.ZodOptional<z.ZodString>;
139
+ logoWidth: z.ZodOptional<z.ZodNumber>;
140
+ logoCid: z.ZodOptional<z.ZodString>;
141
+ colorAccent: z.ZodOptional<z.ZodString>;
142
+ colorInk: z.ZodOptional<z.ZodString>;
143
+ fontSans: z.ZodOptional<z.ZodString>;
144
+ fontSerif: z.ZodOptional<z.ZodString>;
145
+ webfontHref: z.ZodOptional<z.ZodString>;
146
+ footerLines: z.ZodOptional<z.ZodArray<z.ZodString>>;
147
+ }, z.core.$loose>>;
148
+ copy: z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodRecord<z.ZodString, z.ZodString>>>;
149
+ }, z.core.$strip>;
150
+ type MailTemplateOverride = z.infer<typeof MailTemplateOverride>;
151
+ /** The class vocabulary the shell styles — SMALL AND CLOSED. fd-sundhed's dark-mode
152
+ * rules target classes IN the content, so the hooks have to be agreed names. */
153
+ declare const MAIL_CLASSES: {
154
+ readonly bg: "cm-bg";
155
+ readonly card: "cm-card";
156
+ readonly p: "cm-p";
157
+ readonly sm: "cm-sm";
158
+ readonly btn: "cm-btn";
159
+ readonly box: "cm-box";
160
+ };
161
+ /** The preheader, DERIVED when not given: the first paragraph or heading, cut on a
162
+ * word. Omitted, the inbox shows whatever words fall first (vn-leker #6). */
163
+ declare function derivePreheader(blocks: MailBlock[], given?: string): string;
164
+ /** A link in a mail must be ABSOLUTE — `/da/shop/bog` has no page to resolve
165
+ * against (sanne's customer). Returns the reason so a refusal can NAME the link.
166
+ *
167
+ * `filled: true` at render time: a url that is EXACTLY one `{{token}}` is accepted
168
+ * when a template is authored (it must not have to know the host's routes) and
169
+ * refused once filling has happened, because a surviving token is a dead button. */
170
+ declare function checkUrl(url: string, opts?: {
171
+ filled?: boolean;
172
+ }): {
173
+ ok: true;
174
+ } | {
175
+ ok: false;
176
+ reason: string;
177
+ };
178
+ /** The @broberg/mail-core primitives the body needs, INJECTED.
179
+ *
180
+ * mail-core reads files at module scope (node:fs, for the logo attachment), so
181
+ * importing it here would break every browser bundle that renders a preview —
182
+ * cardmem measured exactly that on `vite build`. The host passes mail-core's own
183
+ * functions; there is no fallback, because a fallback IS a second implementation. */
184
+ interface MailPrimitives {
185
+ heading: (text: string, opts: {
186
+ accentColor?: string;
187
+ fontSerif?: string;
188
+ }) => string;
189
+ paragraph: (text: string) => string;
190
+ noteBox: (html: string, opts: {
191
+ accentColor: string;
192
+ }) => string;
193
+ cta: (href: string, label: string, opts: {
194
+ accentColor: string;
195
+ }) => string;
196
+ }
197
+ /** Blocks → body html, styled inline by mail-core and carrying the class hooks. */
198
+ declare function renderBlocks(blocks: MailBlock[], opts: {
199
+ accentColor: string;
200
+ primitives: MailPrimitives;
201
+ fontSerif?: string;
202
+ }): {
203
+ html: string;
204
+ notes: string[];
205
+ };
206
+ /** Fill `{{name}}` placeholders, escaping each value.
207
+ *
208
+ * A MISSING value stays as the literal placeholder and is named in `missing` —
209
+ * a visible `{{navn}}` is caught; a silently blank sentence is not. An EMPTY value
210
+ * is honoured as empty (`in values`, not truthiness). `rawKeys` names values that
211
+ * are already HTML — opt-in per call, so a caller must say which key it trusts. */
212
+ declare function fillPlaceholders(text: string, values: Record<string, string>, rawKeys?: readonly string[]): {
213
+ text: string;
214
+ missing: string[];
215
+ };
216
+ /** Fill WITHOUT escaping, for a destination that escapes for itself (a subject line,
217
+ * a button url). A separate function, not an `escape: false` flag someone flips on a text field. */
218
+ declare function fillPlaceholdersRaw(text: string, values: Record<string, string>): {
219
+ text: string;
220
+ missing: string[];
221
+ };
222
+ interface RenderedTemplate {
223
+ /** The BODY html — wrap it in @broberg/mail-core's renderShell. */
224
+ html: string;
225
+ /** Plain text, filled without escaping: a mail client never renders a subject as HTML. */
226
+ subject: string;
227
+ /** Explicit or derived — never silently empty. */
228
+ preheader: string;
229
+ /** Placeholders with no value, from body, subject and button urls. */
230
+ missing: string[];
231
+ /** Findings that did not stop the render but a person should see. */
232
+ notes: string[];
233
+ }
234
+ /** The WHOLE render, in the one order that is correct: render blocks, THEN fill.
235
+ *
236
+ * Both steps escape, so the other order escapes twice:
237
+ * fill THEN render → "Sørensen & Søn" → "Sørensen &amp;amp; Søn"
238
+ * render THEN fill → "Sørensen & Søn" → "Sørensen &amp; Søn" ✓
239
+ * The wrong order looks right for every customer without an `&` in their name.
240
+ *
241
+ * ONE exception, for the same reason: a button URL is filled FIRST, unescaped, so
242
+ * it goes through checkUrl (a value resolving to a relative or javascript: link
243
+ * degrades to text) and then the cta primitive's own attribute escaping. */
244
+ declare function renderTemplate(template: Pick<MailTemplate, "subject" | "blocks" | "preheader">, opts: {
245
+ /** Resolved by the caller (the shell needs the same value — two places deciding a brand colour disagree). */
246
+ accentColor: string;
247
+ fontSerif?: string;
248
+ primitives: MailPrimitives;
249
+ /** Value keys that are already HTML. See fillPlaceholders. */
250
+ rawValueKeys?: readonly string[];
251
+ }, values?: Record<string, string>): RenderedTemplate;
252
+ /** The EFFECTIVE template: a stored override over the coded default.
253
+ *
254
+ * subject, blocks and preheader REPLACE; brand and copy MERGE key by key, so a
255
+ * tenant that sets only its accent keeps the default's sender and footer. */
256
+ declare function resolveTemplate(standard: MailTemplate, override?: MailTemplateOverride | null): MailTemplate;
257
+ interface EffectiveTemplate {
258
+ purpose: string;
259
+ effective: MailTemplate;
260
+ /** The coded default — what "reset" goes back to. */
261
+ default: MailTemplate;
262
+ /** True when the tenant's override changes at least one field. */
263
+ isCustom: boolean;
264
+ }
265
+ /** Every coded template with its tenant's override applied — what an editor lists.
266
+ *
267
+ * An override whose purpose has no coded default is NAMED in `unknownOverrides`,
268
+ * not dropped: a template renamed in code would otherwise lose a tenant's edits
269
+ * with nothing to show for it. */
270
+ declare function listEffectiveTemplates(defaults: readonly MailTemplate[], overrides?: Readonly<Record<string, MailTemplateOverride | null | undefined>>): {
271
+ templates: EffectiveTemplate[];
272
+ unknownOverrides: string[];
273
+ };
274
+ /** The canonical content a version's hash is taken over: stable key ORDER, so a
275
+ * moved JSON key is not reported as a change (a hash that cries wolf teaches
276
+ * everyone to ignore it). Extra fields a host adds to its template are included. */
277
+ declare function canonicalContent(t: MailTemplate & Record<string, unknown>): string;
278
+ /** SHA-256 hex over canonical content. Submission and sync check must call THIS — two hashers drift. */
279
+ declare function hashContent(canonical: string): Promise<string>;
280
+
281
+ export { type EffectiveTemplate, MAIL_CLASSES, MAX_BLOCKS, MAX_COPY_KEYS, MAX_FOOTER_LINES, MailBlock, MailBrand, MailCopy, type MailPrimitives, MailTemplate, MailTemplateOverride, type RenderedTemplate, canonicalContent, checkUrl, derivePreheader, escapeAttr, escapeHtml, fillPlaceholders, fillPlaceholdersRaw, hashContent, isBrandColor, isFontStack, listEffectiveTemplates, renderBlocks, renderTemplate, resolveTemplate };
@@ -0,0 +1,281 @@
1
+ import { z } from 'zod';
2
+
3
+ /** Escape text for HTML. Covers quotes too, so the same function is safe in an attribute. */
4
+ declare function escapeHtml(s: string): string;
5
+ /** Escape an attribute value. Same job as escapeHtml here; named so a call site says which it means. */
6
+ declare function escapeAttr(s: string): string;
7
+ /** A body BLOCK. The template supplies structure; the shell supplies markup.
8
+ *
9
+ * vn-leker, reviewing their own v1: a body field that takes `<p class style>`
10
+ * works while one person writes both halves — a foreign filler either loses all
11
+ * formatting (plain text) or breaks the layout (HTML). */
12
+ declare const MailBlock: z.ZodDiscriminatedUnion<[z.ZodObject<{
13
+ type: z.ZodLiteral<"paragraph">;
14
+ text: z.ZodString;
15
+ }, z.core.$strip>, z.ZodObject<{
16
+ type: z.ZodLiteral<"heading">;
17
+ text: z.ZodString;
18
+ }, z.core.$strip>, z.ZodObject<{
19
+ type: z.ZodLiteral<"note">;
20
+ text: z.ZodString;
21
+ }, z.core.$strip>, z.ZodObject<{
22
+ type: z.ZodLiteral<"button">;
23
+ text: z.ZodString;
24
+ url: z.ZodString;
25
+ }, z.core.$strip>, z.ZodObject<{
26
+ type: z.ZodLiteral<"divider">;
27
+ }, z.core.$strip>, z.ZodObject<{
28
+ type: z.ZodLiteral<"html">;
29
+ text: z.ZodString;
30
+ }, z.core.$strip>], "type">;
31
+ type MailBlock = z.infer<typeof MailBlock>;
32
+ /** A brand COLOUR — REFUSED, not escaped, when it is not one.
33
+ *
34
+ * Proved against the built shell: an accent colour of
35
+ * `#fff;"></td></tr></table><a href="https://phish.example">Log ind her</a>…`
36
+ * put that anchor into the mail. An escaped non-colour still leaves the building
37
+ * as a broken mail nobody sees an error for; a refusal is an error the project
38
+ * reads while it still has the file open. */
39
+ declare function isBrandColor(v: string): boolean;
40
+ /** A font STACK may hold quotes and commas, never a tag delimiter or a quote that closes its attribute. */
41
+ declare function isFontStack(v: string): boolean;
42
+ /** Ceilings on what a stored override may hold — far above any real template, low
43
+ * enough that a hostile body costs one issue, not one per element. */
44
+ declare const MAX_BLOCKS = 100;
45
+ declare const MAX_FOOTER_LINES = 20;
46
+ declare const MAX_COPY_KEYS = 200;
47
+ /** Brand values, ALL optional — and EMPTY is a value, not an absence (fd-sundhed
48
+ * renders different, correct copy for an empty partner name). */
49
+ declare const MailBrand: z.ZodObject<{
50
+ partner: z.ZodOptional<z.ZodString>;
51
+ senderName: z.ZodOptional<z.ZodString>;
52
+ senderTitle: z.ZodOptional<z.ZodString>;
53
+ senderEmail: z.ZodOptional<z.ZodString>;
54
+ logoUrl: z.ZodOptional<z.ZodString>;
55
+ logoWidth: z.ZodOptional<z.ZodNumber>;
56
+ logoCid: z.ZodOptional<z.ZodString>;
57
+ colorAccent: z.ZodOptional<z.ZodString>;
58
+ colorInk: z.ZodOptional<z.ZodString>;
59
+ fontSans: z.ZodOptional<z.ZodString>;
60
+ fontSerif: z.ZodOptional<z.ZodString>;
61
+ webfontHref: z.ZodOptional<z.ZodString>;
62
+ footerLines: z.ZodOptional<z.ZodArray<z.ZodString>>;
63
+ }, z.core.$loose>;
64
+ type MailBrand = z.infer<typeof MailBrand>;
65
+ /** Greeting, sign-off and other fixed phrases are DATA, per language (vn-leker runs da/no/sv). */
66
+ declare const MailCopy: z.ZodPipe<z.ZodUnknown, z.ZodRecord<z.ZodString, z.ZodString>>;
67
+ type MailCopy = z.infer<typeof MailCopy>;
68
+ /** One template, keyed on its PURPOSE (fd-sport's model: a project has a set, not one file). */
69
+ declare const MailTemplate: z.ZodObject<{
70
+ purpose: z.ZodString;
71
+ subject: z.ZodString;
72
+ blocks: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
73
+ type: z.ZodLiteral<"paragraph">;
74
+ text: z.ZodString;
75
+ }, z.core.$strip>, z.ZodObject<{
76
+ type: z.ZodLiteral<"heading">;
77
+ text: z.ZodString;
78
+ }, z.core.$strip>, z.ZodObject<{
79
+ type: z.ZodLiteral<"note">;
80
+ text: z.ZodString;
81
+ }, z.core.$strip>, z.ZodObject<{
82
+ type: z.ZodLiteral<"button">;
83
+ text: z.ZodString;
84
+ url: z.ZodString;
85
+ }, z.core.$strip>, z.ZodObject<{
86
+ type: z.ZodLiteral<"divider">;
87
+ }, z.core.$strip>, z.ZodObject<{
88
+ type: z.ZodLiteral<"html">;
89
+ text: z.ZodString;
90
+ }, z.core.$strip>], "type">>;
91
+ preheader: z.ZodOptional<z.ZodString>;
92
+ brand: z.ZodOptional<z.ZodObject<{
93
+ partner: z.ZodOptional<z.ZodString>;
94
+ senderName: z.ZodOptional<z.ZodString>;
95
+ senderTitle: z.ZodOptional<z.ZodString>;
96
+ senderEmail: z.ZodOptional<z.ZodString>;
97
+ logoUrl: z.ZodOptional<z.ZodString>;
98
+ logoWidth: z.ZodOptional<z.ZodNumber>;
99
+ logoCid: z.ZodOptional<z.ZodString>;
100
+ colorAccent: z.ZodOptional<z.ZodString>;
101
+ colorInk: z.ZodOptional<z.ZodString>;
102
+ fontSans: z.ZodOptional<z.ZodString>;
103
+ fontSerif: z.ZodOptional<z.ZodString>;
104
+ webfontHref: z.ZodOptional<z.ZodString>;
105
+ footerLines: z.ZodOptional<z.ZodArray<z.ZodString>>;
106
+ }, z.core.$loose>>;
107
+ copy: z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodRecord<z.ZodString, z.ZodString>>>;
108
+ }, z.core.$strip>;
109
+ type MailTemplate = z.infer<typeof MailTemplate>;
110
+ /** What a tenant stores to change a coded default. Every field optional; `purpose` is the key, not a field. */
111
+ declare const MailTemplateOverride: z.ZodObject<{
112
+ subject: z.ZodOptional<z.ZodString>;
113
+ blocks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
114
+ type: z.ZodLiteral<"paragraph">;
115
+ text: z.ZodString;
116
+ }, z.core.$strip>, z.ZodObject<{
117
+ type: z.ZodLiteral<"heading">;
118
+ text: z.ZodString;
119
+ }, z.core.$strip>, z.ZodObject<{
120
+ type: z.ZodLiteral<"note">;
121
+ text: z.ZodString;
122
+ }, z.core.$strip>, z.ZodObject<{
123
+ type: z.ZodLiteral<"button">;
124
+ text: z.ZodString;
125
+ url: z.ZodString;
126
+ }, z.core.$strip>, z.ZodObject<{
127
+ type: z.ZodLiteral<"divider">;
128
+ }, z.core.$strip>, z.ZodObject<{
129
+ type: z.ZodLiteral<"html">;
130
+ text: z.ZodString;
131
+ }, z.core.$strip>], "type">>>;
132
+ preheader: z.ZodOptional<z.ZodString>;
133
+ brand: z.ZodOptional<z.ZodObject<{
134
+ partner: z.ZodOptional<z.ZodString>;
135
+ senderName: z.ZodOptional<z.ZodString>;
136
+ senderTitle: z.ZodOptional<z.ZodString>;
137
+ senderEmail: z.ZodOptional<z.ZodString>;
138
+ logoUrl: z.ZodOptional<z.ZodString>;
139
+ logoWidth: z.ZodOptional<z.ZodNumber>;
140
+ logoCid: z.ZodOptional<z.ZodString>;
141
+ colorAccent: z.ZodOptional<z.ZodString>;
142
+ colorInk: z.ZodOptional<z.ZodString>;
143
+ fontSans: z.ZodOptional<z.ZodString>;
144
+ fontSerif: z.ZodOptional<z.ZodString>;
145
+ webfontHref: z.ZodOptional<z.ZodString>;
146
+ footerLines: z.ZodOptional<z.ZodArray<z.ZodString>>;
147
+ }, z.core.$loose>>;
148
+ copy: z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodRecord<z.ZodString, z.ZodString>>>;
149
+ }, z.core.$strip>;
150
+ type MailTemplateOverride = z.infer<typeof MailTemplateOverride>;
151
+ /** The class vocabulary the shell styles — SMALL AND CLOSED. fd-sundhed's dark-mode
152
+ * rules target classes IN the content, so the hooks have to be agreed names. */
153
+ declare const MAIL_CLASSES: {
154
+ readonly bg: "cm-bg";
155
+ readonly card: "cm-card";
156
+ readonly p: "cm-p";
157
+ readonly sm: "cm-sm";
158
+ readonly btn: "cm-btn";
159
+ readonly box: "cm-box";
160
+ };
161
+ /** The preheader, DERIVED when not given: the first paragraph or heading, cut on a
162
+ * word. Omitted, the inbox shows whatever words fall first (vn-leker #6). */
163
+ declare function derivePreheader(blocks: MailBlock[], given?: string): string;
164
+ /** A link in a mail must be ABSOLUTE — `/da/shop/bog` has no page to resolve
165
+ * against (sanne's customer). Returns the reason so a refusal can NAME the link.
166
+ *
167
+ * `filled: true` at render time: a url that is EXACTLY one `{{token}}` is accepted
168
+ * when a template is authored (it must not have to know the host's routes) and
169
+ * refused once filling has happened, because a surviving token is a dead button. */
170
+ declare function checkUrl(url: string, opts?: {
171
+ filled?: boolean;
172
+ }): {
173
+ ok: true;
174
+ } | {
175
+ ok: false;
176
+ reason: string;
177
+ };
178
+ /** The @broberg/mail-core primitives the body needs, INJECTED.
179
+ *
180
+ * mail-core reads files at module scope (node:fs, for the logo attachment), so
181
+ * importing it here would break every browser bundle that renders a preview —
182
+ * cardmem measured exactly that on `vite build`. The host passes mail-core's own
183
+ * functions; there is no fallback, because a fallback IS a second implementation. */
184
+ interface MailPrimitives {
185
+ heading: (text: string, opts: {
186
+ accentColor?: string;
187
+ fontSerif?: string;
188
+ }) => string;
189
+ paragraph: (text: string) => string;
190
+ noteBox: (html: string, opts: {
191
+ accentColor: string;
192
+ }) => string;
193
+ cta: (href: string, label: string, opts: {
194
+ accentColor: string;
195
+ }) => string;
196
+ }
197
+ /** Blocks → body html, styled inline by mail-core and carrying the class hooks. */
198
+ declare function renderBlocks(blocks: MailBlock[], opts: {
199
+ accentColor: string;
200
+ primitives: MailPrimitives;
201
+ fontSerif?: string;
202
+ }): {
203
+ html: string;
204
+ notes: string[];
205
+ };
206
+ /** Fill `{{name}}` placeholders, escaping each value.
207
+ *
208
+ * A MISSING value stays as the literal placeholder and is named in `missing` —
209
+ * a visible `{{navn}}` is caught; a silently blank sentence is not. An EMPTY value
210
+ * is honoured as empty (`in values`, not truthiness). `rawKeys` names values that
211
+ * are already HTML — opt-in per call, so a caller must say which key it trusts. */
212
+ declare function fillPlaceholders(text: string, values: Record<string, string>, rawKeys?: readonly string[]): {
213
+ text: string;
214
+ missing: string[];
215
+ };
216
+ /** Fill WITHOUT escaping, for a destination that escapes for itself (a subject line,
217
+ * a button url). A separate function, not an `escape: false` flag someone flips on a text field. */
218
+ declare function fillPlaceholdersRaw(text: string, values: Record<string, string>): {
219
+ text: string;
220
+ missing: string[];
221
+ };
222
+ interface RenderedTemplate {
223
+ /** The BODY html — wrap it in @broberg/mail-core's renderShell. */
224
+ html: string;
225
+ /** Plain text, filled without escaping: a mail client never renders a subject as HTML. */
226
+ subject: string;
227
+ /** Explicit or derived — never silently empty. */
228
+ preheader: string;
229
+ /** Placeholders with no value, from body, subject and button urls. */
230
+ missing: string[];
231
+ /** Findings that did not stop the render but a person should see. */
232
+ notes: string[];
233
+ }
234
+ /** The WHOLE render, in the one order that is correct: render blocks, THEN fill.
235
+ *
236
+ * Both steps escape, so the other order escapes twice:
237
+ * fill THEN render → "Sørensen & Søn" → "Sørensen &amp;amp; Søn"
238
+ * render THEN fill → "Sørensen & Søn" → "Sørensen &amp; Søn" ✓
239
+ * The wrong order looks right for every customer without an `&` in their name.
240
+ *
241
+ * ONE exception, for the same reason: a button URL is filled FIRST, unescaped, so
242
+ * it goes through checkUrl (a value resolving to a relative or javascript: link
243
+ * degrades to text) and then the cta primitive's own attribute escaping. */
244
+ declare function renderTemplate(template: Pick<MailTemplate, "subject" | "blocks" | "preheader">, opts: {
245
+ /** Resolved by the caller (the shell needs the same value — two places deciding a brand colour disagree). */
246
+ accentColor: string;
247
+ fontSerif?: string;
248
+ primitives: MailPrimitives;
249
+ /** Value keys that are already HTML. See fillPlaceholders. */
250
+ rawValueKeys?: readonly string[];
251
+ }, values?: Record<string, string>): RenderedTemplate;
252
+ /** The EFFECTIVE template: a stored override over the coded default.
253
+ *
254
+ * subject, blocks and preheader REPLACE; brand and copy MERGE key by key, so a
255
+ * tenant that sets only its accent keeps the default's sender and footer. */
256
+ declare function resolveTemplate(standard: MailTemplate, override?: MailTemplateOverride | null): MailTemplate;
257
+ interface EffectiveTemplate {
258
+ purpose: string;
259
+ effective: MailTemplate;
260
+ /** The coded default — what "reset" goes back to. */
261
+ default: MailTemplate;
262
+ /** True when the tenant's override changes at least one field. */
263
+ isCustom: boolean;
264
+ }
265
+ /** Every coded template with its tenant's override applied — what an editor lists.
266
+ *
267
+ * An override whose purpose has no coded default is NAMED in `unknownOverrides`,
268
+ * not dropped: a template renamed in code would otherwise lose a tenant's edits
269
+ * with nothing to show for it. */
270
+ declare function listEffectiveTemplates(defaults: readonly MailTemplate[], overrides?: Readonly<Record<string, MailTemplateOverride | null | undefined>>): {
271
+ templates: EffectiveTemplate[];
272
+ unknownOverrides: string[];
273
+ };
274
+ /** The canonical content a version's hash is taken over: stable key ORDER, so a
275
+ * moved JSON key is not reported as a change (a hash that cries wolf teaches
276
+ * everyone to ignore it). Extra fields a host adds to its template are included. */
277
+ declare function canonicalContent(t: MailTemplate & Record<string, unknown>): string;
278
+ /** SHA-256 hex over canonical content. Submission and sync check must call THIS — two hashers drift. */
279
+ declare function hashContent(canonical: string): Promise<string>;
280
+
281
+ export { type EffectiveTemplate, MAIL_CLASSES, MAX_BLOCKS, MAX_COPY_KEYS, MAX_FOOTER_LINES, MailBlock, MailBrand, MailCopy, type MailPrimitives, MailTemplate, MailTemplateOverride, type RenderedTemplate, canonicalContent, checkUrl, derivePreheader, escapeAttr, escapeHtml, fillPlaceholders, fillPlaceholdersRaw, hashContent, isBrandColor, isFontStack, listEffectiveTemplates, renderBlocks, renderTemplate, resolveTemplate };