fix(api): send correct staff emails and allow suppressing them (#3048)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 23:55:54 +02:00
1 parent f0b3c82cfd
commit 39f9beda5a
113 files changed
+2496 -206

No files matched your search

@@ -86,6 +86,7 @@ async function queueBulkJob(
reason_code: body.reason_code,
days_until_deletion: body.days_until_deletion,
public_reason: body.public_reason ?? null,
notify_user: body.notify_user,
admin_user_id: adminUserId.toString(),
audit_log_reason: auditLogReason,
},
@@ -167,7 +167,7 @@ export function ReportAdminController(app: HonoApp) {
operationId: 'update_admin_report',
summary: 'Update report',
description:
'Moves a report to the resolved status with an optional public comment shown to the reporter. Marks the report as handled, notifies the reporter, and creates an audit log entry. Requires REPORT_RESOLVE permission.',
'Moves a report to the resolved status with an optional public comment shown to the reporter. Marks the report as handled, notifies the reporter by system DM and email unless notify_reporter is false, and creates an audit log entry. Requires REPORT_RESOLVE permission.',
responseSchema: ResolveReportResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -178,13 +178,14 @@ export function ReportAdminController(app: HonoApp) {
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const {report_id} = ctx.req.valid('param');
const {public_comment} = ctx.req.valid('json');
const {public_comment, notify_reporter} = ctx.req.valid('json');
return ctx.json(
await adminService.reportServiceAggregate.resolveReport(
createReportID(report_id),
adminUserId,
public_comment || null,
auditLogReason,
notify_reporter,
),
);
},
@@ -40,6 +40,7 @@ import {
AdminUserSystemStatusRequest,
AdminUsersMeResponse,
AdminUserTraitsRequest,
AdminUserUnbanRequest,
AdminUserUsernameUpdateRequest,
AdminUserWebAuthnCredentialParam,
ListUserChangeLogResponseSchema,
@@ -825,7 +826,7 @@ export function UserAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Apply temporary ban to user account for specified duration, or permanently with a duration of zero. Prevents login and guild operations. Automatically lifts after expiry. Creates audit log entry. Requires USER_TEMP_BAN permission.',
'Apply temporary ban to user account for specified duration, or permanently with a duration of zero. Prevents login and guild operations. Automatically lifts after expiry. Creates audit log entry. Requires USER_TEMP_BAN permission. Emails the user for temporary bans unless notify_user is false. Permanent bans are never emailed.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -848,6 +849,7 @@ export function UserAdminController(app: HonoApp) {
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_TEMP_BAN),
Validator('param', UserIdParam),
Validator('json', AdminUserUnbanRequest),
OpenAPI({
operationId: 'unban_admin_user',
summary: 'Unban user',
@@ -856,7 +858,7 @@ export function UserAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Immediately remove temporary ban from user account. User can log in and access guilds again. Creates audit log entry. Requires USER_TEMP_BAN permission.',
'Immediately remove the ban from the user account. Emails the user only when notify_user is true, the ban was still in force and the account is not closed or pending deletion. The email shows public_reason and never the audit log reason. Creates audit log entry. Requires USER_TEMP_BAN permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -866,7 +868,7 @@ export function UserAdminController(app: HonoApp) {
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.banService.unbanUser(
{user_id: userId},
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
@@ -912,7 +914,7 @@ export function UserAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. When a deletion is already scheduled, the request must name it in replace_pending_deletion_at or it returns 409. Records who scheduled the deletion. Creates audit log entry. Requires USER_DELETE permission.',
'Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. When a deletion is already scheduled, the request must name it in replace_pending_deletion_at or it returns 409. Records who scheduled the deletion. Creates audit log entry. Requires USER_DELETE permission. Emails the user unless notify_user is false. The email depends on reason_code: user requested and inactivity get neutral wording, other codes get enforcement wording with an appeal path.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -1237,7 +1239,7 @@ export function UserAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Disable user account due to suspicious activity or abuse. Account is locked pending review. User cannot access services. Creates audit log entry. Requires USER_DISABLE_SUSPICIOUS permission.',
'Disable user account due to suspicious activity or abuse. Account is locked pending review. User cannot access services. Emails the user unless notify_user is false. Creates audit log entry. Requires USER_DISABLE_SUSPICIOUS permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Logger} from '@app/api/Logger';
export async function trySendAdminNotification(
send: () => Promise<boolean>,
context: {action: string; targetId: string},
): Promise<boolean> {
try {
const sent = await send();
if (!sent) {
Logger.warn(context, 'Admin action notification was not sent');
}
return sent;
} catch (error) {
Logger.warn({error, ...context}, 'Failed to send admin action notification');
return false;
}
}
@@ -2,6 +2,7 @@
import type {ApiContext} from '@app/api/ApiContext';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {trySendAdminNotification} from '@app/api/admin/services/AdminNotification';
import {
type ChannelID,
createReportID,
@@ -103,10 +104,37 @@ export class AdminReportService {
adminUserId: UserID,
publicComment: string | null,
auditLogReason: string | null,
notifyReporter: boolean,
) {
const {reportService, auditService} = this.deps;
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
let reporterDmSent = false;
let reporterEmailSent = false;
const reporter =
notifyReporter && resolvedReport.reporterId ? await userRepository.findUnique(resolvedReport.reporterId) : null;
if (reporter) {
const commentForTemplate = publicComment ?? '';
reporterDmSent = await this.sendResolvedReportSystemDm({
reporter,
reportId,
publicComment: commentForTemplate,
});
const email = reporter.email;
if (email) {
reporterEmailSent = await trySendAdminNotification(
() =>
emailService.sendReportResolvedEmail(
email,
reporter.username,
reportId.toString(),
commentForTemplate,
reporter.locale,
),
{action: 'resolve_report', targetId: reportId.toString()},
);
}
}
await auditService.createAuditLog({
adminUserId,
targetType: 'report',
@@ -116,28 +144,11 @@ export class AdminReportService {
metadata: new Map([
['report_id', reportId.toString()],
['report_type', resolvedReport.reportType.toString()],
['notify_reporter', notifyReporter ? 'true' : 'false'],
['reporter_dm_sent', reporterDmSent ? 'true' : 'false'],
['reporter_email_sent', reporterEmailSent ? 'true' : 'false'],
]),
});
if (resolvedReport.reporterId) {
const reporter = await userRepository.findUnique(resolvedReport.reporterId);
if (reporter) {
const commentForTemplate = publicComment ?? '';
await this.sendResolvedReportSystemDm({
reporter,
reportId,
publicComment: commentForTemplate,
});
if (reporter.email) {
await emailService.sendReportResolvedEmail(
reporter.email,
reporter.username,
reportId.toString(),
commentForTemplate,
reporter.locale,
);
}
}
}
return {
report_id: resolvedReport.reportId.toString(),
status: resolvedReport.status,
@@ -154,9 +165,8 @@ export class AdminReportService {
reporter: User;
reportId: ReportID;
publicComment: string;
}): Promise<void> {
}): Promise<boolean> {
const {users: userRepository} = this.deps.apiContext.services;
const systemUser = await userRepository.findUniqueAssert(SYSTEM_USER_ID);
const template = getEmailTemplate('report_resolved', reporter.locale, {
username: reporter.username,
reportId: reportId.toString(),
@@ -173,10 +183,11 @@ export class AdminReportService {
},
'Skipping report review system DM because the email template could not be resolved',
);
return;
return false;
}
const requestCache = createRequestCache();
try {
const systemUser = await userRepository.findUniqueAssert(SYSTEM_USER_ID);
const dmChannel = await this.deps.userChannelService.ensureDmOpenForBothUsers({
userId: systemUser.id,
recipientId: reporter.id,
@@ -191,11 +202,13 @@ export class AdminReportService {
},
requestCache,
});
return true;
} catch (error) {
Logger.warn(
{reportId: reportId.toString(), reporterId: reporter.id.toString(), error},
'Failed to send report review system DM',
);
return false;
} finally {
requestCache.clear();
}
@@ -3,16 +3,22 @@
import type {ApiContext} from '@app/api/ApiContext';
import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {trySendAdminNotification} from '@app/api/admin/services/AdminNotification';
import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import * as AuthSession from '@app/api/auth/AuthSession';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import {isAccountClosed, isTemporarilyBanned} from '@app/api/user/UserHelpers';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {AdminUserBanNoteRequest, TempBanUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {
AdminUserBanNoteRequest,
AdminUserUnbanRequest,
TempBanUserRequest,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
interface AdminUserBanServiceDeps {
apiContext: ApiContext;
@@ -52,16 +58,22 @@ export class AdminUserBanService {
);
await AuthSession.terminateAllUserSessions(this.deps.apiContext, userId);
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
if (user.email && data.duration_hours > 0) {
await emailService.sendAccountTempBannedEmail(
user.email,
user.username,
data.reason ?? null,
data.duration_hours,
tempBannedUntil,
user.locale,
);
}
const email = user.email;
const notificationSent =
data.notify_user && email && data.duration_hours > 0
? await trySendAdminNotification(
() =>
emailService.sendAccountTempBannedEmail(
email,
user.username,
data.reason ?? null,
data.duration_hours,
tempBannedUntil,
user.locale,
),
{action: 'temp_ban', targetId: userId.toString()},
)
: false;
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
@@ -72,6 +84,8 @@ export class AdminUserBanService {
['duration_hours', data.duration_hours.toString()],
['reason', data.reason ?? 'null'],
['banned_until', tempBannedUntil.toISOString()],
['notify_user', data.notify_user ? 'true' : 'false'],
['notification_sent', notificationSent ? 'true' : 'false'],
]),
});
await emitAdminAction(adminUserId, userId, 'temp_ban', {durationHours: data.duration_hours});
@@ -118,9 +132,7 @@ export class AdminUserBanService {
}
async unbanUser(
data: {
user_id: bigint;
},
data: AdminUserUnbanRequest & {user_id: bigint},
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
@@ -132,6 +144,7 @@ export class AdminUserBanService {
if (!user) {
throw new UnknownUserError();
}
const wasTempBanned = isTemporarilyBanned(user) && !isAccountClosed(user);
const updatedUser = await userRepository.patchUpsert(
userId,
{
@@ -141,16 +154,25 @@ export class AdminUserBanService {
user.toRow(),
);
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
if (user.email) {
await emailService.sendUnbanNotification(user.email, user.username, auditLogReason || null, user.locale);
}
const email = user.email;
const notificationSent =
data.notify_user && email && wasTempBanned
? await trySendAdminNotification(
() => emailService.sendUnbanNotification(email, user.username, data.public_reason ?? null, user.locale),
{action: 'unban', targetId: userId.toString()},
)
: false;
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'unban',
auditLogReason,
metadata: new Map(),
metadata: new Map([
['notify_user', data.notify_user ? 'true' : 'false'],
['notification_sent', notificationSent ? 'true' : 'false'],
['public_reason', data.public_reason ?? 'null'],
]),
});
await emitAdminAction(adminUserId, userId, 'unban');
return {
@@ -4,6 +4,7 @@ import type {ApiContext} from '@app/api/ApiContext';
import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import type {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
import {trySendAdminNotification} from '@app/api/admin/services/AdminNotification';
import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import * as AuthSession from '@app/api/auth/AuthSession';
import {createReportID, createUserID, type UserID} from '@app/api/BrandedTypes';
@@ -65,6 +66,25 @@ export function resolveDeletionDays(reasonCode: number, requestedDays: number):
return Math.max(requestedDays, minDays);
}
type ScheduledDeletionEmailTemplate =
| 'account_deletion_scheduled_requested'
| 'account_deletion_scheduled_inactivity'
| 'scheduled_deletion_notification'
| 'account_scheduled_deletion';
export function scheduledDeletionEmailTemplate(reasonCode: number): ScheduledDeletionEmailTemplate {
switch (reasonCode) {
case DeletionReasons.USER_REQUESTED:
return 'account_deletion_scheduled_requested';
case DeletionReasons.INACTIVITY:
return 'account_deletion_scheduled_inactivity';
case DeletionReasons.OTHER:
return 'scheduled_deletion_notification';
default:
return 'account_scheduled_deletion';
}
}
export class AdminUserDeletionService {
constructor(private readonly deps: AdminUserDeletionServiceDeps) {}
@@ -86,7 +106,7 @@ export class AdminUserDeletionService {
adminUserId: UserID,
auditLogReason: string | null,
): Promise<User> {
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
const {users: userRepository} = this.deps.apiContext.services;
const {auditService, updatePropagator} = this.deps;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
@@ -186,6 +206,23 @@ export class AdminUserDeletionService {
);
}
}
const email = user.email;
const notificationTemplate = scheduledDeletionEmailTemplate(data.reason_code);
const notificationAttempted = Boolean(data.notify_user && email);
const notificationSent =
data.notify_user && email
? await trySendAdminNotification(
() =>
this.sendScheduledDeletionEmail(notificationTemplate, {
email,
username: user.username,
reason: data.public_reason ?? null,
deletionDate: pendingDeletionAt,
locale: user.locale,
}),
{action: 'schedule_deletion', targetId: userId.toString()},
)
: false;
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
@@ -197,6 +234,9 @@ export class AdminUserDeletionService {
['reason_code', data.reason_code.toString()],
['pending_deletion_at', pendingDeletionAt.toISOString()],
...describePendingDeletion(user, 'replaced'),
['notify_user', data.notify_user ? 'true' : 'false'],
['notification_sent', notificationSent ? 'true' : 'false'],
...(notificationAttempted ? [['notification_template', notificationTemplate] as [string, string]] : []),
]),
});
let knownIps: ReadonlySet<string> = new Set();
@@ -206,25 +246,27 @@ export class AdminUserDeletionService {
}
await emitAdminAction(adminUserId, userId, 'schedule_deletion', {reasonCode: data.reason_code, ips: knownIps});
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
if (user.email) {
try {
await emailService.sendAccountScheduledForDeletionEmail(
user.email,
user.username,
data.public_reason ?? null,
pendingDeletionAt,
user.locale,
);
} catch (error) {
Logger.warn(
{error, userId: userId.toString()},
'Failed to send scheduled deletion email after the deletion was scheduled',
);
}
}
return updatedUser;
}
private sendScheduledDeletionEmail(
template: ScheduledDeletionEmailTemplate,
params: {email: string; username: string; reason: string | null; deletionDate: Date; locale: string | null},
): Promise<boolean> {
const {email: emailService} = this.deps.apiContext.services;
const {email, username, reason, deletionDate, locale} = params;
switch (template) {
case 'account_deletion_scheduled_requested':
return emailService.sendAccountDeletionRequestedEmail(email, username, reason, deletionDate, locale);
case 'account_deletion_scheduled_inactivity':
return emailService.sendAccountDeletionInactivityEmail(email, username, reason, deletionDate, locale);
case 'scheduled_deletion_notification':
return emailService.sendScheduledDeletionNotification(email, username, deletionDate, reason, locale);
case 'account_scheduled_deletion':
return emailService.sendAccountScheduledForDeletionEmail(email, username, reason, deletionDate, locale);
}
}
async cancelAccountDeletion(
data: AdminUserDeletionCancelRequest & {user_id: bigint},
adminUserId: UserID,
@@ -261,9 +303,14 @@ export class AdminUserDeletionService {
deletionQueue: this.deps.kvDeletionQueue,
});
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
if (data.notify_user && user.email) {
await emailService.sendUnbanNotification(user.email, user.username, null, user.locale);
}
const email = user.email;
const notificationSent =
data.notify_user && email
? await trySendAdminNotification(
() => emailService.sendAccountDeletionCancelledEmail(email, user.username, user.locale),
{action: 'cancel_deletion', targetId: userId.toString()},
)
: false;
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
@@ -273,6 +320,7 @@ export class AdminUserDeletionService {
metadata: new Map([
...describePendingDeletion(user, 'cancelled'),
['notify_user', data.notify_user ? 'true' : 'false'],
['notification_sent', notificationSent ? 'true' : 'false'],
]),
});
await emitAdminAction(adminUserId, userId, 'cancel_deletion');
@@ -3,6 +3,7 @@
import type {ApiContext} from '@app/api/ApiContext';
import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {trySendAdminNotification} from '@app/api/admin/services/AdminNotification';
import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import * as AuthEmail from '@app/api/auth/AuthEmail';
import * as AuthMfa from '@app/api/auth/AuthMfa';
@@ -508,16 +509,25 @@ export class AdminUserSecurityService {
);
await AuthSession.terminateAllUserSessions(this.deps.apiContext, userId);
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
if (user.email) {
await emailService.sendAccountDisabledForSuspiciousActivityEmail(user.email, user.username, null, user.locale);
}
const email = user.email;
const notificationSent =
data.notify_user && email
? await trySendAdminNotification(
() => emailService.sendAccountDisabledForSuspiciousActivityEmail(email, user.username, null, user.locale),
{action: 'disable_suspicious_activity', targetId: userId.toString()},
)
: false;
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'disable_suspicious_activity',
auditLogReason,
metadata: new Map([['flags', data.flags.toString()]]),
metadata: new Map([
['flags', data.flags.toString()],
['notify_user', data.notify_user ? 'true' : 'false'],
['notification_sent', notificationSent ? 'true' : 'false'],
]),
});
await emitAdminAction(adminUserId, userId, 'disable_suspicious');
return {
@@ -380,7 +380,7 @@ describe('Admin Deletion Queue', () => {
.expect(HTTP_STATUS.OK)
.execute();
expect(
findLastTestEmail(await listTestEmails(harness, {recipient: target.email}), 'unban_notification'),
findLastTestEmail(await listTestEmails(harness, {recipient: target.email}), 'account_deletion_cancelled'),
).toBeNull();
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
const log = logs.find(
@@ -390,6 +390,7 @@ describe('Admin Deletion Queue', () => {
expect(log?.metadata.get('cancelled_scheduled_by')).toBe(scheduler.userId);
expect(log?.metadata.get('cancelled_reason_code')).toBe(DeletionReasons.SPAM.toString());
expect(log?.metadata.get('notify_user')).toBe('false');
expect(log?.metadata.get('notification_sent')).toBe('false');
const rescheduled = await scheduleAs(scheduler, target, DeletionReasons.SPAM, 'Review again');
await createBuilder(harness, canceller.token)
.delete(`/admin/users/${target.userId}/deletion`)
@@ -397,9 +398,11 @@ describe('Admin Deletion Queue', () => {
.body({expected_pending_deletion_at: rescheduled.user.pending_deletion_at, notify_user: true})
.expect(HTTP_STATUS.OK)
.execute();
const email = findLastTestEmail(await listTestEmails(harness, {recipient: target.email}), 'unban_notification');
const emails = await listTestEmails(harness, {recipient: target.email});
const email = findLastTestEmail(emails, 'account_deletion_cancelled');
expect(email).not.toBeNull();
expect(JSON.stringify(email)).not.toContain('Private cancel note');
expect(findLastTestEmail(emails, 'unban_notification')).toBeNull();
});
});
});
@@ -0,0 +1,326 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {scheduledDeletionEmailTemplate} from '@app/api/admin/services/AdminUserDeletionService';
import {
clearTestEmails,
createTestAccount,
listTestEmails,
setUserACLs,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {getAdminRepository, getUserRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
const DELETION_TEMPLATES = [
'account_deletion_scheduled_requested',
'account_deletion_scheduled_inactivity',
'scheduled_deletion_notification',
'account_scheduled_deletion',
];
const NON_ENFORCEMENT_TEMPLATES: Record<number, string> = {
1: 'account_deletion_scheduled_requested',
2: 'scheduled_deletion_notification',
19: 'account_deletion_scheduled_inactivity',
};
describe('scheduledDeletionEmailTemplate', () => {
test.each(Object.entries(NON_ENFORCEMENT_TEMPLATES))('code %s picks %s', (code, template) => {
expect(scheduledDeletionEmailTemplate(Number(code))).toBe(template);
});
test.each(Object.entries(DeletionReasons).filter(([, code]) => !(code in NON_ENFORCEMENT_TEMPLATES)))(
'%s picks the enforcement template',
(_name, code) => {
expect(scheduledDeletionEmailTemplate(code)).toBe('account_scheduled_deletion');
},
);
});
describe('Admin staff notifications', () => {
let harness: ApiTestHarness;
let admin: TestAccount;
beforeEach(async () => {
harness = await createApiTestHarness();
admin = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.WILDCARD]);
});
afterEach(async () => {
await harness?.shutdown();
});
async function auditMetadata(action: string, targetId: string): Promise<Map<string, string> | undefined> {
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
return logs.find((log) => log.action === action && log.targetId.toString() === targetId)?.metadata;
}
async function emailsTo(account: TestAccount) {
return listTestEmails(harness, {recipient: account.email});
}
async function systemDmMessages(account: TestAccount): Promise<Array<{content: string | null}>> {
const channels = await createBuilder<Array<{id: string; recipients?: Array<{id: string}>}>>(harness, account.token)
.get('/users/@me/channels')
.expect(HTTP_STATUS.OK)
.execute();
const systemChannels = channels.filter((channel) => channel.recipients?.some((recipient) => recipient.id === '0'));
const messages = await Promise.all(
systemChannels.map((channel) =>
createBuilder<Array<{content: string | null}>>(harness, account.token)
.get(`/channels/${channel.id}/messages?limit=50`)
.expect(HTTP_STATUS.OK)
.execute(),
),
);
return messages.flat();
}
async function expireTempBan(target: TestAccount) {
const users = getUserRepository();
const user = (await users.findUnique(createUserID(BigInt(target.userId))))!;
await users.patchUpsert(user.id, {temp_banned_until: new Date(Date.now() - 3_600_000)}, user.toRow());
}
async function scheduleDeletion(target: TestAccount, body: Record<string, unknown>) {
await createBuilder(harness, admin.token)
.put(`/admin/users/${target.userId}/deletion`)
.body({days_until_deletion: 60, ...body})
.expect(HTTP_STATUS.OK)
.execute();
}
async function tempBan(target: TestAccount, body: Record<string, unknown>) {
await createBuilder(harness, admin.token)
.put(`/admin/users/${target.userId}/ban`)
.body(body)
.expect(HTTP_STATUS.OK)
.execute();
}
describe('schedule deletion', () => {
test.each([
[DeletionReasons.USER_REQUESTED, 'account_deletion_scheduled_requested'],
[DeletionReasons.INACTIVITY, 'account_deletion_scheduled_inactivity'],
[DeletionReasons.OTHER, 'scheduled_deletion_notification'],
[DeletionReasons.SPAM, 'account_scheduled_deletion'],
])('reason code %s sends %s', async (reasonCode, template) => {
const target = await createTestAccount(harness);
await clearTestEmails(harness);
await scheduleDeletion(target, {reason_code: reasonCode, public_reason: 'Shown to the user'});
const sent = (await emailsTo(target)).filter((email) => DELETION_TEMPLATES.includes(email.type));
expect(sent.map((email) => email.type)).toEqual([template]);
expect(sent[0]?.metadata.reason).toBe('Shown to the user');
const metadata = await auditMetadata('schedule_deletion', target.userId);
expect(metadata?.get('notify_user')).toBe('true');
expect(metadata?.get('notification_sent')).toBe('true');
expect(metadata?.get('notification_template')).toBe(template);
});
test('notify_user false sends nothing and records it', async () => {
const target = await createTestAccount(harness);
await clearTestEmails(harness);
await scheduleDeletion(target, {reason_code: DeletionReasons.SPAM, notify_user: false});
expect((await emailsTo(target)).filter((email) => DELETION_TEMPLATES.includes(email.type))).toEqual([]);
const metadata = await auditMetadata('schedule_deletion', target.userId);
expect(metadata?.get('notify_user')).toBe('false');
expect(metadata?.get('notification_sent')).toBe('false');
expect(metadata?.has('notification_template')).toBe(false);
});
});
describe('temp ban', () => {
test('emails by default', async () => {
const target = await createTestAccount(harness);
await clearTestEmails(harness);
await tempBan(target, {duration_hours: 24, reason: 'Spam'});
expect((await emailsTo(target)).map((email) => email.type)).toContain('account_temp_banned');
const metadata = await auditMetadata('temp_ban', target.userId);
expect(metadata?.get('notify_user')).toBe('true');
expect(metadata?.get('notification_sent')).toBe('true');
});
test('notify_user false sends nothing', async () => {
const target = await createTestAccount(harness);
await clearTestEmails(harness);
await tempBan(target, {duration_hours: 24, notify_user: false});
expect((await emailsTo(target)).map((email) => email.type)).not.toContain('account_temp_banned');
const metadata = await auditMetadata('temp_ban', target.userId);
expect(metadata?.get('notify_user')).toBe('false');
expect(metadata?.get('notification_sent')).toBe('false');
});
test('a permanent ban sends nothing', async () => {
const target = await createTestAccount(harness);
await clearTestEmails(harness);
await tempBan(target, {duration_hours: 0});
expect((await emailsTo(target)).map((email) => email.type)).not.toContain('account_temp_banned');
const metadata = await auditMetadata('temp_ban', target.userId);
expect(metadata?.get('notify_user')).toBe('true');
expect(metadata?.get('notification_sent')).toBe('false');
});
});
describe('unban', () => {
test('emails the public reason and never the audit log reason', async () => {
const target = await createTestAccount(harness);
await tempBan(target, {duration_hours: 24, notify_user: false});
await clearTestEmails(harness);
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/ban`)
.header('X-Audit-Log-Reason', 'Private staff note')
.body({public_reason: 'Appeal accepted'})
.expect(HTTP_STATUS.OK)
.execute();
const email = (await emailsTo(target)).find((entry) => entry.type === 'unban_notification');
expect(email?.metadata.reason).toBe('Appeal accepted');
expect(JSON.stringify(email)).not.toContain('Private staff note');
const metadata = await auditMetadata('unban', target.userId);
expect(metadata?.get('notify_user')).toBe('true');
expect(metadata?.get('notification_sent')).toBe('true');
expect(metadata?.get('public_reason')).toBe('Appeal accepted');
});
test('accepts a request without a body', async () => {
const target = await createTestAccount(harness);
await tempBan(target, {duration_hours: 24, notify_user: false});
await clearTestEmails(harness);
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/ban`)
.header('X-Audit-Log-Reason', 'Private staff note')
.expect(HTTP_STATUS.OK)
.execute();
const email = (await emailsTo(target)).find((entry) => entry.type === 'unban_notification');
expect(email?.metadata.reason).toBe('');
expect(JSON.stringify(email)).not.toContain('Private staff note');
});
test('notify_user false sends nothing', async () => {
const target = await createTestAccount(harness);
await tempBan(target, {duration_hours: 24, notify_user: false});
await clearTestEmails(harness);
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/ban`)
.body({notify_user: false})
.expect(HTTP_STATUS.OK)
.execute();
expect((await emailsTo(target)).map((email) => email.type)).not.toContain('unban_notification');
const metadata = await auditMetadata('unban', target.userId);
expect(metadata?.get('notify_user')).toBe('false');
expect(metadata?.get('notification_sent')).toBe('false');
});
test('a temp ban that already expired is not emailed', async () => {
const target = await createTestAccount(harness);
await tempBan(target, {duration_hours: 24, notify_user: false});
await expireTempBan(target);
await clearTestEmails(harness);
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/ban`)
.expect(HTTP_STATUS.OK)
.execute();
expect((await emailsTo(target)).map((email) => email.type)).not.toContain('unban_notification');
const metadata = await auditMetadata('unban', target.userId);
expect(metadata?.get('notify_user')).toBe('true');
expect(metadata?.get('notification_sent')).toBe('false');
});
test('an account pending deletion is not told it can log back in', async () => {
const target = await createTestAccount(harness);
await tempBan(target, {duration_hours: 24, notify_user: false});
await scheduleDeletion(target, {reason_code: DeletionReasons.SPAM, notify_user: false});
await clearTestEmails(harness);
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/ban`)
.expect(HTTP_STATUS.OK)
.execute();
expect((await emailsTo(target)).map((email) => email.type)).not.toContain('unban_notification');
expect((await auditMetadata('unban', target.userId))?.get('notification_sent')).toBe('false');
});
test('a user who was never banned is not emailed', async () => {
const target = await createTestAccount(harness);
await clearTestEmails(harness);
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/ban`)
.expect(HTTP_STATUS.OK)
.execute();
expect((await emailsTo(target)).map((email) => email.type)).not.toContain('unban_notification');
expect((await auditMetadata('unban', target.userId))?.get('notification_sent')).toBe('false');
});
});
describe('suspicious activity disable', () => {
test.each([
[undefined, true],
[false, false],
])('notify_user %s emails %s', async (notifyUser, expected) => {
const target = await createTestAccount(harness);
await clearTestEmails(harness);
await createBuilder(harness, admin.token)
.put(`/admin/users/${target.userId}/suspicious-activity-disablement`)
.body({
flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE,
...(notifyUser === undefined ? {} : {notify_user: notifyUser}),
})
.expect(HTTP_STATUS.OK)
.execute();
const types = (await emailsTo(target)).map((email) => email.type);
expect(types.includes('account_disabled_suspicious')).toBe(expected);
const metadata = await auditMetadata('disable_suspicious_activity', target.userId);
expect(metadata?.get('notify_user')).toBe(expected ? 'true' : 'false');
expect(metadata?.get('notification_sent')).toBe(expected ? 'true' : 'false');
});
});
describe('report resolve', () => {
async function fileReport(): Promise<{reporter: TestAccount; reportId: string}> {
const reporter = await createTestAccount(harness);
const reported = await createTestAccount(harness);
const report = await createBuilder<{report_id: string}>(harness, reporter.token)
.post('/reports/user')
.body({user_id: reported.userId, category: 'harassment'})
.execute();
return {reporter, reportId: report.report_id};
}
test('notifies the reporter by default', async () => {
const {reporter, reportId} = await fileReport();
await clearTestEmails(harness);
await createBuilder(harness, admin.token)
.patch(`/admin/reports/${reportId}`)
.body({status: 'resolved', public_comment: 'Handled'})
.expect(HTTP_STATUS.OK)
.execute();
expect((await emailsTo(reporter)).map((email) => email.type)).toContain('report_resolved');
expect(await systemDmMessages(reporter)).toHaveLength(1);
const metadata = await auditMetadata('resolve_report', reportId);
expect(metadata?.get('notify_reporter')).toBe('true');
expect(metadata?.get('reporter_dm_sent')).toBe('true');
expect(metadata?.get('reporter_email_sent')).toBe('true');
});
test('notify_reporter false sends neither DM nor email', async () => {
const {reporter, reportId} = await fileReport();
await clearTestEmails(harness);
await createBuilder(harness, admin.token)
.patch(`/admin/reports/${reportId}`)
.body({status: 'resolved', notify_reporter: false})
.expect(HTTP_STATUS.OK)
.execute();
expect((await emailsTo(reporter)).map((email) => email.type)).not.toContain('report_resolved');
expect(await systemDmMessages(reporter)).toEqual([]);
const metadata = await auditMetadata('resolve_report', reportId);
expect(metadata?.get('notify_reporter')).toBe('false');
expect(metadata?.get('reporter_dm_sent')).toBe('false');
expect(metadata?.get('reporter_email_sent')).toBe('false');
});
});
});
@@ -109,7 +109,13 @@ export const ReportAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
action: 'resolve_report',
targetType: 'report',
targetId: reportId,
metadata: {report_id: reportId, report_type: '1'},
metadata: {
report_id: reportId,
report_type: '1',
notify_reporter: 'true',
reporter_dm_sent: 'true',
reporter_email_sent: 'true',
},
},
};
},
@@ -318,7 +318,13 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
action: 'temp_ban',
targetType: 'user',
targetId: target.userId,
metadata: {duration_hours: '24', reason: 'Coverage ban', banned_until: expect.any(String)},
metadata: {
duration_hours: '24',
reason: 'Coverage ban',
banned_until: expect.any(String),
notify_user: 'true',
notification_sent: 'true',
},
},
};
},
@@ -335,7 +341,7 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
action: 'unban',
targetType: 'user',
targetId: target.userId,
metadata: {},
metadata: {notify_user: 'true', notification_sent: 'true', public_reason: 'null'},
},
};
},
@@ -386,6 +392,9 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
days: '30',
reason_code: DeletionReasons.USER_REQUESTED.toString(),
pending_deletion_at: expect.any(String),
notify_user: 'true',
notification_sent: 'true',
notification_template: 'account_deletion_scheduled_requested',
},
},
};
@@ -416,6 +425,7 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
cancelled_scheduled_at: expect.any(String),
cancelled_reason_code: DeletionReasons.USER_REQUESTED.toString(),
notify_user: 'false',
notification_sent: 'false',
},
},
};
@@ -596,7 +606,7 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
action: 'disable_suspicious_activity',
targetType: 'user',
targetId: target.userId,
metadata: {flags: flags.toString()},
metadata: {flags: flags.toString(), notify_user: 'true', notification_sent: 'true'},
},
};
},