Skip to content

Enable self-hosted deployment with BunMail, SMTP, and Docker fixes.#3491

Open
raedbrahem wants to merge 1 commit into
trycompai:mainfrom
1devspace:selfhost/dctrl-setup
Open

Enable self-hosted deployment with BunMail, SMTP, and Docker fixes.#3491
raedbrahem wants to merge 1 commit into
trycompai:mainfrom
1devspace:selfhost/dctrl-setup

Conversation

@raedbrahem

@raedbrahem raedbrahem commented Jul 23, 2026

Copy link
Copy Markdown

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.

What does this PR do?

  • Fixes #XXXX (GitHub issue number)
  • Fixes COMP-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)

Visual Demo (For contributors especially)

A visual demonstration is strongly recommended, for both the original and new change (video / image - any one).

Video Demo (if applicable):

  • Show screen recordings of the issue or feature.
  • Demonstrate how to reproduce the issue, the behavior before and after the change.

Image Demo (if applicable):

  • Add side-by-side screenshots of the original and updated change.
  • Highlight any significant change(s).

Mandatory Tasks (DO NOT REMOVE)

  • I have self-reviewed the code (A decent size PR without self-review might be rejected).
  • I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox.
  • I confirm automated tests are in place that prove my fix is effective or that my feature works.

How should this be tested?

  • Are there environment variables that should be set?
  • What are the minimal test data to have?
  • What is expected (happy path) to have (input and output)?
  • Any other important info that could help to test that PR

Checklist

  • I haven't read the contributing guide
  • My code doesn't follow the style guidelines of this project
  • I haven't commented my code, particularly in hard-to-understand areas
  • I haven't checked if my changes generate no new warnings

Summary by cubic

Adds BunMail and SMTP email transports with a direct-send fallback when Trigger.dev is not set. Updates Docker for self‑hosting by adding the API service and fixing Prisma TLS for local Postgres.

  • New Features

    • Unified email transport in apps/api with priority: BunMail → SMTP → Resend; attachments and one‑click unsubscribe headers supported. Adds nodemailer for SMTP.
    • Sends emails directly if TRIGGER_SECRET_KEY is unset; the Trigger task now uses the same transport.
    • Docker: new api service on port 3333; App moves to 3001 and Portal to 3002; health checks use wget; images build local packages/db and seed via tsx. Prisma treats comp-postgres as local and defaults ssl to false unless explicitly set.
  • Migration

    • Configure one email option: BunMail (BUNMAIL_API_URL, BUNMAIL_API_KEY, BUNMAIL_FROM), or SMTP (SMTP_*), or Resend; see apps/api/.env.example.
    • Create the external Docker network before starting: docker network create comp_network, then docker compose up -d.
    • Use host comp-postgres in DATABASE_URL with sslmode=disable, and set NEXT_PUBLIC_API_URL for App/Portal. New ports: App 3001, Portal 3002, API 3333.

Written for commit b70915b. 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.
@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

@raedbrahem is attempting to deploy a commit to the Comp AI Team on Vercel.

A member of the Team first needs to authorize it.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@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.

11 issues found across 12 files

Confidence score: 2/5

  • docker-compose.yml currently has conflicting network/DB settings (including an external comp_network that isn’t provisioned), so a clean local docker compose up can fail or the API can’t reach Postgres; this blocks reliable local bring-up and testing — align hosts/credentials with packages/db/docker-compose.yml and let Compose create/provision the shared network.
  • docker-compose.yml maps the app to 3001 while auth docs/URLs still point to 3000, so local sign-in and callback flows can route to the wrong service and appear broken — keep the app on 3000 or update all related auth URL configuration/docs together.
  • apps/api/src/email/email-transport.ts drops headers, attachments, and (for non-Resend direct transports) scheduledAt, which can break Gmail one-click unsubscribe compliance, lose files, and send scheduled mail immediately — preserve these fields across transports or fail fast when unsupported.
  • Dockerfile pipes a mutable remote NodeSource script to bash as root during builds, creating a concrete supply-chain execution risk; move to a pinned Node image/stage or add checksum/signature verification before execution.
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/email-transport.ts">

<violation number="1" location="apps/api/src/email/email-transport.ts:5">
P3: A second `EmailAttachment` interface is defined here, duplicating the one already exported from `./resend.ts`. Since `trigger-email.ts` still imports from `./resend`, the codebase now has two sources of truth. Import and re-export from `./resend.ts` (or move the shared type there) instead of redefining it.</violation>

