132 lines
5.0 KiB
TypeScript
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 };
|
|
}
|