Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/services/admin/admin-email-workspace.server.ts
T

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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#039;");
}