diff --git a/apps/api/docs/media-library.md b/apps/api/docs/media-library.md index 9d3e437..8dd190d 100644 --- a/apps/api/docs/media-library.md +++ b/apps/api/docs/media-library.md @@ -268,11 +268,20 @@ This can be implemented either as a diff or by deleting all existing references Unsplash and external image URLs are ignored by media reference reconciliation because SendLit does not own or clean up those assets. -On deleting a template, broadcast, sequence, or sequence email: +Editing content never deletes media: an image removed from an email or template stays in the media library, because sent versions of that email may still show it and the user may add it back. Users delete unused images from the Media page. -1. Delete the resource's media reference rows. -2. Do not delete the media rows. -3. Do not delete MediaLit files. +On deleting a template, broadcast, sequence, or sequence email (#157): + +1. Delete the resource's media reference rows and note which media they pointed at. +2. Delete the resource. +3. Pass the noted media to `deleteOrphanedMedia` (`src/media/queries.ts`). It deletes each `media` row of the team that now has no references and that no stored sent content (`email_content_versions`) shows, then deletes the MediaLit file unless another team holds a row for the same file. + +Media used by mail that was already sent is never deleted, because inboxes and "view in browser" pages load images by URL: + +- a sequence or broadcast email that has a delivery or a stored content version keeps its media (it stays in the media library, unreferenced, until the user deletes it); +- a template a transactional email was sent with keeps its media. + +MediaLit outages never block a delete or leave a row pointing at a missing file: the `media` row is deleted first, a failed MediaLit delete goes to `captureError` and is queued on the `media-deletion` BullMQ queue (`src/media/deletion-queue.ts`, ten attempts with exponential backoff from one minute). The worker skips a file a team uses again. On deleting media from the media library: @@ -282,14 +291,12 @@ On deleting media from the media library: This prevents the case where an email derived from a template deletes an image that is still used by the original template. -On deleting a team: +On purging a team (organization deletion, and account deletion under #134): -1. List all MediaLit-backed media rows for the team. -2. Delete each underlying file from MediaLit. -3. Delete the team row. -4. Let database cascades delete the team's `Media` and `MediaReference` rows. +1. Delete every `media` row of the team (references cascade). +2. Delete each MediaLit file no other team holds a row for; failures are queued as above. -The team deletion path must perform MediaLit cleanup before the team row is deleted because database cascades cannot clean up external storage. +Archiving a team does not delete its media: an archived team can be restored. Its files go when its organization is deleted. ## Package Boundaries diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 84c2c76..4b85771 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -28,6 +28,8 @@ import { closeSendRateLimiter } from "./mail/rate-limit"; import sequenceQueue from "./mail/sequence-queue"; import sequenceWorker from "./mail/sequence-worker"; import mailWorker from "./mail/worker"; +import { closeMediaDeletionQueue } from "./media/deletion-queue"; +import mediaDeletionWorker from "./media/deletion-worker"; import { captureError, getTeamId, shutdownObservability } from "./observability"; import { startReputationSync, stopReputationSync } from "./reputation/sync"; import logger from "./services/log"; @@ -83,10 +85,12 @@ checkConfig() { name: "sequence_worker", run: () => sequenceWorker.close() }, { name: "feedback_worker", run: () => feedbackWorker.close() }, { name: "contact_deletion_worker", run: () => contactDeletionWorker.close() }, + { name: "media_deletion_worker", run: () => mediaDeletionWorker.close() }, { name: "mail_queue", run: () => mailQueue.close() }, { name: "sequence_queue", run: () => sequenceQueue.close() }, { name: "feedback_queue", run: () => feedbackQueue.close() }, { name: "contact_deletion_queue", run: () => contactDeletionQueue.close() }, + { name: "media_deletion_queue", run: closeMediaDeletionQueue }, { name: "send_rate_limiter", run: closeSendRateLimiter }, { name: "database", run: () => pool.end() }, { name: "observability", run: () => shutdownObservability(2_000) }, diff --git a/apps/api/src/mcp/tools/sequences.ts b/apps/api/src/mcp/tools/sequences.ts index 50162ae..6a48e36 100644 --- a/apps/api/src/mcp/tools/sequences.ts +++ b/apps/api/src/mcp/tools/sequences.ts @@ -365,7 +365,7 @@ export function registerSequenceTools(server: McpToolRegistrar): void { "delete_sequence_email", { description: - "Removes an email from a sequence. Broadcasts cannot have their only email removed.", + "Removes an email from a sequence. Broadcasts cannot have their only email removed. Images only this email used are deleted from the media library, unless it was already sent.", inputSchema: { sequenceId: z.string(), emailId: z.string() }, outputSchema: sequenceSchema, annotations: { diff --git a/apps/api/src/mcp/tools/templates.ts b/apps/api/src/mcp/tools/templates.ts index 7bc1dbe..42b1029 100644 --- a/apps/api/src/mcp/tools/templates.ts +++ b/apps/api/src/mcp/tools/templates.ts @@ -256,7 +256,8 @@ export function registerTemplateTools(server: McpToolRegistrar): void { server.registerTool( "delete_template", { - description: "Permanently deletes an email template.", + description: + "Permanently deletes an email template. Its images that no other template or email uses are deleted from the media library, unless a transactional email was sent with it.", inputSchema: { templateId: z.string().min(1) }, outputSchema: successMessageSchema, annotations: { diff --git a/apps/api/src/media/deletion-queue.test.ts b/apps/api/src/media/deletion-queue.test.ts new file mode 100644 index 0000000..1872492 --- /dev/null +++ b/apps/api/src/media/deletion-queue.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, mock, vi } from "bun:test"; + +const mocks = { + queueAdd: vi.fn(), + deleteMedia: vi.fn(), +}; + +mock.module("../db/client", async () => { + const { makeTestDb } = await import("../test/db"); + return { db: await makeTestDb() }; +}); +mock.module("bullmq", () => ({ + Queue: class { + add = mocks.queueAdd; + }, +})); +mock.module("./service", () => ({ deleteMedia: mocks.deleteMedia })); +const captureError = vi.fn(); +const realObservability = await import("../observability"); +mock.module("../observability", () => ({ ...realObservability, captureError })); + +const { db } = await import("../db/client"); +const { media } = await import("../db/schema"); +const { seedTeamAndContact, truncateAll } = await import("../test/db"); +type TestDb = import("../test/db").TestDb; +const { processMediaDeletionJob, queueMediaLitDeletion } = await import( + "./deletion-queue" +); +const { createUploadedMedia, deleteTeamMediaFiles } = await import("./queries"); + +const tdb = db as unknown as TestDb; + +beforeEach(async () => { + await truncateAll(tdb); + for (const fn of [...Object.values(mocks), captureError]) fn.mockReset(); +}); + +describe("media deletion queue", () => { + it("queues one job per MediaLit file", async () => { + await queueMediaLitDeletion({ teamId: "team-1", mediaLitId: "lit-1" }); + + expect(mocks.queueAdd).toHaveBeenCalledWith( + "delete-medialit-file", + { teamId: "team-1", mediaLitId: "lit-1" }, + { jobId: "medialit-lit-1", delay: 60_000 }, + ); + }); + + it("retries a failed MediaLit delete until nothing is orphaned", async () => { + const { team } = await seedTeamAndContact(tdb); + await createUploadedMedia({ + teamId: team.id, + mediaLitId: "lit-down", + url: "https://cdn.test/p/lit-down/main.webp", + }); + mocks.deleteMedia.mockRejectedValueOnce(new Error("medialit down")); + + await expect(deleteTeamMediaFiles(team.id)).resolves.toEqual({ + deleted: 1, + failed: 1, + }); + expect(captureError).toHaveBeenCalledTimes(1); + const [, data] = mocks.queueAdd.mock.calls[0]; + + // MediaLit is back: the retry deletes the file. + await expect(processMediaDeletionJob({ data })).resolves.toBe("deleted"); + expect(mocks.deleteMedia.mock.calls).toEqual([["lit-down"], ["lit-down"]]); + expect(await tdb.select().from(media)).toHaveLength(0); + }); + + it("rethrows a MediaLit failure so the job is retried", async () => { + mocks.deleteMedia.mockRejectedValueOnce(new Error("medialit down")); + + await expect( + processMediaDeletionJob({ data: { teamId: "team-1", mediaLitId: "lit-1" } }), + ).rejects.toThrow("medialit down"); + }); + + it("skips a file that is in use again", async () => { + const { team } = await seedTeamAndContact(tdb); + await createUploadedMedia({ + teamId: team.id, + mediaLitId: "lit-reused", + url: "https://cdn.test/p/lit-reused/main.webp", + }); + + await expect( + processMediaDeletionJob({ data: { teamId: team.id, mediaLitId: "lit-reused" } }), + ).resolves.toBe("in_use"); + expect(mocks.deleteMedia).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/media/deletion-queue.ts b/apps/api/src/media/deletion-queue.ts new file mode 100644 index 0000000..3ef25f8 --- /dev/null +++ b/apps/api/src/media/deletion-queue.ts @@ -0,0 +1,62 @@ +import { type Job, Queue } from "bullmq"; +import { eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { media } from "../db/schema"; +import redis from "../services/redis"; +import { deleteMedia as deleteMediaFile } from "./service"; + +export const MEDIA_DELETION_QUEUE = "media-deletion"; +const KEEP_FINISHED_SECONDS = 7 * 24 * 60 * 60; +const RETRY_DELAY_MS = 60_000; + +export type MediaDeletionJobData = { teamId: string; mediaLitId: string }; + +let queue: Queue | undefined; + +/** Retries MediaLit deletes that failed inline (#157). Created on first use + * so importing the media queries never opens a Redis connection. Ten + * attempts with exponential backoff from a minute cover about 17 hours of + * MediaLit downtime; a final failure goes to `captureError` through the + * worker's events. */ +function mediaDeletionQueue(): Queue { + queue ??= new Queue(MEDIA_DELETION_QUEUE, { + connection: redis, + defaultJobOptions: { + removeOnComplete: { age: KEEP_FINISHED_SECONDS }, + removeOnFail: { age: KEEP_FINISHED_SECONDS }, + attempts: 10, + backoff: { type: "exponential", delay: RETRY_DELAY_MS }, + }, + }); + return queue; +} + +/** Queues a MediaLit file for deletion, first tried a minute from now (the + * inline attempt just failed). One job per file: queueing the same file + * twice is a no-op. */ +export async function queueMediaLitDeletion(data: MediaDeletionJobData): Promise { + await mediaDeletionQueue().add("delete-medialit-file", data, { + jobId: `medialit-${data.mediaLitId}`, + delay: RETRY_DELAY_MS, + }); +} + +/** Closes the queue's Redis connection, if it was ever opened. */ +export async function closeMediaDeletionQueue(): Promise { + await queue?.close(); +} + +/** Deletes a queued file, unless a team uploaded or referenced it again + * since it was queued (a `media` row for it exists again). */ +export async function processMediaDeletionJob( + job: Pick, "data">, +): Promise<"deleted" | "in_use"> { + const [row] = await db + .select({ id: media.id }) + .from(media) + .where(eq(media.mediaLitId, job.data.mediaLitId)) + .limit(1); + if (row) return "in_use"; + await deleteMediaFile(job.data.mediaLitId); + return "deleted"; +} diff --git a/apps/api/src/media/deletion-worker.ts b/apps/api/src/media/deletion-worker.ts new file mode 100644 index 0000000..cf13350 --- /dev/null +++ b/apps/api/src/media/deletion-worker.ts @@ -0,0 +1,17 @@ +import { Worker } from "bullmq"; +import { registerWorkerEvents, workerOptions } from "../mail/worker-options"; +import { + MEDIA_DELETION_QUEUE, + type MediaDeletionJobData, + processMediaDeletionJob, +} from "./deletion-queue"; + +const worker = new Worker( + MEDIA_DELETION_QUEUE, + (job) => processMediaDeletionJob(job), + { ...workerOptions, concurrency: 2 }, +); + +registerWorkerEvents(worker, MEDIA_DELETION_QUEUE); + +export default worker; diff --git a/apps/api/src/media/queries.test.ts b/apps/api/src/media/queries.test.ts index 1f62f68..d35fb52 100644 --- a/apps/api/src/media/queries.test.ts +++ b/apps/api/src/media/queries.test.ts @@ -7,13 +7,21 @@ mock.module("../db/client", async () => { mock.module("./service", () => ({ deleteMedia: vi.fn(), })); +const queueMediaLitDeletion = vi.fn(); +mock.module("./deletion-queue", () => ({ queueMediaLitDeletion })); +const captureError = vi.fn(); +const realObservability = await import("../observability"); +mock.module("../observability", () => ({ ...realObservability, captureError })); const { db } = await import("../db/client"); -const { media, mediaReferences } = await import("../db/schema"); +const { emailContentVersions, media, mediaReferences, sequenceEmails, sequences } = + await import("../db/schema"); const { seedTeamAndContact, truncateAll } = await import("../test/db"); type TestDb = import("../test/db").TestDb; const { createUploadedMedia, + deleteMediaReferencesForResource, + deleteOrphanedMedia, deleteTeamMediaFiles, deleteUnusedMedia, listMediaReferences, @@ -155,7 +163,7 @@ describe("media queries", () => { ]); }); - it("keeps the media row of a file MediaLit fails to delete", async () => { + it("queues a file MediaLit fails to delete and still deletes its row", async () => { const { team } = await seedTeamAndContact(tdb); await createUploadedMedia({ teamId: team.id, @@ -174,12 +182,177 @@ describe("media queries", () => { ); await expect(deleteTeamMediaFiles(team.id)).resolves.toEqual({ - deleted: 1, + deleted: 2, failed: 1, }); - expect((await tdb.select().from(media)).map((item) => item.mediaLitId)).toEqual([ - "media-lit-fails", - ]); + expect(await tdb.select().from(media)).toHaveLength(0); + expect(queueMediaLitDeletion).toHaveBeenCalledWith({ + teamId: team.id, + mediaLitId: "media-lit-fails", + }); + expect(captureError).toHaveBeenCalledWith( + expect.objectContaining({ + source: "media.delete_team_files", + context: { error_code: "medialit_delete_failed" }, + }), + ); (deleteMediaFile as ReturnType).mockReset(); }); + + it("keeps a file another team also uses when purging a team", async () => { + const { team } = await seedTeamAndContact(tdb); + const { team: otherTeam } = await seedTeamAndContact(tdb); + for (const teamId of [team.id, otherTeam.id]) { + await createUploadedMedia({ + teamId, + mediaLitId: "media-lit-both", + url: "https://cdn.test/p/media-lit-both/main.webp", + }); + } + + await expect(deleteTeamMediaFiles(team.id)).resolves.toEqual({ + deleted: 1, + failed: 0, + }); + expect(deleteMediaFile).not.toHaveBeenCalled(); + const rows = await tdb.select().from(media); + expect(rows.map((row) => row.teamId)).toEqual([otherTeam.id]); + }); +}); + +describe("deleteOrphanedMedia", () => { + async function referencedMedia(teamId: string, mediaLitId: string) { + const row = await createUploadedMedia({ + teamId, + mediaLitId, + url: `https://cdn.test/p/${mediaLitId}/main.webp`, + }); + const resourceInternalId = crypto.randomUUID(); + await replaceMediaReferencesForResource({ + teamId, + resource: { + resourceType: "TEMPLATE", + resourceInternalId, + resourcePublicId: "tpl_1", + }, + mediaRows: [row], + }); + return { row, resourceInternalId }; + } + + it("deletes only media left without references", async () => { + const { team } = await seedTeamAndContact(tdb); + const released = await referencedMedia(team.id, "media-lit-released"); + const kept = await referencedMedia(team.id, "media-lit-kept"); + + const mediaIds = await deleteMediaReferencesForResource({ + teamId: team.id, + resourceType: "TEMPLATE", + resourceInternalId: released.resourceInternalId, + }); + expect(mediaIds).toEqual([released.row.id]); + + await expect( + deleteOrphanedMedia(team.id, [released.row.id, kept.row.id]), + ).resolves.toEqual({ deleted: 1, failed: 0 }); + expect((deleteMediaFile as ReturnType).mock.calls).toEqual([ + ["media-lit-released"], + ]); + expect((await tdb.select().from(media)).map((row) => row.mediaLitId)).toEqual([ + "media-lit-kept", + ]); + }); + + it("keeps media that sent mail shows", async () => { + const { team } = await seedTeamAndContact(tdb); + const { row, resourceInternalId } = await referencedMedia( + team.id, + "media-lit-sent", + ); + const [sequence] = await tdb + .insert(sequences) + .values({ teamId: team.id, type: "broadcast" }) + .returning(); + const content = { + content: [ + { + blockType: "image", + settings: { src: "https://cdn.test/p/media-lit-sent/main.webp" }, + }, + ], + }; + const [email] = await tdb + .insert(sequenceEmails) + .values({ sequenceId: sequence.id, subject: "Sent", content }) + .returning(); + await tdb.insert(emailContentVersions).values({ + teamId: team.id, + sequenceEmailId: email.id, + contentHash: "hash", + subject: "Sent", + content, + }); + await deleteMediaReferencesForResource({ + teamId: team.id, + resourceType: "TEMPLATE", + resourceInternalId, + }); + + await expect(deleteOrphanedMedia(team.id, [row.id])).resolves.toEqual({ + deleted: 0, + failed: 0, + }); + expect(deleteMediaFile).not.toHaveBeenCalled(); + expect(await tdb.select().from(media)).toHaveLength(1); + }); + + it("ignores another team's media ids", async () => { + const { team } = await seedTeamAndContact(tdb); + const { team: otherTeam } = await seedTeamAndContact(tdb); + const other = await createUploadedMedia({ + teamId: otherTeam.id, + mediaLitId: "media-lit-other-team", + url: "https://cdn.test/p/media-lit-other-team/main.webp", + }); + + await expect(deleteOrphanedMedia(team.id, [other.id])).resolves.toEqual({ + deleted: 0, + failed: 0, + }); + expect(deleteMediaFile).not.toHaveBeenCalled(); + expect(await tdb.select().from(media)).toHaveLength(1); + }); + + it("queues the MediaLit delete when MediaLit fails, leaving no row behind", async () => { + const { team } = await seedTeamAndContact(tdb); + const { row, resourceInternalId } = await referencedMedia( + team.id, + "media-lit-down", + ); + await deleteMediaReferencesForResource({ + teamId: team.id, + resourceType: "TEMPLATE", + resourceInternalId, + }); + (deleteMediaFile as ReturnType).mockRejectedValueOnce( + new Error("medialit down"), + ); + + await expect(deleteOrphanedMedia(team.id, [row.id])).resolves.toEqual({ + deleted: 1, + failed: 1, + }); + expect(await tdb.select().from(media)).toHaveLength(0); + expect(queueMediaLitDeletion).toHaveBeenCalledWith({ + teamId: team.id, + mediaLitId: "media-lit-down", + }); + expect(captureError).toHaveBeenCalledWith( + expect.objectContaining({ + source: "media.delete_orphaned", + teamId: team.id, + context: { error_code: "medialit_delete_failed" }, + }), + ); + }); }); diff --git a/apps/api/src/media/queries.ts b/apps/api/src/media/queries.ts index 71eea96..9e83462 100644 --- a/apps/api/src/media/queries.ts +++ b/apps/api/src/media/queries.ts @@ -1,7 +1,8 @@ -import { and, count, desc, eq, ilike, or } from "drizzle-orm"; +import { and, count, desc, eq, ilike, inArray, or, sql } from "drizzle-orm"; import { db } from "../db/client"; -import { media, mediaReferences } from "../db/schema"; +import { emailContentVersions, media, mediaReferences } from "../db/schema"; import { captureError } from "../observability"; +import { queueMediaLitDeletion } from "./deletion-queue"; import { deleteMedia as deleteMediaFile } from "./service"; export type Media = typeof media.$inferSelect; @@ -235,6 +236,9 @@ export async function replaceMediaReferencesForResource({ ); } +/** Removes the media references of one or more resources of a type and + * returns the ids of the `media` rows they pointed at, so the caller can + * pass them to `deleteOrphanedMedia` once the resource itself is gone. */ export async function deleteMediaReferencesForResource({ teamId, resourceType, @@ -242,17 +246,131 @@ export async function deleteMediaReferencesForResource({ }: { teamId: string; resourceType: MediaReferenceResourceType; - resourceInternalId: string; -}): Promise { - await db + resourceInternalId: string | string[]; +}): Promise { + const ids = Array.isArray(resourceInternalId) + ? resourceInternalId + : [resourceInternalId]; + if (!ids.length) return []; + const removed = await db .delete(mediaReferences) .where( and( eq(mediaReferences.teamId, teamId), eq(mediaReferences.resourceType, resourceType), - eq(mediaReferences.resourceInternalId, resourceInternalId), + inArray(mediaReferences.resourceInternalId, ids), + ), + ) + .returning({ mediaId: mediaReferences.mediaId }); + return Array.from(new Set(removed.map((row) => row.mediaId))); +} + +export interface MediaPurgeResult { + /** Media rows deleted (their MediaLit file deleted or queued). */ + deleted: number; + /** Of those, files MediaLit failed to delete now; queued for retry. */ + failed: number; +} + +/** + * Deletes a MediaLit file whose last `media` row is gone. Another team can + * hold a row for the same file (team isolation): then only the row went + * and the file stays. When MediaLit fails, the delete is queued for retry + * and the error goes to `captureError`; it never fails the caller. + */ +async function deleteMediaLitFile( + teamId: string, + mediaLitId: string, + source: string, +): Promise<"deleted" | "shared" | "queued"> { + const [other] = await db + .select({ id: media.id }) + .from(media) + .where(eq(media.mediaLitId, mediaLitId)) + .limit(1); + if (other) return "shared"; + try { + await deleteMediaFile(mediaLitId); + return "deleted"; + } catch (error) { + captureError({ + error, + source, + teamId, + context: { error_code: "medialit_delete_failed" }, + }); + try { + await queueMediaLitDeletion({ teamId, mediaLitId }); + } catch (queueError) { + captureError({ + error: queueError, + source, + teamId, + context: { error_code: "medialit_delete_queue_failed" }, + }); + } + return "queued"; + } +} + +/** Whether mail the team already sent shows this file. Sent content is + * kept in `email_content_versions` (what each recipient got and what the + * "view in browser" page shows), and inboxes load images by URL. */ +async function usedBySentMail(teamId: string, mediaLitId: string): Promise { + const [row] = await db + .select({ id: emailContentVersions.id }) + .from(emailContentVersions) + .where( + and( + eq(emailContentVersions.teamId, teamId), + sql`strpos(${emailContentVersions.content}::text, ${`/${mediaLitId}/`}) > 0`, ), + ) + .limit(1); + return Boolean(row); +} + +/** + * The shared cleanup after a resource's media references are removed + * (#157): deletes each given `media` row of the team that now has no + * references and isn't shown by sent mail, along with its MediaLit file. + * Rows still referenced elsewhere, or used by sent mail, stay in the media + * library. Never touches another team's rows. + */ +export async function deleteOrphanedMedia( + teamId: string, + mediaIds: string[], +): Promise { + const result: MediaPurgeResult = { deleted: 0, failed: 0 }; + if (!mediaIds.length) return result; + + const rows = await db + .select({ id: media.id, mediaLitId: media.mediaLitId }) + .from(media) + .where(and(eq(media.teamId, teamId), inArray(media.id, mediaIds))); + for (const row of rows) { + const [referenced] = await db + .select({ id: mediaReferences.id }) + .from(mediaReferences) + .where(eq(mediaReferences.mediaId, row.id)) + .limit(1); + if (referenced) continue; + if (await usedBySentMail(teamId, row.mediaLitId)) continue; + + const [removed] = await db + .delete(media) + .where(and(eq(media.teamId, teamId), eq(media.id, row.id))) + .returning({ id: media.id }); + if (!removed) continue; + result.deleted += 1; + const outcome = await deleteMediaLitFile( + teamId, + row.mediaLitId, + "media.delete_orphaned", ); + if (outcome === "queued") result.failed += 1; + } + return result; } export async function deleteUnusedMedia( @@ -275,34 +393,25 @@ export async function deleteUnusedMedia( return "deleted"; } -/** Deletes every MediaLit file of a team and its `media` rows (their - * references cascade). A file MediaLit fails to delete keeps its row so a - * later cleanup can retry it (#157); the error goes to `captureError`. */ -export async function deleteTeamMediaFiles( - teamId: string, -): Promise<{ deleted: number; failed: number }> { +/** Deletes every `media` row of a team (their references cascade) and + * its MediaLit files, for team purges (organization and account deletion). + * Files another team also uses are kept. A file MediaLit fails to delete is + * queued for retry (`failed` counts them) and the error goes to + * `captureError`, so the database never points at a missing file. */ +export async function deleteTeamMediaFiles(teamId: string): Promise { const rows = await db - .select({ id: media.id, mediaLitId: media.mediaLitId }) - .from(media) - .where(eq(media.teamId, teamId)); + .delete(media) + .where(eq(media.teamId, teamId)) + .returning({ mediaLitId: media.mediaLitId }); - let deleted = 0; - let failed = 0; + const result: MediaPurgeResult = { deleted: rows.length, failed: 0 }; for (const row of rows) { - try { - await deleteMediaFile(row.mediaLitId); - } catch (error) { - failed += 1; - captureError({ - error, - source: "media.delete_team_files", - teamId, - context: { error_code: "medialit_delete_failed" }, - }); - continue; - } - await db.delete(media).where(and(eq(media.teamId, teamId), eq(media.id, row.id))); - deleted += 1; + const outcome = await deleteMediaLitFile( + teamId, + row.mediaLitId, + "media.delete_team_files", + ); + if (outcome === "queued") result.failed += 1; } - return { deleted, failed }; + return result; } diff --git a/apps/api/src/observability/instance.ts b/apps/api/src/observability/instance.ts index 7eb3ba2..2c19357 100644 --- a/apps/api/src/observability/instance.ts +++ b/apps/api/src/observability/instance.ts @@ -75,6 +75,8 @@ const PROPERTY_ALLOWLIST = new Set([ // Organization deletion (organization/delete.ts): counts only. "teams_count", "media_failed_count", + // Media cleanup on resource deletes (media/queries.ts deleteOrphanedMedia). + "media_deleted_count", // Bulk contact deletion (contacts/bulk-delete.ts): counts only. "suppressed_count", ]); diff --git a/apps/api/src/organization/delete.routes.test.ts b/apps/api/src/organization/delete.routes.test.ts index cca9c9a..0349809 100644 --- a/apps/api/src/organization/delete.routes.test.ts +++ b/apps/api/src/organization/delete.routes.test.ts @@ -19,6 +19,8 @@ mock.module("../auth/middleware", () => ({ }, })); mock.module("../media/service", () => ({ deleteMedia: vi.fn() })); +const queueMediaLitDeletion = vi.fn(); +mock.module("../media/deletion-queue", () => ({ queueMediaLitDeletion })); const realSecurity = await import("../billing/security"); // The billing-action token is covered in billing/security.test.ts. mock.module("../billing/security", () => ({ @@ -166,6 +168,7 @@ async function organizationStatus(id: string) { beforeEach(async () => { await truncateAll(tdb); deleteMediaMock.mockReset(); + queueMediaLitDeletion.mockReset(); captureEvent.mockReset(); authState.userId = ""; authState.authKind = "session"; @@ -273,7 +276,7 @@ describe("DELETE /organizations/:organizationId", () => { } }); - it("still deletes when MediaLit fails, keeping the failed file's row", async () => { + it("still deletes when MediaLit fails, queueing the failed file for retry", async () => { const owner = await insertUser("Owner"); const { organization, mediaLitIds } = await seedOrganization(owner, "acme"); deleteMediaMock.mockImplementation(async (id: string) => { @@ -287,8 +290,10 @@ describe("DELETE /organizations/:organizationId", () => { expect(response.status).toBe(204); expect(await organizationStatus(organization.id)).toBe("closed"); - const remaining = await tdb.select().from(schema.media); - expect(remaining.map((row) => row.mediaLitId)).toEqual([mediaLitIds[0]]); + expect(await tdb.select().from(schema.media)).toHaveLength(0); + expect(queueMediaLitDeletion.mock.calls.map(([job]) => job.mediaLitId)).toEqual([ + mediaLitIds[0], + ]); }); it("is owner only", async () => { diff --git a/apps/api/src/organization/delete.ts b/apps/api/src/organization/delete.ts index 9f21fea..6d6c609 100644 --- a/apps/api/src/organization/delete.ts +++ b/apps/api/src/organization/delete.ts @@ -31,9 +31,9 @@ export interface OrganizationDeletionResult { * revoked and ESP grants cancelled (`closeOrganization`); * - team API keys are revoked, sending domains revoked, and every ESP * config (shared and team-owned) retired with its credentials wiped; - * - every MediaLit file of its teams is deleted. A MediaLit failure does not - * undo the deletion: that file's `media` row stays for a later retry (#157) - * and the error goes to `captureError`. + * - every `media` row of its teams is deleted with its MediaLit file. A + * MediaLit failure does not undo the deletion: the file is queued for + * retry (`media/deletion-queue.ts`) and the error goes to `captureError`. */ export async function deleteOrganization( organizationId: string, diff --git a/apps/api/src/sequences/queries.test.ts b/apps/api/src/sequences/queries.test.ts index 8ec243f..1c9f926 100644 --- a/apps/api/src/sequences/queries.test.ts +++ b/apps/api/src/sequences/queries.test.ts @@ -15,12 +15,14 @@ mock.module("../media/service", () => ({ ), deleteMedia: vi.fn(), })); +mock.module("../media/deletion-queue", () => ({ queueMediaLitDeletion: vi.fn() })); const { EmailEventAction, EventType } = await import("../config/constants"); const { responses } = await import("../config/strings"); const { db } = await import("../db/client"); const { contacts, + emailContentVersions, emailDeliveries, emailEvents, espConfigs, @@ -667,77 +669,169 @@ describe("sequence queries", () => { ).toHaveLength(0); }); - it("removes only media references when a sequence email is deleted", async () => { - const { team } = await seedTeamAndContact(tdb); - const template = await makeTemplate(team.id); - const sequence = await createSequence({ - teamId: team.id, - type: "sequence", - templateId: template.templateId, - }); - const withSecondEmail = await addMailToSequence({ - teamId: team.id, - sequenceId: sequence.sequenceId, + async function sequenceWithImages( + teamId: string, + type: "sequence" | "broadcast", + images: string[], + ) { + const template = await makeTemplate(teamId); + let sequence = await createSequence({ + teamId, + type, templateId: template.templateId, }); - const emailToDelete = withSecondEmail!.emails[0]; + for (let index = 1; index < images.length; index++) { + sequence = (await addMailToSequence({ + teamId, + sequenceId: sequence.sequenceId, + templateId: template.templateId, + }))!; + } + for (const [index, image] of images.entries()) { + await updateMailInSequence({ + teamId, + sequenceId: sequence.sequenceId, + emailId: sequence.emails[index].emailId, + content: emailWithImage(image), + }); + } + vi.clearAllMocks(); + return (await getSequenceBySequenceId(teamId, sequence.sequenceId))!; + } + + /** Records that an email went out, the way a send does. */ + async function markSent(teamId: string, email: { id: string; content: unknown }) { + await tdb.insert(emailContentVersions).values({ + teamId, + sequenceEmailId: email.id, + contentHash: crypto.randomUUID(), + subject: "Sent", + content: email.content, + }); + } + + function deletedMediaLitIds() { + return (deleteMedia as ReturnType).mock.calls + .map(([id]) => id) + .sort(); + } + + async function remainingMediaLitIds() { + return (await tdb.select().from(media)).map((row) => row.mediaLitId).sort(); + } + + it("deletes a deleted sequence email's media and keeps media another email uses", async () => { + const { team } = await seedTeamAndContact(tdb); + const sequence = await sequenceWithImages(team.id, "sequence", [ + "email-only-media", + "email-shared-media", + ]); + // The second email also shows the first email's shared image. await updateMailInSequence({ teamId: team.id, sequenceId: sequence.sequenceId, - emailId: emailToDelete.emailId, - content: emailWithImage("sequence-email-deleted-media"), + emailId: sequence.emails[0].emailId, + content: (() => { + const content = emailWithImage("email-only-media"); + const shared = emailWithImage("email-shared-media").content.find( + (block) => block.blockType === "image", + )!; + return { + ...content, + content: [...content.content.slice(0, -1), shared, content.content.at(-1)!], + }; + })(), }); vi.clearAllMocks(); await deleteMailFromSequence({ teamId: team.id, sequenceId: sequence.sequenceId, - emailId: emailToDelete.emailId, + emailId: sequence.emails[0].emailId, }); - expect(deleteMedia).not.toHaveBeenCalled(); + expect(deletedMediaLitIds()).toEqual(["email-only-media"]); expect(sealMedia).not.toHaveBeenCalled(); - expect(await tdb.select().from(media)).toHaveLength(1); + expect(await remainingMediaLitIds()).toEqual(["email-shared-media"]); expect( await tdb .select() .from(mediaReferences) - .where(eq(mediaReferences.resourceInternalId, emailToDelete.id)), + .where(eq(mediaReferences.resourceInternalId, sequence.emails[0].id)), ).toHaveLength(0); }); - it("removes only media references when a broadcast is deleted", async () => { + it("deletes the media of every email of a deleted sequence, keeping shared media", async () => { const { team } = await seedTeamAndContact(tdb); - const template = await makeTemplate(team.id); - const broadcast = await createSequence({ + await createTemplate({ teamId: team.id, - type: "broadcast", - templateId: template.templateId, + title: "Uses the shared image", + content: emailWithImage("sequence-shared-media"), }); - await updateMailInSequence({ - teamId: team.id, - sequenceId: broadcast.sequenceId, - emailId: broadcast.emails[0].emailId, - content: emailWithImage("broadcast-deleted-media"), - }); - vi.clearAllMocks(); + const sequence = await sequenceWithImages(team.id, "sequence", [ + "sequence-media-1", + "sequence-media-2", + "sequence-shared-media", + ]); await expect( - deleteSequence({ - teamId: team.id, - sequenceId: broadcast.sequenceId, - }), + deleteSequence({ teamId: team.id, sequenceId: sequence.sequenceId }), ).resolves.toBe(true); - expect(deleteMedia).not.toHaveBeenCalled(); + expect(deletedMediaLitIds()).toEqual(["sequence-media-1", "sequence-media-2"]); + expect(await remainingMediaLitIds()).toEqual(["sequence-shared-media"]); + }); + + it("deletes a draft broadcast's media", async () => { + const { team } = await seedTeamAndContact(tdb); + const broadcast = await sequenceWithImages(team.id, "broadcast", [ + "broadcast-deleted-media", + ]); + + await expect( + deleteSequence({ teamId: team.id, sequenceId: broadcast.sequenceId }), + ).resolves.toBe(true); + + expect(deletedMediaLitIds()).toEqual(["broadcast-deleted-media"]); expect(sealMedia).not.toHaveBeenCalled(); - expect(await tdb.select().from(media)).toHaveLength(1); - expect( - await tdb - .select() - .from(mediaReferences) - .where(eq(mediaReferences.resourceInternalId, broadcast.emails[0].id)), - ).toHaveLength(0); + expect(await remainingMediaLitIds()).toEqual([]); + expect(await tdb.select().from(mediaReferences)).toHaveLength(0); expect(await getSequenceBySequenceId(team.id, broadcast.sequenceId)).toBeNull(); }); + + it("keeps the media of a sent broadcast", async () => { + const { team } = await seedTeamAndContact(tdb); + const broadcast = await sequenceWithImages(team.id, "broadcast", [ + "broadcast-sent-media", + ]); + await markSent(team.id, broadcast.emails[0]); + + await deleteSequence({ teamId: team.id, sequenceId: broadcast.sequenceId }); + + expect(deleteMedia).not.toHaveBeenCalled(); + expect(await remainingMediaLitIds()).toEqual(["broadcast-sent-media"]); + }); + + it("keeps media a sent sequence email shows when a draft email is deleted", async () => { + const { team } = await seedTeamAndContact(tdb); + const sequence = await sequenceWithImages(team.id, "sequence", [ + "sequence-sent-media", + "sequence-sent-media", + "sequence-draft-media", + ]); + await markSent(team.id, sequence.emails[0]); + + // The draft copy of the sent image goes, the sent email still shows it. + await deleteMailFromSequence({ + teamId: team.id, + sequenceId: sequence.sequenceId, + emailId: sequence.emails[1].emailId, + }); + expect(deleteMedia).not.toHaveBeenCalled(); + + await deleteSequence({ teamId: team.id, sequenceId: sequence.sequenceId }); + + expect(deletedMediaLitIds()).toEqual(["sequence-draft-media"]); + expect(await remainingMediaLitIds()).toEqual(["sequence-sent-media"]); + }); }); diff --git a/apps/api/src/sequences/queries.ts b/apps/api/src/sequences/queries.ts index 7643ae3..e7f6704 100644 --- a/apps/api/src/sequences/queries.ts +++ b/apps/api/src/sequences/queries.ts @@ -29,6 +29,7 @@ import { import { db } from "../db/client"; import { contacts, + emailContentVersions, emailDeliveries, emailEvents, espConfigs, @@ -41,7 +42,10 @@ import { resolveDeliverySource, } from "../delivery/queries"; import { syncEmailContentMediaReferences } from "../media/email-content"; -import { deleteMediaReferencesForResource } from "../media/queries"; +import { + deleteMediaReferencesForResource, + deleteOrphanedMedia, +} from "../media/queries"; import { captureEvent } from "../observability"; import { assertMailingAddressConfigured } from "../settings/general/queries"; import { getTeam } from "../team/queries"; @@ -613,6 +617,47 @@ export async function updateMailInSequence({ return getSequenceBySequenceId(teamId, sequenceId); } +/** + * Removes the media references of sequence emails about to be deleted and + * returns the media rows to pass to `deleteOrphanedMedia` after the delete. + * Media of an email that was already sent (it has a delivery or a stored + * content version) is left out: inboxes and "view in browser" pages load + * its images by URL, so it stays in the media library until the user + * deletes it there. + */ +async function releaseEmailMedia( + teamId: string, + emailIds: string[], +): Promise { + if (!emailIds.length) return []; + const [delivered, versioned] = await Promise.all([ + db + .selectDistinct({ id: emailDeliveries.emailId }) + .from(emailDeliveries) + .where(inArray(emailDeliveries.emailId, emailIds)), + db + .selectDistinct({ id: emailContentVersions.sequenceEmailId }) + .from(emailContentVersions) + .where(inArray(emailContentVersions.sequenceEmailId, emailIds)), + ]); + const sentIds = new Set([...delivered, ...versioned].map((row) => row.id)); + const draftIds = emailIds.filter((id) => !sentIds.has(id)); + + const kept = new Set( + await deleteMediaReferencesForResource({ + teamId, + resourceType: "SEQUENCE_EMAIL", + resourceInternalId: [...sentIds], + }), + ); + const released = await deleteMediaReferencesForResource({ + teamId, + resourceType: "SEQUENCE_EMAIL", + resourceInternalId: draftIds, + }); + return released.filter((id) => !kept.has(id)); +} + export async function deleteMailFromSequence({ teamId, sequenceId, @@ -632,13 +677,7 @@ export async function deleteMailFromSequence({ } const email = sequence.emails.find((item) => item.emailId === emailId); - if (email) { - await deleteMediaReferencesForResource({ - teamId, - resourceType: "SEQUENCE_EMAIL", - resourceInternalId: email.id, - }); - } + const mediaIds = email ? await releaseEmailMedia(teamId, [email.id]) : []; await db .delete(sequenceEmails) @@ -655,6 +694,7 @@ export async function deleteMailFromSequence({ emailsOrder: sequence.emailsOrder.filter((id) => id !== emailId), }) .where(eq(sequences.id, sequence.id)); + const media = await deleteOrphanedMedia(teamId, mediaIds); captureEvent({ event: "sequence_email_deleted", @@ -664,6 +704,7 @@ export async function deleteMailFromSequence({ sequence_id: sequence.sequenceId, sequence_type: sequence.type, email_id: emailId, + media_deleted_count: media.deleted, }, }); @@ -680,17 +721,15 @@ export async function deleteSequence({ const sequence = await getSequenceBySequenceId(teamId, sequenceId); if (!sequence) return false; - for (const email of sequence.emails) { - await deleteMediaReferencesForResource({ - teamId, - resourceType: "SEQUENCE_EMAIL", - resourceInternalId: email.id, - }); - } + const mediaIds = await releaseEmailMedia( + teamId, + sequence.emails.map((email) => email.id), + ); await db .delete(sequences) .where(and(eq(sequences.teamId, teamId), eq(sequences.sequenceId, sequenceId))); + const media = await deleteOrphanedMedia(teamId, mediaIds); captureEvent({ event: "sequence_deleted", @@ -698,6 +737,7 @@ export async function deleteSequence({ teamId, properties: { sequence_id: sequence.sequenceId, + media_deleted_count: media.deleted, sequence_type: sequence.type, }, }); diff --git a/apps/api/src/templates/queries.test.ts b/apps/api/src/templates/queries.test.ts index 85b57a7..c3b3f08 100644 --- a/apps/api/src/templates/queries.test.ts +++ b/apps/api/src/templates/queries.test.ts @@ -16,9 +16,11 @@ mock.module("../media/service", () => ({ ), deleteMedia: vi.fn(), })); +mock.module("../media/deletion-queue", () => ({ queueMediaLitDeletion: vi.fn() })); const { db } = await import("../db/client"); -const { emailTemplates, media, mediaReferences } = await import("../db/schema"); +const { emailTemplates, espConfigs, media, mediaReferences, transactionalEmails } = + await import("../db/schema"); const { deleteMedia, sealMedia } = await import("../media/service"); const { seedTeamAndContact, truncateAll } = await import("../test/db"); type TestDb = import("../test/db").TestDb; @@ -339,7 +341,7 @@ describe("template queries", () => { ).toHaveLength(0); }); - it("removes only template media references when a template is deleted", async () => { + it("deletes a deleted template's media from MediaLit and the media table", async () => { const { team } = await seedTeamAndContact(tdb); const template = await createTemplate({ teamId: team.id, @@ -350,14 +352,93 @@ describe("template queries", () => { await deleteTemplate(team.id, template.templateId); - expect(deleteMedia).not.toHaveBeenCalled(); + expect((deleteMedia as ReturnType).mock.calls).toEqual([ + ["template-deleted-media"], + ]); expect(sealMedia).not.toHaveBeenCalled(); + expect(await tdb.select().from(media)).toHaveLength(0); + expect(await tdb.select().from(mediaReferences)).toHaveLength(0); + }); + + it("keeps media another template still uses", async () => { + const { team } = await seedTeamAndContact(tdb); + const first = await createTemplate({ + teamId: team.id, + title: "First", + content: emailWithImage("template-shared-media"), + }); + const second = await createTemplate({ + teamId: team.id, + title: "Second", + content: emailWithImage("template-shared-media"), + }); + vi.clearAllMocks(); + + await deleteTemplate(team.id, first.templateId); + + expect(deleteMedia).not.toHaveBeenCalled(); expect(await tdb.select().from(media)).toHaveLength(1); - expect( - await tdb - .select() - .from(mediaReferences) - .where(eq(mediaReferences.resourceInternalId, template.id)), - ).toHaveLength(0); + const references = await tdb.select().from(mediaReferences); + expect(references.map((row) => row.resourceInternalId)).toEqual([second.id]); + }); + + it("keeps the media of a template a transactional email was sent with", async () => { + const { team } = await seedTeamAndContact(tdb); + const template = await createTemplate({ + teamId: team.id, + title: "Receipt", + content: emailWithImage("template-sent-media"), + }); + const [esp] = await tdb + .insert(espConfigs) + .values({ + ownerScope: "team", + teamId: team.id, + name: "Default ESP", + host: "smtp.example.com", + fromName: "Test Sender", + fromEmail: "sender@example.com", + status: "active", + }) + .returning(); + await tdb.insert(transactionalEmails).values({ + teamId: team.id, + deliverySourceType: "team", + outboxId: esp.id, + toEmail: "reader@example.com", + subject: "Receipt", + templateId: template.templateId, + html: '', + status: "sent", + }); + vi.clearAllMocks(); + + await deleteTemplate(team.id, template.templateId); + + expect(deleteMedia).not.toHaveBeenCalled(); + expect(await tdb.select().from(media)).toHaveLength(1); + expect(await tdb.select().from(mediaReferences)).toHaveLength(0); + }); + + it("never deletes another team's media for the same file", async () => { + const { team } = await seedTeamAndContact(tdb); + const { team: otherTeam } = await seedTeamAndContact(tdb); + const template = await createTemplate({ + teamId: team.id, + title: "Ours", + content: emailWithImage("cross-team-media"), + }); + await createTemplate({ + teamId: otherTeam.id, + title: "Theirs", + content: emailWithImage("cross-team-media"), + }); + vi.clearAllMocks(); + + await deleteTemplate(team.id, template.templateId); + + expect(deleteMedia).not.toHaveBeenCalled(); + const rows = await tdb.select().from(media); + expect(rows.map((row) => row.teamId)).toEqual([otherTeam.id]); }); }); diff --git a/apps/api/src/templates/queries.ts b/apps/api/src/templates/queries.ts index efef719..9e3bbda 100644 --- a/apps/api/src/templates/queries.ts +++ b/apps/api/src/templates/queries.ts @@ -2,9 +2,12 @@ import type { TemplatePurpose } from "@sendlit/api-contract"; import type { Email as EmailContent } from "@sendlit/email-editor"; import { and, eq, ne } from "drizzle-orm"; import { db } from "../db/client"; -import { emailTemplates } from "../db/schema"; +import { emailTemplates, transactionalEmails } from "../db/schema"; import { syncEmailContentMediaReferences } from "../media/email-content"; -import { deleteMediaReferencesForResource } from "../media/queries"; +import { + deleteMediaReferencesForResource, + deleteOrphanedMedia, +} from "../media/queries"; import { captureEvent } from "../observability"; import { getSystemTemplate } from "./system-templates"; import { @@ -333,8 +336,9 @@ export async function deleteTemplate( .from(emailTemplates) .where(eq(emailTemplates.templateId, templateId)) .limit(1); + let mediaIds: string[] = []; if (template && template.teamId === teamId) { - await deleteMediaReferencesForResource({ + mediaIds = await deleteMediaReferencesForResource({ teamId, resourceType: "TEMPLATE", resourceInternalId: template.id, @@ -346,10 +350,26 @@ export async function deleteTemplate( .where( and(eq(emailTemplates.teamId, teamId), eq(emailTemplates.templateId, templateId)), ); + // Broadcasts and sequences copy a template's content (their emails hold + // their own references), but transactional sends render the template + // itself: once one went out, inboxes load its images, so they stay. + const [sent] = mediaIds.length + ? await db + .select({ id: transactionalEmails.id }) + .from(transactionalEmails) + .where( + and( + eq(transactionalEmails.teamId, teamId), + eq(transactionalEmails.templateId, templateId), + ), + ) + .limit(1) + : []; + const media = await deleteOrphanedMedia(teamId, sent ? [] : mediaIds); captureEvent({ event: "template_deleted", source: "templates.delete", teamId, - properties: { template_id: templateId }, + properties: { template_id: templateId, media_deleted_count: media.deleted }, }); } diff --git a/apps/docs/content/docs/workspace/media.mdx b/apps/docs/content/docs/workspace/media.mdx index 37e94b2..e9c3989 100644 --- a/apps/docs/content/docs/workspace/media.mdx +++ b/apps/docs/content/docs/workspace/media.mdx @@ -6,3 +6,11 @@ description: Upload and reuse images in email content. The Media library stores images that can be reused by the email editor and templates. Upload an image from **Media**, then select it from the editor’s image controls when composing a broadcast, sequence email, or template. Media is scoped to the current team. Keep uploads optimized for email and use HTTPS-hosted assets in production. The dashboard applies a configurable maximum upload size; the default is 10 MB. + +## When images are deleted + +Deleting a template, broadcast, sequence or sequence email also deletes the images it used, unless another template or email still uses them. Images in mail that was already sent are kept, because recipients' inboxes and the "view in browser" page still load them. Those stay in the Media library, where you can delete them yourself. + +Removing an image from an email or template while editing does not delete it from the Media library. + +Archiving a team keeps its images, so a restored team has them back. Deleting the organization deletes every image of its teams. diff --git a/docs/decisions/0006-delete-media-with-its-resource.md b/docs/decisions/0006-delete-media-with-its-resource.md new file mode 100644 index 0000000..6090f34 --- /dev/null +++ b/docs/decisions/0006-delete-media-with-its-resource.md @@ -0,0 +1,46 @@ +# ADR 0006: Delete media with its resource, but never media sent mail shows + +Status: accepted +Date: 2026-10-29 + +## Context + +Images in email content are MediaLit files tracked by `media` rows and +`media_references` (one per template or sequence email that uses an image). +Deleting a resource used to remove only its references, so files piled up in +MediaLit and counted against storage forever (#157). But a delete can't +simply remove every file the resource used: sent mail loads its images by +URL from recipients' inboxes and from the "view in browser" page, and other +templates or emails can share a file. + +## Decision + +- Deleting a template, broadcast, sequence or sequence email deletes the + media it leaves without references, in MediaLit and in the database + (`deleteOrphanedMedia` in `apps/api/src/media/queries.ts`). New deletes + under #152 reuse it. +- Media used by sent mail is kept: an email with a delivery or a stored + content version keeps its images, a template a transactional email was + sent with keeps its images, and media any stored sent content shows is + never purged. These images stay in the media library, unreferenced, until + the user deletes them there. There is no grace period: drafts were never + seen by anyone, and sent mail is kept outright. +- Editing content never deletes media. A removed image may be in sent + versions, or be added back; the media library is the place to delete it. + This matches Mailchimp's and Klaviyo's content libraries, where removing an + image from a campaign leaves the file in place. +- Archiving a team keeps its media, because archived teams can be restored. + Organization deletion (and account deletion, #134) purges every file of its + teams. +- A file another team also holds a row for is never deleted from MediaLit. +- MediaLit failures never fail or roll back the delete. The row goes first, + the error goes to `captureError`, and the MediaLit delete is retried from + the `media-deletion` BullMQ queue. + +## Consequences + +Storage for drafts and unsent content is reclaimed when they are deleted. +Hard-deleting sent content still leaves its images, so inboxes keep working; +those files only go when the user deletes them in the media library or the +organization is deleted. Files orphaned before this change are not cleaned +up automatically. diff --git a/packages/api-contract/src/contract.ts b/packages/api-contract/src/contract.ts index 3aefcfc..18cb390 100644 --- a/packages/api-contract/src/contract.ts +++ b/packages/api-contract/src/contract.ts @@ -569,6 +569,8 @@ const templatesContract = c.router( path: "/templates/:templateId", responses: { 204: c.noBody() }, summary: "Delete an email template", + description: + "Permanently deletes the template. Its images that no other template or email uses are deleted from the media library and storage, unless a transactional email was sent with the template.", }, }, { metadata: { tag: "Templates" } }, @@ -620,6 +622,8 @@ const sequencesContract = c.router( path: "/sequences/:sequenceId", responses: { 204: c.noBody(), 404: errorSchema }, summary: "Delete a broadcast or sequence", + description: + "Permanently deletes the broadcast or sequence. Images it uses that nothing else uses are deleted from the media library and storage; images used by sent mail are kept.", }, addEmail: { method: "POST", @@ -672,6 +676,8 @@ const sequencesContract = c.router( 404: errorSchema, }, summary: "Remove an email from a sequence", + description: + "Removes the email. Images it uses that nothing else uses are deleted from the media library and storage; images used by sent mail are kept.", }, start: { method: "POST",