Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions apps/api/docs/media-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) },
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/sequences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/mcp/tools/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
92 changes: 92 additions & 0 deletions apps/api/src/media/deletion-queue.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
62 changes: 62 additions & 0 deletions apps/api/src/media/deletion-queue.ts
Original file line number Diff line number Diff line change
@@ -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<MediaDeletionJobData> | 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<MediaDeletionJobData> {
queue ??= new Queue<MediaDeletionJobData>(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<void> {
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<void> {
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<Job<MediaDeletionJobData>, "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";
}
17 changes: 17 additions & 0 deletions apps/api/src/media/deletion-worker.ts
Original file line number Diff line number Diff line change
@@ -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<MediaDeletionJobData>(
MEDIA_DELETION_QUEUE,
(job) => processMediaDeletionJob(job),
{ ...workerOptions, concurrency: 2 },
);

registerWorkerEvents(worker, MEDIA_DELETION_QUEUE);

export default worker;
Loading
Loading