From 61156561537c28f0afbb07a41f55e699c5607851 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:45:00 -0600 Subject: [PATCH] test(email): add email lifecycle E2E suite and harden send behavior --- .github/workflows/e2e-email-lifecycle.yml | 70 ++ DEVELOPMENT.md | 13 +- package.json | 1 + scripts/test/e2e-suites.mjs | 8 + .../src/modules/email/email-db-error.util.ts | 10 + .../email/email-provider-resolver.test.ts | 4 + .../modules/email/email-provider-resolver.ts | 2 + .../email/email-provider.service.test.ts | 13 +- .../modules/email/email-provider.service.ts | 51 +- .../email-recipient-group.service.test.ts | 12 +- .../email/email-recipient-group.service.ts | 35 +- .../email/email-recipient.service.test.ts | 12 +- .../modules/email/email-recipient.service.ts | 41 +- .../email-send-orchestrator.service.test.ts | 50 +- .../email/email-send-orchestrator.service.ts | 24 +- .../email-template-context.service.test.ts | 5 +- .../email/email-template-context.service.ts | 10 +- .../modules/email/email-template-renderer.ts | 2 + .../email/email-template.service.test.ts | 12 +- .../modules/email/email-template.service.ts | 37 +- .../modules/email/email-transport.service.ts | 2 + .../test/e2e/email/email-fixture-builder.ts | 43 + server/test/e2e/email/email-harness.ts | 756 +++++++++++++++ server/test/email-lifecycle.e2e-spec.ts | 915 ++++++++++++++++++ 24 files changed, 2062 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/e2e-email-lifecycle.yml create mode 100644 server/src/modules/email/email-db-error.util.ts create mode 100644 server/test/e2e/email/email-fixture-builder.ts create mode 100644 server/test/e2e/email/email-harness.ts create mode 100644 server/test/email-lifecycle.e2e-spec.ts diff --git a/.github/workflows/e2e-email-lifecycle.yml b/.github/workflows/e2e-email-lifecycle.yml new file mode 100644 index 000000000..02337dd30 --- /dev/null +++ b/.github/workflows/e2e-email-lifecycle.yml @@ -0,0 +1,70 @@ +name: Email Lifecycle E2E +run-name: ${{ format('Email Lifecycle E2E - {0}', github.ref_name) }} + +on: + workflow_dispatch: + push: + paths: + - "server/src/modules/email/**" + - "server/src/modules/book/**" + - "server/src/modules/library/**" + - "server/src/modules/auth/**" + - "server/src/modules/cover/**" + - "server/src/modules/scanner/**" + - "server/src/modules/seed/**" + - "server/src/db/schema/**" + - "packages/types/src/**" + - "server/test/email-lifecycle.e2e-spec.ts" + - "server/test/e2e/email/**" + - "server/test/e2e/**" + - "scripts/test/**" + - "scripts/db/**" + - "client/src/features/email/**" + - "client/src/router/index.ts" + - "client/src/features/settings/SettingsHeader.vue" + - "client/src/views/HomeView.vue" + - "client/src/views/CollectionView.vue" + - "client/src/views/LensView.vue" + - "client/src/features/book/components/BookCoverCard.vue" + - "client/src/features/book/components/BookListRow.vue" + pull_request: + paths: + - "server/src/modules/email/**" + - "server/src/modules/book/**" + - "server/src/modules/library/**" + - "server/src/modules/auth/**" + - "server/src/modules/cover/**" + - "server/src/modules/scanner/**" + - "server/src/modules/seed/**" + - "server/src/db/schema/**" + - "packages/types/src/**" + - "server/test/email-lifecycle.e2e-spec.ts" + - "server/test/e2e/email/**" + - "server/test/e2e/**" + - "scripts/test/**" + - "scripts/db/**" + - "client/src/features/email/**" + - "client/src/router/index.ts" + - "client/src/features/settings/SettingsHeader.vue" + - "client/src/views/HomeView.vue" + - "client/src/views/CollectionView.vue" + - "client/src/views/LensView.vue" + - "client/src/features/book/components/BookCoverCard.vue" + - "client/src/features/book/components/BookListRow.vue" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + +jobs: + email-lifecycle-e2e: + uses: ./.github/workflows/e2e-runner.yml + with: + suite-id: email-lifecycle + check-name: Server Email Lifecycle E2E + artifact-name: email-lifecycle-e2e-report + job-timeout-minutes: 45 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 3f85d35cc..44adaebb9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -121,9 +121,11 @@ pnpm run test:e2e -- scanner-file-ops # scanner file operation e2e suite (dedic pnpm run test:e2e -- staging-ingest-finalize # staging ingest/finalize e2e suite (dedicated projectx_e2e DB) pnpm run test:e2e -- opds-auth-catalog # OPDS auth/catalog e2e suite (dedicated projectx_e2e DB) pnpm run test:e2e -- reader-state-isolation # reader state isolation e2e suite (dedicated projectx_e2e DB) +pnpm run test:e2e -- email-lifecycle # email lifecycle e2e suite (dedicated projectx_e2e DB) pnpm run test:e2e:all # alias for "pnpm run test:e2e -- all" pnpm run test:e2e:opds-auth-catalog # alias for "pnpm run test:e2e -- opds-auth-catalog" pnpm run test:e2e:reader-state-isolation # alias for "pnpm run test:e2e -- reader-state-isolation" +pnpm run test:e2e:email-lifecycle # alias for "pnpm run test:e2e -- email-lifecycle" pnpm run test:e2e:list # list all supported e2e suite ids ``` @@ -187,6 +189,14 @@ pnpm run test:e2e -- scanner --testNamePattern=book-per-folder-disc-folder-flatt - `reader-state-isolation-e2e-junit.xml` - `reader-state-isolation-e2e-scenarios.json` +### Email lifecycle e2e details + +- `pnpm run test:e2e -- email-lifecycle` prepares and migrates the dedicated e2e database (`projectx_e2e`) before running. +- Local runs auto-start PostgreSQL with `docker-compose.dev.yml` if needed. +- Results are written to `test-results/server/`: + - `email-lifecycle-e2e-junit.xml` + - `email-lifecycle-e2e-scenarios.json` + ### E2E in CI (how to trigger) All suite workflows call the reusable `E2E Runner (reusable)` workflow, which runs: @@ -202,11 +212,12 @@ pnpm run test:e2e -- | Staging Ingest Finalize E2E | `staging-ingest-finalize` | `workflow_dispatch`, `push` (staging-related paths), `pull_request` (same paths) | | OPDS Auth Catalog E2E | `opds-auth-catalog` | `workflow_dispatch`, `push` (opds-related paths), `pull_request` (same paths) | | Reader State Isolation E2E | `reader-state-isolation` | `workflow_dispatch`, `push` (reader-state-related paths), `pull_request` (same paths) | +| Email Lifecycle E2E | `email-lifecycle` | `workflow_dispatch`, `push` (email-related paths), `pull_request` (same paths) | Manual trigger steps: 1. Open GitHub Actions. -2. Select **Scanner E2E**, **Scanner File Ops E2E**, **Staging Ingest Finalize E2E**, **OPDS Auth Catalog E2E**, or **Reader State Isolation E2E**. +2. Select **Scanner E2E**, **Scanner File Ops E2E**, **Staging Ingest Finalize E2E**, **OPDS Auth Catalog E2E**, **Reader State Isolation E2E**, or **Email Lifecycle E2E**. 3. Click **Run workflow**. Each run uploads `test-results/server/` as an artifact and publishes JUnit annotations from `test-results/server/*-e2e-junit.xml`. diff --git a/package.json b/package.json index b1b8d6dc3..20f6a98e0 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test:e2e:metadata-write:atomicity": "node ./scripts/test/run-e2e-suite.mjs metadata-write --testNamePattern=\"atomicity failpoints\"", "test:e2e:metadata-write:concurrency": "node ./scripts/test/run-e2e-suite.mjs metadata-write --testNamePattern=\"concurrency hardening\"", "test:e2e:reader-state-isolation": "node ./scripts/test/run-e2e-suite.mjs reader-state-isolation", + "test:e2e:email-lifecycle": "node ./scripts/test/run-e2e-suite.mjs email-lifecycle", "test:e2e:list": "node ./scripts/test/run-e2e-suite.mjs --list", "coverage": "pnpm run coverage:server && pnpm run coverage:client", "coverage:server": "pnpm --filter server test:cov", diff --git a/scripts/test/e2e-suites.mjs b/scripts/test/e2e-suites.mjs index 1442439c3..775aceeb5 100644 --- a/scripts/test/e2e-suites.mjs +++ b/scripts/test/e2e-suites.mjs @@ -73,6 +73,14 @@ export const E2E_SUITES = Object.freeze({ prepareDedicatedDatabase: true, useDedicatedDatabase: true, }, + "email-lifecycle": { + id: "email-lifecycle", + description: "Email lifecycle suite", + vitestTarget: "test/email-lifecycle.e2e-spec.ts", + junitOutput: `${TEST_RESULTS_DIR}/email-lifecycle-e2e-junit.xml`, + prepareDedicatedDatabase: true, + useDedicatedDatabase: true, + }, "reader-state-isolation": { id: "reader-state-isolation", description: "Reader state isolation suite", diff --git a/server/src/modules/email/email-db-error.util.ts b/server/src/modules/email/email-db-error.util.ts new file mode 100644 index 000000000..b75ba3745 --- /dev/null +++ b/server/src/modules/email/email-db-error.util.ts @@ -0,0 +1,10 @@ +export function isUniqueViolation(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + + const directCode = (error as { code?: unknown }).code; + if (directCode === '23505') return true; + + if (!(error instanceof Error)) return false; + const causeCode = (error.cause as { code?: unknown } | undefined)?.code; + return causeCode === '23505'; +} diff --git a/server/src/modules/email/email-provider-resolver.test.ts b/server/src/modules/email/email-provider-resolver.test.ts index 62489c343..8d481bf11 100644 --- a/server/src/modules/email/email-provider-resolver.test.ts +++ b/server/src/modules/email/email-provider-resolver.test.ts @@ -31,6 +31,8 @@ describe('EmailProviderResolver', () => { port: 587, username: 'test-user', plainPassword: 'decrypted-password', + fromName: 'ProjectX Bot', + fromAddress: 'bot@example.com', auth: true, ssl: false, startTls: true, @@ -70,6 +72,8 @@ describe('EmailProviderResolver', () => { expect(result.providerId).toBe(10); expect(result.config.password).toBe('decrypted-password'); expect(result.config.host).toBe('smtp.test.com'); + expect(result.config.fromName).toBe('ProjectX Bot'); + expect(result.config.fromAddress).toBe('bot@example.com'); }); it('should resolve from default provider if no ID requested', async () => { diff --git a/server/src/modules/email/email-provider-resolver.ts b/server/src/modules/email/email-provider-resolver.ts index af19a4523..ebce89188 100644 --- a/server/src/modules/email/email-provider-resolver.ts +++ b/server/src/modules/email/email-provider-resolver.ts @@ -45,6 +45,8 @@ export class EmailProviderResolver { port: provider.port, username: provider.username, password: provider.plainPassword, + fromName: provider.fromName, + fromAddress: provider.fromAddress, auth: provider.auth, ssl: provider.ssl, startTls: provider.startTls, diff --git a/server/src/modules/email/email-provider.service.test.ts b/server/src/modules/email/email-provider.service.test.ts index d5e05f8b0..7d7737487 100644 --- a/server/src/modules/email/email-provider.service.test.ts +++ b/server/src/modules/email/email-provider.service.test.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EmailProviderService } from './email-provider.service'; import { EmailProviderRepository } from './email-provider.repository'; @@ -96,6 +96,12 @@ describe('EmailProviderService', () => { expect(encryption.encrypt).toHaveBeenCalledWith('new-password'); expect(repo.insert).toHaveBeenCalled(); }); + + it('should map duplicate provider names to ConflictException', async () => { + (repo.insert as vi.Mock).mockRejectedValue({ code: '23505' }); + const dto = { name: 'New SMTP', host: 'new.test.com', port: 465, password: 'new-password', auth: true, ssl: true, startTls: false }; + await expect(service.create(dto, mockUser)).rejects.toThrow(ConflictException); + }); }); describe('findOne', () => { @@ -119,6 +125,11 @@ describe('EmailProviderService', () => { (repo.update as vi.Mock).mockResolvedValue([]); await expect(service.update(10, {}, mockUser)).rejects.toThrow(NotFoundException); }); + + it('should map duplicate provider names to ConflictException', async () => { + (repo.update as vi.Mock).mockRejectedValue({ code: '23505' }); + await expect(service.update(10, { name: 'duplicate' }, mockUser)).rejects.toThrow(ConflictException); + }); }); describe('remove', () => { diff --git a/server/src/modules/email/email-provider.service.ts b/server/src/modules/email/email-provider.service.ts index 48f89a230..908ea9036 100644 --- a/server/src/modules/email/email-provider.service.ts +++ b/server/src/modules/email/email-provider.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import type { RequestUser } from '../../common/types/request-user'; import { emailProviders } from '../../db/schema'; @@ -8,6 +8,7 @@ import { EmailProviderRepository } from './email-provider.repository'; import { CreateEmailProviderDto } from './dto/create-email-provider.dto'; import { UpdateEmailProviderDto } from './dto/update-email-provider.dto'; import type { EmailProvider } from '../../db/schema'; +import { isUniqueViolation } from './email-db-error.util'; @Injectable() export class EmailProviderService { @@ -29,20 +30,27 @@ export class EmailProviderService { async create(dto: CreateEmailProviderDto, user: RequestUser) { const passwordEnc = dto.password ? this.encryption.encrypt(dto.password) : null; - const [created] = await this.repo.insert({ - userId: user.id, - name: dto.name, - host: dto.host, - port: dto.port, - username: dto.username ?? null, - passwordEnc, - fromName: dto.fromName ?? null, - fromAddress: dto.fromAddress ?? null, - auth: dto.auth, - ssl: dto.ssl, - startTls: dto.startTls, - }); - return this.sanitize(created); + try { + const [created] = await this.repo.insert({ + userId: user.id, + name: dto.name, + host: dto.host, + port: dto.port, + username: dto.username ?? null, + passwordEnc, + fromName: dto.fromName ?? null, + fromAddress: dto.fromAddress ?? null, + auth: dto.auth, + ssl: dto.ssl, + startTls: dto.startTls, + }); + return this.sanitize(created); + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException('An email provider with this name already exists'); + } + throw error; + } } async update(id: number, dto: UpdateEmailProviderDto, user: RequestUser) { @@ -62,9 +70,16 @@ export class EmailProviderService { patch.passwordEnc = dto.password ? this.encryption.encrypt(dto.password) : null; } - const [updated] = await this.repo.update(id, user.id, patch); - if (!updated) throw new NotFoundException('Provider not found'); - return this.sanitize(updated); + try { + const [updated] = await this.repo.update(id, user.id, patch); + if (!updated) throw new NotFoundException('Provider not found'); + return this.sanitize(updated); + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException('An email provider with this name already exists'); + } + throw error; + } } async remove(id: number, user: RequestUser) { diff --git a/server/src/modules/email/email-recipient-group.service.test.ts b/server/src/modules/email/email-recipient-group.service.test.ts index 0564b2a3e..4850c9c20 100644 --- a/server/src/modules/email/email-recipient-group.service.test.ts +++ b/server/src/modules/email/email-recipient-group.service.test.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EmailRecipientGroupService } from './email-recipient-group.service'; import { EmailRecipientGroupRepository } from './email-recipient-group.repository'; @@ -85,6 +85,11 @@ describe('EmailRecipientGroupService', () => { expect(repo.insert).toHaveBeenCalledWith(expect.objectContaining({ name: 'New Group' })); expect(result.id).toBe(10); }); + + it('should map duplicate group names to ConflictException', async () => { + (repo.insert as vi.Mock).mockRejectedValue({ code: '23505' }); + await expect(service.create({ name: 'New Group' }, mockUser)).rejects.toThrow(ConflictException); + }); }); describe('update', () => { @@ -94,6 +99,11 @@ describe('EmailRecipientGroupService', () => { expect(repo.update).toHaveBeenCalledWith(10, 1, dto); expect(result.id).toBe(10); }); + + it('should map duplicate group names to ConflictException', async () => { + (repo.update as vi.Mock).mockRejectedValue({ code: '23505' }); + await expect(service.update(10, { name: 'Updated' }, mockUser)).rejects.toThrow(ConflictException); + }); }); describe('remove', () => { diff --git a/server/src/modules/email/email-recipient-group.service.ts b/server/src/modules/email/email-recipient-group.service.ts index 2c5e154e1..806f18c89 100644 --- a/server/src/modules/email/email-recipient-group.service.ts +++ b/server/src/modules/email/email-recipient-group.service.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import type { RequestUser } from '../../common/types/request-user'; import { EmailRecipientGroupRepository } from './email-recipient-group.repository'; @@ -6,6 +6,7 @@ import { EmailRecipientRepository } from './email-recipient.repository'; import { CreateEmailRecipientGroupDto } from './dto/create-email-recipient-group.dto'; import { UpdateEmailRecipientGroupDto } from './dto/update-email-recipient-group.dto'; import type { EmailRecipientGroup } from '../../db/schema'; +import { isUniqueViolation } from './email-db-error.util'; @Injectable() export class EmailRecipientGroupService { @@ -42,19 +43,33 @@ export class EmailRecipientGroupService { } async create(dto: CreateEmailRecipientGroupDto, user: RequestUser) { - const [created] = await this.repo.insert({ - userId: user.id, - name: dto.name, - defaultTemplateId: dto.defaultTemplateId ?? null, - }); - return { ...created, members: [] }; + try { + const [created] = await this.repo.insert({ + userId: user.id, + name: dto.name, + defaultTemplateId: dto.defaultTemplateId ?? null, + }); + return { ...created, members: [] }; + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException('An email recipient group with this name already exists'); + } + throw error; + } } async update(id: number, dto: UpdateEmailRecipientGroupDto, user: RequestUser) { await this.getOwned(id, user); - const [updated] = await this.repo.update(id, user.id, dto); - if (!updated) throw new NotFoundException('Group not found'); - return updated; + try { + const [updated] = await this.repo.update(id, user.id, dto); + if (!updated) throw new NotFoundException('Group not found'); + return updated; + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException('An email recipient group with this name already exists'); + } + throw error; + } } async remove(id: number, user: RequestUser) { diff --git a/server/src/modules/email/email-recipient.service.test.ts b/server/src/modules/email/email-recipient.service.test.ts index b1cde2929..929f38e52 100644 --- a/server/src/modules/email/email-recipient.service.test.ts +++ b/server/src/modules/email/email-recipient.service.test.ts @@ -1,4 +1,4 @@ -import { NotFoundException } from '@nestjs/common'; +import { ConflictException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EmailRecipientService } from './email-recipient.service'; import { EmailRecipientRepository } from './email-recipient.repository'; @@ -67,6 +67,11 @@ describe('EmailRecipientService', () => { expect(result.id).toBe(10); }); + it('should map duplicate recipient emails to ConflictException on create', async () => { + (repo.insert as vi.Mock).mockRejectedValue({ code: '23505' }); + await expect(service.create({ name: 'New', email: 'new@test.com' }, mockUser)).rejects.toThrow(ConflictException); + }); + it('should update a recipient', async () => { const dto = { name: 'Updated' }; const result = await service.update(10, dto, mockUser); @@ -91,6 +96,11 @@ describe('EmailRecipientService', () => { await expect(service.update(10, { name: 'U' }, mockUser)).rejects.toThrow(NotFoundException); }); + it('should map duplicate recipient emails to ConflictException on update', async () => { + (repo.update as vi.Mock).mockRejectedValue({ code: '23505' }); + await expect(service.update(10, { email: 'duplicate@test.com' }, mockUser)).rejects.toThrow(ConflictException); + }); + it('should throw NotFoundException on setDefault if not exists', async () => { (repo.setDefault as vi.Mock).mockResolvedValue([]); await expect(service.setDefault(10, mockUser)).rejects.toThrow(NotFoundException); diff --git a/server/src/modules/email/email-recipient.service.ts b/server/src/modules/email/email-recipient.service.ts index 9af383f77..2a328fcc2 100644 --- a/server/src/modules/email/email-recipient.service.ts +++ b/server/src/modules/email/email-recipient.service.ts @@ -1,10 +1,11 @@ -import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import type { RequestUser } from '../../common/types/request-user'; import { EmailRecipientRepository } from './email-recipient.repository'; import { CreateEmailRecipientDto } from './dto/create-email-recipient.dto'; import { UpdateEmailRecipientDto } from './dto/update-email-recipient.dto'; import type { EmailRecipient } from '../../db/schema'; +import { isUniqueViolation } from './email-db-error.util'; @Injectable() export class EmailRecipientService { @@ -19,22 +20,36 @@ export class EmailRecipientService { } async create(dto: CreateEmailRecipientDto, user: RequestUser) { - const [created] = await this.repo.insert({ - userId: user.id, - name: dto.name, - email: dto.email, - deviceType: dto.deviceType ?? null, - preferredFormat: dto.preferredFormat ?? null, - defaultTemplateId: dto.defaultTemplateId ?? null, - }); - return created; + try { + const [created] = await this.repo.insert({ + userId: user.id, + name: dto.name, + email: dto.email, + deviceType: dto.deviceType ?? null, + preferredFormat: dto.preferredFormat ?? null, + defaultTemplateId: dto.defaultTemplateId ?? null, + }); + return created; + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException('An email recipient with this email already exists'); + } + throw error; + } } async update(id: number, dto: UpdateEmailRecipientDto, user: RequestUser) { await this.getOwnedById(id, user); - const [updated] = await this.repo.update(id, user.id, dto); - if (!updated) throw new NotFoundException('Recipient not found'); - return updated; + try { + const [updated] = await this.repo.update(id, user.id, dto); + if (!updated) throw new NotFoundException('Recipient not found'); + return updated; + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException('An email recipient with this email already exists'); + } + throw error; + } } async remove(id: number, user: RequestUser) { diff --git a/server/src/modules/email/email-send-orchestrator.service.test.ts b/server/src/modules/email/email-send-orchestrator.service.test.ts index f26dddfb4..823c01d9c 100644 --- a/server/src/modules/email/email-send-orchestrator.service.test.ts +++ b/server/src/modules/email/email-send-orchestrator.service.test.ts @@ -27,6 +27,7 @@ describe('EmailSendOrchestrator', () => { let sendLogService: EmailSendLogService; let transportService: EmailTransportService; let bookAccessService: EmailBookAccessService; + let templateService: EmailTemplateService; const mockUser: RequestUser = { id: 1, @@ -53,7 +54,10 @@ describe('EmailSendOrchestrator', () => { }; const mockFile = { id: 100, absolutePath: '/path/to/book.mobi', format: 'MOBI', relPath: 'Books/book.mobi' }; const mockTemplate = { id: 200, subject: 'Subject {{title}}', bodyText: 'Body' }; - const mockProvider = { config: { host: 'smtp.test.com' }, providerId: 300 }; + const mockProvider = { + config: { host: 'smtp.test.com', fromName: 'ProjectX Bot', fromAddress: 'bot@example.com' }, + providerId: 300, + }; const mockLogEntry = { id: 400 }; beforeEach(async () => { @@ -125,6 +129,7 @@ describe('EmailSendOrchestrator', () => { sendLogService = module.get(EmailSendLogService); transportService = module.get(EmailTransportService); bookAccessService = module.get(EmailBookAccessService); + templateService = module.get(EmailTemplateService); (fs.createReadStream as vi.Mock).mockReturnValue('mock-stream'); }); @@ -160,6 +165,36 @@ describe('EmailSendOrchestrator', () => { const dto: SendBookDto = { bookIds: [1], recipientIds: [10] }; await expect(orchestrator.send(dto, mockUser)).rejects.toThrow('No access to this library'); }); + + it('should prefer explicit templateId over recipient default template', async () => { + (recipientService.getOwnedByIds as vi.Mock).mockResolvedValue([{ ...mockRecipient, defaultTemplateId: 999 }]); + + await orchestrator.send( + { + bookIds: [1], + recipientIds: [10], + templateId: 123, + }, + mockUser, + ); + + expect(templateService.resolveTemplate).toHaveBeenCalledWith(123, mockUser); + }); + + it('should fall back to user preference defaultTemplateId when recipient and request templates are missing', async () => { + (preferencesService.getForUser as vi.Mock).mockResolvedValue({ defaultTemplateId: 777 }); + (recipientService.getOwnedByIds as vi.Mock).mockResolvedValue([{ ...mockRecipient, defaultTemplateId: null }]); + + await orchestrator.send( + { + bookIds: [1], + recipientIds: [10], + }, + mockUser, + ); + + expect(templateService.resolveTemplate).toHaveBeenCalledWith(777, mockUser); + }); }); describe('quickSend', () => { @@ -258,6 +293,19 @@ describe('EmailSendOrchestrator', () => { ); }); + it('should include from header when provider sender fields are configured', async () => { + const task = { recipientEmail: 'test@test.com' } as any; + const file = { absolutePath: '/test.mobi', relPath: 'test.mobi' } as any; + + await (orchestrator as any).dispatchSend(400, { fromName: 'ProjectX Bot', fromAddress: 'bot@example.com' }, task, file, 'Subject', 'Body', 0); + + expect(mockTransporter.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'ProjectX Bot ', + }), + ); + }); + it('should use "convert" subject in resend if original was "convert"', async () => { const existingLog = { userId: mockUser.id, diff --git a/server/src/modules/email/email-send-orchestrator.service.ts b/server/src/modules/email/email-send-orchestrator.service.ts index 58b0311fb..6ede4ef65 100644 --- a/server/src/modules/email/email-send-orchestrator.service.ts +++ b/server/src/modules/email/email-send-orchestrator.service.ts @@ -59,7 +59,8 @@ export class EmailSendOrchestrator { try { await this.bookAccessService.assertUserCanAccessBooks(dto.bookIds, user); - const tasks = await this.buildTasks(dto, user); + const preferences = await this.preferencesService.getForUser(user.id); + const tasks = await this.buildTasks(dto, user, preferences?.defaultTemplateId ?? null); if (tasks.length === 0) throw new BadRequestException('No recipients specified'); const resolved = await this.providerResolver.resolve(user, dto.providerId); @@ -177,7 +178,7 @@ export class EmailSendOrchestrator { } } - private async buildTasks(dto: SendBookDto, user: RequestUser): Promise[]> { + private async buildTasks(dto: SendBookDto, user: RequestUser, defaultTemplateId: number | null): Promise[]> { const recipientIds = new Set(dto.recipientIds ?? []); if (dto.groupIds?.length) { @@ -197,7 +198,7 @@ export class EmailSendOrchestrator { recipientName: recipient.name, deviceType: recipient.deviceType, preferredFormat: recipient.preferredFormat, - effectiveTemplateId: recipient.defaultTemplateId ?? dto.templateId ?? null, + effectiveTemplateId: dto.templateId ?? recipient.defaultTemplateId ?? defaultTemplateId, })); } @@ -240,8 +241,10 @@ export class EmailSendOrchestrator { try { const transporter = this.transportService.buildTransporter(smtpConfig); const effectiveSubject = task.deviceType === 'kindle' ? KINDLE_CONVERT_SUBJECT : subject; + const from = this.buildFromHeader(smtpConfig.fromName, smtpConfig.fromAddress); await transporter.sendMail({ + ...(from ? { from } : {}), to: task.recipientEmail, subject: effectiveSubject, text: bodyText, @@ -266,7 +269,10 @@ export class EmailSendOrchestrator { 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); + const retryTimer = setTimeout(() => void this.dispatchSend(logId, smtpConfig, task, file, subject, bodyText, attemptCount + 1), delayMs); + if (typeof retryTimer.unref === 'function') { + retryTimer.unref(); + } } else { this.logger.error( `[${EMAIL_DISPATCH_EVENT}] [fail] logId=${logId} toEmail=${task.recipientEmail} attempt=${attempt} willRetry=false errorClass=${errorClass} error="${errorMessage}" - dispatch failed`, @@ -286,6 +292,16 @@ export class EmailSendOrchestrator { return `${base}${ext}`; } + private buildFromHeader(fromName: string | null | undefined, fromAddress: string | null | undefined): string | undefined { + const normalizedAddress = fromAddress?.trim(); + if (!normalizedAddress) return undefined; + + const normalizedName = fromName?.trim(); + if (!normalizedName) return normalizedAddress; + + return `${normalizedName} <${normalizedAddress}>`; + } + private getErrorClass(error: unknown): string { if (error instanceof Error && error.name) return error.name; return 'Error'; diff --git a/server/src/modules/email/email-template-context.service.test.ts b/server/src/modules/email/email-template-context.service.test.ts index 279350bc7..76623f891 100644 --- a/server/src/modules/email/email-template-context.service.test.ts +++ b/server/src/modules/email/email-template-context.service.test.ts @@ -51,9 +51,12 @@ describe('EmailTemplateContextService', () => { expect(context.title).toBe('Book Title'); expect(context.author).toBe('A1, A2'); + expect(context.authors).toBe('A1, A2'); + expect(context.series).toBe('S'); + expect(context.seriesName).toBe('S'); expect(context.tags).toBe('T1'); expect(context.fileSize).toBe('1.0 MB'); - expect(context.coverUrl).toBe('http://localhost/api/books/1/cover'); + expect(context.coverUrl).toBe('http://localhost/api/v1/books/1/cover'); }); it('should throw NotFoundException if book not found', async () => { diff --git a/server/src/modules/email/email-template-context.service.ts b/server/src/modules/email/email-template-context.service.ts index ce54065f6..dbe4b5598 100644 --- a/server/src/modules/email/email-template-context.service.ts +++ b/server/src/modules/email/email-template-context.service.ts @@ -28,13 +28,17 @@ export class EmailTemplateContextService { const selectedFile: BookFile | null = fileId !== null ? file : null; const appUrl = (this.config.get('app.appUrl') ?? '').replace(/\/+$/, ''); - const coverUrl = meta ? `${appUrl}/api/books/${bookId}/cover` : ''; + const coverUrl = meta ? `${appUrl}/api/v1/books/${bookId}/cover` : ''; + const authors = authorRows.map((a) => a.name).join(', '); + const seriesName = meta?.seriesName ?? ''; return { title: meta?.title ?? '', subtitle: meta?.subtitle ?? '', - author: authorRows.map((a) => a.name).join(', '), - series: meta?.seriesName ?? '', + author: authors, + authors, + series: seriesName, + seriesName, seriesIndex: meta?.seriesIndex ?? null, format: selectedFile?.format?.toUpperCase() ?? '', fileSize: formatFileSize(selectedFile?.sizeBytes ?? null), diff --git a/server/src/modules/email/email-template-renderer.ts b/server/src/modules/email/email-template-renderer.ts index a3a532ff2..ff5e84b47 100644 --- a/server/src/modules/email/email-template-renderer.ts +++ b/server/src/modules/email/email-template-renderer.ts @@ -2,7 +2,9 @@ export interface TemplateContext { title?: string | null; subtitle?: string | null; author?: string | null; + authors?: string | null; series?: string | null; + seriesName?: string | null; seriesIndex?: number | null; format?: string | null; fileSize?: string | null; diff --git a/server/src/modules/email/email-template.service.test.ts b/server/src/modules/email/email-template.service.test.ts index 2978755cc..02672c97b 100644 --- a/server/src/modules/email/email-template.service.test.ts +++ b/server/src/modules/email/email-template.service.test.ts @@ -1,4 +1,4 @@ -import { ForbiddenException } from '@nestjs/common'; +import { ConflictException, ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EmailTemplateService } from './email-template.service'; import { EmailTemplateRepository } from './email-template.repository'; @@ -90,6 +90,11 @@ describe('EmailTemplateService', () => { expect(repo.insert).toHaveBeenCalledWith(expect.objectContaining({ name: 'New', userId: 1 })); expect(result.id).toBe(10); }); + + it('should map duplicate template names to ConflictException', async () => { + (repo.insert as vi.Mock).mockRejectedValue({ code: '23505' }); + await expect(service.create({ name: 'New', subject: 'S', bodyText: 'B' }, mockUser)).rejects.toThrow(ConflictException); + }); }); describe('update', () => { @@ -111,6 +116,11 @@ describe('EmailTemplateService', () => { const result = await service.update(20, { name: 'New' }, mockAdmin); expect(result.name).toBe('New'); }); + + it('should map duplicate template names to ConflictException on update', async () => { + (repo.update as vi.Mock).mockRejectedValue({ code: '23505' }); + await expect(service.update(10, { name: 'New' }, mockUser)).rejects.toThrow(ConflictException); + }); }); describe('remove', () => { diff --git a/server/src/modules/email/email-template.service.ts b/server/src/modules/email/email-template.service.ts index 45fcf388f..2149d7933 100644 --- a/server/src/modules/email/email-template.service.ts +++ b/server/src/modules/email/email-template.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import type { RequestUser } from '../../common/types/request-user'; import { EmailTemplateRepository } from './email-template.repository'; @@ -8,6 +8,7 @@ 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'; +import { isUniqueViolation } from './email-db-error.util'; @Injectable() export class EmailTemplateService { @@ -29,13 +30,20 @@ export class EmailTemplateService { } async create(dto: CreateEmailTemplateDto, user: RequestUser) { - const [created] = await this.repo.insert({ - userId: user.id, - name: dto.name, - subject: dto.subject, - bodyText: dto.bodyText, - }); - return created; + try { + const [created] = await this.repo.insert({ + userId: user.id, + name: dto.name, + subject: dto.subject, + bodyText: dto.bodyText, + }); + return created; + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException('An email template with this name already exists'); + } + throw error; + } } async update(id: number, dto: UpdateEmailTemplateDto, user: RequestUser) { @@ -45,9 +53,16 @@ export class EmailTemplateService { if (template.isSystem && !isSuperuser) throw new ForbiddenException('Only administrators can modify system templates'); if (!template.isSystem && template.userId !== user.id) throw new ForbiddenException('Cannot modify this template'); - const [updated] = template.isSystem ? await this.repo.updateById(id, dto) : await this.repo.update(id, user.id, dto); - if (!updated) throw new NotFoundException('Template not found'); - return updated; + try { + const [updated] = template.isSystem ? await this.repo.updateById(id, dto) : await this.repo.update(id, user.id, dto); + if (!updated) throw new NotFoundException('Template not found'); + return updated; + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException('An email template with this name already exists'); + } + throw error; + } } async remove(id: number, user: RequestUser) { diff --git a/server/src/modules/email/email-transport.service.ts b/server/src/modules/email/email-transport.service.ts index e156b0d8b..54c4b6941 100644 --- a/server/src/modules/email/email-transport.service.ts +++ b/server/src/modules/email/email-transport.service.ts @@ -8,6 +8,8 @@ export interface SmtpConfig { port: number; username?: string | null; password?: string | null; + fromName?: string | null; + fromAddress?: string | null; auth: boolean; ssl: boolean; startTls: boolean; diff --git a/server/test/e2e/email/email-fixture-builder.ts b/server/test/e2e/email/email-fixture-builder.ts new file mode 100644 index 000000000..9a6217eaf --- /dev/null +++ b/server/test/e2e/email/email-fixture-builder.ts @@ -0,0 +1,43 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; + +export { createEpubFixture } from '../metadata-write/metadata-write-fixture-builder'; + +export interface EmailFixtureRoot { + rootPath: string; + booksPath: string; + stagingPath: string; + cleanup: () => Promise; +} + +function assertRelativePath(path: string): void { + if (path.startsWith('/')) { + throw new Error(`Fixture paths must be relative. Received "${path}"`); + } +} + +export async function createEmailFixtureRoot(prefix = 'email-lifecycle-e2e-'): Promise { + const rootPath = await mkdtemp(join(tmpdir(), prefix)); + const booksPath = join(rootPath, 'books'); + const stagingPath = join(booksPath, 'staging'); + await mkdir(stagingPath, { recursive: true }); + + return { + rootPath, + booksPath, + stagingPath, + cleanup: async () => { + await rm(rootPath, { recursive: true, force: true }); + }, + }; +} + +export async function writeFixtureFile(rootPath: string, relativePath: string, content: string | Buffer): Promise { + assertRelativePath(relativePath); + + const absolutePath = join(rootPath, relativePath); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, content); + return absolutePath; +} diff --git a/server/test/e2e/email/email-harness.ts b/server/test/e2e/email/email-harness.ts new file mode 100644 index 000000000..6491a2813 --- /dev/null +++ b/server/test/e2e/email/email-harness.ts @@ -0,0 +1,756 @@ +import { randomUUID } from 'crypto'; +import { hash } from 'bcryptjs'; +import fastifyCookie from '@fastify/cookie'; +import { Test } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { ValidationPipe } from '@nestjs/common'; +import { mkdir } from 'fs/promises'; +import { createServer, type Socket } from 'net'; +import { and, eq, inArray, isNotNull } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { DEFAULT_FORMAT_PRIORITY, type Permission } from '@projectx/types'; + +import { AppModule } from '../../../src/app.module'; +import { DB } from '../../../src/db'; +import * as schema from '../../../src/db/schema'; +import { MetadataService } from '../../../src/modules/metadata/metadata.service'; +import { StagingWatcherService } from '../../../src/modules/staging/staging-watcher.service'; +import { waitForScanCompletion } from '../app-harness'; +import { createEmailFixtureRoot, type EmailFixtureRoot } from './email-fixture-builder'; + +type Db = NodePgDatabase; + +const ADMIN_SETUP_DTO = { + username: 'email-lifecycle-e2e-admin', + name: 'Email Lifecycle E2E Admin', + email: 'email-lifecycle-e2e-admin@example.com', + password: 'EmailLifecycleAdmin123', +}; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +interface EnvSnapshot { + booksPath: string | undefined; + stagingPath: string | undefined; + appUrl: string | undefined; +} + +export interface TestUserSession { + userId: number; + username: string; + password: string; + accessToken: string; +} + +export interface EmailE2EContext { + app: NestFastifyApplication; + db: Db; + admin: TestUserSession; + fixture: EmailFixtureRoot; + envSnapshot: EnvSnapshot; + smtpSink: InProcessSmtpSink; +} + +export interface CreatedLibrary { + libraryId: number; + libraryFolderId: number; + folderPath: string; +} + +export interface LocatedBookFile { + libraryId: number; + bookId: number; + bookFileId: number; + absolutePath: string; + relPath: string | null; + format: string | null; +} + +export interface CapturedSmtpMessage { + envelopeFrom: string | null; + envelopeTo: string[]; + raw: string; +} + +interface SmtpSessionState { + mode: 'command' | 'data'; + buffer: string; + envelopeFrom: string | null; + envelopeTo: string[]; + dataLines: string[]; +} + +function parseSmtpAddress(value: string): string { + const trimmed = value.trim(); + const match = /^<(.+)>$/.exec(trimmed); + return (match?.[1] ?? trimmed).trim(); +} + +function normalizeLine(line: string): string { + return line.endsWith('\r') ? line.slice(0, -1) : line; +} + +export function extractSmtpHeader(rawMessage: string, headerName: string): string | null { + const targetHeader = headerName.toLowerCase(); + const [headerBlock] = rawMessage.split(/\r?\n\r?\n/, 1); + if (!headerBlock) return null; + + const lines = headerBlock.split(/\r?\n/); + let currentHeader: string | null = null; + let currentValue = ''; + + for (const rawLine of lines) { + if (/^\s/.test(rawLine)) { + if (currentHeader === targetHeader) { + currentValue = `${currentValue} ${rawLine.trim()}`; + } + continue; + } + + const match = /^([^:]+):\s*(.*)$/.exec(rawLine); + if (!match) continue; + + currentHeader = match[1].trim().toLowerCase(); + currentValue = match[2].trim(); + + if (currentHeader === targetHeader) { + return currentValue; + } + } + + return null; +} + +export class InProcessSmtpSink { + private readonly host = '127.0.0.1'; + private readonly sockets = new Set(); + private readonly messages: CapturedSmtpMessage[] = []; + private readonly server = createServer((socket) => { + this.handleConnection(socket); + }); + private port = 0; + + async start(): Promise { + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + this.server.off('listening', onListening); + reject(error); + }; + const onListening = () => { + this.server.off('error', onError); + const address = this.server.address(); + if (!address || typeof address === 'string') { + reject(new Error('SMTP sink did not expose a TCP address')); + return; + } + this.port = address.port; + resolve(); + }; + + this.server.once('error', onError); + this.server.once('listening', onListening); + this.server.listen(0, this.host); + }); + } + + async stop(): Promise { + for (const socket of this.sockets) { + socket.destroy(); + } + this.sockets.clear(); + + if (!this.server.listening) return; + await new Promise((resolve, reject) => { + this.server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + + clearMessages(): void { + this.messages.splice(0, this.messages.length); + } + + getMessages(): CapturedSmtpMessage[] { + return [...this.messages]; + } + + getHost(): string { + return this.host; + } + + getPort(): number { + if (this.port < 1) { + throw new Error('SMTP sink is not started'); + } + return this.port; + } + + async waitForMessages(count: number, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (this.messages.length >= count) return this.getMessages(); + await sleep(50); + } + + throw new Error(`Timed out waiting for ${count} SMTP message(s). Captured=${this.messages.length}`); + } + + private handleConnection(socket: Socket): void { + this.sockets.add(socket); + socket.setEncoding('utf8'); + + const state: SmtpSessionState = { + mode: 'command', + buffer: '', + envelopeFrom: null, + envelopeTo: [], + dataLines: [], + }; + + socket.write('220 email-lifecycle-smtp-sink ESMTP\r\n'); + + socket.on('data', (chunk: string) => { + state.buffer += chunk; + this.processBufferedInput(socket, state); + }); + + socket.on('close', () => { + this.sockets.delete(socket); + }); + + socket.on('error', () => { + this.sockets.delete(socket); + }); + } + + private processBufferedInput(socket: Socket, state: SmtpSessionState): void { + while (true) { + const newlineIndex = state.buffer.indexOf('\n'); + if (newlineIndex < 0) return; + + const rawLine = state.buffer.slice(0, newlineIndex); + state.buffer = state.buffer.slice(newlineIndex + 1); + const line = normalizeLine(rawLine); + + if (state.mode === 'data') { + this.handleDataLine(socket, state, line); + } else { + this.handleCommandLine(socket, state, line); + } + } + } + + private handleDataLine(socket: Socket, state: SmtpSessionState, line: string): void { + if (line === '.') { + this.messages.push({ + envelopeFrom: state.envelopeFrom, + envelopeTo: [...state.envelopeTo], + raw: state.dataLines.join('\r\n'), + }); + + state.mode = 'command'; + state.envelopeFrom = null; + state.envelopeTo = []; + state.dataLines = []; + + socket.write('250 2.0.0 Message accepted\r\n'); + return; + } + + state.dataLines.push(line.startsWith('..') ? line.slice(1) : line); + } + + private handleCommandLine(socket: Socket, state: SmtpSessionState, line: string): void { + const [commandRaw, ...rest] = line.split(' '); + const command = commandRaw?.toUpperCase() ?? ''; + const arg = rest.join(' ').trim(); + + switch (command) { + case 'EHLO': + case 'HELO': + socket.write('250-email-lifecycle-smtp-sink\r\n250 PIPELINING\r\n'); + return; + case 'MAIL': + if (!arg.toUpperCase().startsWith('FROM:')) { + socket.write('501 5.5.2 Syntax error in MAIL FROM\r\n'); + return; + } + state.envelopeFrom = parseSmtpAddress(arg.slice(5)); + state.envelopeTo = []; + socket.write('250 2.1.0 Sender OK\r\n'); + return; + case 'RCPT': + if (!arg.toUpperCase().startsWith('TO:')) { + socket.write('501 5.5.2 Syntax error in RCPT TO\r\n'); + return; + } + state.envelopeTo.push(parseSmtpAddress(arg.slice(3))); + socket.write('250 2.1.5 Recipient OK\r\n'); + return; + case 'DATA': + state.mode = 'data'; + state.dataLines = []; + socket.write('354 End data with .\r\n'); + return; + case 'RSET': + state.mode = 'command'; + state.envelopeFrom = null; + state.envelopeTo = []; + state.dataLines = []; + socket.write('250 2.0.0 Reset state\r\n'); + return; + case 'NOOP': + socket.write('250 2.0.0 OK\r\n'); + return; + case 'QUIT': + socket.write('221 2.0.0 Bye\r\n'); + socket.end(); + return; + case 'STARTTLS': + socket.write('454 4.7.0 TLS not available\r\n'); + return; + default: + socket.write('250 2.0.0 OK\r\n'); + } + } +} + +export function authHeader(token: string): Record { + return { authorization: `Bearer ${token}` }; +} + +export async function createEmailE2EContext(): Promise { + const fixture = await createEmailFixtureRoot(); + const envSnapshot: EnvSnapshot = { + booksPath: process.env.BOOKS_PATH, + stagingPath: process.env.STAGING_PATH, + appUrl: process.env.APP_URL, + }; + + process.env.BOOKS_PATH = fixture.booksPath; + process.env.STAGING_PATH = fixture.stagingPath; + process.env.APP_URL = 'http://localhost:4173'; + + const smtpSink = new InProcessSmtpSink(); + await smtpSink.start(); + + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(MetadataService) + .useValue(makeMetadataNoopMock()) + .compile(); + + const app = moduleFixture.createNestApplication(new FastifyAdapter()); + app.setGlobalPrefix('api/v1'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.register(fastifyCookie as never); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + + await stopStagingWatcher(app); + + const db = app.get(DB); + const admin = await getAdminSession(app, db); + + return { + app, + db, + admin, + fixture, + envSnapshot, + smtpSink, + }; +} + +export async function closeEmailE2EContext(ctx: EmailE2EContext): Promise { + await ctx.app.close(); + await ctx.smtpSink.stop(); + await ctx.fixture.cleanup(); + restoreEnv(ctx.envSnapshot); +} + +export async function createLibraryWithFolder( + ctx: EmailE2EContext, + options: { + mode?: 'book_per_file' | 'book_per_folder'; + allowedFormats?: string[]; + name?: string; + } = {}, +): Promise { + const folderPath = `${ctx.fixture.booksPath}/library-${randomUUID()}`; + await mkdir(folderPath, { recursive: true }); + + const [library] = await ctx.db + .insert(schema.libraries) + .values({ + name: options.name ?? `email-e2e-library-${randomUUID()}`, + watch: false, + organizationMode: options.mode ?? 'book_per_file', + allowedFormats: options.allowedFormats ?? [], + excludePatterns: [], + formatPriority: [...DEFAULT_FORMAT_PRIORITY], + }) + .returning({ id: schema.libraries.id }); + + const [libraryFolder] = await ctx.db + .insert(schema.libraryFolders) + .values({ + libraryId: library.id, + path: folderPath, + }) + .returning({ id: schema.libraryFolders.id }); + + return { + libraryId: library.id, + libraryFolderId: libraryFolder.id, + folderPath, + }; +} + +export async function triggerAndWaitForLibraryScan( + ctx: EmailE2EContext, + libraryId: number, + timeoutMs = 45_000, +): Promise { + const response = await ctx.app.inject({ + method: 'POST', + url: `/api/v1/scanner/libraries/${libraryId}/scan`, + headers: authHeader(ctx.admin.accessToken), + }); + + if (response.statusCode !== 202) { + throw new Error(`Scan endpoint failed: ${response.statusCode} ${response.body}`); + } + + const body = response.json() as { jobId?: number }; + if (!body.jobId) { + throw new Error(`Scan endpoint returned no jobId: ${response.body}`); + } + + return waitForScanCompletion(ctx.db, body.jobId, timeoutMs); +} + +export async function locateBookByAbsolutePath(ctx: EmailE2EContext, absolutePath: string): Promise { + const [row] = await ctx.db + .select({ + libraryId: schema.books.libraryId, + bookId: schema.books.id, + bookFileId: schema.bookFiles.id, + absolutePath: schema.bookFiles.absolutePath, + relPath: schema.bookFiles.relPath, + format: schema.bookFiles.format, + }) + .from(schema.bookFiles) + .innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId)) + .where(and(eq(schema.books.status, 'present'), eq(schema.bookFiles.absolutePath, absolutePath))) + .limit(1); + + if (!row) { + throw new Error(`No present book file found for path "${absolutePath}"`); + } + + return row; +} + +export async function createUserAndLogin( + ctx: EmailE2EContext, + options: { + permissions?: Permission[]; + isSuperuser?: boolean; + isDefaultPassword?: boolean; + active?: boolean; + username?: string; + password?: string; + email?: string; + } = {}, +): Promise { + const suffix = randomUUID().replaceAll('-', ''); + const username = options.username ?? `email-e2e-user-${suffix}`; + const password = options.password ?? 'EmailE2EUser123'; + const email = options.email ?? `${username}@example.com`; + const passwordHash = await hash(password, 12); + + const [created] = await ctx.db + .insert(schema.users) + .values({ + username, + name: `Email E2E User ${suffix}`, + email, + passwordHash, + active: options.active ?? true, + isSuperuser: options.isSuperuser ?? false, + isDefaultPassword: options.isDefaultPassword ?? false, + provisioningMethod: 'local', + }) + .returning({ id: schema.users.id }); + + const permissions = options.permissions ?? []; + if (permissions.length > 0) { + await ctx.db.insert(schema.userPermissions).values(permissions.map((permissionName) => ({ userId: created.id, permissionName }))); + } + + const accessToken = await loginForToken(ctx.app, username, password); + if (!accessToken) { + throw new Error(`Login failed for ${username}`); + } + + return { + userId: created.id, + username, + password, + accessToken, + }; +} + +export async function grantLibraryAccess( + ctx: EmailE2EContext, + userId: number, + libraryId: number, + accessLevel: 'viewer' | 'editor' | 'owner' = 'viewer', +): Promise { + await ctx.db + .insert(schema.userLibraryAccess) + .values({ userId, libraryId, accessLevel }) + .onConflictDoUpdate({ + target: [schema.userLibraryAccess.userId, schema.userLibraryAccess.libraryId], + set: { accessLevel }, + }); +} + +export async function resetEmailModuleState(ctx: EmailE2EContext): Promise { + await ctx.db.delete(schema.emailSendLog); + await ctx.db.delete(schema.emailPreferences); + await ctx.db.delete(schema.emailRecipientGroupMembers); + await ctx.db.delete(schema.emailRecipientGroups); + await ctx.db.delete(schema.emailRecipients); + await ctx.db.delete(schema.emailProviders); + await ctx.db.delete(schema.emailTemplates).where(isNotNull(schema.emailTemplates.userId)); + ctx.smtpSink.clearMessages(); +} + +export async function seedBookMetadataContext( + ctx: EmailE2EContext, + input: { + bookId: number; + title: string; + seriesName?: string; + seriesIndex?: number; + authorNames: string[]; + tagNames?: string[]; + }, +): Promise { + const metadataPatch = { + title: input.title, + seriesName: input.seriesName ?? null, + seriesIndex: input.seriesIndex ?? null, + updatedAt: new Date(), + } as const; + + await ctx.db + .insert(schema.bookMetadata) + .values({ + bookId: input.bookId, + ...metadataPatch, + }) + .onConflictDoUpdate({ + target: schema.bookMetadata.bookId, + set: metadataPatch, + }); + + await ctx.db.delete(schema.bookAuthors).where(eq(schema.bookAuthors.bookId, input.bookId)); + const authorIds: number[] = []; + for (const authorName of input.authorNames) { + const [inserted] = await ctx.db + .insert(schema.authors) + .values({ name: authorName, sortName: authorName }) + .onConflictDoNothing() + .returning({ id: schema.authors.id }); + + const authorId = + inserted?.id ?? + (await ctx.db.select({ id: schema.authors.id }).from(schema.authors).where(eq(schema.authors.name, authorName)).limit(1))[0]?.id; + + if (!authorId) { + throw new Error(`Unable to resolve author id for "${authorName}"`); + } + authorIds.push(authorId); + } + + if (authorIds.length > 0) { + await ctx.db.insert(schema.bookAuthors).values( + authorIds.map((authorId, index) => ({ + bookId: input.bookId, + authorId, + displayOrder: index, + })), + ); + } + + await ctx.db.delete(schema.bookTags).where(eq(schema.bookTags.bookId, input.bookId)); + const tagNames = [...new Set(input.tagNames ?? [])]; + if (tagNames.length === 0) return; + + const [existingTags, insertedTags] = await Promise.all([ + ctx.db.select({ id: schema.tags.id, name: schema.tags.name }).from(schema.tags).where(inArray(schema.tags.name, tagNames)), + ctx.db + .insert(schema.tags) + .values(tagNames.map((name) => ({ name }))) + .onConflictDoNothing() + .returning({ id: schema.tags.id, name: schema.tags.name }), + ]); + + const tagIdByName = new Map(); + for (const row of existingTags) { + tagIdByName.set(row.name, row.id); + } + for (const row of insertedTags) { + tagIdByName.set(row.name, row.id); + } + + await ctx.db.insert(schema.bookTags).values( + tagNames.map((name) => { + const tagId = tagIdByName.get(name); + if (!tagId) { + throw new Error(`Unable to resolve tag id for "${name}"`); + } + return { bookId: input.bookId, tagId }; + }), + ); +} + +export async function waitForCondition(check: () => Promise, timeoutMs = 15_000, pollMs = 100): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown = null; + + while (Date.now() < deadline) { + try { + await check(); + return; + } catch (error) { + lastError = error; + await sleep(pollMs); + } + } + + throw new Error(`Timed out waiting for condition: ${lastError instanceof Error ? lastError.message : String(lastError)}`); +} + +function makeMetadataNoopMock(): Pick< + MetadataService, + 'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators' +> { + return { + extractAndSave: () => Promise.resolve(undefined), + refreshCoverForBook: () => Promise.resolve(false), + extractAudioFileDuration: () => Promise.resolve(undefined), + aggregateAudioDuration: () => Promise.resolve(undefined), + extractAudioChaptersAndNarrators: () => Promise.resolve(undefined), + }; +} + +async function stopStagingWatcher(app: NestFastifyApplication): Promise { + const watcher = app.get(StagingWatcherService); + await watcher.onModuleDestroy(); +} + +async function getAdminSession(app: NestFastifyApplication, db: Db): Promise { + const setupResponse = await app.inject({ + method: 'POST', + url: '/api/v1/auth/setup', + payload: ADMIN_SETUP_DTO, + }); + + if (setupResponse.statusCode === 201) { + const body = setupResponse.json() as { accessToken?: string }; + if (!body.accessToken) throw new Error('Setup succeeded but accessToken is missing'); + const [admin] = await db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.username, ADMIN_SETUP_DTO.username)).limit(1); + if (!admin) throw new Error('Setup succeeded but admin user was not created'); + return { + userId: admin.id, + username: ADMIN_SETUP_DTO.username, + password: ADMIN_SETUP_DTO.password, + accessToken: body.accessToken, + }; + } + + if (setupResponse.statusCode === 409) { + const existingToken = await loginForToken(app, ADMIN_SETUP_DTO.username, ADMIN_SETUP_DTO.password); + if (existingToken) { + const [admin] = await db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.username, ADMIN_SETUP_DTO.username)).limit(1); + if (!admin) throw new Error('Admin login succeeded but admin row is missing'); + return { + userId: admin.id, + username: ADMIN_SETUP_DTO.username, + password: ADMIN_SETUP_DTO.password, + accessToken: existingToken, + }; + } + + const suffix = randomUUID().replaceAll('-', ''); + const fallbackUsername = `email-lifecycle-e2e-admin-${suffix}`; + const passwordHash = await hash(ADMIN_SETUP_DTO.password, 12); + + const [fallbackAdmin] = await db + .insert(schema.users) + .values({ + username: fallbackUsername, + name: 'Email Lifecycle E2E Admin', + email: `${fallbackUsername}@example.com`, + passwordHash, + isSuperuser: true, + isDefaultPassword: false, + provisioningMethod: 'local', + }) + .returning({ id: schema.users.id }); + + const fallbackToken = await loginForToken(app, fallbackUsername, ADMIN_SETUP_DTO.password); + if (!fallbackToken) { + throw new Error('Setup is already complete and fallback admin login failed'); + } + + return { + userId: fallbackAdmin.id, + username: fallbackUsername, + password: ADMIN_SETUP_DTO.password, + accessToken: fallbackToken, + }; + } + + throw new Error(`Unable to complete setup: ${setupResponse.statusCode} ${setupResponse.body}`); +} + +async function loginForToken(app: NestFastifyApplication, username: string, password: string): Promise { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { username, password }, + }); + if (response.statusCode !== 200) return null; + const body = response.json() as { accessToken?: string }; + return body.accessToken ?? null; +} + +function restoreEnv(snapshot: EnvSnapshot): void { + if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH; + else process.env.BOOKS_PATH = snapshot.booksPath; + + if (snapshot.stagingPath === undefined) delete process.env.STAGING_PATH; + else process.env.STAGING_PATH = snapshot.stagingPath; + + if (snapshot.appUrl === undefined) delete process.env.APP_URL; + else process.env.APP_URL = snapshot.appUrl; +} diff --git a/server/test/email-lifecycle.e2e-spec.ts b/server/test/email-lifecycle.e2e-spec.ts new file mode 100644 index 000000000..7e833957e --- /dev/null +++ b/server/test/email-lifecycle.e2e-spec.ts @@ -0,0 +1,915 @@ +import { randomUUID } from 'crypto'; +import { mkdir, writeFile } from 'fs/promises'; +import { dirname, join } from 'path'; + +import { desc, eq } from 'drizzle-orm'; +import { Permission } from '@projectx/types'; + +import * as schema from '../src/db/schema'; +import { createEpubFixture } from './e2e/email/email-fixture-builder'; +import { + authHeader, + closeEmailE2EContext, + createEmailE2EContext, + createLibraryWithFolder, + createUserAndLogin, + extractSmtpHeader, + grantLibraryAccess, + locateBookByAbsolutePath, + resetEmailModuleState, + seedBookMetadataContext, + triggerAndWaitForLibraryScan, + waitForCondition, + type EmailE2EContext, + type LocatedBookFile, + type TestUserSession, +} from './e2e/email/email-harness'; + +interface ScenarioRunResult { + id: string; + status: 'passed' | 'failed'; + durationMs: number; + error?: string; +} + +function buildProviderPayload( + ctx: EmailE2EContext, + namePrefix: string, + overrides: Partial<{ + host: string; + port: number; + auth: boolean; + ssl: boolean; + startTls: boolean; + username: string; + password: string; + fromName: string; + fromAddress: string; + }> = {}, +) { + return { + name: `${namePrefix}-${randomUUID().slice(0, 8)}`, + host: overrides.host ?? ctx.smtpSink.getHost(), + port: overrides.port ?? ctx.smtpSink.getPort(), + auth: overrides.auth ?? false, + ssl: overrides.ssl ?? false, + startTls: overrides.startTls ?? false, + ...(overrides.username !== undefined ? { username: overrides.username } : {}), + ...(overrides.password !== undefined ? { password: overrides.password } : {}), + ...(overrides.fromName !== undefined ? { fromName: overrides.fromName } : {}), + ...(overrides.fromAddress !== undefined ? { fromAddress: overrides.fromAddress } : {}), + }; +} + +async function writeScenarioReport(results: ScenarioRunResult[]): Promise { + const reportDir = process.env.JUNIT_OUTPUT ? dirname(process.env.JUNIT_OUTPUT) : join(process.cwd(), '..', 'test-results', 'server'); + await mkdir(reportDir, { recursive: true }); + const reportPath = join(reportDir, 'email-lifecycle-e2e-scenarios.json'); + await writeFile( + reportPath, + JSON.stringify( + { + generatedAt: new Date().toISOString(), + total: results.length, + passed: results.filter((result) => result.status === 'passed').length, + failed: results.filter((result) => result.status === 'failed').length, + results, + }, + null, + 2, + ), + ); +} + +describe('Email lifecycle (e2e)', { timeout: 180_000 }, () => { + let ctx!: EmailE2EContext; + const scenarioResults: ScenarioRunResult[] = []; + let scenarioStartedAt = 0; + + let superManager!: TestUserSession; + let managerOnly!: TestUserSession; + let senderOnly!: TestUserSession; + let senderNoLibrary!: TestUserSession; + let emaillessUser!: TestUserSession; + let foreignSender!: TestUserSession; + + let book!: LocatedBookFile; + + async function apiAs(user: TestUserSession, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', url: string, payload?: unknown) { + return ctx.app.inject({ + method, + url, + headers: authHeader(user.accessToken), + ...(payload === undefined ? {} : { payload }), + }); + } + + function expectError(response: Awaited>, statusCode: number, messageIncludes?: string): void { + expect(response.statusCode).toBe(statusCode); + if (!messageIncludes) return; + const body = response.json() as { message?: string }; + expect(body.message ?? '').toContain(messageIncludes); + } + + async function latestLogForUser(userId: number): Promise { + const [entry] = await ctx.db + .select() + .from(schema.emailSendLog) + .where(eq(schema.emailSendLog.userId, userId)) + .orderBy(desc(schema.emailSendLog.id)) + .limit(1); + return entry ?? null; + } + + async function waitForLog( + id: number, + predicate: (entry: typeof schema.emailSendLog.$inferSelect) => boolean, + timeoutMs = 12_000, + ): Promise { + let entry: typeof schema.emailSendLog.$inferSelect | null = null; + + await waitForCondition(async () => { + const [row] = await ctx.db.select().from(schema.emailSendLog).where(eq(schema.emailSendLog.id, id)).limit(1); + if (!row) throw new Error(`Log ${id} not found`); + if (!predicate(row)) throw new Error(`Log ${id} has unexpected state: status=${row.status} attemptCount=${row.attemptCount}`); + entry = row; + }, timeoutMs); + + if (!entry) { + throw new Error(`Log ${id} not found after wait`); + } + + return entry; + } + + beforeAll(async () => { + ctx = await createEmailE2EContext(); + superManager = ctx.admin; + + const library = await createLibraryWithFolder(ctx, { name: `email-lifecycle-library-${randomUUID()}` }); + const bookAbsolutePath = await createEpubFixture(library.folderPath, 'email-lifecycle.epub', { + title: 'Email Lifecycle Book', + }); + + await triggerAndWaitForLibraryScan(ctx, library.libraryId); + book = await locateBookByAbsolutePath(ctx, bookAbsolutePath); + await seedBookMetadataContext(ctx, { + bookId: book.bookId, + title: 'Email Lifecycle Book', + seriesName: 'Email Series', + seriesIndex: 3, + authorNames: ['Email Author One', 'Email Author Two'], + tagNames: ['tag-alpha'], + }); + + managerOnly = await createUserAndLogin(ctx, { permissions: [Permission.ManageEmail] }); + senderOnly = await createUserAndLogin(ctx, { permissions: [Permission.EmailSend] }); + senderNoLibrary = await createUserAndLogin(ctx, { permissions: [Permission.EmailSend] }); + emaillessUser = await createUserAndLogin(ctx); + foreignSender = await createUserAndLogin(ctx, { permissions: [Permission.EmailSend] }); + + await grantLibraryAccess(ctx, senderOnly.userId, library.libraryId, 'viewer'); + await grantLibraryAccess(ctx, foreignSender.userId, library.libraryId, 'viewer'); + }, 180_000); + + beforeEach(async () => { + scenarioStartedAt = Date.now(); + await resetEmailModuleState(ctx); + }); + + afterEach((taskContext) => { + const result = taskContext.task.result; + if (!result) return; + + const status = result.state === 'pass' ? 'passed' : 'failed'; + const error = result.errors?.[0]?.message; + scenarioResults.push({ + id: taskContext.task.name, + status, + durationMs: Math.max(0, Date.now() - scenarioStartedAt), + ...(error ? { error } : {}), + }); + }); + + afterAll(async () => { + await writeScenarioReport(scenarioResults); + if (ctx) { + await closeEmailE2EContext(ctx); + } + }); + + describe('providers lifecycle and policy gates', () => { + it('enforces permission boundaries, share/system rules, and delete invariants', async () => { + const createResponse = await apiAs(managerOnly, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'manager-provider')); + expect(createResponse.statusCode).toBe(201); + const createdProvider = createResponse.json() as { id: number; name: string; hasPassword: boolean; isShared: boolean }; + expect(createdProvider).toEqual(expect.objectContaining({ hasPassword: false, isShared: false })); + + const managerListResponse = await apiAs(managerOnly, 'GET', '/api/v1/email/providers'); + expectError(managerListResponse, 403, Permission.EmailSend); + + const senderCreateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'sender-provider')); + expectError(senderCreateResponse, 403, Permission.ManageEmail); + + const senderListBeforeShare = await apiAs(senderOnly, 'GET', '/api/v1/email/providers'); + expect(senderListBeforeShare.statusCode).toBe(200); + expect(senderListBeforeShare.json()).toEqual([]); + + const managerToggleShare = await apiAs(managerOnly, 'PATCH', `/api/v1/email/providers/${createdProvider.id}/share`); + expectError(managerToggleShare, 403, 'Only superusers can share providers'); + + const superToggleShare = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${createdProvider.id}/share`); + expectError(superToggleShare, 403, 'Cannot modify this provider'); + + const superOwnedProviderResponse = await apiAs( + superManager, + 'POST', + '/api/v1/email/providers', + buildProviderPayload(ctx, 'super-owned-provider'), + ); + expect(superOwnedProviderResponse.statusCode).toBe(201); + const superOwnedProvider = superOwnedProviderResponse.json() as { id: number }; + + const superOwnedShare = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${superOwnedProvider.id}/share`); + expect(superOwnedShare.statusCode).toBe(200); + expect((superOwnedShare.json() as { isShared: boolean }).isShared).toBe(true); + + const senderListAfterShare = await apiAs(senderOnly, 'GET', '/api/v1/email/providers'); + expect(senderListAfterShare.statusCode).toBe(200); + expect(senderListAfterShare.json()).toEqual(expect.arrayContaining([expect.objectContaining({ id: superOwnedProvider.id, isShared: true })])); + + const managerSetSystem = await apiAs(managerOnly, 'PATCH', `/api/v1/email/providers/${superOwnedProvider.id}/system`); + expectError(managerSetSystem, 403, 'Only superusers can configure the system mail provider'); + + const superSetSystem = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${superOwnedProvider.id}/system`); + expect(superSetSystem.statusCode).toBe(200); + expect((superSetSystem.json() as { isSystemProvider: boolean }).isSystemProvider).toBe(true); + + const deleteSystemProvider = await apiAs(superManager, 'DELETE', `/api/v1/email/providers/${superOwnedProvider.id}`); + expectError(deleteSystemProvider, 400, 'Cannot delete the system mail provider'); + + const clearSystemResponse = await apiAs(superManager, 'DELETE', '/api/v1/email/providers/system'); + expect(clearSystemResponse.statusCode).toBe(204); + + const deleteSuperProvider = await apiAs(superManager, 'DELETE', `/api/v1/email/providers/${superOwnedProvider.id}`); + expect(deleteSuperProvider.statusCode).toBe(204); + + const deleteResponse = await apiAs(managerOnly, 'DELETE', `/api/v1/email/providers/${createdProvider.id}`); + expect(deleteResponse.statusCode).toBe(204); + }); + + it('returns connection test contract for both success and failure', async () => { + const validProviderResponse = await apiAs(managerOnly, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'smtp-valid-connection')); + expect(validProviderResponse.statusCode).toBe(201); + const validProvider = validProviderResponse.json() as { id: number }; + + const validTestResponse = await apiAs(managerOnly, 'POST', `/api/v1/email/providers/${validProvider.id}/test`); + expect(validTestResponse.statusCode).toBe(200); + expect(validTestResponse.json()).toEqual({ success: true }); + + const invalidProviderResponse = await apiAs( + managerOnly, + 'POST', + '/api/v1/email/providers', + buildProviderPayload(ctx, 'smtp-invalid-connection', { + host: '127.0.0.1', + port: 1, + }), + ); + expect(invalidProviderResponse.statusCode).toBe(201); + const invalidProvider = invalidProviderResponse.json() as { id: number }; + + const invalidTestResponse = await apiAs(managerOnly, 'POST', `/api/v1/email/providers/${invalidProvider.id}/test`); + expect(invalidTestResponse.statusCode).toBe(200); + expect(invalidTestResponse.json()).toEqual(expect.objectContaining({ success: false, error: expect.any(String) })); + }); + }); + + describe('templates lifecycle, defaults, and preview contract', () => { + it('enforces role-gating and system-template update restrictions', async () => { + const senderListResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/templates'); + expect(senderListResponse.statusCode).toBe(200); + const senderTemplates = senderListResponse.json() as Array<{ id: number; isSystem: boolean }>; + const systemTemplate = senderTemplates.find((template) => template.isSystem); + expect(systemTemplate).toBeDefined(); + + const managerListResponse = await apiAs(managerOnly, 'GET', '/api/v1/email/templates'); + expectError(managerListResponse, 403, Permission.EmailSend); + + const senderSystemUpdate = await apiAs(senderOnly, 'PUT', `/api/v1/email/templates/${systemTemplate!.id}`, { + subject: 'sender-cannot-update-system-template', + }); + expectError(senderSystemUpdate, 403, 'Only administrators can modify system templates'); + + const superSystemUpdate = await apiAs(superManager, 'PUT', `/api/v1/email/templates/${systemTemplate!.id}`, { + subject: `System subject ${randomUUID().slice(0, 8)}`, + }); + expect(superSystemUpdate.statusCode).toBe(200); + expect((superSystemUpdate.json() as { id: number }).id).toBe(systemTemplate!.id); + }); + + it('renders template preview with backward-compatible aliases and /api/v1 coverUrl', async () => { + const createTemplateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', { + name: `preview-template-${randomUUID().slice(0, 8)}`, + subject: '{{title}}|{{authors}}|{{author}}|{{seriesName}}|{{series}}', + bodyText: 'cover={{coverUrl}}', + }); + expect(createTemplateResponse.statusCode).toBe(201); + const template = createTemplateResponse.json() as { id: number }; + + const previewResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/templates/${template.id}/preview`, { + bookId: book.bookId, + }); + expect(previewResponse.statusCode).toBe(200); + expect(previewResponse.json()).toEqual({ + subject: 'Email Lifecycle Book|Email Author One, Email Author Two|Email Author One, Email Author Two|Email Series|Email Series', + bodyText: `cover=http://localhost:4173/api/v1/books/${book.bookId}/cover`, + }); + + const inaccessibleTemplateResponse = await apiAs(senderNoLibrary, 'POST', '/api/v1/email/templates', { + name: `inaccessible-preview-template-${randomUUID().slice(0, 8)}`, + subject: 'x', + bodyText: 'x', + }); + expect(inaccessibleTemplateResponse.statusCode).toBe(201); + const inaccessibleTemplate = inaccessibleTemplateResponse.json() as { id: number }; + + const inaccessiblePreviewResponse = await apiAs(senderNoLibrary, 'POST', `/api/v1/email/templates/${inaccessibleTemplate.id}/preview`, { + bookId: book.bookId, + }); + expectError(inaccessiblePreviewResponse, 403, 'No access to this library'); + }); + + it('maps duplicate template names to 409 conflicts', async () => { + const name = `duplicate-template-${randomUUID().slice(0, 8)}`; + const firstCreate = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', { + name, + subject: 'First subject', + bodyText: 'Body', + }); + expect(firstCreate.statusCode).toBe(201); + + const duplicateCreate = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', { + name, + subject: 'Second subject', + bodyText: 'Body', + }); + expectError(duplicateCreate, 409, 'already exists'); + }); + }); + + describe('recipients/groups/preferences ownership and conflicts', () => { + it('enforces recipient ownership and duplicate-email conflicts', async () => { + const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { + name: 'Sender Kindle', + email: 'kindle+sender@example.com', + deviceType: 'kindle', + preferredFormat: 'epub', + }); + expect(recipientResponse.statusCode).toBe(201); + const recipient = recipientResponse.json() as { id: number; isDefault: boolean }; + expect(recipient.isDefault).toBe(false); + + const setDefaultResponse = await apiAs(senderOnly, 'PATCH', `/api/v1/email/recipients/${recipient.id}/default`); + expect(setDefaultResponse.statusCode).toBe(200); + expect((setDefaultResponse.json() as { isDefault: boolean }).isDefault).toBe(true); + + const foreignReadResponse = await apiAs(foreignSender, 'GET', `/api/v1/email/recipients/${recipient.id}`); + expectError(foreignReadResponse, 403, 'Cannot modify this recipient'); + + const duplicateRecipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { + name: 'Duplicate Sender Kindle', + email: 'kindle+sender@example.com', + }); + expectError(duplicateRecipientResponse, 409, 'already exists'); + + const emaillessCreateResponse = await apiAs(emaillessUser, 'POST', '/api/v1/email/recipients', { + name: 'No Permission', + email: 'no-permission@example.com', + }); + expectError(emaillessCreateResponse, 403, Permission.EmailSend); + }); + + it('enforces group ownership checks and duplicate-name conflicts', async () => { + const senderRecipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { + name: 'Sender Group Member', + email: 'group-member@example.com', + }); + expect(senderRecipientResponse.statusCode).toBe(201); + const senderRecipient = senderRecipientResponse.json() as { id: number }; + + const foreignRecipientResponse = await apiAs(foreignSender, 'POST', '/api/v1/email/recipients', { + name: 'Foreign Group Member', + email: 'foreign-group-member@example.com', + }); + expect(foreignRecipientResponse.statusCode).toBe(201); + const foreignRecipient = foreignRecipientResponse.json() as { id: number }; + + const groupResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipient-groups', { + name: `kindle-group-${randomUUID().slice(0, 8)}`, + }); + expect(groupResponse.statusCode).toBe(201); + const group = groupResponse.json() as { id: number; name: string }; + + const duplicateGroupResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipient-groups', { + name: group.name, + }); + expectError(duplicateGroupResponse, 409, 'already exists'); + + const addMemberResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/recipient-groups/${group.id}/members`, { + recipientId: senderRecipient.id, + }); + expect(addMemberResponse.statusCode).toBe(201); + expect((addMemberResponse.json() as { members: Array<{ id: number }> }).members).toEqual( + expect.arrayContaining([expect.objectContaining({ id: senderRecipient.id })]), + ); + + const addForeignMemberResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/recipient-groups/${group.id}/members`, { + recipientId: foreignRecipient.id, + }); + expectError(addForeignMemberResponse, 403, 'Cannot add a recipient you do not own'); + + const removeMemberResponse = await apiAs(senderOnly, 'DELETE', `/api/v1/email/recipient-groups/${group.id}/members/${senderRecipient.id}`); + expect(removeMemberResponse.statusCode).toBe(204); + }); + + it('validates preferences cross-references against accessible resources', async () => { + const privateProviderResponse = await apiAs(managerOnly, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'private-provider')); + expect(privateProviderResponse.statusCode).toBe(201); + const privateProvider = privateProviderResponse.json() as { id: number }; + + const sharedProviderResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'shared-provider')); + expect(sharedProviderResponse.statusCode).toBe(201); + const sharedProvider = sharedProviderResponse.json() as { id: number }; + const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${sharedProvider.id}/share`); + expect(shareResponse.statusCode).toBe(200); + + const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { + name: 'Pref Recipient', + email: 'prefs-recipient@example.com', + }); + expect(recipientResponse.statusCode).toBe(201); + const recipient = recipientResponse.json() as { id: number }; + + const templateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', { + name: `prefs-template-${randomUUID().slice(0, 8)}`, + subject: 'Prefs {{title}}', + bodyText: 'Body', + }); + expect(templateResponse.statusCode).toBe(201); + const template = templateResponse.json() as { id: number }; + + const foreignRecipientResponse = await apiAs(foreignSender, 'POST', '/api/v1/email/recipients', { + name: 'Foreign Pref Recipient', + email: 'foreign-pref-recipient@example.com', + }); + expect(foreignRecipientResponse.statusCode).toBe(201); + const foreignRecipient = foreignRecipientResponse.json() as { id: number }; + + const foreignTemplateResponse = await apiAs(foreignSender, 'POST', '/api/v1/email/templates', { + name: `foreign-pref-template-${randomUUID().slice(0, 8)}`, + subject: 'Foreign {{title}}', + bodyText: 'Body', + }); + expect(foreignTemplateResponse.statusCode).toBe(201); + const foreignTemplate = foreignTemplateResponse.json() as { id: number }; + + const validPreferencesResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', { + defaultProviderId: sharedProvider.id, + defaultRecipientId: recipient.id, + defaultTemplateId: template.id, + }); + expect(validPreferencesResponse.statusCode).toBe(200); + expect(validPreferencesResponse.json()).toEqual({ + id: expect.any(Number), + userId: senderOnly.userId, + defaultProviderId: sharedProvider.id, + defaultRecipientId: recipient.id, + defaultTemplateId: template.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + + const privateProviderPreferenceResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', { + defaultProviderId: privateProvider.id, + }); + expectError(privateProviderPreferenceResponse, 403, 'No access to this provider'); + + const foreignRecipientPreferenceResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', { + defaultRecipientId: foreignRecipient.id, + }); + expectError(foreignRecipientPreferenceResponse, 403, 'Cannot modify this recipient'); + + const foreignTemplatePreferenceResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', { + defaultTemplateId: foreignTemplate.id, + }); + expectError(foreignTemplatePreferenceResponse, 403, 'No access to this template'); + }); + }); + + describe('send + quick-send + log + resend flow integrity', () => { + it('uses explicit templateId over recipient default and applies provider from fields', async () => { + const providerCreateResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', { + ...buildProviderPayload(ctx, 'sender-provider'), + fromName: 'ProjectX Bot', + fromAddress: 'bot@example.com', + }); + expect(providerCreateResponse.statusCode).toBe(201); + const provider = providerCreateResponse.json() as { id: number }; + const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/share`); + expect(shareResponse.statusCode).toBe(200); + + const defaultTemplateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', { + name: `recipient-default-${randomUUID().slice(0, 8)}`, + subject: 'Recipient {{title}}', + bodyText: 'Recipient body', + }); + expect(defaultTemplateResponse.statusCode).toBe(201); + const defaultTemplate = defaultTemplateResponse.json() as { id: number }; + + const overrideTemplateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', { + name: `override-template-${randomUUID().slice(0, 8)}`, + subject: 'Override {{title}}', + bodyText: 'Override body', + }); + expect(overrideTemplateResponse.statusCode).toBe(201); + const overrideTemplate = overrideTemplateResponse.json() as { id: number }; + + const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { + name: 'Send Recipient', + email: 'send-recipient@example.com', + defaultTemplateId: defaultTemplate.id, + }); + expect(recipientResponse.statusCode).toBe(201); + const recipient = recipientResponse.json() as { id: number }; + + const sendResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/send', { + bookIds: [book.bookId], + recipientIds: [recipient.id], + providerId: provider.id, + templateId: overrideTemplate.id, + }); + expect(sendResponse.statusCode).toBe(202); + expect(sendResponse.json()).toEqual({ queued: 1 }); + + await ctx.smtpSink.waitForMessages(1); + const firstLog = await latestLogForUser(senderOnly.userId); + if (!firstLog) { + throw new Error('Missing send log entry for sender'); + } + await waitForLog(firstLog.id, (entry) => entry.status === 'sent'); + + const [captured] = ctx.smtpSink.getMessages(); + const fromHeader = extractSmtpHeader(captured.raw, 'from'); + const subjectHeader = extractSmtpHeader(captured.raw, 'subject'); + expect(fromHeader).toContain('ProjectX Bot '); + expect(subjectHeader).toBe('Override Email Lifecycle Book'); + expect(subjectHeader).not.toContain('Recipient'); + }); + + it('keeps group defaultTemplateId non-operational in send resolution', async () => { + const providerCreateResponse = await apiAs( + superManager, + 'POST', + '/api/v1/email/providers', + buildProviderPayload(ctx, 'group-template-provider'), + ); + expect(providerCreateResponse.statusCode).toBe(201); + const provider = providerCreateResponse.json() as { id: number }; + const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/share`); + expect(shareResponse.statusCode).toBe(200); + + const prefsTemplateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', { + name: `prefs-template-${randomUUID().slice(0, 8)}`, + subject: 'Prefs {{title}}', + bodyText: 'Prefs body', + }); + expect(prefsTemplateResponse.statusCode).toBe(201); + const prefsTemplate = prefsTemplateResponse.json() as { id: number }; + + const groupTemplateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', { + name: `group-template-${randomUUID().slice(0, 8)}`, + subject: 'Group {{title}}', + bodyText: 'Group body', + }); + expect(groupTemplateResponse.statusCode).toBe(201); + const groupTemplate = groupTemplateResponse.json() as { id: number }; + + const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { + name: 'Group Recipient', + email: 'group-recipient@example.com', + }); + expect(recipientResponse.statusCode).toBe(201); + const recipient = recipientResponse.json() as { id: number }; + + const groupResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipient-groups', { + name: `group-${randomUUID().slice(0, 8)}`, + defaultTemplateId: groupTemplate.id, + }); + expect(groupResponse.statusCode).toBe(201); + const group = groupResponse.json() as { id: number }; + + const addMemberResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/recipient-groups/${group.id}/members`, { + recipientId: recipient.id, + }); + expect(addMemberResponse.statusCode).toBe(201); + + const preferencesResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', { + defaultProviderId: provider.id, + defaultTemplateId: prefsTemplate.id, + }); + expect(preferencesResponse.statusCode).toBe(200); + + const sendResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/send', { + bookIds: [book.bookId], + groupIds: [group.id], + providerId: provider.id, + }); + expect(sendResponse.statusCode).toBe(202); + expect(sendResponse.json()).toEqual({ queued: 1 }); + + await ctx.smtpSink.waitForMessages(1); + const [captured] = ctx.smtpSink.getMessages(); + const subjectHeader = extractSmtpHeader(captured.raw, 'subject'); + expect(subjectHeader).toBe('Prefs Email Lifecycle Book'); + }); + + it('enforces quick-send defaults and library-access boundaries', async () => { + const providerCreateResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'quick-send-provider')); + expect(providerCreateResponse.statusCode).toBe(201); + const provider = providerCreateResponse.json() as { id: number }; + const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/share`); + expect(shareResponse.statusCode).toBe(200); + + const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { + name: 'Quick Recipient', + email: 'quick-recipient@example.com', + }); + expect(recipientResponse.statusCode).toBe(201); + const recipient = recipientResponse.json() as { id: number }; + + const preferenceResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', { + defaultProviderId: provider.id, + defaultRecipientId: recipient.id, + }); + expect(preferenceResponse.statusCode).toBe(200); + + const quickSendResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/send/quick/${book.bookId}`); + expect(quickSendResponse.statusCode).toBe(202); + expect(quickSendResponse.json()).toEqual({ queued: 1 }); + await ctx.smtpSink.waitForMessages(1); + + const noLibraryQuickSend = await apiAs(senderNoLibrary, 'POST', `/api/v1/email/send/quick/${book.bookId}`); + expectError(noLibraryQuickSend, 403, 'No access to this library'); + + const noDefaultQuickSend = await apiAs(foreignSender, 'POST', `/api/v1/email/send/quick/${book.bookId}`); + expectError(noDefaultQuickSend, 400, 'No default recipient configured'); + }); + + it('keeps user log scoped and supports failure-first then successful resend', async () => { + const failingProviderResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', { + ...buildProviderPayload(ctx, 'failing-provider'), + host: '127.0.0.1', + port: 1, + }); + expect(failingProviderResponse.statusCode).toBe(201); + const failingProvider = failingProviderResponse.json() as { id: number }; + const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${failingProvider.id}/share`); + expect(shareResponse.statusCode).toBe(200); + + const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { + name: 'Retry Recipient', + email: 'retry-recipient@example.com', + }); + expect(recipientResponse.statusCode).toBe(201); + const recipient = recipientResponse.json() as { id: number }; + + const sendResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/send', { + bookIds: [book.bookId], + recipientIds: [recipient.id], + providerId: failingProvider.id, + }); + expect(sendResponse.statusCode).toBe(202); + expect(sendResponse.json()).toEqual({ queued: 1 }); + + let failedLogId = 0; + await waitForCondition(async () => { + const latest = await latestLogForUser(senderOnly.userId); + if (!latest) throw new Error('Missing sender log'); + if (latest.attemptCount < 1 || latest.errorMessage === null) { + throw new Error(`Expected first-attempt failure, got attemptCount=${latest.attemptCount} error=${latest.errorMessage ?? 'null'}`); + } + failedLogId = latest.id; + }, 12_000); + + const foreignLogList = await apiAs(foreignSender, 'GET', '/api/v1/email/log?page=0&size=20'); + expect(foreignLogList.statusCode).toBe(200); + expect(foreignLogList.json()).toEqual([]); + + const foreignDelete = await apiAs(foreignSender, 'DELETE', `/api/v1/email/log/${failedLogId}`); + expectError(foreignDelete, 403, 'No access to this log entry'); + + const foreignResend = await apiAs(foreignSender, 'POST', `/api/v1/email/log/${failedLogId}/resend`); + expectError(foreignResend, 403, 'No access to this log entry'); + + const repairProvider = await apiAs(superManager, 'PUT', `/api/v1/email/providers/${failingProvider.id}`, { + host: ctx.smtpSink.getHost(), + port: ctx.smtpSink.getPort(), + auth: false, + ssl: false, + startTls: false, + }); + expect(repairProvider.statusCode).toBe(200); + + const resendResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/log/${failedLogId}/resend`); + expect(resendResponse.statusCode).toBe(202); + expect(resendResponse.json()).toEqual({ queued: 1 }); + + await ctx.smtpSink.waitForMessages(1); + + let resendLogId = 0; + await waitForCondition(async () => { + const latest = await latestLogForUser(senderOnly.userId); + if (!latest) throw new Error('Missing latest sender log'); + if (latest.id === failedLogId) throw new Error('Resend log has not been created yet'); + if (latest.status !== 'sent') throw new Error(`Resend log not sent yet: ${latest.status}`); + resendLogId = latest.id; + }, 12_000); + + const senderDeleteResendLog = await apiAs(senderOnly, 'DELETE', `/api/v1/email/log/${resendLogId}`); + expect(senderDeleteResendLog.statusCode).toBe(204); + }); + }); + + describe('admin log and superuser enforcement', () => { + it('restricts /email/admin/log to superusers only', async () => { + const providerCreateResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'admin-log-provider')); + expect(providerCreateResponse.statusCode).toBe(201); + const provider = providerCreateResponse.json() as { id: number }; + const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/share`); + expect(shareResponse.statusCode).toBe(200); + + const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { + name: 'Admin Log Recipient', + email: 'admin-log-recipient@example.com', + }); + expect(recipientResponse.statusCode).toBe(201); + const recipient = recipientResponse.json() as { id: number }; + + const sendResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/send', { + bookIds: [book.bookId], + recipientIds: [recipient.id], + providerId: provider.id, + }); + expect(sendResponse.statusCode).toBe(202); + await ctx.smtpSink.waitForMessages(1); + + const managerAdminLogResponse = await apiAs(managerOnly, 'GET', '/api/v1/email/admin/log?page=0&size=20'); + expectError(managerAdminLogResponse, 403, 'Only superusers can view all email logs'); + + const senderAdminLogResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/admin/log?page=0&size=20'); + expectError(senderAdminLogResponse, 403, Permission.ManageEmail); + + const superAdminLogResponse = await apiAs(superManager, 'GET', '/api/v1/email/admin/log?page=0&size=20'); + expect(superAdminLogResponse.statusCode).toBe(200); + const entries = superAdminLogResponse.json() as Array<{ userId: number }>; + expect(entries).toEqual(expect.arrayContaining([expect.objectContaining({ userId: senderOnly.userId })])); + }); + }); + + describe('client contract regression checks', () => { + it('preserves API response shapes consumed by email composables', async () => { + const providerCreateResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', { + ...buildProviderPayload(ctx, 'client-contract-provider'), + fromName: 'Contract Bot', + fromAddress: 'contract@example.com', + }); + expect(providerCreateResponse.statusCode).toBe(201); + const provider = providerCreateResponse.json() as { id: number }; + const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/share`); + expect(shareResponse.statusCode).toBe(200); + + const templateCreateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', { + name: `client-contract-template-${randomUUID().slice(0, 8)}`, + subject: 'Client Contract {{title}}', + bodyText: 'Client Contract Body', + }); + expect(templateCreateResponse.statusCode).toBe(201); + const template = templateCreateResponse.json() as { id: number }; + + const recipientCreateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { + name: 'Client Contract Recipient', + email: 'client-contract-recipient@example.com', + }); + expect(recipientCreateResponse.statusCode).toBe(201); + const recipient = recipientCreateResponse.json() as { id: number }; + + const groupCreateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipient-groups', { + name: `client-contract-group-${randomUUID().slice(0, 8)}`, + }); + expect(groupCreateResponse.statusCode).toBe(201); + const group = groupCreateResponse.json() as { id: number }; + + const addMemberResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/recipient-groups/${group.id}/members`, { + recipientId: recipient.id, + }); + expect(addMemberResponse.statusCode).toBe(201); + + const savePreferencesResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', { + defaultProviderId: provider.id, + defaultRecipientId: recipient.id, + defaultTemplateId: template.id, + }); + expect(savePreferencesResponse.statusCode).toBe(200); + + const providersResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/providers'); + expect(providersResponse.statusCode).toBe(200); + expect(providersResponse.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: provider.id, + fromName: 'Contract Bot', + fromAddress: 'contract@example.com', + hasPassword: false, + isShared: true, + }), + ]), + ); + + const templatesResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/templates'); + expect(templatesResponse.statusCode).toBe(200); + expect(templatesResponse.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: template.id, + name: expect.any(String), + subject: expect.any(String), + bodyText: expect.any(String), + isDefault: expect.any(Boolean), + isSystem: expect.any(Boolean), + }), + ]), + ); + + const recipientsResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/recipients'); + expect(recipientsResponse.statusCode).toBe(200); + expect(recipientsResponse.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: recipient.id, + name: expect.any(String), + email: expect.any(String), + isDefault: expect.any(Boolean), + defaultTemplateId: null, + }), + ]), + ); + + const groupsResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/recipient-groups'); + expect(groupsResponse.statusCode).toBe(200); + expect(groupsResponse.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: group.id, + name: expect.any(String), + members: expect.arrayContaining([expect.objectContaining({ id: recipient.id })]), + }), + ]), + ); + + const preferencesResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/preferences'); + expect(preferencesResponse.statusCode).toBe(200); + expect(preferencesResponse.json()).toEqual( + expect.objectContaining({ + userId: senderOnly.userId, + defaultProviderId: provider.id, + defaultRecipientId: recipient.id, + defaultTemplateId: template.id, + }), + ); + + const sendResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/send', { + bookIds: [book.bookId], + recipientIds: [recipient.id], + providerId: provider.id, + }); + expect(sendResponse.statusCode).toBe(202); + expect(sendResponse.json()).toEqual({ queued: 1 }); + await ctx.smtpSink.waitForMessages(1); + + const logResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/log?page=0&size=20'); + expect(logResponse.statusCode).toBe(200); + expect(logResponse.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + userId: senderOnly.userId, + toEmail: expect.any(String), + status: expect.stringMatching(/pending|sent|failed/), + attemptCount: expect.any(Number), + }), + ]), + ); + }); + }); +});