backend-manager 5.0.148 → 5.0.150

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/CLAUDE.md +26 -0
  3. package/package.json +1 -1
  4. package/src/cli/commands/emulator.js +14 -4
  5. package/src/cli/commands/test.js +4 -10
  6. package/src/manager/cron/daily/ghostii-auto-publisher.js +25 -25
  7. package/src/manager/cron/frequent/abandoned-carts.js +7 -5
  8. package/src/manager/cron/frequent/email-queue.js +56 -0
  9. package/src/manager/events/auth/before-signin.js +3 -0
  10. package/src/manager/events/auth/on-delete.js +8 -0
  11. package/src/manager/events/firestore/payments-disputes/on-write.js +2 -1
  12. package/src/manager/events/firestore/payments-webhooks/on-write.js +9 -0
  13. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +7 -21
  14. package/src/manager/functions/core/actions/api/admin/get-stats.js +2 -2
  15. package/src/manager/functions/core/actions/api/admin/send-email.js +14 -14
  16. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +21 -319
  17. package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +1 -1
  18. package/src/manager/functions/core/actions/api/general/remove-marketing-contact.js +2 -186
  19. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  20. package/src/manager/functions/core/actions/api/special/setup-electron-manager-client.js +2 -2
  21. package/src/manager/functions/core/actions/api/test/health.js +1 -0
  22. package/src/manager/helpers/api-manager.js +2 -2
  23. package/src/manager/helpers/user.js +3 -1
  24. package/src/manager/index.js +15 -10
  25. package/src/manager/libraries/email/constants.js +240 -0
  26. package/src/manager/libraries/email/index.js +136 -0
  27. package/src/manager/libraries/email/marketing/index.js +370 -0
  28. package/src/manager/libraries/email/providers/beehiiv.js +274 -0
  29. package/src/manager/libraries/email/providers/sendgrid.js +429 -0
  30. package/src/manager/libraries/{email.js → email/transactional/index.js} +91 -99
  31. package/src/manager/libraries/email/validation.js +168 -0
  32. package/src/manager/routes/admin/cron/post.js +3 -3
  33. package/src/manager/routes/admin/email/post.js +1 -1
  34. package/src/manager/routes/admin/stats/get.js +2 -2
  35. package/src/manager/routes/{app → brand}/get.js +1 -1
  36. package/src/manager/routes/general/email/templates/download-app-link.js +1 -1
  37. package/src/manager/routes/marketing/contact/delete.js +2 -165
  38. package/src/manager/routes/marketing/contact/post.js +42 -298
  39. package/src/manager/routes/marketing/contact/put.js +39 -0
  40. package/src/manager/routes/payments/cancel/post.js +11 -0
  41. package/src/manager/routes/special/electron-client/post.js +3 -3
  42. package/src/manager/routes/test/health/get.js +1 -0
  43. package/src/manager/routes/user/data-request/delete.js +2 -2
  44. package/src/manager/routes/user/data-request/get.js +2 -2
  45. package/src/manager/routes/user/data-request/post.js +2 -2
  46. package/src/manager/routes/user/delete.js +1 -1
  47. package/src/manager/routes/user/feedback/post.js +12 -8
  48. package/src/manager/routes/user/signup/post.js +48 -37
  49. package/src/manager/schemas/admin/email/post.js +4 -4
  50. package/src/manager/schemas/marketing/contact/delete.js +0 -1
  51. package/src/manager/schemas/marketing/contact/post.js +0 -1
  52. package/src/manager/schemas/marketing/contact/put.js +6 -0
  53. package/src/manager/schemas/special/electron-client/post.js +2 -2
  54. package/src/manager/schemas/user/feedback/post.js +2 -2
  55. package/src/test/run-tests.js +1 -1
  56. package/src/test/runner.js +22 -10
  57. package/src/test/test-accounts.js +9 -0
  58. package/src/test/utils/extended-mode-warning.js +11 -0
  59. package/templates/backend-manager-config.json +9 -0
  60. package/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
  61. package/test/events/payments/journey-payments-trial-cancel.js +11 -0
  62. package/test/functions/admin/edit-post.js +2 -2
  63. package/test/functions/admin/write-repo-content.js +2 -2
  64. package/test/functions/general/add-marketing-contact.js +21 -62
  65. package/test/helpers/email-validation.js +420 -0
  66. package/test/helpers/email.js +119 -6
  67. package/test/helpers/marketing-lifecycle.js +121 -0
  68. package/test/helpers/user.js +2 -2
  69. package/test/routes/admin/create-post.js +2 -2
  70. package/test/routes/admin/post.js +2 -2
  71. package/test/routes/admin/repo-content.js +2 -2
  72. package/test/routes/marketing/contact.js +21 -61
  73. package/test/routes/payments/cancel.js +18 -0
  74. /package/src/manager/schemas/{app → brand}/get.js +0 -0
@@ -206,48 +206,9 @@ module.exports = {
206
206
  },
207
207
  },
208
208
 
