Skip to content

feat(self-host): SMTP/BunMail email, Docker stack, and MinIO presigned URL fixes - #3543

Open
raedbrahem wants to merge 7 commits into
trycompai:mainfrom
1devspace:feat/self-hosted-docker-email
Open

feat(self-host): SMTP/BunMail email, Docker stack, and MinIO presigned URL fixes#3543
raedbrahem wants to merge 7 commits into
trycompai:mainfrom
1devspace:feat/self-hosted-docker-email

Conversation

@raedbrahem

@raedbrahem raedbrahem commented Aug 3, 2026

Copy link
Copy Markdown

Summary

Self-hosted deployment improvements from production use of Comp on Docker:

  • Email transport: Optional SMTP and BunMail REST transports, with direct send when TRIGGER_SECRET_KEY is unset (no Trigger.dev required for basic email).
  • Docker Compose: Adds api service, optional MinIO profile (docker compose --profile minio up), build args for NEXT_PUBLIC_API_URL / portal URL, and local Postgres hostname support (comp-postgres).
  • Dockerfile: Build migrator/seeder from local packages/db source; Prisma seed via Node/tsx; workspace package build order fixes.
  • Portal links: Central getPortalBaseUrl() helper so emails and invites respect PORTAL_URL on self-hosted installs.
  • S3/MinIO: APP_AWS_PUBLIC_ENDPOINT for browser-reachable presigned URLs when the internal endpoint is not reachable from clients.
  • Device agent download: Buffer-based S3 response handling for compatibility with MinIO.

Motivation

The upstream Docker guide assumes Resend + Trigger.dev + external Postgres. These changes make a fully self-contained Docker stack workable for teams that prefer SMTP/BunMail and optional local MinIO, without changing cloud behavior when those env vars are unset.

Test plan

  • docker compose build app portal api succeeds with exported BETTER_AUTH_URL / NEXT_PUBLIC_API_URL build args
  • With TRIGGER_SECRET_KEY unset and SMTP_HOST set, invitation/policy emails send directly
  • With docker compose --profile minio up, evidence upload and attachment download work when APP_AWS_ENDPOINT=http://minio:9000 and APP_AWS_PUBLIC_ENDPOINT points to a browser-reachable host
  • Cloud path unchanged: Resend + Trigger.dev still used when configured

Notes

  • MinIO services are behind the minio Compose profile so existing deployments are unaffected.
  • No org-specific domains, credentials, or infrastructure details are included in this PR.

Made with Cursor


Summary by cubic

Adds self‑hosted email and a fuller Docker stack: optional BunMail REST and SMTP transports with direct send when TRIGGER_SECRET_KEY is unset, api service wired into Compose, optional MinIO, and safer presigned URLs. Also centralizes portal links and respects sslmode=disable for local Postgres across apps and tools.

  • New Features

    • Email: Adds BunMail REST and SMTP; sends directly if TRIGGER_SECRET_KEY is unset; scheduled sends route to Resend only (errors if unavailable); uses BunMail for normal sends and falls back to SMTP/Resend for attachments; adds one‑click List‑Unsubscribe headers.
    • Docker: Adds api service (port 3333) with health checks and NEXT_PUBLIC_API_URL build args; migrator seeds via Node/tsx; optional minio profile pins images and auto‑creates the bucket.
    • S3/MinIO: Uses APP_AWS_PUBLIC_ENDPOINT only for signing browser‑reachable presigned URLs; portal agent downloads stream via transformToWebStream().
    • URLs/TLS: Central getPortalBaseUrl() used in app/API/emails; honors sslmode=disable in DATABASE_URL across app, portal, API, framework editor, and DB tools.
  • Migration

    • Email: set BUNMAIL_API_URL, BUNMAIL_API_KEY, BUNMAIL_FROM (takes priority), or SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, SMTP_FROM.
    • MinIO: run with docker compose --profile minio up; set APP_AWS_ENDPOINT (internal), APP_AWS_PUBLIC_ENDPOINT (browser), MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, APP_AWS_BUCKET_NAME.
    • URLs: set PORTAL_URL and NEXT_PUBLIC_API_URL.
    • Scheduling: leave TRIGGER_SECRET_KEY unset for direct sends (no scheduling), or set it to enable scheduled emails via Resend/Trigger.dev.

Written for commit 7af047b. Summary will update on new commits.

Review in cubic

Add BunMail REST and SMTP email transports with direct send fallback when Trigger.dev is unset, wire the API service into docker-compose, and fix Prisma TLS for local Postgres hostnames.
…al URL fixes

