179 lines
7.2 KiB
TypeScript
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" }],
|
|
});
|
|
}
|