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

- 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:
2026-03-30 19:27:56 +02:00
parent c52a132d62
commit 3f6fa36f9b
5 changed files with 355 additions and 2 deletions

View File

@@ -97,6 +97,7 @@ export default buildConfig({
connectionString:
process.env.DATABASE_URI || process.env.POSTGRES_URI || "",
},
push: false,
}),
sharp,
plugins: [

View File

@@ -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),

View 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>
);
};

View 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 });
}
};

View File

@@ -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",