Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/common/request-language.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Request-scoped language context for the ValidationPipe exceptionFactory.
*
* ValidationPipe's exceptionFactory runs outside the normal filter/interceptor
* chain and has no access to the Express Request. Middleware stores the
* Accept-Language header (and optional user preference) in AsyncLocalStorage
* so the factory can pass them into I18nService.translate.
*
* Issue #1234 / #964.
*/
import { AsyncLocalStorage } from 'async_hooks';

export interface RequestLanguageContext {
acceptLanguageHeader?: string | null;
userPreference?: string | null;
}

export const requestLanguageStore = new AsyncLocalStorage<RequestLanguageContext>();

export function getRequestLanguageContext(): RequestLanguageContext {
return requestLanguageStore.getStore() ?? {};
}

export function runWithRequestLanguage<T>(
ctx: RequestLanguageContext,
fn: () => T,
): T {
return requestLanguageStore.run(ctx, fn);
}
135 changes: 93 additions & 42 deletions src/email/email.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import { EmailService } from './email.service';
import { EmailService, HARD_BOUNCE_SUPPRESSION_THRESHOLD } from './email.service';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../database/prisma.service';
import { TrackingService } from '../tracking/tracking.service';
import { I18nService } from '../i18n/i18n.service';
import { Queue } from 'bullmq';

function createService(
prisma: Partial<PrismaService>,
configGet: (key: string, def?: string) => string | undefined = () => 'http://localhost:3000',
queueAdd: jest.Mock = jest.fn().mockResolvedValue(undefined),
) {
return new EmailService(
{ get: jest.fn().mockImplementation(configGet) } as unknown as ConfigService,
prisma as unknown as PrismaService,
{ createEmailEngagement: jest.fn() } as unknown as TrackingService,
{ translate: jest.fn((key) => key) } as unknown as I18nService,
{ add: queueAdd } as unknown as Queue,
);
}

