583 lines
20 KiB
TypeScript
583 lines
20 KiB
TypeScript
import { saveAdminSendingDomain } from "@hivegrid/db/admin-email-domain.server";
|
|
import { renderAdminBulkEmailTemplate } from "@hivegrid/db/admin-email-compose.server";
|
|
import { assignAdminEmailMessage } from "@hivegrid/db/admin-email-sync.server";
|
|
import { writeAdminSalesAudit } from "@hivegrid/db/admin-sales-core.server";
|
|
|
|
import type { AdminEmailOperatorContext } from "./admin-email-connections.server";
|
|
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
|
|
import { db } from "~/utils/db.server";
|
|
|
|
export function adminEmailWorkspaceHrefs() {
|
|
return {
|
|
inbox: adminPath("sales/email/inbox", { mode: "host-aware" }),
|
|
templates: adminPath("sales/email/templates", { mode: "host-aware" }),
|
|
scheduled: adminPath("sales/email/scheduled", { mode: "host-aware" }),
|
|
accounts: adminPath("sales/settings/email-accounts", {
|
|
mode: "host-aware",
|
|
}),
|
|
domains: adminPath("sales/settings/sending-domains", {
|
|
mode: "host-aware",
|
|
}),
|
|
};
|
|
}
|
|
|
|
export async function readAdminEmailInbox(context: AdminEmailOperatorContext, selectedThreadId?: string | null) {
|
|
const ownerWhere = context.canReadAllCommunication
|
|
? {}
|
|
: { adminUserId: context.adminUserId };
|
|
const [threads, contacts, deals] = await Promise.all([
|
|
db.adminEmailThread.findMany({
|
|
where: { ...ownerWhere, archivedAt: null },
|
|
orderBy: [{ latestMessageAt: "desc" }, { id: "asc" }],
|
|
take: 100,
|
|
include: {
|
|
messages: {
|
|
orderBy: [{ sentAt: "desc" }, { id: "desc" }],
|
|
take: 1,
|
|
include: {
|
|
matchedContact: {
|
|
select: { firstName: true, lastName: true },
|
|
},
|
|
matchedDeal: { select: { title: true } },
|
|
events: { select: { type: true } },
|
|
attachments: { select: { id: true } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
db.adminContact.findMany({
|
|
where: { archivedAt: null },
|
|
orderBy: [{ lastName: "asc" }, { firstName: "asc" }, { id: "asc" }],
|
|
take: 250,
|
|
select: { id: true, firstName: true, lastName: true, email: true },
|
|
}),
|
|
db.adminDeal.findMany({
|
|
where: { archivedAt: null },
|
|
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
|
take: 250,
|
|
select: { id: true, title: true },
|
|
}),
|
|
]);
|
|
const foreignThreads = threads.filter(
|
|
(thread) => thread.adminUserId !== context.adminUserId
|
|
);
|
|
if (foreignThreads.length > 0) {
|
|
await writeAdminSalesAudit(db, {
|
|
actor: context.actor("Fremde Team-Postfächer aufgelistet"),
|
|
action: "admin.sales.email.foreignThreads.read",
|
|
targetType: "AdminEmailThread",
|
|
targetId: foreignThreads[0]!.id,
|
|
after: {
|
|
foreignTargets: foreignThreads.map((thread) => ({
|
|
targetId: thread.id,
|
|
dataOwnerAdminUserId: thread.adminUserId,
|
|
})),
|
|
accessKind: "read",
|
|
},
|
|
});
|
|
}
|
|
const selected = selectedThreadId
|
|
? await db.adminEmailThread.findFirst({
|
|
where: {
|
|
id: selectedThreadId,
|
|
...ownerWhere,
|
|
archivedAt: null,
|
|
},
|
|
include: {
|
|
messages: {
|
|
orderBy: [{ sentAt: "asc" }, { id: "asc" }],
|
|
include: {
|
|
events: { select: { type: true } },
|
|
attachments: {
|
|
select: { id: true, filename: true, sizeBytes: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
: null;
|
|
if (selected && selected.adminUserId !== context.adminUserId) {
|
|
await writeAdminSalesAudit(db, {
|
|
actor: context.actor("Fremdes Team-Postfach geöffnet"),
|
|
action: "admin.sales.email.foreignThread.read",
|
|
targetType: "AdminEmailThread",
|
|
targetId: selected.id,
|
|
after: {
|
|
dataOwnerAdminUserId: selected.adminUserId,
|
|
accessKind: "read",
|
|
},
|
|
});
|
|
}
|
|
const rows = threads.map((thread) => {
|
|
const latest = thread.messages[0];
|
|
return {
|
|
id: thread.id,
|
|
subject: thread.normalizedSubject ?? latest?.subject ?? "",
|
|
latestMessageAt: thread.latestMessageAt.toISOString(),
|
|
messageCount: thread.messageCount,
|
|
unreadCount: thread.unreadCount,
|
|
matchStatus: latest?.matchStatus ?? ("unmatched" as const),
|
|
preview: latest?.snippet ?? latest?.bodyText ?? "",
|
|
fromAddress: latest?.fromAddress ?? "",
|
|
contactLabel: latest?.matchedContact ? `${latest.matchedContact.firstName} ${latest.matchedContact.lastName}`.trim() : null,
|
|
dealLabel: latest?.matchedDeal?.title ?? null,
|
|
eventTypes: latest?.events.map((event) => event.type) ?? [],
|
|
attachmentCount: latest?.attachments.length ?? 0,
|
|
};
|
|
});
|
|
return {
|
|
stats: {
|
|
threads: rows.length,
|
|
unread: rows.reduce((sum, row) => sum + row.unreadCount, 0),
|
|
assignmentQueue: rows.filter((row) => row.matchStatus === "ambiguous" || row.matchStatus === "unmatched").length,
|
|
},
|
|
threads: rows,
|
|
selectedThread: selected
|
|
? {
|
|
id: selected.id,
|
|
subject: selected.normalizedSubject ?? "",
|
|
messages: selected.messages.map((message) => ({
|
|
id: message.id,
|
|
direction: message.direction,
|
|
fromAddress: message.fromAddress,
|
|
sentAt: message.sentAt.toISOString(),
|
|
bodyText: message.bodyText ?? message.snippet ?? "",
|
|
eventTypes: message.events.map((event) => event.type),
|
|
attachments: message.attachments.map((attachment) => ({
|
|
...attachment,
|
|
downloadHref: adminPath(
|
|
`sales/email/attachments/${encodeURIComponent(attachment.id)}`,
|
|
{ mode: "host-aware" }
|
|
),
|
|
})),
|
|
})),
|
|
}
|
|
: null,
|
|
assignmentOptions: {
|
|
contacts: contacts.map((contact) => ({
|
|
id: contact.id,
|
|
label: `${contact.firstName} ${contact.lastName}`.trim(),
|
|
email: contact.email,
|
|
})),
|
|
deals: deals.map((deal) => ({ id: deal.id, label: deal.title })),
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function readAdminEmailTemplates(context: AdminEmailOperatorContext) {
|
|
const [templates, snippets, signature] = await Promise.all([
|
|
db.adminEmailTemplate.findMany({
|
|
where: { adminUserId: context.adminUserId, archivedAt: null },
|
|
orderBy: [{ name: "asc" }, { version: "desc" }],
|
|
}),
|
|
db.adminEmailSnippet.findMany({
|
|
where: { adminUserId: context.adminUserId, archivedAt: null },
|
|
orderBy: [{ title: "asc" }, { id: "asc" }],
|
|
}),
|
|
db.adminEmailSignature.findUnique({
|
|
where: { adminUserId: context.adminUserId },
|
|
}),
|
|
]);
|
|
return {
|
|
templates: templates.map((template) => ({
|
|
id: template.id,
|
|
name: template.name,
|
|
subjectTemplate: template.subjectTemplate,
|
|
htmlTemplate: template.htmlTemplate,
|
|
allowedPlaceholders: template.allowedPlaceholders,
|
|
version: template.version,
|
|
updatedAt: template.updatedAt.toISOString(),
|
|
})),
|
|
snippets: snippets.map((snippet) => ({
|
|
id: snippet.id,
|
|
shortcut: snippet.shortcut,
|
|
title: snippet.title,
|
|
content: snippet.content,
|
|
})),
|
|
signature: signature ? { html: signature.html, text: signature.text } : null,
|
|
};
|
|
}
|
|
|
|
export async function readAdminEmailScheduled(context: AdminEmailOperatorContext) {
|
|
const ownerWhere = context.canReadAllCommunication
|
|
? {}
|
|
: { adminUserId: context.adminUserId };
|
|
const [policy, scheduled, bulkRuns, reminders, mailboxes, contacts] = await Promise.all([
|
|
db.adminEmailOutboundPolicy.findUnique({
|
|
where: { adminUserId: context.adminUserId },
|
|
}),
|
|
db.adminScheduledEmail.findMany({
|
|
where: ownerWhere,
|
|
orderBy: [{ scheduledFor: "desc" }, { id: "asc" }],
|
|
take: 100,
|
|
}),
|
|
db.adminBulkEmailRun.findMany({
|
|
where: ownerWhere,
|
|
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
|
|
take: 50,
|
|
}),
|
|
db.adminNoReplyReminder.findMany({
|
|
where: ownerWhere,
|
|
orderBy: [{ dueAt: "desc" }, { id: "asc" }],
|
|
take: 50,
|
|
include: {
|
|
outboundMessage: { select: { subject: true } },
|
|
},
|
|
}),
|
|
db.adminMailbox.findMany({
|
|
where: {
|
|
...ownerWhere,
|
|
archivedAt: null,
|
|
status: "active",
|
|
},
|
|
orderBy: [{ normalizedEmail: "asc" }, { id: "asc" }],
|
|
select: { id: true, emailAddress: true, displayName: true },
|
|
}),
|
|
db.adminContact.findMany({
|
|
where: { archivedAt: null, email: { not: null } },
|
|
orderBy: [{ lastName: "asc" }, { firstName: "asc" }, { id: "asc" }],
|
|
take: 250,
|
|
select: { id: true, firstName: true, lastName: true, email: true },
|
|
}),
|
|
]);
|
|
const foreignScheduledTargets = [
|
|
...scheduled
|
|
.filter((row) => row.adminUserId !== context.adminUserId)
|
|
.map((row) => ({
|
|
targetType: "AdminScheduledEmail",
|
|
targetId: row.id,
|
|
dataOwnerAdminUserId: row.adminUserId,
|
|
})),
|
|
...bulkRuns
|
|
.filter((row) => row.adminUserId !== context.adminUserId)
|
|
.map((row) => ({
|
|
targetType: "AdminBulkEmailRun",
|
|
targetId: row.id,
|
|
dataOwnerAdminUserId: row.adminUserId,
|
|
})),
|
|
...reminders
|
|
.filter((row) => row.adminUserId !== context.adminUserId)
|
|
.map((row) => ({
|
|
targetType: "AdminNoReplyReminder",
|
|
targetId: row.id,
|
|
dataOwnerAdminUserId: row.adminUserId,
|
|
})),
|
|
];
|
|
if (foreignScheduledTargets.length > 0) {
|
|
await writeAdminSalesAudit(db, {
|
|
actor: context.actor("Fremde geplante Team-Kommunikation aufgelistet"),
|
|
action: "admin.sales.email.foreignScheduled.read",
|
|
targetType: foreignScheduledTargets[0]!.targetType,
|
|
targetId: foreignScheduledTargets[0]!.targetId,
|
|
after: {
|
|
foreignTargets: foreignScheduledTargets,
|
|
accessKind: "read",
|
|
},
|
|
});
|
|
}
|
|
return {
|
|
outboundLocked: !policy?.configuredAt || !policy.consentRulesConfigured || policy.allowedWeekDays.length === 0,
|
|
policy: policy
|
|
? {
|
|
timeZone: policy.timeZone,
|
|
workdayStartMinutes: policy.workdayStartMinutes,
|
|
workdayEndMinutes: policy.workdayEndMinutes,
|
|
allowedWeekDays: policy.allowedWeekDays,
|
|
dailySendLimit: policy.dailySendLimit,
|
|
bulkDailySendLimit: policy.bulkDailySendLimit,
|
|
consentRulesConfigured: policy.consentRulesConfigured,
|
|
trackingEnabled: policy.trackingEnabled,
|
|
liveOpenEnabled: policy.liveOpenEnabled,
|
|
trackingRetentionDays: policy.trackingRetentionDays,
|
|
}
|
|
: null,
|
|
scheduled: scheduled.map((item) => ({
|
|
id: item.id,
|
|
subject: item.subject,
|
|
recipients: item.toAddresses,
|
|
scheduledFor: item.scheduledFor.toISOString(),
|
|
status: item.status,
|
|
noReplyAfterDays: item.noReplyAfterDays,
|
|
errorMessageKey: item.lastErrorMessageKey,
|
|
})),
|
|
bulkRuns: bulkRuns.map((run) => ({
|
|
id: run.id,
|
|
name: run.name,
|
|
status: run.status,
|
|
scheduledFor: run.scheduledFor?.toISOString() ?? null,
|
|
recipientCount: run.recipientCount,
|
|
sentCount: run.sentCount,
|
|
failedCount: run.failedCount,
|
|
})),
|
|
reminders: reminders.map((reminder) => ({
|
|
id: reminder.id,
|
|
dueAt: reminder.dueAt.toISOString(),
|
|
status: reminder.status,
|
|
subject: reminder.outboundMessage.subject ?? "",
|
|
taskId: reminder.taskId,
|
|
})),
|
|
mailboxes,
|
|
contacts: contacts.flatMap((contact) =>
|
|
contact.email
|
|
? [
|
|
{
|
|
id: contact.id,
|
|
label: `${contact.firstName} ${contact.lastName}`.trim(),
|
|
email: contact.email,
|
|
},
|
|
]
|
|
: []
|
|
),
|
|
};
|
|
}
|
|
|
|
export async function readAdminEmailDomains(context: AdminEmailOperatorContext) {
|
|
const [domains, bccAddresses, policy] = await Promise.all([
|
|
db.adminSendingDomain.findMany({
|
|
where: { adminUserId: context.adminUserId, archivedAt: null },
|
|
orderBy: [{ domain: "asc" }, { id: "asc" }],
|
|
}),
|
|
db.adminBccAddress.findMany({
|
|
where: { adminUserId: context.adminUserId, archivedAt: null },
|
|
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
|
|
include: {
|
|
mailbox: { select: { emailAddress: true, displayName: true } },
|
|
},
|
|
}),
|
|
db.adminEmailOutboundPolicy.findUnique({
|
|
where: { adminUserId: context.adminUserId },
|
|
}),
|
|
]);
|
|
return {
|
|
domains: domains.map((domain) => ({
|
|
id: domain.id,
|
|
domain: domain.domain,
|
|
status: domain.status,
|
|
spfStatus: domain.spfStatus,
|
|
dkimStatus: domain.dkimStatus,
|
|
spfRecordName: domain.spfRecordName,
|
|
spfExpectedValue: domain.spfExpectedValue,
|
|
dkimRecordName: domain.dkimRecordName,
|
|
dkimExpectedValue: domain.dkimExpectedValue,
|
|
lastCheckedAt: domain.lastCheckedAt?.toISOString() ?? null,
|
|
})),
|
|
bccAddresses: bccAddresses.map((address) => ({
|
|
id: address.id,
|
|
mailboxId: address.mailboxId,
|
|
mailboxLabel: address.mailbox.displayName ?? address.mailbox.emailAddress,
|
|
displayAddress: address.displayAddress,
|
|
rotatedAt: address.rotatedAt?.toISOString() ?? null,
|
|
})),
|
|
tracking: {
|
|
enabled: policy?.trackingEnabled ?? false,
|
|
liveOpenEnabled: policy?.liveOpenEnabled ?? false,
|
|
retentionDays: policy?.trackingRetentionDays ?? 180,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function applyAdminEmailWorkspaceAction(
|
|
context: AdminEmailOperatorContext,
|
|
input:
|
|
| {
|
|
readonly intent: "assign-message";
|
|
readonly messageId: string;
|
|
readonly contactId: string | null;
|
|
readonly dealId: string | null;
|
|
}
|
|
| {
|
|
readonly intent: "queue-email";
|
|
readonly mailboxId: string;
|
|
readonly contactId: string | null;
|
|
readonly dealId: string | null;
|
|
readonly toAddress: string;
|
|
readonly subject: string;
|
|
readonly body: string;
|
|
readonly scheduledFor: string;
|
|
readonly timeZone: string;
|
|
readonly noReplyAfterDays: number | null;
|
|
readonly idempotencyKey: string;
|
|
}
|
|
| {
|
|
readonly intent: "queue-bulk-email";
|
|
readonly mailboxId: string;
|
|
readonly name: string;
|
|
readonly contactIds: readonly string[];
|
|
readonly subjectTemplate: string;
|
|
readonly bodyTemplate: string;
|
|
readonly scheduledFor: string;
|
|
readonly timeZone: string;
|
|
readonly noReplyAfterDays: number | null;
|
|
readonly idempotencyKey: string;
|
|
}
|
|
| {
|
|
readonly intent: "save-template";
|
|
readonly name: string;
|
|
readonly subjectTemplate: string;
|
|
readonly htmlTemplate: string;
|
|
readonly allowedPlaceholders: readonly string[];
|
|
}
|
|
| {
|
|
readonly intent: "save-snippet";
|
|
readonly shortcut: string;
|
|
readonly title: string;
|
|
readonly content: string;
|
|
}
|
|
| {
|
|
readonly intent: "save-signature";
|
|
readonly html: string;
|
|
readonly text: string | null;
|
|
}
|
|
| {
|
|
readonly intent: "save-policy";
|
|
readonly timeZone: string;
|
|
readonly workdayStartMinutes: number;
|
|
readonly workdayEndMinutes: number;
|
|
readonly allowedWeekDays: readonly number[];
|
|
readonly dailySendLimit: number;
|
|
readonly bulkDailySendLimit: number;
|
|
readonly consentRulesConfigured: boolean;
|
|
readonly trackingEnabled: boolean;
|
|
readonly liveOpenEnabled: boolean;
|
|
}
|
|
| {
|
|
readonly intent: "save-domain";
|
|
readonly domain: string;
|
|
readonly spfRecordName: string;
|
|
readonly spfExpectedValue: string;
|
|
readonly dkimRecordName: string;
|
|
readonly dkimExpectedValue: string;
|
|
}
|
|
) {
|
|
const actor = context.actor(actionReason(input.intent));
|
|
if (input.intent === "assign-message") {
|
|
return assignAdminEmailMessage(db, {
|
|
adminUserId: context.adminUserId,
|
|
messageId: input.messageId,
|
|
contactId: input.contactId,
|
|
dealId: input.dealId,
|
|
actor,
|
|
});
|
|
}
|
|
if (input.intent === "queue-email") {
|
|
return context.compose.queue({
|
|
adminUserId: context.adminUserId,
|
|
mailboxId: input.mailboxId,
|
|
contactId: input.contactId,
|
|
dealId: input.dealId,
|
|
toAddresses: [input.toAddress],
|
|
subject: input.subject,
|
|
bodyHtml: `<p>${escapeHtml(input.body).replaceAll("\n", "<br />")}</p>`,
|
|
bodyText: input.body,
|
|
scheduledFor: new Date(input.scheduledFor),
|
|
timeZone: input.timeZone,
|
|
noReplyAfterDays: input.noReplyAfterDays,
|
|
idempotencyKey: input.idempotencyKey,
|
|
actor,
|
|
});
|
|
}
|
|
if (input.intent === "queue-bulk-email") {
|
|
const contacts = await db.adminContact.findMany({
|
|
where: {
|
|
id: { in: [...new Set(input.contactIds)] },
|
|
archivedAt: null,
|
|
email: { not: null },
|
|
},
|
|
orderBy: { id: "asc" },
|
|
select: { id: true, firstName: true, lastName: true, email: true },
|
|
});
|
|
if (contacts.length !== new Set(input.contactIds).size) {
|
|
throw new Error("admin_email_bulk_contact_invalid");
|
|
}
|
|
return context.compose.queueBulk({
|
|
adminUserId: context.adminUserId,
|
|
mailboxId: input.mailboxId,
|
|
name: input.name,
|
|
scheduledFor: new Date(input.scheduledFor),
|
|
timeZone: input.timeZone,
|
|
idempotencyKey: input.idempotencyKey,
|
|
recipients: contacts.map((contact) => {
|
|
const values = {
|
|
first_name: contact.firstName,
|
|
last_name: contact.lastName,
|
|
full_name: `${contact.firstName} ${contact.lastName}`.trim(),
|
|
email: contact.email!,
|
|
};
|
|
const subject = renderAdminBulkEmailTemplate(input.subjectTemplate, values);
|
|
const body = renderAdminBulkEmailTemplate(input.bodyTemplate, values);
|
|
return {
|
|
contactId: contact.id,
|
|
toAddress: contact.email!,
|
|
subject,
|
|
bodyHtml: `<p>${escapeHtml(body).replaceAll("\n", "<br />")}</p>`,
|
|
bodyText: body,
|
|
noReplyAfterDays: input.noReplyAfterDays,
|
|
};
|
|
}),
|
|
actor,
|
|
});
|
|
}
|
|
if (input.intent === "save-template") {
|
|
return context.compose.saveTemplate({
|
|
adminUserId: context.adminUserId,
|
|
name: input.name,
|
|
subjectTemplate: input.subjectTemplate,
|
|
htmlTemplate: `<p>${escapeHtml(input.htmlTemplate).replaceAll("\n", "<br />")}</p>`,
|
|
textTemplate: null,
|
|
allowedPlaceholders: input.allowedPlaceholders,
|
|
actor,
|
|
});
|
|
}
|
|
if (input.intent === "save-snippet") {
|
|
return context.compose.saveSnippet({
|
|
adminUserId: context.adminUserId,
|
|
shortcut: input.shortcut,
|
|
title: input.title,
|
|
content: input.content,
|
|
actor,
|
|
});
|
|
}
|
|
if (input.intent === "save-signature") {
|
|
return context.compose.saveSignature({
|
|
adminUserId: context.adminUserId,
|
|
html: `<p>${escapeHtml(input.text ?? input.html).replaceAll("\n", "<br />")}</p>`,
|
|
text: input.text,
|
|
actor,
|
|
});
|
|
}
|
|
if (input.intent === "save-policy") {
|
|
return context.compose.savePolicy({
|
|
adminUserId: context.adminUserId,
|
|
...input,
|
|
actor,
|
|
});
|
|
}
|
|
return saveAdminSendingDomain(db, {
|
|
adminUserId: context.adminUserId,
|
|
domain: input.domain,
|
|
spfRecordName: input.spfRecordName,
|
|
spfExpectedValue: input.spfExpectedValue,
|
|
dkimRecordName: input.dkimRecordName,
|
|
dkimExpectedValue: input.dkimExpectedValue,
|
|
actor,
|
|
});
|
|
}
|
|
|
|
function actionReason(intent: string) {
|
|
const reasons: Record<string, string> = {
|
|
"assign-message": "E-Mail einem Sales-Vorgang zuordnen",
|
|
"queue-email": "E-Mail im Sales Workspace planen",
|
|
"queue-bulk-email": "Personalisierte Serienmail planen",
|
|
"save-template": "E-Mail-Vorlage speichern",
|
|
"save-snippet": "E-Mail-Textbaustein speichern",
|
|
"save-signature": "Persönliche E-Mail-Signatur speichern",
|
|
"save-policy": "Outbound-Regeln konfigurieren",
|
|
"save-domain": "Sende-Domain konfigurieren",
|
|
};
|
|
return reasons[intent] ?? "E-Mail-Einstellung ändern";
|
|
}
|
|
|
|
function escapeHtml(value: string) {
|
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
}
|