Files
multica/docker-compose.selfhost.yml
apollion69 35e9a7f0f6 feat(email): add SMTP relay as alternative to Resend for self-hosted deployments (#1877)
* feat(email): add SMTP relay as alternative to Resend

Self-hosted deployments often run behind a corporate firewall with an
existing SMTP relay (Exchange, Postfix, sendmail) and no access to
external SaaS APIs. Resend requires a public domain, an API key, and
outbound HTTPS to api.resend.com — all unavailable in air-gapped or
private-network setups.

This adds a second email delivery path using Go's stdlib net/smtp,
activated when SMTP_HOST is set. Priority order:
  1. SMTP relay  (SMTP_HOST set)
  2. Resend API  (RESEND_API_KEY set)
  3. DEV stdout  (neither set)

New env vars (all optional, no breaking change):
  SMTP_HOST            — SMTP server hostname
  SMTP_PORT            — port, default 25
  SMTP_USERNAME        — for authenticated SMTP; empty = unauthenticated relay
  SMTP_PASSWORD        — used only when SMTP_USERNAME is set
  SMTP_TLS_INSECURE    — set to "true" to skip TLS cert verification
                         (for private CA / self-signed certs)

The implementation:
- Dials TCP, creates smtp.Client manually (avoids smtp.SendMail which
  does not expose TLS config)
- Tries STARTTLS if advertised; uses InsecureSkipVerify only when
  SMTP_TLS_INSECURE=true (opt-in, nolint:gosec annotated)
- Applies PlainAuth only when SMTP_USERNAME is non-empty
- Wraps all errors with context for easier debugging
- Reuses existing HTML templates from buildInvitationParams for
  invitation emails (no template duplication)

Also updates .env.example and docker-compose.selfhost.yml with the
new variables and inline documentation.

* fix(email): add dial timeout, session deadline, RFC headers for SMTP path

Address review blockers from multica-eve and Bohan-J (PR #1877):

- net.Dial → net.DialTimeout(10s) + conn.SetDeadline(30s) so a blackholed
  SMTP relay cannot hang SendVerificationCode (called synchronously from the
  auth handler) or leak goroutines in the invitation path.
- Add Date, Message-ID, and proper Content-Transfer-Encoding headers.
  Date is required by RFC 5322; many strict relays reject messages without it.
  Message-ID aids deliverability and threading.
- MIME-encode Subject via mime.QEncoding so non-ASCII workspace/inviter names
  (CJK, emoji) survive without corruption across any RFC 2047-conformant relay.
- Probe 8BITMIME after (possible) STARTTLS: use Content-Transfer-Encoding 8bit
  when the relay advertises 8BITMIME, quoted-printable otherwise — safe for
  all relay configurations without forcing base64 overhead.
- Update SELF_HOSTING_ADVANCED.md to document Option B (SMTP relay) alongside
  the existing Resend section, including all five env vars and a note that
  port 465/SMTPS is not yet supported.

* fix(email): correct has8Bit assignment order (bool is first return of Extension)
2026-05-15 13:35:01 +08:00

86 lines
3.0 KiB
YAML

# Self-hosting Docker Compose — starts PostgreSQL, backend, and frontend.
#
# Usage:
# cp .env.example .env
# # Edit .env — change JWT_SECRET at minimum
# docker compose -f docker-compose.selfhost.yml up -d
#
# Frontend: http://localhost:3000
# Backend: http://localhost:8080 (also used by CLI/daemon)
name: multica
services:
postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_DB: ${POSTGRES_DB:-multica}
POSTGRES_USER: ${POSTGRES_USER:-multica}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-multica} -d ${POSTGRES_DB:-multica}"]
interval: 5s
timeout: 5s
retries: 5
backend:
image: ${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:${MULTICA_IMAGE_TAG:-latest}
depends_on:
postgres:
condition: service_healthy
ports:
- "${PORT:-8080}:8080"
volumes:
- backend_uploads:/app/data/uploads
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
PORT: "8080"
METRICS_ADDR: ${METRICS_ADDR:-}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-25}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_TLS_INSECURE: ${SMTP_TLS_INSECURE:-false}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
S3_BUCKET: ${S3_BUCKET:-}
S3_REGION: ${S3_REGION:-us-west-2}
CLOUDFRONT_DOMAIN: ${CLOUDFRONT_DOMAIN:-}
CLOUDFRONT_KEY_PAIR_ID: ${CLOUDFRONT_KEY_PAIR_ID:-}
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
APP_ENV: ${APP_ENV:-production}
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
restart: unless-stopped
frontend:
image: ${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:${MULTICA_IMAGE_TAG:-latest}
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-3000}:3000"
environment:
HOSTNAME: "0.0.0.0"
restart: unless-stopped
volumes:
pgdata:
backend_uploads: