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

132 lines
5.0 KiB
TypeScript

// VA-P23 · attachment import (M09: "Anhänge landen in v1-Files", DoD 5).
//
// Attachments are imported LAZILY on user action (not eagerly during sync —
// Gmail base64 bodies are expensive and Graph >3 MB needs a $value stream):
// adapter download → private bucket via putBufferAsFile → `File` row (source
// `email_attachment`) + polymorphic FileEntityLink (entityType `email_message`).
// Delivery then runs through the normal Files auth-proxy. v1 size cap: 15 MB.
import { randomUUID } from "node:crypto";
import { withTenantStorageWriteReservation } from "@hivegrid/db/storage-capacity.server";
import { buildFileStorageKeys } from "@hivegrid/storage";
import { deleteFile, putBufferAsFile, resolveStorageBucket } from "~/utils/integrations/storageService";
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
import { adapterForProvider, ensureFreshMailboxCreds, loadMailboxCreds } from "./MailboxConnectionService.server";
import type { EmailOAuthProvider } from "./oauthState.server";
export const EMAIL_ATTACHMENT_MAX_BYTES = 15 * 1024 * 1024;
export class EmailAttachmentError extends Error {
readonly code: "not_found" | "too_large" | "not_connected" | "provider_failed";
constructor(code: EmailAttachmentError["code"]) {
super(code);
this.name = "EmailAttachmentError";
this.code = code;
}
}
/** Provider-side attachment metadata for the detail view. */
export async function listProviderAttachments(input: {
tenantId: string;
mailboxId: string;
provider: EmailOAuthProvider;
providerMessageId: string;
}): Promise<Array<{ attachmentId: string; filename: string; mimeType: string | null; sizeBytes: number }>> {
const creds = await loadMailboxCreds(input.tenantId, input.mailboxId);
if (!creds) {
return [];
}
try {
const fresh = await ensureFreshMailboxCreds(input.tenantId, input.mailboxId, input.provider, creds);
return await adapterForProvider(input.provider).listAttachments(fresh, input.providerMessageId);
} catch {
return [];
}
}
/**
* Import ONE provider attachment into v1-Files and link it to the message.
* Idempotence: linkRole carries the provider attachment id — an existing link
* short-circuits to the already-imported file.
*/
export async function importAttachmentToFiles(input: {
tenantId: string;
userId: string;
mailboxId: string;
provider: EmailOAuthProvider;
messageId: string;
providerMessageId: string;
attachmentId: string;
}): Promise<{ fileId: string }> {
const existing = await withTenantRLS(input.tenantId, async (tx) => {
return tx.fileEntityLink.findFirst({
where: { tenantId: input.tenantId, entityType: "email_message", entityId: input.messageId, linkRole: `attachment:${input.attachmentId}` },
select: { fileId: true },
});
});
if (existing) {
return { fileId: existing.fileId };
}
const creds = await loadMailboxCreds(input.tenantId, input.mailboxId);
if (!creds) {
throw new EmailAttachmentError("not_connected");
}
const fresh = await ensureFreshMailboxCreds(input.tenantId, input.mailboxId, input.provider, creds);
const attachment = await adapterForProvider(input.provider)
.downloadAttachment(fresh, input.providerMessageId, input.attachmentId)
.catch(() => null);
if (!attachment) {
throw new EmailAttachmentError("not_found");
}
const body = Buffer.from(attachment.contentBase64, "base64");
if (body.byteLength > EMAIL_ATTACHMENT_MAX_BYTES) {
throw new EmailAttachmentError("too_large");
}
const fileId = `file_${randomUUID()}`;
const { original: storageKey } = buildFileStorageKeys(input.tenantId, fileId);
const bucket = resolveStorageBucket();
const mimeType = attachment.mimeType ?? "application/octet-stream";
await withTenantStorageWriteReservation({
reservation: {
tenantId: input.tenantId,
actorUserId: input.userId,
targetFileId: fileId,
originalFilename: attachment.filename.slice(0, 255) || "attachment.bin",
mimeType,
sizeBytes: body.byteLength,
},
runTransaction: (operation) => withTenantRLS(input.tenantId, operation),
upload: () => putBufferAsFile({ bucket, key: storageKey, body, contentType: mimeType }),
commit: async (tx) => {
await tx.file.create({
data: {
id: fileId,
tenantId: input.tenantId,
createdByUserId: input.userId,
displayName: attachment.filename.slice(0, 255) || "attachment.bin",
mimeType,
sizeBytes: body.byteLength,
storageKey,
source: "email_attachment",
scanStatus: "pending",
previewStatus: "pending",
},
});
await tx.fileEntityLink.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId,
fileId,
entityType: "email_message",
entityId: input.messageId,
linkRole: `attachment:${input.attachmentId}`,
},
});
},
rollbackUpload: () => deleteFile({ bucket, key: storageKey }),
});
return { fileId };
}