backend-manager 5.11.4 → 5.11.6

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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/manager/events/cron/frequent/marketing-campaigns.js +6 -3
  3. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +4 -1
  4. package/src/manager/index.js +4 -4
  5. package/src/manager/libraries/prompts/infer-contact.md +5 -0
  6. package/src/manager/routes/marketing/contact/post.js +4 -1
  7. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-24-21/metadata.json +14 -0
  8. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-24-21/newsletter.html +486 -0
  9. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-24-21/newsletter.md +41 -0
  10. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-24-21/newsletter.mjml +97 -0
  11. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-24-21/structure.json +42 -0
  12. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-24-21/summary.md +1 -0
  13. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-25-51/metadata.json +14 -0
  14. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-25-51/newsletter.html +486 -0
  15. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-25-51/newsletter.md +41 -0
  16. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-25-51/newsletter.mjml +97 -0
  17. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-25-51/structure.json +42 -0
  18. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-25-51/summary.md +1 -0
  19. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-27-47/metadata.json +14 -0
  20. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-27-47/newsletter.html +486 -0
  21. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-27-47/newsletter.md +41 -0
  22. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-27-47/newsletter.mjml +97 -0
  23. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-27-47/structure.json +42 -0
  24. package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-07-03T23-27-47/summary.md +1 -0
  25. package/src/test/fixtures/firebase-project/.temp/test-mode.json +1 -1
  26. package/src/test/fixtures/firebase-project/database-debug.log +14 -8
  27. package/src/test/fixtures/firebase-project/firestore-debug.log +1345 -54
  28. package/src/test/fixtures/firebase-project/pubsub-debug.log +6 -4
  29. package/test/email/campaign-cron-pipeline.js +5 -1
  30. package/test/email/marketing-lifecycle.js +5 -0
  31. package/test/email/validation.js +42 -7
  32. package/test/events/auth-delete-race.js +11 -2
  33. package/test/functions/admin/edit-post.js +14 -10
  34. package/test/functions/general/add-marketing-contact.js +56 -16
  35. package/test/routes/admin/post.js +14 -10
  36. package/test/routes/marketing/contact.js +43 -10
@@ -1,17 +1,19 @@
1
1
  This is the Google Pub/Sub fake.
2
2
  Implementation may be incomplete or differ from the real system.
3
- Jul 02, 2026 1:48:11 AM com.google.cloud.pubsub.testing.v1.Main main
3
+ Jul 03, 2026 4:27:25 PM com.google.cloud.pubsub.testing.v1.Main main
4
4
  INFO: IAM integration is disabled. IAM policy methods and ACL checks are not supported
5
5
  SLF4J(W): No SLF4J providers were found.
6
6
  SLF4J(W): Defaulting to no-operation (NOP) logger implementation
7
7
  SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
8
8
  WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
9
- WARNING: sun.misc.Unsafe::allocateMemory has been called by io.netty.util.internal.PlatformDependent0$2 (file:/Users/ian/.cache/firebase/emulators/pubsub-emulator-0.8.33/pubsub-emulator/lib/cloud-pubsub-emulator-0.8.33-all.jar)
9
+ WARNING: sun.misc.Unsafe::allocateMemory has been called by io.netty.util.internal.PlatformDependent0$2 (file:/Users/ian/.cache/firebase/emulators/pubsub-emulator-0.8.34/pubsub-emulator/lib/cloud-pubsub-emulator-0.8.34-all.jar)
10
10
  WARNING: Please consider reporting this to the maintainers of class io.netty.util.internal.PlatformDependent0$2
11
11
  WARNING: sun.misc.Unsafe::allocateMemory will be removed in a future release
12
- Jul 02, 2026 1:48:11 AM com.google.cloud.pubsub.testing.v1.Main main
12
+ Jul 03, 2026 4:27:25 PM com.google.cloud.pubsub.testing.v1.Main main
13
13
  INFO: Server started, listening on 8085
14
- Jul 02, 2026 1:48:18 AM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
14
+ Jul 03, 2026 4:27:31 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
15
+ INFO: Detected HTTP/2 connection.
16
+ Jul 03, 2026 4:27:45 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
15
17
  INFO: Detected HTTP/2 connection.
16
18
  *** shutting down gRPC server since JVM is shutting down
17
19
  *** server shut down
