@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.
@@ -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
  */
@@ -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
+ });
@@ -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
+ });
@@ -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 {};
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-crew-berlin/enter-js-utils",
3
- "version": "0.99.0",
3
+ "version": "0.99.2",
4
4
  "description": "utils such as vaildation and other helpers to work with data from the enter app",
5
5
  "files": [
6
6
  "dist",