feat(email): rebuild email settings as four quiet tabs with a send sheet

Fold six tabs into Recipients, Mail servers, Templates and History, where only problems get
colour and one star sets each default through email_preferences. Replace the send dialog with
a sheet that preselects the default recipient and follows the send. Server additions are
optional fields and routes, so released iOS builds keep working.
This commit is contained in:
neon
2026-10-12 22:18:38 +00:00
parent ddc7e848b9
commit 591dea03a2
95 changed files with 29577 additions and 2359 deletions
@@ -0,0 +1,3 @@
ALTER TABLE "email_providers" ADD COLUMN "last_tested_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "email_providers" ADD COLUMN "last_test_ok" boolean;--> statement-breakpoint
ALTER TABLE "email_providers" ADD COLUMN "last_test_error" text;
File diff suppressed because it is too large Load Diff
@@ -771,6 +771,13 @@
"when": 1791821546946,
"tag": "0109_add_hardcover_match_basis",
"breakpoints": true
},
{
"idx": 110,
"version": "7",
"when": 1791838145626,
"tag": "0110_add_email_provider_test_result",
"breakpoints": true
}
]
}
+3
View File
@@ -22,6 +22,9 @@ export const emailProviders = pgTable(
isShared: boolean('is_shared').notNull().default(false),
isSystemProvider: boolean('is_system_provider').notNull().default(false),
tlsRejectUnauthorized: boolean('tls_reject_unauthorized').notNull().default(true),
lastTestedAt: timestamp('last_tested_at', { withTimezone: true }),
lastTestOk: boolean('last_test_ok'),
lastTestError: text('last_test_error'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
@@ -0,0 +1,15 @@
import { IsInt, IsString, MaxLength } from 'class-validator';
/** A template that has not been saved yet, rendered against one book for the editor's preview. */
export class PreviewTemplateTextDto {
@IsString()
@MaxLength(998)
subject: string;
@IsString()
@MaxLength(200_000)
bodyText: string;
@IsInt()
bookId: number;
}
@@ -1,4 +1,4 @@
import { IsInt, IsOptional, IsPositive, Max, Min } from 'class-validator';
import { IsIn, IsInt, IsOptional, IsPositive, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class QuerySendLogDto {
@@ -26,4 +26,19 @@ export class QuerySendLogDto {
@IsInt()
@IsPositive()
bookId?: number;
/** Narrows the log to one outcome. Only the web client sends it; older servers reject it. */
@IsOptional()
@IsIn(['pending', 'sent', 'failed'])
status?: 'pending' | 'sent' | 'failed';
/**
* Narrows the log to one of the caller's recipients, including rows logged before recipient ids
* were stored, which are matched by address. Only the web client sends it; older servers reject it.
*/
@IsOptional()
@Type(() => Number)
@IsInt()
@IsPositive()
recipientId?: number;
}
@@ -0,0 +1,17 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
/**
* The test message comes from the client so it is written in the sender's interface language;
* the server has no catalog for email copy.
*/
export class SendRecipientTestDto {
@IsString()
@MinLength(1)
@MaxLength(200)
subject: string;
@IsString()
@MinLength(1)
@MaxLength(2000)
text: string;
}
@@ -1,11 +1,13 @@
vi.mock('drizzle-orm', () => ({
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
ne: vi.fn((left: unknown, right: unknown) => ({ op: 'ne', left, right })),
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })),
}));
import { eq, sql } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import { emailPreferences } from '../../db/schema';
import { emailPreferences, emailProviders, emailRecipients, emailTemplates } from '../../db/schema';
import { EmailPreferencesRepository } from './email-preferences.repository';
describe('EmailPreferencesRepository', () => {
@@ -32,37 +34,113 @@ describe('EmailPreferencesRepository', () => {
expect(result).toBe(queryResult);
});
it('upserts by user id and bumps updatedAt', () => {
const returningResult = Promise.resolve([{ userId: 9, defaultProviderId: 88 }]);
const conflictBuilder = {
returning: vi.fn().mockReturnValue(returningResult),
};
const insertBuilder = {
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue(conflictBuilder),
const makeTx = () => {
const returningResult = Promise.resolve([{ userId: 9, defaultRecipientId: 5 }]);
const conflict = { returning: vi.fn().mockReturnValue(returningResult) };
const values = { onConflictDoUpdate: vi.fn().mockReturnValue(conflict) };
const insert = { values: vi.fn().mockReturnValue(values) };
const updates: Array<{ table: unknown; set: Record<string, unknown>; where: unknown }> = [];
const tx = {
insert: vi.fn().mockReturnValue(insert),
update: vi.fn((table: unknown) => {
const entry = { table, set: {}, where: undefined as unknown };
updates.push(entry);
const builder = {
set: vi.fn((set: Record<string, unknown>) => {
entry.set = set;
return builder;
}),
where: vi.fn((where: unknown) => {
entry.where = where;
return Promise.resolve();
}),
};
return builder;
}),
};
const db = { transaction: vi.fn((fn: (t: typeof tx) => unknown) => fn(tx)) };
return { db, tx, insert, values, updates };
};
const db = { insert: vi.fn().mockReturnValue(insertBuilder) };
it('writes only the defaults it was given and moves only those flags', async () => {
const { db, tx, insert, values, updates } = makeTx();
const repo = new EmailPreferencesRepository(db as never);
const result = repo.upsert(9, { defaultProviderId: 88 });
const row = await repo.setDefaults(9, { defaultRecipientId: 5 });
expect(db.insert).toHaveBeenCalledWith(emailPreferences);
expect(insertBuilder.values).toHaveBeenCalledWith({ userId: 9, defaultProviderId: 88 });
expect(row).toEqual({ userId: 9, defaultRecipientId: 5 });
expect(tx.insert).toHaveBeenCalledWith(emailPreferences);
expect(insert.values).toHaveBeenCalledWith({ userId: 9, defaultRecipientId: 5 });
const conflict = values.onConflictDoUpdate.mock.calls[0][0] as { target: unknown; set: Record<string, unknown> };
expect(conflict.target).toBe(emailPreferences.userId);
expect(conflict.set.defaultRecipientId).toBe(5);
// Clear the others first, then set the chosen one: a single flipping UPDATE trips the
// one-default-per-user unique index.
expect(updates.map((u) => u.table)).toEqual([emailRecipients, emailRecipients]);
expect(updates[0].set.isDefault).toBe(false);
expect(updates[0].where).toEqual({
op: 'and',
clauses: [
{ op: 'eq', left: emailRecipients.userId, right: 9 },
{ op: 'eq', left: emailRecipients.isDefault, right: true },
{ op: 'ne', left: emailRecipients.id, right: 5 },
],
});
expect(updates[1].set.isDefault).toBe(true);
expect(updates[1].where).toEqual({
op: 'and',
clauses: [
{ op: 'eq', left: emailRecipients.userId, right: 9 },
{ op: 'eq', left: emailRecipients.id, right: 5 },
],
});
});
const onConflictDoUpdate = insertBuilder.values.mock.results[0].value.onConflictDoUpdate as vi.Mock;
expect(onConflictDoUpdate).toHaveBeenCalledTimes(1);
const conflictArg = onConflictDoUpdate.mock.calls[0][0] as {
target: unknown;
set: Record<string, unknown>;
it("clears every flag of a kind when the default is cleared or not the caller's own row", async () => {
const { db, updates } = makeTx();
const repo = new EmailPreferencesRepository(db as never);
await repo.setDefaults(9, { defaultProviderId: null, defaultTemplateId: 31 });
expect(updates.map((u) => u.table)).toEqual([emailProviders, emailTemplates, emailTemplates]);
expect(updates[0].set.isDefault).toBe(false);
expect(updates[0].where).toEqual({
op: 'and',
clauses: [
{ op: 'eq', left: emailProviders.userId, right: 9 },
{ op: 'eq', left: emailProviders.isDefault, right: true },
],
});
expect(updates[2].set.isDefault).toBe(true);
expect(updates[2].where).toEqual({
op: 'and',
clauses: [
{ op: 'eq', left: emailTemplates.userId, right: 9 },
{ op: 'eq', left: emailTemplates.id, right: 31 },
],
});
});
it('reads the rows still flagged isDefault, one per kind, for the owner', async () => {
const limits: unknown[] = [];
const rows = [[{ id: 4 }], [], [{ id: 31 }]];
const db = {
select: vi.fn(() => {
const builder = {
from: vi.fn(() => builder),
where: vi.fn(() => builder),
limit: vi.fn((n: unknown) => {
limits.push(n);
return Promise.resolve(rows[limits.length - 1]);
}),
};
return builder;
}),
};
const repo = new EmailPreferencesRepository(db as never);
expect(conflictArg.target).toBe(emailPreferences.userId);
expect(conflictArg.set.defaultProviderId).toBe(88);
expect(conflictArg.set.updatedAt).toEqual(expect.objectContaining({ op: 'sql', text: 'now()' }));
expect(sql).toHaveBeenCalled();
expect(conflictBuilder.returning).toHaveBeenCalled();
expect(result).toBe(returningResult);
expect(await repo.findFlaggedDefaults(9)).toEqual({ providerId: 4, recipientId: null, templateId: 31 });
expect(and).toHaveBeenCalledWith({ op: 'eq', left: emailProviders.userId, right: 9 }, { op: 'eq', left: emailProviders.isDefault, right: true });
expect(limits).toEqual([1, 1, 1]);
});
});
@@ -1,12 +1,20 @@
import { Inject, Injectable } from '@nestjs/common';
import { eq, sql } from 'drizzle-orm';
import { and, eq, ne, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { emailPreferences } from '../../db/schema';
import { emailPreferences, emailProviders, emailRecipients, emailTemplates } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
type FlaggedTable = typeof emailProviders | typeof emailRecipients | typeof emailTemplates;
export interface EmailDefaultsPatch {
defaultProviderId?: number | null;
defaultRecipientId?: number | null;
defaultTemplateId?: number | null;
}
@Injectable()
export class EmailPreferencesRepository {
@@ -16,14 +24,66 @@ export class EmailPreferencesRepository {
return this.db.select().from(emailPreferences).where(eq(emailPreferences.userId, userId)).limit(1);
}
upsert(userId: number, values: Partial<typeof emailPreferences.$inferInsert>) {
return this.db
.insert(emailPreferences)
.values({ userId, ...values })
.onConflictDoUpdate({
target: emailPreferences.userId,
set: { ...values, updatedAt: sql`now()` },
})
.returning();
/**
* The rows still flagged isDefault from before the preferences row became the only source of
* truth. Read as a fallback, so an account that only ever used the stars keeps its defaults.
*/
async findFlaggedDefaults(userId: number) {
const [[provider], [recipient], [template]] = await Promise.all([
this.db
.select({ id: emailProviders.id })
.from(emailProviders)
.where(and(eq(emailProviders.userId, userId), eq(emailProviders.isDefault, true)))
.limit(1),
this.db
.select({ id: emailRecipients.id })
.from(emailRecipients)
.where(and(eq(emailRecipients.userId, userId), eq(emailRecipients.isDefault, true)))
.limit(1),
this.db
.select({ id: emailTemplates.id })
.from(emailTemplates)
.where(and(eq(emailTemplates.userId, userId), eq(emailTemplates.isDefault, true)))
.limit(1),
]);
return { providerId: provider?.id ?? null, recipientId: recipient?.id ?? null, templateId: template?.id ?? null };
}
/**
* Writes the defaults and moves the legacy isDefault flags to match, in one transaction. A
* shared server or the built-in template cannot carry the caller's flag, so choosing one clears
* the caller's flags and leaves the preference as the only record.
*/
setDefaults(userId: number, patch: EmailDefaultsPatch) {
return this.db.transaction(async (tx) => {
const [row] = await tx
.insert(emailPreferences)
.values({ userId, ...patch })
.onConflictDoUpdate({ target: emailPreferences.userId, set: { ...patch, updatedAt: sql`now()` } })
.returning();
if (patch.defaultProviderId !== undefined) await this.moveFlag(tx, emailProviders, userId, patch.defaultProviderId);
if (patch.defaultRecipientId !== undefined) await this.moveFlag(tx, emailRecipients, userId, patch.defaultRecipientId);
if (patch.defaultTemplateId !== undefined) await this.moveFlag(tx, emailTemplates, userId, patch.defaultTemplateId);
return row;
});
}
/**
* Clears the caller's other flags before setting the chosen one. A single flipping UPDATE trips
* the one-default-per-user unique index, which Postgres checks row by row. A row the caller does
* not own (a shared server, the built-in template) is never flagged.
*/
private async moveFlag(tx: Tx, table: FlaggedTable, userId: number, id: number | null) {
const owned = eq(table.userId, userId);
await tx
.update(table)
.set({ isDefault: false, updatedAt: sql`now()` })
.where(id === null ? and(owned, eq(table.isDefault, true)) : and(owned, eq(table.isDefault, true), ne(table.id, id)));
if (id !== null) {
await tx
.update(table)
.set({ isDefault: true, updatedAt: sql`now()` })
.where(and(owned, eq(table.id, id)));
}
}
}
@@ -31,7 +31,7 @@ describe('EmailPreferencesService', () => {
contentFilters: EMPTY_CONTENT_FILTER_RULES,
};
const mockPrefs = { userId: 1, defaultProviderId: 10, defaultRecipientId: 20 };
const mockPrefs = { id: 1, userId: 1, defaultProviderId: 10, defaultRecipientId: 20, defaultTemplateId: 30 };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -41,7 +41,8 @@ describe('EmailPreferencesService', () => {
provide: EmailPreferencesRepository,
useValue: {
findByUserId: vi.fn().mockResolvedValue([mockPrefs]),
upsert: vi.fn().mockResolvedValue([mockPrefs]),
findFlaggedDefaults: vi.fn().mockResolvedValue({ providerId: null, recipientId: null, templateId: null }),
setDefaults: vi.fn().mockResolvedValue(mockPrefs),
},
},
{
@@ -66,26 +67,47 @@ describe('EmailPreferencesService', () => {
templateService = module.get<EmailTemplateService>(EmailTemplateService);
});
it('should find for user', async () => {
it('returns the stored defaults without looking at the flags', async () => {
const result = await service.findForUser(mockUser);
expect(result.defaultProviderId).toBe(10);
expect(result).toEqual(mockPrefs);
expect(repo.findFlaggedDefaults).not.toHaveBeenCalled();
});
it('should return default object if not found', async () => {
it('returns empty defaults when the account has neither a row nor flags', async () => {
(repo.findByUserId as vi.Mock).mockResolvedValue([]);
const result = await service.findForUser(mockUser);
expect(result.userId).toBe(1);
expect(result.defaultProviderId).toBeNull();
expect(result).toEqual({ userId: 1, defaultProviderId: null, defaultRecipientId: null, defaultTemplateId: null });
expect(await service.getForUser(1)).toBeNull();
});
it('should upsert preferences', async () => {
it('falls back to rows still flagged isDefault, so star-only accounts keep their defaults', async () => {
(repo.findByUserId as vi.Mock).mockResolvedValue([]);
(repo.findFlaggedDefaults as vi.Mock).mockResolvedValue({ providerId: 4, recipientId: 5, templateId: null });
expect(await service.getForUser(1)).toEqual({ userId: 1, defaultProviderId: 4, defaultRecipientId: 5, defaultTemplateId: null });
});
it('fills only the empty fields from the flags', async () => {
(repo.findByUserId as vi.Mock).mockResolvedValue([{ ...mockPrefs, defaultRecipientId: null }]);
(repo.findFlaggedDefaults as vi.Mock).mockResolvedValue({ providerId: 99, recipientId: 7, templateId: 98 });
expect(await service.getForUser(1)).toEqual({ ...mockPrefs, defaultRecipientId: 7 });
});
it('writes the defaults through setDefaults, which also moves the flags', async () => {
const dto = { defaultProviderId: 99 };
const result = await service.upsert(dto, mockUser);
expect(repo.upsert).toHaveBeenCalledWith(1, dto);
expect(providerService.findOne).toHaveBeenCalledWith(99, mockUser);
expect(repo.setDefaults).toHaveBeenCalledWith(1, dto);
expect(result.defaultProviderId).toBe(10);
});
it('clears a default without validating anything', async () => {
await service.upsert({ defaultRecipientId: null }, mockUser);
expect(recipientService.getOwnedById).not.toHaveBeenCalled();
expect(repo.setDefaults).toHaveBeenCalledWith(1, { defaultRecipientId: null });
});
it('should validate recipient ownership when defaultRecipientId is provided', async () => {
await service.upsert({ defaultRecipientId: 55 }, mockUser);
expect(recipientService.getOwnedById).toHaveBeenCalledWith(55, mockUser);
@@ -96,8 +118,9 @@ describe('EmailPreferencesService', () => {
expect(templateService.findOne).toHaveBeenCalledWith(66, mockUser);
});
it('should get for user by id', async () => {
const result = await service.getForUser(1);
expect(result?.defaultProviderId).toBe(10);
it('does not write when validation fails', async () => {
(providerService.findOne as vi.Mock).mockRejectedValue(new Error('No access to this provider'));
await expect(service.upsert({ defaultProviderId: 3 }, mockUser)).rejects.toThrow('No access to this provider');
expect(repo.setDefaults).not.toHaveBeenCalled();
});
});
@@ -1,12 +1,17 @@
import { Injectable } from '@nestjs/common';
import type { RequestUser } from '../../common/types/request-user';
import type { EmailPreferences } from '../../db/schema';
import { EmailPreferencesRepository } from './email-preferences.repository';
import { EmailProviderService } from './email-provider.service';
import { EmailRecipientService } from './email-recipient.service';
import { EmailTemplateService } from './email-template.service';
import { UpdateEmailPreferencesDto } from './dto/update-email-preferences.dto';
/** The stored row when there is one, with every default replaced by the one sending will use. */
export type EffectiveEmailPreferences = Pick<EmailPreferences, 'userId' | 'defaultProviderId' | 'defaultRecipientId' | 'defaultTemplateId'> &
Partial<Pick<EmailPreferences, 'id' | 'createdAt' | 'updatedAt'>>;
@Injectable()
export class EmailPreferencesService {
constructor(
@@ -16,12 +21,11 @@ export class EmailPreferencesService {
private readonly templateService: EmailTemplateService,
) {}
async findForUser(user: RequestUser) {
const [prefs] = await this.repo.findByUserId(user.id);
return prefs ?? { userId: user.id, defaultProviderId: null, defaultRecipientId: null, defaultTemplateId: null };
async findForUser(user: RequestUser): Promise<EffectiveEmailPreferences> {
return (await this.getForUser(user.id)) ?? { userId: user.id, defaultProviderId: null, defaultRecipientId: null, defaultTemplateId: null };
}
async upsert(dto: UpdateEmailPreferencesDto, user: RequestUser) {
async upsert(dto: UpdateEmailPreferencesDto, user: RequestUser): Promise<EffectiveEmailPreferences> {
if (dto.defaultProviderId !== undefined && dto.defaultProviderId !== null) {
await this.providerService.findOne(dto.defaultProviderId, user);
}
@@ -31,12 +35,25 @@ export class EmailPreferencesService {
if (dto.defaultTemplateId !== undefined && dto.defaultTemplateId !== null) {
await this.templateService.findOne(dto.defaultTemplateId, user);
}
const [updated] = await this.repo.upsert(user.id, dto);
return updated;
await this.repo.setDefaults(user.id, dto);
return this.findForUser(user);
}
async getForUser(userId: number) {
/**
* The defaults sending actually uses: the preferences row, falling back to a row still flagged
* isDefault for any default the row leaves empty. Null when the account has neither.
*/
async getForUser(userId: number): Promise<EffectiveEmailPreferences | null> {
const [prefs] = await this.repo.findByUserId(userId);
return prefs ?? null;
const needsFallback = !prefs || prefs.defaultProviderId === null || prefs.defaultRecipientId === null || prefs.defaultTemplateId === null;
const flagged = needsFallback ? await this.repo.findFlaggedDefaults(userId) : null;
const defaults = {
defaultProviderId: prefs?.defaultProviderId ?? flagged?.providerId ?? null,
defaultRecipientId: prefs?.defaultRecipientId ?? flagged?.recipientId ?? null,
defaultTemplateId: prefs?.defaultTemplateId ?? flagged?.templateId ?? null,
};
if (prefs) return { ...prefs, ...defaults };
if (defaults.defaultProviderId === null && defaults.defaultRecipientId === null && defaults.defaultTemplateId === null) return null;
return { userId, ...defaults };
}
}
@@ -105,40 +105,6 @@ describe('EmailProviderRepository', () => {
expect(sql).toHaveBeenCalled();
});
it('clearDefault only clears current owners default rows', () => {
const { db, updateBuilder } = makeDb();
const repo = new EmailProviderRepository(db as never);
void repo.clearDefault(4);
expect(db.update).toHaveBeenCalledWith(emailProviders);
expect(updateBuilder.set).toHaveBeenCalledWith({ isDefault: false });
expect(updateBuilder.where).toHaveBeenCalledWith({
op: 'and',
clauses: [
{ op: 'eq', left: emailProviders.userId, right: 4 },
{ op: 'eq', left: emailProviders.isDefault, right: true },
],
});
});
it('setDefault atomically flips defaults within the owner scope and returns updated rows', () => {
const { db, updateBuilder } = makeDb();
const repo = new EmailProviderRepository(db as never);
void repo.setDefault(9, 4);
expect(db.update).toHaveBeenCalledWith(emailProviders);
expect(updateBuilder.set).toHaveBeenCalledWith(
expect.objectContaining({
isDefault: expect.objectContaining({ op: 'sql', text: 'case when = then true else false end' }),
updatedAt: expect.objectContaining({ op: 'sql', text: 'now()' }),
}),
);
expect(updateBuilder.where).toHaveBeenCalledWith({ op: 'eq', left: emailProviders.userId, right: 4 });
expect(updateBuilder.returning).toHaveBeenCalled();
});
it('setSharedByOwner restricts by owner id', () => {
const { db, updateBuilder } = makeDb();
const repo = new EmailProviderRepository(db as never);
@@ -36,24 +36,6 @@ export class EmailProviderRepository {
.returning();
}
clearDefault(userId: number) {
return this.db
.update(emailProviders)
.set({ isDefault: false })
.where(and(eq(emailProviders.userId, userId), eq(emailProviders.isDefault, true)));
}
setDefault(id: number, userId: number) {
return this.db
.update(emailProviders)
.set({
isDefault: sql`case when ${emailProviders.id} = ${id} then true else false end`,
updatedAt: sql`now()`,
})
.where(eq(emailProviders.userId, userId))
.returning();
}
setSharedByOwner(id: number, userId: number, isShared: boolean) {
return this.db
.update(emailProviders)
@@ -69,6 +51,15 @@ export class EmailProviderRepository {
.returning();
}
/** Kept on the row so the settings page can say whether a server works without testing it again. */
recordTestResult(id: number, ok: boolean, error: string | null) {
return this.db
.update(emailProviders)
.set({ lastTestedAt: sql`now()`, lastTestOk: ok, lastTestError: error })
.where(eq(emailProviders.id, id))
.returning();
}
findSystemProvider() {
return this.db.select().from(emailProviders).where(eq(emailProviders.isSystemProvider, true)).limit(1);
}
@@ -3,6 +3,7 @@ import { EmailProviderService } from './email-provider.service';
import { EmailProviderRepository } from './email-provider.repository';
import { EmailEncryptionService } from './email-encryption.service';
import { EmailTransportService } from './email-transport.service';
import { EmailPreferencesRepository } from './email-preferences.repository';
import type { RequestUser } from '../../common/types/request-user';
import { EMPTY_CONTENT_FILTER_RULES, Permission } from '@bookorbit/types';
@@ -11,6 +12,7 @@ describe('EmailProviderService', () => {
let repo: EmailProviderRepository;
let encryption: EmailEncryptionService;
let transport: EmailTransportService;
let preferences: EmailPreferencesRepository;
const mockUser: RequestUser = {
id: 1,
@@ -64,8 +66,7 @@ describe('EmailProviderService', () => {
insert: vi.fn().mockResolvedValue([mockProvider]),
update: vi.fn().mockResolvedValue([mockProvider]),
delete: vi.fn(),
clearDefault: vi.fn(),
setDefault: vi.fn().mockResolvedValue([mockProvider]),
recordTestResult: vi.fn().mockResolvedValue([mockProvider]),
setSharedByOwner: vi.fn().mockResolvedValue([{ ...mockProvider, isShared: true }]),
setSystemProvider: vi.fn().mockResolvedValue({ ...mockProvider, isSystemProvider: true }),
clearSystemProvider: vi.fn(),
@@ -81,7 +82,9 @@ describe('EmailProviderService', () => {
verifyTransporter: vi.fn(),
} as unknown as EmailTransportService;
service = new EmailProviderService(repo, encryption, transport);
preferences = { setDefaults: vi.fn().mockResolvedValue({}) } as unknown as EmailPreferencesRepository;
service = new EmailProviderService(repo, encryption, transport, preferences);
});
describe('findAll', () => {
@@ -178,11 +181,17 @@ describe('EmailProviderService', () => {
});
describe('setDefault', () => {
it('should set default provider', async () => {
it('writes the preference, which is what sending reads', async () => {
const result = await service.setDefault(10, mockUser);
expect(repo.setDefault).toHaveBeenCalledWith(10, 1);
expect(preferences.setDefaults).toHaveBeenCalledWith(1, { defaultProviderId: 10 });
expect(result.id).toBe(10);
});
it('refuses a provider the caller does not own', async () => {
(repo.findById as vi.Mock).mockResolvedValue([{ ...mockProvider, userId: 2, isShared: true }]);
await expect(service.setDefault(10, mockUser)).rejects.toThrow(ForbiddenException);
expect(preferences.setDefaults).not.toHaveBeenCalled();
});
});
describe('getProviderWithDecryptedPassword', () => {
@@ -247,6 +256,16 @@ describe('EmailProviderService', () => {
expect(result.error).toBe('Connection failed');
});
it('keeps the outcome on the provider so the page can show it later', async () => {
(transport.verifyTransporter as vi.Mock).mockResolvedValueOnce(undefined);
await service.testConnection(10, mockUser);
expect(repo.recordTestResult).toHaveBeenLastCalledWith(10, true, null);
(transport.verifyTransporter as vi.Mock).mockRejectedValueOnce(new Error('connect ECONNREFUSED 127.0.0.1:1025'));
await service.testConnection(10, mockUser);
expect(repo.recordTestResult).toHaveBeenLastCalledWith(10, false, 'connect ECONNREFUSED 127.0.0.1:1025');
});
it('should pass tlsRejectUnauthorized to the transporter builder', async () => {
(repo.findById as vi.Mock).mockResolvedValue([{ ...mockProvider, tlsRejectUnauthorized: false }]);
(transport.verifyTransporter as vi.Mock).mockResolvedValue(undefined);
@@ -6,6 +6,7 @@ import { emailProviders } from '../../db/schema';
import { EmailEncryptionService } from './email-encryption.service';
import { EmailTransportService } from './email-transport.service';
import { EmailProviderRepository } from './email-provider.repository';
import { EmailPreferencesRepository } from './email-preferences.repository';
import { CreateEmailProviderDto } from './dto/create-email-provider.dto';
import { UpdateEmailProviderDto } from './dto/update-email-provider.dto';
import type { EmailProvider } from '../../db/schema';
@@ -17,6 +18,7 @@ export class EmailProviderService {
private readonly repo: EmailProviderRepository,
private readonly encryption: EmailEncryptionService,
private readonly transport: EmailTransportService,
private readonly preferences: EmailPreferencesRepository,
) {}
async findAll(user: RequestUser) {
@@ -95,7 +97,8 @@ export class EmailProviderService {
async setDefault(id: number, user: RequestUser) {
await this.getOwned(id, user);
const updated = (await this.repo.setDefault(id, user.id)).find((provider) => provider.id === id);
await this.preferences.setDefaults(user.id, { defaultProviderId: id });
const [updated] = await this.repo.findById(id);
if (!updated) throw new NotFoundException('Provider not found');
return this.sanitize(updated);
}
@@ -138,12 +141,15 @@ export class EmailProviderService {
tlsRejectUnauthorized: provider.tlsRejectUnauthorized,
});
let result: { success: boolean; error?: string };
try {
await this.transport.verifyTransporter(transporter);
return { success: true };
result = { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
result = { success: false, error: err instanceof Error ? err.message : String(err) };
}
await this.repo.recordTestResult(id, result.success, result.error ? result.error.slice(0, 1000) : null);
return result;
}
async getProviderWithDecryptedPassword(id: number, user: RequestUser): Promise<EmailProvider & { plainPassword: string | null }> {
@@ -0,0 +1,88 @@
import { BadRequestException } from '@nestjs/common';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { EmailProviderResolver } from './email-provider-resolver';
import { EmailRecipientService } from './email-recipient.service';
import { EmailRecipientTestService } from './email-recipient-test.service';
import { EmailTransportService } from './email-transport.service';
describe('EmailRecipientTestService', () => {
const user: RequestUser = {
id: 1,
username: 'reader',
name: 'Reader',
email: 'reader@example.com',
active: true,
isSuperuser: false,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'manual',
permissions: [],
contentFilters: EMPTY_CONTENT_FILTER_RULES,
};
const config = {
host: 'smtp.example.com',
port: 587,
username: 'u',
password: 'p',
fromName: 'BookOrbit',
fromAddress: 'books@example.com',
auth: true,
ssl: false,
startTls: true,
tlsRejectUnauthorized: true,
};
let recipientService: EmailRecipientService;
let resolver: EmailProviderResolver;
let transport: EmailTransportService;
let sendMail: ReturnType<typeof vi.fn>;
let service: EmailRecipientTestService;
beforeEach(() => {
sendMail = vi.fn().mockResolvedValue({});
recipientService = {
getOwnedById: vi.fn().mockResolvedValue({ id: 6, email: 'inbox@example.com', deviceType: null }),
} as unknown as EmailRecipientService;
resolver = { resolve: vi.fn().mockResolvedValue({ config, providerId: 2 }) } as unknown as EmailProviderResolver;
transport = { buildTransporter: vi.fn().mockReturnValue({ sendMail }) } as unknown as EmailTransportService;
service = new EmailRecipientTestService(recipientService, resolver, transport);
});
it('sends the given text from the default server and reports success', async () => {
const result = await service.send(6, 'BookOrbit test', 'It works.', user);
expect(recipientService.getOwnedById).toHaveBeenCalledWith(6, user);
expect(resolver.resolve).toHaveBeenCalledWith(user, null);
expect(sendMail).toHaveBeenCalledWith({
from: 'BookOrbit <books@example.com>',
to: 'inbox@example.com',
subject: 'BookOrbit test',
text: 'It works.',
});
expect(result).toEqual({ success: true });
});
it('answers with the server error instead of throwing', async () => {
sendMail.mockRejectedValue(new Error('535 Authentication failed'));
expect(await service.send(6, 'S', 'T', user)).toEqual({ success: false, error: '535 Authentication failed' });
});
it('refuses a Kindle, which would answer a message without a document with an error email', async () => {
(recipientService.getOwnedById as vi.Mock).mockResolvedValue({ id: 7, email: 'x@kindle.com', deviceType: 'kindle' });
await expect(service.send(7, 'S', 'T', user)).rejects.toThrow(BadRequestException);
expect(sendMail).not.toHaveBeenCalled();
});
it('propagates ownership errors before touching the mail server', async () => {
(recipientService.getOwnedById as vi.Mock).mockRejectedValue(new Error('Cannot modify this recipient'));
await expect(service.send(6, 'S', 'T', user)).rejects.toThrow('Cannot modify this recipient');
expect(resolver.resolve).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,59 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import type { RequestUser } from '../../common/types/request-user';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { EmailProviderResolver } from './email-provider-resolver';
import { EmailRecipientService } from './email-recipient.service';
import { EmailTransportService } from './email-transport.service';
const EMAIL_RECIPIENT_TEST_EVENT = 'email.recipient-test';
/**
* Sends one plain message, without a book, through the caller's default server. It answers with
* the server's verdict instead of queueing, because the point is to see that verdict.
*/
@Injectable()
export class EmailRecipientTestService {
private readonly logger = new Logger(EmailRecipientTestService.name);
constructor(
private readonly recipientService: EmailRecipientService,
private readonly providerResolver: EmailProviderResolver,
private readonly transport: EmailTransportService,
) {}
async send(recipientId: number, subject: string, text: string, user: RequestUser): Promise<{ success: boolean; error?: string }> {
const startedAt = Date.now();
const recipient = await this.recipientService.getOwnedById(recipientId, user);
// Amazon answers a message without a document with an error email of its own.
if (recipient.deviceType === 'kindle')
throw new BadRequestException({ code: 'KINDLE_TEST_UNSUPPORTED', message: 'Kindle accepts only documents' });
const resolved = await this.providerResolver.resolve(user, null);
this.logger.log(
`[${EMAIL_RECIPIENT_TEST_EVENT}] [start] userId=${user.id} recipientId=${recipient.id} providerId=${resolved.providerId} - test send started`,
);
const transporter = this.transport.buildTransporter(resolved.config);
const fromAddress = resolved.config.fromAddress?.trim();
const fromName = resolved.config.fromName?.trim();
try {
await transporter.sendMail({
...(fromAddress ? { from: fromName ? `${fromName} <${fromAddress}>` : fromAddress } : {}),
to: recipient.email,
subject,
text,
});
this.logger.log(
`[${EMAIL_RECIPIENT_TEST_EVENT}] [end] userId=${user.id} recipientId=${recipient.id} durationMs=${Date.now() - startedAt} - test send completed`,
);
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const errorClass = error instanceof Error && error.name ? error.name : 'Error';
this.logger.warn(
`[${EMAIL_RECIPIENT_TEST_EVENT}] [fail] userId=${user.id} recipientId=${recipient.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${sanitizeLogValue(message)}" - test send failed`,
);
return { success: false, error: message.slice(0, 1000) };
}
}
}
@@ -6,6 +6,7 @@ import { EmailRecipientController } from './email-recipient.controller';
import type { CreateEmailRecipientDto } from './dto/create-email-recipient.dto';
import type { UpdateEmailRecipientDto } from './dto/update-email-recipient.dto';
import { EmailRecipientService } from './email-recipient.service';
import { EmailRecipientTestService } from './email-recipient-test.service';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
describe('EmailRecipientController', () => {
@@ -35,7 +36,9 @@ describe('EmailRecipientController', () => {
setDefault: vi.fn(),
} as unknown as EmailRecipientService;
const controller = new EmailRecipientController(service);
const testService = { send: vi.fn() } as unknown as EmailRecipientTestService;
const controller = new EmailRecipientController(service, testService);
beforeEach(() => {
vi.clearAllMocks();
@@ -66,6 +69,11 @@ describe('EmailRecipientController', () => {
expect(service.update).toHaveBeenCalledWith(4, updateDto, user);
});
it("delegates a test email with the client's localized text", async () => {
await controller.sendTest(4, { subject: 'BookOrbit test', text: 'It works.' }, user);
expect(testService.send).toHaveBeenCalledWith(4, 'BookOrbit test', 'It works.', user);
});
it('delegates remove and setDefault', async () => {
await controller.remove(4, user);
await controller.setDefault(4, user);
@@ -8,11 +8,16 @@ import type { RequestUser } from '../../common/types/request-user';
import { CreateEmailRecipientDto } from './dto/create-email-recipient.dto';
import { UpdateEmailRecipientDto } from './dto/update-email-recipient.dto';
import { EmailRecipientService } from './email-recipient.service';
import { EmailRecipientTestService } from './email-recipient-test.service';
import { SendRecipientTestDto } from './dto/send-recipient-test.dto';
@Controller('email/recipients')
@RequirePermission(Permission.EmailSend)
export class EmailRecipientController {
constructor(private readonly service: EmailRecipientService) {}
constructor(
private readonly service: EmailRecipientService,
private readonly testService: EmailRecipientTestService,
) {}
@Get()
findAll(@CurrentUser() user: RequestUser) {
@@ -58,6 +63,12 @@ export class EmailRecipientController {
return this.service.remove(id, user);
}
@Post(':id/test')
@HttpCode(HttpStatus.OK)
sendTest(@Param('id', ParseIntPipe) id: number, @Body() dto: SendRecipientTestDto, @CurrentUser() user: RequestUser) {
return this.testService.send(id, dto.subject, dto.text, user);
}
@Patch(':id/default')
setDefault(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.service.setDefault(id, user);
@@ -116,42 +116,6 @@ describe('EmailRecipientRepository', () => {
expect(updateBuilder.returning).toHaveBeenCalled();
});
it('clears default recipient only for the owner', () => {
const { db, updateBuilder } = makeDb();
const repo = new EmailRecipientRepository(db as never);
void repo.clearDefault(3);
expect(db.update).toHaveBeenCalledWith(emailRecipients);
expect(updateBuilder.set).toHaveBeenCalledWith({ isDefault: false });
expect(eq).toHaveBeenCalledWith(emailRecipients.userId, 3);
expect(eq).toHaveBeenCalledWith(emailRecipients.isDefault, true);
expect(updateBuilder.where).toHaveBeenCalledWith({
op: 'and',
clauses: [
{ op: 'eq', left: emailRecipients.userId, right: 3 },
{ op: 'eq', left: emailRecipients.isDefault, right: true },
],
});
});
it('sets default recipient only for the owner and returns row', () => {
const { db, updateBuilder } = makeDb();
const repo = new EmailRecipientRepository(db as never);
void repo.setDefault(11, 3);
expect(db.update).toHaveBeenCalledWith(emailRecipients);
expect(updateBuilder.set).toHaveBeenCalledWith(
expect.objectContaining({
isDefault: expect.objectContaining({ op: 'sql', parts: expect.any(Array), values: [emailRecipients.id, 11] }),
updatedAt: expect.objectContaining({ op: 'sql', parts: expect.any(Array), values: [] }),
}),
);
expect(updateBuilder.where).toHaveBeenCalledWith({ op: 'eq', left: emailRecipients.userId, right: 3 });
expect(updateBuilder.returning).toHaveBeenCalled();
});
it('deletes only matching owner rows', () => {
const { db, deleteBuilder } = makeDb();
const repo = new EmailRecipientRepository(db as never);
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { and, eq, inArray } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -38,24 +38,6 @@ export class EmailRecipientRepository {
.returning();
}
clearDefault(userId: number) {
return this.db
.update(emailRecipients)
.set({ isDefault: false })
.where(and(eq(emailRecipients.userId, userId), eq(emailRecipients.isDefault, true)));
}
setDefault(id: number, userId: number): Promise<EmailRecipientRow[]> {
return this.db
.update(emailRecipients)
.set({
isDefault: sql`case when ${emailRecipients.id} = ${id} then true else false end`,
updatedAt: sql`now()`,
})
.where(eq(emailRecipients.userId, userId))
.returning();
}
delete(id: number, userId: number): Promise<EmailRecipientRow[]> {
return this.db
.delete(emailRecipients)
@@ -3,6 +3,8 @@ import { Test, TestingModule } from '@nestjs/testing';
import { EmailRecipientService } from './email-recipient.service';
import { EmailRecipientRepository } from './email-recipient.repository';
import { EmailTemplateService } from './email-template.service';
import { EmailPreferencesRepository } from './email-preferences.repository';
import { EmailSendLogRepository } from './email-send-log.repository';
import type { RequestUser } from '../../common/types/request-user';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
@@ -10,6 +12,7 @@ describe('EmailRecipientService', () => {
let service: EmailRecipientService;
let repo: EmailRecipientRepository;
let templateService: EmailTemplateService;
let preferences: EmailPreferencesRepository;
const mockUser: RequestUser = {
id: 1,
@@ -43,8 +46,15 @@ describe('EmailRecipientService', () => {
insert: vi.fn().mockResolvedValue([mockRecipient]),
update: vi.fn().mockResolvedValue([mockRecipient]),
delete: vi.fn(),
clearDefault: vi.fn(),
setDefault: vi.fn().mockResolvedValue([mockRecipient]),
},
},
{ provide: EmailPreferencesRepository, useValue: { setDefaults: vi.fn().mockResolvedValue({}) } },
{
provide: EmailSendLogRepository,
useValue: {
findLastSendPerRecipient: vi
.fn()
.mockResolvedValue([{ recipientId: 10, bookId: 487, bookTitle: 'Verity', status: 'sent', createdAt: '2026-10-12T20:01:00.000Z' }]),
},
},
{
@@ -59,6 +69,7 @@ describe('EmailRecipientService', () => {
service = module.get<EmailRecipientService>(EmailRecipientService);
repo = module.get<EmailRecipientRepository>(EmailRecipientRepository);
templateService = module.get<EmailTemplateService>(EmailTemplateService);
preferences = module.get<EmailPreferencesRepository>(EmailPreferencesRepository);
});
it('should find all for user', async () => {
@@ -67,6 +78,15 @@ describe('EmailRecipientService', () => {
expect(repo.findAllForUser).toHaveBeenCalledWith(1);
});
it('attaches the last send to each recipient, and null to one that never got anything', async () => {
(repo.findAllForUser as vi.Mock).mockResolvedValue([mockRecipient, { ...mockRecipient, id: 11, email: 'other@test.com' }]);
const result = await service.findAll(mockUser);
expect(result[0].lastSend).toEqual({ bookId: 487, bookTitle: 'Verity', status: 'sent', createdAt: '2026-10-12T20:01:00.000Z' });
expect(result[1].lastSend).toBeNull();
});
it('should find one', async () => {
const result = await service.findOne(10, mockUser);
expect(result.id).toBe(10);
@@ -111,9 +131,9 @@ describe('EmailRecipientService', () => {
expect(repo.delete).toHaveBeenCalledWith(10, 1);
});
it('should set default recipient', async () => {
it('sets the default recipient through the preference that quick send reads', async () => {
const result = await service.setDefault(10, mockUser);
expect(repo.setDefault).toHaveBeenCalledWith(10, 1);
expect(preferences.setDefaults).toHaveBeenCalledWith(1, { defaultRecipientId: 10 });
expect(result.id).toBe(10);
});
@@ -128,8 +148,9 @@ describe('EmailRecipientService', () => {
});
it('should throw NotFoundException on setDefault if not exists', async () => {
(repo.setDefault as vi.Mock).mockResolvedValue([]);
(repo.findByIds as vi.Mock).mockResolvedValue([]);
await expect(service.setDefault(10, mockUser)).rejects.toThrow(NotFoundException);
expect(preferences.setDefaults).not.toHaveBeenCalled();
});
it('should get owned recipients by ids', async () => {
@@ -2,6 +2,8 @@ import { BadRequestException, ConflictException, ForbiddenException, Injectable,
import type { RequestUser } from '../../common/types/request-user';
import { EmailRecipientRepository } from './email-recipient.repository';
import { EmailPreferencesRepository } from './email-preferences.repository';
import { EmailSendLogRepository } from './email-send-log.repository';
import { EmailTemplateService } from './email-template.service';
import { CreateEmailRecipientDto } from './dto/create-email-recipient.dto';
import { UpdateEmailRecipientDto } from './dto/update-email-recipient.dto';
@@ -13,10 +15,15 @@ export class EmailRecipientService {
constructor(
private readonly repo: EmailRecipientRepository,
private readonly templateService: EmailTemplateService,
private readonly preferences: EmailPreferencesRepository,
private readonly sendLog: EmailSendLogRepository,
) {}
findAll(user: RequestUser) {
return this.repo.findAllForUser(user.id);
/** Each recipient with the last book sent to it, so the settings page can show what arrived. */
async findAll(user: RequestUser) {
const [recipients, lastSends] = await Promise.all([this.repo.findAllForUser(user.id), this.sendLog.findLastSendPerRecipient(user.id)]);
const lastByRecipient = new Map(lastSends.map(({ recipientId, ...last }) => [recipientId, last]));
return recipients.map((recipient) => ({ ...recipient, lastSend: lastByRecipient.get(recipient.id) ?? null }));
}
async findOne(id: number, user: RequestUser) {
@@ -71,7 +78,8 @@ export class EmailRecipientService {
async setDefault(id: number, user: RequestUser) {
await this.getOwnedById(id, user);
const updated = (await this.repo.setDefault(id, user.id)).find((recipient) => recipient.id === id);
await this.preferences.setDefaults(user.id, { defaultRecipientId: id });
const [updated] = await this.repo.findById(id);
if (!updated) throw new NotFoundException('Recipient not found');
return updated;
}
@@ -25,6 +25,8 @@ describe('EmailSendLogController', () => {
const logService = {
findForUser: vi.fn(),
summary: vi.fn(),
findAttention: vi.fn(),
remove: vi.fn(),
} as unknown as EmailSendLogService;
@@ -40,17 +42,34 @@ describe('EmailSendLogController', () => {
it('uses default pagination values when query is empty', async () => {
await controller.findForUser({} as QuerySendLogDto, user);
expect(logService.findForUser).toHaveBeenCalledWith(user, 0, 20, undefined);
expect(logService.findForUser).toHaveBeenCalledWith(user, 0, 20, { bookId: undefined, status: undefined, recipientId: undefined });
});
it('forwards explicit pagination values', async () => {
await controller.findForUser({ page: 4, size: 5 }, user);
expect(logService.findForUser).toHaveBeenCalledWith(user, 4, 5, undefined);
expect(logService.findForUser).toHaveBeenCalledWith(user, 4, 5, { bookId: undefined, status: undefined, recipientId: undefined });
});
it('forwards the book filter', async () => {
await controller.findForUser({ bookId: 965 }, user);
expect(logService.findForUser).toHaveBeenCalledWith(user, 0, 20, 965);
expect(logService.findForUser).toHaveBeenCalledWith(user, 0, 20, { bookId: 965, status: undefined, recipientId: undefined });
});
it('forwards the status filter', async () => {
await controller.findForUser({ status: 'failed' }, user);
expect(logService.findForUser).toHaveBeenCalledWith(user, 0, 20, { bookId: undefined, status: 'failed', recipientId: undefined });
});
it('forwards the recipient filter', async () => {
await controller.findForUser({ recipientId: 7 }, user);
expect(logService.findForUser).toHaveBeenCalledWith(user, 0, 20, { bookId: undefined, status: undefined, recipientId: 7 });
});
it("delegates summary and attention to the caller's own log", async () => {
await controller.summary(user);
await controller.attention(user);
expect(logService.summary).toHaveBeenCalledWith(user);
expect(logService.findAttention).toHaveBeenCalledWith(user);
});
it('delegates remove', async () => {
@@ -18,7 +18,22 @@ export class EmailSendLogController {
@Get()
findForUser(@Query() query: QuerySendLogDto, @CurrentUser() user: RequestUser) {
return this.logService.findForUser(user, query.page ?? 0, query.size ?? 20, query.bookId);
return this.logService.findForUser(user, query.page ?? 0, query.size ?? 20, {
bookId: query.bookId,
status: query.status,
recipientId: query.recipientId,
});
}
@Get('summary')
summary(@CurrentUser() user: RequestUser) {
return this.logService.summary(user);
}
/** Failed sends from the last week that no later send has replaced. */
@Get('attention')
attention(@CurrentUser() user: RequestUser) {
return this.logService.findAttention(user);
}
@Delete(':id')
@@ -1,25 +1,31 @@
vi.mock('drizzle-orm', () => ({
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
gt: vi.fn((left: unknown, right: unknown) => ({ op: 'gt', left, right })),
gte: vi.fn((left: unknown, right: unknown) => ({ op: 'gte', left, right })),
desc: vi.fn((column: unknown) => ({ op: 'desc', column })),
count: vi.fn((expression?: unknown) => ({ op: 'count', expression })),
getTableColumns: vi.fn(() => ({ id: 'id-column' })),
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })),
}));
import { eq, sql } from 'drizzle-orm';
import { emailSendLog } from '../../db/schema';
import { bookFiles, bookMetadata, emailSendLog } from '../../db/schema';
import { EmailSendLogRepository } from './email-send-log.repository';
describe('EmailSendLogRepository', () => {
const makeDb = () => {
const selectBuilder = {
from: vi.fn(),
leftJoin: vi.fn(),
where: vi.fn(),
orderBy: vi.fn(),
limit: vi.fn(),
offset: vi.fn(),
};
selectBuilder.from.mockReturnValue(selectBuilder);
selectBuilder.leftJoin.mockReturnValue(selectBuilder);
selectBuilder.where.mockReturnValue(selectBuilder);
selectBuilder.orderBy.mockReturnValue(selectBuilder);
selectBuilder.limit.mockReturnValue(selectBuilder);
@@ -67,13 +73,13 @@ describe('EmailSendLogRepository', () => {
const repo = new EmailSendLogRepository(db as never);
void repo.findForUser(9, 20, 40);
expect(selectBuilder.where).toHaveBeenCalledWith({ op: 'eq', left: emailSendLog.userId, right: 9 });
expect(selectBuilder.where).toHaveBeenCalledWith({ op: 'and', clauses: [{ op: 'eq', left: emailSendLog.userId, right: 9 }] });
expect(selectBuilder.limit).toHaveBeenCalledWith(20);
expect(selectBuilder.offset).toHaveBeenCalledWith(40);
// The book filter is additive: ownership still constrains the query, so one user cannot read
// another's sends by naming a book id.
void repo.findForUser(9, 20, 0, 965);
void repo.findForUser(9, 20, 0, { bookId: 965 });
expect(selectBuilder.where).toHaveBeenLastCalledWith({
op: 'and',
clauses: [
@@ -82,11 +88,52 @@ describe('EmailSendLogRepository', () => {
],
});
void repo.findForUser(9, 20, 0, { status: 'failed' });
expect(selectBuilder.where).toHaveBeenLastCalledWith({
op: 'and',
clauses: [
{ op: 'eq', left: emailSendLog.userId, right: 9 },
{ op: 'eq', left: emailSendLog.status, right: 'failed' },
],
});
void repo.findAll(10, 0);
expect(selectBuilder.offset).toHaveBeenLastCalledWith(0);
expect(sql).toHaveBeenCalledWith(expect.any(Array), emailSendLog.createdAt);
});
it('joins the book title and the sent file onto user rows', () => {
const { db, selectBuilder } = makeDb();
const repo = new EmailSendLogRepository(db as never);
void repo.findForUser(9, 20, 0);
expect(db.select).toHaveBeenCalledWith(
expect.objectContaining({ bookTitle: bookMetadata.title, fileFormat: bookFiles.format, fileSizeBytes: bookFiles.sizeBytes }),
);
expect(selectBuilder.leftJoin).toHaveBeenCalledWith(bookMetadata, { op: 'eq', left: bookMetadata.bookId, right: emailSendLog.bookId });
expect(selectBuilder.leftJoin).toHaveBeenCalledWith(bookFiles, { op: 'eq', left: bookFiles.id, right: emailSendLog.bookFileId });
});
it('finds failures since a moment, scoped to the user, skipping any a later send replaced', () => {
const { db, selectBuilder } = makeDb();
const repo = new EmailSendLogRepository(db as never);
const since = new Date('2026-10-05T20:00:00Z');
void repo.findUnresolvedFailures(9, since, 20);
const where = selectBuilder.where.mock.lastCall?.[0] as { op: string; clauses: Array<Record<string, unknown>> };
expect(where.op).toBe('and');
expect(where.clauses.slice(0, 3)).toEqual([
{ op: 'eq', left: emailSendLog.userId, right: 9 },
{ op: 'eq', left: emailSendLog.status, right: 'failed' },
{ op: 'gte', left: emailSendLog.createdAt, right: since },
]);
expect(String(where.clauses[3].text)).toContain('not exists');
expect(String(where.clauses[3].text)).toContain('later.created_at >');
expect(selectBuilder.limit).toHaveBeenLastCalledWith(20);
});
it('findPending scopes to pending status', () => {
const { db, selectBuilder } = makeDb();
const repo = new EmailSendLogRepository(db as never);
@@ -1,14 +1,20 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, gt, sql } from 'drizzle-orm';
import { and, count, desc, eq, getTableColumns, gt, gte, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { emailSendLog } from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, emailRecipients, emailSendLog } from '../../db/schema';
import { EMAIL_SEND_STATUS_FAILED, EMAIL_SEND_STATUS_PENDING, EMAIL_SEND_STATUS_SENT } from './email-send.constants';
type Db = NodePgDatabase<typeof schema>;
export interface EmailLogFilter {
bookId?: number;
status?: string;
recipientId?: number;
}
@Injectable()
export class EmailSendLogRepository {
constructor(@Inject(DB) private readonly db: Db) {}
@@ -21,17 +27,117 @@ export class EmailSendLogRepository {
return this.db.select().from(emailSendLog).where(eq(emailSendLog.id, id)).limit(1);
}
findForUser(userId: number, limit: number, offset: number, bookId?: number) {
const ownedByUser = eq(emailSendLog.userId, userId);
/** Each row plus the book title and the file that went out, so a history can name what was sent. */
private selectWithBook() {
return this.db
.select()
.select({
...getTableColumns(emailSendLog),
bookTitle: bookMetadata.title,
bookAuthors: sql<
string | null
>`(select string_agg(${authors.name}, ', ' order by ${bookAuthors.displayOrder}, ${authors.id}) from ${bookAuthors} inner join ${authors} on ${authors.id} = ${bookAuthors.authorId} where ${bookAuthors.bookId} = ${emailSendLog.bookId})`,
fileFormat: bookFiles.format,
fileSizeBytes: bookFiles.sizeBytes,
})
.from(emailSendLog)
.where(bookId === undefined ? ownedByUser : and(ownedByUser, eq(emailSendLog.bookId, bookId)))
.orderBy(sql`${emailSendLog.createdAt} desc`)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, emailSendLog.bookId))
.leftJoin(bookFiles, eq(bookFiles.id, emailSendLog.bookFileId));
}
findForUser(userId: number, limit: number, offset: number, { bookId, status, recipientId }: EmailLogFilter = {}) {
const conditions = [eq(emailSendLog.userId, userId)];
if (bookId !== undefined) conditions.push(eq(emailSendLog.bookId, bookId));
if (status !== undefined) conditions.push(eq(emailSendLog.status, status));
if (recipientId !== undefined) {
// Rows logged before recipient_id existed carry only the address; the subquery is scoped to the caller.
conditions.push(sql`(${emailSendLog.recipientId} = ${recipientId} or (${emailSendLog.recipientId} is null and lower(${emailSendLog.toEmail}) = (
select lower(${emailRecipients.email}) from ${emailRecipients} where ${emailRecipients.id} = ${recipientId} and ${emailRecipients.userId} = ${userId}
)))`);
}
return this.selectWithBook()
.where(and(...conditions))
.orderBy(desc(emailSendLog.createdAt), desc(emailSendLog.id))
.limit(limit)
.offset(offset);
}
async countForUser(userId: number) {
const [row] = await this.db
.select({
total: count(),
failed: count(sql`case when ${emailSendLog.status} = ${EMAIL_SEND_STATUS_FAILED} then 1 end`),
pending: count(sql`case when ${emailSendLog.status} = ${EMAIL_SEND_STATUS_PENDING} then 1 end`),
})
.from(emailSendLog)
.where(eq(emailSendLog.userId, userId));
return row ?? { total: 0, failed: 0, pending: 0 };
}
/**
* Failed sends since a moment that nothing has replaced: no later send of the same book to the
* same address exists, whatever its outcome, so a resent book drops out on its own.
*/
findUnresolvedFailures(userId: number, since: Date, limit: number) {
return this.selectWithBook()
.where(
and(
eq(emailSendLog.userId, userId),
eq(emailSendLog.status, EMAIL_SEND_STATUS_FAILED),
gte(emailSendLog.createdAt, since),
sql`not exists (
select 1 from ${emailSendLog} as later
where later.user_id = ${emailSendLog.userId}
and later.book_id is not distinct from ${emailSendLog.bookId}
and lower(later.to_email) = lower(${emailSendLog.toEmail})
and later.created_at > ${emailSendLog.createdAt}
)`,
),
)
.orderBy(desc(emailSendLog.createdAt))
.limit(limit);
}
/**
* The newest send to each of the user's recipients, with its book title. Rows logged before
* recipient_id existed are matched by address instead.
*/
async findLastSendPerRecipient(userId: number) {
const result = await this.db.execute<{
recipient_id: number;
book_id: number | null;
status: string;
created_at: Date | string;
attempt_count: number;
failure_code: string | null;
error_message: string | null;
book_title: string | null;
}>(sql`
select r.id as recipient_id, last.book_id, last.status, last.created_at, last.attempt_count, last.failure_code, last.error_message,
bm.title as book_title
from email_recipients r
join lateral (
select l.book_id, l.status, l.created_at, l.attempt_count, l.failure_code, l.error_message
from email_send_log l
where l.user_id = r.user_id
and (l.recipient_id = r.id or (l.recipient_id is null and lower(l.to_email) = lower(r.email)))
order by l.created_at desc, l.id desc
limit 1
) last on true
left join book_metadata bm on bm.book_id = last.book_id
where r.user_id = ${userId}
`);
return result.rows.map((row) => ({
recipientId: Number(row.recipient_id),
bookId: row.book_id === null ? null : Number(row.book_id),
bookTitle: row.book_title,
status: row.status,
createdAt: new Date(row.created_at).toISOString(),
attemptCount: Number(row.attempt_count),
failureCode: row.failure_code,
errorMessage: row.error_message,
}));
}
findAll(limit: number, offset: number) {
return this.db
.select()
@@ -39,6 +39,8 @@ describe('EmailSendLogService', () => {
markSent: vi.fn().mockResolvedValue([{ ...mockLog, status: 'sent' }]),
markFailed: vi.fn(),
findForUser: vi.fn().mockResolvedValue([mockLog]),
countForUser: vi.fn().mockResolvedValue({ total: 3, failed: 1, pending: 0 }),
findUnresolvedFailures: vi.fn().mockResolvedValue([]),
findAll: vi.fn().mockResolvedValue([mockLog]),
findById: vi.fn().mockResolvedValue([mockLog]),
delete: vi.fn(),
@@ -82,13 +84,36 @@ describe('EmailSendLogService', () => {
describe('findForUser', () => {
it('should find for user with pagination', async () => {
const result = await service.findForUser(mockUser, 1, 10);
expect(repo.findForUser).toHaveBeenCalledWith(1, 10, 10, undefined);
expect(repo.findForUser).toHaveBeenCalledWith(1, 10, 10, {});
expect(result).toHaveLength(1);
});
it('should pass the book filter through', async () => {
await service.findForUser(mockUser, 0, 20, 965);
expect(repo.findForUser).toHaveBeenCalledWith(1, 20, 0, 965);
await service.findForUser(mockUser, 0, 20, { bookId: 965 });
expect(repo.findForUser).toHaveBeenCalledWith(1, 20, 0, { bookId: 965 });
});
it('should pass the status filter through', async () => {
await service.findForUser(mockUser, 0, 20, { status: 'failed' });
expect(repo.findForUser).toHaveBeenCalledWith(1, 20, 0, { status: 'failed' });
});
});
describe('summary and attention', () => {
it("counts the caller's log", async () => {
expect(await service.summary(mockUser)).toEqual({ total: 3, failed: 1, pending: 0 });
expect(repo.countForUser).toHaveBeenCalledWith(1);
});
it('asks for unresolved failures from the last seven days', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-12T20:00:00Z'));
try {
await service.findAttention(mockUser);
expect(repo.findUnresolvedFailures).toHaveBeenCalledWith(1, new Date('2026-10-05T20:00:00Z'), 20);
} finally {
vi.useRealTimers();
}
});
});
@@ -5,10 +5,13 @@ import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import type { RequestUser } from '../../common/types/request-user';
import type { NewEmailSendLog } from '../../db/schema';
import { EMAIL_SEND_STATUS_PENDING } from './email-send.constants';
import { EmailSendLogRepository } from './email-send-log.repository';
import { EmailSendLogRepository, type EmailLogFilter } from './email-send-log.repository';
export const MAX_SEND_ATTEMPTS = 3;
export const SEND_RETRY_DELAYS_MS = [30_000, 120_000];
/** How far back the settings page looks for failures still worth a fix. */
export const ATTENTION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
const ATTENTION_LIMIT = 20;
const EMAIL_SEND_LOG_BOOTSTRAP_EVENT = 'email.send-log.bootstrap-cleanup';
@Injectable()
@@ -46,8 +49,16 @@ export class EmailSendLogService implements OnApplicationBootstrap {
return { isFinal };
}
async findForUser(user: RequestUser, page: number, size: number, bookId?: number) {
return this.repo.findForUser(user.id, size, page * size, bookId);
async findForUser(user: RequestUser, page: number, size: number, filter: EmailLogFilter = {}) {
return this.repo.findForUser(user.id, size, page * size, filter);
}
summary(user: RequestUser) {
return this.repo.countForUser(user.id);
}
findAttention(user: RequestUser) {
return this.repo.findUnresolvedFailures(user.id, new Date(Date.now() - ATTENTION_WINDOW_MS), ATTENTION_LIMIT);
}
async findAllAdmin(page: number, size: number) {
@@ -8,6 +8,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { CreateEmailTemplateDto } from './dto/create-email-template.dto';
import { UpdateEmailTemplateDto } from './dto/update-email-template.dto';
import { PreviewTemplateDto } from './dto/preview-template.dto';
import { PreviewTemplateTextDto } from './dto/preview-template-text.dto';
import { EmailTemplateService } from './email-template.service';
@Controller('email/templates')
@@ -70,6 +71,13 @@ export class EmailTemplateController {
return this.service.setDefault(id, user);
}
/** Renders text from the editor before it is saved. */
@Post('preview')
@HttpCode(HttpStatus.OK)
previewText(@Body() dto: PreviewTemplateTextDto, @CurrentUser() user: RequestUser) {
return this.service.previewText(dto.subject, dto.bodyText, dto.bookId, user);
}
@Post(':id/preview')
@HttpCode(HttpStatus.OK)
preview(@Param('id', ParseIntPipe) id: number, @Body() dto: PreviewTemplateDto, @CurrentUser() user: RequestUser) {
@@ -112,33 +112,10 @@ describe('EmailTemplateRepository', () => {
expect(sql).toHaveBeenCalled();
});
it('clearDefault/setDefault/delete preserve owner boundaries', () => {
const { db, updateBuilder, deleteBuilder } = makeDb();
it('delete preserves owner boundaries', () => {
const { db, deleteBuilder } = makeDb();
const repo = new EmailTemplateRepository(db as never);
void repo.clearDefault(4);
expect(updateBuilder.set).toHaveBeenCalledWith({ isDefault: false });
expect(updateBuilder.where).toHaveBeenCalledWith({
op: 'and',
clauses: [
{ op: 'eq', left: emailTemplates.userId, right: 4 },
{ op: 'eq', left: emailTemplates.isDefault, right: true },
],
});
void repo.setDefault(22, 4);
expect(updateBuilder.set).toHaveBeenLastCalledWith(
expect.objectContaining({
isDefault: expect.objectContaining({ op: 'sql', text: 'case when = then true else false end' }),
updatedAt: expect.objectContaining({ op: 'sql', text: 'now()' }),
}),
);
expect(updateBuilder.where).toHaveBeenLastCalledWith({
op: 'eq',
left: emailTemplates.userId,
right: 4,
});
void repo.delete(22, 4);
expect(deleteBuilder.where).toHaveBeenCalledWith({
op: 'and',
@@ -60,24 +60,6 @@ export class EmailTemplateRepository {
.returning();
}
clearDefault(userId: number) {
return this.db
.update(emailTemplates)
.set({ isDefault: false })
.where(and(eq(emailTemplates.userId, userId), eq(emailTemplates.isDefault, true)));
}
setDefault(id: number, userId: number) {
return this.db
.update(emailTemplates)
.set({
isDefault: sql`case when ${emailTemplates.id} = ${id} then true else false end`,
updatedAt: sql`now()`,
})
.where(eq(emailTemplates.userId, userId))
.returning();
}
delete(id: number, userId: number) {
return this.db
.delete(emailTemplates)
@@ -4,6 +4,8 @@ import { EmailTemplateService } from './email-template.service';
import { EmailTemplateRepository } from './email-template.repository';
import { EmailTemplateContextService } from './email-template-context.service';
import { EmailBookAccessService } from './email-book-access.service';
import { EmailPreferencesRepository } from './email-preferences.repository';
import { EmailFileSelector } from './email-file-selector';
import type { RequestUser } from '../../common/types/request-user';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
@@ -11,6 +13,8 @@ describe('EmailTemplateService', () => {
let service: EmailTemplateService;
let repo: EmailTemplateRepository;
let bookAccessService: EmailBookAccessService;
let preferences: EmailPreferencesRepository;
let fileSelector: EmailFileSelector;
const mockUser: RequestUser = {
id: 1,
@@ -54,8 +58,13 @@ describe('EmailTemplateService', () => {
update: vi.fn().mockResolvedValue([mockTemplate]),
updateById: vi.fn().mockResolvedValue([mockTemplate]),
delete: vi.fn(),
clearDefault: vi.fn(),
setDefault: vi.fn().mockResolvedValue([mockTemplate]),
},
},
{ provide: EmailPreferencesRepository, useValue: { setDefaults: vi.fn().mockResolvedValue({}) } },
{
provide: EmailFileSelector,
useValue: {
select: vi.fn().mockResolvedValue({ id: 500, absolutePath: '/library/Verity/Verity (2021).epub', format: 'epub', sizeBytes: 708549 }),
},
},
{
@@ -72,6 +81,8 @@ describe('EmailTemplateService', () => {
service = module.get<EmailTemplateService>(EmailTemplateService);
repo = module.get<EmailTemplateRepository>(EmailTemplateRepository);
bookAccessService = module.get<EmailBookAccessService>(EmailBookAccessService);
preferences = module.get<EmailPreferencesRepository>(EmailPreferencesRepository);
fileSelector = module.get<EmailFileSelector>(EmailFileSelector);
});
describe('findAll', () => {
@@ -149,15 +160,14 @@ describe('EmailTemplateService', () => {
describe('setDefault', () => {
it('should set owned template as default', async () => {
const result = await service.setDefault(10, mockUser);
expect(repo.setDefault).toHaveBeenCalledWith(10, 1);
expect(preferences.setDefaults).toHaveBeenCalledWith(1, { defaultTemplateId: 10 });
expect(result.id).toBe(10);
});
it('should allow system template as default without database update', async () => {
it('records the built-in template as the default in preferences', async () => {
(repo.findById as vi.Mock).mockResolvedValue([mockSystemTemplate]);
const result = await service.setDefault(20, mockUser);
expect(repo.clearDefault).toHaveBeenCalledWith(1);
expect(repo.setDefault).not.toHaveBeenCalled();
expect(preferences.setDefaults).toHaveBeenCalledWith(1, { defaultTemplateId: 20 });
expect(result.id).toBe(20);
});
});
@@ -179,6 +189,39 @@ describe('EmailTemplateService', () => {
});
});
describe('previewText', () => {
it('renders unsaved text against the edition a send would pick', async () => {
const contextService = (service as any).contextService;
(contextService.buildForBook as vi.Mock).mockResolvedValue({ title: 'Verity', format: 'EPUB' });
const result = await service.previewText('Book club: {{title}}', 'The {{format}} is attached.', 7, mockUser);
expect(fileSelector.select).toHaveBeenCalledWith(7, null, null);
expect(contextService.buildForBook).toHaveBeenCalledWith(7, 500, mockUser.name);
expect(result).toEqual({
subject: 'Book club: Verity',
bodyText: 'The EPUB is attached.',
attachment: { fileName: 'Verity (2021).epub', format: 'epub', sizeBytes: 708549 },
});
});
it('still renders when the book has no sendable file', async () => {
const contextService = (service as any).contextService;
(contextService.buildForBook as vi.Mock).mockResolvedValue({ title: 'Verity' });
(fileSelector.select as vi.Mock).mockRejectedValue(new NotFoundException('No files found for this book'));
const result = await service.previewText('{{title}}', '', 7, mockUser);
expect(contextService.buildForBook).toHaveBeenCalledWith(7, null, mockUser.name);
expect(result.attachment).toBeNull();
});
it('checks book access before rendering', async () => {
(bookAccessService.assertUserCanAccessBook as vi.Mock).mockRejectedValue(new ForbiddenException('No access to this library'));
await expect(service.previewText('S', 'B', 7, mockUser)).rejects.toThrow(ForbiddenException);
});
});
describe('resolveTemplate', () => {
it('should return specific template if requested and accessible', async () => {
const result = await service.resolveTemplate(10, mockUser);
@@ -1,10 +1,14 @@
import { basename } from 'node:path';
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import type { RequestUser } from '../../common/types/request-user';
import { EmailTemplateRepository } from './email-template.repository';
import { EmailPreferencesRepository } from './email-preferences.repository';
import { EmailTemplateContextService } from './email-template-context.service';
import { renderTemplate } from './email-template-renderer';
import { EmailBookAccessService } from './email-book-access.service';
import { EmailFileSelector } from './email-file-selector';
import { CreateEmailTemplateDto } from './dto/create-email-template.dto';
import { UpdateEmailTemplateDto } from './dto/update-email-template.dto';
import type { EmailTemplate } from '../../db/schema';
@@ -16,6 +20,8 @@ export class EmailTemplateService {
private readonly repo: EmailTemplateRepository,
private readonly contextService: EmailTemplateContextService,
private readonly bookAccessService: EmailBookAccessService,
private readonly preferences: EmailPreferencesRepository,
private readonly fileSelector: EmailFileSelector,
) {}
async findAll(user: RequestUser) {
@@ -79,20 +85,30 @@ export class EmailTemplateService {
if (template.userId !== user.id && !template.isSystem) {
throw new ForbiddenException('Cannot set this template as default');
}
if (!template.isSystem) {
const updated = (await this.repo.setDefault(id, user.id)).find((row) => row.id === id);
if (!updated) throw new NotFoundException('Template not found');
return updated;
}
await this.repo.clearDefault(user.id);
return template;
await this.preferences.setDefaults(user.id, { defaultTemplateId: id });
if (template.isSystem) return template;
const [updated] = await this.repo.findById(id);
if (!updated) throw new NotFoundException('Template not found');
return updated;
}
async preview(id: number, bookId: number, fileId: number | null, user: RequestUser) {
const template = await this.getAccessible(id, user);
// This route predates the attachment, so its response keeps exactly the shape released clients read.
const { subject, bodyText } = await this.previewText(template.subject, template.bodyText, bookId, user, fileId);
return { subject, bodyText };
}
/**
* The text as a send of this book would read, and the file it would attach, named as the email names
* it. Without a file, {{format}} and {{fileSize}} describe the edition a send would pick by default.
*/
async previewText(subject: string, bodyText: string, bookId: number, user: RequestUser, fileId: number | null = null) {
await this.bookAccessService.assertUserCanAccessBook(bookId, user);
const context = await this.contextService.buildForBook(bookId, fileId, user.name);
return renderTemplate(template.subject, template.bodyText, context);
const file = await this.fileSelector.select(bookId, fileId, null).catch(() => null);
const context = await this.contextService.buildForBook(bookId, file?.id ?? null, user.name);
const attachment = file ? { fileName: basename(file.absolutePath), format: file.format, sizeBytes: file.sizeBytes } : null;
return { ...renderTemplate(subject, bodyText, context), attachment };
}
async resolveTemplate(templateId: number | null | undefined, user: RequestUser): Promise<EmailTemplate> {
+2
View File
@@ -9,6 +9,7 @@ import { EmailBookAccessService } from './email-book-access.service';
import { EmailBookReadRepository } from './email-book-read.repository';
import { EmailEncryptionService } from './email-encryption.service';
import { EmailFileSelector } from './email-file-selector';
import { EmailRecipientTestService } from './email-recipient-test.service';
import { EmailPreferencesController } from './email-preferences.controller';
import { EmailPreferencesRepository } from './email-preferences.repository';
import { EmailPreferencesService } from './email-preferences.service';
@@ -64,6 +65,7 @@ import { SystemMailService } from './system-mail.service';
EmailPreferencesService,
EmailProviderResolver,
EmailFileSelector,
EmailRecipientTestService,
EmailSendLogRepository,
EmailSendLogService,
EmailSendOrchestrator,
@@ -1,9 +1,9 @@
{
"totalRoutes": 644,
"totalRoutes": 648,
"byPermission": {
"(none)": 280,
"Permission.ManageMetadataConfig": 38,
"Permission.EmailSend": 30,
"Permission.EmailSend": 34,
"Permission.LibraryEditMetadata": 35,
"Permission.BookDockAccess": 17,
"Permission.ManageAppSettings": 62,
@@ -527,6 +527,18 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/email/email-recipient.controller.ts",
"className": "EmailRecipientController",
"methodName": "sendTest",
"httpMethod": "POST",
"path": "/email/recipients/:id/test",
"permissions": ["Permission.EmailSend"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/email/email-recipient.controller.ts",
"className": "EmailRecipientController",
@@ -551,6 +563,30 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/email/email-send-log.controller.ts",
"className": "EmailSendLogController",
"methodName": "summary",
"httpMethod": "GET",
"path": "/email/log/summary",
"permissions": ["Permission.EmailSend"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/email/email-send-log.controller.ts",
"className": "EmailSendLogController",
"methodName": "attention",
"httpMethod": "GET",
"path": "/email/log/attention",
"permissions": ["Permission.EmailSend"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/email/email-send-log.controller.ts",
"className": "EmailSendLogController",
@@ -59,6 +59,18 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/email/email-template.controller.ts",
"className": "EmailTemplateController",
"methodName": "previewText",
"httpMethod": "POST",
"path": "/email/templates/preview",
"permissions": ["Permission.EmailSend"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/email/email-template.controller.ts",
"className": "EmailTemplateController",
+146
View File
@@ -768,6 +768,152 @@ describe('Email lifecycle (e2e)', { timeout: 180_000 }, () => {
});
});
describe('outbox page contracts', () => {
it('makes a starred provider and recipient the defaults that quick send uses', async () => {
const providerResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'starred-provider'));
expect(providerResponse.statusCode).toBe(201);
const provider = providerResponse.json() as { id: number };
const recipientResponse = await apiAs(superManager, 'POST', '/api/v1/email/recipients', { name: 'Starred', email: 'starred@example.com' });
expect(recipientResponse.statusCode).toBe(201);
const recipient = recipientResponse.json() as { id: number };
expect((await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/default`)).statusCode).toBe(200);
expect((await apiAs(superManager, 'PATCH', `/api/v1/email/recipients/${recipient.id}/default`)).statusCode).toBe(200);
const preferences = await apiAs(superManager, 'GET', '/api/v1/email/preferences');
expect(preferences.json()).toMatchObject({ defaultProviderId: provider.id, defaultRecipientId: recipient.id });
const quickSend = await apiAs(superManager, 'POST', `/api/v1/email/send/quick/${book.bookId}`);
expect(quickSend.statusCode).toBe(202);
const [message] = await ctx.smtpSink.waitForMessages(1);
expect(message.envelopeTo).toContain('starred@example.com');
await waitForCondition(async () => {
const listed = (await apiAs(superManager, 'GET', '/api/v1/email/recipients')).json() as Array<{
id: number;
lastSend: { bookTitle: string; status: string } | null;
}>;
const lastSend = listed.find((r) => r.id === recipient.id)?.lastSend;
if (lastSend?.status !== 'sent' || lastSend.bookTitle !== 'Email Lifecycle Book')
throw new Error(`Unexpected lastSend ${JSON.stringify(lastSend)}`);
}, 10_000);
// Choosing a default through preferences moves the legacy flag, so the two never disagree again.
const otherRecipient = (
await apiAs(superManager, 'POST', '/api/v1/email/recipients', { name: 'Other', email: 'other@example.com' })
).json() as { id: number };
expect((await apiAs(superManager, 'PUT', '/api/v1/email/preferences', { defaultRecipientId: otherRecipient.id })).statusCode).toBe(200);
const recipients = (await apiAs(superManager, 'GET', '/api/v1/email/recipients')).json() as Array<{ id: number; isDefault: boolean }>;
expect(recipients.find((r) => r.id === otherRecipient.id)?.isDefault).toBe(true);
expect(recipients.find((r) => r.id === recipient.id)?.isDefault).toBe(false);
});
it('lists an unresolved failure with its book until a later send replaces it', async () => {
const failingProvider = (
await apiAs(superManager, 'POST', '/api/v1/email/providers', {
...buildProviderPayload(ctx, 'attention-provider'),
host: '127.0.0.1',
port: 1,
})
).json() as { id: number };
expect((await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${failingProvider.id}/share`)).statusCode).toBe(200);
const recipient = (
await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { name: 'Attention', email: 'attention@example.com' })
).json() as {
id: number;
};
const send = await apiAs(senderOnly, 'POST', '/api/v1/email/send', {
bookIds: [book.bookId],
recipientIds: [recipient.id],
providerId: failingProvider.id,
});
expect(send.statusCode).toBe(202);
let failedId = 0;
await waitForCondition(async () => {
const latest = await latestLogForUser(senderOnly.userId);
if (!latest || latest.attemptCount < 1) throw new Error('First attempt has not failed yet');
failedId = latest.id;
}, 15_000);
// The retry schedule takes two and a half minutes to give up, so settle the row the way the
// last attempt would.
await ctx.db.update(schema.emailSendLog).set({ status: 'failed', nextRetryAt: null }).where(eq(schema.emailSendLog.id, failedId));
const attention = (await apiAs(senderOnly, 'GET', '/api/v1/email/log/attention')).json() as Array<Record<string, unknown>>;
expect(attention).toHaveLength(1);
expect(attention[0]).toMatchObject({ id: failedId, bookTitle: 'Email Lifecycle Book', fileFormat: 'epub', status: 'failed' });
expect(attention[0].fileSizeBytes).toEqual(expect.any(Number));
expect((await apiAs(senderOnly, 'GET', '/api/v1/email/log/summary')).json()).toEqual({ total: 1, failed: 1, pending: 0 });
expect((await apiAs(foreignSender, 'GET', '/api/v1/email/log/attention')).json()).toEqual([]);
expect(attention[0]).toHaveProperty('bookAuthors');
// The recipient filter returns this recipient's sends, and nothing for someone else's recipient.
const forRecipient = (await apiAs(senderOnly, 'GET', `/api/v1/email/log?recipientId=${recipient.id}`)).json() as Array<{ id: number }>;
expect(forRecipient.map((row) => row.id)).toEqual([failedId]);
expect((await apiAs(foreignSender, 'GET', `/api/v1/email/log?recipientId=${recipient.id}`)).json()).toEqual([]);
await apiAs(superManager, 'PUT', `/api/v1/email/providers/${failingProvider.id}`, {
host: ctx.smtpSink.getHost(),
port: ctx.smtpSink.getPort(),
auth: false,
ssl: false,
startTls: false,
});
expect((await apiAs(senderOnly, 'POST', `/api/v1/email/log/${failedId}/resend`)).statusCode).toBe(202);
await ctx.smtpSink.waitForMessages(1);
await waitForCondition(async () => {
const latest = await latestLogForUser(senderOnly.userId);
if (latest?.status !== 'sent') throw new Error('Resend not settled yet');
}, 15_000);
expect((await apiAs(senderOnly, 'GET', '/api/v1/email/log/attention')).json()).toEqual([]);
expect((await apiAs(senderOnly, 'GET', '/api/v1/email/log/summary')).json()).toEqual({ total: 2, failed: 1, pending: 0 });
const failedOnly = (await apiAs(senderOnly, 'GET', '/api/v1/email/log?status=failed')).json() as Array<{ id: number }>;
expect(failedOnly.map((entry) => entry.id)).toEqual([failedId]);
expectError(await apiAs(senderOnly, 'GET', '/api/v1/email/log?status=queued'), 400);
});
it('previews unsaved template text against a book the caller can read', async () => {
const preview = await apiAs(senderOnly, 'POST', '/api/v1/email/templates/preview', {
subject: 'Pick: {{title}}',
bodyText: 'The {{format}} is attached.',
bookId: book.bookId,
});
expect(preview.statusCode).toBe(200);
expect(preview.json()).toMatchObject({
subject: 'Pick: Email Lifecycle Book',
bodyText: 'The EPUB is attached.',
attachment: { format: 'epub' },
});
expect((preview.json() as { attachment: { fileName: string } }).attachment.fileName).not.toContain('/');
const denied = await apiAs(senderNoLibrary, 'POST', '/api/v1/email/templates/preview', { subject: 'S', bodyText: 'B', bookId: book.bookId });
expect(denied.statusCode).toBe(403);
});
it('sends a plain test email to an inbox and refuses a Kindle', async () => {
const provider = (await apiAs(superManager, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'test-mail-provider'))).json() as {
id: number;
};
expect((await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/share`)).statusCode).toBe(200);
expect((await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', { defaultProviderId: provider.id })).statusCode).toBe(200);
const inbox = (await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { name: 'Inbox', email: 'test-inbox@example.com' })).json() as {
id: number;
};
const kindle = (
await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { name: 'Kindle', email: 'reader_1@kindle.com', deviceType: 'kindle' })
).json() as { id: number };
const sent = await apiAs(senderOnly, 'POST', `/api/v1/email/recipients/${inbox.id}/test`, { subject: 'BookOrbit test', text: 'It works.' });
expect(sent.statusCode).toBe(200);
expect(sent.json()).toEqual({ success: true });
const [message] = await ctx.smtpSink.waitForMessages(1);
expect(extractSmtpHeader(message.raw, 'Subject')).toBe('BookOrbit test');
expectError(await apiAs(senderOnly, 'POST', `/api/v1/email/recipients/${kindle.id}/test`, { subject: 'S', text: 'T' }), 400);
expectError(await apiAs(foreignSender, 'POST', `/api/v1/email/recipients/${inbox.id}/test`, { subject: 'S', text: 'T' }), 403);
});
});
describe('admin log and superuser enforcement', () => {
it('restricts /email/admin/log to superusers only', async () => {
const providerCreateResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'admin-log-provider'));