Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/emailInbox/services/EmailSendService.server.ts
T

179 lines
7.2 KiB
TypeScript

// VA-P23 · outbound send over the user's OWN mailbox (M09 Block G, DoD 4c).
//
// The send path goes through the provider adapter (Gmail messages.send with
// threadId + In-Reply-To/References; Graph sendMail → 202 without an id). After
// a successful send the message is mirrored locally as direction=outbound —
// for Graph the sent-items delta later collapses onto this mirror via
// internetMessageId. Provider errors surface as Klartext codes (M002).
import { randomUUID } from "node:crypto";
import { GmailError, GraphMailError } from "@hivegrid/integrations";
import { logActivity } from "@hivegrid/activity";
import { db } from "~/utils/db.server";
import { adapterForProvider, ensureFreshMailboxCreds, loadMailboxCreds } from "./MailboxConnectionService.server";
import type { EmailOAuthProvider } from "./oauthState.server";
import type { NeutralAddress } from "./adapters/EmailProviderAdapter";
export class EmailSendError extends Error {
readonly code: "no_mailbox" | "not_connected" | "quota_exceeded" | "token_expired" | "provider_failed" | "no_recipients";
constructor(code: EmailSendError["code"]) {
super(code);
this.name = "EmailSendError";
this.code = code;
}
}
export interface SendEmailInput {
tenantId: string;
userId: string;
mailboxId: string;
to: readonly NeutralAddress[];
cc?: readonly NeutralAddress[];
bcc?: readonly NeutralAddress[];
subject: string;
bodyHtml: string;
/** Reply context: the mirrored message being answered (threading headers). */
replyToMessageId?: string | null;
attachments?: ReadonlyArray<{ filename: string; mimeType: string; contentBase64: string }>;
}
function mapProviderError(error: unknown): EmailSendError {
if (error instanceof GmailError) {
if (error.code === "quota_exceeded" || (error.code === "auth_scope_missing" && error.httpStatus === 403)) {
return new EmailSendError("quota_exceeded");
}
if (error.code === "auth_token_expired") {
return new EmailSendError("token_expired");
}
}
if (error instanceof GraphMailError && error.code === "auth_token_expired") {
return new EmailSendError("token_expired");
}
return new EmailSendError("provider_failed");
}
/** Send + mirror. Returns the local mirror message id (detail redirect). */
export async function sendEmail(input: SendEmailInput): Promise<{ messageId: string }> {
if (input.to.length === 0) {
throw new EmailSendError("no_recipients");
}
const mailbox = await db.emailIntegrationMailbox.findFirst({
where: { id: input.mailboxId, tenantId: input.tenantId, ownerUserId: input.userId, archivedAt: null, status: { in: ["active", "connecting", "error"] } },
select: { id: true, provider: true, emailAddress: true, displayName: true },
});
if (!mailbox) {
throw new EmailSendError("no_mailbox");
}
const provider = mailbox.provider as EmailOAuthProvider;
const creds = await loadMailboxCreds(input.tenantId, input.mailboxId);
if (!creds) {
throw new EmailSendError("not_connected");
}
// Reply threading context from the mirrored original (M09 Block G).
const replyTo = input.replyToMessageId
? await db.emailIntegrationMessage.findFirst({
where: { id: input.replyToMessageId, tenantId: input.tenantId },
select: { providerThreadId: true, internetMessageId: true },
})
: null;
let result: { providerMessageId: string | null; providerThreadId: string | null };
try {
const fresh = await ensureFreshMailboxCreds(input.tenantId, input.mailboxId, provider, creds);
result = await adapterForProvider(provider).sendMessage(fresh, {
to: input.to,
cc: input.cc,
bcc: input.bcc,
subject: input.subject,
bodyHtml: input.bodyHtml,
attachments: input.attachments,
providerThreadId: replyTo?.providerThreadId ?? null,
inReplyToInternetMessageId: replyTo?.internetMessageId ?? null,
referencesInternetMessageIds: replyTo?.internetMessageId ? [replyTo.internetMessageId] : undefined,
});
} catch (error) {
throw mapProviderError(error);
}
// Local outbound mirror. Graph returns no id (202) → provisional local id;
// the sent-items delta adopts the provider ids via internetMessageId later.
const now = new Date();
const mirror = await db.emailIntegrationMessage.create({
data: {
tenantId: input.tenantId,
mailboxId: input.mailboxId,
providerMessageId: result.providerMessageId ?? `local_${randomUUID()}`,
providerThreadId: result.providerThreadId ?? replyTo?.providerThreadId ?? null,
internetMessageId: null,
direction: "outbound",
fromAddress: mailbox.emailAddress,
fromName: mailbox.displayName,
toAddresses: input.to as never,
ccAddresses: (input.cc ?? []) as never,
bccAddresses: (input.bcc ?? []) as never,
subject: input.subject,
snippet: input.bodyHtml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 140),
bodyHtml: input.bodyHtml,
sentAt: now,
isRead: true,
hasAttachments: (input.attachments?.length ?? 0) > 0,
},
select: { id: true },
});
// Activity at the reply-context entities (auto-match hook covers synced
// inbound mail; the outbound mirror logs directly, actor = the sender).
if (input.replyToMessageId) {
const original = await db.emailIntegrationMessage.findFirst({
where: { id: input.replyToMessageId, tenantId: input.tenantId },
select: { matchedKontaktId: true, matchedLeadId: true, matchedProjektId: true },
});
const subject = original?.matchedKontaktId
? { subjectType: "contact" as const, subjectId: original.matchedKontaktId }
: original?.matchedLeadId
? { subjectType: "lead" as const, subjectId: original.matchedLeadId }
: original?.matchedProjektId
? { subjectType: "project" as const, subjectId: original.matchedProjektId }
: null;
if (subject) {
// The reply inherits the original's match so it shows on the same timeline.
await db.emailIntegrationMessage.update({
where: { id: mirror.id },
data: {
matchedKontaktId: original?.matchedKontaktId ?? null,
matchedLeadId: original?.matchedLeadId ?? null,
matchedProjektId: original?.matchedProjektId ?? null,
matchConfidence: "high",
},
});
await logActivity({
tenantId: input.tenantId,
actorUserId: input.userId,
actorKind: "user",
activityType: "email_sent",
subjectType: subject.subjectType,
subjectId: subject.subjectId,
summaryText: "activity.runtime.email.sent",
metadata: {
emailMessageId: mirror.id,
mailboxId: input.mailboxId,
recipient: input.to[0]?.name ?? input.to[0]?.address ?? null,
subject: input.subject.trim() || null,
},
}).catch(() => undefined);
}
}
return { messageId: mirror.id };
}
/** Active templates for the compose dropdown (M098 — v1-EmailTemplate reuse). */
export async function listEmailTemplates(tenantId: string): Promise<Array<{ id: string; name: string; subject: string; htmlBody: string }>> {
return db.emailTemplate.findMany({
where: { tenantId, archivedAt: null },
select: { id: true, name: true, subject: true, htmlBody: true },
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
}