Files
pn-new-crm/docs/audit-findings-tmp/07-email-integrations.md
Matt 4b5f85cb7d fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00

8.5 KiB
Raw Blame History

Email + Integrations Audit (EM-01-19, IN-01-29) — agent #7

Headline: Broadly well-implemented. Primary issue: missing SMTP timeouts on sales transporter (HIGH — risks worker starvation). Plus 8 medium gaps in portal-email portId scoping, digest catalog key, receipt scanner config, presign TTL.

Counts: 0 critical · 1 high · 8 medium · 0 low · 30 passing


🟠 HIGH EM-XX: Sales transporter missing SMTP timeouts

  • File: src/lib/services/sales-email-config.service.ts:331-337
  • What: createSalesTransporter builds nodemailer transport with no timeout options. Compare createTransporter in src/lib/email/index.ts:26-37 which uses SMTP_TIMEOUTS = { connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 30_000 }.
  • Why it matters: Hung SMTP relay can stall send-out indefinitely. Email queue concurrency=5, maxAttempts=5. Without socket timeouts, one stuck TCP connection holds a worker for nodemailer's 2-min default × 5 retries = 10min/job × 5 slots = whole pool blocked for 10min by a single flaky send.
  • Suggested fix: Apply SMTP_TIMEOUTS constant to nodemailer.createTransport in createSalesTransporter.

🟡 MEDIUM EM-05a: Per-port branding not threaded into portal activation/reset emails

  • File: src/lib/services/portal-auth.service.ts:163-164
  • What: issueActivationToken and issuePasswordReset call sendEmail(email, subject, html, undefined, text) without the 6th portId argument. Without portId, createTransporter() uses global env SMTP. Branding is threaded into HTML via getBrandingShell(portId) but the SMTP transport falls back to global.
  • Why it matters: Multi-port deploys: portal auth emails for port B go through global env SMTP, defeating per-port SMTP override.
  • Suggested fix: Pass portId as 6th arg to sendEmail in both issueActivationToken and the reset send.

🟡 MEDIUM EM-07: CC/BCC not supported in main sendEmail

  • File: src/lib/email/index.ts:54-68
  • What: SendEmailOptions lacks cc/bcc. Sales send-out path also lacks them.
  • Suggested fix: Add optional cc/bcc to SendEmailOptions. Low urgency.

🟡 MEDIUM EM-11: Bounce-to-interest linking not implemented

  • File: src/lib/services/sales-email-config.service.ts:13 (header comment)
  • What: getSalesImapConfig exposes IMAP creds but no BullMQ worker reads IMAP. Failed deliveries don't update document_sends.failedAt.
  • Suggested fix: Wire BullMQ recurring job using imapflow to scan inbox for bounce NDRs, match against document_sends.messageId. Phase 7 §14.9 deferred.

🟡 MEDIUM EM-16: Notification digest uses wrong catalog key for subject resolution

  • File: src/lib/services/notification-digest.service.ts:161-169
  • What: Calls resolveSubject with key: 'crm_invite' as any because 'notification_digest' is not in TEMPLATE_KEYS in src/lib/email/template-catalog.ts.
  • Why it matters: Admin-set CRM invite subject override bleeds into digest emails.
  • Suggested fix: Add 'notification_digest' to TEMPLATE_KEYS; update digest service to use it.

🟡 MEDIUM IN-11: Presigned URL TTL fixed at 900s for portal downloads

  • File: src/lib/storage/index.ts:240-254 (presignDownloadUrl); src/lib/services/portal.service.ts:350 (getDocumentDownloadUrl)
  • What: presignDownloadUrl defaults expirySeconds=900 (15min). Sales send-out correctly overrides to 24h. getDocumentDownloadUrl calls without expiry → 15min default.
  • Why it matters: Portal users opening their doc list and clicking after >15min get 403.
  • Suggested fix: Pass expirySeconds: 4 * 3600 for portal download links, or sign on-demand from API.

🟡 MEDIUM IN-21: OpenAI receipt-scanner module-level instantiation, no credential health check

  • File: src/lib/services/receipt-scanner.ts:4
  • What: const openai = new OpenAI(); at module level reads OPENAI_API_KEY at import. SDK throws on first call when unset; catch returns zero-confidence empty result. No admin-visible health check.
  • Suggested fix: Guard OPENAI_API_KEY upfront with clear error. Add a health-check endpoint similar to checkDocumensoHealth.

🟡 MEDIUM IN-23: Receipt OCR ignores per-port config; hardcoded gpt-4o

  • File: src/lib/services/receipt-scanner.ts:19
  • What: model: 'gpt-4o' hardcoded; per-port getResolvedOcrConfig not consulted; aiEnabled flag does nothing. Module-level singleton OpenAI client.
  • Suggested fix: Accept portId, call getResolvedOcrConfig(portId), check aiEnabled, use config.apiKey and config.model. Branch on provider for OpenAI vs Anthropic.