<violation number="2" location="apps/api/src/email/email-transport.ts:77">
P1: The BunMail transport silently drops `headers` and `attachments` — both `sendViaSmtp` and `sendViaResend` support them. This means `List-Unsubscribe` headers (needed for Gmail one-click unsubscribe compliance) and file attachments are lost when BunMail is active. Either add `headers`/`attachments` to the BunMail API call body, or document the limitation and throw if those params are provided but unsupported.</violation>

<violation number="3" location="apps/api/src/email/email-transport.ts:150">
P2: Scheduled emails are sent immediately whenever either new direct transport is selected, because `scheduledAt` is silently discarded outside the Resend path. Preserve scheduling semantics for those transports or fail explicitly when scheduling is requested.</violation>
</file>

<file name="Dockerfile">

<violation number="1" location="Dockerfile:51">
P2: A changed or compromised NodeSource setup response executes arbitrary code as root during migrator builds because this mutable remote script is piped directly to `bash`. Use a pinned Node base/stage or verify a checksummed/signed artifact before execution.</violation>
</file>

<file name="docker-compose.yml">

<violation number="1" location="docker-compose.yml:35">
P1: The documented local auth URL still targets port 3000, while this mapping exposes the app at 3001; default local sign-in/callback links therefore target the wrong service. Keep the app on 3000 or update the associated documented/build-time URLs together.</violation>

<violation number="2" location="docker-compose.yml:40">
P2: Database password hardcoded in three services' environment sections. The password `comp` is repeated across `app`, `api`, and `portal` services. Each service already loads a `.env` file via `env_file`, so the DATABASE_URL in the `environment` section overrides whatever the operator has configured in their env file. Consider moving the DATABASE_URL (with its password) out of the inline `environment` block and into the respective `.env` files, or use an env-var substitution like `${DATABASE_URL}` so the docker-compose.yml itself contains no credentials.</violation>

<violation number="3" location="docker-compose.yml:40">
P1: The new API cannot connect to the repository's local Postgres setup: its hard-coded host, credentials, and network do not match `packages/db/docker-compose.yml`. Wire both Compose projects to a shared network and use the database service's configured URL, or source the URL from deployment configuration.</violation>

<violation number="4" location="docker-compose.yml:89">
P2: Portal S3 credentials are committed as fixed values and override environment-file credentials, preventing self-hosted deployments from supplying their own MinIO/AWS secret. Source both values from environment configuration instead.</violation>

<violation number="5" location="docker-compose.yml:104">
P1: A clean local `docker compose up` cannot start because `comp_network` is marked external but is not provisioned by this stack. Let Compose create the named network, or document/provision it as a prerequisite alongside the database.</violation>
</file>

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

<violation number="1" location="apps/api/src/email/trigger-email.ts:31">
P2: The `sendEmailDirect` function in this file duplicates the full `List-Unsubscribe` header construction that already exists in `send-email.ts` and `send-batch-email.ts`. Extract this into a shared helper (e.g., `buildUnsubscribeHeaders(to: string)` in the email utils) so changes to the URL template, header format, or API base URL don't require updating three separate locations.</violation>
</file>

<file name="apps/api/prisma/client.ts">

<violation number="1" location="apps/api/prisma/client.ts:6">
P2: The `LOCAL_HOSTNAMES` set and SSL configuration helpers (`isLocalhostUrl`, `stripSslMode`) are duplicated across 4 files instead of being centralized. This diff required adding `'comp-postgres'` to all 4 locations identically — a pattern that is fragile and prone to drift between environments. Consider exporting the shared hostname set and helpers from `packages/db/src/ssl-config.ts` (which already exports `resolveSslConfig`) and importing them in the apps' prisma client files, or aligning the apps to use `@trycompai/db`'s client directly.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread docker-compose.yml
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
ports:
- '3000:3000'
- '3001:3000'

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 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.

P1: The documented local auth URL still targets port 3000, while this mapping exposes the app at 3001; default local sign-in/callback links therefore target the wrong service. Keep the app on 3000 or update the associated documented/build-time URLs together.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docker-compose.yml, line 35:

<comment>The documented local auth URL still targets port 3000, while this mapping exposes the app at 3001; default local sign-in/callback links therefore target the wrong service. Keep the app on 3000 or update the associated documented/build-time URLs together.</comment>

<file context>
@@ -30,17 +30,43 @@ services:
+        NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
     ports:
-      - '3000:3000'
+      - '3001:3000'
     env_file:
       - apps/app/.env
</file context>
Suggested change
- '3001:3000'
- '3000:3000'
Fix with cubic

Comment thread docker-compose.yml
- apps/app/.env
environment:
PGSSLMODE: disable
DATABASE_URL: postgresql://comp:comp@comp-postgres:5432/comp?sslmode=disable

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 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.

