mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(notifications): show the header logo in Gmail and Outlook (#2507)
The logo at the top of every TREK mail was an SVG inlined into the HTML as a data: URI. Gmail strips data: URIs, Outlook blocks them and neither renders SVG, so notification and password-reset mails showed a broken image with "TREK" as its alt text. The image data itself was fine; it only rendered in clients that accept both data: URIs and SVG, such as Apple Mail. The logo is now a 96x96 PNG, rasterized from client/public/icons/icon.svg with the corner radius baked in, and MailerService attaches it to both HTML sends as an inline part. The header img points at it by cid:, so nodemailer puts the HTML and the image into multipart/related. That is the form Gmail, Outlook and Apple Mail all display, and it does not depend on the instance being reachable from the recipient's mail provider. The plain-text admin test mail carries no HTML and stays without an attachment.
This commit is contained in:
@@ -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(
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.06);">
|
||||
<!-- Header -->
|
||||
<tr><td style="background: linear-gradient(135deg, #000000 0%, #1a1a2e 100%); padding: 32px 32px 28px; text-align: center;">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj4NCiAgPGRlZnM+DQogICAgPGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzFlMjkzYiIvPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMGYxNzJhIi8+DQogICAgPC9saW5lYXJHcmFkaWVudD4NCiAgICA8Y2xpcFBhdGggaWQ9Imljb24iPg0KICAgICAgPHBhdGggZD0iTSA4NTUuNjM2NzE5IDY5OS4yMDMxMjUgTCAyMjIuMjQ2MDk0IDY5OS4yMDMxMjUgQyAxOTcuNjc5Njg4IDY5OS4yMDMxMjUgMTc5LjkwNjI1IDY3NS43NSAxODYuNTM5MDYyIDY1Mi4xMDE1NjIgTCAzNjAuNDI5Njg4IDMyLjM5MDYyNSBDIDM2NC45MjE4NzUgMTYuMzg2NzE5IDM3OS41MTE3MTkgNS4zMjgxMjUgMzk2LjEzMjgxMiA1LjMyODEyNSBMIDEwMjkuNTI3MzQ0IDUuMzI4MTI1IEMgMTA1NC4wODk4NDQgNS4zMjgxMjUgMTA3MS44NjcxODggMjguNzc3MzQ0IDEwNjUuMjMwNDY5IDUyLjQyOTY4OCBMIDg5MS4zMzk4NDQgNjcyLjEzNjcxOSBDIDg4Ni44NTE1NjIgNjg4LjE0MDYyNSA4NzIuMjU3ODEyIDY5OS4yMDMxMjUgODU1LjYzNjcxOSA2OTkuMjAzMTI1IFogTSA0NDQuMjM4MjgxIDExNjYuOTgwNDY5IEwgNTMzLjc3MzQzOCA4NDcuODk4NDM4IEMgNTQwLjQxMDE1NiA4MjQuMjQ2MDk0IDUyMi42MzI4MTIgODAwLjc5Njg3NSA0OTguMDcwMzEyIDgwMC43OTY4NzUgTCAxNzIuNDcyNjU2IDgwMC43OTY4NzUgQyAxNTUuODUxNTYyIDgwMC43OTY4NzUgMTQxLjI2MTcxOSA4MTEuODU1NDY5IDEzNi43Njk1MzEgODI3Ljg1OTM3NSBMIDQ3LjIzNDM3NSAxMTQ2Ljk0MTQwNiBDIDQwLjU5NzY1NiAxMTcwLjU5Mzc1IDU4LjM3NSAxMTk0LjA0Mjk2OSA4Mi45Mzc1IDExOTQuMDQyOTY5IEwgNDA4LjUzNTE1NiAxMTk0LjA0Mjk2OSBDIDQyNS4xNTYyNSAxMTk0LjA0Mjk2OSA0MzkuNzUgMTE4Mi45ODQzNzUgNDQ0LjIzODI4MSAxMTY2Ljk4MDQ2OSBaIE0gNjA5LjAwMzkwNiA4MjcuODU5Mzc1IEwgNDM1LjExMzI4MSAxNDQ3LjU3MDMxMiBDIDQyOC40NzY1NjIgMTQ3MS4yMTg3NSA0NDYuMjUzOTA2IDE0OTQuNjcxODc1IDQ3MC44MTY0MDYgMTQ5NC42NzE4NzUgTCAxMTA0LjIxMDkzOCAxNDk0LjY3MTg3NSBDIDExMjAuODMyMDMxIDE0OTQuNjcxODc1IDExMzUuNDIxODc1IDE0ODMuNjA5Mzc1IDExMzkuOTE0MDYyIDE0NjcuNjA1NDY5IEwgMTMxMy44MDQ2ODggODQ3Ljg5ODQzOCBDIDEzMjAuNDQxNDA2IDgyNC4yNDYwOTQgMTMwMi42NjQwNjIgODAwLjc5Njg3NSAxMjc4LjEwMTU2MiA4MDAuNzk2ODc1IEwgNjQ0LjcwNzAzMSA4MDAuNzk2ODc1IEMgNjI4LjA4NTkzOCA4MDAuNzk2ODc1IDYxMy40OTIxODggODExLjg1NTQ2OSA2MDkuMDAzOTA2IDgyNy44NTkzNzUgWiBNIDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgTCA5NjYuNTcwMzEyIDY1Mi4xMDE1NjIgQyA5NTkuOTMzNTk0IDY3NS43NSA5NzcuNzEwOTM4IDY5OS4yMDMxMjUgMTAwMi4yNzM0MzggNjk5LjIwMzEyNSBMIDEzMjcuODcxMDk0IDY5OS4yMDMxMjUgQyAxMzQ0LjQ5MjE4OCA2OTkuMjAzMTI1IDEzNTkuMDg1OTM4IDY4OC4xNDA2MjUgMTM2My41NzQyMTkgNjcyLjEzNjcxOSBMIDE0NTMuMTA5Mzc1IDM1My4wNTQ2ODggQyAxNDU5Ljc0NjA5NCAzMjkuNDA2MjUgMTQ0MS45Njg3NSAzMDUuOTUzMTI1IDE0MTcuNDA2MjUgMzA1Ljk1MzEyNSBMIDEwOTEuODA4NTk0IDMwNS45NTMxMjUgQyAxMDc1LjE4NzUgMzA1Ljk1MzEyNSAxMDYwLjU5NzY1NiAzMTcuMDE1NjI1IDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgWiIvPg0KICAgIDwvY2xpcFBhdGg+DQogIDwvZGVmcz4NCiAgPHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIGZpbGw9InVybCgjYmcpIi8+DQogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU2LDUxKSBzY2FsZSgwLjI2NykiPg0KICAgIDxyZWN0IHdpZHRoPSIxNTAwIiBoZWlnaHQ9IjE1MDAiIGZpbGw9IiNmZmZmZmYiIGNsaXAtcGF0aD0idXJsKCNpY29uKSIvPg0KICA8L2c+DQo8L3N2Zz4NCg==" alt="TREK" width="48" height="48" style="border-radius: 14px; margin-bottom: 14px; display: block; margin-left: auto; margin-right: auto;" />
|
||||
<img src="cid:${EMAIL_LOGO_CID}" alt="TREK" width="48" height="48" style="border-radius: 14px; margin-bottom: 14px; display: block; margin-left: auto; margin-right: auto;" />
|
||||
<div style="color: #ffffff; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">TREK</div>
|
||||
<div style="color: rgba(255,255,255,0.4); font-size: 10px; font-weight: 500; letter-spacing: 2px; text-transform: uppercase; margin-top: 4px;">Travel Resource & Exploration Kit</div>
|
||||
</td></tr>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<typeof import('../../src/app-config')>();
|
||||
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<typeof stream.sendMail>[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<ReturnType<typeof build>>;
|
||||
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 = /<img src="cid:([^"]+)"/.exec(html)?.[1];
|
||||
expect(cid).toBeTruthy();
|
||||
|
||||
const logo = parts.find(p => 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=');
|
||||
});
|
||||
});
|
||||
@@ -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 = /<img src="cid:([^"]+)"/.exec(mail.html ?? '')?.[1];
|
||||
expect(cid).toBeTruthy();
|
||||
const logo = mail.attachments?.find(a => 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(/<img src="cid:[^"]+" alt="TREK" width="48" height="48"/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── SEC: XSS escaping in buildEmailHtml ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user