209
- // Test 7: Admin can specify providers
209
+ // Test 7: Mailbox verification (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
210
210
  {
211
- name: 'admin-specify-providers',
212
- auth: 'admin',
213
- timeout: 30000,
214
-
215
- async run({ http, assert, config, state }) {
216
- const testEmail = TEST_EMAILS.valid(config.domain);
217
- state.testEmail = testEmail;
218
-
219
- const response = await http.command('general:add-marketing-contact', {
220
- email: testEmail,
221
- source: 'bem-test',
222
- providers: ['sendgrid'], // Only SendGrid, not Beehiiv
223
- // No firstName/lastName - should be inferred as "Rachel Greene"
224
- });
225
-
226
- assert.isSuccess(response, 'Add marketing contact with specific providers should succeed');
227
-
228
- // Only check providers if TEST_EXTENDED_MODE is set (external APIs are called)
229
- if (process.env.TEST_EXTENDED_MODE) {
230
- // Should only have sendgrid result
231
- if (response.data?.providers) {
232
- assert.hasProperty(response.data.providers, 'sendgrid', 'Should have SendGrid result');
233
- }
234
- state.sendgridAdded = response.data?.providers?.sendgrid?.success;
235
- // Beehiiv not called since we only specified sendgrid
236
- }
237
- },
238
-
239
- async cleanup({ state, http }) {
240
- if (!process.env.TEST_EXTENDED_MODE || !state.testEmail) {
241
- return;
242
- }
243
-
244
- await http.command('general:remove-marketing-contact', { email: state.testEmail });
245
- },
246
- },
247
-
248
- // Test 8: ZeroBounce validation (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
249
- {
250
- name: 'zerobounce-validation',
211
+ name: 'mailbox-validation',
251
212
  auth: 'admin',
252
213
  timeout: 30000,
253
214
  skip: !process.env.TEST_EXTENDED_MODE || !process.env.ZEROBOUNCE_API_KEY
@@ -261,7 +222,6 @@ module.exports = {
261
222
  const response = await http.command('general:add-marketing-contact', {
262
223
  email: testEmail,
263
224
  source: 'bem-test',
264
- // No firstName/lastName - should be inferred as "Rachel Greene"
265
225
  });
266
226
 
267
227
  assert.isSuccess(response, 'Add marketing contact should succeed');
@@ -270,17 +230,17 @@ module.exports = {
270
230
  assert.hasProperty(response, 'data.validation', 'Response should contain validation');
271
231
  assert.hasProperty(response, 'data.validation.checks', 'Validation should contain checks');
272
232
 
273
- // ZeroBounce should be in checks when key is set
274
- assert.hasProperty(response, 'data.validation.checks.zerobounce', 'Should have ZeroBounce check');
233
+ // Mailbox check should be in checks when key is set
234
+ assert.hasProperty(response, 'data.validation.checks.mailbox', 'Should have mailbox check');
275
235
 
276
- const zbResult = response.data.validation.checks.zerobounce;
236
+ const mbResult = response.data.validation.checks.mailbox;
277
237
 
278
- // If ZeroBounce is out of credits, skip test - not a failure
279
- if (zbResult.error?.includes('out of credits')) {
280
- skip('ZeroBounce out of credits');
238
+ // If out of credits, skip test - not a failure
239
+ if (mbResult.error?.includes('out of credits')) {
240
+ skip('Mailbox verification out of credits');
281
241
  }
282
242
 
283
- assert.hasProperty(zbResult, 'status', 'ZeroBounce should return status');
243
+ assert.hasProperty(mbResult, 'status', 'Mailbox check should return status');
284
244
 
285
245
  state.sendgridAdded = response.data?.providers?.sendgrid?.success;
286
246
  state.beehiivAdded = response.data?.providers?.beehiiv?.success;
@@ -295,9 +255,9 @@ module.exports = {
295
255
  },
296
256
  },
297
257
 
298
- // Test 9: ZeroBounce rejects invalid email (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
258
+ // Test 9: Mailbox verification rejects invalid email (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
299
259
  {
300
- name: 'zerobounce-rejects-invalid',
260
+ name: 'mailbox-rejects-invalid',
301
261
  auth: 'admin',
302
262
  timeout: 30000,
303
263
  skip: !process.env.TEST_EXTENDED_MODE || !process.env.ZEROBOUNCE_API_KEY
@@ -305,30 +265,29 @@ module.exports = {
305
265
  : false,
306
266
 
307
267
  async run({ http, assert, skip }) {
308
- // Use fake email that ZeroBounce should flag as invalid
268
+ // Use fake email that mailbox verification should flag as invalid
309
269
  const testEmail = TEST_EMAILS.invalid();
310
270
 
311
271
  const response = await http.command('general:add-marketing-contact', {
312
272
  email: testEmail,
313
273
  source: 'bem-test',
314
- // No firstName/lastName - AI will try to infer from "test"
315
274
  });
316
275
 
317
- // Should still succeed (we fail open) but ZeroBounce should report invalid
276
+ // Should still succeed (we fail open) but mailbox should report invalid
318
277
  assert.isSuccess(response, 'Request should succeed even with invalid email');
319
278
 
320
- const zbResult = response.data?.validation?.checks?.zerobounce;
279
+ const mbResult = response.data?.validation?.checks?.mailbox;
321
280
 
322
- // If ZeroBounce is out of credits, skip test - not a failure
323
- if (zbResult?.error?.includes('out of credits')) {
324
- skip('ZeroBounce out of credits');
281
+ // If out of credits, skip test - not a failure
282
+ if (mbResult?.error?.includes('out of credits')) {
283
+ skip('Mailbox verification out of credits');
325
284
  }
326
285
 
327
- // ZeroBounce should return a status indicating the email is not valid
328
- if (zbResult) {
329
- assert.hasProperty(zbResult, 'status', 'Should have status');
286
+ // Mailbox should return a status indicating the email is not valid
287
+ if (mbResult) {
288
+ assert.hasProperty(mbResult, 'status', 'Should have status');
330
289
  // Status should NOT be 'valid' for this fake email
331
- assert.notEqual(zbResult.status, 'valid', 'Fake email should not be marked valid');
290
+ assert.notEqual(mbResult.status, 'valid', 'Fake email should not be marked valid');
332
291
  }
333
292
  },
334
293
  },
@@ -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
+ };