P1: The new API cannot connect to the repository's local Postgres setup: its hard-coded host, credentials, and network do not match packages/db/docker-compose.yml. Wire both Compose projects to a shared network and use the database service's configured URL, or source the URL from deployment configuration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docker-compose.yml, line 40:

<comment>The new API cannot connect to the repository's local Postgres setup: its hard-coded host, credentials, and network do not match `packages/db/docker-compose.yml`. Wire both Compose projects to a shared network and use the database service's configured URL, or source the URL from deployment configuration.</comment>

<file context>
@@ -30,17 +30,43 @@ services:
       - apps/app/.env
+    environment:
+      PGSSLMODE: disable
+      DATABASE_URL: postgresql://comp:comp@comp-postgres:5432/comp?sslmode=disable
     restart: unless-stopped
     healthcheck:
</file context>
Fix with cubic

Comment thread docker-compose.yml
networks:
default:
name: comp_network
external: true

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 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.

P1: A clean local docker compose up cannot start because comp_network is marked external but is not provisioned by this stack. Let Compose create the named network, or document/provision it as a prerequisite alongside the database.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docker-compose.yml, line 104:

<comment>A clean local `docker compose up` cannot start because `comp_network` is marked external but is not provisioned by this stack. Let Compose create the named network, or document/provision it as a prerequisite alongside the database.</comment>

<file context>
@@ -49,14 +75,30 @@ services:
+networks:
+  default:
+    name: comp_network
+    external: true
</file context>
Suggested change
external: true
external: false
Fix with cubic

}));
}