Add optional SMTP and BunMail email transports with direct send when Trigger.dev
is unset, extend docker-compose with API and optional MinIO profile, improve local
Postgres/Docker build compatibility, and centralize portal links for self-hosted installs.
When S3/MinIO runs on an internal Docker hostname, presigned attachment URLs
need a browser-reachable host. Use APP_AWS_PUBLIC_ENDPOINT for signing while
keeping APP_AWS_ENDPOINT for server-side object access.
@CLAassistant

CLAassistant commented Aug 3, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found and verified against the latest diff

Confidence score: 3/5

  • In packages/db/src/ssl-config.ts, the hostname handling fix is not applied for framework-editor and combine-schemas.js-emitted clients, so comp-postgres can still be treated as remote and plain Docker Postgres/self-hosted connections may use the wrong SSL behavior and fail to connect — apply the same hostname classification logic across all client creation paths and add a regression test for those emitters.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/db/src/ssl-config.ts">

<violation number="1" location="packages/db/src/ssl-config.ts:6">
P2: Self-hosted connections made through the framework-editor client or a client emitted by `combine-schemas.js` still treat `comp-postgres` as remote, so this hostname fix is not applied consistently and plain Docker Postgres connections can fail TLS negotiation. Keeping the hostname classification synchronized in the generated-client template and every Prisma client would make the self-hosted behavior consistent.

(Based on your team's feedback about generated Prisma client configuration and coordinated call sites.)</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic
Tip: cubic used a learning from your PR history. Let your coding agent read cubic learnings directly with the cubic MCP.

Re-trigger cubic

Comment thread docker-compose.yml Outdated
Comment thread apps/api/src/email/email-transport.ts Outdated
Comment thread apps/api/src/email/email-transport.ts Outdated
Comment thread docker-compose.yml Outdated
Comment thread docker-compose.yml Outdated
Comment thread apps/api/src/email/email-transport.ts
Comment thread apps/api/src/email/email-transport.ts
Comment thread apps/api/src/people/people-invite.service.ts Outdated
Comment thread apps/api/src/email/trigger-email.ts Outdated
Comment thread packages/email/emails/policy-acknowledgment-digest.tsx Outdated
- Fix SMTP attachment base64 decoding for Trigger task payloads
- Remove global RESEND_TO_TEST redirect; reject scheduled sends without Trigger.dev
- Fall back from BunMail when attachments/headers/scheduling are required
- Share unsubscribe header builder; reuse getPortalBaseUrl in app/API
- Stream device agent downloads via transformToWebStream
- Require explicit MinIO credentials and bucket name; pin image tags
- Use sslmode=disable in DATABASE_URL instead of hardcoded comp-postgres host
- Install pinned tsx via Bun in migrator image
@raedbrahem

Copy link
Copy Markdown
Author

Thanks for the review — pushed commit 0df2e54 addressing the cubic feedback:

Email transport

  • Decode Trigger task base64 attachment payloads before SMTP send
  • Removed global RESEND_TO_TEST redirect from centralized transport
  • BunMail falls back to SMTP/Resend when attachments, custom headers, or scheduling are required
  • Reject scheduledAt sends when TRIGGER_SECRET_KEY is unset
  • Extracted shared buildUnsubscribeHeaders() helper

Portal URLs

  • Reused getPortalBaseUrl() in no-access page and invite service
  • Removed redundant trailing-slash strip in policy digest email

Docker / MinIO

  • Portal build arg uses documented NEXT_PUBLIC_API_URL
  • Pinned MinIO/mc image tags; require MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, and APP_AWS_BUCKET_NAME (no default credentials)
  • minio-init verifies bucket with mc stat before reporting success

DB TLS

  • Removed hardcoded comp-postgres hostname bypass; sslmode=disable in DATABASE_URL is the explicit opt-out

Other

  • Device agent download streams via transformToWebStream() again
  • Migrator installs pinned tsx@4.19.3 via Bun instead of npm global

CLA signing is still pending on my side — will complete that separately.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 16 files (changes from recent commits).

Confidence score: 5/5

  • In apps/api/src/email/send-batch-email.ts, unsubscribe URL/header construction is still duplicated instead of using buildUnsubscribeHeaders() from apps/api/src/email/unsubscribe-headers.ts, which creates a drift risk where future updates could make batch emails behave differently for one-click unsubscribe and cause subtle compliance/user-experience inconsistencies — route batch sending through the shared helper to keep behavior centralized.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/api/src/email/unsubscribe-headers.ts">

<violation number="1" location="apps/api/src/email/unsubscribe-headers.ts:3">
P3: The PR extracts buildUnsubscribeHeaders() to share one-click unsubscribe logic, but send-batch-email.ts still inlines the exact same URL/header construction instead of calling the new helper. Two copies of the unsubscribe URL logic now drift independently; recommend updating send-batch-email.ts to use buildUnsubscribeHeaders(email.to) so the shared helper is actually the single source of truth.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread apps/api/src/email/email-transport.ts
Comment thread docker-compose.yml Outdated
Comment thread Dockerfile
Comment thread apps/api/src/email/email-transport.ts Outdated
Comment thread apps/api/src/app/s3.ts Outdated
Comment thread apps/api/src/email/unsubscribe-headers.ts Outdated
@@ -0,0 +1,12 @@
import { generateUnsubscribeToken } from '@trycompai/email';

export function buildUnsubscribeHeaders(to: string): Record<string, string> {

@cubic-dev-ai cubic-dev-ai Bot Aug 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The PR extracts buildUnsubscribeHeaders() to share one-click unsubscribe logic, but send-batch-email.ts still inlines the exact same URL/header construction instead of calling the new helper. Two copies of the unsubscribe URL logic now drift independently; recommend updating send-batch-email.ts to use buildUnsubscribeHeaders(email.to) so the shared helper is actually the single source of truth.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/email/unsubscribe-headers.ts, line 3:

<comment>The PR extracts buildUnsubscribeHeaders() to share one-click unsubscribe logic, but send-batch-email.ts still inlines the exact same URL/header construction instead of calling the new helper. Two copies of the unsubscribe URL logic now drift independently; recommend updating send-batch-email.ts to use buildUnsubscribeHeaders(email.to) so the shared helper is actually the single source of truth.</comment>

<file context>
@@ -0,0 +1,12 @@
+import { generateUnsubscribeToken } from '@trycompai/email';
+
+export function buildUnsubscribeHeaders(to: string): Record<string, string> {
+  const apiBaseUrl =
+    process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai';
</file context>
Fix with cubic

- Allow BunMail for standard triggerEmail flows (headers no longer block it)
- Route scheduled sends to Resend only; reject when Resend is unavailable
- Only override S3 presigning client when APP_AWS_PUBLIC_ENDPOINT is set
- Validate MinIO env vars at container start, not compose parse time
- Restore Node.js in migrator image for tsx/Prisma seed compatibility
- Sync sslmode=disable handling in framework-editor and combine-schemas
- Trim API base URL in unsubscribe helper; reuse in batch email task
@raedbrahem

Copy link
Copy Markdown
Author

Pushed 30114013d addressing the second cubic review:

  • BunMail: List-Unsubscribe headers no longer force SMTP/Resend fallback — BunMail works for standard triggerEmail flows again
  • Scheduling: scheduledAt routes to Resend only; throws if Resend unavailable (prevents immediate SMTP delivery)
  • S3 presigning: Caller-provided client preserved unless APP_AWS_PUBLIC_ENDPOINT is explicitly set
  • MinIO profile: Env validation moved to container startup (default docker compose up works without MinIO vars)
  • Migrator: Node.js runtime restored for tsx seeding; still installs pinned tsx@4.19.3 via Bun
  • DB TLS: sslmode=disable handling synced to framework-editor client and combine-schemas.js emitter
  • Unsubscribe: Trim trailing slash from API base URL; batch email task reuses buildUnsubscribeHeaders()

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 9 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread packages/db/scripts/combine-schemas.js Outdated
Comment thread Dockerfile Outdated
Comment thread docker-compose.yml
Comment thread apps/api/src/app/s3.ts
Co-authored-by: Cursor <cursoragent@cursor.com>
@mohamedboukari
mohamedboukari force-pushed the feat/self-hosted-docker-email branch from 29652c5 to 7af047b Compare August 3, 2026 10:35
@raedbrahem

Copy link
Copy Markdown
Author

Pushed 7af047bfd for the third cubic review:

  • combine-schemas.js: malformed DATABASE_URL no longer disables TLS via substring localhost match
  • Dockerfile: pinned Node 22.13.1 copied from official node:22.13.1-bookworm-slim (no curl|bash NodeSource installer)
  • S3 comments: clarified APP_AWS_PUBLIC_ENDPOINT only affects presigning when explicitly set
  • docker-compose: removed compose-time :? on APP_AWS_BUCKET_NAME in minio-init (runtime validation only)

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.

2 participants