From 3e7cfaf165c70a1ca0b65a2a1e0429cea64f7403 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 28 Oct 2026 14:30:12 -0700 Subject: [PATCH 1/3] fix(web): copy email templates into the production image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit templates/ sits at the repo root, outside every workspace package, so turbo prune drops it from out/full/ and the runner stage never copied it. Every sendEmail call in the image returned 'Template "…" not found'. Refs #253 --- apps/web/Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 1c5d805..06da952 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -56,6 +56,12 @@ WORKDIR /app COPY --from=installer --chown=outpost:outpost /app/apps/web/.next/standalone ./ COPY --from=installer --chown=outpost:outpost /app/apps/web/.next/static ./apps/web/.next/static COPY --from=installer --chown=outpost:outpost /app/apps/web/public ./apps/web/public +# Email templates live at the repo root, outside every workspace package, so turbo +# prune leaves them out of out/full/ and Next standalone tracing cannot see the +# runtime-computed readFileSync path. Copied from the pruner, which has the whole +# build context. The standalone server chdirs to /app/apps/web, and the loader finds +# /app/templates via its ../../templates candidate (outpost#253). +COPY --from=pruner --chown=outpost:outpost /app/templates ./templates # Prisma query engine for linux-musl (required by Next.js standalone) COPY --from=installer --chown=outpost:outpost /app/node_modules/.pnpm/@prisma+client*/node_modules/.prisma/client/*.node ./node_modules/.prisma/client/ COPY --from=installer --chown=outpost:outpost /app/node_modules/.pnpm/@prisma+client*/node_modules/.prisma/client/schema.prisma ./node_modules/.prisma/client/ From f0bbf87c56f71a5dc47d12f9d64338119763eeb4 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 28 Oct 2026 14:30:12 -0700 Subject: [PATCH 2/3] fix(web): stop replacing postmark with an empty module in the server bundle sendEmail is transpiled into the web server bundle, so nulling postmark made new ServerClient() throw and every Postmark send fall back to console output. Refs #253 --- apps/web/next.config.ts | 48 ++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index a6a259e..970e965 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -4,17 +4,25 @@ const nextConfig: NextConfig = { output: 'standalone', transpilePackages: ['@copilotkit/outpost'], async headers() { - return [{ - source: '/(.*)', - headers: [ - { key: 'X-Content-Type-Options', value: 'nosniff' }, - { key: 'X-Frame-Options', value: 'DENY' }, - { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, - { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, - { key: 'X-XSS-Protection', value: '1; mode=block' }, - { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, - ], - }]; + return [ + { + source: '/(.*)', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { + key: 'Strict-Transport-Security', + value: 'max-age=31536000; includeSubDomains', + }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'X-XSS-Protection', value: '1; mode=block' }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, + ], + }, + ]; }, webpack: (config, { isServer }) => { if (isServer) { @@ -22,10 +30,20 @@ const nextConfig: NextConfig = { // but get pulled in transitively via the queue/shared barrel exports. // Replace with empty modules so webpack doesn't try to require them. const nullPkgs = [ - 'discord.js', '@discordjs/rest', '@discordjs/ws', '@discordjs/collection', - 'zlib-sync', 'bufferutil', 'utf-8-validate', - '@slack/web-api', '@slack/bolt', - 'postmark', 'nodemailer', + 'discord.js', + '@discordjs/rest', + '@discordjs/ws', + '@discordjs/collection', + 'zlib-sync', + 'bufferutil', + 'utf-8-validate', + '@slack/web-api', + '@slack/bolt', + // nodemailer stays nulled: it is not installed, and the SMTP transport + // only matters for self-hosted setups. postmark is NOT here: sendEmail + // (transpiled from @copilotkit/outpost) loads it in this server bundle, + // and nulling it made every Postmark send fail (outpost#253). + 'nodemailer', ]; for (const pkg of nullPkgs) { config.resolve.alias[pkg] = false; From ed7ec8460e78c29e64005c2e727cfc84fa19e9c4 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 28 Oct 2026 14:30:12 -0700 Subject: [PATCH 3/3] test(web): guard templates copy and postmark resolution for the production image Fixes #253 --- .../__tests__/production-email-image.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 apps/web/src/__tests__/production-email-image.test.ts diff --git a/apps/web/src/__tests__/production-email-image.test.ts b/apps/web/src/__tests__/production-email-image.test.ts new file mode 100644 index 0000000..0d84373 --- /dev/null +++ b/apps/web/src/__tests__/production-email-image.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment node +/** + * Guards for outpost#253: email could not work in the production web image. + * + * The template tests pass from a checkout because the loader finds the repo-root + * templates/ by walking up from cwd. The image has no checkout, so the runner stage + * has to copy templates/ in itself. And the server bundle has to be able to load + * postmark, because sendEmail is transpiled into it. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'fs'; +import path from 'path'; +import nextConfig from '../../next.config'; + +const repoRoot = path.resolve(__dirname, '../../../..'); + +function runnerStage(dockerfile: string): string[] { + const lines = dockerfile.split('\n').map((l) => l.trim()); + const start = lines.findIndex((l) => /^FROM\s+\S+\s+AS\s+runner$/i.test(l)); + expect(start, 'apps/web/Dockerfile has no `AS runner` stage').toBeGreaterThanOrEqual(0); + const rest = lines.slice(start + 1); + const next = rest.findIndex((l) => /^FROM\s/i.test(l)); + return next === -1 ? rest : rest.slice(0, next); +} + +describe('production web image: email', () => { + it('copies templates/ into /app/templates in the runner stage', () => { + const stage = runnerStage( + readFileSync(path.join(repoRoot, 'apps/web/Dockerfile'), 'utf-8'), + ); + + const workdir = stage.find((l) => /^WORKDIR\s/i.test(l))?.split(/\s+/)[1]; + expect(workdir).toBe('/app'); + + const copy = stage.find( + (l) => /^COPY\s/i.test(l) && /\s\/app\/templates\/?\s/.test(`${l} `), + ); + expect(copy, 'runner stage never copies /app/templates').toBeDefined(); + const dest = copy!.split(/\s+/).at(-1)!; + expect(path.posix.resolve(workdir!, dest)).toBe('/app/templates'); + }); + + it('has templates in the repo-root templates/ directory that the image copies', () => { + const slugs = readdirSync(path.join(repoRoot, 'templates')).filter((f) => + f.endsWith('.md'), + ); + expect(slugs).toContain('invite.md'); + }); + + it('does not replace postmark with an empty module in the server bundle', () => { + const config = { resolve: { alias: {} as Record } }; + const webpack = nextConfig.webpack as ( + c: typeof config, + o: { isServer: boolean }, + ) => typeof config; + const out = webpack(config, { isServer: true }); + expect(out.resolve.alias.postmark).toBeUndefined(); + }); +});