async function sendViaBunMail(params: {

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 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.

P1: The BunMail transport silently drops headers and attachments — both sendViaSmtp and sendViaResend support them. This means List-Unsubscribe headers (needed for Gmail one-click unsubscribe compliance) and file attachments are lost when BunMail is active. Either add headers/attachments to the BunMail API call body, or document the limitation and throw if those params are provided but unsupported.

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

<comment>The BunMail transport silently drops `headers` and `attachments` — both `sendViaSmtp` and `sendViaResend` support them. This means `List-Unsubscribe` headers (needed for Gmail one-click unsubscribe compliance) and file attachments are lost when BunMail is active. Either add `headers`/`attachments` to the BunMail API call body, or document the limitation and throw if those params are provided but unsupported.</comment>

<file context>
@@ -0,0 +1,243 @@
+  }));
+}
+
+async function sendViaBunMail(params: {
+  from: string;
+  to: string;
</file context>
Fix with cubic

html: string;
cc?: string | string[];
headers?: Record<string, string>;
scheduledAt?: string;

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 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.

P2: Scheduled emails are sent immediately whenever either new direct transport is selected, because scheduledAt is silently discarded outside the Resend path. Preserve scheduling semantics for those transports or fail explicitly when scheduling is requested.

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

<comment>Scheduled emails are sent immediately whenever either new direct transport is selected, because `scheduledAt` is silently discarded outside the Resend path. Preserve scheduling semantics for those transports or fail explicitly when scheduling is requested.</comment>

<file context>
@@ -0,0 +1,243 @@
+  html: string;
+  cc?: string | string[];
+  headers?: Record<string, string>;
+  scheduledAt?: string;
+  attachments?: EmailAttachment[];
+}): Promise<{ id: string }> {
</file context>
Fix with cubic

Comment thread docker-compose.yml
@@ -21,7 +21,7 @@ services:
target: migrator

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 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.

P2: Database password hardcoded in three services' environment sections. The password comp is repeated across app, api, and portal services. Each service already loads a .env file via env_file, so the DATABASE_URL in the environment section overrides whatever the operator has configured in their env file. Consider moving the DATABASE_URL (with its password) out of the inline environment block and into the respective .env files, or use an env-var substitution like ${DATABASE_URL} so the docker-compose.yml itself contains no credentials.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docker-compose.yml, line 40:

<comment>Database password hardcoded in three services' environment sections. The password `comp` is repeated across `app`, `api`, and `portal` services. Each service already loads a `.env` file via `env_file`, so the DATABASE_URL in the `environment` section overrides whatever the operator has configured in their env file. Consider moving the DATABASE_URL (with its password) out of the inline `environment` block and into the respective `.env` files, or use an env-var substitution like `${DATABASE_URL}` so the docker-compose.yml itself contains no credentials.</comment>

<file context>
@@ -30,17 +30,43 @@ services:
       - apps/app/.env
+    environment:
+      PGSSLMODE: disable
+      DATABASE_URL: postgresql://comp:comp@comp-postgres:5432/comp?sslmode=disable
     restart: unless-stopped
     healthcheck:
</file context>
Fix with cubic

scheduledAt?: string;
attachments?: EmailAttachment[];
}): Promise<{ id: string }> {
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai';

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 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.

P2: The sendEmailDirect function in this file duplicates the full List-Unsubscribe header construction that already exists in send-email.ts and send-batch-email.ts. Extract this into a shared helper (e.g., buildUnsubscribeHeaders(to: string) in the email utils) so changes to the URL template, header format, or API base URL don't require updating three separate locations.

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

<comment>The `sendEmailDirect` function in this file duplicates the full `List-Unsubscribe` header construction that already exists in `send-email.ts` and `send-batch-email.ts`. Extract this into a shared helper (e.g., `buildUnsubscribeHeaders(to: string)` in the email utils) so changes to the URL template, header format, or API base URL don't require updating three separate locations.</comment>

<file context>
@@ -17,6 +19,34 @@ function resolveChannel(flags: TriggerEmailFlags): EmailChannel {
+  scheduledAt?: string;
+  attachments?: EmailAttachment[];
+}): Promise<{ id: string }> {
+  const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai';
+  const token = generateUnsubscribeToken(params.to);
+  const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(params.to)}&token=${encodeURIComponent(token)}`;
</file context>
Fix with cubic

Comment thread docker-compose.yml
MOCK_REDIS: "true"
FLEET_AGENT_BUCKET_NAME: comp
DEVICE_AGENT_S3_ENV: production
APP_AWS_ACCESS_KEY_ID: minioadmin

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 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.

P2: Portal S3 credentials are committed as fixed values and override environment-file credentials, preventing self-hosted deployments from supplying their own MinIO/AWS secret. Source both values from environment configuration instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docker-compose.yml, line 89:

<comment>Portal S3 credentials are committed as fixed values and override environment-file credentials, preventing self-hosted deployments from supplying their own MinIO/AWS secret. Source both values from environment configuration instead.</comment>

<file context>
@@ -49,14 +75,30 @@ services:
+      MOCK_REDIS: "true"
+      FLEET_AGENT_BUCKET_NAME: comp
+      DEVICE_AGENT_S3_ENV: production
+      APP_AWS_ACCESS_KEY_ID: minioadmin
+      APP_AWS_SECRET_ACCESS_KEY: minioadmin
+      APP_AWS_BUCKET_NAME: comp
</file context>
Fix with cubic

Comment thread apps/api/prisma/client.ts
const globalForPrisma = global as unknown as { prisma?: PrismaClient };

const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']);

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 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.

P2: The LOCAL_HOSTNAMES set and SSL configuration helpers (isLocalhostUrl, stripSslMode) are duplicated across 4 files instead of being centralized. This diff required adding 'comp-postgres' to all 4 locations identically — a pattern that is fragile and prone to drift between environments. Consider exporting the shared hostname set and helpers from packages/db/src/ssl-config.ts (which already exports resolveSslConfig) and importing them in the apps' prisma client files, or aligning the apps to use @trycompai/db's client directly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/prisma/client.ts, line 6:

<comment>The `LOCAL_HOSTNAMES` set and SSL configuration helpers (`isLocalhostUrl`, `stripSslMode`) are duplicated across 4 files instead of being centralized. This diff required adding `'comp-postgres'` to all 4 locations identically — a pattern that is fragile and prone to drift between environments. Consider exporting the shared hostname set and helpers from `packages/db/src/ssl-config.ts` (which already exports `resolveSslConfig`) and importing them in the apps' prisma client files, or aligning the apps to use `@trycompai/db`'s client directly.</comment>

<file context>
@@ -3,7 +3,7 @@ import { PrismaPg } from '@prisma/adapter-pg';
 const globalForPrisma = global as unknown as { prisma?: PrismaClient };
 
-const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
+const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']);
 
 function stripSslMode(connectionString: string): string {
</file context>
Fix with cubic

import type Mail from 'nodemailer/lib/mailer';
import { resend } from './resend';

export interface EmailAttachment {

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 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: A second EmailAttachment interface is defined here, duplicating the one already exported from ./resend.ts. Since trigger-email.ts still imports from ./resend, the codebase now has two sources of truth. Import and re-export from ./resend.ts (or move the shared type there) instead of redefining it.

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

<comment>A second `EmailAttachment` interface is defined here, duplicating the one already exported from `./resend.ts`. Since `trigger-email.ts` still imports from `./resend`, the codebase now has two sources of truth. Import and re-export from `./resend.ts` (or move the shared type there) instead of redefining it.</comment>

<file context>
@@ -0,0 +1,243 @@
+import type Mail from 'nodemailer/lib/mailer';
+import { resend } from './resend';
+
+export interface EmailAttachment {
+  filename: string;
+  content: Buffer | string;
</file context>
Fix with cubic

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