Initial commit on Forgejo
Fresh repository history for elektrine/elektrine-haraka hosted at https://git.elektrine.com/elektrine/elektrine-haraka.
This commit is contained in:
commit
67c1d41a0c
82 changed files with 8713 additions and 0 deletions
173
test/email-builder.test.js
Normal file
173
test/email-builder.test.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('crypto');
|
||||
const { simpleParser } = require('mailparser');
|
||||
const libmime = require('libmime');
|
||||
|
||||
const builder = require('../lib/email-builder');
|
||||
|
||||
const BASE = {
|
||||
from: 'sender@elektrine.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'test',
|
||||
text_body: 'hello'
|
||||
};
|
||||
|
||||
// Every physical line of a built message must respect the RFC 5322 hard limit.
|
||||
function assert_line_limit(email_content) {
|
||||
for (const line of email_content.split('\r\n')) {
|
||||
assert.ok(
|
||||
Buffer.byteLength(line, 'utf8') <= 998,
|
||||
`line exceeds 998 octets: ${line.slice(0, 80)}...`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test('round-trips display names, unicode subject and bodies', async () => {
|
||||
const { email_content, message_id } = builder.build({
|
||||
from: '"Maxfield" <maxfield@elektrine.com>',
|
||||
to: 'Ada Lovelace <ada@example.com>',
|
||||
cc: 'Grüße Müller <mueller@example.com>',
|
||||
subject: 'Grüße aus Zürich — 日本語テスト',
|
||||
text_body: 'Plain body with unicode ✓ and a long stretch ' + 'x'.repeat(300),
|
||||
html_body: '<p>HTML body ✓ — ünïcödé</p>'
|
||||
});
|
||||
|
||||
assert_line_limit(email_content);
|
||||
const parsed = await simpleParser(email_content);
|
||||
|
||||
assert.equal(parsed.from.value[0].name, 'Maxfield');
|
||||
assert.equal(parsed.from.value[0].address, 'maxfield@elektrine.com');
|
||||
assert.equal(parsed.to.value[0].name, 'Ada Lovelace');
|
||||
assert.equal(parsed.to.value[0].address, 'ada@example.com');
|
||||
assert.equal(parsed.cc.value[0].name, 'Grüße Müller');
|
||||
assert.equal(parsed.subject, 'Grüße aus Zürich — 日本語テスト');
|
||||
assert.equal(parsed.text.trim(), 'Plain body with unicode ✓ and a long stretch ' + 'x'.repeat(300));
|
||||
assert.equal(parsed.html.trim(), '<p>HTML body ✓ — ünïcödé</p>');
|
||||
assert.ok(parsed.messageId.includes(message_id));
|
||||
assert.ok(parsed.date instanceof Date && !Number.isNaN(parsed.date.getTime()));
|
||||
});
|
||||
|
||||
test('drops custom headers that could override structure or identity', async () => {
|
||||
const { email_content } = builder.build({
|
||||
...BASE,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/mixed; boundary=evil',
|
||||
'Content-Transfer-Encoding': '7bit',
|
||||
'Reply-To': 'attacker@evil.example',
|
||||
'Sender': 'attacker@evil.example',
|
||||
'Return-Path': 'attacker@evil.example',
|
||||
'X-Campaign': 'summer-2026'
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(!email_content.includes('boundary=evil'));
|
||||
assert.ok(!email_content.includes('attacker@evil.example'));
|
||||
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(parsed.headers.get('x-campaign'), 'summer-2026');
|
||||
// The body's real Content-Type must be the only one.
|
||||
assert.match(parsed.headers.get('content-type').value, /^text\/plain$/);
|
||||
});
|
||||
|
||||
test('MIME-encodes non-ASCII custom header values', async () => {
|
||||
const { email_content } = builder.build({
|
||||
...BASE,
|
||||
headers: { 'X-Note': 'wörld ünïcödé' }
|
||||
});
|
||||
|
||||
// The raw header block (before the blank line) must be pure ASCII.
|
||||
const header_block = email_content.split('\r\n\r\n')[0];
|
||||
assert.match(header_block, /^[\x00-\x7F]*$/);
|
||||
|
||||
// mailparser leaves unknown headers raw; decode the encoded-word ourselves.
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(libmime.decodeWords(parsed.headers.get('x-note')), 'wörld ünïcödé');
|
||||
});
|
||||
|
||||
test('neutralizes CRLF header injection in subject and display names', async () => {
|
||||
const { email_content } = builder.build({
|
||||
...BASE,
|
||||
from: 'Evil\r\nBcc: hidden@evil.example\r\n <sender@elektrine.com>',
|
||||
subject: 'Hello\r\nX-Injected: 1'
|
||||
});
|
||||
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(parsed.headers.get('x-injected'), undefined);
|
||||
assert.equal(parsed.bcc, undefined);
|
||||
assert.equal(parsed.from.value[0].address, 'sender@elektrine.com');
|
||||
});
|
||||
|
||||
test('folds long recipient lists under the line limit', async () => {
|
||||
const to = Array.from({ length: 40 }, (_, i) =>
|
||||
`Recipient Number ${i} With A Fairly Long Display Name <recipient${i}@example.com>`);
|
||||
const { email_content } = builder.build({ ...BASE, to });
|
||||
|
||||
assert_line_limit(email_content);
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(parsed.to.value.length, 40);
|
||||
assert.equal(parsed.to.value[39].address, 'recipient39@example.com');
|
||||
});
|
||||
|
||||
test('re-wraps attachment base64 and round-trips the payload', async () => {
|
||||
const payload = crypto.randomBytes(3000);
|
||||
const { email_content } = builder.build({
|
||||
...BASE,
|
||||
html_body: '<p>with attachment</p>',
|
||||
attachments: [{
|
||||
filename: 'data.bin',
|
||||
content_type: 'application/octet-stream',
|
||||
data: payload.toString('base64')
|
||||
}]
|
||||
});
|
||||
|
||||
assert_line_limit(email_content);
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(parsed.attachments.length, 1);
|
||||
assert.equal(parsed.attachments[0].filename, 'data.bin');
|
||||
assert.ok(parsed.attachments[0].content.equals(payload));
|
||||
});
|
||||
|
||||
test('rejects missing or non-base64 attachment data', () => {
|
||||
assert.throws(
|
||||
() => builder.build({ ...BASE, attachments: [{ filename: 'x.bin' }] }),
|
||||
/Invalid attachment data/
|
||||
);
|
||||
assert.throws(
|
||||
() => builder.build({ ...BASE, attachments: [{ filename: 'x.bin', data: 'not base64!!' }] }),
|
||||
/Invalid attachment data/
|
||||
);
|
||||
});
|
||||
|
||||
test('encodes non-ASCII attachment filenames per RFC 2231', async () => {
|
||||
const { email_content } = builder.build({
|
||||
...BASE,
|
||||
attachments: [{
|
||||
filename: 'résumé 2026.pdf',
|
||||
content_type: 'application/pdf',
|
||||
data: Buffer.from('%PDF-fake').toString('base64')
|
||||
}]
|
||||
});
|
||||
|
||||
assert.ok(email_content.includes("filename*=UTF-8''"));
|
||||
const header_block = email_content.split('\r\n\r\n')[0];
|
||||
assert.match(header_block, /^[\x00-\x7F]*$/);
|
||||
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(parsed.attachments[0].filename, 'résumé 2026.pdf');
|
||||
});
|
||||
|
||||
test('quoted-printable bodies stay within soft line limits and round-trip', async () => {
|
||||
const text = '日本語のテキスト mixed with ASCII words. '.repeat(120);
|
||||
const { email_content } = builder.build({ ...BASE, text_body: text });
|
||||
|
||||
const body = email_content.split('\r\n\r\n').slice(1).join('\r\n\r\n');
|
||||
for (const line of body.split('\r\n')) {
|
||||
assert.ok(line.length <= 76, `QP line exceeds 76 chars: ${line.length}`);
|
||||
}
|
||||
|
||||
const parsed = await simpleParser(email_content);
|
||||
assert.equal(parsed.text.replace(/\n$/, ''), text);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue