Skip to content

feat(email): add bounded timeouts for outbound email providers - #349

Merged
telivity-otaip merged 6 commits into
mainfrom
cursor/email-bounded-transport-4a4f
Aug 27, 2026
Merged

feat(email): add bounded timeouts for outbound email providers#349
telivity-otaip merged 6 commits into
mainfrom
cursor/email-bounded-transport-4a4f

Conversation

@telivity-otaip

@telivity-otaip telivity-otaip commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds hard send deadlines for outbound guest email providers. Slow or hung SMTP/HTTP transports return outcomeUnknown at the deadline instead of blocking callers indefinitely.

Changes

  • New bounded-email-transport helper with configurable timeoutMs
  • HTTP (Mailgun, SendGrid, SES): hard outer deadline around fetch and response-body consumption — returns at timeoutMs even when abort is ignored; detached in-flight work gets a rejection handler to prevent unhandled rejections
  • SMTP: hard-close owned pool sockets at deadline via Promise.race; cleanup attached to sendMail settlement (scheduled late close is not cancelled from finally)
  • EmailSendOptions extended across SMTP, SES, Mailgun, and SendGrid providers
  • EmailService auto-retries definitely-not-sent failures only (default 3 attempts); never auto-retries outcomeUnknown
  • SMTP integration tests for bounded greeting stall, active-transaction hard-close, and late-close behavior

Public contract (delivery / retry / correlation)

Every send returns one of three outcomes via EmailResult.status:

Status Meaning Auto-retry?
sent Provider confirmed acceptance N/A (done)
notSent Definitely not accepted Yes (safe)
outcomeUnknown May have been accepted but response was lost No — record for reconciliation / manual resend

EmailMessage optionally accepts correlation metadata (not deduplication guarantees):

  • idempotencyKey — forwarded to providers as custom metadata (e.g. Mailgun v:haip-idempotency-key, SendGrid custom_args). Useful for log/trace correlation across retries. Not an exactly-once guarantee in Mailgun, SendGrid, SES, or SMTP.
  • messageId — stable RFC Message-ID reused across retries when the provider supports setting it. Does not prevent duplicate delivery.

How to test

pnpm --filter @telivityhaip/api exec vitest run src/modules/agent/guest-comms/

Contributor credit

@agustinjch

Add bounded-email-transport with configurable connect/send timeouts.
Extend EmailSendOptions across providers (SMTP, SES, Mailgun, SendGrid).
Add SMTP provider unit tests.

Co-authored-by: Agus <agustin.jch@gmail.com>
cursor Bot pushed a commit that referenced this pull request Aug 26, 2026
Remove email transport, webhook dedup, shared utils, test-count sync, and
core webhook migration changes that land in separate focused PRs. Document
booking-requests in README packages section and optional enablement steps.

Depends on #349, #350, #351, #352, and #353.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
@telivity-otaip
telivity-otaip marked this pull request as ready for review August 26, 2026 19:56
Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
@telivity-otaip

Copy link
Copy Markdown
Collaborator Author

@agustinjch

@agustinjch

Copy link
Copy Markdown
Collaborator

Thanks for splitting this out. I checked the PR at ddfc90e; the build/typecheck path and the focused guest-comms tests pass.

Before I confirm the merge, I think we should address these points:

  1. apps/api/package.json and pnpm-lock.yaml include @telivityhaip/booking-requests, including a lockfile importer for packages/booking-requests. That package is not part of this PR and belongs to feat(booking-requests): add optional booking-requests module #348, so feat(email): add bounded timeouts for outbound email providers #349 is carrying unrelated package state. Please remove those booking-request entries from this PR while keeping the justified nodemailer dependency.
  2. The PR says transports fail fast instead of blocking indefinitely, but boundedEmailFetch() aborts at the deadline and then continues awaiting fetch/body settlement; SMTP similarly closes the owned transport but still awaits sendMail(). If a transport does not settle after cancellation, the promise remains pending. Either make the settlement itself truly bounded or document this as a cooperative cancellation deadline and cover that contract explicitly.
  3. The stable idempotencyKey / messageId provider metadata is useful for retry safety, but it is additional public behavior beyond the timeout summary. Please mention it in the PR description so the contract is clear.

