From 1853eaa963168a2812563445abe36614859381a9 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Mon, 17 Aug 2026 16:35:14 -0400 Subject: [PATCH] Add Mailpit email transport --- Dockerfile | 18 ++- README.md | 5 +- apps/web/.env.example | 11 +- apps/web/README.md | 16 ++- apps/web/docs/ARCHITECTURE.md | 28 ++-- apps/web/package.json | 1 + apps/web/src/env.js | 12 ++ apps/web/src/lib/password-reset.ts | 19 +-- apps/web/src/server/api/routers/invoices.ts | 92 ++++++------- apps/web/src/server/services/email-sender.ts | 53 +++++++ .../src/server/services/send-invoice-email.ts | 90 ++++-------- bun.lock | 24 ++++ docker-compose.coolify.yml | 6 + docker-compose.dev.yml | 11 ++ docker-compose.yml | 6 + package.json | 4 +- packages/email/README.md | 16 +++ packages/email/package.json | 25 ++++ packages/email/src/index.ts | 130 ++++++++++++++++++ packages/email/tests/email.test.ts | 59 ++++++++ packages/email/tsconfig.json | 8 ++ scripts/send-email-preview.ts | 68 +++++++++ turbo.json | 13 ++ 23 files changed, 567 insertions(+), 148 deletions(-) create mode 100644 apps/web/src/server/services/email-sender.ts create mode 100644 packages/email/README.md create mode 100644 packages/email/package.json create mode 100644 packages/email/src/index.ts create mode 100644 packages/email/tests/email.test.ts create mode 100644 packages/email/tsconfig.json create mode 100644 scripts/send-email-preview.ts diff --git a/Dockerfile b/Dockerfile index dd12fdf..3e131f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,13 @@ COPY apps/web/package.json apps/web/package.json COPY apps/worker/package.json apps/worker/package.json COPY apps/mobile/package.json apps/mobile/package.json COPY packages/domain/package.json packages/domain/package.json -RUN bun install --frozen-lockfile --filter @beenvoice/web +COPY packages/email/package.json packages/email/package.json +RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \ + for attempt in 1 2 3; do \ + bun install --frozen-lockfile --filter @beenvoice/web && exit 0; \ + echo "Bun install failed (attempt ${attempt}/3); retrying" >&2; \ + done; \ + exit 1 FROM base AS worker-install COPY package.json bun.lock ./ @@ -17,7 +23,13 @@ COPY apps/web/package.json apps/web/package.json COPY apps/worker/package.json apps/worker/package.json COPY apps/mobile/package.json apps/mobile/package.json COPY packages/domain/package.json packages/domain/package.json -RUN bun install --frozen-lockfile --filter @beenvoice/worker +COPY packages/email/package.json packages/email/package.json +RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \ + for attempt in 1 2 3; do \ + bun install --frozen-lockfile --filter @beenvoice/worker && exit 0; \ + echo "Bun install failed (attempt ${attempt}/3); retrying" >&2; \ + done; \ + exit 1 # Next's production build runs under Node because Bun can fail during the # page-data worker phase on Linux arm64. Dependencies still come from Bun. @@ -52,6 +64,7 @@ COPY --from=install /app/apps/web/node_modules ./apps/web/node_modules COPY --from=build /app/package.json ./package.json COPY --from=build /app/apps/web/package.json ./apps/web/package.json COPY --from=build /app/packages/domain ./packages/domain +COPY --from=build /app/packages/email ./packages/email COPY --from=build /app/apps/web/.next ./apps/web/.next COPY --from=build /app/apps/web/public ./apps/web/public COPY --from=build /app/apps/web/drizzle.config.ts ./apps/web/drizzle.config.ts @@ -74,6 +87,7 @@ COPY apps/web/src ./apps/web/src COPY apps/web/tsconfig.json ./apps/web/tsconfig.json COPY apps/worker ./apps/worker COPY packages/domain ./packages/domain +COPY packages/email ./packages/email RUN ln -s ../worker/node_modules apps/web/node_modules USER bun diff --git a/README.md b/README.md index 749de11..426700a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ beenvoice/ │ ├── mobile/ # Expo Router mobile app and iOS widgets │ └── worker/ # PostgreSQL-backed scheduler and background jobs ├── packages/ -│ └── domain/ # Platform-neutral shared rules and parsing +│ ├── domain/ # Platform-neutral shared rules and parsing +│ └── email/ # Resend and SMTP/Mailpit delivery adapter ├── Dockerfile ├── docker-compose*.yml ├── package.json @@ -43,6 +44,7 @@ bun run lint bun run test bun run build bun run check +bun run email:preview # sends a PDF-bearing message to local Mailpit ``` Run an app-specific command with a workspace filter: @@ -72,6 +74,7 @@ The root Dockerfile installs the frozen Bun workspace lockfile and exposes separ - [Mobile architecture](./apps/mobile/docs/ARCHITECTURE.md) - [Worker architecture](./apps/worker/README.md) - [Shared domain package](./packages/domain/README.md) +- [Email delivery package](./packages/email/README.md) ## Product concepts diff --git a/apps/web/.env.example b/apps/web/.env.example index a1fd812..494b4cc 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -114,12 +114,19 @@ NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice NEXT_PUBLIC_BRAND_ICON=$ # ============================================================================= -# Email — Resend (optional) +# Email — Mailpit locally, Resend in production # ============================================================================= -# Leave blank to disable invoice and password-reset email delivery. +# Start local dependencies, then inspect messages at http://localhost:8028. +# Production must use EMAIL_PROVIDER=resend (Mailpit is rejected in production). +EMAIL_PROVIDER=mailpit +EMAIL_FROM=beenvoice +SMTP_HOST=127.0.0.1 +SMTP_PORT=1028 +SMTP_SECURE=false RESEND_API_KEY= RESEND_DOMAIN= +RESEND_FROM= # ============================================================================= # Analytics — Umami (optional) diff --git a/apps/web/README.md b/apps/web/README.md index 0a7c245..95df515 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -16,7 +16,7 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal | Database | PostgreSQL 17, Drizzle ORM | | Auth | better-auth (email/password, optional Authentik OIDC, Expo mobile) | | UI | shadcn/ui, Tailwind CSS v4 | -| Email / PDF | Resend, `@react-pdf/renderer` | +| Email / PDF | Resend or SMTP/Mailpit, `@react-pdf/renderer` | | Runtime | Bun | ## Features @@ -24,7 +24,7 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal - Clients, businesses, invoices (line items, tax, status workflow) - Time clock with one running timer per user; clock-out can append invoice lines - Expenses, payments, recurring invoices, invoice templates -- PDF export and email delivery (Resend) +- PDF export and email delivery (Resend in production, Mailpit locally) - Public invoice links (`/i/[token]`) - CSV import, reports, platform branding / admin settings - MCP API (`/api/mcp`) for automation via API keys (`bv_…`) @@ -62,7 +62,13 @@ BETTER_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000 ``` -Email and SSO are optional for local work — leave `RESEND_*` and `AUTHENTIK_*` blank unless you need them. +SSO is optional for local work. Email defaults to Mailpit: start the development +Compose services and open `http://localhost:8028` to inspect messages. + +```bash +bun run --filter @beenvoice/web docker:up +bun run email:preview +``` ### 3. Database @@ -199,7 +205,9 @@ Use the literal strings `true` or `false` (or omit the variable). Do not rely on | Variable | Purpose | | -------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `RESEND_API_KEY`, `RESEND_DOMAIN` | Invoice and password-reset email | +| `EMAIL_PROVIDER`, `EMAIL_FROM` | Select `mailpit`, `smtp`, or `resend` and configure the sender | +| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | Local Mailpit or another SMTP-compatible transport | +| `RESEND_API_KEY`, `RESEND_DOMAIN`, `RESEND_FROM` | Production Resend delivery | | `AUTHENTIK_ISSUER`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET` | OIDC SSO (also set `NEXT_PUBLIC_AUTHENTIK_ENABLED=true` and rebuild) | | `CRON_SECRET` | Protects `/api/cron/generate-recurring` | | `DISABLE_SIGNUPS=true` | Block new registrations | diff --git a/apps/web/docs/ARCHITECTURE.md b/apps/web/docs/ARCHITECTURE.md index 3f9a87f..3bf30e2 100644 --- a/apps/web/docs/ARCHITECTURE.md +++ b/apps/web/docs/ARCHITECTURE.md @@ -13,7 +13,7 @@ This application is the server and browser workspace in the Beenvoice monorepo. | ORM | Drizzle + `pg` pool | | Auth | better-auth (email/password, optional Authentik OIDC, Expo plugin for mobile) | | UI | shadcn/ui, Tailwind CSS v4, Radix primitives | -| Email | Resend | +| Email | Shared Resend/SMTP transport; Mailpit for local capture | | PDF | `@react-pdf/renderer` | ## Request flow @@ -166,18 +166,20 @@ API keys: format `bv_`; stored as SHA-256 hash (`src/server/api/api-k Validated in `src/env.js`. See `.env.example`. -| Variable | Required | Notes | -| --------------------------------- | -------------------- | ------------------------------------------------------------------------------ | -| `DATABASE_URL` | yes | PostgreSQL connection string | -| `AUTH_SECRET` | prod | `openssl rand -base64 32` | -| `BETTER_AUTH_URL` | yes | Public URL of API (no trailing path) | -| `NEXT_PUBLIC_APP_URL` | yes | Browser-facing URL | -| `DB_DISABLE_SSL` | local | `true` for Docker dev DB | -| `RESEND_API_KEY`, `RESEND_DOMAIN` | optional | Email; blank disables send | -| `AUTHENTIK_*` | optional | OIDC SSO | -| `DISABLE_SIGNUPS` | optional | `true` blocks registration; use string `true`/`false` (parsed in `src/env.js`) | -| `CRON_SECRET` | worker / cron routes | Protects worker delivery and `/api/cron/generate-recurring` | -| `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults | +| Variable | Required | Notes | +| ------------------------------------------------ | -------------------- | ------------------------------------------------------------------------------ | +| `DATABASE_URL` | yes | PostgreSQL connection string | +| `AUTH_SECRET` | prod | `openssl rand -base64 32` | +| `BETTER_AUTH_URL` | yes | Public URL of API (no trailing path) | +| `NEXT_PUBLIC_APP_URL` | yes | Browser-facing URL | +| `DB_DISABLE_SSL` | local | `true` for Docker dev DB | +| `EMAIL_PROVIDER`, `EMAIL_FROM` | optional | `mailpit`, `smtp`, or `resend`; sender identity | +| `SMTP_HOST`, `SMTP_PORT` | SMTP/Mailpit | SMTP endpoint (`127.0.0.1:1028` in local development) | +| `RESEND_API_KEY`, `RESEND_DOMAIN`, `RESEND_FROM` | Resend | Production delivery credentials and verified sender | +| `AUTHENTIK_*` | optional | OIDC SSO | +| `DISABLE_SIGNUPS` | optional | `true` blocks registration; use string `true`/`false` (parsed in `src/env.js`) | +| `CRON_SECRET` | worker / cron routes | Protects worker delivery and `/api/cron/generate-recurring` | +| `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults | ## Docker diff --git a/apps/web/package.json b/apps/web/package.json index 6d70fcb..c2ea5dd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@beenvoice/domain": "workspace:*", + "@beenvoice/email": "workspace:*", "@aws-sdk/client-s3": "3.1075.0", "@better-auth/expo": "1.6.19", "@dnd-kit/core": "6.3.1", diff --git a/apps/web/src/env.js b/apps/web/src/env.js index df6c726..91ffa18 100644 --- a/apps/web/src/env.js +++ b/apps/web/src/env.js @@ -27,8 +27,14 @@ export const env = createEnv({ : z.string().optional(), DATABASE_URL: z.string().url(), BETTER_AUTH_URL: z.string().url().optional(), + EMAIL_PROVIDER: z.enum(["mailpit", "smtp", "resend"]).default("resend"), + EMAIL_FROM: z.string().min(1).optional(), + SMTP_HOST: z.string().min(1).optional(), + SMTP_PORT: z.string().regex(/^\d+$/).optional(), + SMTP_SECURE: optionalEnvBoolean(), RESEND_API_KEY: z.string().min(1).optional(), RESEND_DOMAIN: z.string().optional(), + RESEND_FROM: z.string().min(1).optional(), NODE_ENV: z .enum(["development", "test", "production"]) .default("development"), @@ -76,8 +82,14 @@ export const env = createEnv({ AUTH_SECRET: process.env.AUTH_SECRET, DATABASE_URL: process.env.DATABASE_URL, BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, + EMAIL_PROVIDER: process.env.EMAIL_PROVIDER, + EMAIL_FROM: process.env.EMAIL_FROM, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_SECURE: process.env.SMTP_SECURE, RESEND_API_KEY: process.env.RESEND_API_KEY, RESEND_DOMAIN: process.env.RESEND_DOMAIN, + RESEND_FROM: process.env.RESEND_FROM, NODE_ENV: process.env.NODE_ENV, DB_DISABLE_SSL: process.env.DB_DISABLE_SSL, DISABLE_SIGNUPS: process.env.DISABLE_SIGNUPS, diff --git a/apps/web/src/lib/password-reset.ts b/apps/web/src/lib/password-reset.ts index 7c75060..b35d545 100644 --- a/apps/web/src/lib/password-reset.ts +++ b/apps/web/src/lib/password-reset.ts @@ -1,7 +1,5 @@ import { eq } from "drizzle-orm"; -import { Resend } from "resend"; -import { env } from "~/env"; -import { APP_EMAIL_DOMAIN } from "~/lib/app-email"; +import { sendEmail } from "@beenvoice/email"; import { getAppUrl } from "~/lib/app-url"; import { generatePasswordResetEmailTemplate } from "~/lib/email-templates"; import { @@ -10,6 +8,7 @@ import { } from "~/lib/reset-token"; import { db } from "~/server/db"; import { users } from "~/server/db/schema"; +import { resolveEmailSender } from "~/server/services/email-sender"; export type PasswordResetResult = { success: boolean; @@ -22,15 +21,7 @@ export async function sendPasswordResetEmail(input: { userName?: string; resetToken: string; }): Promise { - if (!env.RESEND_API_KEY) { - console.warn( - "Password reset requested, but RESEND_API_KEY is not configured.", - ); - return { success: true, emailSent: false, userEmail: input.userEmail }; - } - try { - const resend = new Resend(env.RESEND_API_KEY); const resetUrl = `${getAppUrl()}/auth/reset-password?token=${input.resetToken}`; const emailTemplate = generatePasswordResetEmailTemplate({ userEmail: input.userEmail, @@ -39,10 +30,8 @@ export async function sendPasswordResetEmail(input: { resetUrl, expiryHours: 1, }); - const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN; - - await resend.emails.send({ - from: `beenvoice `, + await sendEmail({ + ...resolveEmailSender(null, "beenvoice"), to: input.userEmail, subject: emailTemplate.subject, html: emailTemplate.html, diff --git a/apps/web/src/server/api/routers/invoices.ts b/apps/web/src/server/api/routers/invoices.ts index abc6f51..750d918 100644 --- a/apps/web/src/server/api/routers/invoices.ts +++ b/apps/web/src/server/api/routers/invoices.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { and, desc, eq, inArray } from "drizzle-orm"; +import { sendEmail } from "@beenvoice/email"; import { createTRPCRouter, protectedProcedure, @@ -18,11 +19,9 @@ import { calculateLineItemAmount } from "~/lib/invoice-line-item"; import { generateInvoicePDFBlob } from "~/lib/pdf-export"; import { getRequestOrigin } from "~/lib/app-url"; import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice"; -import { Resend } from "resend"; -import { env } from "~/env"; -import { NOREPLY_EMAIL } from "~/lib/app-email"; import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email"; import type { db } from "~/server/db"; +import { resolveEmailSender } from "~/server/services/email-sender"; type InvoiceRouterContext = { db: typeof db; @@ -204,9 +203,7 @@ function findExistingClient( if (clientRef.email?.trim()) { const email = clientRef.email.trim().toLowerCase(); - const byEmail = userClients.find( - (c) => c.email?.toLowerCase() === email, - ); + const byEmail = userClients.find((c) => c.email?.toLowerCase() === email); if (byEmail) return byEmail; } @@ -235,16 +232,19 @@ function deriveIssueDateFromItems( export const invoicesRouter = createTRPCRouter({ getAll: protectedProcedure .input( - z.object({ - status: z.enum(["draft", "sent", "paid"]).optional(), - clientId: z.string().optional(), - }).optional(), + z + .object({ + status: z.enum(["draft", "sent", "paid"]).optional(), + clientId: z.string().optional(), + }) + .optional(), ) .query(async ({ ctx, input }) => { try { const conditions = [eq(invoices.createdById, ctx.session.user.id)]; if (input?.status) conditions.push(eq(invoices.status, input.status)); - if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId)); + if (input?.clientId) + conditions.push(eq(invoices.clientId, input.clientId)); return await ctx.db.query.invoices.findMany({ where: and(...conditions), @@ -282,7 +282,8 @@ export const invoicesRouter = createTRPCRouter({ eq(invoices.createdById, ctx.session.user.id), eq(invoices.status, "draft"), ]; - if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId)); + if (input?.clientId) + conditions.push(eq(invoices.clientId, input.clientId)); return ctx.db.query.invoices.findMany({ where: and(...conditions), @@ -897,8 +898,7 @@ export const invoicesRouter = createTRPCRouter({ invoicesCreated++; } catch (err) { - const msg = - err instanceof Error ? err.message : "Unknown error"; + const msg = err instanceof Error ? err.message : "Unknown error"; rowErrors.push(`${label}: ${msg}`); } } @@ -1006,7 +1006,9 @@ export const invoicesRouter = createTRPCRouter({ // ── Public token (shareable link) ────────────────────────────────────────── generatePublicToken: sessionProcedure - .input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() })) + .input( + z.object({ id: z.string(), ttlHours: z.number().positive().optional() }), + ) .mutation(async ({ ctx, input }) => { const invoice = await ctx.db.query.invoices.findFirst({ where: eq(invoices.id, input.id), @@ -1081,8 +1083,14 @@ export const invoicesRouter = createTRPCRouter({ }, }); if (!invoice) throw new TRPCError({ code: "NOT_FOUND" }); - if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) { - throw new TRPCError({ code: "FORBIDDEN", message: "This link has expired" }); + if ( + invoice.publicTokenExpiresAt && + new Date(invoice.publicTokenExpiresAt) < new Date() + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This link has expired", + }); } return invoice; }), @@ -1100,11 +1108,17 @@ export const invoicesRouter = createTRPCRouter({ throw new TRPCError({ code: "NOT_FOUND" }); } if (!invoice.client?.email) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Client has no email address" }); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Client has no email address", + }); } const userName = - invoice.business?.emailFromName ?? invoice.business?.name ?? ctx.session.user.name ?? ""; + invoice.business?.emailFromName ?? + invoice.business?.name ?? + ctx.session.user.name ?? + ""; const userEmail = invoice.business?.email ?? ctx.session.user.email ?? ""; const { html, text, subject } = generateReminderEmailTemplate({ @@ -1122,38 +1136,20 @@ export const invoicesRouter = createTRPCRouter({ userEmail, }); - // Resolve Resend instance (same two-tier logic as email router) - let resendInstance: Resend; - let fromEmail: string; - if (invoice.business?.resendApiKey && invoice.business?.resendDomain) { - resendInstance = new Resend(invoice.business.resendApiKey); - const fromName = invoice.business.emailFromName ?? invoice.business.name; - fromEmail = `${fromName} `; - } else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) { - resendInstance = new Resend(env.RESEND_API_KEY); - fromEmail = `noreply@${env.RESEND_DOMAIN}`; - } else if (env.RESEND_API_KEY) { - resendInstance = new Resend(env.RESEND_API_KEY); - fromEmail = invoice.business?.email ?? NOREPLY_EMAIL; - } else { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Email delivery is not configured. Add a Resend API key.", + try { + await sendEmail({ + ...resolveEmailSender(invoice.business, userName || "beenvoice"), + to: [invoice.client.email], + subject, + html, + text, + idempotencyKey: `invoice-reminder:${invoice.id}:${Date.now()}`, }); - } - - const result = await resendInstance.emails.send({ - from: fromEmail, - to: [invoice.client.email], - subject, - html, - text, - }); - - if (result.error) { + } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: result.error.message, + message: + error instanceof Error ? error.message : "Email delivery failed", }); } diff --git a/apps/web/src/server/services/email-sender.ts b/apps/web/src/server/services/email-sender.ts new file mode 100644 index 0000000..28605e4 --- /dev/null +++ b/apps/web/src/server/services/email-sender.ts @@ -0,0 +1,53 @@ +import { getEmailReadiness } from "@beenvoice/email"; + +import { env } from "~/env"; +import { NOREPLY_EMAIL } from "~/lib/app-email"; + +interface BusinessEmailSettings { + name?: string | null; + nickname?: string | null; + email?: string | null; + emailFromName?: string | null; + resendApiKey?: string | null; + resendDomain?: string | null; +} + +export function resolveEmailSender( + business?: BusinessEmailSettings | null, + fallbackName = "beenvoice", +) { + const readiness = getEmailReadiness({ + from: env.EMAIL_FROM ?? env.RESEND_FROM, + resendApiKey: business?.resendApiKey ?? undefined, + }); + + if (readiness.provider !== "resend") { + return { + from: env.EMAIL_FROM ?? `${fallbackName} <${NOREPLY_EMAIL}>`, + resendApiKey: undefined, + }; + } + + if (business?.resendApiKey && business.resendDomain) { + const fromName = + business.emailFromName ?? + (business.nickname + ? `${business.name ?? fallbackName} (${business.nickname})` + : business.name) ?? + fallbackName; + return { + from: `${fromName} `, + resendApiKey: business.resendApiKey, + }; + } + + return { + from: + env.RESEND_FROM ?? + env.EMAIL_FROM ?? + (env.RESEND_DOMAIN + ? `noreply@${env.RESEND_DOMAIN}` + : (business?.email ?? NOREPLY_EMAIL)), + resendApiKey: env.RESEND_API_KEY, + }; +} diff --git a/apps/web/src/server/services/send-invoice-email.ts b/apps/web/src/server/services/send-invoice-email.ts index 8362be0..c0efe59 100644 --- a/apps/web/src/server/services/send-invoice-email.ts +++ b/apps/web/src/server/services/send-invoice-email.ts @@ -1,12 +1,11 @@ import { and, eq } from "drizzle-orm"; -import { Resend } from "resend"; +import { sendEmail } from "@beenvoice/email"; -import { NOREPLY_EMAIL } from "~/lib/app-email"; import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { generateInvoicePDFBlob } from "~/lib/pdf-export"; -import { env } from "~/env"; import { db } from "~/server/db"; import { backgroundJobs, invoices, platformSettings } from "~/server/db/schema"; +import { resolveEmailSender } from "~/server/services/email-sender"; export interface InvoiceEmailOptions { customSubject?: string; @@ -234,28 +233,8 @@ export async function deliverInvoiceEmail(input: DeliverInvoiceEmailInput) { baseUrl: input.baseUrl, }); - let resend: Resend; - let fromEmail: string; - if (invoice.business?.resendApiKey && invoice.business?.resendDomain) { - resend = new Resend(invoice.business.resendApiKey); - const fromName = - invoice.business.emailFromName ?? - (invoice.business.nickname - ? `${invoice.business.name} (${invoice.business.nickname})` - : invoice.business.name) ?? - userName; - fromEmail = `${fromName} `; - } else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) { - resend = new Resend(env.RESEND_API_KEY); - fromEmail = `noreply@${env.RESEND_DOMAIN}`; - } else if (env.RESEND_API_KEY) { - resend = new Resend(env.RESEND_API_KEY); - fromEmail = invoice.business?.email ?? NOREPLY_EMAIL; - } else { - throw new Error( - "Email delivery is not configured. Add a Resend API key globally or on this business.", - ); - } + const sender = resolveEmailSender(invoice.business, userName); + const fromEmail = sender.from; const ccEmails = parseEmailList(input.ccEmails); const bccEmails = parseEmailList(input.bccEmails); @@ -269,43 +248,30 @@ export async function deliverInvoiceEmail(input: DeliverInvoiceEmailInput) { let emailResult; try { - emailResult = await resend.emails.send( - { - from: fromEmail, - to: [invoice.client.email], - cc: ccEmails.length ? ccEmails : undefined, - bcc: bccEmails.length ? bccEmails : undefined, - subject, - html: emailTemplate.html, - text: emailTemplate.text, - headers: { - "X-Priority": "3", - "X-MSMail-Priority": "Normal", - "X-Mailer": "beenvoice", - "MIME-Version": "1.0", - }, - attachments: [ - { - filename: `invoice-${invoice.invoiceNumber}.pdf`, - content: pdfBuffer, - }, - ], + emailResult = await sendEmail({ + ...sender, + to: [invoice.client.email], + cc: ccEmails.length ? ccEmails : undefined, + bcc: bccEmails.length ? bccEmails : undefined, + subject, + html: emailTemplate.html, + text: emailTemplate.text, + headers: { + "X-Priority": "3", + "X-MSMail-Priority": "Normal", + "X-Mailer": "beenvoice", + "MIME-Version": "1.0", }, - input.idempotencyKey - ? { idempotencyKey: input.idempotencyKey } - : undefined, - ); - } catch { - throw new Error( - "Email service is currently unavailable. Please try again later.", - ); - } - - if (emailResult.error) throw deliveryError(emailResult.error.message); - if (!emailResult.data?.id) { - throw new Error( - "Email was not sent successfully - no delivery ID received", - ); + attachments: [ + { + filename: `invoice-${invoice.invoiceNumber}.pdf`, + content: pdfBuffer, + }, + ], + idempotencyKey: input.idempotencyKey, + }); + } catch (error) { + throw deliveryError(error instanceof Error ? error.message : undefined); } const sentAt = new Date(); @@ -322,7 +288,7 @@ export async function deliverInvoiceEmail(input: DeliverInvoiceEmailInput) { return { skipped: false as const, success: true, - emailId: emailResult.data.id, + emailId: emailResult.id, message: `Invoice sent successfully to ${invoice.client.email}${ ccEmails.length ? ` (CC: ${ccEmails.join(", ")})` : "" }${bccEmails.length ? ` (BCC: ${bccEmails.join(", ")})` : ""}`, diff --git a/bun.lock b/bun.lock index 5dd52d7..f759e0b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "beenvoice", "devDependencies": { + "@beenvoice/email": "workspace:*", "turbo": "2.10.10", "typescript": "5.9.3", }, @@ -79,6 +80,7 @@ "dependencies": { "@aws-sdk/client-s3": "3.1075.0", "@beenvoice/domain": "workspace:*", + "@beenvoice/email": "workspace:*", "@better-auth/expo": "1.6.19", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", @@ -192,6 +194,20 @@ "typescript": "5.9.3", }, }, + "packages/email": { + "name": "@beenvoice/email", + "version": "0.0.1", + "dependencies": { + "nodemailer": "^9.0.3", + "resend": "4.8.0", + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/node": "20.19.39", + "@types/nodemailer": "^8.0.1", + "typescript": "5.9.3", + }, + }, }, "trustedDependencies": [ "@tailwindcss/oxide", @@ -444,6 +460,8 @@ "@beenvoice/domain": ["@beenvoice/domain@workspace:packages/domain"], + "@beenvoice/email": ["@beenvoice/email@workspace:packages/email"], + "@beenvoice/mobile": ["@beenvoice/mobile@workspace:apps/mobile"], "@beenvoice/web": ["@beenvoice/web@workspace:apps/web"], @@ -1148,6 +1166,8 @@ "@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="], + "@types/nodemailer": ["@types/nodemailer@8.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw=="], + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="], @@ -2130,6 +2150,8 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], + "nodemailer": ["nodemailer@9.0.5", "", {}, "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ=="], + "normalize-svg-path": ["normalize-svg-path@1.1.0", "", { "dependencies": { "svg-arc-to-cubic-bezier": "^3.0.0" } }, "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg=="], "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="], @@ -2816,6 +2838,8 @@ "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "@types/nodemailer/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + "@types/pg/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], "@types/react-test-renderer/@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index b5a86e0..0e7279f 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -29,8 +29,14 @@ services: DB_DISABLE_SSL: "true" BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:${APP_PORT:-3000}}} NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:${APP_PORT:-3000}}} + EMAIL_PROVIDER: ${EMAIL_PROVIDER:-resend} + EMAIL_FROM: ${EMAIL_FROM:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-} + SMTP_SECURE: ${SMTP_SECURE:-false} RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_DOMAIN: ${RESEND_DOMAIN:-} + RESEND_FROM: ${RESEND_FROM:-} NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-} NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js} NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6f72af6..61c3739 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -41,6 +41,17 @@ services: start_period: 20s restart: unless-stopped + # Local email capture for host development. UI: http://localhost:8028 + mailpit: + image: axllent/mailpit:v1.27 + ports: + - "${MAILPIT_SMTP_PORT:-1028}:1025" + - "${MAILPIT_UI_PORT:-8028}:8025" + environment: + MP_SMTP_AUTH_ACCEPT_ANY: "1" + MP_SMTP_AUTH_ALLOW_INSECURE: "1" + restart: unless-stopped + volumes: beenvoice_dev_pg_data: beenvoice_dev_garage_meta: diff --git a/docker-compose.yml b/docker-compose.yml index dab8863..c70c05a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,8 +25,14 @@ services: DB_DISABLE_SSL: "true" BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + EMAIL_PROVIDER: ${EMAIL_PROVIDER:-resend} + EMAIL_FROM: ${EMAIL_FROM:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-} + SMTP_SECURE: ${SMTP_SECURE:-false} RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_DOMAIN: ${RESEND_DOMAIN:-} + RESEND_FROM: ${RESEND_FROM:-} NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-} NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js} NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false} diff --git a/package.json b/package.json index 2999a5b..e2e103e 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,11 @@ "typecheck": "turbo typecheck", "lint": "turbo lint", "test": "turbo test", - "check": "turbo typecheck lint test" + "check": "turbo typecheck lint test", + "email:preview": "bun scripts/send-email-preview.ts" }, "devDependencies": { + "@beenvoice/email": "workspace:*", "turbo": "2.10.10", "typescript": "5.9.3" }, diff --git a/packages/email/README.md b/packages/email/README.md new file mode 100644 index 0000000..fab8513 --- /dev/null +++ b/packages/email/README.md @@ -0,0 +1,16 @@ +# @beenvoice/email + +Server-only email transport shared by Beenvoice delivery paths. + +- `EMAIL_PROVIDER=mailpit` uses local SMTP and is rejected in production. +- `EMAIL_PROVIDER=smtp` uses any configured SMTP-compatible provider. +- `EMAIL_PROVIDER=resend` uses the Resend HTTP API and supports idempotency keys. + +Local development defaults to Mailpit at `127.0.0.1:1028`; its web UI is at +`http://localhost:8028`. Start it with the web development dependencies and send +a representative message with a PDF attachment: + +```bash +bun run --filter @beenvoice/web docker:up +bun run email:preview +``` diff --git a/packages/email/package.json b/packages/email/package.json new file mode 100644 index 0000000..8eee755 --- /dev/null +++ b/packages/email/package.json @@ -0,0 +1,25 @@ +{ + "name": "@beenvoice/email", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "nodemailer": "^9.0.3", + "resend": "4.8.0" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/node": "20.19.39", + "@types/nodemailer": "^8.0.1", + "typescript": "5.9.3" + } +} diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts new file mode 100644 index 0000000..a9bd3c6 --- /dev/null +++ b/packages/email/src/index.ts @@ -0,0 +1,130 @@ +import nodemailer from "nodemailer"; +import { Resend } from "resend"; + +export type EmailProvider = "mailpit" | "smtp" | "resend"; + +export interface EmailAttachment { + filename: string; + content: Buffer; + contentId?: string; + contentDisposition?: "attachment" | "inline"; +} + +export interface SendEmailInput { + from?: string; + to: string | string[]; + cc?: string[]; + bcc?: string[]; + subject: string; + html: string; + text: string; + headers?: Record; + attachments?: EmailAttachment[]; + idempotencyKey?: string; + resendApiKey?: string; +} + +export interface EmailReadinessOptions { + provider?: EmailProvider; + resendApiKey?: string; + from?: string; +} + +function selectedProvider(override?: EmailProvider): EmailProvider { + return ( + override ?? + (process.env.EMAIL_PROVIDER as EmailProvider | undefined) ?? + "resend" + ); +} + +export function getEmailReadiness(options: EmailReadinessOptions = {}) { + const provider = selectedProvider(options.provider); + const missing: string[] = []; + + if (!options.from && !process.env.EMAIL_FROM && !process.env.RESEND_FROM) { + missing.push("EMAIL_FROM"); + } + if (provider === "resend") { + if (!options.resendApiKey && !process.env.RESEND_API_KEY) { + missing.push("RESEND_API_KEY"); + } + } else if (provider === "mailpit" || provider === "smtp") { + if (provider === "mailpit" && process.env.NODE_ENV === "production") { + missing.push("EMAIL_PROVIDER (mailpit is development-only)"); + } + if (!process.env.SMTP_HOST) missing.push("SMTP_HOST"); + if (!process.env.SMTP_PORT) missing.push("SMTP_PORT"); + } else { + missing.push("EMAIL_PROVIDER"); + } + + return { provider, configured: missing.length === 0, missing }; +} + +export async function sendEmail(input: SendEmailInput) { + const readiness = getEmailReadiness({ + from: input.from, + resendApiKey: input.resendApiKey, + }); + if (!readiness.configured) { + throw new Error( + `Email delivery is not configured: ${readiness.missing.join(", ")}`, + ); + } + const from = input.from ?? process.env.EMAIL_FROM ?? process.env.RESEND_FROM; + if (!from) throw new Error("Email delivery is not configured: EMAIL_FROM"); + + if (readiness.provider === "mailpit" || readiness.provider === "smtp") { + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + secure: process.env.SMTP_SECURE === "true", + }); + const result = await transporter.sendMail({ + from, + to: input.to, + cc: input.cc, + bcc: input.bcc, + subject: input.subject, + html: input.html, + text: input.text, + headers: { + ...input.headers, + ...(input.idempotencyKey + ? { "X-Entity-Ref-ID": input.idempotencyKey } + : {}), + }, + attachments: input.attachments?.map((attachment) => ({ + filename: attachment.filename, + content: attachment.content, + cid: attachment.contentId, + contentDisposition: attachment.contentDisposition ?? "attachment", + })), + }); + return { id: result.messageId, provider: readiness.provider }; + } + + const resend = new Resend(input.resendApiKey ?? process.env.RESEND_API_KEY); + const { data, error } = await resend.emails.send( + { + from, + to: input.to, + cc: input.cc, + bcc: input.bcc, + subject: input.subject, + html: input.html, + text: input.text, + headers: input.headers, + attachments: input.attachments?.map((attachment) => ({ + filename: attachment.filename, + content: attachment.content, + contentId: attachment.contentId, + })), + }, + input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined, + ); + if (error) throw new Error(error.message); + if (!data?.id) throw new Error("Email provider returned no message ID"); + return { id: data.id, provider: readiness.provider }; +} diff --git a/packages/email/tests/email.test.ts b/packages/email/tests/email.test.ts new file mode 100644 index 0000000..4dce92c --- /dev/null +++ b/packages/email/tests/email.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { getEmailReadiness } from "../src"; + +const originalEnv = { + EMAIL_FROM: process.env.EMAIL_FROM, + EMAIL_PROVIDER: process.env.EMAIL_PROVIDER, + NODE_ENV: process.env.NODE_ENV, + RESEND_API_KEY: process.env.RESEND_API_KEY, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, +}; + +afterEach(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +describe("email provider readiness", () => { + test("accepts a configured Mailpit transport in development", () => { + process.env.NODE_ENV = "development"; + process.env.EMAIL_PROVIDER = "mailpit"; + process.env.EMAIL_FROM = "beenvoice "; + process.env.SMTP_HOST = "127.0.0.1"; + process.env.SMTP_PORT = "1028"; + + expect(getEmailReadiness()).toEqual({ + provider: "mailpit", + configured: true, + missing: [], + }); + }); + + test("rejects Mailpit in production", () => { + process.env.NODE_ENV = "production"; + process.env.EMAIL_PROVIDER = "mailpit"; + process.env.EMAIL_FROM = "beenvoice "; + process.env.SMTP_HOST = "mailpit"; + process.env.SMTP_PORT = "1025"; + + expect(getEmailReadiness().configured).toBe(false); + expect(getEmailReadiness().missing).toContain( + "EMAIL_PROVIDER (mailpit is development-only)", + ); + }); + + test("accepts a per-business Resend key", () => { + process.env.NODE_ENV = "production"; + process.env.EMAIL_PROVIDER = "resend"; + process.env.EMAIL_FROM = "beenvoice "; + delete process.env.RESEND_API_KEY; + + expect(getEmailReadiness({ resendApiKey: "re_business" }).configured).toBe( + true, + ); + }); +}); diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json new file mode 100644 index 0000000..218dbf5 --- /dev/null +++ b/packages/email/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["bun", "node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/scripts/send-email-preview.ts b/scripts/send-email-preview.ts new file mode 100644 index 0000000..4dd9469 --- /dev/null +++ b/scripts/send-email-preview.ts @@ -0,0 +1,68 @@ +import { sendEmail } from "@beenvoice/email"; + +process.env.EMAIL_PROVIDER = "mailpit"; +process.env.EMAIL_FROM ??= "beenvoice "; +process.env.SMTP_HOST ??= "127.0.0.1"; +process.env.SMTP_PORT ??= "1028"; +process.env.SMTP_SECURE ??= "false"; + +const recipient = + process.env.EMAIL_PREVIEW_TO ?? "invoice-preview@beenvoice.test"; +const mailpitApiUrl = + process.env.MAILPIT_API_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8028"; +const referenceId = `beenvoice-preview-${crypto.randomUUID()}`; +const previewPdf = Buffer.from( + "%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\ntrailer<>\n%%EOF\n", +); + +const result = await sendEmail({ + to: recipient, + subject: "Beenvoice invoice delivery preview", + html: "

Invoice delivery is working

This message verifies the Mailpit transport and PDF attachment path.

", + text: "Invoice delivery is working. This message verifies the Mailpit transport and PDF attachment path.", + idempotencyKey: referenceId, + attachments: [ + { + filename: "invoice-preview.pdf", + content: previewPdf, + }, + ], +}); + +type MailpitMessageSummary = { + ID: string; + MessageID: string; + Attachments: number; +}; + +let captured: MailpitMessageSummary | undefined; +const expectedMessageId = result.id.replace(/^<|>$/g, ""); +for (let attempt = 0; attempt < 10 && !captured; attempt += 1) { + const response = await fetch(`${mailpitApiUrl}/api/v1/messages`); + if (!response.ok) { + throw new Error(`Mailpit API returned ${response.status}`); + } + const body = (await response.json()) as { + messages?: MailpitMessageSummary[]; + }; + captured = body.messages?.find( + (message) => message.MessageID === expectedMessageId, + ); + if (!captured) await Bun.sleep(100); +} + +if (!captured) throw new Error("Mailpit did not capture the preview message"); +if (captured.Attachments !== 1) { + throw new Error("Mailpit did not capture the invoice PDF attachment"); +} + +console.info( + JSON.stringify({ + provider: result.provider, + recipient, + messageId: result.id, + referenceId, + mailpitMessageId: captured.ID, + attachmentCount: captured.Attachments, + }), +); diff --git a/turbo.json b/turbo.json index cc3414c..4c37a77 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,15 @@ { "$schema": "https://turbo.build/schema.json", + "globalEnv": [ + "EMAIL_PROVIDER", + "EMAIL_FROM", + "SMTP_HOST", + "SMTP_PORT", + "SMTP_SECURE", + "RESEND_API_KEY", + "RESEND_DOMAIN", + "RESEND_FROM" + ], "tasks": { "dev": { "cache": false, @@ -12,6 +22,9 @@ "@beenvoice/domain#build": { "outputs": [] }, + "@beenvoice/email#build": { + "outputs": [] + }, "@beenvoice/mobile#build": { "outputs": [] },