@dev-crew-berlin/enter-js-utils 0.99.0 → 0.99.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.
- package/dist/api-client/api-client.test.js +109 -0
- package/dist/api-client/index.d.ts +21 -0
- package/dist/api-client/index.js +32 -0
- package/dist/lib/email-render.d.ts +38 -0
- package/dist/lib/email-render.js +104 -0
- package/dist/lib/email-render.test.js +293 -0
- package/dist/lib/index.d.ts +3 -0
- package/dist/lib/index.js +4 -1
- package/dist/lib/mail-template-props.d.ts +47 -0
- package/dist/lib/mail-template-props.js +1 -0
- package/dist/lib/parse-address.d.ts +11 -0
- package/dist/lib/parse-address.js +23 -0
- package/dist/lib/parse-address.test.js +42 -0
- package/dist/models/email-content.d.ts +23 -0
- package/dist/models/email-content.js +1 -0
- package/dist/models/index.d.ts +1 -0
- package/package.json +1 -1
|
@@ -935,4 +935,113 @@ describe('createEvents', () => {
|
|
|
935
935
|
expect(result.data.created).toBe(0);
|
|
936
936
|
expect(result.data.eventCursors).toEqual({});
|
|
937
937
|
});
|
|
938
|
+
});
|
|
939
|
+
describe('getEmailContent', () => {
|
|
940
|
+
afterEach(() => {
|
|
941
|
+
vi.restoreAllMocks();
|
|
942
|
+
});
|
|
943
|
+
it('returns the parsed content on success', async () => {
|
|
944
|
+
const content = {
|
|
945
|
+
subject: {
|
|
946
|
+
type: 'text',
|
|
947
|
+
content: 'Hello'
|
|
948
|
+
},
|
|
949
|
+
to: {
|
|
950
|
+
type: 'text',
|
|
951
|
+
content: 'a@b.com'
|
|
952
|
+
},
|
|
953
|
+
to_name: {
|
|
954
|
+
type: 'text',
|
|
955
|
+
content: ''
|
|
956
|
+
},
|
|
957
|
+
from: {
|
|
958
|
+
type: 'text',
|
|
959
|
+
content: 'sender@example.com'
|
|
960
|
+
},
|
|
961
|
+
cc: {
|
|
962
|
+
type: 'text',
|
|
963
|
+
content: ''
|
|
964
|
+
},
|
|
965
|
+
bcc: {
|
|
966
|
+
type: 'text',
|
|
967
|
+
content: ''
|
|
968
|
+
},
|
|
969
|
+
reply: {
|
|
970
|
+
type: 'text',
|
|
971
|
+
content: ''
|
|
972
|
+
},
|
|
973
|
+
pre: {
|
|
974
|
+
type: 'text',
|
|
975
|
+
content: ''
|
|
976
|
+
},
|
|
977
|
+
pdf: {
|
|
978
|
+
type: 'text',
|
|
979
|
+
content: ''
|
|
980
|
+
},
|
|
981
|
+
pdfname: {
|
|
982
|
+
type: 'text',
|
|
983
|
+
content: ''
|
|
984
|
+
},
|
|
985
|
+
blocks: []
|
|
986
|
+
};
|
|
987
|
+
vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify(content), {
|
|
988
|
+
status: 200
|
|
989
|
+
})));
|
|
990
|
+
const api = makeFullAPI();
|
|
991
|
+
const result = await api.getEmailContent({
|
|
992
|
+
instanceName: 'test-instance',
|
|
993
|
+
emailName: 'welcome'
|
|
994
|
+
});
|
|
995
|
+
expect(result.success).toBe(true);
|
|
996
|
+
if (!result.success) return;
|
|
997
|
+
expect(result.data).toEqual(content);
|
|
998
|
+
});
|
|
999
|
+
it('maps a 404 to a failure result', async () => {
|
|
1000
|
+
vi.stubGlobal('fetch', () => Promise.resolve(new Response(null, {
|
|
1001
|
+
status: 404
|
|
1002
|
+
})));
|
|
1003
|
+
const api = makeFullAPI();
|
|
1004
|
+
const result = await api.getEmailContent({
|
|
1005
|
+
instanceName: 'test-instance',
|
|
1006
|
+
emailName: 'missing'
|
|
1007
|
+
});
|
|
1008
|
+
expect(result.success).toBe(false);
|
|
1009
|
+
if (result.success) return;
|
|
1010
|
+
expect(result.error[0]).toMatchObject({
|
|
1011
|
+
statusCode: 404
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
describe('getLegacyTemplateSource', () => {
|
|
1016
|
+
afterEach(() => {
|
|
1017
|
+
vi.restoreAllMocks();
|
|
1018
|
+
});
|
|
1019
|
+
it('returns the parsed template source string on success', async () => {
|
|
1020
|
+
vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify('<p>{{userdata.vorname}}</p>'), {
|
|
1021
|
+
status: 200
|
|
1022
|
+
})));
|
|
1023
|
+
const api = makeFullAPI();
|
|
1024
|
+
const result = await api.getLegacyTemplateSource({
|
|
1025
|
+
instanceName: 'test-instance',
|
|
1026
|
+
templateName: 'footer'
|
|
1027
|
+
});
|
|
1028
|
+
expect(result.success).toBe(true);
|
|
1029
|
+
if (!result.success) return;
|
|
1030
|
+
expect(result.data).toBe('<p>{{userdata.vorname}}</p>');
|
|
1031
|
+
});
|
|
1032
|
+
it('maps a 404 to a failure result', async () => {
|
|
1033
|
+
vi.stubGlobal('fetch', () => Promise.resolve(new Response(null, {
|
|
1034
|
+
status: 404
|
|
1035
|
+
})));
|
|
1036
|
+
const api = makeFullAPI();
|
|
1037
|
+
const result = await api.getLegacyTemplateSource({
|
|
1038
|
+
instanceName: 'test-instance',
|
|
1039
|
+
templateName: 'missing'
|
|
1040
|
+
});
|
|
1041
|
+
expect(result.success).toBe(false);
|
|
1042
|
+
if (result.success) return;
|
|
1043
|
+
expect(result.error[0]).toMatchObject({
|
|
1044
|
+
statusCode: 404
|
|
1045
|
+
});
|
|
1046
|
+
});
|
|
938
1047
|
});
|
|
@@ -9,6 +9,7 @@ import { User } from '../models/user';
|
|
|
9
9
|
import { Variable } from '../models/variable';
|
|
10
10
|
import { components } from '../generated/api-schema';
|
|
11
11
|
import { Email } from '../models/email';
|
|
12
|
+
import { PublicEmailContent } from '../models/email-content';
|
|
12
13
|
export type { APICredentials, SubscribeOptions } from './api-base';
|
|
13
14
|
/**
|
|
14
15
|
* api client for the enter backend
|
|
@@ -160,6 +161,26 @@ declare class API extends APIBase {
|
|
|
160
161
|
emailName: string;
|
|
161
162
|
mailingId?: string | null;
|
|
162
163
|
}, options?: FetchOptions): Promise<APIResult<Email>>;
|
|
164
|
+
/**
|
|
165
|
+
* @category Emails
|
|
166
|
+
*
|
|
167
|
+
* TODO: switch to the typed `get()` helper once enter-core MR !2007 lands
|
|
168
|
+
* and `npm run generate-client` adds this path to `paths`.
|
|
169
|
+
*/
|
|
170
|
+
getEmailContent(args: {
|
|
171
|
+
instanceName: string;
|
|
172
|
+
emailName: string;
|
|
173
|
+
}, options?: FetchOptions): Promise<APIResult<PublicEmailContent>>;
|
|
174
|
+
/**
|
|
175
|
+
* @category Templates
|
|
176
|
+
*
|
|
177
|
+
* TODO: switch to the typed `get()` helper once enter-core MR !2007 lands
|
|
178
|
+
* and `npm run generate-client` adds this path to `paths`.
|
|
179
|
+
*/
|
|
180
|
+
getLegacyTemplateSource(args: {
|
|
181
|
+
instanceName: string;
|
|
182
|
+
templateName: string;
|
|
183
|
+
}, options?: FetchOptions): Promise<APIResult<string>>;
|
|
163
184
|
/**
|
|
164
185
|
* @category Events
|
|
165
186
|
*/
|
package/dist/api-client/index.js
CHANGED
|
@@ -224,6 +224,38 @@ class API extends APIBase {
|
|
|
224
224
|
return this.get(`/instances/${instanceName}/attendees/${attendeeId}/emails/${emailName}/render${queryString}`, options);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/**
|
|
228
|
+
* @category Emails
|
|
229
|
+
*
|
|
230
|
+
* TODO: switch to the typed `get()` helper once enter-core MR !2007 lands
|
|
231
|
+
* and `npm run generate-client` adds this path to `paths`.
|
|
232
|
+
*/
|
|
233
|
+
async getEmailContent(args, options = {}) {
|
|
234
|
+
const result = await this.fetchResponse(`/instances/${args.instanceName}/emails/${args.emailName}/content`, {
|
|
235
|
+
method: 'GET',
|
|
236
|
+
headers: this.buildHeaders(),
|
|
237
|
+
...options
|
|
238
|
+
});
|
|
239
|
+
if (!result.success) return result;
|
|
240
|
+
return success(await result.data.json());
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @category Templates
|
|
245
|
+
*
|
|
246
|
+
* TODO: switch to the typed `get()` helper once enter-core MR !2007 lands
|
|
247
|
+
* and `npm run generate-client` adds this path to `paths`.
|
|
248
|
+
*/
|
|
249
|
+
async getLegacyTemplateSource(args, options = {}) {
|
|
250
|
+
const result = await this.fetchResponse(`/instances/${args.instanceName}/templates/${args.templateName}`, {
|
|
251
|
+
method: 'GET',
|
|
252
|
+
headers: this.buildHeaders(),
|
|
253
|
+
...options
|
|
254
|
+
});
|
|
255
|
+
if (!result.success) return result;
|
|
256
|
+
return success(await result.data.json());
|
|
257
|
+
}
|
|
258
|
+
|
|
227
259
|
/**
|
|
228
260
|
* @category Events
|
|
229
261
|
*/
|
|
@@ -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
|
+
});
|
package/dist/lib/index.d.ts
CHANGED
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
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ContentValue = {
|
|
2
|
+
type: 'handlebars' | 'text';
|
|
3
|
+
content: string;
|
|
4
|
+
};
|
|
5
|
+
export type EmailContentBlock = {
|
|
6
|
+
key: string;
|
|
7
|
+
name: string;
|
|
8
|
+
sort_order: number;
|
|
9
|
+
value: ContentValue;
|
|
10
|
+
};
|
|
11
|
+
export type PublicEmailContent = {
|
|
12
|
+
subject: ContentValue;
|
|
13
|
+
to: ContentValue;
|
|
14
|
+
to_name: ContentValue;
|
|
15
|
+
from: ContentValue;
|
|
16
|
+
cc: ContentValue;
|
|
17
|
+
bcc: ContentValue;
|
|
18
|
+
reply: ContentValue;
|
|
19
|
+
pre: ContentValue;
|
|
20
|
+
pdf: ContentValue;
|
|
21
|
+
pdfname: ContentValue;
|
|
22
|
+
blocks: EmailContentBlock[];
|
|
23
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/models/index.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type { FieldValue } from './field-value';
|
|
|
16
16
|
export type { TextField, EmailField, NumberField, CheckboxField, ToggleField, RadioField, SelectField, Field, } from './field';
|
|
17
17
|
export { Instance } from './instance';
|
|
18
18
|
export type { InstanceJSON } from './instance';
|
|
19
|
+
export type { ContentValue, EmailContentBlock, PublicEmailContent, } from './email-content';
|
|
19
20
|
export type { Permission } from './permission';
|
|
20
21
|
export type { User } from './user';
|
|
21
22
|
export type { Variable } from './variable';
|