Non-blocking: the SMTP hard-close walks Nodemailer private internals (transporter._connections, _socket). The integration tests reduce the risk, but it would be good to isolate/document that version-specific adapter.

Once the unrelated package entries and timeout contract are cleaned up, I am happy to re-review this first.

… contract

Remove @telivityhaip/booking-requests from the email-only PR. Document
cooperative HTTP deadlines and idempotency metadata. SMTP returns at the
deadline via Promise.race while hard-closing owned pool sockets.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
Split Testing/Build table rows and normalize legacy concatenated rows in
sync-test-count.mjs per review.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
@telivity-otaip

Copy link
Copy Markdown
Collaborator Author

Thanks for the review — all four points addressed on bcd63ca (current head).

1. @telivityhaip/booking-requests removed
Dropped from apps/api/package.json and pnpm-lock.yaml. This PR only adds the justified nodemailer dependency for SMTP. No booking-requests package state.

2. Timeout contract — cooperative deadline, not “fail fast”
We did not leave hung promises pending:

  • HTTP: boundedEmailFetch() aborts at timeoutMs but waits for settlement before returning outcomeUnknown, so we don’t mark a delivery retry-eligible while the original request may still complete. Documented in bounded-email-transport.ts and EmailSendOptions.
  • SMTP: send() uses Promise.race — at the deadline we hard-close the owned pool and return outcomeUnknown even if sendMail is still settling.

PR description updated to say cooperative deadline instead of “fail fast.” Guest-comms tests still pass.

3. idempotencyKey / messageId
Documented in the PR description under Public contract (retry / idempotency) and in email-provider.interface.ts.

4. Nodemailer internals (non-blocking)
closeOwnedTransport documents that it uses version-specific pool/socket fields (_connections, _socket), covered by smtp-email.provider.spec.ts.

Ready for your re-review when you have a minute.

Conflicts were README badge/table/comment test totals only (1575 vs 1578).
Took main baseline and regenerated with sync-test-count: 1585/220.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
@agustinjch

Copy link
Copy Markdown
Collaborator

@telivity-otaip Thanks for the updates. I think the remaining issues are all fixable without removing retries altogether.

For email delivery, the important distinction is between:

  • sent: the provider confirmed acceptance
  • notSent: we know it was not accepted, so an automatic retry is safe
  • outcomeUnknown: it may have been accepted but the response was lost, so an automatic retry can duplicate the guest email

The current provider metadata and reused Message-ID are useful correlation keys, but they do not provide actual deduplication in Mailgun, SendGrid, SES, or SMTP. My recommendation is therefore to retain automatic retries for definitely-not-sent failures, but not automatically retry outcomeUnknown. Record that state for reconciliation or an explicit manual resend, and describe the idempotency key as correlation rather than an exactly-once guarantee.

Two smaller implementation fixes remain:

  1. Put a hard outer deadline around the entire HTTP operation, including response-body consumption. The operation should return at the deadline even if the underlying transport ignores abort; attach a background rejection handler so the detached work cannot produce an unhandled rejection.
  2. For SMTP, close on timeout and attach cleanup to the eventual settlement of sendMailPromise. The scheduled late close should not be cancelled immediately from finally.

With those changes, the retry behavior remains useful while the contract becomes accurate and guest emails are not automatically duplicated after ambiguous outcomes.

- Add EmailDeliveryStatus (sent/notSent/outcomeUnknown) to EmailResult
- EmailService auto-retries notSent only; never retries outcomeUnknown
- HTTP boundedEmailFetch: hard outer deadline incl. body consumption
- SMTP: cleanup on sendMail settlement; do not cancel late close in finally
- Clarify idempotencyKey/messageId as correlation, not exactly-once
- Update guest-comms tests for retry, HTTP deadline, and SMTP late-close

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
@telivity-otaip

Copy link
Copy Markdown
Collaborator Author

