// 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> { return db.emailTemplate.findMany({ where: { tenantId, archivedAt: null }, select: { id: true, name: true, subject: true, htmlBody: true }, orderBy: [{ isDefault: "desc" }, { name: "asc" }], }); }