diff --git a/apps/web/payload.config.ts b/apps/web/payload.config.ts
index dea0b77..bf4d4cb 100644
--- a/apps/web/payload.config.ts
+++ b/apps/web/payload.config.ts
@@ -97,6 +97,7 @@ export default buildConfig({
connectionString:
process.env.DATABASE_URI || process.env.POSTGRES_URI || "",
},
+ push: false,
}),
sharp,
plugins: [
diff --git a/apps/web/src/payload/collections/CrmContacts.ts b/apps/web/src/payload/collections/CrmContacts.ts
index 711f269..8ed909e 100644
--- a/apps/web/src/payload/collections/CrmContacts.ts
+++ b/apps/web/src/payload/collections/CrmContacts.ts
@@ -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),
diff --git a/apps/web/src/payload/components/BulkMailButton.tsx b/apps/web/src/payload/components/BulkMailButton.tsx
new file mode 100644
index 0000000..66bf2e7
--- /dev/null
+++ b/apps/web/src/payload/components/BulkMailButton.tsx
@@ -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 (
+
+
+
+ {isOpen && (
+
+
+
AI Bulk Mail
+
+ Generate and send personalized emails to {selectedCount} contacts.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/payload/endpoints/bulkMailEndpoint.ts b/apps/web/src/payload/endpoints/bulkMailEndpoint.ts
new file mode 100644
index 0000000..9ad8551
--- /dev/null
+++ b/apps/web/src/payload/endpoints/bulkMailEndpoint.ts
@@ -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: "
+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 = `${bodyText.replace(/\n/g, "
")}
`;
+
+ 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 });
+ }
+};
diff --git a/package.json b/package.json
index 427a832..aaba05c 100644
--- a/package.json
+++ b/package.json
@@ -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",