Thanks @agustinjch — all points from your latest review are addressed on 72e3aff.

1. Delivery contract (sent / notSent / outcomeUnknown)

  • EmailResult.status is now the canonical field (sent boolean kept as a convenience mirror).
  • Providers return sent, notSent, or outcomeUnknown via shared helpers in bounded-email-transport.ts.
  • EmailService auto-retries notSent only (default 3 attempts); outcomeUnknown is logged and not auto-retried.

2. Correlation vs deduplication

  • idempotencyKey / messageId documented as correlation metadata only — not exactly-once guarantees in Mailgun, SendGrid, SES, or SMTP.
  • PR description updated accordingly.

3. HTTP hard outer deadline

  • boundedEmailFetch() races the full fetch+body work against a hard timer; returns at deadline even when abort is ignored.
  • Detached in-flight work gets .catch(() => undefined) so no unhandled rejection.
  • Tests cover hanging fetch and hanging response body (fake timers).

4. SMTP late-close fix

  • On timeout: close transport + schedule setImmediate re-close; no clearImmediate in finally.
  • Cleanup attached to sendMailPromise.finally() instead.
  • New spec asserts clearImmediate is never called.

Tests: pnpm --filter @telivityhaip/api exec vitest run src/modules/agent/guest-comms/ — 54 passed.