@@ -255,7 +255,11 @@ module.exports = {
255
255
  const unknownGenerator = await firestore.get('marketing-campaigns/_test-unknown-generator');
256
256
  const unknownType = await firestore.get('marketing-campaigns/_test-unknown-type');
257
257
 
258
- return oneoff?.status !== 'pending'
258
+ // 'sent'/'failed' — NOT merely !== 'pending': the claim flips the
259
+ // doc to 'processing' first, and proceeding on that mid-flight
260
+ // state races the send (oneoff-campaign-processed then reads
261
+ // 'processing' and fails).
262
+ return ['sent', 'failed'].includes(oneoff?.status)
259
263
  && recurring?.sendAt !== state.pastSendAt
260
264
  && recurringStale?.sendAt !== state.staleSendAt
261
265
  && ['sent', 'failed'].includes(staleProcessing?.status)
@@ -37,6 +37,11 @@ module.exports = {
37
37
  firstName: 'Lifecycle',
38
38
  lastName: 'Test',
39
39
  source: 'bem-test-lifecycle',
40
+ // skipValidation bypasses the mailbox verification check — the lifecycle
41
+ // address has no real mailbox so the provider (correctly) marks it
42
+ // undeliverable. This suite tests add → sync → remove against the real
43
+ // providers, not deliverability (covered by email/validation + contact tests).
44
+ skipValidation: true,
40
45
  });
41
46
 
42
47
  assert.isSuccess(response, 'Add contact should succeed');
