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:
|
connectionString:
|
||||||
process.env.DATABASE_URI || process.env.POSTGRES_URI || "",
|
process.env.DATABASE_URI || process.env.POSTGRES_URI || "",
|
||||||
},
|
},
|
||||||
|
push: false,
|
||||||
}),
|
}),
|
||||||
sharp,
|
sharp,
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CollectionConfig } from "payload";
|
import type { CollectionConfig } from "payload";
|
||||||
|
import { bulkMailEndpointHandler } from "../endpoints/bulkMailEndpoint";
|
||||||
export const CrmContacts: CollectionConfig = {
|
export const CrmContacts: CollectionConfig = {
|
||||||
slug: "crm-contacts",
|
slug: "crm-contacts",
|
||||||
labels: {
|
labels: {
|
||||||
@@ -12,7 +12,21 @@ export const CrmContacts: CollectionConfig = {
|
|||||||
group: "CRM",
|
group: "CRM",
|
||||||
description:
|
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.",
|
"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: {
|
access: {
|
||||||
read: ({ req: { user } }) => Boolean(user),
|
read: ({ req: { user } }) => Boolean(user),
|
||||||
create: ({ 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": {
|
"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": "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: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:stop": "COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down",
|
||||||
"dev:local": "pnpm -r dev",
|
"dev:local": "pnpm -r dev",
|
||||||
"build": "pnpm -r build",
|
"build": "pnpm -r build",
|
||||||
|
|||||||
Reference in New Issue
Block a user