refactor(api): drop hand-rolled helpers the platform or one caller already covers

This commit is contained in:
Amruth Pillai
2026-09-29 10:13:19 +02:00
parent 2506509538
commit 8951f45a3a
22 changed files with 78 additions and 258 deletions
+1 -9
View File
@@ -80,13 +80,5 @@ export async function handleHealth() {
console.warn("[Healthcheck]", { route: "/api/health", database, storage });
}
const headers = new Headers();
const body = JSON.stringify(checks);
headers.set("Content-Type", "application/json; charset=UTF-8");
headers.set("Content-Length", Buffer.byteLength(body, "utf-8").toString());
return new Response(body, {
headers,
status: checks.status === "unhealthy" ? 503 : 200,
});
return Response.json(checks, { status: checks.status === "unhealthy" ? 503 : 200 });
}
@@ -8,6 +8,7 @@ const envMock = vi.hoisted(() => ({
S3_SECRET_ACCESS_KEY: "test-secret-key",
S3_REGION: "us-east-1",
S3_ENDPOINT: "",
STORAGE_BACKEND: "s3",
S3_BUCKET: "test-bucket",
S3_FORCE_PATH_STYLE: true,
}));
+2 -1
View File
@@ -54,7 +54,7 @@
"better-auth": "1.7.6",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.52.0",
"fflate": "^0.8.3",
"ioredis": "^6.0.0",
"jsonrepair": "^3.15.0",
"ollama-ai-provider-v2": "^4.0.1",
@@ -71,6 +71,7 @@
"@types/pg": "^8.23.1",
"@types/sanitize-html": "^2.16.1",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"es-toolkit": "^1.52.0",
"pg": "^8.23.0",
"typescript": "^7.0.2"
}
@@ -39,7 +39,6 @@ const resumeServiceMock = {
getById: vi.fn(),
patch: vi.fn(),
patchInTransaction: vi.fn(),
notifyResumePatched: vi.fn(),
};
const aiProvidersServiceMock = {
@@ -59,10 +59,6 @@ vi.mock("../ai/credentials", () => ({
assertCredentialEncryptionConfigured: vi.fn(),
decryptCredential: vi.fn(() => "decrypted-key"),
encryptCredential: vi.fn(),
redactEncryptedCredential: vi.fn(() => ({
apiKeyFingerprint: "fingerprint",
apiKeyPreview: "sk-...test",
})),
}));
vi.mock("../ai/service", () => ({ testConnection: vi.fn() }));
vi.mock("../ai/url-policy", () => ({ resolveAiBaseUrl: vi.fn() }));
@@ -4,12 +4,7 @@ import { and, asc, desc, eq, sql } from "drizzle-orm";
import { aiProviderSchema } from "@reactive-resume/ai/types";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import {
assertCredentialEncryptionConfigured,
decryptCredential,
encryptCredential,
redactEncryptedCredential,
} from "../ai/credentials";
import { assertCredentialEncryptionConfigured, decryptCredential, encryptCredential } from "../ai/credentials";
import { testConnection } from "../ai/service";
import { resolveAiBaseUrl } from "../ai/url-policy";
@@ -54,12 +49,6 @@ type UpdateAiProviderInput = {
function toResponse(row: AiProviderRecord): AiProviderResponse {
const provider = aiProviderSchema.parse(row.provider);
const { apiKeyFingerprint, apiKeyPreview } = redactEncryptedCredential({
encryptedApiKey: row.encryptedApiKey,
apiKeySalt: row.apiKeySalt,
apiKeyHash: row.apiKeyHash,
apiKeyPreview: row.apiKeyPreview,
});
return {
id: row.id,
@@ -70,8 +59,9 @@ function toResponse(row: AiProviderRecord): AiProviderResponse {
enabled: row.enabled,
testStatus: row.testStatus,
testError: row.testError,
apiKeyPreview,
apiKeyFingerprint,
apiKeyPreview: row.apiKeyPreview,
// The hash identifies the key without revealing it; the ciphertext and salt never leave the server.
apiKeyFingerprint: row.apiKeyHash,
lastTestedAt: row.lastTestedAt,
lastUsedAt: row.lastUsedAt,
createdAt: row.createdAt,
@@ -7,14 +7,9 @@ const envMock = vi.hoisted(() => ({
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
const {
assertAgentEnvironment,
decryptCredential,
encryptCredential,
fingerprintCredential,
isAgentEnvironmentConfigured,
redactEncryptedCredential,
} = await import("./credentials");
const { assertAgentEnvironment, decryptCredential, encryptCredential, fingerprintCredential } = await import(
"./credentials"
);
describe("AI credential encryption", () => {
it("encrypts and decrypts provider API keys without storing plaintext", () => {
@@ -34,36 +29,16 @@ describe("AI credential encryption", () => {
expect(first).not.toBe(differentSalt);
expect(first).not.toContain("sk-test-secret");
});
it("redacts stored encrypted credential fields from API responses", () => {
const encrypted = encryptCredential("sk-test-secret");
const redacted = redactEncryptedCredential({
encryptedApiKey: encrypted.encryptedApiKey,
apiKeySalt: encrypted.apiKeySalt,
apiKeyHash: encrypted.apiKeyHash,
apiKeyPreview: encrypted.apiKeyPreview,
});
expect(redacted).toEqual({
apiKeyFingerprint: encrypted.apiKeyHash,
apiKeyPreview: encrypted.apiKeyPreview,
});
expect(JSON.stringify(redacted)).not.toContain(encrypted.encryptedApiKey);
expect(JSON.stringify(redacted)).not.toContain(encrypted.apiKeySalt);
});
});
describe("AI agent environment", () => {
it("needs the encryption secret, and works without Redis", () => {
expect(isAgentEnvironmentConfigured()).toBe(true);
expect(() => assertAgentEnvironment()).not.toThrow();
envMock.REDIS_URL = "";
expect(isAgentEnvironmentConfigured()).toBe(true);
expect(() => assertAgentEnvironment()).not.toThrow();
envMock.ENCRYPTION_SECRET = "";
expect(isAgentEnvironmentConfigured()).toBe(false);
expect(() => assertAgentEnvironment()).toThrow("AGENT_ENVIRONMENT_UNAVAILABLE");
});
});
+3 -19
View File
@@ -13,11 +13,6 @@ type StoredCredentialFields = {
apiKeyPreview: string;
};
type RedactedCredentialFields = {
apiKeyFingerprint: string;
apiKeyPreview: string;
};
function getEncryptionSecret() {
return env.ENCRYPTION_SECRET?.trim() ?? "";
}
@@ -77,27 +72,16 @@ export function decryptCredential(payload: string) {
return Buffer.concat([decipher.update(decode(encodedCiphertext)), decipher.final()]).toString("utf8");
}
export function redactEncryptedCredential(fields: StoredCredentialFields): RedactedCredentialFields {
return {
apiKeyFingerprint: fields.apiKeyHash,
apiKeyPreview: fields.apiKeyPreview,
};
}
function isCredentialEncryptionConfigured() {
return !!getEncryptionSecret();
}
// The assistant needs stored keys, so ENCRYPTION_SECRET. Redis is optional: with it, replies survive a reload
// and Stop reaches a run on another server; without it, both work within one server.
export function isAgentEnvironmentConfigured() {
return isCredentialEncryptionConfigured();
}
export function assertCredentialEncryptionConfigured() {
if (!isCredentialEncryptionConfigured()) throw new Error("AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE");
}
// The assistant needs stored keys, so ENCRYPTION_SECRET. Redis is optional: with it, replies survive a reload
// and Stop reaches a run on another server; without it, both work within one server.
export function assertAgentEnvironment() {
if (!isAgentEnvironmentConfigured()) throw new Error("AGENT_ENVIRONMENT_UNAVAILABLE");
if (!isCredentialEncryptionConfigured()) throw new Error("AGENT_ENVIRONMENT_UNAVAILABLE");
}
+11 -68
View File
@@ -1,7 +1,6 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ModelMessage } from "ai";
import { inflateRawSync } from "node:zlib";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createCerebras } from "@ai-sdk/cerebras";
import { createCohere } from "@ai-sdk/cohere";
@@ -16,6 +15,7 @@ import { createPerplexity } from "@ai-sdk/perplexity";
import { createTogetherAI } from "@ai-sdk/togetherai";
import { createXai } from "@ai-sdk/xai";
import { APICallError, createGateway, generateText, LoadAPIKeyError, NoSuchModelError } from "ai";
import { strFromU8, unzipSync } from "fflate";
import { createOllama } from "ollama-ai-provider-v2";
import { match } from "ts-pattern";
import { z } from "zod";
@@ -86,11 +86,6 @@ const TEST_CONNECTION_TIMEOUT_MS = z.coerce
.catch(30_000)
.parse(process.env.AI_TEST_TIMEOUT_MS?.trim() || undefined);
const DOCX_DOCUMENT_XML_PATH = "word/document.xml";
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
const ZIP_STORED_METHOD = 0;
const ZIP_DEFLATED_METHOD = 8;
export function getModel(input: GetModelInput) {
const { provider, model, apiKey } = input;
@@ -338,68 +333,16 @@ type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
mediaType: "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
};
function assertZipRange(buffer: Buffer, offset: number, length: number) {
if (offset < 0 || length < 0 || offset + length > buffer.length) throw new Error("Invalid DOCX archive.");
}
function findEndOfCentralDirectory(buffer: Buffer): number {
const minOffset = Math.max(0, buffer.length - 0xffff - 22);
for (let offset = buffer.length - 22; offset >= minOffset; offset--) {
if (buffer.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) return offset;
function readDocumentXml(data: string): string {
let entries: Record<string, Uint8Array>;
try {
entries = unzipSync(Buffer.from(data, "base64"), { filter: (file) => file.name === DOCX_DOCUMENT_XML_PATH });
} catch {
throw new Error("Invalid DOCX archive.");
}
throw new Error("Invalid DOCX archive.");
}
function readZipEntry(buffer: Buffer, entryName: string): Buffer {
const eocdOffset = findEndOfCentralDirectory(buffer);
assertZipRange(buffer, eocdOffset, 22);
const centralDirectorySize = buffer.readUInt32LE(eocdOffset + 12);
const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16);
assertZipRange(buffer, centralDirectoryOffset, centralDirectorySize);
let offset = centralDirectoryOffset;
const endOffset = centralDirectoryOffset + centralDirectorySize;
while (offset < endOffset) {
assertZipRange(buffer, offset, 46);
if (buffer.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) throw new Error("Invalid DOCX archive.");
const compressionMethod = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const fileNameLength = buffer.readUInt16LE(offset + 28);
const extraFieldLength = buffer.readUInt16LE(offset + 30);
const commentLength = buffer.readUInt16LE(offset + 32);
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
const fileNameOffset = offset + 46;
assertZipRange(buffer, fileNameOffset, fileNameLength);
const fileName = buffer.toString("utf8", fileNameOffset, fileNameOffset + fileNameLength);
if (fileName === entryName) {
assertZipRange(buffer, localHeaderOffset, 30);
if (buffer.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
throw new Error("Invalid DOCX archive.");
}
const localFileNameLength = buffer.readUInt16LE(localHeaderOffset + 26);
const localExtraFieldLength = buffer.readUInt16LE(localHeaderOffset + 28);
const dataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraFieldLength;
assertZipRange(buffer, dataOffset, compressedSize);
const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize);
if (compressionMethod === ZIP_STORED_METHOD) return compressed;
if (compressionMethod === ZIP_DEFLATED_METHOD) return inflateRawSync(compressed);
throw new Error("Unsupported DOCX archive compression.");
}
offset = fileNameOffset + fileNameLength + extraFieldLength + commentLength;
}
throw new Error("DOCX document content not found.");
const xml = entries[DOCX_DOCUMENT_XML_PATH];
if (!xml) throw new Error("DOCX document content not found.");
return strFromU8(xml);
}
function decodeXmlEntities(value: string): string {
@@ -416,7 +359,7 @@ function decodeXmlEntities(value: string): string {
}
function extractDocxText(file: z.infer<typeof fileInputSchema>): string {
const documentXml = readZipEntry(Buffer.from(file.data, "base64"), DOCX_DOCUMENT_XML_PATH).toString("utf8");
const documentXml = readDocumentXml(file.data);
// ponytail: minimal OOXML body-text extraction; add a DOCX parser dependency if tracked changes matter.
const text = decodeXmlEntities(
documentXml
@@ -1,4 +1,4 @@
import type { db } from "@reactive-resume/db/client";
import type { DbOrTx } from "@reactive-resume/db/client";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { and, eq, inArray, sql } from "drizzle-orm";
import * as schema from "@reactive-resume/db/schema";
@@ -7,8 +7,6 @@ import { coverLetterSchema } from "@reactive-resume/schema/cover-letter/data";
import { sanitizeCoverLetterHtml } from "./html";
import { writeLetterVersion } from "./versions";
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
/**
* Letters are documents of their own. Older app versions, API clients, imported files and restored versions can
* still hand the server a resume with cover-letter sections; each resume write passes through here first, so those
@@ -1,3 +1,4 @@
import type { DbOrTx } from "@reactive-resume/db/client";
import type { CoverLetterVersionData, CoverLetterVersionKind } from "@reactive-resume/db/schema";
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import { ORPCError } from "@orpc/client";
@@ -5,8 +6,6 @@ import { and, desc, eq, inArray, lt, notInArray } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
// The same retention as resumes (see resume/version-history.ts): sessions refresh their autosave at most every two
// minutes; autosaves and restore markers last 90 days; at most 500 autosaves per letter.
const SESSION_REFRESH_MS = 2 * 60 * 1000;
+9 -24
View File
@@ -1,3 +1,4 @@
import type { DbOrTx } from "@reactive-resume/db/client";
import type { JsonPatchOperation } from "@reactive-resume/resume/patch";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Locale } from "@reactive-resume/utils/locale";
@@ -5,7 +6,6 @@ import type { ResumeUpdatedEvent } from "./events";
import { ORPCError } from "@orpc/client";
import { compare, hash } from "bcrypt";
import { and, arrayContains, asc, desc, eq, gte, isNotNull, isNull, sql } from "drizzle-orm";
import { get } from "es-toolkit/compat";
import { match } from "ts-pattern";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
@@ -30,8 +30,6 @@ import {
} from "./version-history";
import { clientKeyFromHeaders, shouldCountView } from "./view-dedup";
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
function resumeVersionConflict(updatedAt: Date) {
return new ORPCError("RESUME_VERSION_CONFLICT", {
status: 409,
@@ -40,14 +38,13 @@ function resumeVersionConflict(updatedAt: Date) {
});
}
function invalidPatchOperation(message: string, index?: number, operation?: JsonPatchOperation) {
if (index !== undefined && operation !== undefined) {
return new ORPCError("INVALID_PATCH_OPERATIONS", { status: 400, message, data: { index, operation } });
}
return new ORPCError("INVALID_PATCH_OPERATIONS", { status: 400, message });
function invalidPatchOperation(message: string, index: number, operation: JsonPatchOperation) {
return new ORPCError("INVALID_PATCH_OPERATIONS", { status: 400, message, data: { index, operation } });
}
/** The unique constraint a Postgres insert or update broke, if that's why it failed. */
const uniqueConstraint = (error: unknown) => (error as { cause?: { constraint?: string } } | null)?.cause?.constraint;
function isValidJsonPointer(pointer: string): boolean {
if (pointer === "") return true;
if (!pointer.startsWith("/")) return false;
@@ -423,9 +420,7 @@ export const resumeService = {
and(
eq(schema.resume.userId, input.userId),
isNull(schema.resume.trashedAt),
match(input.tags.length)
.with(0, () => undefined)
.otherwise(() => arrayContains(schema.resume.tags, input.tags)),
input.tags.length > 0 ? arrayContains(schema.resume.tags, input.tags) : undefined,
),
)
.orderBy(
@@ -568,7 +563,7 @@ export const resumeService = {
return id;
} catch (error) {
const constraint = get(error, "cause.constraint") as string | undefined;
const constraint = uniqueConstraint(error);
if (constraint === "resume_slug_user_id_unique") {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
@@ -675,7 +670,7 @@ export const resumeService = {
.catch((error: unknown) => {
if (error instanceof ORPCError) throw error;
if (get(error, "cause.constraint") === "resume_slug_user_id_unique") {
if (uniqueConstraint(error) === "resume_slug_user_id_unique") {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
}
@@ -720,16 +715,6 @@ export const resumeService = {
patchInTransaction: applyResumePatchTx,
notifyResumePatched: async (input: { resumeId: string; userId: string; updatedAt: Date }) => {
await notifyResumeUpdated({
type: "resume.updated",
resumeId: input.resumeId,
userId: input.userId,
updatedAt: input.updatedAt.toISOString(),
mutation: "patch",
});
},
setLocked: async (input: { id: string; userId: string; isLocked: boolean }) => {
const [resume] = await db
.update(schema.resume)
+1 -2
View File
@@ -1,10 +1,9 @@
import type { DbOrTx } from "@reactive-resume/db/client";
import { and, eq, gt, inArray, like, or, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { slugify } from "@reactive-resume/utils/string";
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
/** Lowercase letters and numbers, in groups joined by single dashes. Only new and changed slugs must match. */
export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
@@ -1,3 +1,4 @@
import type { DbOrTx } from "@reactive-resume/db/client";
import type { ResumeVersionKind } from "@reactive-resume/db/schema";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { ORPCError } from "@orpc/client";
@@ -6,8 +7,6 @@ import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { parseStoredResumeData, parseWritableResumeData } from "./resume-data-validation";
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
// An editing session's autosave is refreshed at most this often.
const SESSION_REFRESH_MS = 2 * 60 * 1000;
// Autosaves, AI edits and restore markers are kept this long. Named, sent, created, imported and
@@ -52,17 +52,13 @@ const getCachedCount = async (
const getCountFromDatabase = async (table: typeof schema.user | typeof schema.resume): Promise<number | null> => {
const [result] = await db.select({ count: count() }).from(table);
if (!result) return null;
return result.count;
return result?.count ?? null;
};
const fetchGitHubStarsOnce = async (): Promise<number | null> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), GITHUB_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(GITHUB_API_URL, {
signal: controller.signal,
signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS),
headers: {
Accept: "application/vnd.github+json",
},
@@ -74,8 +70,6 @@ const fetchGitHubStarsOnce = async (): Promise<number | null> => {
return Number.isFinite(stars) && stars > 0 ? stars : null;
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
};
+1 -2
View File
@@ -330,8 +330,7 @@ export function getStorageService(): StorageService {
cachedService ??=
env.STORAGE_BACKEND === "blob"
? new BlobStorageService()
: env.STORAGE_BACKEND === "s3" ||
(!env.STORAGE_BACKEND && env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET)
: env.STORAGE_BACKEND === "s3"
? new S3StorageService()
: new LocalStorageService();
return cachedService;
+2 -3
View File
@@ -1,14 +1,13 @@
export function getTrustedOrigins(appUrl: string): string[] {
const normalizeOrigin = (origin: string): string => origin.replace(/\/$/, "");
const trustedOrigins = new Set<string>(["http://localhost:3000", "http://127.0.0.1:3000"]);
const configuredUrl = new URL(appUrl);
trustedOrigins.add(normalizeOrigin(configuredUrl.origin));
trustedOrigins.add(configuredUrl.origin);
if (configuredUrl.hostname === "localhost" || configuredUrl.hostname === "127.0.0.1") {
const loopbackAlias = configuredUrl.hostname === "localhost" ? "127.0.0.1" : "localhost";
configuredUrl.hostname = loopbackAlias;
trustedOrigins.add(normalizeOrigin(configuredUrl.origin));
trustedOrigins.add(configuredUrl.origin);
}
return [...trustedOrigins];
+3
View File
@@ -36,3 +36,6 @@ export function getPool() {
// ponytail: two private fns collapsed; getPool() is already a singleton, global cache preserved
globalThis.__drizzle ??= drizzle({ client: getPool() });
export const db = globalThis.__drizzle;
/** The client, or a transaction on it: helpers that write take either, so callers choose the transaction. */
export type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
+6 -24
View File
@@ -24,6 +24,8 @@ vi.mock("react-email", () => ({
const { sendEmail } = await import("./transport");
const fakeReact = { $$typeof: Symbol.for("react.element") } as unknown as React.ReactElement;
const resetEnv = () => {
envMock.SMTP_HOST = undefined;
envMock.SMTP_USER = undefined;
@@ -34,16 +36,10 @@ const resetEnv = () => {
};
describe("sendEmail", () => {
it("does nothing when neither text nor html is provided", async () => {
resetEnv();
await sendEmail({ to: "a@b.com", subject: "hi" });
expect(sendMail).not.toHaveBeenCalled();
});
it("skips sending and logs when SMTP is not configured (no host)", async () => {
resetEnv();
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
await sendEmail({ to: "a@b.com", subject: "hi", text: "body" });
await sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact });
expect(infoSpy).toHaveBeenCalledWith(
"SMTP not configured; skipping email send.",
@@ -60,7 +56,7 @@ describe("sendEmail", () => {
envMock.SMTP_PASS = "pass";
envMock.SMTP_FROM = "noreply@example.com";
await sendEmail({ to: "a@b.com", subject: "hi", text: "body" });
await sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact });
expect(createTransport).toHaveBeenCalledWith(
expect.objectContaining({
@@ -75,24 +71,11 @@ describe("sendEmail", () => {
to: "a@b.com",
from: "noreply@example.com",
subject: "hi",
text: "body",
text: "plain text body",
}),
);
});
it("falls back to a default 'noreply@localhost' from address when SMTP_FROM is unset", async () => {
resetEnv();
envMock.SMTP_HOST = "smtp.example.com";
envMock.SMTP_USER = "user";
envMock.SMTP_PASS = "pass";
// SMTP not "enabled" without SMTP_FROM — skipping branch — but options.from is used.
// Manually provide from in options instead.
await sendEmail({ to: "a@b.com", from: "explicit@x.com", subject: "hi", text: "body" });
// SMTP isn't enabled, so sendMail isn't called — confirm the info-log branch instead.
expect(sendMail).not.toHaveBeenCalled();
});
it("renders react element into both html and plain-text bodies", async () => {
resetEnv();
envMock.SMTP_HOST = "smtp.example.com";
@@ -100,7 +83,6 @@ describe("sendEmail", () => {
envMock.SMTP_PASS = "pass";
envMock.SMTP_FROM = "noreply@example.com";
const fakeReact = { $$typeof: Symbol.for("react.element") } as unknown as React.ReactElement;
await sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact });
expect(sendMail).toHaveBeenCalledWith(
@@ -120,7 +102,7 @@ describe("sendEmail", () => {
sendMail.mockRejectedValueOnce(new Error("boom"));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(sendEmail({ to: "a@b.com", subject: "hi", text: "body" })).resolves.toBeUndefined();
await expect(sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact })).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
+7 -22
View File
@@ -4,14 +4,7 @@ import nodemailer from "nodemailer";
import { render } from "react-email";
import { env } from "@reactive-resume/env/server";
type SendEmailOptions = {
to: string | string[];
subject: string;
text?: string;
html?: string;
react?: ReactElement;
from?: string;
};
type SendEmailOptions = { to: string; subject: string; react: ReactElement };
let cachedTransport: Transporter | undefined;
@@ -29,24 +22,16 @@ const getTransport = () => {
return cachedTransport;
};
export const sendEmail = async (options: SendEmailOptions) => {
export const sendEmail = async ({ to, subject, react }: SendEmailOptions) => {
const transport = getTransport();
const from = options.from ?? env.SMTP_FROM ?? "Reactive Resume <noreply@localhost>";
const payload: SendMailOptions = {
to: options.to,
from,
subject: options.subject,
...(options.text === undefined ? {} : { text: options.text }),
...(options.html === undefined ? {} : { html: options.html }),
to,
from: env.SMTP_FROM,
subject,
html: await render(react),
text: await render(react, { plainText: true }),
};
if (options.react) {
payload.html = await render(options.react);
payload.text = options.text ?? (await render(options.react, { plainText: true }));
}
if (!payload.text && !payload.html) return;
if (!transport) {
console.info("SMTP not configured; skipping email send.", {
to: payload.to,
+12 -18
View File
@@ -131,7 +131,7 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
if (resumes.length === 0) return text(`No resumes found. Use \`${T.createResume}\` to create one.`);
return text(JSON.stringify(resumes, null, 2));
return json(resumes);
},
),
);
@@ -145,7 +145,7 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
if (tags.length === 0) return text("No tags in use yet. Add tags when creating or updating a resume.");
return text(JSON.stringify(tags, null, 2));
return json(tags);
}),
);
@@ -156,7 +156,7 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
withErrorHandling("getting resume", async ({ id }: { id: string }) => {
const resume = await client.resume.getById({ id });
return text(JSON.stringify(resume.data, null, 2));
return json(resume.data);
}),
);
@@ -171,20 +171,14 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
const signedUrl = createResumePdfDownloadUrl({ resumeId: id, userId: user.id });
return text(
JSON.stringify(
{
resumeId: id,
name: resume.name,
downloadUrl: signedUrl.url,
expiresAt: signedUrl.expiresAt,
expiresInSeconds: signedUrl.expiresInSeconds,
contentType: "application/pdf",
},
null,
2,
),
);
return json({
resumeId: id,
name: resume.name,
downloadUrl: signedUrl.url,
expiresAt: signedUrl.expiresAt,
expiresInSeconds: signedUrl.expiresInSeconds,
contentType: "application/pdf",
});
}),
);
@@ -360,7 +354,7 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
withErrorHandling("getting resume statistics", async ({ id }: { id: string }) => {
const stats = await client.resume.statistics.getById({ id });
return text(JSON.stringify(stats, null, 2));
return json(stats);
}),
);
+6 -3
View File
@@ -914,9 +914,9 @@ importers:
drizzle-zod:
specifier: 1.0.0-beta.14-a36c63d
version: 1.0.0-beta.14-a36c63d(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.0)(zod@4.6.5))(zod@4.6.5)
es-toolkit:
specifier: ^1.52.0
version: 1.52.0
fflate:
specifier: ^0.8.3
version: 0.8.3
ioredis:
specifier: ^6.0.0
version: 6.0.0(supports-color@7.2.0)
@@ -960,6 +960,9 @@ importers:
'@typescript/native-preview':
specifier: 7.0.0-dev.20260707.2
version: 7.0.0-dev.20260707.2
es-toolkit:
specifier: ^1.52.0
version: 1.52.0
pg:
specifier: ^8.23.0
version: 8.23.0