feat(email): add bounded timeouts for outbound email providers - #349
Conversation
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>
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>
Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
|
Thanks for splitting this out. I checked the PR at Before I confirm the merge, I think we should address these points:
Non-blocking: the SMTP hard-close walks Nodemailer private internals ( 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>
Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
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>
|
Thanks for the review — all four points addressed on 1. 2. Timeout contract — cooperative deadline, not “fail fast”
PR description updated to say cooperative deadline instead of “fail fast.” Guest-comms tests still pass. 3. 4. Nodemailer internals (non-blocking) 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>
|
@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:
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 Two smaller implementation fixes remain:
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>
|
Thanks @agustinjch — all points from your latest review are addressed on 1. Delivery contract (
2. Correlation vs deduplication
3. HTTP hard outer deadline
4. SMTP late-close fix
Tests: Ready for re-review when you have a minute. |
Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
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>
Resolve README/test-stats conflicts after #349 merge; sync test counts. Co-authored-by: telivity-otaip <telivity-otaip@users.noreply.github.com>
--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>
--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>
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>
- 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>
* 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.
Summary
Adds hard send deadlines for outbound guest email providers. Slow or hung SMTP/HTTP transports return
outcomeUnknownat the deadline instead of blocking callers indefinitely.Changes
bounded-email-transporthelper with configurabletimeoutMstimeoutMseven when abort is ignored; detached in-flight work gets a rejection handler to prevent unhandled rejectionsPromise.race; cleanup attached tosendMailsettlement (scheduled late close is not cancelled fromfinally)EmailSendOptionsextended across SMTP, SES, Mailgun, and SendGrid providersEmailServiceauto-retries definitely-not-sent failures only (default 3 attempts); never auto-retriesoutcomeUnknownPublic contract (delivery / retry / correlation)
Every send returns one of three outcomes via
EmailResult.status:sentnotSentoutcomeUnknownEmailMessageoptionally accepts correlation metadata (not deduplication guarantees):idempotencyKey— forwarded to providers as custom metadata (e.g. Mailgunv:haip-idempotency-key, SendGridcustom_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