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(
-
+
TREK
Travel Resource & Exploration Kit
|
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(/