@dev-crew-berlin/enter-js-utils 0.99.1 → 0.99.3

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.
@@ -1,5 +1,5 @@
1
1
  import Handlebars from 'handlebars';
2
- import { makeGoogleWalletLink, makeQrCodeLink, makeRegistrationLink, makeTrackingLink, makeUnsubscribeLink, makeWalletLink } from './email-links';
2
+ import { makeFileLink, makeGoogleWalletLink, makeQrCodeLink, makeRegistrationLink, makeTrackingLink, makeUnsubscribeLink, makeWalletLink } from './email-links';
3
3
 
4
4
  /**
5
5
  * A from-scratch TS port of the pybars helper set registered in
@@ -389,6 +389,9 @@ function registerLinkHelpers(hb, linkOptions, ctx) {
389
389
  hb.registerHelper('google_wallet_link', function (id) {
390
390
  return safe(makeGoogleWalletLink(linkOptions, id ?? ctx.attendeeId));
391
391
  });
392
+ hb.registerHelper('file', function (path) {
393
+ return safe(makeFileLink(linkOptions, path));
394
+ });
392
395
  hb.registerHelper('qr_code_link', function (...args) {
393
396
  const {
394
397
  explicit
@@ -336,4 +336,8 @@ describe('link helpers', () => {
336
336
  });
337
337
  expect(html).toBe('https://test.frontend.example/home');
338
338
  });
339
+ it('file builds an unsigned /files/{path} link', async () => {
340
+ const html = await renderEn('{{file "attachments/agenda.pdf"}}', {});
341
+ expect(html).toBe('https://test.frontend.example/files/attachments/agenda.pdf');
342
+ });
339
343
  });
@@ -36,6 +36,7 @@ export declare function makeRegistrationLink(options: LinkBuilderOptions, args:
36
36
  }): string;
37
37
  export declare function makeWalletLink(options: LinkBuilderOptions, attendeeId: string): string;
38
38
  export declare function makeGoogleWalletLink(options: LinkBuilderOptions, attendeeId: string): string;
39
+ export declare function makeFileLink(options: LinkBuilderOptions, path: string): string;
39
40
  export declare function makeQrCodeLink(options: LinkBuilderOptions, args: {
40
41
  content: string;
41
42
  size?: number;
@@ -55,6 +55,9 @@ export function makeWalletLink(options, attendeeId) {
55
55
  export function makeGoogleWalletLink(options, attendeeId) {
56
56
  return `https://${options.domain}/google-wallet/${attendeeId}`;
57
57
  }
58
+ export function makeFileLink(options, path) {
59
+ return `https://${options.domain}/files/${path}`;
60
+ }
58
61
  export function makeQrCodeLink(options, args) {
59
62
  const params = new URLSearchParams({
60
63
  content: args.content,
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import jws from 'jws';
3
- import { makeGoogleWalletLink, makeQrCodeLink, makeRegistrationLink, makeTrackingLink, makeUnsubscribeLink, makeWalletLink } from './email-links';
3
+ import { makeFileLink, makeGoogleWalletLink, makeQrCodeLink, makeRegistrationLink, makeTrackingLink, makeUnsubscribeLink, makeWalletLink } from './email-links';
4
4
 
5
5
  /**
6
6
  * Mirrors FrontendConfig's link builders
@@ -277,6 +277,11 @@ describe('makeGoogleWalletLink', () => {
277
277
  expect(makeGoogleWalletLink(options, 'att-1')).toBe('https://example.com/google-wallet/att-1');
278
278
  });
279
279
  });
280
+ describe('makeFileLink', () => {
281
+ it('builds an unsigned /files/{path} link', () => {
282
+ expect(makeFileLink(options, 'attachments/agenda.pdf')).toBe('https://example.com/files/attachments/agenda.pdf');
283
+ });
284
+ });
280
285
  describe('makeQrCodeLink', () => {
281
286
  it('builds a link under /qr/', () => {
282
287
  const link = makeQrCodeLink(options, {
@@ -0,0 +1,38 @@
1
+ import { PartialResolver } from './email-handlebars';
2
+ import { LinkBuilderOptions } from './email-links';
3
+ import { MailTemplateProps } from './mail-template-props';
4
+ import { Attendee, PublicEmailContent } from '../models';
5
+ export type RenderedEmailContent = {
6
+ subject: string;
7
+ toAddress: {
8
+ name: string;
9
+ email: string;
10
+ };
11
+ fromAddress: {
12
+ name: string;
13
+ email: string;
14
+ };
15
+ cc: string[];
16
+ bcc: string[];
17
+ reply: string;
18
+ pdfHtml: string;
19
+ pdfName: string;
20
+ props: MailTemplateProps;
21
+ };
22
+ /**
23
+ * Pure rendering: given an already-fetched attendee + email content, runs
24
+ * every field/block through the Handlebars engine and resolves the props a
25
+ * per-client `emails/{mail_name}.tsx` template needs. No API calls here --
26
+ * fetching (attendee, content, legacy templates, and any per-client extra
27
+ * data like payment info) is entirely the caller's responsibility, since it
28
+ * varies per client and has nothing to do with rendering itself.
29
+ */
30
+ export declare function renderEmailContent(args: {
31
+ content: PublicEmailContent;
32
+ attendee: Attendee;
33
+ mailName: string;
34
+ attendeeId: string;
35
+ mailingId?: string;
36
+ linkOptions: LinkBuilderOptions;
37
+ resolvePartial: PartialResolver;
38
+ }): Promise<RenderedEmailContent>;
@@ -0,0 +1,104 @@
1
+ import { renderHandlebars } from './email-handlebars';
2
+ import { parseAddress, splitAddressList } from './parse-address';
3
+ async function renderValue(value, data, handlebarsOptions) {
4
+ if (value.type === 'text') return value.content;
5
+ return renderHandlebars(value.content, data, handlebarsOptions);
6
+ }
7
+
8
+ /**
9
+ * Pure rendering: given an already-fetched attendee + email content, runs
10
+ * every field/block through the Handlebars engine and resolves the props a
11
+ * per-client `emails/{mail_name}.tsx` template needs. No API calls here --
12
+ * fetching (attendee, content, legacy templates, and any per-client extra
13
+ * data like payment info) is entirely the caller's responsibility, since it
14
+ * varies per client and has nothing to do with rendering itself.
15
+ */
16
+ export async function renderEmailContent(args) {
17
+ const {
18
+ content,
19
+ attendee,
20
+ mailName,
21
+ attendeeId,
22
+ mailingId,
23
+ linkOptions,
24
+ resolvePartial
25
+ } = args;
26
+ const attendeeJson = attendee.toJSON();
27
+ const lang = attendee.language ?? 'en';
28
+ const linkContext = {
29
+ attendeeId,
30
+ emailName: mailName,
31
+ mailingId,
32
+ // the legacy renderer disables tracking only for mailgun accounts with
33
+ // their own link tracking enabled -- that's instance-level email_sender
34
+ // config the frontend doesn't otherwise need, so default to tracking
35
+ // on; a template can still pass `{{link url false}}` to opt out.
36
+ trackingEnabled: true
37
+ };
38
+ const handlebarsData = {
39
+ id: attendeeId,
40
+ mailing_id: mailingId,
41
+ language: lang,
42
+ userdata: attendeeJson.userdata ?? {},
43
+ ...attendeeJson
44
+ };
45
+ const handlebarsOptions = {
46
+ lang,
47
+ languages: [lang, 'en', 'de'].filter((v, i, arr) => arr.indexOf(v) === i),
48
+ linkOptions,
49
+ linkContext,
50
+ resolvePartial
51
+ };
52
+ const [subject, to, toName, from, cc, bcc, reply, pre, pdfHtml] = await Promise.all([renderValue(content.subject, handlebarsData, handlebarsOptions), renderValue(content.to, handlebarsData, handlebarsOptions), renderValue(content.to_name, handlebarsData, handlebarsOptions), renderValue(content.from, handlebarsData, handlebarsOptions), renderValue(content.cc, handlebarsData, handlebarsOptions), renderValue(content.bcc, handlebarsData, handlebarsOptions), renderValue(content.reply, handlebarsData, handlebarsOptions), renderValue(content.pre, handlebarsData, handlebarsOptions), renderValue(content.pdf, handlebarsData, handlebarsOptions)]);
53
+ // mirrors the legacy renderer (app/emails.py): only render the pdf
54
+ // filename if there's actually a pdf body to attach it to.
55
+ const pdfName = pdfHtml ? await renderValue(content.pdfname, handlebarsData, handlebarsOptions) : '';
56
+ const renderedBlocks = await Promise.all([...content.blocks].sort((a, b) => a.sort_order - b.sort_order).map(async block => {
57
+ if (block.value.type === 'text') {
58
+ return {
59
+ key: block.key,
60
+ name: block.name,
61
+ type: 'text',
62
+ content: block.value.content
63
+ };
64
+ }
65
+ return {
66
+ key: block.key,
67
+ name: block.name,
68
+ type: 'html',
69
+ html: await renderHandlebars(block.value.content, handlebarsData, handlebarsOptions)
70
+ };
71
+ }));
72
+ const props = {
73
+ instanceName: linkOptions.instanceName,
74
+ attendee,
75
+ content,
76
+ linkOptions,
77
+ linkContext,
78
+ renderedFields: {
79
+ subject,
80
+ to,
81
+ toName,
82
+ from,
83
+ cc,
84
+ bcc,
85
+ reply,
86
+ pre
87
+ },
88
+ renderedBlocks
89
+ };
90
+ const fromAddress = parseAddress(from);
91
+ const toAddress = parseAddress(to);
92
+ if (toName && !toAddress.name) toAddress.name = toName;
93
+ return {
94
+ subject,
95
+ toAddress,
96
+ fromAddress,
97
+ cc: splitAddressList(cc),
98
+ bcc: splitAddressList(bcc),
99
+ reply,
100
+ pdfHtml,
101
+ pdfName,
102
+ props
103
+ };
104
+ }
@@ -0,0 +1,293 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { renderEmailContent } from './email-render';
3
+ const linkOptions = {
4
+ jwtSecret: 'test-secret',
5
+ instanceName: 'test-instance',
6
+ domain: 'test.example.com'
7
+ };
8
+ const notFoundPartial = vi.fn().mockResolvedValue('Template not found');
9
+ function fakeAttendee(overrides = {}) {
10
+ const json = {
11
+ id: 'att-1',
12
+ language: 'en',
13
+ userdata: {
14
+ vorname: 'Max',
15
+ email: 'max@example.com'
16
+ },
17
+ ...overrides
18
+ };
19
+ return {
20
+ ...json,
21
+ toJSON: () => json
22
+ };
23
+ }
24
+ describe('renderEmailContent', () => {
25
+ it('renders fixed fields and blocks through the Handlebars engine, sorted by sort_order', async () => {
26
+ const result = await renderEmailContent({
27
+ attendee: fakeAttendee(),
28
+ mailName: 'welcome',
29
+ attendeeId: 'att-1',
30
+ linkOptions,
31
+ resolvePartial: notFoundPartial,
32
+ content: {
33
+ subject: {
34
+ type: 'handlebars',
35
+ content: 'Welcome {{userdata.vorname}}!'
36
+ },
37
+ to: {
38
+ type: 'handlebars',
39
+ content: '{{userdata.email}}'
40
+ },
41
+ to_name: {
42
+ type: 'text',
43
+ content: ''
44
+ },
45
+ from: {
46
+ type: 'text',
47
+ content: 'sender@example.com'
48
+ },
49
+ cc: {
50
+ type: 'text',
51
+ content: ''
52
+ },
53
+ bcc: {
54
+ type: 'text',
55
+ content: ''
56
+ },
57
+ reply: {
58
+ type: 'text',
59
+ content: ''
60
+ },
61
+ pre: {
62
+ type: 'text',
63
+ content: ''
64
+ },
65
+ pdf: {
66
+ type: 'handlebars',
67
+ content: '<p>Ticket for {{userdata.vorname}}</p>'
68
+ },
69
+ pdfname: {
70
+ type: 'handlebars',
71
+ content: '{{userdata.vorname}}.pdf'
72
+ },
73
+ blocks: [{
74
+ key: 'second',
75
+ name: 'Second',
76
+ sort_order: 1,
77
+ value: {
78
+ type: 'text',
79
+ content: 'Second block'
80
+ }
81
+ }, {
82
+ key: 'first',
83
+ name: 'First',
84
+ sort_order: 0,
85
+ value: {
86
+ type: 'handlebars',
87
+ content: 'Hi {{userdata.vorname}}'
88
+ }
89
+ }]
90
+ }
91
+ });
92
+ expect(result.subject).toBe('Welcome Max!');
93
+ expect(result.toAddress).toEqual({
94
+ name: '',
95
+ email: 'max@example.com'
96
+ });
97
+ expect(result.fromAddress).toEqual({
98
+ name: '',
99
+ email: 'sender@example.com'
100
+ });
101
+ expect(result.pdfHtml).toBe('<p>Ticket for Max</p>');
102
+ expect(result.pdfName).toBe('Max.pdf');
103
+ expect(result.props.renderedBlocks).toEqual([{
104
+ key: 'first',
105
+ name: 'First',
106
+ type: 'html',
107
+ html: 'Hi Max'
108
+ }, {
109
+ key: 'second',
110
+ name: 'Second',
111
+ type: 'text',
112
+ content: 'Second block'
113
+ }]);
114
+ });
115
+ it('leaves the pdf filename empty when there is no pdf body to attach it to', async () => {
116
+ const result = await renderEmailContent({
117
+ attendee: fakeAttendee(),
118
+ mailName: 'welcome',
119
+ attendeeId: 'att-1',
120
+ linkOptions,
121
+ resolvePartial: notFoundPartial,
122
+ content: {
123
+ subject: {
124
+ type: 'text',
125
+ content: 'Hello'
126
+ },
127
+ to: {
128
+ type: 'text',
129
+ content: 'a@b.com'
130
+ },
131
+ to_name: {
132
+ type: 'text',
133
+ content: ''
134
+ },
135
+ from: {
136
+ type: 'text',
137
+ content: 'sender@example.com'
138
+ },
139
+ cc: {
140
+ type: 'text',
141
+ content: ''
142
+ },
143
+ bcc: {
144
+ type: 'text',
145
+ content: ''
146
+ },
147
+ reply: {
148
+ type: 'text',
149
+ content: ''
150
+ },
151
+ pre: {
152
+ type: 'text',
153
+ content: ''
154
+ },
155
+ pdf: {
156
+ type: 'text',
157
+ content: ''
158
+ },
159
+ pdfname: {
160
+ type: 'text',
161
+ content: 'never-rendered.pdf'
162
+ },
163
+ blocks: []
164
+ }
165
+ });
166
+ expect(result.pdfHtml).toBe('');
167
+ expect(result.pdfName).toBe('');
168
+ });
169
+ it('falls back toAddress.name to the rendered to_name when the address has none', async () => {
170
+ const result = await renderEmailContent({
171
+ attendee: fakeAttendee(),
172
+ mailName: 'welcome',
173
+ attendeeId: 'att-1',
174
+ linkOptions,
175
+ resolvePartial: notFoundPartial,
176
+ content: {
177
+ subject: {
178
+ type: 'text',
179
+ content: 'Hello'
180
+ },
181
+ to: {
182
+ type: 'text',
183
+ content: 'a@b.com'
184
+ },
185
+ to_name: {
186
+ type: 'text',
187
+ content: 'A B'
188
+ },
189
+ from: {
190
+ type: 'text',
191
+ content: 'sender@example.com'
192
+ },
193
+ cc: {
194
+ type: 'text',
195
+ content: ''
196
+ },
197
+ bcc: {
198
+ type: 'text',
199
+ content: ''
200
+ },
201
+ reply: {
202
+ type: 'text',
203
+ content: ''
204
+ },
205
+ pre: {
206
+ type: 'text',
207
+ content: ''
208
+ },
209
+ pdf: {
210
+ type: 'text',
211
+ content: ''
212
+ },
213
+ pdfname: {
214
+ type: 'text',
215
+ content: ''
216
+ },
217
+ blocks: []
218
+ }
219
+ });
220
+ expect(result.toAddress).toEqual({
221
+ name: 'A B',
222
+ email: 'a@b.com'
223
+ });
224
+ });
225
+ it('resolves handlebars partials via the injected resolvePartial callback', async () => {
226
+ const resolvePartial = vi.fn().mockResolvedValue('resolved partial text');
227
+ const result = await renderEmailContent({
228
+ attendee: fakeAttendee(),
229
+ mailName: 'welcome',
230
+ attendeeId: 'att-1',
231
+ linkOptions,
232
+ resolvePartial,
233
+ content: {
234
+ subject: {
235
+ type: 'text',
236
+ content: 'Hello'
237
+ },
238
+ to: {
239
+ type: 'text',
240
+ content: 'a@b.com'
241
+ },
242
+ to_name: {
243
+ type: 'text',
244
+ content: ''
245
+ },
246
+ from: {
247
+ type: 'text',
248
+ content: 'sender@example.com'
249
+ },
250
+ cc: {
251
+ type: 'text',
252
+ content: ''
253
+ },
254
+ bcc: {
255
+ type: 'text',
256
+ content: ''
257
+ },
258
+ reply: {
259
+ type: 'text',
260
+ content: ''
261
+ },
262
+ pre: {
263
+ type: 'text',
264
+ content: ''
265
+ },
266
+ pdf: {
267
+ type: 'text',
268
+ content: ''
269
+ },
270
+ pdfname: {
271
+ type: 'text',
272
+ content: ''
273
+ },
274
+ blocks: [{
275
+ key: 'body',
276
+ name: 'Body',
277
+ sort_order: 0,
278
+ value: {
279
+ type: 'handlebars',
280
+ content: '{{> footer}}'
281
+ }
282
+ }]
283
+ }
284
+ });
285
+ expect(resolvePartial).toHaveBeenCalledWith('footer');
286
+ expect(result.props.renderedBlocks).toEqual([{
287
+ key: 'body',
288
+ name: 'Body',
289
+ type: 'html',
290
+ html: 'resolved partial text'
291
+ }]);
292
+ });
293
+ });
@@ -9,3 +9,6 @@ export * from './csrf';
9
9
  export * from './tokens';
