@hogsend/engine 0.4.0 → 0.6.0

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.
@@ -15,7 +15,7 @@ import {
15
15
  importJobs,
16
16
  userEvents,
17
17
  } from "@hogsend/db";
18
- import { and, eq, gt, gte, inArray, isNull, sql } from "drizzle-orm";
18
+ import { and, eq, gt, gte, inArray, isNull, max, sql } from "drizzle-orm";
19
19
  import {
20
20
  computeExpiresAt,
21
21
  computeMaxDwellAt,
@@ -209,6 +209,18 @@ async function backfillJoins(opts: {
209
209
  // unset value would never be force-left.
210
210
  const maxDwellAt = computeMaxDwellAt(bucket);
211
211
 
212
+ // Historical dwell anchor (Section 6.3 / LOCKED DECISION 1). For a
213
+ // windowed/event criterion the anchor is `max(occurredAt)` of the qualifying
214
+ // event = "when they became dormant" (e.g. went-dormant = the last
215
+ // `app_opened`). The dwell gate reads `coalesce(dwellAnchorAt, enteredAt)`, so
216
+ // backfilled members start the dwell clock at their real historical instant
217
+ // rather than the deploy-time `enteredAt`. Shapes with no cheap per-matcher
218
+ // timestamp leave the anchor NULL (fall back to enteredAt). The live join path
219
+ // (handleJoin) never sets dwellAnchorAt, so post-deploy joins clock from their
220
+ // real enteredAt. Computed batched per chunk (one GROUP BY max(occurredAt),
221
+ // mirroring the priorCounts GROUP BY) — never per-user serial queries.
222
+ const anchorEvent = resolveDwellAnchorEvent(criteria);
223
+
212
224
  // Fix C (DEFERRED): backfilled fastExpiry rows are NOT armed with a
213
225
  // bucket:arm-expiry durable timer here — they are picked up by the next cron
214
226
  // sweep instead (reconcileBucketLeaves / reconcileBucketTtlLeaves are the
@@ -253,6 +265,35 @@ async function backfillJoins(opts: {
253
265
  priorCounts.map((r) => [r.userId, Number(r.cnt)]),
254
266
  );
255
267
 
268
+ // Batched dwell-anchor derivation (LOCKED DECISION 1): one GROUP BY
269
+ // max(occurredAt) over the qualifying event for THIS chunk, mirroring the
270
+ // priorCounts GROUP BY above (never per-user serial queries). Only computed
271
+ // when the criteria shape exposes a cheap per-matcher anchor event; an empty
272
+ // map leaves dwellAnchorAt NULL → the dwell gate falls back to enteredAt.
273
+ let anchorByUser = new Map<string, Date>();
274
+ if (anchorEvent != null) {
275
+ const anchors = await db
276
+ .select({
277
+ userId: userEvents.userId,
278
+ lastAt: max(userEvents.occurredAt),
279
+ })
280
+ .from(userEvents)
281
+ .where(
282
+ and(
283
+ eq(userEvents.event, anchorEvent),
284
+ inArray(userEvents.userId, chunk),
285
+ ),
286
+ )
287
+ .groupBy(userEvents.userId);
288
+ anchorByUser = new Map(
289
+ anchors
290
+ .filter(
291
+ (r): r is { userId: string; lastAt: Date } => r.lastAt != null,
292
+ )
293
+ .map((r) => [r.userId, r.lastAt]),
294
+ );
295
+ }
296
+
256
297
  const rows = chunk.map((userId) => ({
257
298
  userId,
258
299
  userEmail: emailByUser.get(userId) ?? null,
@@ -262,6 +303,8 @@ async function backfillJoins(opts: {
262
303
  entryCount: 1 + (priorByUser.get(userId) ?? 0),
263
304
  expiresAt: computeExpiresAt(bucket),
264
305
  maxDwellAt,
306
+ // Historical dwell anchor where derivable; NULL otherwise (→ enteredAt).
307
+ dwellAnchorAt: anchorByUser.get(userId) ?? null,
265
308
  lastEvaluatedAt: new Date(),
266
309
  }));
267
310
 
@@ -365,6 +408,7 @@ async function reevalLeaves(opts: {
365
408
  userEmail: row.userEmail,
366
409
  epoch: row.entryCount,
367
410
  source: "backfill",
411
+ reason: "criteria",
368
412
  });
369
413
  }
370
414
  leftCount += flipped.length;
@@ -504,6 +548,51 @@ async function selectCompositeMatchers(
504
548
  return matchers;
505
549
  }
506
550
 
551
+ /**
552
+ * Resolve the event whose `max(occurredAt)` is the historical dwell anchor for a
553
+ * backfilled member (LOCKED DECISION 1 / Section 6.3) — "when they became
554
+ * dormant". Returns an event name only for the windowed/event shapes that expose
555
+ * a cheap per-matcher timestamp; `null` for everything else (the anchor stays
556
+ * NULL and the dwell gate falls back to `enteredAt`):
557
+ *
558
+ * - a single windowed `event` criterion → its `eventName` (the last qualifying
559
+ * occurrence is the window boundary, e.g. the last `app_opened`).
560
+ * - the lapsed-active composite `all(event(X).exists(),
561
+ * event(X).within(W).not_exists())` → event X (the flagship went-dormant
562
+ * shape; the last X is when they lapsed).
563
+ *
564
+ * Other shapes (property/count composites, OR-of-absence, multi-event) have no
565
+ * single cheap per-matcher timestamp, so they keep a NULL anchor.
566
+ */
567
+ function resolveDwellAnchorEvent(criteria: ConditionEval): string | null {
568
+ if (criteria.type === "event") {
569
+ return criteria.within != null ? criteria.eventName : null;
570
+ }
571
+ // Lapsed-active composite — two legs on the SAME event X: an unwindowed
572
+ // exists() anchor and a windowed not_exists() leg. Mirrors
573
+ // isLapsedActiveComposite in bucket-reconcile.ts.
574
+ if (
575
+ criteria.type === "composite" &&
576
+ criteria.operator === "and" &&
577
+ criteria.conditions.length === 2
578
+ ) {
579
+ const existsLeg = criteria.conditions.find(
580
+ (c) => c.type === "event" && c.check === "exists" && c.within == null,
581
+ );
582
+ const notExistsLeg = criteria.conditions.find(
583
+ (c) => c.type === "event" && c.check === "not_exists" && c.within != null,
584
+ );
585
+ if (
586
+ existsLeg?.type === "event" &&
587
+ notExistsLeg?.type === "event" &&
588
+ existsLeg.eventName === notExistsLeg.eventName
589
+ ) {
590
+ return notExistsLeg.eventName;
591
+ }
592
+ }
593
+ return null;
594
+ }
595
+
507
596
  /**
508
597
  * Upsert the bucket's current criteria fingerprint onto `bucket_configs` (Section
509
598
  * 6.6 B). Mirrors the admin enable/disable onConflictDoUpdate target.
@@ -7,6 +7,7 @@ import {
7
7
  durationToMs,
8
8
  evaluateCondition,
9
9
  } from "@hogsend/core";
10
+ import type { JourneyMeta } from "@hogsend/core/types";
10
11
  import {
11
12
  bucketConfigs,
12
13
  bucketMemberships,
@@ -27,6 +28,7 @@ import {
27
28
  or,
28
29
  sql,
29
30
  } from "drizzle-orm";
31
+ import type { BucketLeaveReason } from "../buckets/bucket-reactions.js";
30
32
  import { shouldEmitJoin } from "../buckets/check-membership.js";
31
33
  import {
32
34
  BUCKET_EVENT_PREFIX,
@@ -97,12 +99,21 @@ export const bucketReconcileTask = hatchet.task({
97
99
  // kind:"manual" buckets are NEVER auto-recomputed (early-continue).
98
100
  if (bucket.kind === "manual" || !bucket.criteria) continue;
99
101
 
100
- // Process a bucket here iff a clock can flip its membership: a TIME-BASED
101
- // criteria window (criteria-driven leaves/joins) OR an unconditional
102
- // `maxDwell` TTL (membership-age-driven leaves). timeBased is honoured
103
- // explicitly OR inferred from a `within` window.
102
+ // Process a bucket here iff a clock can flip its membership OR fire a
103
+ // membership-age dwell: a TIME-BASED criteria window (criteria-driven
104
+ // leaves/joins), an unconditional `maxDwell` TTL (membership-age-driven
105
+ // leaves), OR a `dwell` reaction (membership-age-driven fire). timeBased is
106
+ // honoured explicitly OR inferred from a `within` window. The dwell-only
107
+ // bucket falls through and runs ONLY the dwell pass (the criteria pass is
108
+ // behind `if (timeBased)`, the TTL pass behind `if (bucket.maxDwell)`).
104
109
  const timeBased = isTimeBased(bucket);
105
- if (!timeBased && !bucket.maxDwell) continue;
110
+ const dwellReactions = journeyRegistry
111
+ .getAll()
112
+ .filter(
113
+ (j) => j.sourceBucketId === bucket.id && j.reactionKind === "dwell",
114
+ );
115
+ const hasDwell = dwellReactions.length > 0;
116
+ if (!timeBased && !bucket.maxDwell && !hasDwell) continue;
106
117
 
107
118
  try {
108
119
  if (timeBased) {
@@ -145,6 +156,21 @@ export const bucketReconcileTask = hatchet.task({
145
156
  bucket,
146
157
  });
147
158
  }
159
+
160
+ // Dwell pass — runs AFTER the TTL pass (ordering is load-bearing: a
161
+ // member force-left by maxDwell earlier this iteration is status='left'
162
+ // here, so the dwell scan's status='active' filter excludes it). Fires
163
+ // `bucket:dwell:<id>:<label>` over the continuously-dwelling active
164
+ // population at cron resolution (Section 6.4–6.6).
165
+ if (hasDwell) {
166
+ reconciled += await reconcileBucketDwell({
167
+ db,
168
+ logger,
169
+ journeyRegistry,
170
+ bucket,
171
+ dwellReactions,
172
+ });
173
+ }
148
174
  } catch (err) {
149
175
  logger.error("Bucket reconcile failed", {
150
176
  bucketId: bucket.id,
@@ -255,6 +281,8 @@ export const bucketExpiryTask = hatchet.durableTask({
255
281
  userEmail: input.userEmail,
256
282
  epoch: flipped.entryCount,
257
283
  source: "reconcile",
284
+ // Fast-expiry is a criteria re-confirm leave (Section 6.7).
285
+ reason: "criteria",
258
286
  });
259
287
 
260
288
  return { status: "left", rowId: flipped.id };
@@ -287,6 +315,7 @@ async function reconcileBucketLeaves(opts: {
287
315
  journeyRegistry,
288
316
  bucket,
289
317
  userIds: leaverIds,
318
+ reason: "criteria",
290
319
  });
291
320
  }
292
321
 
@@ -421,7 +450,14 @@ async function reconcileCompositeLeaves(opts: {
421
450
  }
422
451
 
423
452
  if (leaverIds.length === 0) return 0;
424
- return bulkLeave({ db, logger, journeyRegistry, bucket, userIds: leaverIds });
453
+ return bulkLeave({
454
+ db,
455
+ logger,
456
+ journeyRegistry,
457
+ bucket,
458
+ userIds: leaverIds,
459
+ reason: "criteria",
460
+ });
425
461
  }
426
462
 
427
463
  /**
@@ -462,6 +498,7 @@ async function reconcileBucketTtlLeaves(opts: {
462
498
  journeyRegistry,
463
499
  bucket,
464
500
  userIds: expired.map((r) => r.userId),
501
+ reason: "maxDwell",
465
502
  });
466
503
  }
467
504
 
@@ -478,8 +515,10 @@ async function bulkLeave(opts: {
478
515
  journeyRegistry: ReturnType<typeof getJourneyRegistrySingleton>;
479
516
  bucket: BucketMeta;
480
517
  userIds: string[];
518
+ /** Why these members leave — TTL passes "maxDwell", criteria passes "criteria". */
519
+ reason: BucketLeaveReason;
481
520
  }): Promise<number> {
482
- const { db, logger, journeyRegistry, bucket, userIds } = opts;
521
+ const { db, logger, journeyRegistry, bucket, userIds, reason } = opts;
483
522
 
484
523
  const dwellMs = bucket.minDwell ? durationToMs(bucket.minDwell) : 0;
485
524
  const dwellCutoff = dwellMs > 0 ? new Date(Date.now() - dwellMs) : null;
@@ -524,12 +563,171 @@ async function bulkLeave(opts: {
524
563
  userEmail: row.userEmail,
525
564
  epoch: row.entryCount,
526
565
  source: "reconcile",
566
+ reason,
527
567
  });
528
568
  }
529
569
 
530
570
  return flipped.length;
531
571
  }
532
572
 
573
+ /**
574
+ * Dwell pass for one bucket (Section 6.4–6.6). Fires `bucket:dwell:<id>:<label>`
575
+ * over the EXISTING continuously-dwelling active population at cron resolution —
576
+ * its unique value over `on("enter") + ctx.sleep`. Idempotent across sweeps,
577
+ * interoperable with maxDwell/fastExpiry, and routed through
578
+ * `emitBucketTransition` (NOT a raw push) for `userEvents`/exitOn/history/analytics
579
+ * parity (Section 6.1):
580
+ *
581
+ * - PUSH FIRST (at-least-once; the deterministic idempotencyKey + the userEvents
582
+ * dedup absorb a same-sweep retry), THEN stamp `dwellState` (the inter-sweep
583
+ * "already fired this membership" gate). The stamp's `status='active'` clause
584
+ * makes the leave/fastExpiry interop correct (a row flipped to `left` between
585
+ * SELECT and UPDATE no-ops).
586
+ * - The dwell clock is `coalesce(dwellAnchorAt, enteredAt)` — backfilled members
587
+ * use their derived historical anchor (LOCKED DECISION 1), live joins use
588
+ * enteredAt (anchor NULL).
589
+ * - Candidates are ordered `lastEvaluatedAt asc nulls first` (oldest-served-first)
590
+ * so a busy `every` bucket cannot starve members past BATCH_SIZE; the stamp
591
+ * bumps `lastEvaluatedAt`, advancing the cursor. Hitting BATCH_SIZE is logged
592
+ * once per sweep (visibility, not silent).
593
+ * - First-deploy quiet window: reuse `firstTimeBackfillIncomplete` so the
594
+ * pre-existing/backfilled population is not blasted before the first-time
595
+ * backfill has settled.
596
+ *
597
+ * `every` is fires-at-most-once-per-sweep, coalescing (one catch-up fire after a
598
+ * multi-interval outage); `dwellCount` is the deterministic interval ordinal
599
+ * `floor((sweepInstant - anchor) / offsetMs)` (gap-stable, NOT a fire count). For
600
+ * `after` the ordinal is always 1 (one-shot).
601
+ */
602
+ async function reconcileBucketDwell(opts: {
603
+ db: Database;
604
+ logger: Logger;
605
+ journeyRegistry: ReturnType<typeof getJourneyRegistrySingleton>;
606
+ bucket: BucketMeta;
607
+ dwellReactions: JourneyMeta[];
608
+ }): Promise<number> {
609
+ const { db, logger, journeyRegistry, bucket, dwellReactions } = opts;
610
+
611
+ // First-deploy quiet window: do not blast the pre-existing/backfilled
612
+ // population before the first-time backfill has settled (reuse the guard).
613
+ if (await firstTimeBackfillIncomplete(db, bucket)) return 0;
614
+
615
+ // Captured once per invocation and reused for the ordinal. The ordinal is
616
+ // floor((sweepInstant - anchor) / offsetMs), so it is grid-quantized: a Hatchet
617
+ // retry (a fresh fn() invocation, seconds–minutes later) lands in the SAME
618
+ // interval window and recomputes the SAME ordinal → SAME idempotencyKey →
619
+ // absorbed by the userEvents dedup. `after` is always ordinal 1 (fully stable).
620
+ // Residual edge: an `every` retry that straddles an interval boundary yields a
621
+ // new ordinal and thus one extra dwell fire — bounded to a single duplicate by
622
+ // the key, and only possible for sub-retry-window intervals. Documented, not
623
+ // load-bearing for the common (hours/days) intervals.
624
+ const sweepInstant = Date.now();
625
+ let fired = 0;
626
+
627
+ for (const reaction of dwellReactions) {
628
+ const schedule = reaction.dwellSchedule;
629
+ if (!schedule) continue;
630
+ const { label, after, every } = schedule;
631
+ const offsetMs = after ?? every;
632
+ if (offsetMs == null) continue;
633
+ const cutoff = new Date(sweepInstant - offsetMs);
634
+
635
+ // Continuous-member gate. coalesce(dwellAnchorAt, enteredAt) is the dwell
636
+ // clock. Oldest-served-first (Section 6.5).
637
+ const candidates = await db
638
+ .select({
639
+ id: bucketMemberships.id,
640
+ userId: bucketMemberships.userId,
641
+ userEmail: bucketMemberships.userEmail,
642
+ entryCount: bucketMemberships.entryCount,
643
+ anchor: sql<Date>`coalesce(${bucketMemberships.dwellAnchorAt}, ${bucketMemberships.enteredAt})`,
644
+ dwellState: bucketMemberships.dwellState,
645
+ })
646
+ .from(bucketMemberships)
647
+ .innerJoin(contacts, eq(contacts.externalId, bucketMemberships.userId))
648
+ .where(
649
+ and(
650
+ eq(bucketMemberships.bucketId, bucket.id),
651
+ eq(bucketMemberships.status, "active"),
652
+ isNull(bucketMemberships.deletedAt),
653
+ isNull(contacts.deletedAt),
654
+ // Fold the comparison into the fragment with an explicit cast: a JS
655
+ // Date passed to lte() against a raw sql`coalesce(...)` fragment has no
656
+ // column type to drive param encoding, so the pg driver throws on the
657
+ // Date (and the per-bucket try/catch would silently swallow it → 0
658
+ // dwell fires). Binding the ISO string + ::timestamptz is well-typed.
659
+ sql`coalesce(${bucketMemberships.dwellAnchorAt}, ${bucketMemberships.enteredAt}) <= ${cutoff.toISOString()}::timestamptz`,
660
+ ),
661
+ )
662
+ .orderBy(sql`${bucketMemberships.lastEvaluatedAt} asc nulls first`)
663
+ .limit(BATCH_SIZE);
664
+
665
+ if (candidates.length >= BATCH_SIZE) {
666
+ logger.warn("Bucket dwell pass bounded to BATCH_SIZE/tick", {
667
+ bucketId: bucket.id,
668
+ label,
669
+ batchSize: BATCH_SIZE,
670
+ });
671
+ }
672
+
673
+ for (const m of candidates) {
674
+ const state = (m.dwellState ?? {}) as Record<string, string>;
675
+ const lastFired = state[label] ? Date.parse(state[label]) : null;
676
+ const anchorMs = new Date(m.anchor).getTime();
677
+
678
+ if (after != null) {
679
+ // one-shot: already fired for this membership → skip.
680
+ if (lastFired != null) continue;
681
+ } else {
682
+ // every: not yet due since the last fire (or the anchor).
683
+ const since = lastFired ?? anchorMs;
684
+ if (sweepInstant - since < offsetMs) continue;
685
+ }
686
+
687
+ // Deterministic per (membership, sweepInstant) so a retry recomputes it.
688
+ const ordinal =
689
+ after != null ? 1 : Math.floor((sweepInstant - anchorMs) / offsetMs);
690
+
691
+ // PUSH FIRST (at-least-once; idempotencyKey + userEvents dedup absorb
692
+ // retries), THEN stamp. emitBucketTransition handles the
693
+ // userEvents/exitOn/analytics parity.
694
+ await emitBucketTransition({
695
+ db,
696
+ registry: journeyRegistry,
697
+ hatchet,
698
+ logger,
699
+ kind: "dwell",
700
+ bucket,
701
+ userId: m.userId,
702
+ userEmail: m.userEmail,
703
+ epoch: m.entryCount,
704
+ source: "reconcile",
705
+ dwellLabel: label,
706
+ dwellOrdinal: ordinal,
707
+ });
708
+
709
+ // Stamp the membership (inter-sweep gate). status='active' clause = leave
710
+ // interop (a row flipped to 'left' between SELECT and UPDATE no-ops).
711
+ await db
712
+ .update(bucketMemberships)
713
+ .set({
714
+ dwellState: sql`jsonb_set(coalesce(${bucketMemberships.dwellState}, '{}'::jsonb), ${`{${label}}`}, ${`"${new Date(sweepInstant).toISOString()}"`}::jsonb)`,
715
+ lastEvaluatedAt: new Date(),
716
+ updatedAt: new Date(),
717
+ })
718
+ .where(
719
+ and(
720
+ eq(bucketMemberships.id, m.id),
721
+ eq(bucketMemberships.status, "active"),
722
+ ),
723
+ );
724
+ fired += 1;
725
+ }
726
+ }
727
+
728
+ return fired;
729
+ }
730
+
533
731
  /**
534
732
  * reconcileJoins (absence buckets): materialize NEW members the real-time path
535
733
  * cannot see — a user who STOPS doing X fires no event, so only the clock can
@@ -16,7 +16,6 @@ export const checkAlertsTask = hatchet.task({
16
16
  await checkAlertRules({
17
17
  db,
18
18
  logger,
19
- resendApiKey: process.env.RESEND_API_KEY,
20
19
  });
21
20
 
22
21
  return { checked: true };
@@ -1,22 +1,16 @@
1
1
  import { NonRetryableError } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
- import { createResendClient } from "@hogsend/plugin-resend";
2
+ import { EmailSendError } from "@hogsend/email";
3
+ import { getEmailService } from "../lib/email.js";
3
4
  import { hatchet } from "../lib/hatchet.js";
4
5
 
5
- const resend = createResendClient({
6
- apiKey: process.env.RESEND_API_KEY ?? "",
7
- });
8
-
9
- const NON_RETRYABLE_CODES = new Set([
10
- "validation_error",
11
- "missing_required_field",
12
- "invalid_api_key",
13
- "not_found",
14
- "restricted_api_key",
15
- ]);
16
-
17
6
  export const sendEmailTask = hatchet.task({
18
7
  name: "send-email",
19
- retries: 3,
8
+ // The EmailProvider owns transient-failure backoff internally (classified
9
+ // exponential retry in its `send`), and permanent failures fail fast below via
10
+ // NonRetryableError — so Hatchet's retry is just ONE durability re-attempt for a
11
+ // worker crash/timeout, not a second transient-retry loop layered on the
12
+ // provider's. (Previously 3, which multiplied with the provider's own retries.)
13
+ retries: 1,
20
14
  executionTimeout: "30s",
21
15
  backoff: { factor: 2, maxSeconds: 30 },
22
16
  fn: async (input: {
@@ -28,27 +22,35 @@ export const sendEmailTask = hatchet.task({
28
22
  tags?: Array<{ name: string; value: string }>;
29
23
  headers?: Record<string, string>;
30
24
  }) => {
31
- const { data, error } = await resend.emails.send({
32
- from:
33
- input.from ??
34
- process.env.RESEND_FROM_EMAIL ??
35
- "Hogsend <noreply@hogsend.com>",
36
- to: input.to,
37
- subject: input.subject,
38
- html: input.html,
39
- replyTo: input.replyTo,
40
- tags: input.tags,
41
- headers: input.headers,
42
- });
25
+ // Deliver through the injected, provider-backed mailer (set by
26
+ // createHogsendClient → setEmailService). `sendRaw` calls the swappable
27
+ // EmailProvider's `send`, resolving the default `from` from the mailer
28
+ // config — so a swapped provider is honored and this task no longer
29
+ // constructs a raw Resend client of its own. The provider already retries
30
+ // transient failures internally and surfaces a classified EmailSendError;
31
+ // map a non-retryable one to Hatchet's NonRetryableError so the task's own
32
+ // retry/backoff doesn't re-attempt a permanent failure.
33
+ const emailService = getEmailService();
43
34
 
44
- if (error) {
45
- const name = (error as { name?: string }).name ?? "";
46
- if (NON_RETRYABLE_CODES.has(name)) {
47
- throw new NonRetryableError(`${name}: ${error.message}`);
35
+ try {
36
+ // `from` is optional: when absent the mailer's `resolveFrom` falls back to
37
+ // its configured defaultFrom (env.RESEND_FROM_EMAIL).
38
+ const result = await emailService.sendRaw({
39
+ from: input.from,
40
+ to: input.to,
41
+ subject: input.subject,
42
+ html: input.html,
43
+ replyTo: input.replyTo,
44
+ tags: input.tags,
45
+ headers: input.headers,
46
+ });
47
+
48
+ return { emailId: result.id };
49
+ } catch (error) {
50
+ if (error instanceof EmailSendError && !error.retryable) {
51
+ throw new NonRetryableError(error.message);
48
52
  }
49
- throw new Error(`Failed to send email: ${error.message}`);
53
+ throw error;
50
54
  }
51
-
52
- return { emailId: data?.id ?? "" };
53
55
  },
54
56
  });