Add the ability to send emails

This commit is contained in:
Moshfegh Hamedani 2026-03-02 11:48:14 -08:00
parent f98646d7ac
commit f5cd564852
7 changed files with 69 additions and 1 deletions

View file

@ -70,6 +70,7 @@
"@prisma/adapter-pg": "^7.3.0", "@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0", "@prisma/client": "^7.3.0",
"@sendgrid/inbound-mail-parser": "^8.0.0", "@sendgrid/inbound-mail-parser": "^8.0.0",
"@sendgrid/mail": "^8.1.6",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"ai": "^6.0.100", "ai": "^6.0.100",
"better-auth": "^1.4.18", "better-auth": "^1.4.18",
@ -559,10 +560,14 @@
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@sendgrid/client": ["@sendgrid/client@8.1.6", "", { "dependencies": { "@sendgrid/helpers": "^8.0.0", "axios": "^1.12.0" } }, "sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA=="],
"@sendgrid/helpers": ["@sendgrid/helpers@8.0.0", "", { "dependencies": { "deepmerge": "^4.2.2" } }, "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA=="], "@sendgrid/helpers": ["@sendgrid/helpers@8.0.0", "", { "dependencies": { "deepmerge": "^4.2.2" } }, "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA=="],
"@sendgrid/inbound-mail-parser": ["@sendgrid/inbound-mail-parser@8.0.0", "", { "dependencies": { "@sendgrid/helpers": "^8.0.0", "mailparser": "^2.3.4" } }, "sha512-y7tH39f3uszeYlq6SGiO3npMpzAGsTWqIl46YjvJNHvaYP7B85YGcINIhm4CPdBA+dA+2wFYbDyvxFVt4ZhpcA=="], "@sendgrid/inbound-mail-parser": ["@sendgrid/inbound-mail-parser@8.0.0", "", { "dependencies": { "@sendgrid/helpers": "^8.0.0", "mailparser": "^2.3.4" } }, "sha512-y7tH39f3uszeYlq6SGiO3npMpzAGsTWqIl46YjvJNHvaYP7B85YGcINIhm4CPdBA+dA+2wFYbDyvxFVt4ZhpcA=="],
"@sendgrid/mail": ["@sendgrid/mail@8.1.6", "", { "dependencies": { "@sendgrid/client": "^8.1.5", "@sendgrid/helpers": "^8.0.0" } }, "sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],

View file

@ -16,5 +16,8 @@ WEBHOOK_SECRET="" # Required for inbound email webhook
OPENAI_API_KEY="" # Required for AI polish feature OPENAI_API_KEY="" # Required for AI polish feature
SENDGRID_API_KEY="" # Required for sending outbound emails
SENDGRID_FROM_EMAIL="" # Verified sender address (e.g. support@yourdomain.com)
SEED_ADMIN_EMAIL="admin@example.com" SEED_ADMIN_EMAIL="admin@example.com"
SEED_ADMIN_PASSWORD="" # Use a strong password SEED_ADMIN_PASSWORD="" # Use a strong password

View file

@ -20,6 +20,7 @@
"@prisma/adapter-pg": "^7.3.0", "@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0", "@prisma/client": "^7.3.0",
"@sendgrid/inbound-mail-parser": "^8.0.0", "@sendgrid/inbound-mail-parser": "^8.0.0",
"@sendgrid/mail": "^8.1.6",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"ai": "^6.0.100", "ai": "^6.0.100",
"better-auth": "^1.4.18", "better-auth": "^1.4.18",

View file

@ -4,6 +4,7 @@ import type { PgBoss } from "pg-boss";
import { generateText } from "ai"; import { generateText } from "ai";
import { openai } from "@ai-sdk/openai"; import { openai } from "@ai-sdk/openai";
import prisma from "../db"; import prisma from "../db";
import { sendEmailJob } from "./send-email";
const QUEUE_NAME = "auto-resolve-ticket"; const QUEUE_NAME = "auto-resolve-ticket";
@ -17,6 +18,7 @@ interface AutoResolveJobData {
subject: string; subject: string;
body: string; body: string;
senderName: string; senderName: string;
senderEmail: string;
} }
export async function registerAutoResolveWorker(boss: PgBoss): Promise<void> { export async function registerAutoResolveWorker(boss: PgBoss): Promise<void> {
@ -27,7 +29,7 @@ export async function registerAutoResolveWorker(boss: PgBoss): Promise<void> {
}); });
await boss.work<AutoResolveJobData>(QUEUE_NAME, async (jobs) => { await boss.work<AutoResolveJobData>(QUEUE_NAME, async (jobs) => {
const { ticketId, subject, body, senderName } = jobs[0]!.data; const { ticketId, subject, body, senderName, senderEmail } = jobs[0]!.data;
const firstName = senderName.split(" ")[0]; const firstName = senderName.split(" ")[0];
await prisma.ticket.update({ await prisma.ticket.update({
@ -85,6 +87,12 @@ export async function registerAutoResolveWorker(boss: PgBoss): Promise<void> {
data: { status: "resolved" }, data: { status: "resolved" },
}), }),
]); ]);
await sendEmailJob({
to: senderEmail,
subject: `Re: ${subject}`,
body: response,
});
} }
}); });
} }
@ -94,6 +102,7 @@ export async function sendAutoResolveJob(ticket: {
subject: string; subject: string;
body: string; body: string;
senderName: string; senderName: string;
senderEmail: string;
}): Promise<void> { }): Promise<void> {
const { boss } = await import("./queue"); const { boss } = await import("./queue");
await boss.send(QUEUE_NAME, { await boss.send(QUEUE_NAME, {
@ -101,5 +110,6 @@ export async function sendAutoResolveJob(ticket: {
subject: ticket.subject, subject: ticket.subject,
body: ticket.body, body: ticket.body,
senderName: ticket.senderName, senderName: ticket.senderName,
senderEmail: ticket.senderEmail,
}); });
} }

View file

@ -1,6 +1,7 @@
import { PgBoss } from "pg-boss"; import { PgBoss } from "pg-boss";
import { registerClassifyWorker } from "./classify-ticket"; import { registerClassifyWorker } from "./classify-ticket";
import { registerAutoResolveWorker } from "./auto-resolve-ticket"; import { registerAutoResolveWorker } from "./auto-resolve-ticket";
import { registerSendEmailWorker } from "./send-email";
const boss = new PgBoss({ const boss = new PgBoss({
connectionString: process.env.DATABASE_URL!, connectionString: process.env.DATABASE_URL!,
@ -15,6 +16,7 @@ export async function startQueue(): Promise<void> {
await registerClassifyWorker(boss); await registerClassifyWorker(boss);
await registerAutoResolveWorker(boss); await registerAutoResolveWorker(boss);
await registerSendEmailWorker(boss);
console.log("Job queue started"); console.log("Job queue started");
} }

View file

@ -0,0 +1,40 @@
import sgMail from "@sendgrid/mail";
import type { PgBoss } from "pg-boss";
const QUEUE_NAME = "send-email";
interface SendEmailJobData {
to: string;
subject: string;
body: string;
bodyHtml?: string;
}
export async function registerSendEmailWorker(boss: PgBoss): Promise<void> {
await boss.createQueue(QUEUE_NAME, {
retryLimit: 3,
retryDelay: 30,
retryBackoff: true,
});
await boss.work<SendEmailJobData>(QUEUE_NAME, async (jobs) => {
const { to, subject, body, bodyHtml } = jobs[0]!.data;
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
await sgMail.send({
to,
from: process.env.SENDGRID_FROM_EMAIL!,
subject,
text: body,
...(bodyHtml && { html: bodyHtml }),
});
console.log(`Email sent to ${to} — subject: "${subject}"`);
});
}
export async function sendEmailJob(data: SendEmailJobData): Promise<void> {
const { boss } = await import("./queue");
await boss.send(QUEUE_NAME, data);
}

View file

@ -6,6 +6,7 @@ import { generateText } from "ai";
import { openai } from "@ai-sdk/openai"; import { openai } from "@ai-sdk/openai";
import { createReplySchema, polishReplySchema } from "core/schemas/replies.ts"; import { createReplySchema, polishReplySchema } from "core/schemas/replies.ts";
import prisma from "../db"; import prisma from "../db";
import { sendEmailJob } from "../lib/send-email";
const router = Router({ mergeParams: true }); const router = Router({ mergeParams: true });
@ -57,6 +58,12 @@ router.post("/", requireAuth, async (req, res) => {
include: { user: { select: { id: true, name: true } } }, include: { user: { select: { id: true, name: true } } },
}); });
await sendEmailJob({
to: ticket.senderEmail,
subject: `Re: ${ticket.subject}`,
body: data.body,
});
res.status(201).json(reply); res.status(201).json(reply);
}); });