🟡 MEDIUM IN-24: Stale "pdfme" references in comments/seed

  • File: src/lib/db/seed-data.ts:807, src/lib/services/document-templates.ts:573
  • What: Comments still reference pdfme even though the rendering path was removed; tiptap-validation.ts:8 confirms pdfme retired. document-templates.ts:648-652 throws ValidationError for non-EOI templates.
  • Suggested fix: Update comments to reference pdf-lib AcroForm fill; remove "pdfme" from seed-data description.

🟡 MEDIUM IN-29: Umami testConnection throws instead of returning typed result

  • File: src/lib/services/umami.service.ts:80-101, 292
  • What: loadUmamiConfig returns null gracefully; all public APIs return null when unconfigured. But testConnection throws CodedError('UMAMI_NOT_CONFIGURED') instead of returning { ok: false, error } like checkDocumensoHealth.
  • Suggested fix: Return { ok: false, error: string } to match Documenso convention.

Passing checks

  • EM-01 per-port SMTP override (getPortEmailConfig in port-config.ts:136)
  • EM-02/03 default send-froms cascade (explicit fromcfg.fromAddress → env.SMTP_FROM → noreply@${SMTP_HOST})
  • EM-04 EMAIL_REDIRECT_TO subject prefix [redirected from <orig>]; documenso-client also applies applyRecipientRedirect/applyPayloadRedirect; env.ts:110 prod boot guard
  • EM-05 branded shell (renderShell in src/lib/email/shell.ts:37)
  • EM-06 reply-to override applied
  • EM-08 send rate limit 50/user/hour Redis sliding-window keyed ${portId}:${userId}
  • EM-09 streamAttachmentOrLink threshold + filename HTML-escape pre-SMTP
  • EM-10 IMAP probe script + getSalesImapConfig AES-256-GCM decrypted
  • EM-12 document_sends audit row in success + failure branches
  • EM-13 portal activation token: 32-byte token, hash stored in portalAuthTokens, #token=... fragment to stay out of logs
  • EM-14/15 reset/invite emails wired
  • EM-17 EOI sent via Documenso (not as nodemailer attachment)
  • EM-18/19 renderEmailBody escape-first + isSafeHref (https/mailto only) + MERGE_VALUE_ESCAPE_MAP neutralizes markdown chars
  • IN-01 v1 template-generate path (generateDocumentFromTemplate)
  • IN-02 v2 envelope/create multipart (FormData with payload JSON + files Blob)
  • IN-03 v2 distribute returns recipients[].signingUrl in one round-trip
  • IN-04 redistribute version-aware (v2 caveat: recipientIds may not target single recipient — API behavior risk, not code bug)
  • IN-05 downloadSignedPdf version-aware
  • IN-06 voidDocument version-aware (idempotent on 404)
  • IN-07 placeFields v2 bulk field/create-many percent coords + fieldMeta; v1 one POST per field with pixel coords
  • IN-08 normalizeDocument id ?? documentId for both docs and recipients (handles legacy r.Recipient capital-R)
  • IN-09 NocoDB pg_advisory_xact_lock + skip rows where updated_at > last_imported_at
  • IN-10 S3Backend with SSE AES256, all calls wrapped in withTimeout(30_000), never imports MinIO directly
  • IN-12 filesystem MULTI_NODE_DEPLOYMENT guard (boot-time throw)
  • IN-13 BullMQ exponential backoff: email/docs 5×1s, webhooks 8×30s
  • IN-14 Redis noeviction in both compose files
  • IN-15 src/worker.ts imports all 10 workers + SIGTERM/SIGINT graceful shutdown
  • IN-16 public berths cache s-maxage=300, stale-while-revalidate=60
  • IN-17 status filter Sold > Under Offer (status OR has active is_specific_interest with isNull(end_date)+outcome) > Available
  • IN-18 mooring regex ^[A-Z]+\d+$ checked pre-DB; returns 400 for malformed
  • IN-19/20 dual-mode health endpoint with timingSafeEqual
  • IN-22 berth-pdf-parser tier-2 is unpdf (not Tesseract — prior comment correction); 30s timeout
  • IN-25 fillEoiFormFields flatten + metadata; missing fields warn rather than throw
  • IN-26 VALID_MERGE_TOKENS allow-list including {{eoi.berthRange}}
  • IN-27 formatBerthRange handles all cases (single/contig/non-contig/cross-pontoon/dedup)
  • IN-28 portal magic-link rate-limited 10/h/IP via enforcePublicRateLimit(req, 'portalToken')