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