Skip to content

Commit ad8f4c6

Browse files
committed
test: add comprehensive regression tests for RFC compliance
- Add RFC 3676 flowed text tests (signature separator, DelSp, space stuffing) - Add RFC 2231 parameter continuation tests (encoded and non-encoded) - Add boundary edge case tests (empty parts, preamble/epilogue, special chars) - Add nested multipart structure tests (alternative inside mixed, related) - Add malformed email permissive parsing tests - Add additional edge case tests (empty email, headers only, etc.) fix: add null checks for contentType.parsed access Fixes crash when parsing empty emails or emails where headers were not processed (contentType.parsed would be undefined).
1 parent e004c3a commit ad8f4c6

File tree

2 files changed

+324
-3
lines changed

2 files changed

+324
-3
lines changed

src/postal-mime.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ export default class PostalMime {
358358
return false;
359359
}
360360

361-
switch (node.contentType.parsed.value) {
361+
switch (node.contentType.parsed?.value) {
362362
case 'text/html':
363363
case 'text/plain':
364364
return true;
@@ -371,7 +371,7 @@ export default class PostalMime {
371371
}
372372

373373
isInlineMessageRfc822(node) {
374-
if (node.contentType.parsed.value !== 'message/rfc822') {
374+
if (node.contentType.parsed?.value !== 'message/rfc822') {
375375
return false;
376376
}
377377
let disposition =
@@ -388,7 +388,10 @@ export default class PostalMime {
388388
let forceRfc822Attachments = false;
389389
let walk = node => {
390390
if (!node.contentType.multipart) {
391-
if (['message/delivery-status', 'message/feedback-report'].includes(node.contentType.parsed.value)) {
391+
if (
392+
node.contentType.parsed &&
393+
['message/delivery-status', 'message/feedback-report'].includes(node.contentType.parsed.value)
394+
) {
392395
forceRfc822Attachments = true;
393396
}
394397
}

test/postal-mime-test.js

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,3 +681,321 @@ test('Parse Content-Type with comment but parentheses in quoted string preserved
681681
const email = await PostalMime.parse(mail);
682682
assert.strictEqual(email.attachments[0].filename, 'file (1).txt');
683683
});
684+
685+
// RFC 3676 Format=Flowed tests
686+
687+
test('Flowed text - basic soft line break', async t => {
688+
const mail = Buffer.from(
689+
'Content-Type: text/plain; format=flowed\r\n\r\n' + 'This is a long line that \r\n' + 'continues here.\r\n'
690+
);
691+
const email = await PostalMime.parse(mail);
692+
assert.strictEqual(email.text, 'This is a long line that continues here.\n');
693+
});
694+
695+
test('Flowed text - signature separator not joined (RFC 3676)', async t => {
696+
// The signature separator "-- " should not be joined with following lines
697+
// even though it ends with a space
698+
const mail = Buffer.from(
699+
'Content-Type: text/plain; format=flowed\r\n\r\n' + 'Body text \r\n' + 'continues.\r\n' + '-- \r\n' + 'Signature\r\n'
700+
);
701+
const email = await PostalMime.parse(mail);
702+
// Signature separator should remain on its own line
703+
assert.ok(email.text.includes('-- \n') || email.text.includes('--\n'));
704+
});
705+
706+
test('Flowed text - DelSp=yes removes trailing space', async t => {
707+
const mail = Buffer.from(
708+
'Content-Type: text/plain; format=flowed; delsp=yes\r\n\r\n' + 'Hello \r\n' + 'World\r\n'
709+
);
710+
const email = await PostalMime.parse(mail);
711+
assert.strictEqual(email.text, 'HelloWorld\n');
712+
});
713+
714+
test('Flowed text - space stuffing removed', async t => {
715+
// Lines starting with space, ">", or "From " are space-stuffed
716+
const mail = Buffer.from(
717+
'Content-Type: text/plain; format=flowed\r\n\r\n' + ' >quoted\r\n' + ' From someone\r\n'
718+
);
719+
const email = await PostalMime.parse(mail);
720+
assert.ok(email.text.includes('>quoted'));
721+
assert.ok(email.text.includes('From someone'));
722+
});
723+
724+
// RFC 2231 Parameter Value Continuations tests
725+
726+
test('RFC 2231 - simple continuation', async t => {
727+
const mail = Buffer.from(
728+
'Content-Type: text/plain\r\n' +
729+
"Content-Disposition: attachment;\r\n filename*0=long;\r\n filename*1=file;\r\n filename*2=name.txt\r\n\r\n" +
730+
'Content'
731+
);
732+
const email = await PostalMime.parse(mail);
733+
assert.strictEqual(email.attachments[0].filename, 'longfilename.txt');
734+
});
735+
736+
test('RFC 2231 - encoded continuation with charset', async t => {
737+
const mail = Buffer.from(
738+
'Content-Type: text/plain\r\n' +
739+
"Content-Disposition: attachment;\r\n filename*0*=utf-8''%C3%A4bc;\r\n filename*1*=%C3%B6%C3%BC.txt\r\n\r\n" +
740+
'Content'
741+
);
742+
const email = await PostalMime.parse(mail);
743+
// Should decode UTF-8 percent-encoded characters (ä, ö, ü)
744+
assert.strictEqual(email.attachments[0].filename, 'äbcöü.txt');
745+
});
746+
747+
test('RFC 2231 - single encoded parameter (no continuation)', async t => {
748+
const mail = Buffer.from(
749+
'Content-Type: text/plain\r\n' +
750+
"Content-Disposition: attachment; filename*=utf-8''%E2%9C%93check.txt\r\n\r\n" +
751+
'Content'
752+
);
753+
const email = await PostalMime.parse(mail);
754+
// Checkmark character
755+
assert.ok(email.attachments[0].filename.includes('check.txt'));
756+
});
757+
758+
// Boundary edge case tests
759+
760+
test('Boundary - empty MIME part', async t => {
761+
const mail = Buffer.from(
762+
'Content-Type: multipart/mixed; boundary="bound"\r\n\r\n' +
763+
'--bound\r\n' +
764+
'Content-Type: text/plain\r\n\r\n' +
765+
'\r\n' + // empty content
766+
'--bound\r\n' +
767+
'Content-Type: text/plain\r\n\r\n' +
768+
'Second part\r\n' +
769+
'--bound--\r\n'
770+
);
771+
const email = await PostalMime.parse(mail);
772+
assert.ok(email.text.includes('Second part'));
773+
});
774+
775+
test('Boundary - with preamble and epilogue', async t => {
776+
const mail = Buffer.from(
777+
'Content-Type: multipart/mixed; boundary="bound"\r\n\r\n' +
778+
'This is the preamble (should be ignored)\r\n' +
779+
'--bound\r\n' +
780+
'Content-Type: text/plain\r\n\r\n' +
781+
'Body content\r\n' +
782+
'--bound--\r\n' +
783+
'This is the epilogue (should be ignored)\r\n'
784+
);
785+
const email = await PostalMime.parse(mail);
786+
assert.strictEqual(email.text.trim(), 'Body content');
787+
assert.ok(!email.text.includes('preamble'));
788+
assert.ok(!email.text.includes('epilogue'));
789+
});
790+
791+
test('Boundary - special characters in boundary string', async t => {
792+
const mail = Buffer.from(
793+
"Content-Type: multipart/mixed; boundary=\"=_Part_123_+special'\"\r\n\r\n" +
794+
"--=_Part_123_+special'\r\n" +
795+
'Content-Type: text/plain\r\n\r\n' +
796+
'Content\r\n' +
797+
"--=_Part_123_+special'--\r\n"
798+
);
799+
const email = await PostalMime.parse(mail);
800+
assert.strictEqual(email.text.trim(), 'Content');
801+
});
802+
803+
test('Boundary - very long boundary string', async t => {
804+
const boundary = 'a'.repeat(70); // Max allowed is 70 chars
805+
const mail = Buffer.from(
806+
`Content-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n` +
807+
`--${boundary}\r\n` +
808+
'Content-Type: text/plain\r\n\r\n' +
809+
'Content\r\n' +
810+
`--${boundary}--\r\n`
811+
);
812+
const email = await PostalMime.parse(mail);
813+
assert.strictEqual(email.text.trim(), 'Content');
814+
});
815+
816+
// Nested multipart structure tests
817+
818+
test('Nested multipart - alternative inside mixed', async t => {
819+
const mail = Buffer.from(
820+
'Content-Type: multipart/mixed; boundary="outer"\r\n\r\n' +
821+
'--outer\r\n' +
822+
'Content-Type: multipart/alternative; boundary="inner"\r\n\r\n' +
823+
'--inner\r\n' +
824+
'Content-Type: text/plain\r\n\r\n' +
825+
'Plain text\r\n' +
826+
'--inner\r\n' +
827+
'Content-Type: text/html\r\n\r\n' +
828+
'<p>HTML text</p>\r\n' +
829+
'--inner--\r\n' +
830+
'--outer\r\n' +
831+
'Content-Type: text/plain\r\n' +
832+
'Content-Disposition: attachment; filename="test.txt"\r\n\r\n' +
833+
'Attachment content\r\n' +
834+
'--outer--\r\n'
835+
);
836+
const email = await PostalMime.parse(mail);
837+
assert.ok(email.text.includes('Plain text'));
838+
assert.ok(email.html.includes('<p>HTML text</p>'));
839+
assert.strictEqual(email.attachments.length, 1);
840+
assert.strictEqual(email.attachments[0].filename, 'test.txt');
841+
});
842+
843+
test('Nested multipart - related with inline image', async t => {
844+
const mail = Buffer.from(
845+
'Content-Type: multipart/related; boundary="related"\r\n\r\n' +
846+
'--related\r\n' +
847+
'Content-Type: text/html\r\n\r\n' +
848+
'<html><body><img src="cid:image1"/></body></html>\r\n' +
849+
'--related\r\n' +
850+
'Content-Type: image/png\r\n' +
851+
'Content-ID: <image1>\r\n' +
852+
'Content-Transfer-Encoding: base64\r\n\r\n' +
853+
'iVBORw0KGgo=\r\n' +
854+
'--related--\r\n'
855+
);
856+
const email = await PostalMime.parse(mail);
857+
assert.ok(email.html.includes('cid:image1'));
858+
assert.strictEqual(email.attachments.length, 1);
859+
assert.strictEqual(email.attachments[0].contentId, '<image1>');
860+
assert.strictEqual(email.attachments[0].related, true);
861+
});
862+
863+
// Malformed email permissive parsing tests
864+
865+
test('Malformed - missing final boundary terminator', async t => {
866+
const mail = Buffer.from(
867+
'Content-Type: multipart/mixed; boundary="bound"\r\n\r\n' +
868+
'--bound\r\n' +
869+
'Content-Type: text/plain\r\n\r\n' +
870+
'Content without terminator\r\n'
871+
// Missing --bound--
872+
);
873+
const email = await PostalMime.parse(mail);
874+
assert.ok(email.text.includes('Content without terminator'));
875+
});
876+
877+
test('Malformed - extra whitespace in headers', async t => {
878+
const mail = Buffer.from(
879+
'Content-Type: text/plain; charset=utf-8 \r\n\r\n' + // extra spaces
880+
'Content'
881+
);
882+
const email = await PostalMime.parse(mail);
883+
assert.strictEqual(email.text.trim(), 'Content');
884+
});
885+
886+
test('Malformed - LF only line endings (no CR)', async t => {
887+
const mail = Buffer.from('Content-Type: text/plain\n\nBody with LF only');
888+
const email = await PostalMime.parse(mail);
889+
assert.strictEqual(email.text.trim(), 'Body with LF only');
890+
});
891+
892+
test('Malformed - mixed CRLF and LF line endings', async t => {
893+
const mail = Buffer.from('Content-Type: text/plain\r\n\nBody\r\nMore body\n');
894+
const email = await PostalMime.parse(mail);
895+
assert.ok(email.text.includes('Body'));
896+
assert.ok(email.text.includes('More body'));
897+
});
898+
899+
test('Malformed - header without value', async t => {
900+
const mail = Buffer.from('Content-Type: text/plain\r\nX-Empty-Header:\r\n\r\nBody');
901+
const email = await PostalMime.parse(mail);
902+
assert.strictEqual(email.text.trim(), 'Body');
903+
const emptyHeader = email.headers.find(h => h.key === 'x-empty-header');
904+
assert.ok(emptyHeader);
905+
assert.strictEqual(emptyHeader.value, '');
906+
});
907+
908+
test('Malformed - duplicate Content-Type headers (last wins)', async t => {
909+
const mail = Buffer.from(
910+
'Content-Type: text/plain\r\n' + 'Content-Type: text/html\r\n\r\n' + '<p>Content</p>'
911+
);
912+
const email = await PostalMime.parse(mail);
913+
// Last Content-Type is used (headers processed in reverse order)
914+
assert.ok(email.html);
915+
assert.ok(email.html.includes('Content'));
916+
});
917+
918+
// Additional edge case tests
919+
920+
test('Edge case - empty email', async t => {
921+
const mail = Buffer.from('');
922+
const email = await PostalMime.parse(mail);
923+
assert.ok(email);
924+
assert.strictEqual(email.text, undefined);
925+
});
926+
927+
test('Edge case - headers only, no body', async t => {
928+
const mail = Buffer.from('From: test@example.com\r\nSubject: Test\r\n');
929+
const email = await PostalMime.parse(mail);
930+
assert.strictEqual(email.from.address, 'test@example.com');
931+
assert.strictEqual(email.subject, 'Test');
932+
});
933+
934+
test('Edge case - body only, no headers', async t => {
935+
const mail = Buffer.from('\r\nJust a body');
936+
const email = await PostalMime.parse(mail);
937+
assert.ok(email.text.includes('Just a body'));
938+
});
939+
940+
test('Edge case - very long subject line (folded)', async t => {
941+
const longSubject = 'Word '.repeat(100);
942+
const mail = Buffer.from(`Subject: ${longSubject}\r\n\r\nBody`);
943+
const email = await PostalMime.parse(mail);
944+
assert.ok(email.subject.includes('Word'));
945+
});
946+
947+
test('Edge case - Content-Type with multiple parameters', async t => {
948+
const mail = Buffer.from(
949+
'Content-Type: text/plain; charset=utf-8; format=flowed; delsp=yes; reply-type=original\r\n\r\n' +
950+
'Flowed \r\n' +
951+
'text\r\n'
952+
);
953+
const email = await PostalMime.parse(mail);
954+
assert.strictEqual(email.text, 'Flowedtext\n');
955+
});
956+
957+
test('Edge case - attachment with no filename', async t => {
958+
const mail = Buffer.from(
959+
'Content-Type: multipart/mixed; boundary="bound"\r\n\r\n' +
960+
'--bound\r\n' +
961+
'Content-Type: application/octet-stream\r\n' +
962+
'Content-Transfer-Encoding: base64\r\n\r\n' +
963+
'SGVsbG8=\r\n' +
964+
'--bound--\r\n'
965+
);
966+
const email = await PostalMime.parse(mail);
967+
assert.strictEqual(email.attachments.length, 1);
968+
assert.strictEqual(email.attachments[0].filename, null);
969+
});
970+
971+
test('Edge case - multiple text/plain parts concatenated', async t => {
972+
const mail = Buffer.from(
973+
'Content-Type: multipart/mixed; boundary="bound"\r\n\r\n' +
974+
'--bound\r\n' +
975+
'Content-Type: text/plain\r\n\r\n' +
976+
'First part\r\n' +
977+
'--bound\r\n' +
978+
'Content-Type: text/plain\r\n\r\n' +
979+
'Second part\r\n' +
980+
'--bound--\r\n'
981+
);
982+
const email = await PostalMime.parse(mail);
983+
assert.ok(email.text.includes('First part'));
984+
assert.ok(email.text.includes('Second part'));
985+
});
986+
987+
test('Edge case - Content-Transfer-Encoding case insensitive', async t => {
988+
const mail = Buffer.from(
989+
'Content-Type: text/plain\r\n' + 'Content-Transfer-Encoding: BASE64\r\n\r\n' + 'SGVsbG8gV29ybGQ='
990+
);
991+
const email = await PostalMime.parse(mail);
992+
assert.strictEqual(email.text.trim(), 'Hello World');
993+
});
994+
995+
test('Edge case - quoted-printable with literal equals sign', async t => {
996+
const mail = Buffer.from(
997+
'Content-Type: text/plain\r\n' + 'Content-Transfer-Encoding: quoted-printable\r\n\r\n' + '1+1=3D2'
998+
);
999+
const email = await PostalMime.parse(mail);
1000+
assert.strictEqual(email.text.trim(), '1+1=2');
1001+
});

0 commit comments

Comments
 (0)