describe('EmailService.handleBounce', () => {
it('disables email notifications on hard bounce', async () => {
const prisma = {
Expand All @@ -20,14 +34,7 @@ describe('EmailService.handleBounce', () => {
},
};

const service = new EmailService(
{ get: jest.fn().mockReturnValue('http://localhost:3000/api') } as unknown as ConfigService,
prisma as unknown as PrismaService,
{ createEmailEngagement: jest.fn() } as unknown as TrackingService,
{ translate: jest.fn((key) => key) } as unknown as I18nService,
{ add: jest.fn() } as unknown as Queue,
);

const service = createService(prisma as any);
await service.handleBounce('test@example.com', 'HARD', 'Mailbox disabled', {
id: 'evt-1',
});
Expand Down Expand Up @@ -57,46 +64,90 @@ describe('EmailService.handleBounce', () => {
});
});


describe('EmailService localization (issue #1231)', () => {
function buildService(i18nTranslate: jest.Mock) {
describe('EmailService bounce suppression (issue #1233)', () => {
it('shouldSuppressAddress is true when hard-bounce count meets threshold', async () => {
const prisma = {
user: { findUnique: jest.fn().mockResolvedValue(null) },
emailBounce: {
count: jest.fn().mockResolvedValue(HARD_BOUNCE_SUPPRESSION_THRESHOLD),
},
};
const queue = { add: jest.fn().mockResolvedValue({ id: 'q1' }) };
const service = new EmailService(
{ get: jest.fn().mockReturnValue('http://localhost:3000/api') } as any,
prisma as any,
{ createEmailEngagement: jest.fn() } as any,
{
translate: i18nTranslate,
tFor: i18nTranslate,
resolveLanguage: jest.fn().mockReturnValue('es'),
} as any,
queue as any,
const service = createService(prisma as any);
await expect(service.shouldSuppressAddress('bounced@example.com')).resolves.toBe(true);
expect(prisma.emailBounce.count).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
email: 'bounced@example.com',
bounceType: 'HARD',
}),
}),
);
return { service, queue };
}
});

it('injects context.t with Spanish strings when language=es', async () => {
const i18nTranslate = jest.fn((key: string) => {
if (key === 'email.password_reset_title') return 'Solicitud de restablecimiento de contraseña';
if (key === 'email.password_reset_subject') return 'Restablecimiento de contraseña - PropChain';
return key;
it('sendEmail skips queue when address is hard-bounced', async () => {
const queueAdd = jest.fn().mockResolvedValue(undefined);
const prisma = {
emailBounce: {
count: jest
.fn()
// first call: hard bounces >= threshold
.mockResolvedValueOnce(HARD_BOUNCE_SUPPRESSION_THRESHOLD)
.mockResolvedValue(0),
},
user: { findUnique: jest.fn() },
};
const service = createService(prisma as any, () => 'https://app.example.com', queueAdd);

await service.sendEmail({
to: 'bounced@example.com',
subject: 'Hello',
text: 'body',
});
const { service, queue } = buildService(i18nTranslate);

expect(queueAdd).not.toHaveBeenCalled();
});

it('sendEmail queues when no recent hard bounces', async () => {
const queueAdd = jest.fn().mockResolvedValue(undefined);
const prisma = {
emailBounce: {
count: jest.fn().mockResolvedValue(0),
},
user: { findUnique: jest.fn().mockResolvedValue(null) },
};
const service = createService(prisma as any, () => 'https://app.example.com', queueAdd);

await service.sendEmail({
to: 'user@example.com',
subject: 'Password Reset - PropChain',
template: 'password-reset',
context: { resetUrl: 'https://example.com/reset' },
language: 'es',
to: 'ok@example.com',
subject: 'Hello',
text: 'body',
});
expect(queue.add).toHaveBeenCalled();
const payload = queue.add.mock.calls[0][1];
expect(payload.context.t).toBeDefined();
expect(payload.context.language).toBe('es');
expect(payload.context.t.password_reset_title).toBe('Solicitud de restablecimiento de contraseña');

expect(queueAdd).toHaveBeenCalled();
});
});

describe('EmailService unsubscribe URL (issue #1232)', () => {
it('buildListUnsubscribeHeader uses ConfigService FRONTEND_URL at call time', () => {
const prisma = { emailBounce: { count: jest.fn() } };
const service = createService(prisma as any, (key: string) =>
key === 'FRONTEND_URL' ? 'https://tenant-a.example.com' : undefined,
);
const header = service.buildListUnsubscribeHeader('user-1', 'u@example.com');
expect(header).toContain('https://tenant-a.example.com/unsubscribe?token=');
});

it('reflects a different FRONTEND_URL when config changes', () => {
const prisma = { emailBounce: { count: jest.fn() } };
let frontend = 'https://first.example.com';
const service = createService(prisma as any, (key: string) =>
key === 'FRONTEND_URL' ? frontend : undefined,
);
expect(service.buildListUnsubscribeHeader('u', 'a@b.com')).toContain(
'https://first.example.com/unsubscribe',
);
frontend = 'https://second.example.com';
expect(service.buildListUnsubscribeHeader('u', 'a@b.com')).toContain(
'https://second.example.com/unsubscribe',
);
});
});
57 changes: 52 additions & 5 deletions src/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import { v4 as uuidv4 } from 'uuid';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { redactEmail } from '../auth/security.utils';
import { buildUnsubscribeUrl } from './unsubscribe-url.helper';

const UNSUBSCRIBE_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
/** Number of hard bounces within the lookback window that triggers suppression. */
export const HARD_BOUNCE_SUPPRESSION_THRESHOLD = 1;
/** Lookback window for hard-bounce suppression (ms). Default 90 days. */
export const HARD_BOUNCE_LOOKBACK_MS = 90 * 24 * 60 * 60 * 1000;

export interface EmailOptions {
to: string;
Expand Down Expand Up @@ -251,7 +255,42 @@ export class EmailService {
buildListUnsubscribeHeader(userId?: string, email?: string): string | null {
if (!userId || !email) return null;
const token = Buffer.from(`${userId}:${email}`).toString('base64');
return `<${UNSUBSCRIBE_URL}/unsubscribe?token=${token}>`;
// Read FRONTEND_URL at call time via ConfigService so tests and multi-tenant
// overrides are not stuck with a module-load snapshot (issue #1232).
const frontendUrl = this.configService.get<string>('FRONTEND_URL');
try {
const url = buildUnsubscribeUrl(token, frontendUrl);
return `<${url}>`;
} catch {
this.logger.warn('FRONTEND_URL not set; omitting List-Unsubscribe header');
return null;
}
}

/**
* Returns true when the address has recent hard bounces / complaints at or
* above HARD_BOUNCE_SUPPRESSION_THRESHOLD (issue #1233).
*/
async shouldSuppressAddress(email: string): Promise<boolean> {
const since = new Date(Date.now() - HARD_BOUNCE_LOOKBACK_MS);
const hardCount = await this.prisma.emailBounce.count({
where: {
email,
bounceType: 'HARD',
createdAt: { gte: since },
},
});
if (hardCount >= HARD_BOUNCE_SUPPRESSION_THRESHOLD) {
return true;
}
const complaints = await this.prisma.emailBounce.count({
where: {
email,
spamAction: 'COMPLAINED',
createdAt: { gte: since },
},
});
return complaints >= HARD_BOUNCE_SUPPRESSION_THRESHOLD;
}

async sendEmail(options: EmailOptions): Promise<void> {
Expand Down Expand Up @@ -284,11 +323,19 @@ export class EmailService {
}
}

// 1. Check if user is blocked or has invalid email
// 0. Bounce / complaint suppression (issue #1233)
if (await this.shouldSuppressAddress(options.to)) {
this.logger.warn(
`🚫 Skipping email to ${redactEmail(options.to)} (hard-bounce or complaint suppression)`,
);
return;
}

// 1. Check if user is blocked or has invalid / bounced email
if (options.userId) {
const user = await this.prisma.user.findUnique({ where: { id: options.userId } });
if (user && (user.isBlocked || user.emailStatus === 'INVALID')) {
this.logger.warn(`🚫 Skipping email to ${redactEmail(options.to)} (User blocked or email invalid)`);
if (user && (user.isBlocked || user.emailStatus === 'INVALID' || user.emailStatus === 'BOUNCED')) {
this.logger.warn(`🚫 Skipping email to ${redactEmail(options.to)} (User blocked or email invalid/bounced)`);
return;
}
}
Expand Down
Loading