Skip to content
Open
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
632 changes: 632 additions & 0 deletions output.txt

Large diffs are not rendered by default.

20,148 changes: 12,709 additions & 7,439 deletions package-lock.json

Large diffs are not rendered by default.

25 changes: 15 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,21 @@
"@keyv/redis": "^5.1.6",
"@nestjs/axios": "^4.0.1",
"@nestjs/bull": "^11.0.2",
"@nestjs/cache-manager": "^3.1.2",
"@nestjs/cache-manager": "^2.0.0",
"@nestjs/common": "^11.1.24",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.24",
"@nestjs/schedule": "^5.0.0",
"@nestjs/swagger": "^11.0.1",
"@nestjs/terminus": "^11.1.1",
"@nestjs/platform-socket.io": "^7.6.18",
"@nestjs/schedule": "^2.2.3",
"@nestjs/swagger": "^5.2.1",
"@nestjs/terminus": "^7.2.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/websockets": "^11.1.24",
"@nestjs/websockets": "^7.6.18",
"@prisma/client": "^6.19.3",
"@sentry/node": "^9.47.1",
"@sentry/node": "^10.59.0",
"@stellar/stellar-sdk": "^13.1.0",
"bull": "^4.16.5",
"cache-manager": "^7.2.8",
Expand All @@ -64,7 +64,7 @@
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@nestjs/testing": "^7.5.5",
"@types/bull": "^3.15.9",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
Expand All @@ -75,12 +75,12 @@
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"jest": "^25.0.0",
"prettier": "^3.4.2",
"prisma": "^6.19.3",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-jest": "^27.0.3",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
Expand All @@ -103,5 +103,10 @@
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"optionalDependencies": {
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@emnapi/wasi-threads": "^1.2.2"
}
}
5 changes: 3 additions & 2 deletions src/notifications/email.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Job } from 'bull';
import { QUEUE_EMAIL } from '../queue/queue.constants';
import { EmailService } from './email.service';
import { NotificationsService } from './notifications.service';
import { maskEmail } from './email.utils';

export interface EmailJobData {
to: string;
Expand All @@ -28,7 +29,7 @@ export class EmailProcessor {
async handleSendEmail(job: Job<EmailJobData>): Promise<void> {
const { to, subject, html, preferenceKey, userId } = job.data;

this.logger.log(`Processing email job ${job.id}: ${subject} -> ${to}`);
this.logger.log(`Processing email job ${job.id}: ${subject} -> ${maskEmail(to)}`);

// If a preference key is provided, check user's notification preferences first
if (preferenceKey && userId) {
Expand All @@ -38,7 +39,7 @@ export class EmailProcessor {
);
if (!shouldSend) {
this.logger.log(
`Skipping email to ${to}: user has disabled ${preferenceKey} email notifications`,
`Skipping email to ${maskEmail(to)}: user has disabled ${preferenceKey} email notifications`,
);
return;
}
Expand Down
178 changes: 178 additions & 0 deletions src/notifications/email.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { Logger } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { EmailService } from './email.service';

jest.mock('nodemailer');

describe('EmailService', () => {
let service: EmailService;
let configValues: Record<string, string>;
let sendMailMock: jest.Mock;

const buildConfigService = (): ConfigService => {
return {
get: jest.fn((key: string, defaultValue?: unknown) => {
return key in configValues ? configValues[key] : defaultValue;
}),
} as unknown as ConfigService;
};

const createService = async (overrides: Record<string, string> = {}) => {
configValues = {
EMAIL_FROM: 'noreply@orbitchain.io',
APP_BASE_URL: 'http://localhost:3000',
NODE_ENV: 'development',
...overrides,
};

sendMailMock = jest.fn().mockResolvedValue({
messageId: 'test-message-id',
message: JSON.stringify({
to: 'donor@example.com',
subject: 'New Donation Received! 💰',
html: '<strong>John Doe</strong> just donated <strong>500 USDC</strong> to "Save the Rainforest"',
}),
});

(nodemailer.createTransport as jest.Mock).mockReturnValue({
sendMail: sendMailMock,
});

const module: TestingModule = await Test.createTestingModule({
providers: [
EmailService,
{ provide: ConfigService, useValue: buildConfigService() },
],
}).compile();

return module.get<EmailService>(EmailService);
};

let logSpy: jest.SpyInstance;
let debugSpy: jest.SpyInstance;
let errorSpy: jest.SpyInstance;

beforeEach(() => {
jest.clearAllMocks();
logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation();
debugSpy = jest.spyOn(Logger.prototype, 'debug').mockImplementation();
errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation();
});

afterEach(() => {
logSpy.mockRestore();
debugSpy.mockRestore();
errorSpy.mockRestore();
});

const allLoggedStrings = () => {
const calls = [
...logSpy.mock.calls,
...debugSpy.mock.calls,
...errorSpy.mock.calls,
];
return calls.map((call) => String(call[0]));
};

it('never logs the raw HTML body, even when jsonTransport returns it on info.message', async () => {
service = await createService(); // EMAIL_PREVIEW unset -> defaults off
await service.send({
to: 'donor@example.com',
subject: 'New Donation Received! 💰',
html: '<strong>John Doe</strong> just donated <strong>500 USDC</strong> to "Save the Rainforest"',
});

const logged = allLoggedStrings();
for (const entry of logged) {
expect(entry).not.toContain('John Doe');
expect(entry).not.toContain('500 USDC');
expect(entry).not.toContain('<strong>');
}
});

it('does not log a body preview by default (EMAIL_PREVIEW unset)', async () => {
service = await createService();
await service.send({
to: 'donor@example.com',
subject: 'Milestone Unlocked! 🏆',
html: '<p>secret donor info</p>',
});

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

it('masks the recipient email in the success log line', async () => {
service = await createService();
await service.send({
to: 'donor@example.com',
subject: 'New Donation Received! 💰',
html: '<p>hi</p>',
});

const logged = allLoggedStrings();
expect(logged.some((entry) => entry.includes('do***@example.com'))).toBe(
true,
);
expect(logged.some((entry) => entry.includes('donor@example.com'))).toBe(
false,
);
});

it('masks the recipient email in the error log line on send failure', async () => {
service = await createService();
sendMailMock.mockRejectedValueOnce(new Error('SMTP timeout'));

await expect(
service.send({
to: 'donor@example.com',
subject: 'New Donation Received! 💰',
html: '<p>hi</p>',
}),
).rejects.toThrow('SMTP timeout');

const logged = allLoggedStrings();
expect(logged.some((entry) => entry.includes('do***@example.com'))).toBe(
true,
);
expect(logged.some((entry) => entry.includes('donor@example.com'))).toBe(
false,
);
});

it('logs a subject/recipient-only preview when EMAIL_PREVIEW=1 in non-production', async () => {
service = await createService({
NODE_ENV: 'development',
EMAIL_PREVIEW: '1',
});

await service.send({
to: 'donor@example.com',
subject: 'New Donation Received! 💰',
html: '<strong>John Doe</strong> donated 500 USDC',
});

expect(debugSpy).toHaveBeenCalledTimes(1);
const previewLine = String(debugSpy.mock.calls[0][0]);
expect(previewLine).toContain('New Donation Received! 💰');
expect(previewLine).toContain('do***@example.com');
expect(previewLine).not.toContain('John Doe');
expect(previewLine).not.toContain('500 USDC');
});

it('never enables the preview when NODE_ENV=production, even if EMAIL_PREVIEW=1', async () => {
service = await createService({
NODE_ENV: 'production',
EMAIL_PREVIEW: '1',
});

await service.send({
to: 'donor@example.com',
subject: 'New Donation Received! 💰',
html: '<strong>John Doe</strong> donated 500 USDC',
});

expect(debugSpy).not.toHaveBeenCalled();
});
});
24 changes: 21 additions & 3 deletions src/notifications/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
import { maskEmail } from './email.utils';

export interface EmailTemplate {
subject: string;
Expand All @@ -25,6 +26,8 @@ export class EmailService {
private transporter: Transporter | null = null;
private readonly fromAddress: string;
private readonly appBaseUrl: string;
private readonly nodeEnv: string;
private readonly emailPreviewEnabled: boolean;

constructor(private readonly config: ConfigService) {
this.fromAddress = config.get<string>(
Expand All @@ -35,6 +38,12 @@ export class EmailService {
'APP_BASE_URL',
'http://localhost:3000',
);
this.nodeEnv = config.get<string>('NODE_ENV', 'development');
// Opt-in only: previewing the rendered email body in logs can leak PII
// (donor names, emails, donation amounts). Never honoured in production.
this.emailPreviewEnabled =
this.nodeEnv !== 'production' &&
config.get<string>('EMAIL_PREVIEW', '0') === '1';
}

private getTransporter(): Transporter {
Expand Down Expand Up @@ -98,18 +107,27 @@ export class EmailService {
try {
const info = await transporter.sendMail(mailOptions);
this.logger.log(
`Email sent to ${options.to}: ${options.subject} (id=${info.messageId})`,
`Email sent to ${maskEmail(options.to)}: ${options.subject} (id=${info.messageId})`,
);


// Dev-only, explicit opt-in preview. Never logs the HTML body, and
// never runs in production regardless of how EMAIL_PREVIEW is set.
if (this.emailPreviewEnabled && info.messageId) {
this.logger.debug(
`Email preview (subject/recipient only): subject="${options.subject}" to=${maskEmail(options.to)}`,
);
}

// In dev mode with jsonTransport, log the message content
if (info.messageId && info.message) {
this.logger.debug(`Email body preview: ${info.message}`);
}
} catch (error) {
this.logger.error(
`Failed to send email to ${options.to}: ${(error as Error).message}`,
`Failed to send email to ${maskEmail(options.to)}: ${(error as Error).message}`,
);
throw error;
}
}
}
}
11 changes: 11 additions & 0 deletions src/notifications/email.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Masks an email address for safe logging, e.g. "jo***@example.com".
* Keeps enough of the local part to be useful for debugging without
* exposing the full address in log aggregators.
*/
export function maskEmail(email: string): string {
const [local, domain] = email.split('@');
if (!domain) return '***';
const visible = local.slice(0, 2);
return `${visible}${'*'.repeat(Math.max(local.length - visible.length, 1))}@${domain}`;
}
9 changes: 5 additions & 4 deletions src/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Queue } from 'bull';
import { PrismaService } from '../prisma/prisma.service';
import { QUEUE_EMAIL } from '../queue/queue.constants';
import type { EmailJobData } from './email.processor';
import { maskEmail } from './email.utils';
import {
donationReceivedTemplate,
milestoneUnlockedTemplate,
Expand Down Expand Up @@ -112,7 +113,7 @@ export class NotificationsService {
};

await this.emailQueue.add('send-email', jobData);
this.logger.log(`Queued donation received email to ${payload.toEmail}`);
this.logger.log(`Queued donation received email to ${maskEmail(payload.toEmail)}`);
}

/** Queue a milestone unlocked email via Bull for async processing */
Expand All @@ -135,7 +136,7 @@ export class NotificationsService {
};

await this.emailQueue.add('send-email', jobData);
this.logger.log(`Queued milestone unlocked email to ${payload.toEmail}`);
this.logger.log(`Queued milestone unlocked email to ${maskEmail(payload.toEmail)}`);
}

/** Queue a campaign update email via Bull for async processing */
Expand All @@ -157,7 +158,7 @@ export class NotificationsService {
};

await this.emailQueue.add('send-email', jobData);
this.logger.log(`Queued campaign update email to ${payload.toEmail}`);
this.logger.log(`Queued campaign update email to ${maskEmail(payload.toEmail)}`);
}

/**
Expand All @@ -168,7 +169,7 @@ export class NotificationsService {
payload: SuspensionEmailPayload,
): Promise<void> {
this.logger.log(
`[EMAIL] To: ${payload.toEmail} | Subject: Your campaign "${payload.campaignTitle}" has been suspended | Reason: ${payload.reason}`,
`[EMAIL] To: ${maskEmail(payload.toEmail)} | Subject: Your campaign "${payload.campaignTitle}" has been suspended | Reason: ${payload.reason}`,
);
// TODO: replace with real mailer call, e.g.:
// await this.emailService.send({
Expand Down
Loading
Loading