582 lines
30 KiB
Markdown
582 lines
30 KiB
Markdown
# Technology Stack
|
|
|
|
**Project:** teressa-copeland-homes
|
|
**Researched (v1.1):** 2026-03-21 | **Updated (v1.2):** 2026-04-03
|
|
**Overall confidence:** HIGH
|
|
|
|
---
|
|
|
|
## v1.1 Stack Research (retained — do not re-research)
|
|
|
|
**Scope:** OpenAI integration, expanded field types, agent signature storage, filled preview
|
|
|
|
### Existing Stack (Do Not Re-research)
|
|
|
|
Already validated and in `package.json`. Do not change these.
|
|
|
|
| Technology | Version in package.json | Role |
|
|
|------------|------------------------|------|
|
|
| Next.js | 16.2.0 | Full-stack framework |
|
|
| React | 19.2.4 | UI |
|
|
| `@cantoo/pdf-lib` | ^2.6.3 | PDF modification (server-side) |
|
|
| `react-pdf` | ^10.4.1 | In-browser PDF rendering |
|
|
| `signature_pad` | ^5.1.3 | Canvas signature drawing |
|
|
| `zod` | ^4.3.6 | Schema validation |
|
|
| `@vercel/blob` | ^2.3.1 | File storage |
|
|
| Drizzle ORM + `postgres` | ^0.45.1 / ^3.4.8 | Database |
|
|
| Auth.js (next-auth) | 5.0.0-beta.30 | Authentication |
|
|
| `nodemailer` | ^7.0.13 | Transactional email (SMTP) |
|
|
| `@react-email/components` | ^1.0.10 | Typed React email templates |
|
|
| `@react-email/render` | ^2.0.4 | Server-side renders React email to HTML |
|
|
| `@dnd-kit/core` + `@dnd-kit/utilities` | ^6.3.1 / ^3.2.2 | Drag-drop field placement UI |
|
|
| `pdfjs-dist` | (bundled in node_modules) | PDF text extraction for AI pipeline — uses `legacy/build/pdf.mjs` to handle Node 20 |
|
|
| `@napi-rs/canvas` | ^0.1.97 | Native canvas bindings (Node.js) — used server-side for canvas operations; architecture-specific prebuilt binary |
|
|
|
|
**Note on `unpdf`:** The v1.1 research recommended `unpdf` as a safer serverless wrapper around PDF.js, but the implemented code uses `pdfjs-dist/legacy/build/pdf.mjs` directly with `GlobalWorkerOptions.workerSrc` pointing to the local worker file. `unpdf` is NOT in `package.json` and was not installed. Do not add it — the existing `pdfjs-dist` integration is working.
|
|
|
|
### New Stack Additions for v1.1
|
|
|
|
#### Core New Dependency: OpenAI API
|
|
|
|
| Technology | Version | Purpose | Why Recommended |
|
|
|------------|---------|---------|-----------------|
|
|
| `openai` | ^6.32.0 | OpenAI API client for GPT calls | Official SDK, current latest, TypeScript-native. Provides `client.chat.completions.create()` for structured JSON output via manual `json_schema` response format. Required for AI field placement and pre-fill. |
|
|
|
|
**No other new core dependencies are needed.** The remaining v1.1 features extend capabilities already in `@cantoo/pdf-lib`, `signature_pad`, and `react-pdf`.
|
|
|
|
#### Supporting Libraries
|
|
|
|
| Library | Version | Purpose | When to Use |
|
|
|---------|---------|---------|-------------|
|
|
| `unpdf` | NOT INSTALLED — see note above | Originally recommended, not used | Do not add |
|
|
|
|
No other new supporting libraries needed. See "What NOT to Add" below.
|
|
|
|
#### Development Tools
|
|
|
|
No new dev tooling required for v1.1 features.
|
|
|
|
### Installation
|
|
|
|
```bash
|
|
# New dependencies for v1.1
|
|
npm install openai
|
|
```
|
|
|
|
That is the full installation delta for v1.1.
|
|
|
|
### Feature-by-Feature Integration Notes
|
|
|
|
#### Feature 1: OpenAI PDF Analysis + Field Placement
|
|
|
|
**Flow:**
|
|
1. API route receives document ID
|
|
2. Fetch PDF bytes from Vercel Blob (`@vercel/blob` — already installed)
|
|
3. Extract text per page using `pdfjs-dist/legacy/build/pdf.mjs`: `getDocument()` + `page.getTextContent()`
|
|
4. Call OpenAI `gpt-4.1` with extracted text + a manually defined JSON schema
|
|
5. Parse structured response: array of `{ fieldType, label, pageNumber, x, y, width, height, suggestedValue }`
|
|
6. Save placement records to DB via Drizzle ORM
|
|
|
|
**Why `gpt-4.1` (not `gpt-4o`):** The implemented code uses `gpt-4.1` which was released after the v1.1 research was written. Use whatever model is set in the existing `field-placement.ts` implementation.
|
|
|
|
**Why manual JSON schema (not `zodResponseFormat`):** The project uses `zod` v4.3.6. The `zodResponseFormat` helper in `openai/helpers/zod` uses vendored `zod-to-json-schema` that still expects `ZodFirstPartyTypeKind` — removed in Zod v4. This is a confirmed open bug as of late 2025. Using `zodResponseFormat` with Zod v4 throws runtime exceptions. Use `response_format: { type: "json_schema", json_schema: { name: "...", strict: true, schema: { ... } } }` directly with plain TypeScript types instead.
|
|
|
|
```typescript
|
|
// CORRECT for Zod v4 project — use manual JSON schema, not zodResponseFormat
|
|
const response = await openai.chat.completions.create({
|
|
model: "gpt-4.1",
|
|
messages: [{ role: "user", content: prompt }],
|
|
response_format: {
|
|
type: "json_schema",
|
|
json_schema: {
|
|
name: "field_placements",
|
|
strict: true,
|
|
schema: {
|
|
type: "object",
|
|
properties: {
|
|
fields: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
fieldType: { type: "string", enum: ["text", "checkbox", "initials", "date", "signature"] },
|
|
label: { type: "string" },
|
|
pageNumber: { type: "number" },
|
|
x: { type: "number" },
|
|
y: { type: "number" },
|
|
width: { type: "number" },
|
|
height: { type: "number" },
|
|
suggestedValue: { type: "string" }
|
|
},
|
|
required: ["fieldType", "label", "pageNumber", "x", "y", "width", "height", "suggestedValue"],
|
|
additionalProperties: false
|
|
}
|
|
}
|
|
},
|
|
required: ["fields"],
|
|
additionalProperties: false
|
|
}
|
|
}
|
|
}
|
|
});
|
|
const result = JSON.parse(response.choices[0].message.content!);
|
|
```
|
|
|
|
#### Feature 2: Expanded Field Types in @cantoo/pdf-lib
|
|
|
|
**No new library needed.** `@cantoo/pdf-lib` v2.6.3 already supports all required field types natively:
|
|
|
|
| Field Type | @cantoo/pdf-lib API |
|
|
|------------|---------------------|
|
|
| Text | `form.createTextField(name)` → `.addToPage(page, options)` → `.setText(value)` |
|
|
| Checkbox | `form.createCheckBox(name)` → `.addToPage(page, options)` → `.check()` / `.uncheck()` |
|
|
| Initials | No dedicated type — use `createTextField` with width/height appropriate for initials |
|
|
| Date | No dedicated type — use `createTextField`, constrain value format in application logic |
|
|
| Agent Signature | Use `page.drawImage(embeddedPng, { x, y, width, height })` — see Feature 3 |
|
|
|
|
**Key pattern for checkboxes:**
|
|
```typescript
|
|
const checkBox = form.createCheckBox('fieldName')
|
|
checkBox.addToPage(page, { x, y, width: 15, height: 15, borderWidth: 1 })
|
|
if (shouldBeChecked) checkBox.check()
|
|
```
|
|
|
|
**Coordinate system note:** `@cantoo/pdf-lib` uses PDF coordinate space where y=0 is the bottom of the page. If field positions come from `pdfjs-dist` (which uses y=0 at top), you must transform: `pdfY = pageHeight - sourceY - fieldHeight`.
|
|
|
|
#### Feature 3: Agent Signature Storage
|
|
|
|
**No new library needed.** The project already has `signature_pad` v5.1.3, `@vercel/blob`, and Drizzle ORM.
|
|
|
|
**Architecture:**
|
|
1. Agent draws signature in browser using `signature_pad` (already installed)
|
|
2. Call `signaturePad.toDataURL('image/png')` to get base64 PNG
|
|
3. POST to API route; server converts base64 → `Uint8Array` → uploads to Vercel Blob at a stable path (e.g., `/agents/{agentId}/signature.png`)
|
|
4. Save blob URL to agent record in DB (add `signatureImageUrl` column to `Agent`/`User` table via Drizzle migration)
|
|
5. On "apply agent signature": server fetches blob URL, embeds PNG into PDF using `@cantoo/pdf-lib`
|
|
|
|
**`signature_pad` v5 in React — use `useRef` on a `<canvas>` element directly:**
|
|
|
|
```typescript
|
|
import SignaturePad from 'signature_pad'
|
|
import { useRef, useEffect } from 'react'
|
|
|
|
export function SignatureDrawer() {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const padRef = useRef<SignaturePad | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (canvasRef.current) {
|
|
padRef.current = new SignaturePad(canvasRef.current)
|
|
}
|
|
return () => padRef.current?.off()
|
|
}, [])
|
|
|
|
const save = () => {
|
|
const dataUrl = padRef.current?.toDataURL('image/png')
|
|
// POST dataUrl to /api/agent/signature
|
|
}
|
|
|
|
return <canvas ref={canvasRef} width={400} height={150} />
|
|
}
|
|
```
|
|
|
|
**Do NOT add `react-signature-canvas`.** It wraps `signature_pad` at v1.1.0-alpha.2 (alpha status) and the project already has `signature_pad` directly. Use the raw library with a `useRef`.
|
|
|
|
**Embedding the saved signature into PDF:**
|
|
```typescript
|
|
const sigBytes = await fetch(agentSignatureBlobUrl).then(r => r.arrayBuffer())
|
|
const sigImage = await pdfDoc.embedPng(new Uint8Array(sigBytes))
|
|
const dims = sigImage.scaleToFit(fieldWidth, fieldHeight)
|
|
page.drawImage(sigImage, { x: fieldX, y: fieldY, width: dims.width, height: dims.height })
|
|
```
|
|
|
|
#### Feature 4: Filled Document Preview
|
|
|
|
**No new library needed.** `react-pdf` v10.4.1 is already installed and supports rendering a PDF from an `ArrayBuffer` directly.
|
|
|
|
**Architecture:**
|
|
1. Server Action: load original PDF from Vercel Blob, apply all field values (text, checkboxes, embedded signature image) using `@cantoo/pdf-lib`, return `pdfDoc.save()` bytes
|
|
2. API route returns the bytes as `application/pdf`; client receives as `ArrayBuffer`
|
|
3. Pass `ArrayBuffer` directly to `react-pdf`'s `<Document file={arrayBuffer}>` — no upload required
|
|
|
|
**Known issue with react-pdf v7+:** `ArrayBuffer` becomes detached after first use. Always copy:
|
|
```typescript
|
|
const safeCopy = (buf: ArrayBuffer) => {
|
|
const copy = new ArrayBuffer(buf.byteLength)
|
|
new Uint8Array(copy).set(new Uint8Array(buf))
|
|
return copy
|
|
}
|
|
<Document file={safeCopy(previewBuffer)}>
|
|
```
|
|
|
|
**react-pdf renders the flattened PDF accurately** — all filled text fields, checked checkboxes, and embedded signature images will appear correctly because they are baked into the PDF bytes by `@cantoo/pdf-lib` before rendering.
|
|
|
|
### Alternatives Considered (v1.1)
|
|
|
|
| Recommended | Alternative | Why Not |
|
|
|-------------|-------------|---------|
|
|
| `pdfjs-dist/legacy/build/pdf.mjs` directly | `unpdf` wrapper | `unpdf` was recommended in research but not actually installed; the legacy build path works correctly with Node 20 LTS. |
|
|
| `pdfjs-dist/legacy/build/pdf.mjs` directly | `pdf-parse` | `pdf-parse` is unmaintained (last publish 2019). |
|
|
| Manual JSON schema for OpenAI | `zodResponseFormat` helper | Broken with Zod v4 — open bug in `openai-node` as of Nov 2025. Manual schema avoids the dependency entirely. |
|
|
| `gpt-4.1` | `gpt-4o` | Real estate form field extraction is a structured extraction task on templated documents. Upgrade only if accuracy on unusual forms is unacceptable. |
|
|
| `page.drawImage()` for agent signature | `PDFSignature` AcroForm field | `@cantoo/pdf-lib` has no `createSignature()` API — `PDFSignature` only reads existing signature fields and provides no image embedding. The correct approach is `embedPng()` + `drawImage()` at the field coordinates. |
|
|
|
|
### What NOT to Add (v1.1)
|
|
|
|
| Avoid | Why | Use Instead |
|
|
|-------|-----|-------------|
|
|
| `zodResponseFormat` from `openai/helpers/zod` | Broken at runtime with Zod v4.x (throws exceptions). Open bug, no fix merged as of 2026-03-21. | Plain `response_format: { type: "json_schema", ... }` with hand-written schema |
|
|
| `react-signature-canvas` | Alpha version (1.1.0-alpha.2); project already has `signature_pad` v5 directly — the wrapper adds nothing | `signature_pad` + `useRef<HTMLCanvasElement>` directly |
|
|
| `@signpdf/placeholder-pdf-lib` | For cryptographic PKCS#7 digital signatures (DocuSign-style). This project needs visual e-signatures (image embedded in PDF), not cryptographic signing. | `@cantoo/pdf-lib` `embedPng()` + `drawImage()` |
|
|
| `pdf2json` | Extracts spatial text data; useful for arbitrary document analysis. Overkill here — we only need raw text content to feed OpenAI. | `pdfjs-dist` legacy build |
|
|
| `unpdf` | Was in the v1.1 research recommendation but not installed. The existing `pdfjs-dist/legacy/build/pdf.mjs` usage works correctly in Node 20 — do not add `unpdf` retroactively. | `pdfjs-dist` legacy build (already in use) |
|
|
| `langchain` / Vercel AI SDK | Heavy abstractions for the simple use case of one structured extraction call per document. Adds bundle size and abstraction layers with no benefit here. | `openai` SDK directly |
|
|
| A separate image processing library (`sharp`, `jimp`) | Not needed — signature PNGs from `signature_pad.toDataURL()` are already correctly sized canvas exports. `@cantoo/pdf-lib` handles embedding without pre-processing. | N/A |
|
|
|
|
### Version Compatibility (v1.1)
|
|
|
|
| Package | Compatible With | Notes |
|
|
|---------|-----------------|-------|
|
|
| `openai@6.32.0` | `zod@4.x` (manual schema only) | Do NOT use `zodResponseFormat` helper — use raw `json_schema` response_format. The helper is broken with Zod v4. |
|
|
| `openai@6.32.0` | Node.js 20+ | Requires Node 20 LTS or later. |
|
|
| `pdfjs-dist` (legacy build) | Node.js 20+ | Uses `legacy/build/pdf.mjs` path which handles `Promise.withResolvers` polyfill issues. Set `GlobalWorkerOptions.workerSrc` to local worker path. |
|
|
| `@cantoo/pdf-lib@2.6.3` | `react-pdf@10.4.1` | These do not interact at runtime — `@cantoo/pdf-lib` runs server-side, `react-pdf` runs client-side. No conflict. |
|
|
| `signature_pad@5.1.3` | React 19 | Use as a plain class instantiated in `useEffect` with a `useRef<HTMLCanvasElement>`. No React wrapper needed. |
|
|
|
|
### Sources (v1.1)
|
|
|
|
- [openai npm page](https://www.npmjs.com/package/openai) — v6.32.0 confirmed, Node 20 requirement — HIGH confidence
|
|
- [OpenAI Structured Outputs docs](https://platform.openai.com/docs/guides/structured-outputs) — manual json_schema format confirmed — HIGH confidence
|
|
- [openai-node Issue #1540](https://github.com/openai/openai-node/issues/1540) — zodResponseFormat broken with Zod v4 — HIGH confidence
|
|
- [openai-node Issue #1602](https://github.com/openai/openai-node/issues/1602) — zodTextFormat broken with Zod 4 — HIGH confidence
|
|
- [@cantoo/pdf-lib npm page](https://www.npmjs.com/package/@cantoo/pdf-lib) — v2.6.3, field types confirmed — HIGH confidence
|
|
- [signature_pad GitHub](https://github.com/szimek/signature_pad) — v5.1.3, toDataURL API — HIGH confidence
|
|
- Code audit: `src/lib/ai/extract-text.ts` — confirms `pdfjs-dist/legacy/build/pdf.mjs` in use, `unpdf` not installed — HIGH confidence
|
|
|
|
---
|
|
|
|
## v1.2 Stack Research — Multi-Signer + Docker Production
|
|
|
|
**Scope:** Multi-signer support, production Docker Compose, SMTP in Docker
|
|
**Confidence:** HIGH for Docker patterns and schema approach, HIGH for email env vars (code-verified)
|
|
|
|
---
|
|
|
|
### Summary
|
|
|
|
Multi-signer support requires **zero new npm packages**. It is a pure schema extension: a new
|
|
`document_signers` junction table in the existing Drizzle/PostgreSQL setup, plus a `signerEmail`
|
|
field added to the `SignatureFieldData` interface. The existing `signingTokens` table is extended
|
|
with a `signerEmail` column. Everything else — token generation, field rendering, email sending,
|
|
PDF merging — uses libraries already installed.
|
|
|
|
Docker production is a three-stage Dockerfile with Next.js `standalone` mode plus a
|
|
`docker-compose.yml` with `env_file` for SMTP credentials. The known "email not working in
|
|
Docker" failure mode is almost always environment variables not reaching the container — not a
|
|
nodemailer bug.
|
|
|
|
**Email provider confirmed:** The project uses `nodemailer` v7.0.13 with SMTP (not Resend, not
|
|
SendGrid). Both the contact form (`contact-mailer.ts`) and signing emails (`signing-mailer.tsx`)
|
|
use nodemailer with shared env vars `CONTACT_SMTP_HOST`, `CONTACT_SMTP_PORT`, `CONTACT_EMAIL_USER`,
|
|
`CONTACT_EMAIL_PASS`. The `sendAgentNotificationEmail` function already exists — it just needs to
|
|
be called for the completion event. No email library changes needed.
|
|
|
|
---
|
|
|
|
### New Dependencies for v1.2
|
|
|
|
#### Multi-Signer: None
|
|
|
|
| Capability | Already Covered By |
|
|
|---|---|
|
|
| Per-signer token | `signingTokens` table — extend with `signerEmail TEXT NOT NULL` column |
|
|
| Per-signer field filtering | Filter `signatureFields` JSONB by `field.signerEmail` at query time |
|
|
| Completion detection | Query `document_signers` WHERE `signedAt IS NULL` |
|
|
| Parallel email dispatch | `nodemailer` (already installed) — `Promise.all([sendMail(...), sendMail(...)])` |
|
|
| Final PDF merge after all sign | `@cantoo/pdf-lib` (already installed) |
|
|
| Agent notification on completion | `sendAgentNotificationEmail()` already implemented in `signing-mailer.tsx` |
|
|
| Final PDF to all parties | `nodemailer` + `@react-email/render` (already installed) |
|
|
|
|
**Schema additions needed (pure Drizzle migration, no new packages):**
|
|
|
|
1. **New table `document_signers`** — one row per (document, signer email). Columns:
|
|
`id`, `documentId` (FK → documents), `signerEmail`, `signerName` (optional),
|
|
`signedAt` (nullable timestamp), `ipAddress` (captured at signing), `tokenJti` (FK → signingTokens).
|
|
|
|
2. **New field on `SignatureFieldData` interface** — `signerEmail?: string`. Fields without
|
|
`signerEmail` are agent-only fields (already handled). Fields with `signerEmail` route to
|
|
that signer's session.
|
|
|
|
3. **Extend `documentStatusEnum`** — add `'PartialSigned'` (some but not all signers complete).
|
|
`'Signed'` continues to mean all signers have completed.
|
|
|
|
4. **Extend `auditEventTypeEnum`** — add `'all_signers_complete'` for the completion notification
|
|
trigger.
|
|
|
|
5. **Extend `signingTokens` table** — add `signerEmail text NOT NULL` column so each token is
|
|
scoped to one signer and the signing page can filter fields correctly.
|
|
|
|
#### Docker: No New Application Packages
|
|
|
|
The Docker setup is infrastructure only (Dockerfile + docker-compose.yml). No npm packages are
|
|
added to the application.
|
|
|
|
**One optional dev-only Docker image for local email testing:**
|
|
|
|
| Tool | What It Is | When to Use |
|
|
|---|---|---|
|
|
| `maildev/maildev:latest` | Lightweight SMTP trap that catches all outbound mail and shows it in a web UI | Add to a `docker-compose.override.yml` for local development only. Never deploy to production. |
|
|
|
|
---
|
|
|
|
### Docker Stack
|
|
|
|
#### Image Versions
|
|
|
|
| Service | Image | Rationale |
|
|
|---|---|---|
|
|
| Next.js app | `node:20-alpine` | LTS, small. Do NOT use node:24 — @napi-rs/canvas ships prebuilt `.node` binaries and the build for node:24-alpine may not exist yet. Verify before upgrading. |
|
|
| PostgreSQL | `postgres:16-alpine` | Current stable, alpine keeps it small. Pin to `16-alpine` explicitly — never use `postgres:latest` which silently upgrades major versions on `docker pull`. |
|
|
|
|
#### Dockerfile Pattern — Three-Stage Standalone
|
|
|
|
Next.js `output: 'standalone'` in `next.config.ts` must be enabled. This generates
|
|
`.next/standalone/` with a minimal self-contained Node.js server. Reduces image from ~7 GB
|
|
(naive) to ~300 MB (verified across multiple production reports).
|
|
|
|
**Stage 1 — deps:** `npm ci --omit=dev` with cache mounts. This layer is cached until
|
|
`package-lock.json` changes, making subsequent builds fast.
|
|
|
|
**Stage 2 — builder:** Copy deps from Stage 1, copy source, run `next build`. Set
|
|
`NEXT_TELEMETRY_DISABLED=1` and `NODE_ENV=production`.
|
|
|
|
**Stage 3 — runner:** Copy `.next/standalone/`, `.next/static/`, and `public/` from builder
|
|
only. Set `HOSTNAME=0.0.0.0` and `PORT=3000`. Run as non-root user. The `uploads/`
|
|
directory must be a named Docker volume — never baked into the image.
|
|
|
|
**@napi-rs/canvas native binding note:** This package includes a compiled `.node` binary for a
|
|
specific OS and CPU architecture. The build stage and runner stage must use the same OS
|
|
(both `node:20-alpine`) and the Docker build must run on the same CPU architecture as the
|
|
deployment server (arm64 for Apple Silicon servers, amd64 for x86). Use
|
|
`docker buildx --platform linux/arm64` or `linux/amd64` explicitly if building cross-platform.
|
|
Cross-architecture builds will produce a binary that silently fails at runtime.
|
|
|
|
**Database migrations on startup:** Add an `entrypoint.sh` that runs
|
|
`npx drizzle-kit migrate && exec node server.js`. This ensures schema migrations run before
|
|
the application accepts traffic on every container start.
|
|
|
|
#### Docker Compose Structure
|
|
|
|
```yaml
|
|
services:
|
|
app:
|
|
build: .
|
|
restart: unless-stopped
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
env_file: .env.production # SMTP creds, AUTH_SECRET, OPENAI_API_KEY etc.
|
|
environment:
|
|
NODE_ENV: production
|
|
DATABASE_URL: postgresql://appuser:${POSTGRES_PASSWORD}@db:5432/tchmesapp
|
|
volumes:
|
|
- uploads:/app/uploads # PDFs written at runtime
|
|
ports:
|
|
- "3000:3000"
|
|
networks:
|
|
- internal
|
|
|
|
db:
|
|
image: postgres:16-alpine
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: appuser
|
|
POSTGRES_DB: tchmesapp
|
|
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
|
secrets:
|
|
- postgres_password
|
|
volumes:
|
|
- pgdata:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U appuser -d tchmesapp"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
networks:
|
|
- internal
|
|
|
|
volumes:
|
|
pgdata:
|
|
uploads:
|
|
|
|
networks:
|
|
internal:
|
|
driver: bridge
|
|
|
|
secrets:
|
|
postgres_password:
|
|
file: ./secrets/postgres_password.txt
|
|
```
|
|
|
|
**Key decisions:**
|
|
|
|
- `db` is on the `internal` bridge network only — not exposed to the host or internet.
|
|
- `app` waits for `db` to pass its health check before starting (`condition: service_healthy`),
|
|
preventing migration failures on cold boot.
|
|
- `restart: unless-stopped` survives server reboots without systemd service files.
|
|
- Named volumes for `pgdata` and `uploads` survive container recreation.
|
|
- PostgreSQL uses a Docker secret for its password because Postgres natively supports
|
|
`POSTGRES_PASSWORD_FILE`. The app reads its `DATABASE_URL` from `.env.production` via
|
|
`env_file` — no code change needed.
|
|
|
|
---
|
|
|
|
### SMTP / Email in Docker
|
|
|
|
#### Actual Environment Variable Names (Code-Verified)
|
|
|
|
The signing mailer (`src/lib/signing/signing-mailer.tsx`) reads these exact env vars:
|
|
|
|
```
|
|
CONTACT_SMTP_HOST=smtp.gmail.com
|
|
CONTACT_SMTP_PORT=587
|
|
CONTACT_EMAIL_USER=teressa@tcopelandhomes.com
|
|
CONTACT_EMAIL_PASS=xxxx-xxxx-xxxx-xxxx
|
|
AGENT_EMAIL=teressa@tcopelandhomes.com
|
|
```
|
|
|
|
These same vars are used by the contact form mailer (`src/lib/contact-mailer.ts`). Both
|
|
mailers share the same SMTP transport configuration via the same env var names.
|
|
|
|
#### The Actual Problem
|
|
|
|
The current "email not working in Docker" bug is almost certainly one of two causes:
|
|
|
|
**Cause 1 — Environment variables not passed to the container.** Docker does not inherit host
|
|
environment variables. If `CONTACT_SMTP_HOST`, `CONTACT_EMAIL_PASS` etc. are in `.env.local`
|
|
on the host, they are invisible inside the container unless explicitly injected via `env_file`
|
|
or `environment:` in docker-compose.yml.
|
|
|
|
**Cause 2 — DNS resolution failure (EAI_AGAIN).** Docker containers use Docker's internal DNS
|
|
resolver (127.0.0.11). This can intermittently fail to resolve external hostnames, producing
|
|
`getaddrinfo EAI_AGAIN smtp.gmail.com`. The symptom is email that works locally but silently
|
|
fails (or errors) in Docker.
|
|
|
|
nodemailer itself is reliable in Docker containers. The multiple open GitHub issues on this
|
|
topic all trace back to environment or DNS configuration problems, not library bugs.
|
|
|
|
#### Solution: env_file for Application Secrets
|
|
|
|
Docker Compose native secrets (non-Swarm) mount plaintext files at `/run/secrets/secret_name`.
|
|
Application code must explicitly read those file paths. nodemailer reads credentials from
|
|
`process.env` string values — not file paths. Rewriting the transporter initialization to read
|
|
from `/run/secrets/` would require code changes for no meaningful security gain on a
|
|
single-server setup.
|
|
|
|
The correct approach for this application is `env_file`:
|
|
|
|
1. Create `.env.production` on the server (never commit, add to `.gitignore`):
|
|
```
|
|
CONTACT_SMTP_HOST=smtp.gmail.com
|
|
CONTACT_SMTP_PORT=587
|
|
CONTACT_EMAIL_USER=teressa@tcopelandhomes.com
|
|
CONTACT_EMAIL_PASS=xxxx-xxxx-xxxx-xxxx
|
|
AGENT_EMAIL=teressa@tcopelandhomes.com
|
|
AUTH_SECRET=<long-random-string>
|
|
OPENAI_API_KEY=sk-...
|
|
POSTGRES_PASSWORD=<db-password>
|
|
NEXT_PUBLIC_BASE_URL=https://teressacopelandhomes.com
|
|
```
|
|
|
|
2. In `docker-compose.yml`, reference it:
|
|
```yaml
|
|
services:
|
|
app:
|
|
env_file: .env.production
|
|
```
|
|
|
|
3. All variables in that file are injected as `process.env.*` inside the container.
|
|
nodemailer reads `process.env.CONTACT_EMAIL_PASS` exactly as in development. Zero code changes.
|
|
|
|
**Why not Docker Swarm secrets for SMTP?** Plain Compose secrets have no encryption — they are
|
|
just bind-mounted plaintext files. The security profile is identical to a chmod 600
|
|
`.env.production` file. The complexity cost (code that reads from `/run/secrets/`) is not
|
|
justified on a single-server home deployment. Use Docker secrets for PostgreSQL password only
|
|
because PostgreSQL natively reads from `POSTGRES_PASSWORD_FILE` — no code change required.
|
|
|
|
#### DNS Fix for EAI_AGAIN
|
|
|
|
If SMTP resolves correctly in dev but fails in Docker, add to the app service:
|
|
|
|
```yaml
|
|
services:
|
|
app:
|
|
dns:
|
|
- 8.8.8.8
|
|
- 1.1.1.1
|
|
environment:
|
|
NODE_OPTIONS: --dns-result-order=ipv4first
|
|
```
|
|
|
|
The `dns:` keys bypass Docker's internal resolver for external lookups. `--dns-result-order=ipv4first`
|
|
tells Node.js to try IPv4 DNS results before IPv6, which resolves the most common Docker DNS
|
|
timeout pattern (IPv6 path unreachable, long timeout before IPv4 fallback).
|
|
|
|
#### SMTP Provider
|
|
|
|
Gmail with an App Password (not the account password) is the recommended choice for a solo
|
|
agent at low volume. Requires 2FA enabled on the Google account. The signing mailer already
|
|
uses port logic: port 465 → `secure: true`; any other port → `secure: false`. Port 587 with
|
|
STARTTLS is more reliable than port 465 implicit TLS in Docker environments — use
|
|
`CONTACT_SMTP_PORT=587`.
|
|
|
|
---
|
|
|
|
### What NOT to Add (v1.2)
|
|
|
|
| Temptation | Why to Avoid |
|
|
|---|---|
|
|
| Redis / BullMQ for email queuing | Overkill. This app sends at most 5 emails per document. `Promise.all([sendMail(...)])` is sufficient. Redis adds a third container, more ops burden, and more failure modes. |
|
|
| Resend / SendGrid / Postmark | Adds a paid external dependency. nodemailer + Gmail App Password is free, already implemented, and reliable when env vars are correctly passed. Switch only if Gmail SMTP becomes a persistent problem. |
|
|
| Docker Swarm secrets for SMTP | Requires code changes to read from file paths. No security benefit over a permission-restricted `env_file` on single-server non-Swarm setup. |
|
|
| `postgres:latest` image | Will silently upgrade major versions on `docker pull`. Always pin to `postgres:16-alpine`. |
|
|
| Node.js 22 or 24 as base image | @napi-rs/canvas ships prebuilt `.node` binaries. Verify the binding exists for the target node/alpine version before upgrading. Node 20 LTS is verified. |
|
|
| Sequential signing enforcement | The PROJECT.md specifies parallel signing only ("any order"). Do not add sequencing logic. |
|
|
| WebSockets for real-time signing status | Polling the agent dashboard every 30 seconds is sufficient for one agent monitoring a handful of documents. No WebSocket infrastructure needed. |
|
|
| Separate migration container | A `depends_on: condition: service_completed_successfully` init container is architecturally cleaner but adds complexity. An `entrypoint.sh` in the same `app` container is simpler and sufficient at this scale. |
|
|
| HelloSign / DocuSign integration | Explicitly out of scope per PROJECT.md. Custom e-signature is the intentional choice. |
|
|
| `unpdf` | Already documented in v1.1 "What NOT to Add" — the existing `pdfjs-dist` legacy build is in use and working. |
|
|
|
|
---
|
|
|
|
### OpenSign Architecture Reference
|
|
|
|
OpenSign (React + Node.js + MongoDB) implements multi-recipient signing as a signers array
|
|
embedded in each document record. Each signer object holds its own status, token reference, and
|
|
completed-at timestamp. All signing links are sent simultaneously (parallel) by default. Their
|
|
MongoDB document array maps directly to a PostgreSQL `document_signers` junction table in
|
|
relational terms. The core insight confirmed by OpenSign's design: multi-signer needs no
|
|
specialized packages — it is a data model and routing concern.
|
|
|
|
---
|
|
|
|
### Sources (v1.2)
|
|
|
|
- Code audit: `src/lib/signing/signing-mailer.tsx` — env var names `CONTACT_SMTP_HOST`, `CONTACT_EMAIL_USER`, `CONTACT_EMAIL_PASS`, `AGENT_EMAIL` confirmed — HIGH confidence
|
|
- Code audit: `src/lib/db/schema.ts` — current `SignatureFieldData`, `signingTokens`, `documentStatusEnum`, `auditEventTypeEnum` confirmed — HIGH confidence
|
|
- Code audit: `package.json` — nodemailer v7.0.13, @react-email installed, unpdf NOT installed — HIGH confidence
|
|
- [Docker Compose: Next.js + PostgreSQL + Redis (Feb 2026)](https://oneuptime.com/blog/post/2026-02-08-how-to-set-up-a-nextjs-postgresql-redis-stack-with-docker-compose/view) — HIGH confidence
|
|
- [Docker Official Next.js Containerize Guide](https://docs.docker.com/guides/nextjs/containerize/) — HIGH confidence
|
|
- [Docker Compose Secrets — Official Docs](https://docs.docker.com/compose/how-tos/use-secrets/) — HIGH confidence
|
|
- [Docker Compose Secrets: What Works, What Doesn't](https://www.bitdoze.com/docker-compose-secrets/) — MEDIUM confidence
|
|
- [Docker Compose Secrets: Export /run/secrets to Env Vars (Dec 2025)](https://phoenixtrap.com/2025/12/22/10-lines-to-better-docker-compose-secrets/) — MEDIUM confidence
|
|
- [Nodemailer Docker EAI_AGAIN — Docker Forums](https://forums.docker.com/t/not-able-to-send-email-using-nodemailer-within-docker-container-due-to-eai-again/40649) — HIGH confidence (root cause confirmed)
|
|
- [Nodemailer works local, fails in Docker — GitHub Issue #1495](https://github.com/nodemailer/nodemailer/issues/1495) — HIGH confidence (issue confirmed unresolved = env problem, not library)
|
|
- [OpenSign GitHub Repository](https://github.com/OpenSignLabs/OpenSign) — HIGH confidence
|
|
- [Next.js Standalone Docker Mode — DEV Community (2025)](https://dev.to/angojay/optimizing-nextjs-docker-images-with-standalone-mode-2nnh) — MEDIUM confidence
|
|
- [DNS EAI_AGAIN in Docker — Beyond 'It Works on My Machine'](https://dev.to/ameer-pk/beyond-it-works-on-my-machine-solving-docker-networking-dns-bottlenecks-4f3m) — MEDIUM confidence
|
|
|
|
---
|
|
|
|
*Last updated: 2026-04-03 — v1.2 multi-signer and Docker production additions; corrected unpdf status, env var names, and added missing packages to existing stack table*
|