backend-manager 5.0.147 → 5.0.149
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/CHANGELOG.md +58 -0
- package/CLAUDE.md +26 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +14 -4
- package/src/cli/commands/test.js +4 -10
- package/src/manager/cron/daily/ghostii-auto-publisher.js +25 -25
- package/src/manager/cron/frequent/abandoned-carts.js +7 -5
- package/src/manager/cron/frequent/email-queue.js +56 -0
- package/src/manager/events/auth/before-signin.js +3 -0
- package/src/manager/events/auth/on-delete.js +8 -0
- package/src/manager/events/firestore/payments-disputes/on-write.js +2 -1
- package/src/manager/events/firestore/payments-webhooks/on-write.js +9 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +7 -21
- package/src/manager/functions/core/actions/api/admin/get-stats.js +2 -2
- package/src/manager/functions/core/actions/api/admin/send-email.js +14 -14
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +22 -318
- package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +1 -1
- package/src/manager/functions/core/actions/api/general/remove-marketing-contact.js +2 -185
- package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
- package/src/manager/functions/core/actions/api/special/setup-electron-manager-client.js +2 -2
- package/src/manager/functions/core/actions/api/test/health.js +1 -0
- package/src/manager/helpers/api-manager.js +2 -2
- package/src/manager/helpers/user.js +3 -1
- package/src/manager/index.js +15 -10
- package/src/manager/libraries/email/constants.js +243 -0
- package/src/manager/libraries/email/index.js +145 -0
- package/src/manager/libraries/email/marketing/index.js +377 -0
- package/src/manager/libraries/email/providers/beehiiv.js +258 -0
- package/src/manager/libraries/email/providers/sendgrid.js +429 -0
- package/src/manager/libraries/{email.js → email/transactional/index.js} +91 -99
- package/src/manager/libraries/email/validation.js +168 -0
- package/src/manager/libraries/infer-contact.js +1 -1
- package/src/manager/routes/admin/cron/post.js +3 -3
- package/src/manager/routes/admin/email/post.js +1 -1
- package/src/manager/routes/admin/stats/get.js +2 -2
- package/src/manager/routes/{app → brand}/get.js +1 -1
- package/src/manager/routes/general/email/templates/download-app-link.js +1 -1
- package/src/manager/routes/marketing/contact/delete.js +2 -164
- package/src/manager/routes/marketing/contact/post.js +45 -298
- package/src/manager/routes/marketing/contact/put.js +39 -0
- package/src/manager/routes/payments/cancel/post.js +11 -0
- package/src/manager/routes/special/electron-client/post.js +3 -3
- package/src/manager/routes/test/health/get.js +1 -0
- package/src/manager/routes/user/data-request/delete.js +2 -2
- package/src/manager/routes/user/data-request/get.js +2 -2
- package/src/manager/routes/user/data-request/post.js +2 -2
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/feedback/post.js +12 -8
- package/src/manager/routes/user/signup/post.js +48 -37
- package/src/manager/schemas/admin/email/post.js +4 -4
- package/src/manager/schemas/marketing/contact/delete.js +3 -1
- package/src/manager/schemas/marketing/contact/post.js +3 -1
- package/src/manager/schemas/marketing/contact/put.js +6 -0
- package/src/manager/schemas/special/electron-client/post.js +2 -2
- package/src/manager/schemas/user/feedback/post.js +2 -2
- package/src/test/run-tests.js +1 -1
- package/src/test/runner.js +22 -10
- package/src/test/test-accounts.js +9 -0
- package/src/test/utils/extended-mode-warning.js +11 -0
- package/templates/_.env +1 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
- package/test/events/payments/journey-payments-trial-cancel.js +11 -0
- package/test/functions/admin/edit-post.js +2 -2
- package/test/functions/admin/write-repo-content.js +2 -2
- package/test/functions/general/add-marketing-contact.js +21 -23
- package/test/helpers/email-validation.js +420 -0
- package/test/helpers/email.js +119 -6
- package/test/helpers/marketing-lifecycle.js +121 -0
- package/test/helpers/user.js +2 -2
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/post.js +2 -2
- package/test/routes/admin/repo-content.js +2 -2
- package/test/routes/marketing/contact.js +21 -24
- package/test/routes/payments/cancel.js +18 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Email validation library (libraries/email/validation.js)
|
|
3
|
+
* Unit tests for format, local part, disposable domain, and ZeroBounce checks
|
|
4
|
+
*
|
|
5
|
+
* Format, local part, and disposable tests always run (free, regex-based).
|
|
6
|
+
* Mailbox verification tests require TEST_EXTENDED_MODE + ZEROBOUNCE_API_KEY.
|
|
7
|
+
*/
|
|
8
|
+
const { validate, DEFAULT_CHECKS, ALL_CHECKS } = require('../../src/manager/libraries/email/validation.js');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
description: 'Email validation',
|
|
12
|
+
type: 'group',
|
|
13
|
+
tests: [
|
|
14
|
+
// --- Format checks ---
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
name: 'format-valid-email-passes',
|
|
18
|
+
timeout: 5000,
|
|
19
|
+
|
|
20
|
+
async run({ assert }) {
|
|
21
|
+
const result = await validate('rachel.greene@gmail.com');
|
|
22
|
+
|
|
23
|
+
assert.equal(result.valid, true, 'Valid email should pass');
|
|
24
|
+
assert.propertyEquals(result, 'checks.format.valid', true, 'Format check should pass');
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
name: 'format-no-at-sign-fails',
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
|
|
32
|
+
async run({ assert }) {
|
|
33
|
+
const result = await validate('not-a-valid-email');
|
|
34
|
+
|
|
35
|
+
assert.equal(result.valid, false, 'Missing @ should fail');
|
|
36
|
+
assert.propertyEquals(result, 'checks.format.valid', false, 'Format check should fail');
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
name: 'format-no-domain-fails',
|
|
42
|
+
timeout: 5000,
|
|
43
|
+
|
|
44
|
+
async run({ assert }) {
|
|
45
|
+
const result = await validate('user@');
|
|
46
|
+
|
|
47
|
+
assert.equal(result.valid, false, 'Missing domain should fail');
|
|
48
|
+
assert.propertyEquals(result, 'checks.format.valid', false, 'Format check should fail');
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
name: 'format-empty-string-fails',
|
|
54
|
+
timeout: 5000,
|
|
55
|
+
|
|
56
|
+
async run({ assert }) {
|
|
57
|
+
const result = await validate('');
|
|
58
|
+
|
|
59
|
+
assert.equal(result.valid, false, 'Empty string should fail');
|
|
60
|
+
assert.propertyEquals(result, 'checks.format.valid', false, 'Format check should fail');
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
name: 'format-spaces-fails',
|
|
66
|
+
timeout: 5000,
|
|
67
|
+
|
|
68
|
+
async run({ assert }) {
|
|
69
|
+
const result = await validate('user name@gmail.com');
|
|
70
|
+
|
|
71
|
+
assert.equal(result.valid, false, 'Spaces should fail');
|
|
72
|
+
assert.propertyEquals(result, 'checks.format.valid', false, 'Format check should fail');
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// --- Local part checks ---
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
name: 'localpart-test-blocked',
|
|
80
|
+
timeout: 5000,
|
|
81
|
+
|
|
82
|
+
async run({ assert }) {
|
|
83
|
+
const result = await validate('test@gmail.com');
|
|
84
|
+
|
|
85
|
+
assert.equal(result.valid, false, '"test" local part should be blocked');
|
|
86
|
+
assert.propertyEquals(result, 'checks.localPart.blocked', true, 'Should be flagged as blocked');
|
|
87
|
+
assert.propertyEquals(result, 'checks.localPart.localPart', 'test', 'Should include the local part');
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
name: 'localpart-noreply-blocked',
|
|
93
|
+
timeout: 5000,
|
|
94
|
+
|
|
95
|
+
async run({ assert }) {
|
|
96
|
+
const result = await validate('noreply@company.com');
|
|
97
|
+
|
|
98
|
+
assert.equal(result.valid, false, '"noreply" local part should be blocked');
|
|
99
|
+
assert.propertyEquals(result, 'checks.localPart.blocked', true, 'Should be flagged as blocked');
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
name: 'localpart-admin-blocked',
|
|
105
|
+
timeout: 5000,
|
|
106
|
+
|
|
107
|
+
async run({ assert }) {
|
|
108
|
+
const result = await validate('admin@company.com');
|
|
109
|
+
|
|
110
|
+
assert.equal(result.valid, false, '"admin" local part should be blocked');
|
|
111
|
+
assert.propertyEquals(result, 'checks.localPart.blocked', true, 'Should be flagged as blocked');
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
name: 'localpart-all-numeric-blocked',
|
|
117
|
+
timeout: 5000,
|
|
118
|
+
|
|
119
|
+
async run({ assert }) {
|
|
120
|
+
const result = await validate('123456@gmail.com');
|
|
121
|
+
|
|
122
|
+
assert.equal(result.valid, false, 'All-numeric local part should be blocked');
|
|
123
|
+
assert.propertyEquals(result, 'checks.localPart.blocked', true, 'Should be flagged as blocked');
|
|
124
|
+
assert.propertyEquals(result, 'checks.localPart.reason', 'Matches junk pattern', 'Should match junk pattern');
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
name: 'localpart-repeating-chars-blocked',
|
|
130
|
+
timeout: 5000,
|
|
131
|
+
|
|
132
|
+
async run({ assert }) {
|
|
133
|
+
const result = await validate('aaaa@gmail.com');
|
|
134
|
+
|
|
135
|
+
assert.equal(result.valid, false, 'Repeating chars should be blocked');
|
|
136
|
+
assert.propertyEquals(result, 'checks.localPart.blocked', true, 'Should be flagged as blocked');
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
name: 'localpart-keyboard-walk-blocked',
|
|
142
|
+
timeout: 5000,
|
|
143
|
+
|
|
144
|
+
async run({ assert }) {
|
|
145
|
+
const result = await validate('asdf@gmail.com');
|
|
146
|
+
|
|
147
|
+
assert.equal(result.valid, false, 'Keyboard walk should be blocked');
|
|
148
|
+
assert.propertyEquals(result, 'checks.localPart.blocked', true, 'Should be flagged as blocked');
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
{
|
|
153
|
+
name: 'localpart-test-prefix-blocked',
|
|
154
|
+
timeout: 5000,
|
|
155
|
+
|
|
156
|
+
async run({ assert }) {
|
|
157
|
+
const result = await validate('test.user@gmail.com');
|
|
158
|
+
|
|
159
|
+
assert.equal(result.valid, false, '"test." prefix should be blocked');
|
|
160
|
+
assert.propertyEquals(result, 'checks.localPart.blocked', true, 'Should be flagged as blocked');
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
{
|
|
165
|
+
name: 'localpart-letter-plus-numbers-blocked',
|
|
166
|
+
timeout: 5000,
|
|
167
|
+
|
|
168
|
+
async run({ assert }) {
|
|
169
|
+
const result = await validate('a123@gmail.com');
|
|
170
|
+
|
|
171
|
+
assert.equal(result.valid, false, 'Single letter + numbers should be blocked');
|
|
172
|
+
assert.propertyEquals(result, 'checks.localPart.blocked', true, 'Should be flagged as blocked');
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
name: 'localpart-plus-suffix-stripped-before-check',
|
|
178
|
+
timeout: 5000,
|
|
179
|
+
|
|
180
|
+
async run({ assert }) {
|
|
181
|
+
// "test+something" → strips to "test" → blocked
|
|
182
|
+
const result = await validate('test+newsletter@gmail.com');
|
|
183
|
+
|
|
184
|
+
assert.equal(result.valid, false, '"test+suffix" should still be blocked (strips +suffix first)');
|
|
185
|
+
assert.propertyEquals(result, 'checks.localPart.blocked', true, 'Should be blocked after stripping suffix');
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
{
|
|
190
|
+
name: 'localpart-bem-suffix-allowed-on-real-names',
|
|
191
|
+
timeout: 5000,
|
|
192
|
+
|
|
193
|
+
async run({ assert }) {
|
|
194
|
+
// "rachel.greene+bem" → strips to "rachel.greene" → allowed
|
|
195
|
+
const result = await validate('rachel.greene+bem@gmail.com');
|
|
196
|
+
|
|
197
|
+
assert.equal(result.valid, true, 'Real name with +bem suffix should pass');
|
|
198
|
+
assert.propertyEquals(result, 'checks.localPart.valid', true, 'Local part check should pass');
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
name: 'localpart-real-name-passes',
|
|
204
|
+
timeout: 5000,
|
|
205
|
+
|
|
206
|
+
async run({ assert }) {
|
|
207
|
+
const result = await validate('john.smith@company.com');
|
|
208
|
+
|
|
209
|
+
assert.equal(result.valid, true, 'Real name should pass');
|
|
210
|
+
assert.propertyEquals(result, 'checks.localPart.valid', true, 'Local part check should pass');
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
{
|
|
215
|
+
name: 'localpart-single-real-name-passes',
|
|
216
|
+
timeout: 5000,
|
|
217
|
+
|
|
218
|
+
async run({ assert }) {
|
|
219
|
+
const result = await validate('rachel@company.com');
|
|
220
|
+
|
|
221
|
+
assert.equal(result.valid, true, 'Single real name should pass');
|
|
222
|
+
assert.propertyEquals(result, 'checks.localPart.valid', true, 'Local part check should pass');
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
// --- Disposable domain checks ---
|
|
227
|
+
|
|
228
|
+
{
|
|
229
|
+
name: 'disposable-mailinator-blocked',
|
|
230
|
+
timeout: 5000,
|
|
231
|
+
|
|
232
|
+
async run({ assert }) {
|
|
233
|
+
const result = await validate('rachel.greene@mailinator.com');
|
|
234
|
+
|
|
235
|
+
assert.equal(result.valid, false, 'Mailinator should be invalid');
|
|
236
|
+
assert.propertyEquals(result, 'checks.disposable.blocked', true, 'Should be flagged as blocked');
|
|
237
|
+
assert.propertyEquals(result, 'checks.disposable.domain', 'mailinator.com', 'Should include blocked domain');
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
{
|
|
242
|
+
name: 'disposable-guerrillamail-blocked',
|
|
243
|
+
timeout: 5000,
|
|
244
|
+
|
|
245
|
+
async run({ assert }) {
|
|
246
|
+
const result = await validate('rachel.greene@guerrillamail.com');
|
|
247
|
+
|
|
248
|
+
assert.equal(result.valid, false, 'GuerrillaMail should be invalid');
|
|
249
|
+
assert.propertyEquals(result, 'checks.disposable.blocked', true, 'Should be flagged as blocked');
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
{
|
|
254
|
+
name: 'disposable-tempmail-blocked',
|
|
255
|
+
timeout: 5000,
|
|
256
|
+
|
|
257
|
+
async run({ assert }) {
|
|
258
|
+
const result = await validate('rachel.greene@temp-mail.org');
|
|
259
|
+
|
|
260
|
+
assert.equal(result.valid, false, 'temp-mail.org should be invalid');
|
|
261
|
+
assert.propertyEquals(result, 'checks.disposable.blocked', true, 'Should be flagged as blocked');
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
{
|
|
266
|
+
name: 'valid-gmail-passes-disposable',
|
|
267
|
+
timeout: 5000,
|
|
268
|
+
|
|
269
|
+
async run({ assert }) {
|
|
270
|
+
const result = await validate('rachel.greene@gmail.com');
|
|
271
|
+
|
|
272
|
+
assert.equal(result.valid, true, 'Gmail should be valid');
|
|
273
|
+
assert.propertyEquals(result, 'checks.disposable.valid', true, 'Should pass disposable check');
|
|
274
|
+
assert.propertyEquals(result, 'checks.disposable.blocked', false, 'Should not be blocked');
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
{
|
|
279
|
+
name: 'valid-custom-domain-passes',
|
|
280
|
+
timeout: 5000,
|
|
281
|
+
|
|
282
|
+
async run({ assert }) {
|
|
283
|
+
const result = await validate('ian@somiibo.com');
|
|
284
|
+
|
|
285
|
+
assert.equal(result.valid, true, 'Custom domain should be valid');
|
|
286
|
+
assert.propertyEquals(result, 'checks.disposable.valid', true, 'Should pass disposable check');
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
// --- Selective checks ---
|
|
291
|
+
|
|
292
|
+
{
|
|
293
|
+
name: 'checks-format-only',
|
|
294
|
+
timeout: 5000,
|
|
295
|
+
|
|
296
|
+
async run({ assert }) {
|
|
297
|
+
// "test@gmail.com" normally blocked by localPart, but only running format
|
|
298
|
+
const result = await validate('test@gmail.com', { checks: ['format'] });
|
|
299
|
+
|
|
300
|
+
assert.equal(result.valid, true, 'Should pass with only format check');
|
|
301
|
+
assert.propertyEquals(result, 'checks.format.valid', true, 'Format should pass');
|
|
302
|
+
assert.equal(result.checks.localPart, undefined, 'localPart should not run');
|
|
303
|
+
assert.equal(result.checks.disposable, undefined, 'disposable should not run');
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
{
|
|
308
|
+
name: 'checks-format-and-disposable-skips-localpart',
|
|
309
|
+
timeout: 5000,
|
|
310
|
+
|
|
311
|
+
async run({ assert }) {
|
|
312
|
+
// "test@gmail.com" would be blocked by localPart, but we only run format + disposable
|
|
313
|
+
const result = await validate('test@gmail.com', { checks: ['format', 'disposable'] });
|
|
314
|
+
|
|
315
|
+
assert.equal(result.valid, true, 'Should pass without localPart check');
|
|
316
|
+
assert.propertyEquals(result, 'checks.format.valid', true, 'Format should pass');
|
|
317
|
+
assert.propertyEquals(result, 'checks.disposable.blocked', false, 'Disposable should pass');
|
|
318
|
+
assert.equal(result.checks.localPart, undefined, 'localPart should not run');
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
{
|
|
323
|
+
name: 'checks-format-and-disposable-still-blocks-disposable',
|
|
324
|
+
timeout: 5000,
|
|
325
|
+
|
|
326
|
+
async run({ assert }) {
|
|
327
|
+
const result = await validate('rachel.greene@mailinator.com', { checks: ['format', 'disposable'] });
|
|
328
|
+
|
|
329
|
+
assert.equal(result.valid, false, 'Disposable should still fail');
|
|
330
|
+
assert.propertyEquals(result, 'checks.disposable.blocked', true, 'Should be blocked');
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
{
|
|
335
|
+
name: 'checks-default-matches-expected',
|
|
336
|
+
timeout: 5000,
|
|
337
|
+
|
|
338
|
+
async run({ assert }) {
|
|
339
|
+
assert.deepEqual(DEFAULT_CHECKS, ['format', 'disposable', 'localPart'], 'DEFAULT_CHECKS should be format + disposable + localPart');
|
|
340
|
+
assert.deepEqual(ALL_CHECKS, ['format', 'disposable', 'localPart', 'mailbox'], 'ALL_CHECKS should include mailbox');
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
// --- Mailbox verification behavior ---
|
|
345
|
+
|
|
346
|
+
{
|
|
347
|
+
name: 'mailbox-not-in-default-checks',
|
|
348
|
+
timeout: 5000,
|
|
349
|
+
|
|
350
|
+
async run({ assert }) {
|
|
351
|
+
const result = await validate('rachel.greene@gmail.com');
|
|
352
|
+
|
|
353
|
+
assert.equal(result.valid, true, 'Should be valid');
|
|
354
|
+
assert.equal(result.checks.mailbox, undefined, 'Mailbox should not run with default checks');
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
{
|
|
359
|
+
name: 'mailbox-skipped-without-api-key',
|
|
360
|
+
timeout: 5000,
|
|
361
|
+
|
|
362
|
+
async run({ assert }) {
|
|
363
|
+
const originalKey = process.env.ZEROBOUNCE_API_KEY;
|
|
364
|
+
delete process.env.ZEROBOUNCE_API_KEY;
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const result = await validate('rachel.greene@gmail.com', { checks: ALL_CHECKS });
|
|
368
|
+
|
|
369
|
+
assert.equal(result.valid, true, 'Should still be valid');
|
|
370
|
+
assert.hasProperty(result, 'checks.mailbox', 'Should have mailbox check');
|
|
371
|
+
assert.propertyEquals(result, 'checks.mailbox.skipped', true, 'Should be marked as skipped');
|
|
372
|
+
} finally {
|
|
373
|
+
if (originalKey) {
|
|
374
|
+
process.env.ZEROBOUNCE_API_KEY = originalKey;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
// --- Mailbox verification API checks (require TEST_EXTENDED_MODE + ZEROBOUNCE_API_KEY) ---
|
|
381
|
+
|
|
382
|
+
{
|
|
383
|
+
name: 'mailbox-valid-email-passes',
|
|
384
|
+
timeout: 15000,
|
|
385
|
+
skip: !process.env.TEST_EXTENDED_MODE || !process.env.ZEROBOUNCE_API_KEY
|
|
386
|
+
? 'TEST_EXTENDED_MODE or ZEROBOUNCE_API_KEY not set'
|
|
387
|
+
: false,
|
|
388
|
+
|
|
389
|
+
async run({ assert, skip }) {
|
|
390
|
+
const result = await validate('disposable@gmail.com', { checks: ALL_CHECKS });
|
|
391
|
+
|
|
392
|
+
if (result.checks.mailbox?.error?.includes('out of credits')) {
|
|
393
|
+
skip('Mailbox verification out of credits');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
assert.hasProperty(result, 'checks.mailbox', 'Should have mailbox check');
|
|
397
|
+
assert.hasProperty(result, 'checks.mailbox.status', 'Should have status');
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
{
|
|
402
|
+
name: 'mailbox-fake-domain-fails',
|
|
403
|
+
timeout: 15000,
|
|
404
|
+
skip: !process.env.TEST_EXTENDED_MODE || !process.env.ZEROBOUNCE_API_KEY
|
|
405
|
+
? 'TEST_EXTENDED_MODE or ZEROBOUNCE_API_KEY not set'
|
|
406
|
+
: false,
|
|
407
|
+
|
|
408
|
+
async run({ assert, skip }) {
|
|
409
|
+
const result = await validate('rachel.greene@thisfakedomain99999.com', { checks: ALL_CHECKS });
|
|
410
|
+
|
|
411
|
+
if (result.checks.mailbox?.error?.includes('out of credits')) {
|
|
412
|
+
skip('Mailbox verification out of credits');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
assert.hasProperty(result, 'checks.mailbox.status', 'Should have status');
|
|
416
|
+
assert.notEqual(result.checks.mailbox.status, 'valid', 'Fake domain should not be valid');
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
};
|
package/test/helpers/email.js
CHANGED
|
@@ -76,7 +76,7 @@ module.exports = {
|
|
|
76
76
|
async run({ http, assert }) {
|
|
77
77
|
const response = await http.post('admin/email', {
|
|
78
78
|
subject: 'BEM Test Email - Bad UID',
|
|
79
|
-
to: '
|
|
79
|
+
to: 'nonexistent_uid_12345',
|
|
80
80
|
copy: false,
|
|
81
81
|
});
|
|
82
82
|
|
|
@@ -141,7 +141,7 @@ module.exports = {
|
|
|
141
141
|
async run({ http, assert, accounts }) {
|
|
142
142
|
const response = await http.post('admin/email', {
|
|
143
143
|
subject: 'BEM Test Email - UID Recipient',
|
|
144
|
-
to:
|
|
144
|
+
to: accounts.admin.uid,
|
|
145
145
|
copy: false,
|
|
146
146
|
data: {
|
|
147
147
|
email: {
|
|
@@ -167,7 +167,7 @@ module.exports = {
|
|
|
167
167
|
to: [
|
|
168
168
|
`_test-receiver@${config.domain}`,
|
|
169
169
|
{ email: `_test-receiver-2@${config.domain}`, name: 'Receiver 2' },
|
|
170
|
-
|
|
170
|
+
accounts.admin.uid,
|
|
171
171
|
],
|
|
172
172
|
copy: false,
|
|
173
173
|
data: {
|
|
@@ -378,18 +378,131 @@ module.exports = {
|
|
|
378
378
|
assert.isSuccess(response, 'Should send email');
|
|
379
379
|
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
380
380
|
|
|
381
|
-
const
|
|
381
|
+
const brandImages = response.data.options.dynamicTemplateData.brand.images;
|
|
382
382
|
|
|
383
383
|
// Any image that was an SVG should now be a PNG (-x.svg → -1024.png)
|
|
384
|
-
for (const [key, value] of Object.entries(
|
|
384
|
+
for (const [key, value] of Object.entries(brandImages)) {
|
|
385
385
|
assert.ok(
|
|
386
386
|
!String(value || '').endsWith('.svg'),
|
|
387
|
-
`
|
|
387
|
+
`brand.images.${key} should not be an SVG (got: ${value})`,
|
|
388
388
|
);
|
|
389
389
|
}
|
|
390
390
|
},
|
|
391
391
|
},
|
|
392
392
|
|
|
393
|
+
// --- Sender Resolution ---
|
|
394
|
+
|
|
395
|
+
{
|
|
396
|
+
name: 'sender-orders-resolves-from-and-asm',
|
|
397
|
+
auth: 'admin',
|
|
398
|
+
timeout: 30000,
|
|
399
|
+
|
|
400
|
+
async run({ http, assert, config }) {
|
|
401
|
+
const response = await http.post('admin/email', {
|
|
402
|
+
subject: 'BEM Test Email - Sender Orders',
|
|
403
|
+
to: `_test-receiver@${config.domain}`,
|
|
404
|
+
sender: 'orders',
|
|
405
|
+
copy: false,
|
|
406
|
+
data: {
|
|
407
|
+
email: {
|
|
408
|
+
subject: 'BEM Test Email - Sender Orders',
|
|
409
|
+
body: 'Testing sender resolution for orders.',
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
assert.isSuccess(response, 'Should send email with orders sender');
|
|
415
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
416
|
+
assert.ok(response.data.options.from.email.startsWith('orders@'), 'From email should start with orders@');
|
|
417
|
+
assert.ok(response.data.options.from.name.includes('Orders'), 'From name should include Orders');
|
|
418
|
+
assert.ok(response.data.options.asm, 'Should have ASM group');
|
|
419
|
+
assert.ok(response.data.options.replyTo.startsWith('orders@'), 'replyTo should match from address');
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
{
|
|
424
|
+
name: 'sender-security-resolves-from-asm-and-unsubscribe',
|
|
425
|
+
auth: 'admin',
|
|
426
|
+
timeout: 30000,
|
|
427
|
+
|
|
428
|
+
async run({ http, assert, config }) {
|
|
429
|
+
const response = await http.post('admin/email', {
|
|
430
|
+
subject: 'BEM Test Email - Sender Security',
|
|
431
|
+
to: `_test-receiver@${config.domain}`,
|
|
432
|
+
sender: 'security',
|
|
433
|
+
copy: false,
|
|
434
|
+
data: {
|
|
435
|
+
email: {
|
|
436
|
+
subject: 'BEM Test Email - Sender Security',
|
|
437
|
+
body: 'Testing that security sender resolves correctly.',
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
assert.isSuccess(response, 'Should send email with security sender');
|
|
443
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
444
|
+
assert.ok(response.data.options.from.email.startsWith('security@'), 'From email should start with security@');
|
|
445
|
+
assert.ok(response.data.options.asm, 'Should have ASM group');
|
|
446
|
+
assert.ok(response.data.options.headers['List-Unsubscribe'], 'Should have List-Unsubscribe header');
|
|
447
|
+
assert.ok(response.data.options.dynamicTemplateData.email.unsubscribeUrl, 'Should have unsubscribeUrl');
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
{
|
|
452
|
+
name: 'sender-explicit-from-overrides-sender',
|
|
453
|
+
auth: 'admin',
|
|
454
|
+
timeout: 30000,
|
|
455
|
+
|
|
456
|
+
async run({ http, assert, config }) {
|
|
457
|
+
const customFrom = { email: `custom@${config.domain}`, name: 'Custom Sender' };
|
|
458
|
+
|
|
459
|
+
const response = await http.post('admin/email', {
|
|
460
|
+
subject: 'BEM Test Email - From Override',
|
|
461
|
+
to: `_test-receiver@${config.domain}`,
|
|
462
|
+
sender: 'orders',
|
|
463
|
+
from: customFrom,
|
|
464
|
+
copy: false,
|
|
465
|
+
data: {
|
|
466
|
+
email: {
|
|
467
|
+
subject: 'BEM Test Email - From Override',
|
|
468
|
+
body: 'Testing that explicit from overrides sender.',
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
assert.isSuccess(response, 'Should send email with explicit from');
|
|
474
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
475
|
+
assert.equal(response.data.options.from.email, customFrom.email, 'Explicit from should override sender');
|
|
476
|
+
assert.equal(response.data.options.from.name, customFrom.name, 'Explicit from name should override sender');
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
{
|
|
481
|
+
name: 'sender-unknown-falls-back-to-defaults',
|
|
482
|
+
auth: 'admin',
|
|
483
|
+
timeout: 30000,
|
|
484
|
+
|
|
485
|
+
async run({ http, assert, config }) {
|
|
486
|
+
const response = await http.post('admin/email', {
|
|
487
|
+
subject: 'BEM Test Email - Unknown Sender',
|
|
488
|
+
to: `_test-receiver@${config.domain}`,
|
|
489
|
+
sender: 'nonexistent',
|
|
490
|
+
copy: false,
|
|
491
|
+
data: {
|
|
492
|
+
email: {
|
|
493
|
+
subject: 'BEM Test Email - Unknown Sender',
|
|
494
|
+
body: 'Testing that unknown sender falls back to brand defaults.',
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
assert.isSuccess(response, 'Should send email with default from');
|
|
500
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
501
|
+
assert.ok(response.data.options.from.email, 'Should have a from email (brand default)');
|
|
502
|
+
assert.ok(response.data.options.asm, 'Should have default ASM group');
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
|
|
393
506
|
{
|
|
394
507
|
name: 'sendat-iso-string-accepted',
|
|
395
508
|
auth: 'admin',
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Marketing lifecycle (add → sync → remove)
|
|
3
|
+
* End-to-end suite testing the full marketing contact flow via routes
|
|
4
|
+
*
|
|
5
|
+
* Requires TEST_EXTENDED_MODE=true and SENDGRID_API_KEY / BEEHIIV_API_KEY env vars.
|
|
6
|
+
* These tests hit real external APIs.
|
|
7
|
+
*/
|
|
8
|
+
module.exports = {
|
|
9
|
+
description: 'Marketing lifecycle (add → sync → remove)',
|
|
10
|
+
type: 'suite',
|
|
11
|
+
timeout: 60000,
|
|
12
|
+
skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE not set' : false,
|
|
13
|
+
|
|
14
|
+
tests: [
|
|
15
|
+
// Step 0: Pre-clean test contacts from providers (in case a previous run left them)
|
|
16
|
+
{
|
|
17
|
+
name: 'pre-clean-test-contacts',
|
|
18
|
+
auth: 'admin',
|
|
19
|
+
|
|
20
|
+
async run({ http, config, state }) {
|
|
21
|
+
const testEmail = `lifecycle.test+bem@${config.domain}`;
|
|
22
|
+
state.testEmail = testEmail;
|
|
23
|
+
|
|
24
|
+
// Delete from all providers — handles "not found" gracefully
|
|
25
|
+
await http.delete('marketing/contact', { email: testEmail }).catch(() => {});
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
// Step 1: Add a contact via POST /marketing/contact
|
|
30
|
+
{
|
|
31
|
+
name: 'add-contact',
|
|
32
|
+
auth: 'admin',
|
|
33
|
+
|
|
34
|
+
async run({ http, assert, state }) {
|
|
35
|
+
const response = await http.post('marketing/contact', {
|
|
36
|
+
email: state.testEmail,
|
|
37
|
+
firstName: 'Lifecycle',
|
|
38
|
+
lastName: 'Test',
|
|
39
|
+
source: 'bem-test-lifecycle',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
assert.isSuccess(response, 'Add contact should succeed');
|
|
43
|
+
assert.propertyEquals(response, 'data.success', true, 'success should be true');
|
|
44
|
+
|
|
45
|
+
if (process.env.SENDGRID_API_KEY) {
|
|
46
|
+
assert.hasProperty(response, 'data.providers.sendgrid', 'Should have SendGrid result');
|
|
47
|
+
assert.propertyEquals(response, 'data.providers.sendgrid.success', true, 'SendGrid add should succeed');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (process.env.BEEHIIV_API_KEY) {
|
|
51
|
+
assert.hasProperty(response, 'data.providers.beehiiv', 'Should have Beehiiv result');
|
|
52
|
+
assert.propertyEquals(response, 'data.providers.beehiiv.success', true, 'Beehiiv add should succeed');
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// Step 2: Sync by UID via PUT /marketing/contact
|
|
58
|
+
// Tests the full sync pipeline: UID resolution → buildFields → upsert with custom fields
|
|
59
|
+
{
|
|
60
|
+
name: 'sync-contact-by-uid',
|
|
61
|
+
auth: 'admin',
|
|
62
|
+
|
|
63
|
+
async run({ http, assert, accounts }) {
|
|
64
|
+
// Sync the admin test account — exercises UID→doc resolution + buildFields
|
|
65
|
+
const response = await http.put('marketing/contact', {
|
|
66
|
+
uid: accounts.admin.uid,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
assert.isSuccess(response, 'Sync contact should succeed');
|
|
70
|
+
assert.propertyEquals(response, 'data.success', true, 'success should be true');
|
|
71
|
+
|
|
72
|
+
if (process.env.SENDGRID_API_KEY) {
|
|
73
|
+
assert.hasProperty(response, 'data.providers.sendgrid', 'Should have SendGrid result');
|
|
74
|
+
assert.propertyEquals(response, 'data.providers.sendgrid.success', true, 'SendGrid sync should succeed');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (process.env.BEEHIIV_API_KEY) {
|
|
78
|
+
assert.hasProperty(response, 'data.providers.beehiiv', 'Should have Beehiiv result');
|
|
79
|
+
assert.propertyEquals(response, 'data.providers.beehiiv.success', true, 'Beehiiv sync should succeed');
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// Step 3: Remove the contact via DELETE /marketing/contact
|
|
85
|
+
{
|
|
86
|
+
name: 'remove-contact',
|
|
87
|
+
auth: 'admin',
|
|
88
|
+
|
|
89
|
+
async run({ http, assert, state }) {
|
|
90
|
+
const response = await http.delete('marketing/contact', {
|
|
91
|
+
email: state.testEmail,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
assert.isSuccess(response, 'Remove contact should succeed');
|
|
95
|
+
assert.propertyEquals(response, 'data.success', true, 'success should be true');
|
|
96
|
+
|
|
97
|
+
if (process.env.SENDGRID_API_KEY) {
|
|
98
|
+
assert.hasProperty(response, 'data.providers.sendgrid', 'Should have SendGrid result');
|
|
99
|
+
assert.propertyEquals(response, 'data.providers.sendgrid.success', true, 'SendGrid remove should succeed');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (process.env.BEEHIIV_API_KEY) {
|
|
103
|
+
assert.hasProperty(response, 'data.providers.beehiiv', 'Should have Beehiiv result');
|
|
104
|
+
assert.propertyEquals(response, 'data.providers.beehiiv.success', true, 'Beehiiv remove should succeed');
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// Step 4: Clean up the admin test contact that sync added
|
|
110
|
+
{
|
|
111
|
+
name: 'cleanup-synced-admin-contact',
|
|
112
|
+
auth: 'admin',
|
|
113
|
+
|
|
114
|
+
async run({ http, accounts }) {
|
|
115
|
+
await http.delete('marketing/contact', {
|
|
116
|
+
email: accounts.admin.email,
|
|
117
|
+
}).catch(() => {});
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|