10
10
  export * from './email-links';
11
11
  export * from './email-handlebars';
12
+ export * from './parse-address';
13
+ export * from './mail-template-props';
14
+ export * from './email-render';
package/dist/lib/index.js CHANGED
@@ -8,4 +8,7 @@ export * from './headers';
8
8
  export * from './csrf';
9
9
  export * from './tokens';
10
10
  export * from './email-links';
11
- export * from './email-handlebars';
11
+ export * from './email-handlebars';
12
+ export * from './parse-address';
13
+ export * from './mail-template-props';
14
+ export * from './email-render';
@@ -0,0 +1,47 @@
1
+ import { Attendee, PublicEmailContent } from '../models';
2
+ import { EmailLinkContext } from './email-handlebars';
3
+ import { LinkBuilderOptions } from './email-links';
4
+ /**
5
+ * A block's rendered output, keeping the text/HTML distinction from its
6
+ * source `ContentValue.type` (see models/email-content.ts) instead of
7
+ * collapsing both to a plain HTML string -- lets a template render a
8
+ * `'text'` block through react-email's own (escaping) `<Text>` and reserve
9
+ * `dangerouslySetInnerHTML` for `'html'` blocks only.
10
+ */
11
+ export type RenderedBlock = {
12
+ key: string;
13
+ name: string;
14
+ type: 'text';
15
+ content: string;
16
+ } | {
17
+ key: string;
18
+ name: string;
19
+ type: 'html';
20
+ html: string;
21
+ };
22
+ /**
23
+ * Props every per-client `emails/{mail_name}.tsx` (and its `_default`
24
+ * fallback) receives from `renderHeadlessEmail`. `renderedFields`/
25
+ * `renderedBlocks` are already resolved/awaited -- a single ContentValue (a
26
+ * fixed field or a block's value) run through the Handlebars engine when its
27
+ * type is "handlebars", or the literal string for "text" -- since
28
+ * react-email components render synchronously.
29
+ */
30
+ export type MailTemplateProps = {
31
+ instanceName: string;
32
+ attendee: Attendee;
33
+ content: PublicEmailContent;
34
+ linkOptions: LinkBuilderOptions;
35
+ linkContext: EmailLinkContext;
36
+ renderedFields: {
37
+ subject: string;
38
+ to: string;
39
+ toName: string;
40
+ from: string;
41
+ cc: string;
42
+ bcc: string;
43
+ reply: string;
44
+ pre: string;
45
+ };
46
+ renderedBlocks: RenderedBlock[];
47
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A small subset of Python's email.utils.parseaddr, which is what the
3
+ * legacy renderer (app/emails.py) uses to split a rendered "to"/"from"
4
+ * field into a name + email pair. Handles the two shapes templates
5
+ * actually produce: "Name <email@x.com>" and a bare "email@x.com".
6
+ */
7
+ export declare function parseAddress(raw: string): {
8
+ name: string;
9
+ email: string;
10
+ };
11
+ export declare function splitAddressList(raw: string): string[];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A small subset of Python's email.utils.parseaddr, which is what the
3
+ * legacy renderer (app/emails.py) uses to split a rendered "to"/"from"
4
+ * field into a name + email pair. Handles the two shapes templates
5
+ * actually produce: "Name <email@x.com>" and a bare "email@x.com".
6
+ */
7
+ export function parseAddress(raw) {
8
+ const trimmed = raw.trim();
9
+ const match = /^(.*)<([^<>]+)>\s*$/.exec(trimmed);
10
+ if (match) {
11
+ return {
12
+ name: match[1].trim().replace(/^"|"$/g, ''),
13
+ email: match[2].trim()
14
+ };
15
+ }
16
+ return {
17
+ name: '',
18
+ email: trimmed
19
+ };
20
+ }
21
+ export function splitAddressList(raw) {
22
+ return raw.replace(/;/g, ',').split(',').map(s => s.trim()).filter(Boolean);
23
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseAddress, splitAddressList } from './parse-address';
3
+ describe('parseAddress', () => {
4
+ it('splits a "Name <email>" address into name and email', () => {
5
+ expect(parseAddress('Max Mustermann <max@example.com>')).toEqual({
6
+ name: 'Max Mustermann',
7
+ email: 'max@example.com'
8
+ });
9
+ });
10
+ it('strips surrounding quotes from the name', () => {
11
+ expect(parseAddress('"Max Mustermann" <max@example.com>')).toEqual({
12
+ name: 'Max Mustermann',
13
+ email: 'max@example.com'
14
+ });
15
+ });
16
+ it('returns an empty name for a bare email address', () => {
17
+ expect(parseAddress('max@example.com')).toEqual({
18
+ name: '',
19
+ email: 'max@example.com'
20
+ });
21
+ });
22
+ it('trims surrounding whitespace', () => {
23
+ expect(parseAddress(' Max Mustermann <max@example.com> ')).toEqual({
24
+ name: 'Max Mustermann',
25
+ email: 'max@example.com'
26
+ });
27
+ });
28
+ });
29
+ describe('splitAddressList', () => {
30
+ it('splits a comma-separated list and trims each entry', () => {
31
+ expect(splitAddressList('a@example.com, b@example.com')).toEqual(['a@example.com', 'b@example.com']);
32
+ });
33
+ it('treats semicolons as separators too', () => {
34
+ expect(splitAddressList('a@example.com; b@example.com')).toEqual(['a@example.com', 'b@example.com']);
35
+ });
36
+ it('drops empty entries', () => {
37
+ expect(splitAddressList('a@example.com,,b@example.com,')).toEqual(['a@example.com', 'b@example.com']);
38
+ });
39
+ it('returns an empty array for an empty string', () => {
40
+ expect(splitAddressList('')).toEqual([]);
41
+ });
42
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-crew-berlin/enter-js-utils",
3
- "version": "0.99.1",
3
+ "version": "0.99.3",
4
4
  "description": "utils such as vaildation and other helpers to work with data from the enter app",
5
5
  "files": [
6
6
  "dist",