@@ -679,8 +679,10 @@ module.exports = {
679
679
  async run({ assert, skip }) {
680
680
  const result = await validate('disposable@gmail.com', { checks: ALL_CHECKS });
681
681
 
682
- if (result.checks.mailbox?.error?.includes('out of credits')) {
683
- skip('Mailbox verification out of credits');
682
+ // Provider errors fail open as { valid: true, error } with NO status field —
683
+ // out of credits, bad key, timeouts. Nothing assertable; skip.
684
+ if (result.checks.mailbox?.error) {
685
+ skip(`Mailbox verification unavailable: ${result.checks.mailbox.error}`);
684
686
  }
685
687
 
686
688
  assert.hasProperty(result, 'checks.mailbox', 'Should have mailbox check');
@@ -689,7 +691,7 @@ module.exports = {
689
691
  },
690
692
 
691
693
  {
692
- name: 'mailbox-fake-domain-fails',
694
+ name: 'mailbox-not-called-when-dns-fails',
693
695
  timeout: 15000,
694
696
  skip: !process.env.TEST_EXTENDED_MODE || !HAS_MAILBOX_API_KEY
695
697
  ? 'TEST_EXTENDED_MODE or mailbox API key not set'
@@ -698,12 +700,45 @@ module.exports = {
698
700
  async run({ assert, skip }) {
699
701
  const result = await validate('rachel.greene@thisfakedomain99999.com', { checks: ALL_CHECKS });
700
702
 
701
- if (result.checks.mailbox?.error?.includes('out of credits')) {
702
- skip('Mailbox verification out of credits');
703
+ // Transient resolver errors fail open (dns: { valid: true, skipped: true })
704
+ // and let mailbox run — the short-circuit can't be observed that run.
705
+ if (result.checks.dns?.skipped) {
706
+ skip(`DNS lookup errored (${result.checks.dns.reason}) — cannot observe short-circuit`);
703
707
  }
704
708
 
705
- assert.hasProperty(result, 'checks.mailbox.status', 'Should have status');
706
- assert.notEqual(result.checks.mailbox.status, 'valid', 'Fake domain should not be valid');
709
+ assert.equal(result.valid, false, 'Fake domain should be invalid');
710
+ assert.propertyEquals(result, 'checks.dns.valid', false, 'DNS check should fail (NXDOMAIN)');
711
+ assert.equal(
712
+ result.checks.mailbox,
713
+ undefined,
714
+ 'Mailbox must NOT run after dns fails — the short-circuit saves a paid API call'
715
+ );
716
+ },
717
+ },
718
+
719
+ {
720
+ name: 'mailbox-nonexistent-mailbox-not-valid',
721
+ timeout: 15000,
722
+ skip: !process.env.TEST_EXTENDED_MODE || !HAS_MAILBOX_API_KEY
723
+ ? 'TEST_EXTENDED_MODE or mailbox API key not set'
724
+ : false,
725
+
726
+ async run({ assert, skip }) {
727
+ // Real-MX domain (gmail passes dns) with a fabricated local part — this is
728
+ // the case that actually reaches the paid mailbox provider.
729
+ const result = await validate('rachel.greene.zk84qx@gmail.com', { checks: ALL_CHECKS });
730
+
731
+ assert.hasProperty(result, 'checks.mailbox', 'Should have mailbox check');
732
+
733
+ const mb = result.checks.mailbox;
734
+
735
+ // Provider errors fail open as { valid: true, error } with NO status; skip.
736
+ if (mb.error) {
737
+ skip(`Mailbox verification unavailable: ${mb.error}`);
738
+ }
739
+
740
+ assert.hasProperty(mb, 'status', 'Should have status');
741
+ assert.notEqual(mb.status, 'valid', 'Fabricated gmail mailbox should not verify as valid');
707
742
  },
708
743
  },
709
744
  ],
@@ -47,7 +47,7 @@ module.exports = {
47
47
 
48
48
  {
49
49
  name: 'no-wait-gets-clobbered',
50
- async run({ Manager, assert }) {
50
+ async run({ Manager, assert, skip }) {
51
51
  const admin = Manager.libraries.admin;
52
52
  const testUid = '_test-race-no-wait';
53
53
  const testEmail = '_test.race-no-wait@test.com';
@@ -76,7 +76,16 @@ module.exports = {
76
76
  const doc = await userRef.get();
77
77
  const survived = doc.exists && !!(doc.data()?.api?.clientId);
78
78
 
79
- assert.ok(!survived, 'Doc should be clobbered by late on-delete (proves the race exists)');
79
+ // The race resolves either way: when the emulator happens to finish
80
+ // the on-delete BEFORE the re-create's onCreate writes the doc, the
81
+ // dangerous late-clobber ordering never materializes that run —
82
+ // scheduler luck, not a regression. Only the clobber outcome is
83
+ // assertable; the benign ordering skips.
84
+ if (survived) {
85
+ await admin.auth().deleteUser(testUid).catch(() => {});
86
+ await pollUntilGone(userRef);
87
+ return skip('on-delete completed before the re-create this run — race did not manifest');
88
+ }
80
89
 
81
90
  await admin.auth().deleteUser(testUid).catch(() => {});
82
91
  await pollUntilGone(userRef);
@@ -243,10 +243,10 @@ module.exports = {
243
243
  {
244
244
  name: 'verify-edit',
245
245
  auth: 'admin',
246
- timeout: 30000,
246
+ timeout: 90000, // waitFor polls up to 60s for GitHub content propagation
247
247
  skip: !process.env.GH_TOKEN ? 'GH_TOKEN env var not set' : false,
248
248
 
249
- async run({ assert, state }) {
249
+ async run({ assert, state, waitFor }) {
250
250
  if (!state.postPath || state.editSkipped) {
251
251
  assert.ok(true, 'Skipping verify - edit was not performed');
252
252
  return;
@@ -254,15 +254,19 @@ module.exports = {
254
254
 
255
255
  const octokit = new Octokit({ auth: process.env.GH_TOKEN });
256
256
 
257
- // Fetch the file directly from GitHub
258
- const { data: fileData } = await octokit.rest.repos.getContent({
259
- owner: state.owner,
260
- repo: state.repo,
261
- path: state.postPath,
262
- });
257
+ // GitHub's contents API is eventually consistent after createOrUpdateFileContents —
258
+ // an immediate read-back can serve the pre-edit blob. Poll until the edit lands.
259
+ const content = await waitFor(async () => {
260
+ const { data: fileData } = await octokit.rest.repos.getContent({
261
+ owner: state.owner,
262
+ repo: state.repo,
263
+ path: state.postPath,
264
+ });
265
+
266
+ const decoded = Buffer.from(fileData.content, 'base64').toString();
263
267
 
264
- // Decode the content
265
- const content = Buffer.from(fileData.content, 'base64').toString();
268
+ return decoded.includes('EDITED by the BEM test suite') ? decoded : false;
269
+ }, 60000, 3000);
266
270
 
267
271
  assert.ok(
268
272
  content.includes('EDITED by the BEM test suite'),
@@ -7,9 +7,11 @@
7
7
  */
8
8
 
9
9
  // Test email patterns - look like real emails but +bem suffix identifies them for cleanup.
10
- // Fixed `acme.com` test domain — deterministic across brands. Avoids cross-brand
11
- // state contamination in SendGrid/Beehiiv when the same test runs under different brands.
12
- const TEST_DOMAIN = 'acme.com';
10
+ // Fixed `itwcreativeworks.com` test domain — deterministic across brands (avoids cross-brand
11
+ // state contamination in SendGrid/Beehiiv) and we control it: mailbox verdicts are stable
12
+ // and the AI won't flag it as a placeholder (which `acme.com` now is — empty names,
13
+ // confidence 0 — breaking inference).
14
+ const TEST_DOMAIN = 'itwcreativeworks.com';
13
15
  const TEST_EMAILS = {
14
16
  valid: () => `sarah.martinez+bem@${TEST_DOMAIN}`, // Should infer: Sarah Martinez
15
17
  invalid: () => `nonexistent.user+bem@${TEST_DOMAIN}`, // No such mailbox — mailbox verification should flag invalid
@@ -32,7 +34,11 @@ module.exports = {
32
34
  const response = await http.command('general:add-marketing-contact', {
33
35
  email: testEmail,
34
36
  source: 'bem-test',
35
- // No firstName/lastName - should be inferred as "Rachel Greene"
37
+ // No firstName/lastName - should be inferred
38
+ // skipValidation bypasses the mailbox verification check — the test email
39
+ // doesn't have a real mailbox so the provider (correctly) marks it as not
40
+ // deliverable. We're testing the command flow, not the deliverability check.
41
+ skipValidation: true,
36
42
  });
37
43
 
38
44
  assert.isSuccess(response, 'Add marketing contact should succeed for admin');
@@ -146,6 +152,10 @@ module.exports = {
146
152
  email: testEmail,
147
153
  source: 'bem-test',
148
154
  // No firstName/lastName - should be inferred
155
+ // skipValidation bypasses the mailbox verification check — this test's concern
156
+ // is AI name inference (which runs after validation); without it the mailbox
157
+ // check 400s the fabricated address before the inference asserts run.
158
+ skipValidation: true,
149
159
  });
150
160
 
151
161
  assert.isSuccess(response, 'Add marketing contact should succeed');
@@ -227,6 +237,27 @@ module.exports = {
227
237
  source: 'bem-test',
228
238
  });
229
239
 
240
+ // Outcome A: the provider rejected the fabricated mailbox → admin 400. Every
241
+ // earlier check passes deterministically for this address (format/localPart/
242
+ // disposable/corporate return their own dedicated messages, typo can't match,
243
+ // and the domain has real MX), so reaching the generic validation-failed 400
244
+ // proves the paid mailbox pipeline ran and rejected. Nothing was added, so
245
+ // cleanup is a no-op.
246
+ if (response.status === 400) {
247
+ assert.ok(
248
+ response.error?.includes('Email validation failed'),
249
+ `400 should be the validation-failed branch, got: ${response.error}`
250
+ );
251
+
252
+ if (response.error.includes('(dns)')) {
253
+ skip('Transient DNS failure — mailbox check never ran');
254
+ }
255
+
256
+ assert.ok(response.error.includes('(mailbox)'), `Failed check should be mailbox, got: ${response.error}`);
257
+ return;
258
+ }
259
+
260
+ // Outcome B: provider accepted (valid/catch-all/unknown) or failed open.
230
261
  assert.isSuccess(response, 'Add marketing contact should succeed');
231
262
 
232
263
  // Check that validation info is included
@@ -238,9 +269,10 @@ module.exports = {
238
269
 
239
270
  const mbResult = response.data.validation.checks.mailbox;
240
271
 
241
- // If out of credits, skip test - not a failure
242
- if (mbResult.error?.includes('out of credits')) {
243
- skip('Mailbox verification out of credits');
272
+ // Provider errors fail open as { valid: true, error, provider } with NO status
273
+ // field — out of credits, bad key, timeouts. Nothing assertable; skip.
274
+ if (mbResult.error) {
275
+ skip(`Mailbox verification unavailable: ${mbResult.error}`);
244
276
  }
245
277
 
246
278
  assert.hasProperty(mbResult, 'status', 'Mailbox check should return status');
@@ -268,7 +300,8 @@ module.exports = {
268
300
  : false,
269
301
 
270
302
  async run({ http, assert, skip }) {
271
- // Use fake email that mailbox verification should flag as invalid
303
+ // Email that should reach the mailbox provider and be flagged as undeliverable.
304
+ // Must NOT trip earlier checks (localPart blocklist, disposable, corporate).
272
305
  const testEmail = TEST_EMAILS.invalid();
273
306
 
274
307
  const response = await http.command('general:add-marketing-contact', {
@@ -276,20 +309,27 @@ module.exports = {
276
309
  source: 'bem-test',
277
310
  });
278
311
 
279
- // Should still succeed (we fail open) but mailbox should report invalid
280
- assert.isSuccess(response, 'Request should succeed even with invalid email');
281
-
282
312
  const mbResult = response.data?.validation?.checks?.mailbox;
283
313
 
284
- // If out of credits, skip test - not a failure
285
- if (mbResult?.error?.includes('out of credits')) {
286
- skip('Mailbox verification out of credits');
314
+ // Provider errors fail open with no status — the test can't exercise rejection; skip.
315
+ if (mbResult?.error) {
316
+ skip(`Mailbox verification unavailable: ${mbResult.error}`);
317
+ }
318
+
319
+ // If the response was a 400, that's the legitimate rejection path — done.
320
+ if (response.status === 400) {
321
+ if (response.error?.includes('(dns)')) {
322
+ skip('Transient DNS failure — mailbox check never ran');
323
+ }
324
+
325
+ assert.ok(response.error?.includes('(mailbox)'), `Failed check should be mailbox, got: ${response.error}`);
326
+ return;
287
327
  }
288
328
 
289
- // Mailbox should return a status indicating the email is not valid
329
+ // Otherwise expect a 200 with a non-"valid" mailbox status.
330
+ assert.isSuccess(response, 'Request should succeed (fail-open) or error 400');
290
331
  if (mbResult) {
291
332
  assert.hasProperty(mbResult, 'status', 'Should have status');
292
- // Status should NOT be 'valid' for this fake email
293
333
  assert.notEqual(mbResult.status, 'valid', 'Fake email should not be marked valid');
294
334
  }
295
335
  },
@@ -245,10 +245,10 @@ module.exports = {
245
245
  {
246
246
  name: 'verify-edit',
247
247
  auth: 'admin',
248
- timeout: 30000,
248
+ timeout: 90000, // waitFor polls up to 60s for GitHub content propagation
249
249
  skip: !process.env.GH_TOKEN ? 'GH_TOKEN env var not set' : false,
250
250
 
251
- async run({ assert, state }) {
251
+ async run({ assert, state, waitFor }) {
252
252
  if (!state.postPath || state.editSkipped) {
253
253
  assert.ok(true, 'Skipping verify - edit was not performed');
254
254
  return;
@@ -256,15 +256,19 @@ module.exports = {
256
256
 
257
257
  const octokit = new Octokit({ auth: process.env.GH_TOKEN });
258
258
 
259
- // Fetch the file directly from GitHub
260
- const { data: fileData } = await octokit.rest.repos.getContent({
261
- owner: state.owner,
262
- repo: state.repo,
263
- path: state.postPath,
264
- });
259
+ // GitHub's contents API is eventually consistent after createOrUpdateFileContents —
260
+ // an immediate read-back can serve the pre-edit blob. Poll until the edit lands.
261
+ const content = await waitFor(async () => {
262
+ const { data: fileData } = await octokit.rest.repos.getContent({
263
+ owner: state.owner,
264
+ repo: state.repo,
265
+ path: state.postPath,
266
+ });
267
+
268
+ const decoded = Buffer.from(fileData.content, 'base64').toString();
265
269
 
266
- // Decode the content
267
- const content = Buffer.from(fileData.content, 'base64').toString();
270
+ return decoded.includes('EDITED by the BEM test suite') ? decoded : false;
271
+ }, 60000, 3000);
268
272
 
269
273
  assert.ok(
270
274
  content.includes('EDITED by the BEM test suite'),
@@ -9,9 +9,11 @@
9
9
  // Test email patterns - look like real emails but +bem suffix identifies them for cleanup.
10
10
  // Names should be inferred by AI from the email local part.
11
11
  //
12
- // Fixed test domain (`acme.com`) — deterministic across brands. Using the running brand's
13
- // domain caused cross-brand state divergence in SendGrid/Beehiiv and non-deterministic
14
- // company inference (different domain different inferred company name).
12
+ // Fixed test domain (`itwcreativeworks.com`) — deterministic across brands (using the
13
+ // running brand's domain caused cross-brand state divergence in SendGrid/Beehiiv and
14
+ // non-deterministic company inference), and we control it: mailbox verdicts are stable
15
+ // and any double-opt-in email lands on our own domain. Was `acme.com`, but the AI now
16
+ // treats that domain as a placeholder (empty names, confidence 0), breaking inference.
15
17
  //
16
18
  // `valid`: use a name that won't be flagged as fictional/placeholder by the AI prompt.
17
19
  // (The infer-contact prompt rejects fictional names — e.g. "rachel.greene" sometimes
@@ -21,7 +23,7 @@
21
23
  // NOT start with "test"/"example" which are in BLOCKED_LOCAL_PATTERNS, NOT be on
22
24
  // a corporate/disposable domain). Real-looking name on a real domain with no actual
23
25
  // mailbox there is the safest pick.
24
- const TEST_DOMAIN = 'acme.com';
26
+ const TEST_DOMAIN = 'itwcreativeworks.com';
25
27
  const TEST_EMAILS = {
26
28
  valid: () => `sarah.martinez+bem@${TEST_DOMAIN}`, // Should infer: Sarah Martinez
27
29
  invalid: () => `nonexistent.user+bem@${TEST_DOMAIN}`, // No such mailbox — mailbox verification should flag as invalid
@@ -164,6 +166,10 @@ module.exports = {
164
166
  email: testEmail,
165
167
  source: 'bem-test',
166
168
  // No firstName/lastName - should be inferred
169
+ // skipValidation bypasses the mailbox verification check — this test's concern
170
+ // is AI name inference (which runs after validation); without it the mailbox
171
+ // check 400s the fabricated address before the inference asserts run.
172
+ skipValidation: true,
167
173
  });
168
174
 
169
175
  assert.isSuccess(response, 'Add marketing contact should succeed');
@@ -245,6 +251,27 @@ module.exports = {
245
251
  source: 'bem-test',
246
252
  });
247
253
 
254
+ // Outcome A: the provider rejected the fabricated mailbox → admin 400. Every
255
+ // earlier check passes deterministically for this address (format/localPart/
256
+ // disposable/corporate return their own dedicated messages, typo can't match,
257
+ // and the domain has real MX), so reaching the generic validation-failed 400
258
+ // proves the paid mailbox pipeline ran and rejected. Nothing was added, so
259
+ // cleanup is a no-op.
260
+ if (response.status === 400) {
261
+ assert.ok(
262
+ response.error?.includes('Email validation failed'),
263
+ `400 should be the validation-failed branch, got: ${response.error}`
264
+ );
265
+
266
+ if (response.error.includes('(dns)')) {
267
+ skip('Transient DNS failure — mailbox check never ran');
268
+ }
269
+
270
+ assert.ok(response.error.includes('(mailbox)'), `Failed check should be mailbox, got: ${response.error}`);
271
+ return;
272
+ }
273
+
274
+ // Outcome B: provider accepted (valid/catch-all/unknown) or failed open.
248
275
  assert.isSuccess(response, 'Add marketing contact should succeed');
249
276
 
250
277
  // Check that validation info is included
@@ -256,9 +283,10 @@ module.exports = {
256
283
 
257
284
  const mbResult = response.data.validation.checks.mailbox;
258
285
 
259
- // If out of credits, skip test - not a failure
260
- if (mbResult.error?.includes('out of credits')) {
261
- skip('Mailbox verification out of credits');
286
+ // Provider errors fail open as { valid: true, error, provider } with NO status
287
+ // field — out of credits, bad key, timeouts. Nothing assertable; skip.
288
+ if (mbResult.error) {
289
+ skip(`Mailbox verification unavailable: ${mbResult.error}`);
262
290
  }
263
291
 
264
292
  assert.hasProperty(mbResult, 'status', 'Mailbox check should return status');
@@ -301,13 +329,18 @@ module.exports = {
301
329
  // verify here is that mailbox check ran and didn't mark the email as `valid`.
302
330
  const mbResult = response.data?.validation?.checks?.mailbox;
303
331
 
304
- // If credits are out, the test can't actually exercise rejection skip.
305
- if (mbResult?.error?.includes('out of credits') || mbResult?.error?.includes('Invalid API key')) {
306
- skip('Mailbox verification out of credits');
332
+ // Provider errors fail open with no status — the test can't exercise rejection; skip.
333
+ if (mbResult?.error) {
334
+ skip(`Mailbox verification unavailable: ${mbResult.error}`);
307
335
  }
308
336
 
309
337
  // If the response was a 400, that's the legitimate rejection path — done.
310
338
  if (response.status === 400) {
339
+ if (response.error?.includes('(dns)')) {
340
+ skip('Transient DNS failure — mailbox check never ran');
341
+ }
342
+
343
+ assert.ok(response.error?.includes('(mailbox)'), `Failed check should be mailbox, got: ${response.error}`);
311
344
  return;
312
345
  }
313
346