fix(email): harden security and reliability with stronger coverage

This commit is contained in:
neon
2026-04-01 04:31:40 -06:00
parent acc5f833e8
commit 2e37c2d8e6
46 changed files with 1404 additions and 630 deletions
@@ -47,8 +47,6 @@ describe('Architecture boundaries', () => {
'src/modules/book-metadata-fetch/book-metadata-fetch-config.service.ts',
'src/modules/catalog/catalog.service.ts',
'src/modules/cover/cover.service.ts',
'src/modules/email/email-template-context.service.ts',
'src/modules/embedding/book-embedder.service.ts',
'src/modules/file-write/file-write-settings.service.ts',
'src/modules/kobo/services/kobo-device.service.ts',
'src/modules/kobo/services/kobo-download.service.ts',
@@ -109,6 +109,7 @@ describe('Email DTO validation', () => {
expect(valid.size).toBe(50);
expect((await errorsFor(QuerySendLogDto, { page: -1, size: 0 })).length).toBeGreaterThan(0);
expect((await errorsFor(QuerySendLogDto, { size: 101 })).length).toBeGreaterThan(0);
});
it('requires at least one book and integer collections for send requests', async () => {
@@ -1,4 +1,4 @@
import { IsInt, IsOptional, IsPositive, Min } from 'class-validator';
import { IsInt, IsOptional, IsPositive, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class QuerySendLogDto {
@@ -12,5 +12,6 @@ export class QuerySendLogDto {
@Type(() => Number)
@IsInt()
@IsPositive()
@Max(100)
size?: number = 20;
}
@@ -0,0 +1,64 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import type { RequestUser } from '../../common/types/request-user';
import { BookReadService } from '../book/book-read.service';
import { LibraryService } from '../library/library.service';
import { EmailBookAccessService } from './email-book-access.service';
describe('EmailBookAccessService', () => {
const user: RequestUser = {
id: 9,
username: 'reader9',
name: 'Reader Nine',
email: 'reader9@example.com',
active: true,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'manual',
isSuperuser: false,
permissions: [],
};
let service: EmailBookAccessService;
let bookReadService: { findLibraryIdsByBookIds: ReturnType<typeof vi.fn> };
let libraryService: { verifyUserAccess: ReturnType<typeof vi.fn> };
beforeEach(() => {
bookReadService = {
findLibraryIdsByBookIds: vi.fn(),
};
libraryService = {
verifyUserAccess: vi.fn().mockResolvedValue(undefined),
};
service = new EmailBookAccessService(bookReadService as unknown as BookReadService, libraryService as unknown as LibraryService);
});
it('verifies access for each unique book id', async () => {
bookReadService.findLibraryIdsByBookIds.mockResolvedValue([
{ id: 1, libraryId: 10 },
{ id: 2, libraryId: 11 },
]);
await service.assertUserCanAccessBooks([1, 2, 1], user);
expect(bookReadService.findLibraryIdsByBookIds).toHaveBeenCalledWith([1, 2]);
expect(libraryService.verifyUserAccess).toHaveBeenCalledTimes(2);
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(user.id, 10, user.isSuperuser);
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(user.id, 11, user.isSuperuser);
});
it('throws NotFoundException when any requested book is missing', async () => {
bookReadService.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 10 }]);
await expect(service.assertUserCanAccessBooks([1, 2], user)).rejects.toThrow(NotFoundException);
});
it('propagates ForbiddenException from library access checks', async () => {
bookReadService.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 10 }]);
libraryService.verifyUserAccess.mockRejectedValue(new ForbiddenException('No access to this library'));
await expect(service.assertUserCanAccessBook(1, user)).rejects.toThrow(ForbiddenException);
});
});
@@ -0,0 +1,37 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { RequestUser } from '../../common/types/request-user';
import { BookReadService } from '../book/book-read.service';
import { LibraryService } from '../library/library.service';
@Injectable()
export class EmailBookAccessService {
constructor(
private readonly bookReadService: BookReadService,
private readonly libraryService: LibraryService,
) {}
async assertUserCanAccessBook(bookId: number, user: RequestUser): Promise<void> {
await this.assertUserCanAccessBooks([bookId], user);
}
async assertUserCanAccessBooks(bookIds: number[], user: RequestUser): Promise<void> {
const uniqueBookIds = [...new Set(bookIds)];
if (uniqueBookIds.length === 0) return;
const rows = await this.bookReadService.findLibraryIdsByBookIds(uniqueBookIds);
const libraryByBookId = new Map(rows.map((row) => [row.id, row.libraryId]));
const missingBookId = uniqueBookIds.find((bookId) => !libraryByBookId.has(bookId));
if (missingBookId !== undefined) {
throw new NotFoundException(`Book ${missingBookId} not found`);
}
await Promise.all(
uniqueBookIds.map(async (bookId) => {
const libraryId = libraryByBookId.get(bookId);
if (libraryId === undefined) throw new NotFoundException(`Book ${bookId} not found`);
await this.libraryService.verifyUserAccess(user.id, libraryId, user.isSuperuser);
}),
);
}
}
@@ -0,0 +1,60 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, bookTags, books, tags } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@Injectable()
export class EmailBookReadRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findBookById(bookId: number): Promise<typeof books.$inferSelect | null> {
const [book] = await this.db.select().from(books).where(eq(books.id, bookId)).limit(1);
return book ?? null;
}
async findBookPrimaryFileId(bookId: number): Promise<number | null> {
const [book] = await this.db.select({ primaryFileId: books.primaryFileId }).from(books).where(eq(books.id, bookId)).limit(1);
return book?.primaryFileId ?? null;
}
async findFileForBook(bookId: number, fileId: number): Promise<typeof bookFiles.$inferSelect | null> {
const [file] = await this.db
.select()
.from(bookFiles)
.where(and(eq(bookFiles.id, fileId), eq(bookFiles.bookId, bookId)))
.limit(1);
return file ?? null;
}
findFilesByBookId(bookId: number): Promise<(typeof bookFiles.$inferSelect)[]> {
return this.db.select().from(bookFiles).where(eq(bookFiles.bookId, bookId));
}
async findMetadataByBookId(bookId: number): Promise<typeof bookMetadata.$inferSelect | null> {
const [metadata] = await this.db.select().from(bookMetadata).where(eq(bookMetadata.bookId, bookId)).limit(1);
return metadata ?? null;
}
findAuthorNamesByBookId(bookId: number): Promise<{ name: string | null }[]> {
return this.db
.select({ name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(eq(bookAuthors.bookId, bookId))
.orderBy(bookAuthors.displayOrder);
}
findTagNamesByBookId(bookId: number): Promise<{ name: string }[]> {
return this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, bookId));
}
async findFileById(fileId: number): Promise<typeof bookFiles.$inferSelect | null> {
const [file] = await this.db.select().from(bookFiles).where(eq(bookFiles.id, fileId)).limit(1);
return file ?? null;
}
}
@@ -1,64 +1,64 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { EmailFileSelector } from './email-file-selector';
import { DB } from '../../db';
import { EmailBookReadRepository } from './email-book-read.repository';
describe('EmailFileSelector', () => {
let selector: EmailFileSelector;
let db: any;
let repo: {
findFileForBook: ReturnType<typeof vi.fn>;
findFilesByBookId: ReturnType<typeof vi.fn>;
findBookPrimaryFileId: ReturnType<typeof vi.fn>;
};
const mockFile = { id: 100, bookId: 1, format: 'EPUB', role: 'primary' };
const mockFile2 = { id: 101, bookId: 1, format: 'PDF', role: 'secondary' };
beforeEach(async () => {
db = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn(),
beforeEach(() => {
repo = {
findFileForBook: vi.fn(),
findFilesByBookId: vi.fn(),
findBookPrimaryFileId: vi.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [EmailFileSelector, { provide: DB, useValue: db }],
}).compile();
selector = module.get<EmailFileSelector>(EmailFileSelector);
selector = new EmailFileSelector(repo as unknown as EmailBookReadRepository);
});
it('should select by fileId if provided', async () => {
db.where.mockReturnValue({
limit: vi.fn().mockResolvedValue([mockFile]),
});
repo.findFileForBook.mockResolvedValue(mockFile);
const result = await selector.select(1, 100, null);
expect(result).toEqual(mockFile);
expect(db.select).toHaveBeenCalled();
expect(repo.findFileForBook).toHaveBeenCalledWith(1, 100);
});
it('should throw NotFoundException if specific fileId not found', async () => {
db.where.mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
});
repo.findFileForBook.mockResolvedValue(null);
await expect(selector.select(1, 999, null)).rejects.toThrow(NotFoundException);
});
it('should select preferred format if available', async () => {
db.where.mockResolvedValueOnce([mockFile, mockFile2]);
repo.findFilesByBookId.mockResolvedValue([mockFile, mockFile2]);
const result = await selector.select(1, null, 'pdf');
expect(result).toEqual(mockFile2);
});
it('should fallback to primary if preferred format not found', async () => {
db.where.mockResolvedValueOnce([mockFile, mockFile2]).mockReturnValueOnce({ limit: vi.fn().mockResolvedValue([{ primaryFileId: 100 }]) });
repo.findFilesByBookId.mockResolvedValue([mockFile, mockFile2]);
repo.findBookPrimaryFileId.mockResolvedValue(100);
const result = await selector.select(1, null, 'mobi');
expect(result).toEqual(mockFile);
});
it('should throw if no files found for book', async () => {
db.where.mockResolvedValue([]);
it('should fallback to first file when primary file is missing', async () => {
repo.findFilesByBookId.mockResolvedValue([mockFile, mockFile2]);
repo.findBookPrimaryFileId.mockResolvedValue(404);
const result = await selector.select(1, null, 'mobi');
expect(result).toEqual(mockFile);
});
it('should throw if no files found for book', async () => {
repo.findFilesByBookId.mockResolvedValue([]);
await expect(selector.select(1, null, null)).rejects.toThrow(NotFoundException);
});
});
+10 -23
View File
@@ -1,33 +1,20 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { and, eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Injectable, NotFoundException } from '@nestjs/common';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookFiles, books } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
import type { BookFile } from '../../db/schema';
import { EmailBookReadRepository } from './email-book-read.repository';
@Injectable()
export class EmailFileSelector {
constructor(@Inject(DB) private readonly db: Db) {}
constructor(private readonly bookReadRepository: EmailBookReadRepository) {}
async select(
bookId: number,
fileId: number | null | undefined,
preferredFormat: string | null | undefined,
): Promise<typeof bookFiles.$inferSelect> {
if (fileId) {
const [file] = await this.db
.select()
.from(bookFiles)
.where(and(eq(bookFiles.id, fileId), eq(bookFiles.bookId, bookId)))
.limit(1);
async select(bookId: number, fileId: number | null | undefined, preferredFormat: string | null | undefined): Promise<BookFile> {
if (fileId !== null && fileId !== undefined) {
const file = await this.bookReadRepository.findFileForBook(bookId, fileId);
if (!file) throw new NotFoundException('Book file not found');
return file;
}
const allFiles = await this.db.select().from(bookFiles).where(eq(bookFiles.bookId, bookId));
const allFiles = await this.bookReadRepository.findFilesByBookId(bookId);
if (allFiles.length === 0) throw new NotFoundException('No files found for this book');
if (preferredFormat) {
@@ -35,8 +22,8 @@ export class EmailFileSelector {
if (match) return match;
}
const [book] = await this.db.select({ primaryFileId: books.primaryFileId }).from(books).where(eq(books.id, bookId)).limit(1);
const primary = allFiles.find((f) => f.id === book?.primaryFileId);
const primaryFileId = await this.bookReadRepository.findBookPrimaryFileId(bookId);
const primary = allFiles.find((f) => f.id === primaryFileId);
return primary ?? allFiles[0];
}
}
@@ -14,6 +14,7 @@ export interface ResolvedSmtp {
@Injectable()
export class EmailProviderResolver {
private readonly logger = new Logger(EmailProviderResolver.name);
private readonly event = 'email.provider.resolve';
constructor(
private readonly providerService: EmailProviderService,
@@ -23,14 +24,14 @@ export class EmailProviderResolver {
async resolve(user: RequestUser, requestedProviderId?: number | null): Promise<ResolvedSmtp> {
if (requestedProviderId !== null && requestedProviderId !== undefined) {
const provider = await this.providerService.getProviderWithDecryptedPassword(requestedProviderId, user);
this.logger.debug(`[email.provider] [resolved] id=${provider.id} host=${provider.host} (requested)`);
this.logger.debug(`[${this.event}] [end] userId=${user.id} providerId=${provider.id} source=requested - provider resolved`);
return this.fromProvider(provider);
}
const prefs = await this.preferencesService.getForUser(user.id);
if (prefs?.defaultProviderId !== null && prefs?.defaultProviderId !== undefined) {
const provider = await this.providerService.getProviderWithDecryptedPassword(prefs.defaultProviderId, user);
this.logger.debug(`[email.provider] [resolved] id=${provider.id} host=${provider.host} (default)`);
this.logger.debug(`[${this.event}] [end] userId=${user.id} providerId=${provider.id} source=default - provider resolved`);
return this.fromProvider(provider);
}
@@ -1,12 +1,11 @@
vi.mock('drizzle-orm', () => ({
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
isNull: vi.fn((value: unknown) => ({ op: 'isNull', value })),
or: vi.fn((...clauses: unknown[]) => ({ op: 'or', clauses })),
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })),
}));
import { and, eq, isNull, or, sql } from 'drizzle-orm';
import { and, eq, or, sql } from 'drizzle-orm';
import { emailProviders } from '../../db/schema';
import { EmailProviderRepository } from './email-provider.repository';
@@ -137,29 +136,6 @@ describe('EmailProviderRepository', () => {
expect(updateBuilder.returning).toHaveBeenCalled();
});
it('setShared allows only system providers or already shared entries', () => {
const { db, updateBuilder } = makeDb();
const repo = new EmailProviderRepository(db as never);
void repo.setShared(6, true);
expect(isNull).toHaveBeenCalledWith(emailProviders.userId);
expect(or).toHaveBeenCalledWith({ op: 'isNull', value: emailProviders.userId }, { op: 'eq', left: emailProviders.isShared, right: true });
expect(updateBuilder.where).toHaveBeenCalledWith({
op: 'and',
clauses: [
{ op: 'eq', left: emailProviders.id, right: 6 },
{
op: 'or',
clauses: [
{ op: 'isNull', value: emailProviders.userId },
{ op: 'eq', left: emailProviders.isShared, right: true },
],
},
],
});
});
it('setSharedByOwner restricts by owner id', () => {
const { db, updateBuilder } = makeDb();
const repo = new EmailProviderRepository(db as never);
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, isNull, ne, or, sql } from 'drizzle-orm';
import { and, eq, ne, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -51,14 +51,6 @@ export class EmailProviderRepository {
.returning();
}
setShared(id: number, isShared: boolean) {
return this.db
.update(emailProviders)
.set({ isShared, updatedAt: sql`now()` })
.where(and(eq(emailProviders.id, id), or(isNull(emailProviders.userId), eq(emailProviders.isShared, true))))
.returning();
}
setSharedByOwner(id: number, userId: number, isShared: boolean) {
return this.db
.update(emailProviders)
@@ -1,6 +1,7 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import type { RequestUser } from '../../common/types/request-user';
import { emailProviders } from '../../db/schema';
import { EmailEncryptionService } from './email-encryption.service';
import { EmailTransportService } from './email-transport.service';
import { EmailProviderRepository } from './email-provider.repository';
@@ -47,11 +48,19 @@ export class EmailProviderService {
async update(id: number, dto: UpdateEmailProviderDto, user: RequestUser) {
await this.getOwned(id, user);
const patch: Record<string, unknown> = { ...dto };
const patch: Partial<typeof emailProviders.$inferInsert> = {};
if (dto.name !== undefined) patch.name = dto.name;
if (dto.host !== undefined) patch.host = dto.host;
if (dto.port !== undefined) patch.port = dto.port;
if (dto.username !== undefined) patch.username = dto.username;
if (dto.fromName !== undefined) patch.fromName = dto.fromName;
if (dto.fromAddress !== undefined) patch.fromAddress = dto.fromAddress;
if (dto.auth !== undefined) patch.auth = dto.auth;
if (dto.ssl !== undefined) patch.ssl = dto.ssl;
if (dto.startTls !== undefined) patch.startTls = dto.startTls;
if (dto.password !== undefined) {
patch.passwordEnc = dto.password ? this.encryption.encrypt(dto.password) : null;
}
delete patch.password;
const [updated] = await this.repo.update(id, user.id, patch);
if (!updated) throw new NotFoundException('Provider not found');
@@ -1,10 +1,11 @@
vi.mock('drizzle-orm', () => ({
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
inArray: vi.fn((left: unknown, values: unknown[]) => ({ op: 'inArray', left, values })),
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })),
}));
import { eq, sql } from 'drizzle-orm';
import { eq, inArray, sql } from 'drizzle-orm';
import { emailRecipientGroupMembers, emailRecipientGroups, emailRecipients } from '../../db/schema';
import { EmailRecipientGroupRepository } from './email-recipient-group.repository';
@@ -92,6 +93,20 @@ describe('EmailRecipientGroupRepository', () => {
});
});
it('findMembersForGroupIds batches membership lookup by group ids', () => {
const { db, selectBuilder } = makeDb();
const repo = new EmailRecipientGroupRepository(db as never);
void repo.findMembersForGroupIds([1, 2]);
expect(inArray).toHaveBeenCalledWith(emailRecipientGroupMembers.groupId, [1, 2]);
expect(selectBuilder.where).toHaveBeenCalledWith({
op: 'inArray',
left: emailRecipientGroupMembers.groupId,
values: [1, 2],
});
});
it('insert/update/delete enforce owner scoping and return rows', () => {
const { db, insertBuilder, updateBuilder, deleteBuilder } = makeDb();
const repo = new EmailRecipientGroupRepository(db as never);
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, sql } from 'drizzle-orm';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -7,6 +7,9 @@ import * as schema from '../../db/schema';
import { emailRecipientGroupMembers, emailRecipientGroups, emailRecipients } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type EmailRecipientRow = typeof emailRecipients.$inferSelect;
type EmailGroupMemberRow = { recipient: EmailRecipientRow };
type EmailGroupMemberByGroupRow = { groupId: number; recipient: EmailRecipientRow };
@Injectable()
export class EmailRecipientGroupRepository {
@@ -20,7 +23,7 @@ export class EmailRecipientGroupRepository {
return this.db.select().from(emailRecipientGroups).where(eq(emailRecipientGroups.id, id)).limit(1);
}
findMembers(groupId: number) {
findMembers(groupId: number): Promise<EmailGroupMemberRow[]> {
return this.db
.select({ recipient: emailRecipients })
.from(emailRecipientGroupMembers)
@@ -28,6 +31,15 @@ export class EmailRecipientGroupRepository {
.where(eq(emailRecipientGroupMembers.groupId, groupId));
}
findMembersForGroupIds(groupIds: number[]): Promise<EmailGroupMemberByGroupRow[]> {
if (groupIds.length === 0) return Promise.resolve<EmailGroupMemberByGroupRow[]>([]);
return this.db
.select({ groupId: emailRecipientGroupMembers.groupId, recipient: emailRecipients })
.from(emailRecipientGroupMembers)
.innerJoin(emailRecipients, eq(emailRecipients.id, emailRecipientGroupMembers.recipientId))
.where(inArray(emailRecipientGroupMembers.groupId, groupIds));
}
insert(values: typeof emailRecipientGroups.$inferInsert) {
return this.db.insert(emailRecipientGroups).values(values).returning();
}
@@ -38,6 +38,7 @@ describe('EmailRecipientGroupService', () => {
findAllForUser: vi.fn().mockResolvedValue([mockGroup]),
findById: vi.fn().mockResolvedValue([mockGroup]),
findMembers: vi.fn().mockResolvedValue([mockMember]),
findMembersForGroupIds: vi.fn().mockResolvedValue([{ groupId: 10, recipient: mockMember.recipient }]),
insert: vi.fn().mockResolvedValue([mockGroup]),
update: vi.fn().mockResolvedValue([mockGroup]),
delete: vi.fn(),
@@ -65,6 +66,7 @@ describe('EmailRecipientGroupService', () => {
expect(result).toHaveLength(1);
expect(result[0].members).toHaveLength(1);
expect(result[0].members[0].name).toBe('R1');
expect(repo.findMembersForGroupIds).toHaveBeenCalledWith([10]);
});
});
@@ -16,12 +16,23 @@ export class EmailRecipientGroupService {
async findAll(user: RequestUser) {
const groups = await this.repo.findAllForUser(user.id);
return Promise.all(
groups.map(async (group) => {
const memberRows = await this.repo.findMembers(group.id);
return { ...group, members: memberRows.map((r) => r.recipient) };
}),
);
if (groups.length === 0) return [];
const memberRows = await this.repo.findMembersForGroupIds(groups.map((group) => group.id));
const membersByGroupId = new Map<number, Array<(typeof memberRows)[number]>>();
for (const memberRow of memberRows) {
const rows = membersByGroupId.get(memberRow.groupId);
if (rows) {
rows.push(memberRow);
} else {
membersByGroupId.set(memberRow.groupId, [memberRow]);
}
}
return groups.map((group) => ({
...group,
members: (membersByGroupId.get(group.id) ?? []).map((row) => row.recipient),
}));
}
async findOne(id: number, user: RequestUser) {
@@ -1,9 +1,10 @@
vi.mock('drizzle-orm', () => ({
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
inArray: vi.fn((left: unknown, values: unknown[]) => ({ op: 'inArray', left, values })),
}));
import { and, eq } from 'drizzle-orm';
import { and, eq, inArray } from 'drizzle-orm';
import { emailRecipients } from '../../db/schema';
import { EmailRecipientRepository } from './email-recipient.repository';
@@ -71,6 +72,20 @@ describe('EmailRecipientRepository', () => {
expect(selectBuilder.limit).toHaveBeenCalledWith(1);
});
it('findByIds queries all requested ids in one statement', () => {
const { db, selectBuilder } = makeDb();
const repo = new EmailRecipientRepository(db as never);
void repo.findByIds([3, 7, 9]);
expect(inArray).toHaveBeenCalledWith(emailRecipients.id, [3, 7, 9]);
expect(selectBuilder.where).toHaveBeenCalledWith({
op: 'inArray',
left: emailRecipients.id,
values: [3, 7, 9],
});
});
it('inserts provided values and returns inserted rows', async () => {
const { db, insertBuilder } = makeDb();
const inserted = [{ id: 7 }];
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq } from 'drizzle-orm';
import { and, eq, inArray } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -7,24 +7,30 @@ import * as schema from '../../db/schema';
import { emailRecipients } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type EmailRecipientRow = typeof emailRecipients.$inferSelect;
@Injectable()
export class EmailRecipientRepository {
constructor(@Inject(DB) private readonly db: Db) {}
findAllForUser(userId: number) {
findAllForUser(userId: number): Promise<EmailRecipientRow[]> {
return this.db.select().from(emailRecipients).where(eq(emailRecipients.userId, userId)).orderBy(emailRecipients.name);
}
findById(id: number) {
findById(id: number): Promise<EmailRecipientRow[]> {
return this.db.select().from(emailRecipients).where(eq(emailRecipients.id, id)).limit(1);
}
insert(values: typeof emailRecipients.$inferInsert) {
findByIds(ids: number[]): Promise<EmailRecipientRow[]> {
if (ids.length === 0) return Promise.resolve<EmailRecipientRow[]>([]);
return this.db.select().from(emailRecipients).where(inArray(emailRecipients.id, ids));
}
insert(values: typeof emailRecipients.$inferInsert): Promise<EmailRecipientRow[]> {
return this.db.insert(emailRecipients).values(values).returning();
}
update(id: number, userId: number, values: Partial<typeof emailRecipients.$inferInsert>) {
update(id: number, userId: number, values: Partial<typeof emailRecipients.$inferInsert>): Promise<EmailRecipientRow[]> {
return this.db
.update(emailRecipients)
.set(values)
@@ -39,7 +45,7 @@ export class EmailRecipientRepository {
.where(and(eq(emailRecipients.userId, userId), eq(emailRecipients.isDefault, true)));
}
setDefault(id: number, userId: number) {
setDefault(id: number, userId: number): Promise<EmailRecipientRow[]> {
return this.db
.update(emailRecipients)
.set({ isDefault: true })
@@ -47,7 +53,7 @@ export class EmailRecipientRepository {
.returning();
}
delete(id: number, userId: number) {
delete(id: number, userId: number): Promise<EmailRecipientRow[]> {
return this.db
.delete(emailRecipients)
.where(and(eq(emailRecipients.id, id), eq(emailRecipients.userId, userId)))
@@ -34,6 +34,7 @@ describe('EmailRecipientService', () => {
useValue: {
findAllForUser: vi.fn().mockResolvedValue([mockRecipient]),
findById: vi.fn().mockResolvedValue([mockRecipient]),
findByIds: vi.fn().mockResolvedValue([mockRecipient]),
insert: vi.fn().mockResolvedValue([mockRecipient]),
update: vi.fn().mockResolvedValue([mockRecipient]),
delete: vi.fn(),
@@ -95,8 +96,19 @@ describe('EmailRecipientService', () => {
await expect(service.setDefault(10, mockUser)).rejects.toThrow(NotFoundException);
});
it('should get by id', async () => {
const result = await service.getById(10);
it('should get owned recipients by ids', async () => {
const result = await service.getOwnedByIds([10], mockUser);
expect(result).toHaveLength(1);
expect(repo.findByIds).toHaveBeenCalledWith([10]);
});
it('should throw NotFoundException when one of requested recipients does not exist', async () => {
(repo.findByIds as vi.Mock).mockResolvedValue([]);
await expect(service.getOwnedByIds([10], mockUser)).rejects.toThrow(NotFoundException);
});
it('should get owned recipient by id', async () => {
const result = await service.getOwnedById(10, mockUser);
expect(result.id).toBe(10);
});
});
@@ -50,16 +50,24 @@ export class EmailRecipientService {
return updated;
}
async getById(id: number): Promise<EmailRecipient> {
const [recipient] = await this.repo.findById(id);
if (!recipient) throw new NotFoundException('Recipient not found');
async getOwnedById(id: number, user: RequestUser): Promise<EmailRecipient> {
const [recipient] = await this.getOwnedByIds([id], user);
return recipient;
}
async getOwnedById(id: number, user: RequestUser): Promise<EmailRecipient> {
const [recipient] = await this.repo.findById(id);
if (!recipient) throw new NotFoundException('Recipient not found');
if (recipient.userId !== user.id) throw new ForbiddenException('Cannot modify this recipient');
return recipient;
async getOwnedByIds(ids: number[], user: RequestUser): Promise<EmailRecipient[]> {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return [];
const recipients = await this.repo.findByIds(uniqueIds);
const recipientById = new Map(recipients.map((recipient) => [recipient.id, recipient]));
const missingId = uniqueIds.find((id) => !recipientById.has(id));
if (missingId !== undefined) throw new NotFoundException('Recipient not found');
for (const recipient of recipients) {
if (recipient.userId !== user.id) throw new ForbiddenException('Cannot modify this recipient');
}
return uniqueIds.map((id) => recipientById.get(id)!);
}
}
@@ -1,11 +1,10 @@
vi.mock('drizzle-orm', () => ({
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
lte: vi.fn((left: unknown, right: unknown) => ({ op: 'lte', left, right })),
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })),
}));
import { and, eq, lte, sql } from 'drizzle-orm';
import { eq, sql } from 'drizzle-orm';
import { emailSendLog } from '../../db/schema';
import { EmailSendLogRepository } from './email-send-log.repository';
@@ -76,25 +75,17 @@ describe('EmailSendLogRepository', () => {
expect(sql).toHaveBeenCalledWith(expect.any(Array), emailSendLog.createdAt);
});
it('findPendingRetries enforces pending status and retry time cutoff', () => {
it('findPending scopes to pending status', () => {
const { db, selectBuilder } = makeDb();
const repo = new EmailSendLogRepository(db as never);
const now = new Date('2026-03-31T12:00:00.000Z');
void repo.findPendingRetries(now);
void repo.findPending();
expect(eq).toHaveBeenCalledWith(emailSendLog.status, 'pending');
expect(lte).toHaveBeenCalledWith(emailSendLog.nextRetryAt, now);
expect(and).toHaveBeenCalledWith(
{ op: 'eq', left: emailSendLog.status, right: 'pending' },
{ op: 'lte', left: emailSendLog.nextRetryAt, right: now },
);
expect(selectBuilder.where).toHaveBeenCalledWith({
op: 'and',
clauses: [
{ op: 'eq', left: emailSendLog.status, right: 'pending' },
{ op: 'lte', left: emailSendLog.nextRetryAt, right: now },
],
op: 'eq',
left: emailSendLog.status,
right: 'pending',
});
});
@@ -1,10 +1,11 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, lte, sql } from 'drizzle-orm';
import { and, eq, 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 { EMAIL_SEND_STATUS_FAILED, EMAIL_SEND_STATUS_PENDING, EMAIL_SEND_STATUS_SENT } from './email-send.constants';
type Db = NodePgDatabase<typeof schema>;
@@ -39,17 +40,14 @@ export class EmailSendLogRepository {
.offset(offset);
}
findPendingRetries(now: Date) {
return this.db
.select()
.from(emailSendLog)
.where(and(eq(emailSendLog.status, 'pending'), lte(emailSendLog.nextRetryAt, now)));
findPending() {
return this.db.select().from(emailSendLog).where(eq(emailSendLog.status, EMAIL_SEND_STATUS_PENDING));
}
markSent(id: number) {
return this.db
.update(emailSendLog)
.set({ status: 'sent', sentAt: sql`now()`, errorMessage: null, updatedAt: sql`now()` })
.set({ status: EMAIL_SEND_STATUS_SENT, sentAt: sql`now()`, errorMessage: null, updatedAt: sql`now()` })
.where(eq(emailSendLog.id, id))
.returning();
}
@@ -58,7 +56,7 @@ export class EmailSendLogRepository {
return this.db
.update(emailSendLog)
.set({
status: nextRetryAt ? 'pending' : 'failed',
status: nextRetryAt ? EMAIL_SEND_STATUS_PENDING : EMAIL_SEND_STATUS_FAILED,
errorMessage,
nextRetryAt,
attemptCount: sql`${emailSendLog.attemptCount} + 1`,
@@ -72,7 +70,7 @@ export class EmailSendLogRepository {
return this.db
.update(emailSendLog)
.set({
status: 'failed',
status: EMAIL_SEND_STATUS_FAILED,
errorMessage: 'Server restarted before send completed. Use resend to retry.',
nextRetryAt: null,
updatedAt: sql`now()`,
@@ -39,7 +39,7 @@ describe('EmailSendLogService', () => {
findAll: vi.fn().mockResolvedValue([mockLog]),
findById: vi.fn().mockResolvedValue([mockLog]),
delete: vi.fn(),
findPendingRetries: vi.fn().mockResolvedValue([]),
findPending: vi.fn().mockResolvedValue([]),
markAbandoned: vi.fn(),
},
},
@@ -51,16 +51,16 @@ describe('EmailSendLogService', () => {
});
describe('onApplicationBootstrap', () => {
it('should mark stale pending entries as abandoned', async () => {
(repo.findPendingRetries as vi.Mock).mockResolvedValue([{ id: 100 }, { id: 101 }]);
it('should mark pending entries as abandoned', async () => {
(repo.findPending as vi.Mock).mockResolvedValue([{ id: 100 }, { id: 101 }]);
await service.onApplicationBootstrap();
expect(repo.markAbandoned).toHaveBeenCalledTimes(2);
expect(repo.markAbandoned).toHaveBeenCalledWith(100);
expect(repo.markAbandoned).toHaveBeenCalledWith(101);
});
it('should do nothing if no stale pending entries', async () => {
(repo.findPendingRetries as vi.Mock).mockResolvedValue([]);
it('should do nothing if no pending entries', async () => {
(repo.findPending as vi.Mock).mockResolvedValue([]);
await service.onApplicationBootstrap();
expect(repo.markAbandoned).not.toHaveBeenCalled();
});
@@ -2,10 +2,12 @@ import { ForbiddenException, Injectable, Logger, NotFoundException, OnApplicatio
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';
export const MAX_SEND_ATTEMPTS = 3;
export const SEND_RETRY_DELAYS_MS = [30_000, 120_000];
const EMAIL_SEND_LOG_BOOTSTRAP_EVENT = 'email.send-log.bootstrap-cleanup';
@Injectable()
export class EmailSendLogService implements OnApplicationBootstrap {
@@ -14,11 +16,11 @@ export class EmailSendLogService implements OnApplicationBootstrap {
constructor(private readonly repo: EmailSendLogRepository) {}
async onApplicationBootstrap() {
await this.abandonStalePending();
await this.abandonPendingOnBootstrap();
}
async create(values: Omit<NewEmailSendLog, 'status' | 'attemptCount'>) {
const [entry] = await this.repo.insert({ ...values, status: 'pending', attemptCount: 0 });
const [entry] = await this.repo.insert({ ...values, status: EMAIL_SEND_STATUS_PENDING, attemptCount: 0 });
return entry;
}
@@ -62,10 +64,32 @@ export class EmailSendLogService implements OnApplicationBootstrap {
return this.findOneOwned(id, user);
}
private async abandonStalePending() {
const stale = await this.repo.findPendingRetries(new Date());
if (stale.length === 0) return;
this.logger.warn(`Marking ${stale.length} stale pending send(s) as failed — use resend to retry`);
await Promise.all(stale.map((entry) => this.repo.markAbandoned(entry.id)));
private async abandonPendingOnBootstrap() {
const startedAt = Date.now();
this.logger.log(`[${EMAIL_SEND_LOG_BOOTSTRAP_EVENT}] [start] - pending send cleanup started`);
try {
const pending = await this.repo.findPending();
if (pending.length === 0) {
this.logger.log(
`[${EMAIL_SEND_LOG_BOOTSTRAP_EVENT}] [end] durationMs=${Date.now() - startedAt} abandoned=0 - pending send cleanup completed`,
);
return;
}
await Promise.all(pending.map((entry) => this.repo.markAbandoned(entry.id)));
this.logger.warn(
`[${EMAIL_SEND_LOG_BOOTSTRAP_EVENT}] [end] durationMs=${Date.now() - startedAt} abandoned=${pending.length} - pending send cleanup completed`,
);
} catch (error) {
const errorClass = error instanceof Error && error.name ? error.name : 'Error';
const errorMessage = (error instanceof Error ? error.message : String(error))
.replace(/[\r\n"]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
this.logger.error(
`[${EMAIL_SEND_LOG_BOOTSTRAP_EVENT}] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - pending send cleanup failed`,
);
throw error;
}
}
}
@@ -10,9 +10,11 @@ import { EmailTemplateContextService } from './email-template-context.service';
import { EmailPreferencesService } from './email-preferences.service';
import { EmailSendLogService } from './email-send-log.service';
import { EmailTransportService } from './email-transport.service';
import { EmailBookAccessService } from './email-book-access.service';
import type { RequestUser } from '../../common/types/request-user';
import type { SendBookDto } from './dto/send-book.dto';
import * as fs from 'fs';
import { KINDLE_CONVERT_SUBJECT } from './email-send.constants';
vi.mock('fs');
@@ -24,6 +26,7 @@ describe('EmailSendOrchestrator', () => {
let preferencesService: EmailPreferencesService;
let sendLogService: EmailSendLogService;
let transportService: EmailTransportService;
let bookAccessService: EmailBookAccessService;
const mockUser: RequestUser = {
id: 1,
@@ -70,7 +73,10 @@ describe('EmailSendOrchestrator', () => {
},
{
provide: EmailRecipientService,
useValue: { getOwnedById: vi.fn().mockResolvedValue(mockRecipient) },
useValue: {
getOwnedById: vi.fn().mockResolvedValue(mockRecipient),
getOwnedByIds: vi.fn().mockResolvedValue([mockRecipient]),
},
},
{
provide: EmailRecipientGroupService,
@@ -101,6 +107,13 @@ describe('EmailSendOrchestrator', () => {
provide: EmailTransportService,
useValue: { buildTransporter: vi.fn().mockReturnValue({ sendMail: vi.fn().mockResolvedValue({}) }) },
},
{
provide: EmailBookAccessService,
useValue: {
assertUserCanAccessBook: vi.fn().mockResolvedValue(undefined),
assertUserCanAccessBooks: vi.fn().mockResolvedValue(undefined),
},
},
],
}).compile();
@@ -111,6 +124,7 @@ describe('EmailSendOrchestrator', () => {
preferencesService = module.get<EmailPreferencesService>(EmailPreferencesService);
sendLogService = module.get<EmailSendLogService>(EmailSendLogService);
transportService = module.get<EmailTransportService>(EmailTransportService);
bookAccessService = module.get<EmailBookAccessService>(EmailBookAccessService);
(fs.createReadStream as vi.Mock).mockReturnValue('mock-stream');
});
@@ -123,6 +137,8 @@ describe('EmailSendOrchestrator', () => {
expect(result.queued).toBe(1);
expect(providerResolver.resolve).toHaveBeenCalledWith(mockUser, 300);
expect(sendLogService.create).toHaveBeenCalled();
expect(bookAccessService.assertUserCanAccessBooks).toHaveBeenCalledWith([1], mockUser);
expect(recipientService.getOwnedByIds).toHaveBeenCalledWith([10], mockUser);
});
it('should expand groups and queue emails', async () => {
@@ -131,13 +147,19 @@ describe('EmailSendOrchestrator', () => {
expect(result.queued).toBe(1);
expect(groupService.expandOwnedGroupToRecipientIds).toHaveBeenCalledWith(5, mockUser);
expect(recipientService.getOwnedById).toHaveBeenCalledWith(10, mockUser);
expect(recipientService.getOwnedByIds).toHaveBeenCalledWith([10], mockUser);
});
it('should throw BadRequestException if no recipients', async () => {
const dto: SendBookDto = { bookIds: [1], recipientIds: [], groupIds: [] };
await expect(orchestrator.send(dto, mockUser)).rejects.toThrow(BadRequestException);
});
it('should fail if user cannot access requested books', async () => {
(bookAccessService.assertUserCanAccessBooks as vi.Mock).mockRejectedValue(new Error('No access to this library'));
const dto: SendBookDto = { bookIds: [1], recipientIds: [10] };
await expect(orchestrator.send(dto, mockUser)).rejects.toThrow('No access to this library');
});
});
describe('quickSend', () => {
@@ -147,6 +169,7 @@ describe('EmailSendOrchestrator', () => {
const result = await orchestrator.quickSend(1, mockUser);
expect(result.queued).toBe(1);
expect(bookAccessService.assertUserCanAccessBook).toHaveBeenCalledWith(1, mockUser);
expect(recipientService.getOwnedById).toHaveBeenCalledWith(10, mockUser);
});
@@ -173,6 +196,7 @@ describe('EmailSendOrchestrator', () => {
const result = await orchestrator.resend(400, mockUser);
expect(result.queued).toBe(1);
expect(bookAccessService.assertUserCanAccessBook).toHaveBeenCalledWith(1, mockUser);
expect(sendLogService.create).toHaveBeenCalledWith(
expect.objectContaining({
toEmail: 'resend@test.com',
@@ -229,7 +253,7 @@ describe('EmailSendOrchestrator', () => {
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'convert',
subject: KINDLE_CONVERT_SUBJECT,
}),
);
});
@@ -243,7 +267,7 @@ describe('EmailSendOrchestrator', () => {
templateId: 200,
toEmail: 'resend@test.com',
toName: 'Resend',
subject: 'convert',
subject: KINDLE_CONVERT_SUBJECT,
};
(sendLogService.getForResend as vi.Mock).mockResolvedValue(existingLog);
@@ -251,7 +275,7 @@ describe('EmailSendOrchestrator', () => {
expect(sendLogService.create).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'convert',
subject: KINDLE_CONVERT_SUBJECT,
}),
);
});
@@ -1,13 +1,15 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { createReadStream } from 'fs';
import * as schema from '../../db/schema';
import type { BookFile } from '../../db/schema';
import type { RequestUser } from '../../common/types/request-user';
import { EmailBookAccessService } from './email-book-access.service';
import { EmailFileSelector } from './email-file-selector';
import { EmailPreferencesService } from './email-preferences.service';
import { EmailProviderResolver } from './email-provider-resolver';
import { EmailRecipientGroupService } from './email-recipient-group.service';
import { EmailRecipientService } from './email-recipient.service';
import { KINDLE_CONVERT_SUBJECT } from './email-send.constants';
import { EmailSendLogService, SEND_RETRY_DELAYS_MS } from './email-send-log.service';
import { EmailTemplateContextService } from './email-template-context.service';
import { EmailTemplateService } from './email-template.service';
@@ -25,11 +27,17 @@ interface SendTask {
effectiveTemplateId: number | null;
}
const EMAIL_SEND_EVENT = 'email.send';
const EMAIL_RESEND_EVENT = 'email.resend';
const EMAIL_QUICK_SEND_EVENT = 'email.quick-send';
const EMAIL_DISPATCH_EVENT = 'email.dispatch';
@Injectable()
export class EmailSendOrchestrator {
private readonly logger = new Logger(EmailSendOrchestrator.name);
constructor(
private readonly bookAccessService: EmailBookAccessService,
private readonly providerResolver: EmailProviderResolver,
private readonly fileSelector: EmailFileSelector,
private readonly recipientService: EmailRecipientService,
@@ -42,133 +50,169 @@ export class EmailSendOrchestrator {
) {}
async send(dto: SendBookDto, user: RequestUser): Promise<{ queued: number }> {
const startedAt = Date.now();
const requestedRecipientCount = (dto.recipientIds?.length ?? 0) + (dto.groupIds?.length ?? 0);
this.logger.log(
`[email.send] [start] user=${user.id} books=${dto.bookIds.length} recipients=${(dto.recipientIds?.length ?? 0) + (dto.groupIds?.length ?? 0)}`,
`[${EMAIL_SEND_EVENT}] [start] userId=${user.id} bookCount=${dto.bookIds.length} requestedRecipientCount=${requestedRecipientCount} - send enqueue started`,
);
const tasks = await this.buildTasks(dto, user);
if (tasks.length === 0) throw new BadRequestException('No recipients specified');
const resolved = await this.providerResolver.resolve(user, dto.providerId);
try {
await this.bookAccessService.assertUserCanAccessBooks(dto.bookIds, user);
let queued = 0;
for (const task of tasks) {
for (const bookId of dto.bookIds) {
await this.enqueueOne({ ...task, bookId }, resolved, dto, user);
queued++;
const tasks = await this.buildTasks(dto, user);
if (tasks.length === 0) throw new BadRequestException('No recipients specified');
const resolved = await this.providerResolver.resolve(user, dto.providerId);
let queued = 0;
for (const task of tasks) {
for (const bookId of dto.bookIds) {
await this.enqueueOne({ ...task, bookId }, resolved, dto.fileId ?? null, user);
queued++;
}
}
}
this.logger.log(`[email.send] [queued] user=${user.id} total=${queued}`);
return { queued };
this.logger.log(`[${EMAIL_SEND_EVENT}] [end] userId=${user.id} durationMs=${Date.now() - startedAt} queued=${queued} - send enqueue completed`);
return { queued };
} catch (error) {
this.logger.error(
`[${EMAIL_SEND_EVENT}] [fail] userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${this.getErrorClass(error)} error="${this.getErrorMessage(error)}" - send enqueue failed`,
);
throw error;
}
}
async resend(logEntryId: number, user: RequestUser): Promise<{ queued: number }> {
this.logger.log(`[email.resend] [start] user=${user.id} logEntry=${logEntryId}`);
const logEntry = await this.sendLogService.getForResend(logEntryId, user);
if (!logEntry.bookId || !logEntry.toEmail) {
throw new BadRequestException('Cannot resend: original book or recipient is missing');
const startedAt = Date.now();
this.logger.log(`[${EMAIL_RESEND_EVENT}] [start] userId=${user.id} logEntryId=${logEntryId} - resend enqueue started`);
try {
const logEntry = await this.sendLogService.getForResend(logEntryId, user);
if (logEntry.bookId === null || logEntry.bookId === undefined || !logEntry.toEmail) {
throw new BadRequestException('Cannot resend: original book or recipient is missing');
}
await this.bookAccessService.assertUserCanAccessBook(logEntry.bookId, user);
const resolved = await this.providerResolver.resolve(user, logEntry.providerId ?? null);
const file = await this.fileSelector.select(logEntry.bookId, logEntry.bookFileId ?? null, null);
const template = await this.templateService.resolveTemplate(logEntry.templateId ?? null, user);
const context = await this.templateContextService.buildForBook(logEntry.bookId, file.id, user.name);
const rendered = renderTemplate(template.subject, template.bodyText, context);
const effectiveSubject = logEntry.subject === KINDLE_CONVERT_SUBJECT ? KINDLE_CONVERT_SUBJECT : rendered.subject;
const newLog = await this.sendLogService.create({
userId: user.id,
bookId: logEntry.bookId,
bookFileId: file.id,
providerId: resolved.providerId,
templateId: template.id,
toEmail: logEntry.toEmail,
toName: logEntry.toName ?? '',
subject: effectiveSubject,
});
const task: SendTask = {
bookId: logEntry.bookId,
recipientId: 0,
recipientEmail: logEntry.toEmail,
recipientName: logEntry.toName ?? '',
deviceType: logEntry.subject === KINDLE_CONVERT_SUBJECT ? 'kindle' : null,
preferredFormat: null,
effectiveTemplateId: logEntry.templateId ?? null,
};
setImmediate(() => void this.dispatchSend(newLog.id, resolved.config, task, file, effectiveSubject, rendered.bodyText, 0));
this.logger.log(
`[${EMAIL_RESEND_EVENT}] [end] userId=${user.id} logEntryId=${logEntryId} durationMs=${Date.now() - startedAt} queued=1 - resend enqueue completed`,
);
return { queued: 1 };
} catch (error) {
this.logger.error(
`[${EMAIL_RESEND_EVENT}] [fail] userId=${user.id} logEntryId=${logEntryId} durationMs=${Date.now() - startedAt} errorClass=${this.getErrorClass(error)} error="${this.getErrorMessage(error)}" - resend enqueue failed`,
);
throw error;
}
const resolved = await this.providerResolver.resolve(user, logEntry.providerId ?? null);
const file = await this.fileSelector.select(logEntry.bookId, logEntry.bookFileId ?? null, null);
const template = await this.templateService.resolveTemplate(logEntry.templateId ?? null, user);
const context = await this.templateContextService.buildForBook(logEntry.bookId, file.id, user.name);
const rendered = renderTemplate(template.subject, template.bodyText, context);
const effectiveSubject = logEntry.subject === 'convert' ? 'convert' : rendered.subject;
const newLog = await this.sendLogService.create({
userId: user.id,
bookId: logEntry.bookId,
bookFileId: file.id,
providerId: resolved.providerId,
templateId: template.id,
toEmail: logEntry.toEmail,
toName: logEntry.toName ?? '',
subject: effectiveSubject,
});
const task: SendTask = {
bookId: logEntry.bookId,
recipientId: 0,
recipientEmail: logEntry.toEmail,
recipientName: logEntry.toName ?? '',
deviceType: logEntry.subject === 'convert' ? 'kindle' : null,
preferredFormat: null,
effectiveTemplateId: logEntry.templateId ?? null,
};
setImmediate(() => void this.dispatchSend(newLog.id, resolved.config, task, file, effectiveSubject, rendered.bodyText, 0));
return { queued: 1 };
}
async quickSend(bookId: number, user: RequestUser): Promise<{ queued: number }> {
this.logger.log(`[email.quick-send] [start] user=${user.id} book=${bookId}`);
const prefs = await this.preferencesService.getForUser(user.id);
if (!prefs?.defaultRecipientId) {
throw new BadRequestException('No default recipient configured. Set one in email settings.');
const startedAt = Date.now();
this.logger.log(`[${EMAIL_QUICK_SEND_EVENT}] [start] userId=${user.id} bookId=${bookId} - quick send enqueue started`);
try {
await this.bookAccessService.assertUserCanAccessBook(bookId, user);
const prefs = await this.preferencesService.getForUser(user.id);
if (!prefs?.defaultRecipientId) {
throw new BadRequestException('No default recipient configured. Set one in email settings.');
}
const recipient = await this.recipientService.getOwnedById(prefs.defaultRecipientId, user);
const resolved = await this.providerResolver.resolve(user, null);
await this.enqueueOne(
{
bookId,
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
deviceType: recipient.deviceType,
preferredFormat: recipient.preferredFormat,
effectiveTemplateId: recipient.defaultTemplateId ?? prefs.defaultTemplateId ?? null,
},
resolved,
null,
user,
);
this.logger.log(
`[${EMAIL_QUICK_SEND_EVENT}] [end] userId=${user.id} bookId=${bookId} durationMs=${Date.now() - startedAt} queued=1 - quick send enqueue completed`,
);
return { queued: 1 };
} catch (error) {
this.logger.error(
`[${EMAIL_QUICK_SEND_EVENT}] [fail] userId=${user.id} bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${this.getErrorClass(error)} error="${this.getErrorMessage(error)}" - quick send enqueue failed`,
);
throw error;
}
const recipient = await this.recipientService.getOwnedById(prefs.defaultRecipientId, user);
const resolved = await this.providerResolver.resolve(user, null);
await this.enqueueOne(
{
bookId,
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
deviceType: recipient.deviceType,
preferredFormat: recipient.preferredFormat,
effectiveTemplateId: recipient.defaultTemplateId ?? prefs.defaultTemplateId ?? null,
},
resolved,
{ bookIds: [bookId] },
user,
);
return { queued: 1 };
}
private async buildTasks(dto: SendBookDto, user: RequestUser): Promise<Omit<SendTask, 'bookId'>[]> {
const tasks: Omit<SendTask, 'bookId'>[] = [];
const recipientIds = new Set<number>(dto.recipientIds ?? []);
if (dto.groupIds?.length) {
for (const groupId of dto.groupIds) {
const ids = await this.groupService.expandOwnedGroupToRecipientIds(groupId, user);
ids.forEach((id) => recipientIds.add(id));
const groupRecipients = await Promise.all(dto.groupIds.map((groupId) => this.groupService.expandOwnedGroupToRecipientIds(groupId, user)));
for (const groupMemberIds of groupRecipients) {
groupMemberIds.forEach((id) => recipientIds.add(id));
}
}
for (const id of recipientIds) {
const recipient = await this.recipientService.getOwnedById(id, user);
tasks.push({
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
deviceType: recipient.deviceType,
preferredFormat: recipient.preferredFormat,
effectiveTemplateId: recipient.defaultTemplateId ?? dto.templateId ?? null,
});
}
const dedupedRecipientIds = [...recipientIds];
if (dedupedRecipientIds.length === 0) return [];
return tasks;
const recipients = await this.recipientService.getOwnedByIds(dedupedRecipientIds, user);
return recipients.map((recipient) => ({
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
deviceType: recipient.deviceType,
preferredFormat: recipient.preferredFormat,
effectiveTemplateId: recipient.defaultTemplateId ?? dto.templateId ?? null,
}));
}
private async enqueueOne(
task: SendTask,
resolved: Awaited<ReturnType<EmailProviderResolver['resolve']>>,
dto: Pick<SendBookDto, 'bookIds' | 'fileId' | 'templateId'>,
fileId: number | null,
user: RequestUser,
) {
const file = await this.fileSelector.select(task.bookId, dto.fileId ?? null, task.preferredFormat);
const file = await this.fileSelector.select(task.bookId, fileId, task.preferredFormat);
const template = await this.templateService.resolveTemplate(task.effectiveTemplateId, user);
const context = await this.templateContextService.buildForBook(task.bookId, file.id, user.name);
const rendered = renderTemplate(template.subject, template.bodyText, context);
const effectiveSubject = task.deviceType === 'kindle' ? 'convert' : rendered.subject;
const effectiveSubject = task.deviceType === 'kindle' ? KINDLE_CONVERT_SUBJECT : rendered.subject;
const logEntry = await this.sendLogService.create({
userId: user.id,
@@ -188,14 +232,14 @@ export class EmailSendOrchestrator {
logId: number,
smtpConfig: Awaited<ReturnType<EmailProviderResolver['resolve']>>['config'],
task: SendTask,
file: typeof schema.bookFiles.$inferSelect,
file: BookFile,
subject: string,
bodyText: string,
attemptCount: number,
) {
try {
const transporter = this.transportService.buildTransporter(smtpConfig);
const effectiveSubject = task.deviceType === 'kindle' ? 'convert' : subject;
const effectiveSubject = task.deviceType === 'kindle' ? KINDLE_CONVERT_SUBJECT : subject;
await transporter.sendMail({
to: task.recipientEmail,
@@ -210,23 +254,28 @@ export class EmailSendOrchestrator {
});
await this.sendLogService.markSent(logId);
this.logger.log(`[email.dispatch] [success] log=${logId} to=${task.recipientEmail}`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
this.logger.warn(`[email.dispatch] [retry] log=${logId} attempt=${attemptCount + 1} error="${errorMessage}"`);
} catch (error) {
const errorClass = this.getErrorClass(error);
const errorMessage = this.getErrorMessage(error);
const attempt = attemptCount + 1;
const { isFinal } = await this.sendLogService.markFailed(logId, errorMessage, attemptCount);
if (!isFinal) {
const delayMs = SEND_RETRY_DELAYS_MS[attemptCount] ?? 0;
this.logger.warn(
`[${EMAIL_DISPATCH_EVENT}] [fail] logId=${logId} attempt=${attempt} willRetry=true retryDelayMs=${delayMs} errorClass=${errorClass} error="${errorMessage}" - dispatch failed`,
);
setTimeout(() => void this.dispatchSend(logId, smtpConfig, task, file, subject, bodyText, attemptCount + 1), delayMs);
} else {
this.logger.error(`[email.dispatch] [fail] log=${logId} to=${task.recipientEmail} error="${errorMessage}"`);
this.logger.error(
`[${EMAIL_DISPATCH_EVENT}] [fail] logId=${logId} toEmail=${task.recipientEmail} attempt=${attempt} willRetry=false errorClass=${errorClass} error="${errorMessage}" - dispatch failed`,
);
}
}
}
private buildAttachmentFilename(file: typeof schema.bookFiles.$inferSelect): string {
private buildAttachmentFilename(file: BookFile): string {
const ext = file.format ? `.${file.format.toLowerCase()}` : '';
const base = file.relPath
? (file.relPath
@@ -236,4 +285,17 @@ export class EmailSendOrchestrator {
: 'book';
return `${base}${ext}`;
}
private getErrorClass(error: unknown): string {
if (error instanceof Error && error.name) return error.name;
return 'Error';
}
private getErrorMessage(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error);
return raw
.replace(/[\r\n"]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
}
@@ -0,0 +1,5 @@
export const KINDLE_CONVERT_SUBJECT = 'convert';
export const EMAIL_SEND_STATUS_PENDING = 'pending';
export const EMAIL_SEND_STATUS_SENT = 'sent';
export const EMAIL_SEND_STATUS_FAILED = 'failed';
@@ -1,27 +1,33 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { EmailTemplateContextService } from './email-template-context.service';
import { DB } from '../../db';
import { EmailBookReadRepository } from './email-book-read.repository';
describe('EmailTemplateContextService', () => {
let service: EmailTemplateContextService;
let db: any;
let repo: {
findBookById: ReturnType<typeof vi.fn>;
findMetadataByBookId: ReturnType<typeof vi.fn>;
findAuthorNamesByBookId: ReturnType<typeof vi.fn>;
findTagNamesByBookId: ReturnType<typeof vi.fn>;
findFileById: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
db = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
repo = {
findBookById: vi.fn(),
findMetadataByBookId: vi.fn(),
findAuthorNamesByBookId: vi.fn(),
findTagNamesByBookId: vi.fn(),
findFileById: vi.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
EmailTemplateContextService,
{ provide: DB, useValue: db },
{ provide: EmailBookReadRepository, useValue: repo },
{ provide: ConfigService, useValue: { get: vi.fn().mockReturnValue('http://localhost') } },
],
}).compile();
@@ -30,41 +36,16 @@ describe('EmailTemplateContextService', () => {
});
it('should build context for a book', async () => {
const mockBook = { id: 1 };
const mockBook = { id: 1, primaryFileId: 100 };
const mockMeta = { title: 'Book Title', subtitle: 'Sub', seriesName: 'S', seriesIndex: 1 };
const mockAuthors = [{ name: 'A1' }, { name: 'A2' }];
const mockTags = [{ name: 'T1' }];
const mockFile = { format: 'EPUB', sizeBytes: 1024 * 1024 };
// Promise.all order: book, meta, authors, tags, file
(db.limit as vi.Mock)
.mockResolvedValueOnce([mockBook]) // book
.mockResolvedValueOnce([mockMeta]) // meta
.mockResolvedValueOnce([mockFile]); // file (because it's the last one with limit)
// Authors and tags use orderBy or innerJoin and don't have limit
(db.orderBy as vi.Mock).mockResolvedValueOnce(mockAuthors);
(db.where as vi.Mock).mockImplementation(function (this: any) {
if (db.where.mock.calls.length === 4) {
// tags call
return Promise.resolve(mockTags);
}
return this;
});
// Actually, it's cleaner to mock each stage
db.limit = vi.fn().mockResolvedValueOnce([mockBook]).mockResolvedValueOnce([mockMeta]).mockResolvedValueOnce([mockFile]);
db.orderBy = vi.fn().mockResolvedValueOnce(mockAuthors);
// For tags, it's select().from().innerJoin().where() -> no limit, no orderBy
// We need to make where() return mockTags for the 4th call
let callCount = 0;
db.where = vi.fn().mockImplementation(function (this: any) {
callCount++;
if (callCount === 4) return Promise.resolve(mockTags);
return this;
});
const mockFile = { id: 100, bookId: 1, format: 'EPUB', sizeBytes: 1024 * 1024 };
repo.findBookById.mockResolvedValue(mockBook);
repo.findMetadataByBookId.mockResolvedValue(mockMeta);
repo.findAuthorNamesByBookId.mockResolvedValue(mockAuthors);
repo.findTagNamesByBookId.mockResolvedValue(mockTags);
repo.findFileById.mockResolvedValue(mockFile);
const context = await service.buildForBook(1, 100, 'Sender');
@@ -76,10 +57,21 @@ describe('EmailTemplateContextService', () => {
});
it('should throw NotFoundException if book not found', async () => {
db.limit = vi.fn().mockResolvedValueOnce([]); // book not found
db.where = vi.fn().mockReturnThis();
db.orderBy = vi.fn().mockReturnThis();
repo.findBookById.mockResolvedValue(null);
repo.findMetadataByBookId.mockResolvedValue(null);
repo.findAuthorNamesByBookId.mockResolvedValue([]);
repo.findTagNamesByBookId.mockResolvedValue([]);
await expect(service.buildForBook(1, null, 'Sender')).rejects.toThrow(NotFoundException);
});
it('should throw NotFoundException if file does not belong to the book', async () => {
repo.findBookById.mockResolvedValue({ id: 1 });
repo.findMetadataByBookId.mockResolvedValue(null);
repo.findAuthorNamesByBookId.mockResolvedValue([]);
repo.findTagNamesByBookId.mockResolvedValue([]);
repo.findFileById.mockResolvedValue({ id: 200, bookId: 2, format: 'PDF' });
await expect(service.buildForBook(1, 200, 'Sender')).rejects.toThrow(NotFoundException);
});
});
@@ -1,44 +1,33 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, bookTags, books, tags } from '../../db/schema';
import type { BookFile } from '../../db/schema';
import type { TemplateContext } from './email-template-renderer';
import { formatFileSize } from './email-template-renderer';
type Db = NodePgDatabase<typeof schema>;
import { EmailBookReadRepository } from './email-book-read.repository';
@Injectable()
export class EmailTemplateContextService {
constructor(
@Inject(DB) private readonly db: Db,
private readonly bookReadRepository: EmailBookReadRepository,
private readonly config: ConfigService,
) {}
async buildForBook(bookId: number, fileId: number | null, senderName: string): Promise<TemplateContext> {
const [bookResult, metaResult, authorRows, tagRows, fileResult] = await Promise.all([
this.db.select().from(books).where(eq(books.id, bookId)).limit(1),
this.db.select().from(bookMetadata).where(eq(bookMetadata.bookId, bookId)).limit(1),
this.db
.select({ name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(eq(bookAuthors.bookId, bookId))
.orderBy(bookAuthors.displayOrder),
this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, bookId)),
fileId ? this.db.select().from(bookFiles).where(eq(bookFiles.id, fileId)).limit(1) : Promise.resolve([]),
const [book, meta, authorRows, tagRows, file] = await Promise.all([
this.bookReadRepository.findBookById(bookId),
this.bookReadRepository.findMetadataByBookId(bookId),
this.bookReadRepository.findAuthorNamesByBookId(bookId),
this.bookReadRepository.findTagNamesByBookId(bookId),
fileId !== null ? this.bookReadRepository.findFileById(fileId) : Promise.resolve(null),
]);
const [book] = bookResult;
if (!book) throw new NotFoundException('Book not found');
if (fileId !== null && (!file || file.bookId !== bookId)) throw new NotFoundException('Book file not found');
const [meta] = metaResult;
const file: typeof bookFiles.$inferSelect | undefined = fileResult[0];
const selectedFile: BookFile | null = fileId !== null ? file : null;
const appUrl = this.config.get<string>('app.appUrl') ?? '';
const appUrl = (this.config.get<string>('app.appUrl') ?? '').replace(/\/+$/, '');
const coverUrl = meta ? `${appUrl}/api/books/${bookId}/cover` : '';
return {
@@ -47,8 +36,8 @@ export class EmailTemplateContextService {
author: authorRows.map((a) => a.name).join(', '),
series: meta?.seriesName ?? '',
seriesIndex: meta?.seriesIndex ?? null,
format: file?.format?.toUpperCase() ?? '',
fileSize: formatFileSize(file?.sizeBytes ?? null),
format: selectedFile?.format?.toUpperCase() ?? '',
fileSize: formatFileSize(selectedFile?.sizeBytes ?? null),
pageCount: meta?.pageCount ?? null,
publisher: meta?.publisher ?? '',
publishedYear: meta?.publishedYear ?? null,
@@ -3,11 +3,13 @@ import { Test, TestingModule } from '@nestjs/testing';
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 type { RequestUser } from '../../common/types/request-user';
describe('EmailTemplateService', () => {
let service: EmailTemplateService;
let repo: EmailTemplateRepository;
let bookAccessService: EmailBookAccessService;
const mockUser: RequestUser = {
id: 1,
@@ -55,11 +57,16 @@ describe('EmailTemplateService', () => {
provide: EmailTemplateContextService,
useValue: { buildForBook: vi.fn() },
},
{
provide: EmailBookAccessService,
useValue: { assertUserCanAccessBook: vi.fn().mockResolvedValue(undefined) },
},
],
}).compile();
service = module.get<EmailTemplateService>(EmailTemplateService);
repo = module.get<EmailTemplateRepository>(EmailTemplateRepository);
bookAccessService = module.get<EmailBookAccessService>(EmailBookAccessService);
});
describe('findAll', () => {
@@ -148,6 +155,13 @@ describe('EmailTemplateService', () => {
const result = await service.preview(10, 1, 100, mockUser);
expect(result.subject).toBe('S');
expect(bookAccessService.assertUserCanAccessBook).toHaveBeenCalledWith(1, mockUser);
});
it('should fail preview when book access is denied', async () => {
(bookAccessService.assertUserCanAccessBook as vi.Mock).mockRejectedValue(new ForbiddenException('No access to this library'));
await expect(service.preview(10, 1, 100, mockUser)).rejects.toThrow(ForbiddenException);
});
});
@@ -4,6 +4,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { EmailTemplateRepository } from './email-template.repository';
import { EmailTemplateContextService } from './email-template-context.service';
import { renderTemplate } from './email-template-renderer';
import { EmailBookAccessService } from './email-book-access.service';
import { CreateEmailTemplateDto } from './dto/create-email-template.dto';
import { UpdateEmailTemplateDto } from './dto/update-email-template.dto';
import type { EmailTemplate } from '../../db/schema';
@@ -13,6 +14,7 @@ export class EmailTemplateService {
constructor(
private readonly repo: EmailTemplateRepository,
private readonly contextService: EmailTemplateContextService,
private readonly bookAccessService: EmailBookAccessService,
) {}
async findAll(user: RequestUser) {
@@ -73,6 +75,7 @@ export class EmailTemplateService {
async preview(id: number, bookId: number, fileId: number | null, user: RequestUser) {
const template = await this.getAccessible(id, user);
await this.bookAccessService.assertUserCanAccessBook(bookId, user);
const context = await this.contextService.buildForBook(bookId, fileId, user.name);
return renderTemplate(template.subject, template.bodyText, context);
}
@@ -83,4 +83,22 @@ describe('EmailTransportService', () => {
}),
);
});
it('should not disable TLS certificate verification by default', () => {
(nodemailer.createTransport as vi.Mock).mockReturnValue({});
service.buildTransporter({
host: 'smtp.test.com',
port: 587,
username: 'user',
password: 'pass',
auth: true,
ssl: false,
startTls: true,
});
const calls = (nodemailer.createTransport as vi.Mock).mock.calls;
const callArg = calls[calls.length - 1][0] as Record<string, unknown>;
expect(callArg).not.toHaveProperty('tls');
});
});
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import type { Transporter } from 'nodemailer';
export interface SmtpConfig {
@@ -17,16 +18,18 @@ export class EmailTransportService {
buildTransporter(config: SmtpConfig): Transporter {
const secure = config.ssl || config.port === 465;
const requireTls = !secure && (config.startTls || config.port === 587);
const hasAuthCredentials = config.username !== undefined && config.username !== null && config.password !== undefined && config.password !== null;
return nodemailer.createTransport({
const username = config.username ?? undefined;
const password = config.password ?? undefined;
const auth = config.auth && username !== undefined && password !== undefined ? { user: username, pass: password } : undefined;
const options: SMTPTransport.Options = {
host: config.host,
port: config.port,
secure,
requireTLS: requireTls,
...(config.auth && hasAuthCredentials ? { auth: { user: config.username, pass: config.password } } : {}),
tls: { rejectUnauthorized: false },
} as nodemailer.TransportOptions);
...(auth ? { auth } : {}),
};
return nodemailer.createTransport(options);
}
async verifyTransporter(transporter: Transporter): Promise<void> {
@@ -1,6 +1,8 @@
import { MODULE_METADATA } from '@nestjs/common/constants';
import { EmailAdminLogController } from './email-admin-log.controller';
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 { EmailModule } from './email.module';
@@ -27,6 +29,7 @@ import { EmailTemplateController } from './email-template.controller';
import { EmailTemplateRepository } from './email-template.repository';
import { EmailTemplateService } from './email-template.service';
import { EmailTransportService } from './email-transport.service';
import { SystemMailService } from './system-mail.service';
describe('EmailModule metadata', () => {
it('registers all email controllers', () => {
@@ -66,13 +69,16 @@ describe('EmailModule metadata', () => {
EmailPreferencesRepository,
EmailPreferencesService,
EmailProviderResolver,
EmailBookReadRepository,
EmailBookAccessService,
EmailFileSelector,
EmailSendLogRepository,
EmailSendLogService,
EmailSendOrchestrator,
SystemMailService,
]),
);
expect(exportsList).toEqual(expect.arrayContaining([EmailSendOrchestrator, EmailTransportService, EmailEncryptionService]));
expect(exportsList).toEqual(expect.arrayContaining([EmailSendOrchestrator, EmailTransportService, EmailEncryptionService, SystemMailService]));
});
});
+8 -2
View File
@@ -1,6 +1,10 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { BookModule } from '../book/book.module';
import { LibraryModule } from '../library/library.module';
import { EmailAdminLogController } from './email-admin-log.controller';
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 { EmailPreferencesController } from './email-preferences.controller';
@@ -29,7 +33,7 @@ import { EmailTransportService } from './email-transport.service';
import { SystemMailService } from './system-mail.service';
@Module({
imports: [],
imports: [forwardRef(() => BookModule), forwardRef(() => LibraryModule)],
controllers: [
EmailProviderController,
EmailRecipientController,
@@ -41,6 +45,8 @@ import { SystemMailService } from './system-mail.service';
EmailAdminLogController,
],
providers: [
EmailBookReadRepository,
EmailBookAccessService,
EmailEncryptionService,
EmailTransportService,
EmailProviderRepository,
@@ -0,0 +1,110 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { EmailEncryptionService } from './email-encryption.service';
import { EmailProviderRepository } from './email-provider.repository';
import { EmailTransportService } from './email-transport.service';
import { SystemMailService } from './system-mail.service';
describe('SystemMailService', () => {
let service: SystemMailService;
let repo: { findSystemProvider: ReturnType<typeof vi.fn> };
let encryption: { decrypt: ReturnType<typeof vi.fn> };
let transport: { buildTransporter: ReturnType<typeof vi.fn> };
let config: { get: ReturnType<typeof vi.fn> };
let transporter: { sendMail: ReturnType<typeof vi.fn> };
const provider = {
id: 3,
host: 'smtp.example.com',
port: 587,
username: 'smtp-user',
passwordEnc: 'enc-password',
auth: true,
ssl: false,
startTls: true,
fromName: 'Project X',
fromAddress: 'no-reply@example.com',
};
beforeEach(async () => {
transporter = {
sendMail: vi.fn().mockResolvedValue({ messageId: 'abc123' }),
};
repo = {
findSystemProvider: vi.fn().mockResolvedValue([provider]),
};
encryption = {
decrypt: vi.fn().mockReturnValue('decrypted-password'),
};
transport = {
buildTransporter: vi.fn().mockReturnValue(transporter),
};
config = {
get: vi.fn((key: string) => {
if (key === 'app.appUrl') return 'http://localhost:3000/';
if (key === 'app.nodeEnv') return 'test';
return undefined;
}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
SystemMailService,
{ provide: EmailProviderRepository, useValue: repo },
{ provide: EmailEncryptionService, useValue: encryption },
{ provide: EmailTransportService, useValue: transport },
{ provide: ConfigService, useValue: config },
],
}).compile();
service = module.get<SystemMailService>(SystemMailService);
});
it('reports configured when a system provider exists', async () => {
await expect(service.isConfigured()).resolves.toBe(true);
});
it('reports not configured when no system provider exists', async () => {
repo.findSystemProvider.mockResolvedValue([]);
await expect(service.isConfigured()).resolves.toBe(false);
});
it('sends a password reset email using the configured provider', async () => {
await service.sendPasswordReset('user@example.com', 'User', 'raw-token');
expect(transport.buildTransporter).toHaveBeenCalledWith({
host: 'smtp.example.com',
port: 587,
username: 'smtp-user',
password: 'decrypted-password',
auth: true,
ssl: false,
startTls: true,
});
expect(transporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
from: 'Project X <no-reply@example.com>',
to: 'user@example.com',
subject: 'Reset your password',
}),
);
expect(transporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('http://localhost:3000/reset-password?token=raw-token'),
}),
);
});
it('throws when no system provider is configured at send time', async () => {
repo.findSystemProvider.mockResolvedValue([]);
await expect(service.sendPasswordReset('user@example.com', 'User', 'raw-token')).rejects.toThrow(ServiceUnavailableException);
expect(transport.buildTransporter).not.toHaveBeenCalled();
});
it('throws when SMTP delivery fails', async () => {
transporter.sendMail.mockRejectedValue(new Error('smtp timeout'));
await expect(service.sendPasswordReset('user@example.com', 'User', 'raw-token')).rejects.toThrow(ServiceUnavailableException);
});
});
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EmailEncryptionService } from './email-encryption.service';
@@ -8,6 +8,7 @@ import { EmailTransportService } from './email-transport.service';
@Injectable()
export class SystemMailService {
private readonly logger = new Logger(SystemMailService.name);
private readonly event = 'system-mail.password-reset';
constructor(
private readonly repo: EmailProviderRepository,
@@ -22,17 +23,21 @@ export class SystemMailService {
}
async sendPasswordReset(to: string, name: string, rawToken: string): Promise<void> {
const startedAt = Date.now();
const appUrl = (this.config.get<string>('app.appUrl') ?? '').replace(/\/+$/, '');
const resetUrl = `${appUrl}/reset-password?token=${encodeURIComponent(rawToken)}`;
this.logger.log(`[${this.event}] [start] toEmail=${to} - password reset send started`);
if (this.config.get('app.nodeEnv') !== 'production') {
this.logger.log(`[DEV] Password reset URL for ${name}: ${resetUrl}`);
this.logger.debug(`[${this.event}] [start] toEmail=${to} - password reset requested in non-production environment`);
}
const [provider] = await this.repo.findSystemProvider();
if (!provider) {
this.logger.warn('System mail provider not found when sending password reset - provider may have been removed');
return;
this.logger.error(
`[${this.event}] [fail] toEmail=${to} durationMs=${Date.now() - startedAt} errorClass=ServiceUnavailableException error="system mail provider not configured" - password reset send failed`,
);
throw new ServiceUnavailableException('System mail provider is not configured');
}
const password = provider.passwordEnc ? this.encryption.decrypt(provider.passwordEnc) : undefined;
@@ -56,10 +61,14 @@ export class SystemMailService {
text: this.buildResetText(name, resetUrl),
html: this.buildResetHtml(name, resetUrl),
});
this.logger.log(`[system-mail.password-reset] [success] to=${to}`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
this.logger.error(`[system-mail.password-reset] [fail] to=${to} error="${errorMessage}"`);
this.logger.log(`[${this.event}] [end] toEmail=${to} durationMs=${Date.now() - startedAt} - password reset send completed`);
} catch (error) {
const errorClass = error instanceof Error && error.name ? error.name : 'Error';
const errorMessage = this.sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
this.logger.error(
`[${this.event}] [fail] toEmail=${to} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - password reset send failed`,
);
throw new ServiceUnavailableException('Password reset email delivery failed');
}
}
@@ -126,4 +135,11 @@ export class SystemMailService {
</body>
</html>`;
}
private sanitizeErrorMessage(value: string): string {
return value
.replace(/[\r\n"]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
}
@@ -0,0 +1,116 @@
import { BookEmbedderRepository } from './book-embedder.repository';
type SelectStep = {
terminal: 'where' | 'limit';
result: unknown;
};
function makeDb(steps: SelectStep[]) {
const chains: Array<Record<string, vi.Mock>> = [];
const select = vi.fn().mockImplementation(() => {
const step = steps.shift();
if (!step) throw new Error('No mocked select step available');
const chain = {
from: vi.fn(),
where: vi.fn(),
limit: vi.fn(),
innerJoin: vi.fn(),
};
chain.from.mockReturnValue(chain);
chain.innerJoin.mockReturnValue(chain);
chain.where.mockImplementation(() => {
if (step.terminal === 'where') return Promise.resolve(step.result);
return chain;
});
chain.limit.mockImplementation(() => Promise.resolve(step.result));
chains.push(chain);
return chain;
});
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
const update = vi.fn().mockReturnValue({ set: updateSet });
return {
db: { select, update } as never,
select,
chains,
update,
updateSet,
updateWhere,
};
}
describe('BookEmbedderRepository', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('returns null when metadata row does not exist', async () => {
const { db, select } = makeDb([{ terminal: 'limit', result: [] }]);
const repository = new BookEmbedderRepository(db);
const result = await repository.findSourceData(42);
expect(result).toBeNull();
expect(select).toHaveBeenCalledTimes(1);
});
it('combines metadata with normalized author, genre, and tag names', async () => {
const { db, select } = makeDb([
{
terminal: 'limit',
result: [
{
title: 'The Last Wish',
seriesName: 'The Witcher',
publisher: 'Orbit',
description: 'A monster hunter travels the world',
},
],
},
{
terminal: 'where',
result: [{ name: ' Andrzej Sapkowski ' }, { name: ' ' }, { name: null }],
},
{
terminal: 'where',
result: [{ name: 'Fantasy' }, { name: 'Epic Fantasy' }],
},
{
terminal: 'where',
result: [{ name: 'Sword & Sorcery' }, { name: '' }],
},
]);
const repository = new BookEmbedderRepository(db);
const result = await repository.findSourceData(7);
expect(select).toHaveBeenCalledTimes(4);
expect(result).toEqual({
title: 'The Last Wish',
seriesName: 'The Witcher',
publisher: 'Orbit',
description: 'A monster hunter travels the world',
authors: ['Andrzej Sapkowski'],
genres: ['Fantasy', 'Epic Fantasy'],
tags: ['Sword & Sorcery'],
});
});
it('persists embedding vectors to book metadata', async () => {
const { db, update, updateSet, updateWhere } = makeDb([]);
const repository = new BookEmbedderRepository(db);
const embedding = [0.1, 0.2, 0.3];
await repository.saveEmbedding(11, embedding);
expect(update).toHaveBeenCalledTimes(1);
expect(updateSet).toHaveBeenCalledWith({ embedding });
expect(updateWhere).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,70 @@
import { Inject, Injectable } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookGenres, bookMetadata, bookTags, genres, tags } from '../../db/schema';
import { BookEmbeddingSourceData } from './book-embedding.types';
type Db = NodePgDatabase<typeof schema>;
type NameRow = { name: string | null };
@Injectable()
export class BookEmbedderRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findSourceData(bookId: number): Promise<BookEmbeddingSourceData | null> {
const [metadata] = await this.db
.select({
title: bookMetadata.title,
seriesName: bookMetadata.seriesName,
publisher: bookMetadata.publisher,
description: bookMetadata.description,
})
.from(bookMetadata)
.where(eq(bookMetadata.bookId, bookId))
.limit(1);
if (!metadata) return null;
const [authorNames, genreNames, tagNames] = await Promise.all([
this.fetchRelationNames(
this.db
.select({ name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(eq(bookAuthors.bookId, bookId)),
),
this.fetchRelationNames(
this.db
.select({ name: genres.name })
.from(bookGenres)
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
.where(eq(bookGenres.bookId, bookId)),
),
this.fetchRelationNames(
this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, bookId)),
),
]);
return {
title: metadata.title,
seriesName: metadata.seriesName,
publisher: metadata.publisher,
description: metadata.description,
authors: authorNames,
genres: genreNames,
tags: tagNames,
};
}
async saveEmbedding(bookId: number, embedding: number[]): Promise<void> {
await this.db.update(bookMetadata).set({ embedding }).where(eq(bookMetadata.bookId, bookId));
}
private async fetchRelationNames(queryPromise: Promise<NameRow[]>): Promise<string[]> {
const rows = await queryPromise;
return rows.map((row) => (row.name ?? '').trim()).filter((name): name is string => name.length > 0);
}
}
@@ -1,137 +1,104 @@
import { Logger } from '@nestjs/common';
import { BookEmbeddingSourceData } from './book-embedding.types';
import { BookEmbedderService } from './book-embedder.service';
function makeMetaChain(metaRow: unknown[] = []) {
const chain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue(metaRow),
function makeSourceData(overrides: Partial<BookEmbeddingSourceData> = {}): BookEmbeddingSourceData {
return {
title: 'The Last Wish',
seriesName: 'The Witcher',
publisher: 'Orbit',
description: 'A monster hunter travels the world',
authors: ['Andrzej Sapkowski'],
genres: ['Fantasy'],
tags: ['Sword & Sorcery'],
...overrides,
};
}
function makeService() {
const embedderRepository = {
findSourceData: vi.fn(),
saveEmbedding: vi.fn(),
};
const vectorizer = {
buildVector: vi.fn(),
};
return {
service: new BookEmbedderService(embedderRepository as never, vectorizer as never),
embedderRepository,
vectorizer,
};
return chain;
}
describe('BookEmbedderService', () => {
const logger = { debug: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
afterEach(() => {
vi.restoreAllMocks();
});
it('returns null when book metadata is missing', async () => {
const db = {
select: vi.fn().mockReturnValue(makeMetaChain([])),
};
const service = new BookEmbedderService(db as never);
it('returns null when metadata row does not exist', async () => {
const { service, embedderRepository, vectorizer } = makeService();
const debugSpy = vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
embedderRepository.findSourceData.mockResolvedValue(null);
await expect(service.embedBook(42)).resolves.toBeNull();
expect(db.select).toHaveBeenCalledTimes(1);
expect(embedderRepository.findSourceData).toHaveBeenCalledWith(42);
expect(vectorizer.buildVector).not.toHaveBeenCalled();
expect(embedderRepository.saveEmbedding).not.toHaveBeenCalled();
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('[book.embedding] [start] bookId=42'));
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('outcome=metadata_missing'));
});
it('builds and persists deterministic embedding for metadata + relations', async () => {
const metaChain = makeMetaChain([
{
title: 'The Last Wish',
seriesName: 'The Witcher',
publisher: 'Orbit',
description: 'A monster hunter travels the world',
},
]);
it('builds and persists embedding when source data exists', async () => {
const { service, embedderRepository, vectorizer } = makeService();
const debugSpy = vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
const sourceData = makeSourceData();
const embedding = [0.12, 0.34, 0.56];
const authorChain = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([{ name: 'Andrzej Sapkowski' }]),
};
const genreChain = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([{ name: 'Fantasy' }]),
};
const tagChain = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([{ name: 'Sword & Sorcery' }]),
};
embedderRepository.findSourceData.mockResolvedValue(sourceData);
vectorizer.buildVector.mockReturnValue(embedding);
embedderRepository.saveEmbedding.mockResolvedValue(undefined);
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
const update = vi.fn().mockReturnValue({ set: updateSet });
await expect(service.embedBook(7)).resolves.toEqual(embedding);
const db = {
select: vi.fn().mockReturnValueOnce(metaChain).mockReturnValueOnce(authorChain).mockReturnValueOnce(genreChain).mockReturnValueOnce(tagChain),
update,
};
const service = new BookEmbedderService(db as never);
(service as unknown as { logger: typeof logger }).logger = logger as never;
const embedding = await service.embedBook(7);
expect(embedding).not.toBeNull();
expect(embedding).toHaveLength(256);
const norm = Math.sqrt((embedding ?? []).reduce((sum, v) => sum + v * v, 0));
expect(norm).toBeCloseTo(1, 10);
expect(update).toHaveBeenCalledTimes(1);
expect(updateSet).toHaveBeenCalledWith({ embedding });
expect(updateWhere).toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenCalledWith('Embedded book 7');
const again = service.buildVector({
title: 'The Last Wish',
seriesName: 'The Witcher',
publisher: 'Orbit',
description: 'A monster hunter travels the world',
authors: ['Andrzej Sapkowski'],
genres: ['Fantasy'],
tags: ['Sword & Sorcery'],
});
expect(again).toEqual(embedding);
expect(embedderRepository.findSourceData).toHaveBeenCalledWith(7);
expect(vectorizer.buildVector).toHaveBeenCalledWith(sourceData);
expect(embedderRepository.saveEmbedding).toHaveBeenCalledWith(7, embedding);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('[book.embedding] [end] bookId=7'));
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('authorCount=1 genreCount=1 tagCount=1'));
});
it('filters stopwords/short tokens and keeps zero vector when no usable content', () => {
const service = new BookEmbedderService({} as never);
it('logs fail and rethrows when reading source data fails', async () => {
const { service, embedderRepository, vectorizer } = makeService();
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
embedderRepository.findSourceData.mockRejectedValue(new Error('database "offline"\n'));
const vec = service.buildVector({
title: 'a the of',
description: 'it is as to',
seriesName: null,
publisher: null,
authors: [],
genres: [],
tags: [],
});
await expect(service.embedBook(9)).rejects.toThrow('database "offline"');
expect(vec).toHaveLength(256);
expect(vec.every((v) => v === 0)).toBe(true);
expect(vectorizer.buildVector).not.toHaveBeenCalled();
expect(embedderRepository.saveEmbedding).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[book.embedding] [fail] bookId=9'));
expect(warnSpy.mock.calls[0][0]).toContain('errorClass=Error');
expect(warnSpy.mock.calls[0][0]).toMatch(/error="database\s+offline\s*"/);
expect(warnSpy.mock.calls[0][0]).not.toContain('\n');
});
it('limits description contribution to first 100 words', () => {
const service = new BookEmbedderService({} as never);
it('logs fail and rethrows when persistence fails', async () => {
const { service, embedderRepository, vectorizer } = makeService();
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const sourceData = makeSourceData({ authors: ['Andrzej Sapkowski', 'A. Author'] });
const embedding = [0.3, 0.7];
const words = Array.from({ length: 130 }, (_, i) => `token${i}`);
const longDescription = words.join(' ');
embedderRepository.findSourceData.mockResolvedValue(sourceData);
vectorizer.buildVector.mockReturnValue(embedding);
embedderRepository.saveEmbedding.mockRejectedValue(new Error('write failed'));
const with130 = service.buildVector({
title: null,
seriesName: null,
publisher: null,
authors: [],
genres: [],
tags: [],
description: longDescription,
});
await expect(service.embedBook(15)).rejects.toThrow('write failed');
const with100 = service.buildVector({
title: null,
seriesName: null,
publisher: null,
authors: [],
genres: [],
tags: [],
description: words.slice(0, 100).join(' '),
});
expect(with130).toEqual(with100);
expect(vectorizer.buildVector).toHaveBeenCalledWith(sourceData);
expect(embedderRepository.saveEmbedding).toHaveBeenCalledWith(15, embedding);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[book.embedding] [fail] bookId=15'));
});
});
@@ -1,178 +1,54 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Injectable, Logger } from '@nestjs/common';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookGenres, bookMetadata, bookTags, genres, tags } from '../../db/schema';
import { BookEmbeddingVectorizerService } from './book-embedding-vectorizer.service';
import { BookEmbedderRepository } from './book-embedder.repository';
type Db = NodePgDatabase<typeof schema>;
const DIMS = 256;
const STOPWORDS = new Set([
'the',
'a',
'an',
'and',
'or',
'of',
'in',
'to',
'for',
'is',
'it',
'on',
'at',
'by',
'with',
'as',
'be',
'this',
'that',
'are',
'was',
'were',
'has',
'have',
'had',
'not',
'but',
'from',
'can',
'all',
'so',
'if',
'no',
'will',
'would',
'could',
'should',
'may',
'might',
'do',
'does',
'did',
'been',
'being',
'into',
'out',
'up',
'one',
'two',
'more',
'most',
'also',
'just',
'about',
'after',
'its',
]);
const EMBEDDING_EVENT = 'book.embedding';
@Injectable()
export class BookEmbedderService {
private readonly logger = new Logger(BookEmbedderService.name);
constructor(@Inject(DB) private readonly db: Db) {}
constructor(
private readonly embedderRepository: BookEmbedderRepository,
private readonly vectorizer: BookEmbeddingVectorizerService,
) {}
async embedBook(bookId: number): Promise<number[] | null> {
const [meta] = await this.db
.select({
title: bookMetadata.title,
seriesName: bookMetadata.seriesName,
publisher: bookMetadata.publisher,
description: bookMetadata.description,
})
.from(bookMetadata)
.where(eq(bookMetadata.bookId, bookId))
.limit(1);
const startedAt = Date.now();
this.logger.debug(`[${EMBEDDING_EVENT}] [start] bookId=${bookId} - embedding started`);
if (!meta) return null;
const [authorRows, genreRows, tagRows] = await Promise.all([
this.db
.select({ name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(eq(bookAuthors.bookId, bookId)),
this.db
.select({ name: genres.name })
.from(bookGenres)
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
.where(eq(bookGenres.bookId, bookId)),
this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, bookId)),
]);
const embedding = this.buildVector({
title: meta.title,
seriesName: meta.seriesName,
publisher: meta.publisher,
description: meta.description,
authors: authorRows.map((r) => r.name),
genres: genreRows.map((r) => r.name),
tags: tagRows.map((r) => r.name),
});
await this.db.update(bookMetadata).set({ embedding }).where(eq(bookMetadata.bookId, bookId));
this.logger.debug(`Embedded book ${bookId}`);
return embedding;
}
buildVector(params: {
title: string | null;
authors: string[];
genres: string[];
tags: string[];
seriesName: string | null;
publisher: string | null;
description: string | null;
}): number[] {
const acc = new Float64Array(DIMS);
const addExact = (text: string, weight: number) => {
const key = text.toLowerCase().trim();
if (key.length > 0) acc[this.fnv32(key) % DIMS] += weight;
};
const addTokens = (text: string, weight: number) => {
for (const token of this.tokenize(text)) {
acc[this.fnv32(token) % DIMS] += weight;
try {
const sourceData = await this.embedderRepository.findSourceData(bookId);
if (!sourceData) {
this.logger.debug(
`[${EMBEDDING_EVENT}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} outcome=metadata_missing - embedding completed`,
);
return null;
}
};
if (params.seriesName) addExact(params.seriesName, 3.0);
for (const author of params.authors) addExact(author, 1.0);
for (const genre of params.genres) addExact(genre, 4.0);
for (const tag of params.tags) addExact(tag, 3.5);
if (params.title) addTokens(params.title, 3.0);
if (params.publisher) addExact(params.publisher, 2.0);
if (params.description) {
addTokens(params.description.split(/\s+/).slice(0, 100).join(' '), 1.0);
const embedding = this.vectorizer.buildVector(sourceData);
await this.embedderRepository.saveEmbedding(bookId, embedding);
this.logger.debug(
`[${EMBEDDING_EVENT}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} authorCount=${sourceData.authors.length} genreCount=${sourceData.genres.length} tagCount=${sourceData.tags.length} - embedding completed`,
);
return embedding;
} catch (error) {
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
this.logger.warn(
`[${EMBEDDING_EVENT}] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${this.sanitizeErrorMessage(
error,
)}" - embedding failed`,
);
throw error;
}
return this.l2normalize(Array.from(acc));
}
private tokenize(text: string): string[] {
return text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((t) => t.length >= 2 && !STOPWORDS.has(t));
}
private fnv32(s: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
hash ^= s.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash;
}
private l2normalize(vec: number[]): number[] {
const norm = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0));
if (norm === 0) return vec;
return vec.map((v) => v / norm);
private sanitizeErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message.replace(/[\r\n"]/g, ' ').slice(0, 200);
return String(error)
.replace(/[\r\n"]/g, ' ')
.slice(0, 200);
}
}
@@ -0,0 +1,116 @@
import { BookEmbeddingVectorizerService, EMBEDDING_DIMENSIONS } from './book-embedding-vectorizer.service';
import { BookEmbeddingSourceData } from './book-embedding.types';
function makeSourceData(overrides: Partial<BookEmbeddingSourceData> = {}): BookEmbeddingSourceData {
return {
title: 'The Last Wish',
seriesName: 'The Witcher',
publisher: 'Orbit',
description: 'A monster hunter travels the world',
authors: ['Andrzej Sapkowski'],
genres: ['Fantasy'],
tags: ['Sword & Sorcery'],
...overrides,
};
}
describe('BookEmbeddingVectorizerService', () => {
it('produces deterministic normalized vectors', () => {
const vectorizer = new BookEmbeddingVectorizerService();
const sourceData = makeSourceData();
const vectorA = vectorizer.buildVector(sourceData);
const vectorB = vectorizer.buildVector(sourceData);
expect(vectorA).toEqual(vectorB);
expect(vectorA).toHaveLength(EMBEDDING_DIMENSIONS);
const norm = Math.sqrt(vectorA.reduce((sum, value) => sum + value * value, 0));
expect(norm).toBeCloseTo(1, 10);
});
it('keeps a zero vector when all text is stopwords or too short', () => {
const vectorizer = new BookEmbeddingVectorizerService();
const vector = vectorizer.buildVector(
makeSourceData({
title: 'a the of',
seriesName: null,
publisher: null,
description: 'it is as to',
authors: [],
genres: [],
tags: [],
}),
);
expect(vector).toHaveLength(EMBEDDING_DIMENSIONS);
expect(vector.every((value) => value === 0)).toBe(true);
});
it('limits description contribution to the first 100 tokens', () => {
const vectorizer = new BookEmbeddingVectorizerService();
const words = Array.from({ length: 130 }, (_, index) => `token${index}`);
const longDescription = words.join(' ');
const vectorWith130 = vectorizer.buildVector(
makeSourceData({
title: null,
seriesName: null,
publisher: null,
description: longDescription,
authors: [],
genres: [],
tags: [],
}),
);
const vectorWith100 = vectorizer.buildVector(
makeSourceData({
title: null,
seriesName: null,
publisher: null,
description: words.slice(0, 100).join(' '),
authors: [],
genres: [],
tags: [],
}),
);
expect(vectorWith130).toEqual(vectorWith100);
});
it('keeps accented and non-latin tokens in the vector', () => {
const vectorizer = new BookEmbeddingVectorizerService();
const vector = vectorizer.buildVector(
makeSourceData({
title: 'Cien años de soledad Преступление и наказание',
seriesName: null,
publisher: null,
description: null,
authors: [],
genres: [],
tags: [],
}),
);
expect(vector.some((value) => value !== 0)).toBe(true);
});
it('ignores blank exact-match fields', () => {
const vectorizer = new BookEmbeddingVectorizerService();
const vector = vectorizer.buildVector(
makeSourceData({
title: null,
seriesName: ' ',
publisher: '',
description: null,
authors: [' '],
genres: [''],
tags: [],
}),
);
expect(vector.every((value) => value === 0)).toBe(true);
});
});
@@ -0,0 +1,135 @@
import { Injectable } from '@nestjs/common';
import { BookEmbeddingSourceData } from './book-embedding.types';
export const EMBEDDING_DIMENSIONS = 256;
const MAX_DESCRIPTION_TOKENS = 100;
const MIN_TOKEN_LENGTH = 2;
const FEATURE_WEIGHTS = {
seriesName: 3.0,
author: 1.0,
genre: 4.0,
tag: 3.5,
title: 3.0,
publisher: 2.0,
description: 1.0,
} as const;
const STOPWORDS = new Set([
'the',
'a',
'an',
'and',
'or',
'of',
'in',
'to',
'for',
'is',
'it',
'on',
'at',
'by',
'with',
'as',
'be',
'this',
'that',
'are',
'was',
'were',
'has',
'have',
'had',
'not',
'but',
'from',
'can',
'all',
'so',
'if',
'no',
'will',
'would',
'could',
'should',
'may',
'might',
'do',
'does',
'did',
'been',
'being',
'into',
'out',
'up',
'one',
'two',
'more',
'most',
'also',
'just',
'about',
'after',
'its',
]);
@Injectable()
export class BookEmbeddingVectorizerService {
buildVector(params: BookEmbeddingSourceData): number[] {
const accumulator = new Float64Array(EMBEDDING_DIMENSIONS);
const addExact = (text: string, weight: number) => {
const normalized = text.toLowerCase().trim();
if (normalized.length === 0) return;
accumulator[this.fnv32(normalized) % EMBEDDING_DIMENSIONS] += weight;
};
const addTokens = (text: string, weight: number, maxTokens = Number.POSITIVE_INFINITY) => {
for (const token of this.tokenize(text, maxTokens)) {
accumulator[this.fnv32(token) % EMBEDDING_DIMENSIONS] += weight;
}
};
if (params.seriesName) addExact(params.seriesName, FEATURE_WEIGHTS.seriesName);
for (const authorName of params.authors) addExact(authorName, FEATURE_WEIGHTS.author);
for (const genreName of params.genres) addExact(genreName, FEATURE_WEIGHTS.genre);
for (const tagName of params.tags) addExact(tagName, FEATURE_WEIGHTS.tag);
if (params.title) addTokens(params.title, FEATURE_WEIGHTS.title);
if (params.publisher) addExact(params.publisher, FEATURE_WEIGHTS.publisher);
if (params.description) addTokens(params.description, FEATURE_WEIGHTS.description, MAX_DESCRIPTION_TOKENS);
return this.l2normalize(Array.from(accumulator));
}
private tokenize(text: string, maxTokens: number): string[] {
const tokens: string[] = [];
for (const match of text.toLowerCase().matchAll(/[\p{L}\p{N}]+/gu)) {
const token = match[0];
if (token.length < MIN_TOKEN_LENGTH || STOPWORDS.has(token)) continue;
tokens.push(token);
if (tokens.length >= maxTokens) break;
}
return tokens;
}
private fnv32(text: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash;
}
private l2normalize(vector: number[]): number[] {
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
if (norm === 0) return vector;
return vector.map((value) => value / norm);
}
}
@@ -0,0 +1,9 @@
export interface BookEmbeddingSourceData {
title: string | null;
authors: string[];
genres: string[];
tags: string[];
seriesName: string | null;
publisher: string | null;
description: string | null;
}
@@ -1,14 +1,29 @@
import { MODULE_METADATA } from '@nestjs/common/constants';
import { Global, Module } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { DB } from '../../db';
import { BookEmbeddingVectorizerService } from './book-embedding-vectorizer.service';
import { BookEmbedderRepository } from './book-embedder.repository';
import { BookEmbedderService } from './book-embedder.service';
import { EmbeddingModule } from './embedding.module';
describe('EmbeddingModule', () => {
it('registers and exports BookEmbedderService', () => {
const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, EmbeddingModule);
const exportsMeta = Reflect.getMetadata(MODULE_METADATA.EXPORTS, EmbeddingModule);
@Global()
@Module({
providers: [{ provide: DB, useValue: {} }],
exports: [DB],
})
class DbTestModule {}
expect(providers).toEqual(expect.arrayContaining([BookEmbedderService]));
expect(exportsMeta).toEqual(expect.arrayContaining([BookEmbedderService]));
describe('EmbeddingModule', () => {
it('wires embedding providers in a Nest module', async () => {
const testingModule = await Test.createTestingModule({
imports: [DbTestModule, EmbeddingModule],
}).compile();
expect(testingModule.get(BookEmbedderService)).toBeInstanceOf(BookEmbedderService);
expect(testingModule.get(BookEmbedderRepository)).toBeInstanceOf(BookEmbedderRepository);
expect(testingModule.get(BookEmbeddingVectorizerService)).toBeInstanceOf(BookEmbeddingVectorizerService);
await testingModule.close();
});
});
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { BookEmbeddingVectorizerService } from './book-embedding-vectorizer.service';
import { BookEmbedderRepository } from './book-embedder.repository';
import { BookEmbedderService } from './book-embedder.service';
@Module({
providers: [BookEmbedderService],
providers: [BookEmbedderService, BookEmbedderRepository, BookEmbeddingVectorizerService],
exports: [BookEmbedderService],
})
export class EmbeddingModule {}