Ready for re-review when you have a minute.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
@telivity-otaip
telivity-otaip merged commit 6641ca5 into main Aug 27, 2026
5 checks passed
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
Resolve guest-comms email stack conflicts using main (#349).
Regenerate README test counts after merge.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
Resolve README/test-stats conflicts after #349 merge; sync test counts.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
Resolve README/test-stats conflicts after #349/#350 merges; sync counts.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
--check now compares the entire README to applyCounts output so badge,
heading, command count, and malformed-row normalization cannot go stale
while CI still passes. Adds regression coverage for each managed site.

Rebuilt on current main (#349/#350/#351), regenerated counts: 1630/228.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
telivity-otaip added a commit that referenced this pull request Aug 27, 2026
--check now compares the entire README to applyCounts output so badge,
heading, command count, and malformed-row normalization cannot go stale
while CI still passes. Adds regression coverage for each managed site.

Rebuilt on current main (#349/#350/#351), regenerated counts: 1630/228.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
Resolve conflicts with #349#353: take main for confirmation-number,
connect events, and connect-booking token re-export; merge Stripe
financial-state so core one-arg classifyHaipMetadata coexists with
booking-request correlation helpers and webhook delegation.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
- Ship runnable migrator + SQL in dist; production db:migrate via node
- Wire VITE_HAIP_BOOKING_REQUESTS through Docker/compose/release
- Reject bookingMode=request when HAIP_BOOKING_REQUESTS is off
- Ledger + checksum for package migrations (auto-commit for PG enums)
- Drop unused stripeHandlerToken from root module options
- Fix EmailResult outcomeUnknown after #349 status contract
- Point regression/e2e installs at run-migrations.js (post-#350)
- Harden push-schema CLI path resolution; sync README (2175/259)

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
telivity-otaip added a commit that referenced this pull request Aug 28, 2026
* Add opt-in booking-requests package with core seams and UI gates

Introduce @telivityhaip/booking-requests as a deploy-time optional module
(HAIP_BOOKING_REQUESTS=true) with separate migrations, Stripe handler
delegation, and dashboard/booking widget feature flags. Core instant booking
paths stay unchanged when the flag is off.

Includes booking request API/controllers, schema split (0022-0032), email
transport hardening, webhook logicalEventId dedup, and CI release-gate job.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* refactor(booking-requests): slim package PR to booking-requests scope

Remove email transport, webhook dedup, shared utils, test-count sync, and
core webhook migration changes that land in separate focused PRs. Document
booking-requests in README packages section and optional enablement steps.

Depends on #349, #350, #351, #352, and #353.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(ci): restore core seams, push-schema columns, and README test counts

Re-include prerequisite core changes so typecheck and docker seed pass.
Add booking-requests to CI/Docker builds. Sync README to 2120 tests / 252 files.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(ci): add charge amendment columns to push-schema and webhook spec

push-schema now creates adjusts_charge_id and source_key so seed and
docker init succeed. Webhook logicalEventId spec uses reservation.created
from the core WEBHOOK_EVENTS catalog.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(docker): lazy-load booking-requests only when feature flag is on

Static imports pulled @telivityhaip/booking-requests into the default demo
image and crashed startup with missing @nestjs/common. Gate the optional
package behind HAIP_BOOKING_REQUESTS and read the flag from core seams.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(docker): preload optional booking-requests modules async

Avoid loading @telivityhaip/booking-requests at startup when
HAIP_BOOKING_REQUESTS is off (docker demo). Preload before Nest bootstrap
when the flag is enabled; read the flag from core payment seams in
DatabaseModule.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(api): type bootstrap cache as DynamicModule array

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(booking-requests): address Agustin packaging/safety review

- Ship runnable migrator + SQL in dist; production db:migrate via node
- Wire VITE_HAIP_BOOKING_REQUESTS through Docker/compose/release
- Reject bookingMode=request when HAIP_BOOKING_REQUESTS is off
- Ledger + checksum for package migrations (auto-commit for PG enums)
- Drop unused stripeHandlerToken from root module options
- Fix EmailResult outcomeUnknown after #349 status contract
- Point regression/e2e installs at run-migrations.js (post-#350)
- Harden push-schema CLI path resolution; sync README (2175/259)

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(database): keep logical_event_id out of push-schema baseline

#350 moved webhook logical_event_id to migration 0022. Remove the column
and unique index from push-schema so the pre-0022 upgrade regression
passes again.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* chore: sync README test counts to 2180/260

Matches CI after push-schema logical_event_id cleanup (all packages green).

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* test(booking-requests): flag-off regression gate + port PR #347 safety specs

- Add a flag-OFF default-install regression spec: only core migrations run,
  HAIP_BOOKING_REQUESTS is unset, AppModule boots without the
  booking-requests module/tables, request mode is rejected, and instant
  booking + deposit capture + partial/full refund still work. Runs
  automatically under `pnpm test` (apps/api's normal *.spec.ts glob), so it
  is wired into CI without any workflow changes.
- Extract the ephemeral-database subprocess helpers shared by that spec and
  the existing default-flow regression spec into
  regression-database-utils.ts (createdb/dropdb, sanitized child-process
  errors) instead of duplicating them.
- Port PR #347's booking-request-schema.spec.ts and
  booking-request-migration-safety.spec.ts into the package, scoped to the
  tables/migrations this package now owns (the duplicate push-schema DDL
  those specs cross-checked no longer exists — it moved into this package's
  migrations).
- Port PR #347's booking-request-remediation.postgres.spec.ts, replaying
  migration 0032's SQL directly (instead of through push-schema) since the
  ledger-based migrator can't be re-run against a manually-reverted schema.
  Kept opt-in via BOOKING_REQUEST_REMEDIATION_LIVE_PG like the original.
- Document remaining scope (vertical-slice move, push-schema de-pollution)
  in the package README instead of attempting it in this pass.
- Sync README/test-stats test counts (2211 tests, 263 files).

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* feat(booking-requests): package-owned port for booking_mode/payment_method_collection/form_questions

Adds BOOKING_REQUEST_CONFIG_FIELDS_PORT + DrizzleBookingRequestConfigFieldsAdapter
so the package owns reading/writing booking_engine_config's request-mode-only
columns via its own Drizzle table fragment, instead of core declaring them.
Wired into BookingRequestModule.forRoot() (global) so it's injectable into
core's BookingEngineConfigService without that module importing this package.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* chore(database): remove request-only DDL from core push-schema/drizzle

Removes booking_mode/payment_method_collection/form_questions ALTERs on
booking_engine_config, audit_logs.booking_request_id (+ its timeline index),
and the request-shape unique indexes/checks on payments
(payments_property_request_*_unique, booking_request_parent_positive_check,
booking_request_child_shape_check) from core's push-schema.ts and Drizzle
schema. These are now declared and migrated exclusively by
packages/booking-requests. payments.booking_request_id/idempotency_key,
reservations.accepted_pricing_snapshot, and charges
adjusts_charge_id/source_key stay in core as documented in
push-schema-kept-fields.spec.ts.

Also adds the DRIZZLE injection token to @telivityhaip/database so packages
outside apps/api (booking-requests) can inject the shared Drizzle client
without importing apps/api.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* refactor(booking-requests): move Nest vertical slice into package

Own controllers/services/DTOs behind package ports; strip request-only
audit DDL and payment indexes from core schema; keep thin config/payment hooks.

- Nest controllers, DTOs, services, Stripe handler, and pricing/money/
  state/db/ledger/reconciler/template helpers now live in
  packages/booking-requests/src (http/ + domain/), with their unit specs.
  BookingRequestModule.forRoot(...) is a real DynamicModule owning those
  controllers/providers directly, not a facade over apps/api classes.
- apps/api/src/modules/booking-request/ keeps only the e2e, authorization,
  default-flow-regression, flag-off-instant-booking.regression, and
  transaction-seams specs plus regression-database-utils.ts.
- apps/api/src/booking-requests.bootstrap.ts wires every package-local port
  (folio, webhook, email, reservation, rate-plan, guest, ancillary,
  availability, booking-engine, booking-engine-config, plus guard-bridge
  ports) to the concrete core singleton via `useExisting`.
- Guard bridge classes (BookingKeyGuardBridge, BookingEngineScopeGuardBridge,
  BookingThrottleGuardBridge) resolve the "guards landmine": @UseGuards(...)
  is populated from decorator metadata, a separate path from `providers`, so
  a bare useExisting binding on an abstract port class is silently dropped —
  the bridges are real injectable classes referenced in @UseGuards(...).
- DRIZZLE token declaration moved to @telivityhaip/database; apps/api's
  DatabaseModule still @Global-provides it and merges in the package's
  optional schema only when HAIP_BOOKING_REQUESTS is on.
- Relocated pure/shared pieces to packages/shared: SAVED_PAYMENT_METHOD_GATEWAY
  / PAYMENT_GATEWAY / BOOKING_REQUEST_STRIPE_HANDLER interfaces, IsMoneyString,
  canonical calendar date validators, stayDates, AuditActor helpers,
  RequirePermissions/Public decorators, stripe-financial-state helpers, and
  the pure payment-ledger math (remainingCapturedAmount/sumRefundChildren) —
  bookingRequestPaymentSumWhere stays in core payment-ledger.
- Schema de-pollution: removed audit_logs.booking_request_id (+ its timeline
  index) and the request-shape payments unique indexes/checks from core
  push-schema/drizzle; kept booking_engine_config.booking_mode /
  payment_method_collection / form_questions, payments.booking_request_id /
  idempotency_key, reservations.accepted_pricing_snapshot, and charges
  adjusts_charge_id/source_key as thin config/payment hooks core still reads
  directly. packages/database/src/push-schema-kept-fields.spec.ts guards the
  contract; packages/booking-requests declares its own local audit table
  extension for the timeline index it still owns.
- EventEmitterModule.forRoot() re-enabled `wildcard: true` — required for the
  webhook fan-out (@onevent('**')) to receive booking-request events.
- README's "Package boundary" section replaces the old "Remaining work"
  deferrals list with an accurate description of the current split.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(docker): ship shared node_modules for Nest peer in prod image

Shared now requires @nestjs/common at runtime after the booking-requests
boundary move; copy packages/shared/node_modules into the API image so
demo smoke can boot. Sync published test counts to 2222/266.

Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>

* fix(api): load AppModule after booking-requests preload

When HAIP_BOOKING_REQUESTS=true, AppModule evaluates bookingRequestsModules()
at import time; dynamic-import AppModule after preload so flag-on dev/prod
boot does not crash before Nest starts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants