Add Mailpit email transport
This commit is contained in:
+16
-2
@@ -9,7 +9,13 @@ COPY apps/web/package.json apps/web/package.json
|
|||||||
COPY apps/worker/package.json apps/worker/package.json
|
COPY apps/worker/package.json apps/worker/package.json
|
||||||
COPY apps/mobile/package.json apps/mobile/package.json
|
COPY apps/mobile/package.json apps/mobile/package.json
|
||||||
COPY packages/domain/package.json packages/domain/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
|
FROM base AS worker-install
|
||||||
COPY package.json bun.lock ./
|
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/worker/package.json apps/worker/package.json
|
||||||
COPY apps/mobile/package.json apps/mobile/package.json
|
COPY apps/mobile/package.json apps/mobile/package.json
|
||||||
COPY packages/domain/package.json packages/domain/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
|
# 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.
|
# 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/package.json ./package.json
|
||||||
COPY --from=build /app/apps/web/package.json ./apps/web/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/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/.next ./apps/web/.next
|
||||||
COPY --from=build /app/apps/web/public ./apps/web/public
|
COPY --from=build /app/apps/web/public ./apps/web/public
|
||||||
COPY --from=build /app/apps/web/drizzle.config.ts ./apps/web/drizzle.config.ts
|
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/web/tsconfig.json ./apps/web/tsconfig.json
|
||||||
COPY apps/worker ./apps/worker
|
COPY apps/worker ./apps/worker
|
||||||
COPY packages/domain ./packages/domain
|
COPY packages/domain ./packages/domain
|
||||||
|
COPY packages/email ./packages/email
|
||||||
RUN ln -s ../worker/node_modules apps/web/node_modules
|
RUN ln -s ../worker/node_modules apps/web/node_modules
|
||||||
|
|
||||||
USER bun
|
USER bun
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ beenvoice/
|
|||||||
│ ├── mobile/ # Expo Router mobile app and iOS widgets
|
│ ├── mobile/ # Expo Router mobile app and iOS widgets
|
||||||
│ └── worker/ # PostgreSQL-backed scheduler and background jobs
|
│ └── worker/ # PostgreSQL-backed scheduler and background jobs
|
||||||
├── packages/
|
├── packages/
|
||||||
│ └── domain/ # Platform-neutral shared rules and parsing
|
│ ├── domain/ # Platform-neutral shared rules and parsing
|
||||||
|
│ └── email/ # Resend and SMTP/Mailpit delivery adapter
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── docker-compose*.yml
|
├── docker-compose*.yml
|
||||||
├── package.json
|
├── package.json
|
||||||
@@ -43,6 +44,7 @@ bun run lint
|
|||||||
bun run test
|
bun run test
|
||||||
bun run build
|
bun run build
|
||||||
bun run check
|
bun run check
|
||||||
|
bun run email:preview # sends a PDF-bearing message to local Mailpit
|
||||||
```
|
```
|
||||||
|
|
||||||
Run an app-specific command with a workspace filter:
|
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)
|
- [Mobile architecture](./apps/mobile/docs/ARCHITECTURE.md)
|
||||||
- [Worker architecture](./apps/worker/README.md)
|
- [Worker architecture](./apps/worker/README.md)
|
||||||
- [Shared domain package](./packages/domain/README.md)
|
- [Shared domain package](./packages/domain/README.md)
|
||||||
|
- [Email delivery package](./packages/email/README.md)
|
||||||
|
|
||||||
## Product concepts
|
## Product concepts
|
||||||
|
|
||||||
|
|||||||
@@ -114,12 +114,19 @@ NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
|
|||||||
NEXT_PUBLIC_BRAND_ICON=$
|
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 <noreply@beenvoice.test>
|
||||||
|
SMTP_HOST=127.0.0.1
|
||||||
|
SMTP_PORT=1028
|
||||||
|
SMTP_SECURE=false
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
RESEND_DOMAIN=
|
RESEND_DOMAIN=
|
||||||
|
RESEND_FROM=
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Analytics — Umami (optional)
|
# Analytics — Umami (optional)
|
||||||
|
|||||||
+12
-4
@@ -16,7 +16,7 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal
|
|||||||
| Database | PostgreSQL 17, Drizzle ORM |
|
| Database | PostgreSQL 17, Drizzle ORM |
|
||||||
| Auth | better-auth (email/password, optional Authentik OIDC, Expo mobile) |
|
| Auth | better-auth (email/password, optional Authentik OIDC, Expo mobile) |
|
||||||
| UI | shadcn/ui, Tailwind CSS v4 |
|
| UI | shadcn/ui, Tailwind CSS v4 |
|
||||||
| Email / PDF | Resend, `@react-pdf/renderer` |
|
| Email / PDF | Resend or SMTP/Mailpit, `@react-pdf/renderer` |
|
||||||
| Runtime | Bun |
|
| Runtime | Bun |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -24,7 +24,7 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal
|
|||||||
- Clients, businesses, invoices (line items, tax, status workflow)
|
- Clients, businesses, invoices (line items, tax, status workflow)
|
||||||
- Time clock with one running timer per user; clock-out can append invoice lines
|
- Time clock with one running timer per user; clock-out can append invoice lines
|
||||||
- Expenses, payments, recurring invoices, invoice templates
|
- 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]`)
|
- Public invoice links (`/i/[token]`)
|
||||||
- CSV import, reports, platform branding / admin settings
|
- CSV import, reports, platform branding / admin settings
|
||||||
- MCP API (`/api/mcp`) for automation via API keys (`bv_…`)
|
- 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
|
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
|
### 3. Database
|
||||||
|
|
||||||
@@ -199,7 +205,9 @@ Use the literal strings `true` or `false` (or omit the variable). Do not rely on
|
|||||||
|
|
||||||
| Variable | Purpose |
|
| 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) |
|
| `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` |
|
| `CRON_SECRET` | Protects `/api/cron/generate-recurring` |
|
||||||
| `DISABLE_SIGNUPS=true` | Block new registrations |
|
| `DISABLE_SIGNUPS=true` | Block new registrations |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ This application is the server and browser workspace in the Beenvoice monorepo.
|
|||||||
| ORM | Drizzle + `pg` pool |
|
| ORM | Drizzle + `pg` pool |
|
||||||
| Auth | better-auth (email/password, optional Authentik OIDC, Expo plugin for mobile) |
|
| Auth | better-auth (email/password, optional Authentik OIDC, Expo plugin for mobile) |
|
||||||
| UI | shadcn/ui, Tailwind CSS v4, Radix primitives |
|
| UI | shadcn/ui, Tailwind CSS v4, Radix primitives |
|
||||||
| Email | Resend |
|
| Email | Shared Resend/SMTP transport; Mailpit for local capture |
|
||||||
| PDF | `@react-pdf/renderer` |
|
| PDF | `@react-pdf/renderer` |
|
||||||
|
|
||||||
## Request flow
|
## Request flow
|
||||||
@@ -166,18 +166,20 @@ API keys: format `bv_<base64url>`; stored as SHA-256 hash (`src/server/api/api-k
|
|||||||
|
|
||||||
Validated in `src/env.js`. See `.env.example`.
|
Validated in `src/env.js`. See `.env.example`.
|
||||||
|
|
||||||
| Variable | Required | Notes |
|
| Variable | Required | Notes |
|
||||||
| --------------------------------- | -------------------- | ------------------------------------------------------------------------------ |
|
| ------------------------------------------------ | -------------------- | ------------------------------------------------------------------------------ |
|
||||||
| `DATABASE_URL` | yes | PostgreSQL connection string |
|
| `DATABASE_URL` | yes | PostgreSQL connection string |
|
||||||
| `AUTH_SECRET` | prod | `openssl rand -base64 32` |
|
| `AUTH_SECRET` | prod | `openssl rand -base64 32` |
|
||||||
| `BETTER_AUTH_URL` | yes | Public URL of API (no trailing path) |
|
| `BETTER_AUTH_URL` | yes | Public URL of API (no trailing path) |
|
||||||
| `NEXT_PUBLIC_APP_URL` | yes | Browser-facing URL |
|
| `NEXT_PUBLIC_APP_URL` | yes | Browser-facing URL |
|
||||||
| `DB_DISABLE_SSL` | local | `true` for Docker dev DB |
|
| `DB_DISABLE_SSL` | local | `true` for Docker dev DB |
|
||||||
| `RESEND_API_KEY`, `RESEND_DOMAIN` | optional | Email; blank disables send |
|
| `EMAIL_PROVIDER`, `EMAIL_FROM` | optional | `mailpit`, `smtp`, or `resend`; sender identity |
|
||||||
| `AUTHENTIK_*` | optional | OIDC SSO |
|
| `SMTP_HOST`, `SMTP_PORT` | SMTP/Mailpit | SMTP endpoint (`127.0.0.1:1028` in local development) |
|
||||||
| `DISABLE_SIGNUPS` | optional | `true` blocks registration; use string `true`/`false` (parsed in `src/env.js`) |
|
| `RESEND_API_KEY`, `RESEND_DOMAIN`, `RESEND_FROM` | Resend | Production delivery credentials and verified sender |
|
||||||
| `CRON_SECRET` | worker / cron routes | Protects worker delivery and `/api/cron/generate-recurring` |
|
| `AUTHENTIK_*` | optional | OIDC SSO |
|
||||||
| `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults |
|
| `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
|
## Docker
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@beenvoice/domain": "workspace:*",
|
"@beenvoice/domain": "workspace:*",
|
||||||
|
"@beenvoice/email": "workspace:*",
|
||||||
"@aws-sdk/client-s3": "3.1075.0",
|
"@aws-sdk/client-s3": "3.1075.0",
|
||||||
"@better-auth/expo": "1.6.19",
|
"@better-auth/expo": "1.6.19",
|
||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
|
|||||||
@@ -27,8 +27,14 @@ export const env = createEnv({
|
|||||||
: z.string().optional(),
|
: z.string().optional(),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
BETTER_AUTH_URL: z.string().url().optional(),
|
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_API_KEY: z.string().min(1).optional(),
|
||||||
RESEND_DOMAIN: z.string().optional(),
|
RESEND_DOMAIN: z.string().optional(),
|
||||||
|
RESEND_FROM: z.string().min(1).optional(),
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "test", "production"])
|
.enum(["development", "test", "production"])
|
||||||
.default("development"),
|
.default("development"),
|
||||||
@@ -76,8 +82,14 @@ export const env = createEnv({
|
|||||||
AUTH_SECRET: process.env.AUTH_SECRET,
|
AUTH_SECRET: process.env.AUTH_SECRET,
|
||||||
DATABASE_URL: process.env.DATABASE_URL,
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
BETTER_AUTH_URL: process.env.BETTER_AUTH_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_API_KEY: process.env.RESEND_API_KEY,
|
||||||
RESEND_DOMAIN: process.env.RESEND_DOMAIN,
|
RESEND_DOMAIN: process.env.RESEND_DOMAIN,
|
||||||
|
RESEND_FROM: process.env.RESEND_FROM,
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
DB_DISABLE_SSL: process.env.DB_DISABLE_SSL,
|
DB_DISABLE_SSL: process.env.DB_DISABLE_SSL,
|
||||||
DISABLE_SIGNUPS: process.env.DISABLE_SIGNUPS,
|
DISABLE_SIGNUPS: process.env.DISABLE_SIGNUPS,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { Resend } from "resend";
|
import { sendEmail } from "@beenvoice/email";
|
||||||
import { env } from "~/env";
|
|
||||||
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
|
|
||||||
import { getAppUrl } from "~/lib/app-url";
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +8,7 @@ import {
|
|||||||
} from "~/lib/reset-token";
|
} from "~/lib/reset-token";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { users } from "~/server/db/schema";
|
import { users } from "~/server/db/schema";
|
||||||
|
import { resolveEmailSender } from "~/server/services/email-sender";
|
||||||
|
|
||||||
export type PasswordResetResult = {
|
export type PasswordResetResult = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -22,15 +21,7 @@ export async function sendPasswordResetEmail(input: {
|
|||||||
userName?: string;
|
userName?: string;
|
||||||
resetToken: string;
|
resetToken: string;
|
||||||
}): Promise<PasswordResetResult> {
|
}): Promise<PasswordResetResult> {
|
||||||
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 {
|
try {
|
||||||
const resend = new Resend(env.RESEND_API_KEY);
|
|
||||||
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${input.resetToken}`;
|
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${input.resetToken}`;
|
||||||
const emailTemplate = generatePasswordResetEmailTemplate({
|
const emailTemplate = generatePasswordResetEmailTemplate({
|
||||||
userEmail: input.userEmail,
|
userEmail: input.userEmail,
|
||||||
@@ -39,10 +30,8 @@ export async function sendPasswordResetEmail(input: {
|
|||||||
resetUrl,
|
resetUrl,
|
||||||
expiryHours: 1,
|
expiryHours: 1,
|
||||||
});
|
});
|
||||||
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
|
await sendEmail({
|
||||||
|
...resolveEmailSender(null, "beenvoice"),
|
||||||
await resend.emails.send({
|
|
||||||
from: `beenvoice <noreply@${fromDomain}>`,
|
|
||||||
to: input.userEmail,
|
to: input.userEmail,
|
||||||
subject: emailTemplate.subject,
|
subject: emailTemplate.subject,
|
||||||
html: emailTemplate.html,
|
html: emailTemplate.html,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import { sendEmail } from "@beenvoice/email";
|
||||||
import {
|
import {
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
@@ -18,11 +19,9 @@ import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
|||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
import { getRequestOrigin } from "~/lib/app-url";
|
import { getRequestOrigin } from "~/lib/app-url";
|
||||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
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 { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
|
||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
|
import { resolveEmailSender } from "~/server/services/email-sender";
|
||||||
|
|
||||||
type InvoiceRouterContext = {
|
type InvoiceRouterContext = {
|
||||||
db: typeof db;
|
db: typeof db;
|
||||||
@@ -204,9 +203,7 @@ function findExistingClient(
|
|||||||
|
|
||||||
if (clientRef.email?.trim()) {
|
if (clientRef.email?.trim()) {
|
||||||
const email = clientRef.email.trim().toLowerCase();
|
const email = clientRef.email.trim().toLowerCase();
|
||||||
const byEmail = userClients.find(
|
const byEmail = userClients.find((c) => c.email?.toLowerCase() === email);
|
||||||
(c) => c.email?.toLowerCase() === email,
|
|
||||||
);
|
|
||||||
if (byEmail) return byEmail;
|
if (byEmail) return byEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,16 +232,19 @@ function deriveIssueDateFromItems(
|
|||||||
export const invoicesRouter = createTRPCRouter({
|
export const invoicesRouter = createTRPCRouter({
|
||||||
getAll: protectedProcedure
|
getAll: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z
|
||||||
status: z.enum(["draft", "sent", "paid"]).optional(),
|
.object({
|
||||||
clientId: z.string().optional(),
|
status: z.enum(["draft", "sent", "paid"]).optional(),
|
||||||
}).optional(),
|
clientId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
try {
|
try {
|
||||||
const conditions = [eq(invoices.createdById, ctx.session.user.id)];
|
const conditions = [eq(invoices.createdById, ctx.session.user.id)];
|
||||||
if (input?.status) conditions.push(eq(invoices.status, input.status));
|
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({
|
return await ctx.db.query.invoices.findMany({
|
||||||
where: and(...conditions),
|
where: and(...conditions),
|
||||||
@@ -282,7 +282,8 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
eq(invoices.createdById, ctx.session.user.id),
|
eq(invoices.createdById, ctx.session.user.id),
|
||||||
eq(invoices.status, "draft"),
|
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({
|
return ctx.db.query.invoices.findMany({
|
||||||
where: and(...conditions),
|
where: and(...conditions),
|
||||||
@@ -897,8 +898,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
invoicesCreated++;
|
invoicesCreated++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg =
|
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||||
err instanceof Error ? err.message : "Unknown error";
|
|
||||||
rowErrors.push(`${label}: ${msg}`);
|
rowErrors.push(`${label}: ${msg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1006,7 +1006,9 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
// ── Public token (shareable link) ──────────────────────────────────────────
|
// ── Public token (shareable link) ──────────────────────────────────────────
|
||||||
|
|
||||||
generatePublicToken: sessionProcedure
|
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 }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
where: eq(invoices.id, input.id),
|
where: eq(invoices.id, input.id),
|
||||||
@@ -1081,8 +1083,14 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!invoice) throw new TRPCError({ code: "NOT_FOUND" });
|
if (!invoice) throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) {
|
if (
|
||||||
throw new TRPCError({ code: "FORBIDDEN", message: "This link has expired" });
|
invoice.publicTokenExpiresAt &&
|
||||||
|
new Date(invoice.publicTokenExpiresAt) < new Date()
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "This link has expired",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return invoice;
|
return invoice;
|
||||||
}),
|
}),
|
||||||
@@ -1100,11 +1108,17 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
throw new TRPCError({ code: "NOT_FOUND" });
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
}
|
}
|
||||||
if (!invoice.client?.email) {
|
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 =
|
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 userEmail = invoice.business?.email ?? ctx.session.user.email ?? "";
|
||||||
|
|
||||||
const { html, text, subject } = generateReminderEmailTemplate({
|
const { html, text, subject } = generateReminderEmailTemplate({
|
||||||
@@ -1122,38 +1136,20 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
userEmail,
|
userEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resolve Resend instance (same two-tier logic as email router)
|
try {
|
||||||
let resendInstance: Resend;
|
await sendEmail({
|
||||||
let fromEmail: string;
|
...resolveEmailSender(invoice.business, userName || "beenvoice"),
|
||||||
if (invoice.business?.resendApiKey && invoice.business?.resendDomain) {
|
to: [invoice.client.email],
|
||||||
resendInstance = new Resend(invoice.business.resendApiKey);
|
subject,
|
||||||
const fromName = invoice.business.emailFromName ?? invoice.business.name;
|
html,
|
||||||
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
|
text,
|
||||||
} else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) {
|
idempotencyKey: `invoice-reminder:${invoice.id}:${Date.now()}`,
|
||||||
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.",
|
|
||||||
});
|
});
|
||||||
}
|
} catch (error) {
|
||||||
|
|
||||||
const result = await resendInstance.emails.send({
|
|
||||||
from: fromEmail,
|
|
||||||
to: [invoice.client.email],
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: result.error.message,
|
message:
|
||||||
|
error instanceof Error ? error.message : "Email delivery failed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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} <noreply@${business.resendDomain}>`,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
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 { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
import { env } from "~/env";
|
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { backgroundJobs, invoices, platformSettings } from "~/server/db/schema";
|
import { backgroundJobs, invoices, platformSettings } from "~/server/db/schema";
|
||||||
|
import { resolveEmailSender } from "~/server/services/email-sender";
|
||||||
|
|
||||||
export interface InvoiceEmailOptions {
|
export interface InvoiceEmailOptions {
|
||||||
customSubject?: string;
|
customSubject?: string;
|
||||||
@@ -234,28 +233,8 @@ export async function deliverInvoiceEmail(input: DeliverInvoiceEmailInput) {
|
|||||||
baseUrl: input.baseUrl,
|
baseUrl: input.baseUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
let resend: Resend;
|
const sender = resolveEmailSender(invoice.business, userName);
|
||||||
let fromEmail: string;
|
const fromEmail = sender.from;
|
||||||
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} <noreply@${invoice.business.resendDomain}>`;
|
|
||||||
} 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 ccEmails = parseEmailList(input.ccEmails);
|
const ccEmails = parseEmailList(input.ccEmails);
|
||||||
const bccEmails = parseEmailList(input.bccEmails);
|
const bccEmails = parseEmailList(input.bccEmails);
|
||||||
@@ -269,43 +248,30 @@ export async function deliverInvoiceEmail(input: DeliverInvoiceEmailInput) {
|
|||||||
|
|
||||||
let emailResult;
|
let emailResult;
|
||||||
try {
|
try {
|
||||||
emailResult = await resend.emails.send(
|
emailResult = await sendEmail({
|
||||||
{
|
...sender,
|
||||||
from: fromEmail,
|
to: [invoice.client.email],
|
||||||
to: [invoice.client.email],
|
cc: ccEmails.length ? ccEmails : undefined,
|
||||||
cc: ccEmails.length ? ccEmails : undefined,
|
bcc: bccEmails.length ? bccEmails : undefined,
|
||||||
bcc: bccEmails.length ? bccEmails : undefined,
|
subject,
|
||||||
subject,
|
html: emailTemplate.html,
|
||||||
html: emailTemplate.html,
|
text: emailTemplate.text,
|
||||||
text: emailTemplate.text,
|
headers: {
|
||||||
headers: {
|
"X-Priority": "3",
|
||||||
"X-Priority": "3",
|
"X-MSMail-Priority": "Normal",
|
||||||
"X-MSMail-Priority": "Normal",
|
"X-Mailer": "beenvoice",
|
||||||
"X-Mailer": "beenvoice",
|
"MIME-Version": "1.0",
|
||||||
"MIME-Version": "1.0",
|
|
||||||
},
|
|
||||||
attachments: [
|
|
||||||
{
|
|
||||||
filename: `invoice-${invoice.invoiceNumber}.pdf`,
|
|
||||||
content: pdfBuffer,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
input.idempotencyKey
|
attachments: [
|
||||||
? { idempotencyKey: input.idempotencyKey }
|
{
|
||||||
: undefined,
|
filename: `invoice-${invoice.invoiceNumber}.pdf`,
|
||||||
);
|
content: pdfBuffer,
|
||||||
} catch {
|
},
|
||||||
throw new Error(
|
],
|
||||||
"Email service is currently unavailable. Please try again later.",
|
idempotencyKey: input.idempotencyKey,
|
||||||
);
|
});
|
||||||
}
|
} catch (error) {
|
||||||
|
throw deliveryError(error instanceof Error ? error.message : undefined);
|
||||||
if (emailResult.error) throw deliveryError(emailResult.error.message);
|
|
||||||
if (!emailResult.data?.id) {
|
|
||||||
throw new Error(
|
|
||||||
"Email was not sent successfully - no delivery ID received",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sentAt = new Date();
|
const sentAt = new Date();
|
||||||
@@ -322,7 +288,7 @@ export async function deliverInvoiceEmail(input: DeliverInvoiceEmailInput) {
|
|||||||
return {
|
return {
|
||||||
skipped: false as const,
|
skipped: false as const,
|
||||||
success: true,
|
success: true,
|
||||||
emailId: emailResult.data.id,
|
emailId: emailResult.id,
|
||||||
message: `Invoice sent successfully to ${invoice.client.email}${
|
message: `Invoice sent successfully to ${invoice.client.email}${
|
||||||
ccEmails.length ? ` (CC: ${ccEmails.join(", ")})` : ""
|
ccEmails.length ? ` (CC: ${ccEmails.join(", ")})` : ""
|
||||||
}${bccEmails.length ? ` (BCC: ${bccEmails.join(", ")})` : ""}`,
|
}${bccEmails.length ? ` (BCC: ${bccEmails.join(", ")})` : ""}`,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "beenvoice",
|
"name": "beenvoice",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@beenvoice/email": "workspace:*",
|
||||||
"turbo": "2.10.10",
|
"turbo": "2.10.10",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
},
|
},
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.1075.0",
|
"@aws-sdk/client-s3": "3.1075.0",
|
||||||
"@beenvoice/domain": "workspace:*",
|
"@beenvoice/domain": "workspace:*",
|
||||||
|
"@beenvoice/email": "workspace:*",
|
||||||
"@better-auth/expo": "1.6.19",
|
"@better-auth/expo": "1.6.19",
|
||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/modifiers": "9.0.0",
|
"@dnd-kit/modifiers": "9.0.0",
|
||||||
@@ -192,6 +194,20 @@
|
|||||||
"typescript": "5.9.3",
|
"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": [
|
"trustedDependencies": [
|
||||||
"@tailwindcss/oxide",
|
"@tailwindcss/oxide",
|
||||||
@@ -444,6 +460,8 @@
|
|||||||
|
|
||||||
"@beenvoice/domain": ["@beenvoice/domain@workspace:packages/domain"],
|
"@beenvoice/domain": ["@beenvoice/domain@workspace:packages/domain"],
|
||||||
|
|
||||||
|
"@beenvoice/email": ["@beenvoice/email@workspace:packages/email"],
|
||||||
|
|
||||||
"@beenvoice/mobile": ["@beenvoice/mobile@workspace:apps/mobile"],
|
"@beenvoice/mobile": ["@beenvoice/mobile@workspace:apps/mobile"],
|
||||||
|
|
||||||
"@beenvoice/web": ["@beenvoice/web@workspace:apps/web"],
|
"@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/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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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/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=="],
|
"@types/react-test-renderer/@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||||
|
|||||||
@@ -29,8 +29,14 @@ services:
|
|||||||
DB_DISABLE_SSL: "true"
|
DB_DISABLE_SSL: "true"
|
||||||
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:${APP_PORT:-3000}}}
|
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}}}
|
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_API_KEY: ${RESEND_API_KEY:-}
|
||||||
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
|
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
|
||||||
|
RESEND_FROM: ${RESEND_FROM:-}
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
|
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_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
|
||||||
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
|
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
beenvoice_dev_pg_data:
|
beenvoice_dev_pg_data:
|
||||||
beenvoice_dev_garage_meta:
|
beenvoice_dev_garage_meta:
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ services:
|
|||||||
DB_DISABLE_SSL: "true"
|
DB_DISABLE_SSL: "true"
|
||||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_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_API_KEY: ${RESEND_API_KEY:-}
|
||||||
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
|
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
|
||||||
|
RESEND_FROM: ${RESEND_FROM:-}
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
|
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_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
|
||||||
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
|
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
|
||||||
|
|||||||
+3
-1
@@ -12,9 +12,11 @@
|
|||||||
"typecheck": "turbo typecheck",
|
"typecheck": "turbo typecheck",
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"test": "turbo test",
|
"test": "turbo test",
|
||||||
"check": "turbo typecheck lint test"
|
"check": "turbo typecheck lint test",
|
||||||
|
"email:preview": "bun scripts/send-email-preview.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@beenvoice/email": "workspace:*",
|
||||||
"turbo": "2.10.10",
|
"turbo": "2.10.10",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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 <noreply@beenvoice.test>";
|
||||||
|
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 <noreply@beenvoice.test>";
|
||||||
|
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 <noreply@beenvoice.app>";
|
||||||
|
delete process.env.RESEND_API_KEY;
|
||||||
|
|
||||||
|
expect(getEmailReadiness({ resendApiKey: "re_business" }).configured).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["bun", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { sendEmail } from "@beenvoice/email";
|
||||||
|
|
||||||
|
process.env.EMAIL_PROVIDER = "mailpit";
|
||||||
|
process.env.EMAIL_FROM ??= "beenvoice <noreply@beenvoice.test>";
|
||||||
|
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<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Count 0>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await sendEmail({
|
||||||
|
to: recipient,
|
||||||
|
subject: "Beenvoice invoice delivery preview",
|
||||||
|
html: "<h1>Invoice delivery is working</h1><p>This message verifies the Mailpit transport and PDF attachment path.</p>",
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
+13
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turbo.build/schema.json",
|
"$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": {
|
"tasks": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
@@ -12,6 +22,9 @@
|
|||||||
"@beenvoice/domain#build": {
|
"@beenvoice/domain#build": {
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
|
"@beenvoice/email#build": {
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
"@beenvoice/mobile#build": {
|
"@beenvoice/mobile#build": {
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user