@@ -15,6 +15,20 @@ function decodeSimpleMessage(encoded: string): string {
1515 return Buffer . from ( encoded , 'base64url' ) . toString ( 'utf-8' )
1616}
1717
18+ /**
19+ * Extract and base64-decode the body of a specific MIME part identified by its
20+ * Content-Type prefix (e.g. `text/plain`, `text/html`). Returns the decoded
21+ * UTF-8 string.
22+ */
23+ function decodePart ( mime : string , contentTypePrefix : string ) : string {
24+ const partRegex = new RegExp (
25+ `Content-Type: ${ contentTypePrefix } [^\\n]*\\nContent-Transfer-Encoding: base64\\n\\n([\\s\\S]*?)\\n\\n--`
26+ )
27+ const match = mime . match ( partRegex )
28+ if ( ! match ) throw new Error ( `No ${ contentTypePrefix } part found` )
29+ return Buffer . from ( match [ 1 ] . replace ( / \n / g, '' ) , 'base64' ) . toString ( 'utf-8' )
30+ }
31+
1832describe ( 'encodeRfc2047' , ( ) => {
1933 it ( 'returns ASCII text unchanged' , ( ) => {
2034 expect ( encodeRfc2047 ( 'Simple ASCII Subject' ) ) . toBe ( 'Simple ASCII Subject' )
@@ -81,6 +95,12 @@ describe('htmlToPlainText', () => {
8195 '< is the literal < entity'
8296 )
8397 } )
98+
99+ it ( 'decodes decimal and hexadecimal numeric entities' , ( ) => {
100+ expect ( htmlToPlainText ( '<p>“hi”  and’s</p>' ) ) . toBe (
101+ '\u201chi\u201d \u00a0and\u2019s'
102+ )
103+ } )
84104} )
85105
86106describe ( 'buildSimpleEmailMessage' , ( ) => {
@@ -96,8 +116,21 @@ describe('buildSimpleEmailMessage', () => {
96116 const htmlIdx = decoded . indexOf ( 'text/html' )
97117 expect ( plainIdx ) . toBeGreaterThan ( - 1 )
98118 expect ( htmlIdx ) . toBeGreaterThan ( plainIdx )
99- expect ( decoded ) . toContain ( 'Hi Janice,' )
100- expect ( decoded ) . toContain ( '<p>Hi Janice,</p>' )
119+ expect ( decodePart ( decoded , 'text/plain' ) ) . toBe ( 'Hi Janice,\n\nQuick question.' )
120+ expect ( decodePart ( decoded , 'text/html' ) ) . toContain ( '<p>Hi Janice,</p>' )
121+ } )
122+
123+ it ( 'encodes bodies as base64 so UTF-8 (emoji, accents) round-trips cleanly' , ( ) => {
124+ const body = 'Café 🎉 — résumé'
125+ const encoded = buildSimpleEmailMessage ( {
126+ to : 'a@example.com' ,
127+ subject : 'Hi' ,
128+ body,
129+ } )
130+ const decoded = decodeSimpleMessage ( encoded )
131+ expect ( decoded ) . toContain ( 'Content-Transfer-Encoding: base64' )
132+ expect ( decodePart ( decoded , 'text/plain' ) ) . toBe ( body )
133+ expect ( decodePart ( decoded , 'text/html' ) ) . toContain ( 'Café 🎉 — résumé' )
101134 } )
102135
103136 it ( 'uses the supplied HTML body and derives a plain-text fallback when contentType is html' , ( ) => {
@@ -108,8 +141,8 @@ describe('buildSimpleEmailMessage', () => {
108141 contentType : 'html' ,
109142 } )
110143 const decoded = decodeSimpleMessage ( encoded )
111- expect ( decoded ) . toContain ( '<p>Hello <b>there</b></p>' )
112- expect ( decoded ) . toContain ( 'Hello there' )
144+ expect ( decodePart ( decoded , 'text/html' ) ) . toBe ( '<p>Hello <b>there</b></p>' )
145+ expect ( decodePart ( decoded , 'text/plain' ) ) . toBe ( 'Hello there' )
113146 } )
114147
115148 it ( 'includes threading headers when replying' , ( ) => {
@@ -142,7 +175,8 @@ describe('buildMimeMessage', () => {
142175 expect ( message ) . toMatch ( / C o n t e n t - T y p e : m u l t i p a r t \/ m i x e d ; b o u n d a r y = " ( [ ^ " ] + ) " / )
143176 expect ( message ) . toMatch ( / C o n t e n t - T y p e : m u l t i p a r t \/ a l t e r n a t i v e ; b o u n d a r y = " ( [ ^ " ] + ) " / )
144177 expect ( message ) . toContain ( 'Content-Disposition: attachment; filename="note.txt"' )
145- expect ( message ) . toContain ( '<p>Hello</p>' )
178+ expect ( decodePart ( message , 'text/plain' ) ) . toBe ( 'Hello' )
179+ expect ( decodePart ( message , 'text/html' ) ) . toContain ( '<p>Hello</p>' )
146180 } )
147181
148182 it ( 'emits multipart/alternative without multipart/mixed when no attachments' , ( ) => {
0 commit comments