feat(crm): implement bulk mail action for contacts
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Failing after 6m25s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Failing after 6m25s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Add BulkMailButton component for Payload list view - Add bulkMailEndpoint handler for processing prompts - Integrate bulk mail action into CrmContacts collection - Update dev:clean script to skip interactive prompts
This commit is contained in:
@@ -97,6 +97,7 @@ export default buildConfig({
|
||||
connectionString:
|
||||
process.env.DATABASE_URI || process.env.POSTGRES_URI || "",
|
||||
},
|
||||
push: false,
|
||||
}),
|
||||
sharp,
|
||||
plugins: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
import { bulkMailEndpointHandler } from "../endpoints/bulkMailEndpoint";
|
||||
export const CrmContacts: CollectionConfig = {
|
||||
slug: "crm-contacts",
|
||||
labels: {
|
||||
@@ -12,7 +12,21 @@ export const CrmContacts: CollectionConfig = {
|
||||
group: "CRM",
|
||||
description:
|
||||
"Contacts are the individual people linked to an Account. A person should only be created once and can be assigned to a company here.",
|
||||
components: {
|
||||
views: {
|
||||
list: {
|
||||
actions: ["@/src/payload/components/BulkMailButton#BulkMailButton"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
path: "/bulk-mail",
|
||||
method: "post",
|
||||
handler: bulkMailEndpointHandler,
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req: { user } }) => Boolean(user),
|
||||
create: ({ req: { user } }) => Boolean(user),
|
||||
|
||||
147
apps/web/src/payload/components/BulkMailButton.tsx
Normal file
147
apps/web/src/payload/components/BulkMailButton.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useSelection, Button, toast } from "@payloadcms/ui";
|
||||
|
||||
export const BulkMailButton: React.FC = () => {
|
||||
const { selected } = useSelection();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isTest, setIsTest] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const selectedCount = Object.keys(selected).length;
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBulkMail = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const contactIds = Object.keys(selected);
|
||||
|
||||
const response = await fetch("/api/crm-contacts/bulk-mail", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contactIds,
|
||||
instructions: prompt,
|
||||
isTest,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
`Successfully sent emails to ${contactIds.length} contacts.`,
|
||||
);
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
toast.error(`Failed: ${data.error}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(`An error occurred: ${e.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "inline-block", marginLeft: "1rem" }}>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
buttonStyle="primary"
|
||||
size="small"
|
||||
>
|
||||
🤖 AI Bulk Mail ({selectedCount})
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
zIndex: 9999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--theme-elevation-100, #fff)",
|
||||
color: "var(--theme-text, #000)",
|
||||
padding: "2rem",
|
||||
borderRadius: "8px",
|
||||
width: "500px",
|
||||
maxWidth: "90vw",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0 }}>AI Bulk Mail</h2>
|
||||
<p>
|
||||
Generate and send personalized emails to {selectedCount} contacts.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||
<strong>Prompt / Instructions for AI</strong>
|
||||
</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="e.g. Mache ein Angebot für ein neues Messe-Design"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100px",
|
||||
padding: "0.5rem",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--theme-elevation-400, #ccc)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isTest}
|
||||
onChange={(e) => setIsTest(e.target.checked)}
|
||||
/>
|
||||
<strong>Test Mode</strong> (Send all emails to yourself)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "1rem",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleBulkMail} disabled={isLoading || !prompt}>
|
||||
{isLoading ? "Sending..." : "Send Emails"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
191
apps/web/src/payload/endpoints/bulkMailEndpoint.ts
Normal file
191
apps/web/src/payload/endpoints/bulkMailEndpoint.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { PayloadRequest } from "payload";
|
||||
|
||||
export const bulkMailEndpointHandler = async (req: PayloadRequest) => {
|
||||
try {
|
||||
let body: any = {};
|
||||
if (req.body) {
|
||||
try {
|
||||
body = (await req.json?.()) || {};
|
||||
} catch (_e) {
|
||||
body = req.body;
|
||||
}
|
||||
}
|
||||
|
||||
const { contactIds, instructions, isTest } = body;
|
||||
|
||||
if (!req.user) {
|
||||
return Response.json(
|
||||
{ success: false, error: "Unauthorized" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return Response.json(
|
||||
{ success: false, error: "No contacts selected" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const OPENROUTER_KEY =
|
||||
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
||||
if (!OPENROUTER_KEY) {
|
||||
return Response.json(
|
||||
{ success: false, error: "Missing OPENROUTER_API_KEY" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const sentEmails = [];
|
||||
const interactions = [];
|
||||
|
||||
for (const contactId of contactIds) {
|
||||
// Fetch contact with account populated
|
||||
const contact = await req.payload.findByID({
|
||||
collection: "crm-contacts",
|
||||
id: contactId,
|
||||
depth: 1,
|
||||
});
|
||||
|
||||
if (!contact || !contact.email) continue;
|
||||
|
||||
const account = contact.account as any;
|
||||
const accountInfo = account
|
||||
? `
|
||||
Company Name: ${account.name || "Unknown"}
|
||||
Website: ${account.website || "Unknown"}
|
||||
Industry: ${account.industry || "Unknown"}
|
||||
Website Status: ${account.websiteStatus || "Unknown"}
|
||||
Internal Notes: ${account.notes || "None"}
|
||||
`
|
||||
: "No company information available.";
|
||||
|
||||
const prompt = `You are an expert sales/business development AI assistant. Write a professional, personalized German B2B outreach email ("Anschreiben").
|
||||
|
||||
CONTEXT ABOUT THE CONTACT:
|
||||
Name: ${contact.fullName || contact.firstName || "Unknown"}
|
||||
Role: ${contact.role || "Unknown"}
|
||||
|
||||
CONTEXT ABOUT THEIR COMPANY:
|
||||
${accountInfo}
|
||||
|
||||
USER INSTRUCTIONS / GOAL OF THE EMAIL:
|
||||
${instructions}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. Write the email subject on the FIRST line as: "SUBJECT: <Your Subject>"
|
||||
2. The rest of the message should be the email body.
|
||||
3. Keep it professional and natural in German (Sie-form unless you think Du is appropriate based on the industry, but prefer Sie).
|
||||
4. Output only the email text, no markdown code blocks around it. No extra chit-chat.
|
||||
`;
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${OPENROUTER_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
const generatedText = data.choices?.[0]?.message?.content?.trim() || "";
|
||||
|
||||
if (!generatedText) {
|
||||
console.error("AI Generation failed for contact", contactId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse subject and body
|
||||
const lines = generatedText.split("\n");
|
||||
let subject = "Information von Mintel";
|
||||
let bodyText = generatedText;
|
||||
|
||||
if (lines[0].startsWith("SUBJECT:")) {
|
||||
subject = lines[0].replace("SUBJECT:", "").trim();
|
||||
bodyText = lines.slice(1).join("\n").trim();
|
||||
}
|
||||
|
||||
// Use a simple HTML wrapper to preserve line breaks
|
||||
const htmlBody = `<p>${bodyText.replace(/\n/g, "<br/>")}</p>`;
|
||||
|
||||
const targetEmail = isTest ? req.user.email : contact.email;
|
||||
|
||||
// Send the email
|
||||
await req.payload.sendEmail({
|
||||
to: targetEmail,
|
||||
subject: (isTest ? "[TEST] " : "") + subject,
|
||||
html: htmlBody,
|
||||
});
|
||||
|
||||
sentEmails.push({ contactId, email: targetEmail });
|
||||
|
||||
if (!isTest) {
|
||||
// Log interaction
|
||||
const interaction = await req.payload.create({
|
||||
collection: "crm-interactions",
|
||||
data: {
|
||||
type: "email",
|
||||
subject: `Outbound AI Mail: ${subject}`,
|
||||
date: new Date().toISOString(),
|
||||
contact: contact.id,
|
||||
account: account?.id,
|
||||
content: {
|
||||
root: {
|
||||
type: "root",
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
direction: "ltr",
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
direction: "ltr",
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
mode: "normal",
|
||||
style: "",
|
||||
detail: 0,
|
||||
format: 0,
|
||||
text: bodyText,
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
interactions.push(interaction.id);
|
||||
|
||||
// Update lead temperature to "warm" if it's currently cold and the status is lead
|
||||
if (
|
||||
account &&
|
||||
account.status === "lead" &&
|
||||
account.leadTemperature === "cold"
|
||||
) {
|
||||
await req.payload.update({
|
||||
collection: "crm-accounts",
|
||||
id: account.id,
|
||||
data: {
|
||||
leadTemperature: "warm",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ success: true, sentEmails, testMode: isTest });
|
||||
} catch (e: any) {
|
||||
console.error("Bulk Mail error", e);
|
||||
return Response.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "bash -c 'trap \"COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml up app postgres-db --remove-orphans'",
|
||||
"dev:docker": "docker network create infra 2>/dev/null || true && echo \"\\n🚀 Dockerized Environment Starting...\\n\\n📱 App: http://mintel.localhost\\n🚦 Caddy Proxy: http://localhost:80\\n\" && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml up app postgres-db",
|
||||
"dev:clean": "pnpm dev:stop && rm -rf apps/web/.next apps/web/node_modules && pnpm install && pnpm dev",
|
||||
"dev:clean": "pnpm dev:stop && rm -rf apps/web/.next apps/web/node_modules && pnpm install && CI=true pnpm dev",
|
||||
"dev:stop": "COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down",
|
||||
"dev:local": "pnpm -r dev",
|
||||
"build": "pnpm -r build",
|
||||
|
||||
Reference in New Issue
Block a user