Add Mailpit email transport
This commit is contained in:
@@ -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 <noreply@beenvoice.test>
|
||||
SMTP_HOST=127.0.0.1
|
||||
SMTP_PORT=1028
|
||||
SMTP_SECURE=false
|
||||
RESEND_API_KEY=
|
||||
RESEND_DOMAIN=
|
||||
RESEND_FROM=
|
||||
|
||||
# =============================================================================
|
||||
# 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -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_<base64url>`; 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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 {
|
||||
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 <noreply@${fromDomain}>`,
|
||||
await sendEmail({
|
||||
...resolveEmailSender(null, "beenvoice"),
|
||||
to: input.userEmail,
|
||||
subject: emailTemplate.subject,
|
||||
html: emailTemplate.html,
|
||||
|
||||
@@ -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} <noreply@${invoice.business.resendDomain}>`;
|
||||
} 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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { 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} <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 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(", ")})` : ""}`,
|
||||
|
||||
Reference in New Issue
Block a user