feat(server): refactor mail queue (#15204)

This commit is contained in:
DarkSky
2026-07-07 08:38:16 +08:00
committed by GitHub
parent 9581432d21
commit 998b255afd
72 changed files with 8386 additions and 763 deletions
@@ -29,6 +29,7 @@ import { DocUserModel } from './doc-user';
import { FeatureModel } from './feature';
import { HistoryModel } from './history';
import { MagicLinkOtpModel } from './magic-link-otp';
import { MailDeliveryModel } from './mail-delivery';
import { NotificationModel } from './notification';
import { PermissionProjectionModel } from './permission-projection';
import {
@@ -57,6 +58,7 @@ const MODELS = {
session: SessionModel,
verificationToken: VerificationTokenModel,
magicLinkOtp: MagicLinkOtpModel,
mailDelivery: MailDeliveryModel,
feature: FeatureModel,
workspace: WorkspaceModel,
userFeature: UserFeatureModel,
@@ -165,6 +167,7 @@ export * from './doc-user';
export * from './feature';
export * from './history';
export * from './magic-link-otp';
export * from './mail-delivery';
export * from './notification';
export * from './permission-projection';
export * from './permission-write';
@@ -36,6 +36,7 @@ export class MagicLinkOtpModel extends BaseModel {
create: { email, otpHash, token, clientNonce, expiresAt, attempts: 0 },
update: { otpHash, token, clientNonce, expiresAt, attempts: 0 },
});
return { expiresAt };
}
@Transactional()
@@ -0,0 +1,665 @@
import { createHmac } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { CryptoHelper } from '../base';
import { BaseModel } from './base';
export type MailDeliveryStatus =
| 'queued'
| 'sending'
| 'retry_wait'
| 'sent'
| 'skipped'
| 'failed'
| 'canceled';
export type MailDeliveryPriority = 'critical' | 'high' | 'normal' | 'low';
export type MailDeliveryRow = {
id: string;
mailName: string;
mailClass: string;
priority: MailDeliveryPriority;
status: MailDeliveryStatus;
dedupeKey: string | null;
recipientEmail: string | null;
recipientHash: string;
recipientDomain: string;
recipientUserId: string | null;
actorUserId: string | null;
workspaceId: string | null;
notificationId: string | null;
abuseSubjectKey: string | null;
quotaReservationId: string | null;
quotaDecision: Prisma.JsonValue | null;
payload: Prisma.JsonValue | null;
sendAfter: Date;
expiresAt: Date | null;
attemptCount: number;
maxAttempts: number;
lockedBy: string | null;
lockedUntil: Date | null;
firstAttemptAt: Date | null;
lastAttemptAt: Date | null;
sentAt: Date | null;
settledAt: Date | null;
canceledAt: Date | null;
failedAt: Date | null;
providerMessageId: string | null;
providerResponse: string | null;
lastErrorCode: string | null;
lastError: string | null;
retentionState: 'full' | 'anonymized';
anonymizedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export type MailDeliveryCreateInput = {
mailName: string;
mailClass: string;
priority: MailDeliveryPriority;
status?: Extract<MailDeliveryStatus, 'queued' | 'skipped' | 'failed'>;
dedupeKey?: string;
recipientEmail: string;
recipientUserId?: string;
actorUserId?: string;
workspaceId?: string;
notificationId?: string;
abuseSubjectKey?: string;
quotaReservationId?: string;
quotaDecision?: Prisma.JsonValue;
payload: Prisma.JsonValue;
sendAfter?: Date;
expiresAt?: Date;
maxAttempts?: number;
lastErrorCode?: string;
lastError?: string;
};
export type MailDeliveryAdminAggregate = {
bucket: Date;
mailName: string;
mailClass: string;
status: MailDeliveryStatus;
count: number;
};
export type MailDeliveryMetricsSnapshot = {
retryWait: number;
failedRecent: number;
expiredLeases: number;
readyDelayMs: number;
};
const TERMINAL_STATUSES = new Set<MailDeliveryStatus>([
'sent',
'skipped',
'failed',
'canceled',
]);
function recipientDomain(email: string) {
const parts = email.trim().toLowerCase().split('@');
return parts.length === 2 ? parts[1] : '';
}
function redact(value: string | undefined | null) {
if (!value) {
return null;
}
return value
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]')
.replace(/\b\d{6,}\b/g, '[code]')
.replace(/https?:\/\/\S+/gi, '[url]')
.slice(0, 1000);
}
function mapRow(row: Record<string, unknown>): MailDeliveryRow {
return {
id: row.id as string,
mailName: row.mailName as string,
mailClass: row.mailClass as string,
priority: row.priority as MailDeliveryPriority,
status: row.status as MailDeliveryStatus,
dedupeKey: row.dedupeKey as string | null,
recipientEmail: row.recipientEmail as string | null,
recipientHash: row.recipientHash as string,
recipientDomain: row.recipientDomain as string,
recipientUserId: row.recipientUserId as string | null,
actorUserId: row.actorUserId as string | null,
workspaceId: row.workspaceId as string | null,
notificationId: row.notificationId as string | null,
abuseSubjectKey: row.abuseSubjectKey as string | null,
quotaReservationId: row.quotaReservationId as string | null,
quotaDecision: row.quotaDecision as Prisma.JsonValue | null,
payload: row.payload as Prisma.JsonValue | null,
sendAfter: row.sendAfter as Date,
expiresAt: row.expiresAt as Date | null,
attemptCount: row.attemptCount as number,
maxAttempts: row.maxAttempts as number,
lockedBy: row.lockedBy as string | null,
lockedUntil: row.lockedUntil as Date | null,
firstAttemptAt: row.firstAttemptAt as Date | null,
lastAttemptAt: row.lastAttemptAt as Date | null,
sentAt: row.sentAt as Date | null,
settledAt: row.settledAt as Date | null,
canceledAt: row.canceledAt as Date | null,
failedAt: row.failedAt as Date | null,
providerMessageId: row.providerMessageId as string | null,
providerResponse: row.providerResponse as string | null,
lastErrorCode: row.lastErrorCode as string | null,
lastError: row.lastError as string | null,
retentionState: row.retentionState as 'full' | 'anonymized',
anonymizedAt: row.anonymizedAt as Date | null,
createdAt: row.createdAt as Date,
updatedAt: row.updatedAt as Date,
};
}
const SELECT_FIELDS = Prisma.sql`
id::text AS "id",
mail_name AS "mailName",
mail_class AS "mailClass",
priority,
status,
dedupe_key AS "dedupeKey",
recipient_email AS "recipientEmail",
recipient_hash AS "recipientHash",
recipient_domain AS "recipientDomain",
recipient_user_id AS "recipientUserId",
actor_user_id AS "actorUserId",
workspace_id AS "workspaceId",
notification_id AS "notificationId",
abuse_subject_key AS "abuseSubjectKey",
quota_reservation_id::text AS "quotaReservationId",
quota_decision AS "quotaDecision",
payload,
send_after AS "sendAfter",
expires_at AS "expiresAt",
attempt_count AS "attemptCount",
max_attempts AS "maxAttempts",
locked_by AS "lockedBy",
locked_until AS "lockedUntil",
first_attempt_at AS "firstAttemptAt",
last_attempt_at AS "lastAttemptAt",
sent_at AS "sentAt",
settled_at AS "settledAt",
canceled_at AS "canceledAt",
failed_at AS "failedAt",
provider_message_id AS "providerMessageId",
provider_response AS "providerResponse",
last_error_code AS "lastErrorCode",
last_error AS "lastError",
retention_state AS "retentionState",
anonymized_at AS "anonymizedAt",
created_at AS "createdAt",
updated_at AS "updatedAt"
`;
@Injectable()
export class MailDeliveryModel extends BaseModel {
constructor(private readonly crypto: CryptoHelper) {
super();
}
private recipientHash(email: string) {
return createHmac('sha256', this.crypto.keyPair.sha256.privateKey)
.update(email.trim().toLowerCase())
.digest('hex');
}
async create(input: MailDeliveryCreateInput): Promise<MailDeliveryRow> {
const now = new Date();
const sendAfter = input.sendAfter ?? now;
const status = input.status ?? 'queued';
const expired =
input.expiresAt &&
(input.expiresAt.getTime() <= now.getTime() ||
sendAfter.getTime() >= input.expiresAt.getTime());
const finalStatus = expired ? 'failed' : status;
const terminal = TERMINAL_STATUSES.has(finalStatus);
const lastErrorCode = expired
? 'expired'
: (input.lastErrorCode ?? (status === 'skipped' ? 'skipped' : null));
const rows = await this.db.$queryRaw<Array<Record<string, unknown>>>`
INSERT INTO mail_deliveries (
mail_name,
mail_class,
priority,
status,
dedupe_key,
recipient_email,
recipient_hash,
recipient_domain,
recipient_user_id,
actor_user_id,
workspace_id,
notification_id,
abuse_subject_key,
quota_reservation_id,
quota_decision,
payload,
send_after,
expires_at,
max_attempts,
settled_at,
failed_at,
last_error_code,
last_error,
retention_state,
anonymized_at
)
VALUES (
${input.mailName},
${input.mailClass},
${input.priority},
${finalStatus},
${input.dedupeKey ?? null},
${terminal ? null : input.recipientEmail},
${this.recipientHash(input.recipientEmail)},
${recipientDomain(input.recipientEmail)},
${input.recipientUserId ?? null},
${input.actorUserId ?? null},
${input.workspaceId ?? null},
${input.notificationId ?? null},
${input.abuseSubjectKey ?? null},
${input.quotaReservationId ?? null}::uuid,
${input.quotaDecision ?? null},
${terminal ? null : input.payload},
${sendAfter},
${input.expiresAt ?? null},
${input.maxAttempts ?? 3},
${terminal ? now : null},
${finalStatus === 'failed' ? now : null},
${lastErrorCode},
${redact(input.lastError)},
${terminal ? 'anonymized' : 'full'},
${terminal ? now : null}
)
ON CONFLICT (dedupe_key) WHERE dedupe_key IS NOT NULL
DO NOTHING
RETURNING ${SELECT_FIELDS}
`;
if (rows[0]) {
return mapRow(rows[0]);
}
if (!input.dedupeKey) {
throw new Error('Failed to create mail delivery.');
}
const existing = await this.findByDedupeKey(input.dedupeKey);
if (!existing) {
throw new Error('Failed to find deduped mail delivery.');
}
return existing;
}
async findByDedupeKey(dedupeKey: string) {
const rows = await this.db.$queryRaw<Array<Record<string, unknown>>>`
SELECT ${SELECT_FIELDS}
FROM mail_deliveries
WHERE dedupe_key = ${dedupeKey}
LIMIT 1
`;
return rows[0] ? mapRow(rows[0]) : null;
}
async claimReady(
workerId: string,
options: { batchSize: number; leaseMs: number }
) {
await this.markExpiredPending(options.batchSize);
const rows = await this.db.$queryRaw<Array<Record<string, unknown>>>`
UPDATE mail_deliveries
SET status = 'sending',
locked_by = ${workerId},
locked_until = now() + (${options.leaseMs}::text || ' milliseconds')::interval,
updated_at = now()
WHERE id IN (
SELECT id
FROM mail_deliveries
WHERE (
status IN ('queued', 'retry_wait')
OR (status = 'sending' AND locked_until < now())
)
AND send_after <= now()
AND (expires_at IS NULL OR expires_at > now())
AND (locked_until IS NULL OR locked_until < now())
AND max_attempts > attempt_count
ORDER BY
CASE priority
WHEN 'critical' THEN 0
WHEN 'high' THEN 1
WHEN 'normal' THEN 2
ELSE 3
END,
send_after,
created_at
FOR UPDATE SKIP LOCKED
LIMIT ${options.batchSize}
)
RETURNING ${SELECT_FIELDS}
`;
return rows.map(mapRow);
}
async markAttemptStarted(id: string, workerId: string) {
const rows = await this.db.$queryRaw<Array<Record<string, unknown>>>`
UPDATE mail_deliveries
SET attempt_count = attempt_count + 1,
first_attempt_at = COALESCE(first_attempt_at, now()),
last_attempt_at = now(),
updated_at = now()
WHERE id = ${id}::uuid
AND status = 'sending'
AND locked_by = ${workerId}
AND (expires_at IS NULL OR expires_at > now())
AND max_attempts > attempt_count
RETURNING ${SELECT_FIELDS}
`;
return rows[0] ? mapRow(rows[0]) : null;
}
async markSent(
id: string,
workerId: string,
result: {
providerMessageId?: string | null;
providerResponse?: string | null;
}
) {
return await this.markTerminal(id, workerId, 'sent', {
providerMessageId: result.providerMessageId,
providerResponse: redact(result.providerResponse),
});
}
async markRetry(
id: string,
workerId: string,
input: {
sendAfter: Date;
errorCode?: string | null;
error?: string | null;
}
) {
const rows = await this.db.$queryRaw<Array<Record<string, unknown>>>`
UPDATE mail_deliveries
SET status = 'retry_wait',
send_after = ${input.sendAfter},
locked_by = NULL,
locked_until = NULL,
last_error_code = ${input.errorCode ?? null},
last_error = ${redact(input.error)},
updated_at = now()
WHERE id = ${id}::uuid
AND status = 'sending'
AND locked_by = ${workerId}
AND (expires_at IS NULL OR ${input.sendAfter} < expires_at)
AND attempt_count < max_attempts
RETURNING ${SELECT_FIELDS}
`;
return rows[0] ? mapRow(rows[0]) : null;
}
async markSkipped(
id: string,
workerId: string,
reason: string,
detail?: string
) {
return await this.markTerminal(id, workerId, 'skipped', {
lastErrorCode: reason,
lastError: redact(detail),
});
}
async markFailed(
id: string,
workerId: string,
errorCode: string,
error?: string
) {
return await this.markTerminal(id, workerId, 'failed', {
lastErrorCode: errorCode,
lastError: redact(error),
});
}
async markCanceled(id: string, workerId: string, reason = 'canceled') {
return await this.markTerminal(id, workerId, 'canceled', {
lastErrorCode: reason,
});
}
async cancelByRecipient(
recipientEmail: string,
reason = 'recipient_deleted'
) {
return await this.cancelWhere(
Prisma.sql`recipient_hash = ${this.recipientHash(recipientEmail)}`,
reason
);
}
async cancelById(id: string, reason = 'canceled') {
return await this.cancelWhere(Prisma.sql`id = ${id}::uuid`, reason);
}
async cancelMemberInvitationByActor(
actorUserId: string,
reason = 'actor_deleted'
) {
return await this.cancelWhere(
Prisma.sql`mail_name = 'MemberInvitation' AND actor_user_id = ${actorUserId}`,
reason
);
}
async cancelByActor(actorUserId: string, reason = 'actor_quarantined') {
return await this.cancelWhere(
Prisma.sql`actor_user_id = ${actorUserId}`,
reason
);
}
async cancelByWorkspace(
workspaceId: string,
reason = 'workspace_quarantined'
) {
return await this.cancelWhere(
Prisma.sql`workspace_id = ${workspaceId}`,
reason
);
}
async cancelByAbuseSubject(
subjectKey: string,
reason = 'abuse_subject_quarantined'
) {
return await this.cancelWhere(
Prisma.sql`abuse_subject_key = ${subjectKey}`,
reason
);
}
async markExpiredPending(limit: number) {
const result = await this.db.$executeRaw`
UPDATE mail_deliveries
SET status = 'failed',
failed_at = now(),
settled_at = now(),
locked_by = NULL,
locked_until = NULL,
recipient_email = NULL,
payload = NULL,
retention_state = 'anonymized',
anonymized_at = now(),
last_error_code = 'expired',
updated_at = now()
WHERE id IN (
SELECT id
FROM mail_deliveries
WHERE (
status IN ('queued', 'retry_wait')
OR (status = 'sending' AND locked_until < now())
)
AND expires_at IS NOT NULL
AND expires_at <= now()
LIMIT ${limit}
)
`;
return Number(result);
}
async adminAggregate(input: {
from: Date;
to: Date;
bucket: 'hour' | 'day';
}) {
const rows = await this.db.$queryRaw<Array<Record<string, unknown>>>`
WITH events AS (
SELECT
date_trunc(${input.bucket}, created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS bucket,
mail_name,
mail_class,
status,
COUNT(*)::int AS count
FROM mail_deliveries
WHERE created_at >= ${input.from}
AND created_at < ${input.to}
GROUP BY
bucket,
mail_name,
mail_class,
status
)
SELECT
bucket AS "bucket",
mail_name AS "mailName",
mail_class AS "mailClass",
status,
count
FROM events
ORDER BY bucket ASC, count DESC, "mailName" ASC
`;
return rows.map(row => ({
bucket: row.bucket as Date,
mailName: row.mailName as string,
mailClass: row.mailClass as string,
status: row.status as MailDeliveryStatus,
count: row.count as number,
})) satisfies MailDeliveryAdminAggregate[];
}
async deleteAnonymizedBefore(before: Date, limit: number) {
const result = await this.db.$executeRaw`
DELETE FROM mail_deliveries
WHERE id IN (
SELECT id
FROM mail_deliveries
WHERE retention_state = 'anonymized'
AND settled_at IS NOT NULL
AND settled_at < ${before}
ORDER BY settled_at
LIMIT ${limit}
)
`;
return Number(result);
}
async metricsSnapshot(): Promise<MailDeliveryMetricsSnapshot> {
const rows = await this.db.$queryRaw<
Array<{
retryWait: number;
failedRecent: number;
expiredLeases: number;
readyDelayMs: number;
}>
>`
SELECT
COUNT(*) FILTER (WHERE status = 'retry_wait')::int AS "retryWait",
COUNT(*) FILTER (
WHERE status = 'failed' AND failed_at >= now() - interval '1 hour'
)::int AS "failedRecent",
COUNT(*) FILTER (
WHERE status = 'sending' AND locked_until < now()
)::int AS "expiredLeases",
COALESCE(MAX(EXTRACT(EPOCH FROM now() - send_after) * 1000) FILTER (
WHERE status IN ('queued', 'retry_wait') AND send_after <= now()
), 0)::int AS "readyDelayMs"
FROM mail_deliveries
`;
return (
rows[0] ?? {
retryWait: 0,
failedRecent: 0,
expiredLeases: 0,
readyDelayMs: 0,
}
);
}
private async markTerminal(
id: string,
workerId: string,
status: Extract<
MailDeliveryStatus,
'sent' | 'skipped' | 'failed' | 'canceled'
>,
input: {
providerMessageId?: string | null;
providerResponse?: string | null;
lastErrorCode?: string | null;
lastError?: string | null;
}
) {
const rows = await this.db.$queryRaw<Array<Record<string, unknown>>>`
UPDATE mail_deliveries
SET status = ${status},
sent_at = CASE WHEN ${status} = 'sent' THEN now() ELSE sent_at END,
failed_at = CASE WHEN ${status} = 'failed' THEN now() ELSE failed_at END,
canceled_at = CASE WHEN ${status} = 'canceled' THEN now() ELSE canceled_at END,
settled_at = now(),
locked_by = NULL,
locked_until = NULL,
recipient_email = NULL,
payload = NULL,
retention_state = 'anonymized',
anonymized_at = now(),
provider_message_id = ${input.providerMessageId ?? null},
provider_response = ${input.providerResponse ?? null},
last_error_code = ${input.lastErrorCode ?? null},
last_error = ${input.lastError ?? null},
updated_at = now()
WHERE id = ${id}::uuid
AND status = 'sending'
AND locked_by = ${workerId}
RETURNING ${SELECT_FIELDS}
`;
return rows[0] ? mapRow(rows[0]) : null;
}
private async cancelWhere(where: Prisma.Sql, reason: string) {
const result = await this.db.$executeRaw`
UPDATE mail_deliveries
SET status = 'canceled',
canceled_at = now(),
settled_at = now(),
locked_by = NULL,
locked_until = NULL,
recipient_email = NULL,
payload = NULL,
retention_state = 'anonymized',
anonymized_at = now(),
last_error_code = ${reason},
updated_at = now()
WHERE status IN ('queued', 'retry_wait', 'sending')
AND ${where}
`;
return Number(result);
}
}
@@ -358,6 +358,32 @@ export class WorkspaceInvitationModel extends BaseModel {
});
}
@Transactional()
async cancelPendingByActor(actorUserId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceInvitation.deleteMany({
where: {
inviterUserId: actorUserId,
status: {
in: ['pending', 'waiting_review', 'waiting_seat'],
},
},
});
}
@Transactional()
async cancelPendingByWorkspace(workspaceId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceInvitation.deleteMany({
where: {
workspaceId,
status: {
in: ['pending', 'waiting_review', 'waiting_seat'],
},
},
});
}
private async supportsCurrentInvitationColumns() {
this.hasCurrentColumns ??= this.db.$queryRaw<Array<{ exists: boolean }>>`
SELECT EXISTS (
@@ -28,17 +28,31 @@ export class VerificationTokenModel extends BaseModel {
type: TokenType,
credential?: string,
ttlInSec: number = 30 * 60
) {
const { token } = await this.createWithExpiresAt(
type,
credential,
ttlInSec
);
return token;
}
async createWithExpiresAt(
type: TokenType,
credential?: string,
ttlInSec: number = 30 * 60
) {
const plaintextToken = randomUUID();
const expiresAt = new Date(Date.now() + ttlInSec * 1000);
const { token } = await this.db.verificationToken.create({
data: {
type,
token: plaintextToken,
credential,
expiresAt: new Date(Date.now() + ttlInSec * 1000),
expiresAt,
},
});
return this.crypto.encrypt(token);
return { token: this.crypto.encrypt(token), expiresAt };
}
/**