@@ -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