diff --git a/server/src/nest/notifications/mailer/email-html.ts b/server/src/nest/notifications/mailer/email-html.ts index 9fd35e1d6..8bef780a7 100644 --- a/server/src/nest/notifications/mailer/email-html.ts +++ b/server/src/nest/notifications/mailer/email-html.ts @@ -2,12 +2,16 @@ import { EMAIL_I18N as I18N, EVENT_TEXTS } from '@trek/shared/i18n/externalNotif import type { EventText, PasswordResetStrings } from '@trek/shared/i18n/externalNotifications'; import { getAppUrl } from '../../../app-config'; import type { NotifEventType } from '../notification-events'; +import { EMAIL_LOGO_CID } from './email-logo'; /** * Pure rendering for outgoing mail: the shell, the CTA and the password-reset * body. No database, no transport, no DI - which is why it stays a plain module * and not a provider. `getAppUrl()` is the canonical app URL from app-config, * never a request header. + * + * The header logo is not in the HTML itself: the shell points at it by cid:, so + * every mail carrying this HTML has to attach `emailLogoAttachment()`. */ function escapeHtml(str: string): string { @@ -49,7 +53,7 @@ export function buildEmailHtml( diff --git a/server/src/nest/notifications/mailer/email-logo.ts b/server/src/nest/notifications/mailer/email-logo.ts new file mode 100644 index 000000000..1969ba544 --- /dev/null +++ b/server/src/nest/notifications/mailer/email-logo.ts @@ -0,0 +1,36 @@ +import type Mail from 'nodemailer/lib/mailer'; + +/** + * The TREK mark in the mail header, sent as a PNG part of the message and + * addressed from the HTML by Content-ID (#2507). + * + * It used to be an SVG inlined as a data: URI. Gmail strips data: URIs, Outlook + * blocks them and neither of them renders SVG, so most people saw a broken image + * with "TREK" as its alt text. An inline part referenced through cid: is what + * Gmail, Outlook and Apple Mail all display, and unlike a hosted image it does + * not depend on the instance being reachable from the recipient's mail provider. + * + * Rasterized from client/public/icons/icon.svg at 96x96, twice the size the + * header shows it at, with the corner radius baked into the image because + * Outlook ignores border-radius on an img. + */ +export const EMAIL_LOGO_CID = 'logo@trek'; + +const EMAIL_LOGO_PNG = Buffer.from( + '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', + 'base64', +); + +/** + * A fresh attachment object per message, so nodemailer never shares state + * between two sends. Every HTML mail from buildEmailHtml needs it. + */ +export function emailLogoAttachment(): Mail.Attachment { + return { + filename: 'trek-logo.png', + content: EMAIL_LOGO_PNG, + contentType: 'image/png', + contentDisposition: 'inline', + cid: EMAIL_LOGO_CID, + }; +} diff --git a/server/src/nest/notifications/mailer/mailer.service.ts b/server/src/nest/notifications/mailer/mailer.service.ts index 60e3197e6..9630e044f 100644 --- a/server/src/nest/notifications/mailer/mailer.service.ts +++ b/server/src/nest/notifications/mailer/mailer.service.ts @@ -6,6 +6,7 @@ import { logError, logInfo, logDebug, logWarn } from '../../audit/audit-log.logg import { decrypt_api_key } from '../../common/crypto/apiKeyCrypto'; import { DatabaseService } from '../../database/database.service'; import { buildEmailHtml, buildPasswordResetHtml } from './email-html'; +import { emailLogoAttachment } from './email-logo'; import { describeSmtpFailure, describeSmtpGap, parseSmtpPort, type SmtpTarget } from './smtp-diagnostics'; interface SmtpConfig { @@ -188,6 +189,7 @@ export class MailerService { subject: `TREK — ${strings.subject}`, text: `${strings.greeting}, ${to}\n\n${strings.body}\n\n${strings.ctaIntro}: ${resetUrl}\n\n${strings.expiry}\n${strings.ignore}`, html: buildPasswordResetHtml(strings.subject, strings, to, resetUrl, lang), + attachments: [emailLogoAttachment()], }); logInfo(`Password reset email sent to=${to}`); return { delivered: 'email' }; @@ -216,6 +218,7 @@ export class MailerService { subject: `TREK — ${subject}`, text: body, html: buildEmailHtml(subject, body, lang, navigateTarget), + attachments: [emailLogoAttachment()], }); logInfo(`Email sent to=${to} subject="${subject}"`); logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`); diff --git a/server/tests/e2e/mailer.e2e.test.ts b/server/tests/e2e/mailer.e2e.test.ts new file mode 100644 index 000000000..d5a881197 --- /dev/null +++ b/server/tests/e2e/mailer.e2e.test.ts @@ -0,0 +1,160 @@ +/** + * Mailer e2e (#2507): the message a real POST /api/auth/forgot-password puts on + * the wire, built by the real AuthModule, the real MailerService and nodemailer's + * real MIME composer against a temp SQLite db. Only the socket is gone: + * createTransport hands back nodemailer's stream transport, which produces the + * exact bytes an SMTP relay would receive. + * + * The header logo used to be an SVG data: URI inside the HTML. Gmail strips + * those, Outlook blocks them, and neither renders SVG, so the header showed a + * broken image. These cases pin the replacement: a PNG part inside the message, + * next to the HTML in multipart/related, that the HTML addresses by Content-ID. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec('PRAGMA foreign_keys = ON'); + return { db: tmp }; +}); + +vi.mock('../../src/db/database', () => ({ + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => undefined, + isOwner: () => false, +})); +vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); +vi.mock('../../src/app-config', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getAppUrl: () => 'https://trek.example' }; +}); + +const { wire } = vi.hoisted(() => ({ wire: [] as string[] })); +vi.mock('nodemailer', async (importOriginal) => { + const real = (await importOriginal<{ default: typeof import('nodemailer') }>()).default; + return { + default: { + ...real, + createTransport: () => { + const stream = real.createTransport({ streamTransport: true, buffer: true, newline: 'windows' }); + return { + async sendMail(mail: Parameters[0]) { + const info = await stream.sendMail(mail); + wire.push(info.message.toString()); + return info; + }, + }; + }, + }, + }; +}); + +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { createUser } from '../helpers/factories'; +import { AuthModule } from '../../src/nest/auth/auth.module'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; + +interface MimePart { + headers: string; + body: string; +} + +/** The leaf parts of a raw message, in order, with their header block and raw body. */ +function leafParts(raw: string): MimePart[] { + const split = raw.indexOf('\r\n\r\n'); + const headers = raw.slice(0, split); + const body = raw.slice(split + 4); + const boundary = /boundary="([^"]+)"/i.exec(headers.replace(/\r\n\s+/g, ' '))?.[1]; + if (!/^content-type:\s*multipart\//im.test(headers) || !boundary) return [{ headers, body }]; + return body + .split(`--${boundary}`) + .slice(1, -1) + .flatMap(chunk => leafParts(chunk.replace(/^\r\n/, ''))); +} + +/** The decoded text of a text/* part, honouring its transfer encoding. */ +function partText(part: MimePart): string { + if (!/^Content-Transfer-Encoding: quoted-printable/im.test(part.headers)) return part.body; + const bytes = part.body + .replace(/=\r\n/g, '') + .replace(/=([0-9A-F]{2})/g, (_m, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))); + return Buffer.from(bytes, 'latin1').toString('utf8'); +} + +describe('Mailer e2e: the header logo in a mail on the wire (#2507)', () => { + let server: Server; + let app: Awaited>; + let email: string; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AuthModule] }).compile(); + const nest = moduleRef.createNestApplication(); + nest.useGlobalFilters(new TrekExceptionFilter()); + nest.useGlobalPipes(new ZodValidationPipe()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + createTables(db as never); + runMigrations(db as never); + email = createUser(db as never, { username: 'mail-e2e', email: 'mail-e2e@example.test' }).user.email; + const setting = db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)'); + setting.run('smtp_host', 'mail.internal.example'); + setting.run('smtp_port', '587'); + setting.run('smtp_from', 'trek@example.test'); + app = await build(); + server = app.getHttpServer(); + + const res = await request(server).post('/api/auth/forgot-password').send({ email }); + expect(res.status).toBe(200); + }); + + afterAll(async () => { + await app.close(); + }); + + it('sends exactly one message, the HTML next to the logo in multipart/related', () => { + expect(wire).toHaveLength(1); + const raw = wire[0]; + expect(raw).toMatch(/^Content-Type: multipart\/alternative;/m); + expect(raw).toMatch(/^Content-Type: multipart\/related; type="text\/html";/m); + }); + + it('the HTML addresses the logo by Content-ID and carries no data: URI', () => { + const parts = leafParts(wire[0]); + const htmlPart = parts.find(p => /^Content-Type: text\/html/im.test(p.headers))!; + const html = partText(htmlPart); + expect(html).not.toContain('data:'); + + const cid = / p.headers.includes(`Content-ID: <${cid}>`)); + expect(logo, `no MIME part with Content-ID <${cid}>`).toBeDefined(); + expect(logo!.headers).toMatch(/^Content-Type: image\/png/im); + expect(logo!.headers).toMatch(/^Content-Disposition: inline/im); + expect(logo!.headers).toMatch(/^Content-Transfer-Encoding: base64/im); + const png = Buffer.from(logo!.body.replace(/\s+/g, ''), 'base64'); + expect(png.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); + }); + + it('the plain-text alternative stays first and untouched', () => { + const parts = leafParts(wire[0]); + expect(parts[0].headers).toMatch(/^Content-Type: text\/plain/im); + expect(partText(parts[0])).toContain('https://trek.example/reset-password?token='); + }); +}); diff --git a/server/tests/unit/nest/mailer.service.test.ts b/server/tests/unit/nest/mailer.service.test.ts index 55d0707bb..cf8de3513 100644 --- a/server/tests/unit/nest/mailer.service.test.ts +++ b/server/tests/unit/nest/mailer.service.test.ts @@ -267,3 +267,61 @@ describe('MailerService test send', () => { expect(lines.some(line => line.includes('SMTP test email sent to=admin@example.com smtp=mail.internal.example:587'))).toBe(true); }); }); + +describe('MailerService header logo (#2507)', () => { + interface SentAttachment { + cid?: string; + contentType?: string; + contentDisposition?: string; + content?: Buffer; + } + + /** The options object of the most recent sendMail() call. */ + function lastMail(): { html?: string; attachments?: SentAttachment[] } { + const calls = sendMail.mock.calls; + return calls[calls.length - 1][0] as { html?: string; attachments?: SentAttachment[] }; + } + + /** + * The attachment the HTML's header image resolves to. Before #2507 the logo was + * an SVG data: URI, which Gmail strips and Outlook blocks; it has to be a PNG + * part inside the message, marked inline and addressed by Content-ID. + */ + function expectLogoResolves(mail: { html?: string; attachments?: SentAttachment[] }): void { + const cid = / a.cid === cid); + expect(logo).toBeDefined(); + expect(logo!.contentType).toBe('image/png'); + expect(logo!.contentDisposition).toBe('inline'); + const png = logo!.content!; + expect(png.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); + // IHDR width and height: twice the 48px the header shows it at. + expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([96, 96]); + } + + it('MAILER-015: a notification mail carries the logo its HTML points to', async () => { + configureSmtp(); + + expect(await newMailer().sendEmail('someone@example.com', 'Subject', 'Body')).toBe(true); + + expectLogoResolves(lastMail()); + }); + + it('MAILER-016: the password-reset mail carries it too', async () => { + configureSmtp(); + + expect((await newMailer().sendPasswordResetEmail('someone@example.com', 'https://trek.example/reset', null)).delivered).toBe('email'); + + expectLogoResolves(lastMail()); + }); + + it('MAILER-017: the plain-text test mail has no HTML, so it carries no logo either', async () => { + configureSmtp(); + + await newMailer().testSmtp('admin@example.com'); + + expect(lastMail().html).toBeUndefined(); + expect(lastMail().attachments).toBeUndefined(); + }); +}); diff --git a/server/tests/unit/nest/notification-transports.test.ts b/server/tests/unit/nest/notification-transports.test.ts index 6a48ac339..d799a9c92 100644 --- a/server/tests/unit/nest/notification-transports.test.ts +++ b/server/tests/unit/nest/notification-transports.test.ts @@ -232,6 +232,14 @@ describe('buildEmailHtml', () => { // Both should have the same footer text expect(unknown).toContain('notifications enabled in TREK'); }); + + it('points the header logo at an inline part by Content-ID, not at a data: URI (#2507)', () => { + const html = buildEmailHtml('Subject', 'Body', 'en'); + // Gmail strips data: URIs and Outlook blocks them, which left a broken image + // in the header of every mail. + expect(html).not.toContain('data:'); + expect(html).toMatch(/
- TREK + TREK
TREK
Travel Resource & Exploration Kit