1+ import { MockProxy , mock } from "jest-mock-extended"
2+ import { when } from "jest-when"
13import { GMailMocks } from "../../test/mocks/GMailMocks"
24import { MockFactory , Mocks } from "../../test/mocks/MockFactory"
35import { GmailExportAdapter } from "./GmailExportAdapter"
46
7+ // Helper Mocks
58let mocks : Mocks
9+ let urlFetchApp : MockProxy < GoogleAppsScript . URL_Fetch . UrlFetchApp >
10+ let utilities : MockProxy < GoogleAppsScript . Utilities . Utilities >
611let adapter : GmailExportAdapter
12+ let avatarBlob : MockProxy < GoogleAppsScript . Base . Blob >
13+ let avatarUrlResponse : MockProxy < GoogleAppsScript . URL_Fetch . HTTPResponse >
714
8- beforeEach ( ( ) => {
15+ beforeAll ( ( ) => {
916 mocks = MockFactory . newMocks ( )
17+ urlFetchApp = mocks . envContext . env
18+ . urlFetchApp as MockProxy < GoogleAppsScript . URL_Fetch . UrlFetchApp >
19+ utilities = mocks . envContext . env
20+ . utilities as MockProxy < GoogleAppsScript . Utilities . Utilities >
1021 adapter = new GmailExportAdapter (
1122 mocks . envContext ,
1223 mocks . processingContext . proc . config . settings ,
1324 )
25+ avatarBlob = mock < GoogleAppsScript . Base . Blob > ( )
26+ avatarUrlResponse = mock < GoogleAppsScript . URL_Fetch . HTTPResponse > ( )
27+ } )
28+
29+ beforeEach ( ( ) => {
30+ jest . clearAllMocks ( )
1431} )
1532
1633const emailHeader =
@@ -30,6 +47,99 @@ it("should generate a HTML message without header", () => {
3047 expect ( actual ) . not . toContain ( emailHeader )
3148} )
3249
50+ describe ( "generateMessageHtmlHeader" , ( ) => {
51+ it ( "should generate a HTML header with avatar" , ( ) => {
52+ // Arrange
53+ const senderEmail = "test1@example.com"
54+ const avatarContentType = "image/png"
55+ const avatarBytes = [ 1 , 2 , 3 , 4 ] // Some dummy bytes
56+ const avatarBase64 = "AQIDBA==" // Base64 representation of [1, 2, 3, 4]
57+ const expectedDataUri = `data:${ avatarContentType } ;base64,${ avatarBase64 } `
58+
59+ const message =
60+ mocks . message as MockProxy < GoogleAppsScript . Gmail . GmailMessage >
61+
62+ urlFetchApp . fetch
63+ . mockReturnValue ( avatarUrlResponse )
64+ . mockName ( "fetch-avatar" )
65+ avatarUrlResponse . getBlob . mockReturnValue ( avatarBlob )
66+ avatarUrlResponse . getResponseCode . mockReturnValue ( 200 ) // Simulate successful fetch
67+ avatarBlob . getContentType . mockReturnValue ( avatarContentType )
68+ avatarBlob . getBytes . mockReturnValue ( avatarBytes )
69+ utilities . base64Encode . mockReturnValue ( avatarBase64 )
70+ message . getFrom . mockReturnValue ( senderEmail )
71+
72+ // Act
73+ const actual = adapter . generateMessageHtmlHeader ( mocks . message , {
74+ embedAvatar : true ,
75+ includeHeader : true ,
76+ } )
77+
78+ // Assert
79+ expect ( urlFetchApp . fetch ) . toHaveBeenCalled ( )
80+ expect ( utilities . base64Encode ) . toHaveBeenCalledWith ( avatarBytes )
81+ expect ( actual ) . toContain (
82+ `<dd class="avatar"><img src="${ expectedDataUri } " /></dd>` ,
83+ )
84+ expect ( actual ) . toContain (
85+ `<a href="mailto:${ senderEmail } ">${ senderEmail } </a>` ,
86+ )
87+ } )
88+
89+ it ( "should generate a HTML header without avatar if none is available" , ( ) => {
90+ // Arrange
91+ const senderEmail = "test2@example.com"
92+ const avatarUrlResponse : MockProxy < GoogleAppsScript . URL_Fetch . HTTPResponse > =
93+ mock < GoogleAppsScript . URL_Fetch . HTTPResponse > ( )
94+ const message =
95+ mocks . message as MockProxy < GoogleAppsScript . Gmail . GmailMessage >
96+
97+ urlFetchApp . fetch
98+ . mockReturnValue ( avatarUrlResponse )
99+ . mockName ( "fetch-avatar" )
100+ avatarUrlResponse . getResponseCode . mockReturnValue ( 404 ) // Simulate failed fetch
101+ message . getFrom . mockReturnValue ( senderEmail )
102+
103+ // Act
104+ const actual = adapter . generateMessageHtmlHeader ( mocks . message , {
105+ embedAvatar : true ,
106+ includeHeader : true ,
107+ } )
108+
109+ // Assert
110+ expect ( urlFetchApp . fetch ) . toHaveBeenCalled ( )
111+ expect ( avatarUrlResponse . getBlob ) . not . toHaveBeenCalled ( )
112+ expect ( utilities . base64Encode ) . not . toHaveBeenCalled ( )
113+ expect ( actual ) . not . toContain ( `<dd class="avatar">` )
114+ expect ( actual ) . toContain (
115+ `<a href="mailto:${ senderEmail } ">${ senderEmail } </a>` ,
116+ )
117+ } )
118+
119+ it ( "should include CC and BCC addresses in the header" , ( ) => {
120+ // Arrange
121+ const message =
122+ mocks . message as MockProxy < GoogleAppsScript . Gmail . GmailMessage >
123+ const ccEmail = "cc@example.com"
124+ const bccEmail = "bcc@example.com"
125+ message . getCc . mockReturnValue ( ccEmail )
126+ message . getBcc . mockReturnValue ( bccEmail )
127+
128+ // Act
129+ const actual = adapter . generateMessageHtmlHeader ( message , {
130+ includeHeader : true ,
131+ } )
132+
133+ // Assert
134+ expect ( actual ) . toContain (
135+ `<dt>cc:</dt><dd><a href="mailto:${ ccEmail } ">${ ccEmail } </a></dd>` ,
136+ )
137+ expect ( actual ) . toContain (
138+ `<dt>bcc:</dt><dd><a href="mailto:${ bccEmail } ">${ bccEmail } </a></dd>` ,
139+ )
140+ } )
141+ } )
142+
33143it ( "should process data urls in HTML" , ( ) => {
34144 const img =
35145 '<img width="16" height="16" alt="tick" src="data:image/gif;base64,R0lGODdhEAAQAMwAAPj7+FmhUYjNfGuxYYDJdYTIeanOpT+DOTuANXi/bGOrWj6CONzv2sPjv2CmV1unU4zPgISg6DJnJ3ImTh8Mtbs00aNP1CZSGy0YqLEn47RgXW8amasW7XWsmmvX2iuXiwAAAAAEAAQAAAFVyAgjmRpnihqGCkpDQPbGkNUOFk6DZqgHCNGg2T4QAQBoIiRSAwBE4VA4FACKgkB5NGReASFZEmxsQ0whPDi9BiACYQAInXhwOUtgCUQoORFCGt/g4QAIQA7">'
@@ -45,3 +155,153 @@ it("should process data urls in HTML", () => {
45155 } )
46156 expect ( actual ) . toContain ( img )
47157} )
158+
159+ describe ( "Image Embedding" , ( ) => {
160+ const remoteImageUrl = "http://example.com/remote.png"
161+ const remoteImageContentType = "image/png"
162+ const remoteImageBytes : number [ ] = [ 5 , 6 , 7 , 8 ]
163+ const remoteImageBase64 = "BQYHCA=="
164+ const remoteImageDataUri = `data:${ remoteImageContentType } ;base64,${ remoteImageBase64 } `
165+
166+ const inlineImageCid = "?view=att&th=inline_123"
167+ const inlineImageContentType = "image/jpeg"
168+ const inlineImageBytes : number [ ] = [ 9 , 10 , 11 , 12 ]
169+ const inlineImageBase64 = "CQoLDA=="
170+ const inlineImageDataUri = `data:${ inlineImageContentType } ;base64,${ inlineImageBase64 } `
171+
172+ const failedImageUrl = "http://example.com/failed.png"
173+
174+ beforeEach ( ( ) => {
175+ const remoteResponse = mock < GoogleAppsScript . URL_Fetch . HTTPResponse > ( )
176+ const failedResponse = mock < GoogleAppsScript . URL_Fetch . HTTPResponse > ( )
177+ const remoteBlob = mock < GoogleAppsScript . Base . Blob > ( )
178+ const failedBlob = mock < GoogleAppsScript . Base . Blob > ( )
179+ when ( urlFetchApp . fetch )
180+ . calledWith ( remoteImageUrl , expect . anything ( ) )
181+ . mockReturnValue ( remoteResponse )
182+ . calledWith ( failedImageUrl , expect . anything ( ) )
183+ . mockReturnValue ( failedResponse )
184+ remoteResponse . getResponseCode . mockReturnValue ( 200 )
185+ remoteResponse . getBlob . mockReturnValue ( remoteBlob )
186+ remoteBlob . getContentType . mockReturnValue ( remoteImageContentType )
187+ remoteBlob . getBytes . mockReturnValue ( remoteImageBytes )
188+ failedResponse . getResponseCode . mockReturnValue ( 404 )
189+ failedResponse . getBlob . mockReturnValue ( failedBlob )
190+ failedBlob . getContentType . mockReturnValue ( null )
191+ failedBlob . getBytes . mockReturnValue ( [ ] )
192+ utilities . base64Encode . mockImplementation (
193+ ( bytes : string , _charset : unknown ) => {
194+ if ( String ( remoteImageBytes ) === String ( bytes ) ) {
195+ return remoteImageBase64
196+ }
197+ if ( String ( inlineImageBytes ) === String ( bytes ) ) {
198+ return inlineImageBase64
199+ }
200+ return ""
201+ } ,
202+ )
203+ const inlineBlob = mock <
204+ GoogleAppsScript . Base . Blob & GoogleAppsScript . Gmail . GmailAttachment
205+ > ( )
206+ inlineBlob . getContentType . mockReturnValue ( inlineImageContentType )
207+ inlineBlob . getBytes . mockReturnValue ( inlineImageBytes )
208+ inlineBlob . copyBlob . mockReturnValue ( inlineBlob ) // Return self for copyBlob
209+ } )
210+
211+ it ( "should embed remote images in <img> tags" , ( ) => {
212+ const body = `<p>Remote image: <img src="${ remoteImageUrl } "></p>`
213+ const message = GMailMocks . newMessageMock ( { body } )
214+ const actual = adapter . generateMessagesHtml ( [ message ] , {
215+ embedRemoteImages : true ,
216+ includeHeader : false ,
217+ } )
218+ expect ( actual ) . toContain ( `<img src="${ remoteImageDataUri } ">` )
219+ expect ( urlFetchApp . fetch ) . toHaveBeenCalledWith (
220+ remoteImageUrl ,
221+ expect . anything ( ) ,
222+ )
223+ expect ( utilities . base64Encode ) . toHaveBeenCalledWith ( remoteImageBytes )
224+ } )
225+
226+ it ( "should embed remote images in style attributes" , ( ) => {
227+ const body = `<p style="background: url('${ remoteImageUrl } ')">Styled</p>`
228+ const message = GMailMocks . newMessageMock ( { body } )
229+ const actual = adapter . generateMessagesHtml ( [ message ] , {
230+ embedRemoteImages : true ,
231+ includeHeader : false ,
232+ } )
233+ expect ( actual ) . toContain ( `style="background: url('${ remoteImageDataUri } ')"` )
234+ expect ( urlFetchApp . fetch ) . toHaveBeenCalledWith (
235+ remoteImageUrl ,
236+ expect . anything ( ) ,
237+ )
238+ expect ( utilities . base64Encode ) . toHaveBeenCalledWith ( remoteImageBytes )
239+ } )
240+
241+ it ( "should embed remote images in <style> tags" , ( ) => {
242+ const body = `<style>.bg { background-image: url("${ remoteImageUrl } "); }</style><p class="bg">Styled</p>`
243+ const message = GMailMocks . newMessageMock ( { body } )
244+ const actual = adapter . generateMessagesHtml ( [ message ] , {
245+ embedRemoteImages : true ,
246+ includeHeader : false ,
247+ } )
248+ expect ( actual ) . toContain ( `background-image: url("${ remoteImageDataUri } ");` )
249+ expect ( urlFetchApp . fetch ) . toHaveBeenCalledWith (
250+ remoteImageUrl ,
251+ expect . anything ( ) ,
252+ )
253+ expect ( utilities . base64Encode ) . toHaveBeenCalledWith ( remoteImageBytes )
254+ } )
255+
256+ it ( "should embed inline images in <img> tags" , ( ) => {
257+ const body = `<p>Inline image: <img src="${ inlineImageCid } "></p>`
258+ const message = GMailMocks . newMessageMock ( { body } )
259+ const inlineBlob = mock <
260+ GoogleAppsScript . Gmail . GmailAttachment & GoogleAppsScript . Base . Blob
261+ > ( )
262+ inlineBlob . getContentType . mockReturnValue ( inlineImageContentType )
263+ inlineBlob . getBytes . mockReturnValue ( inlineImageBytes )
264+ inlineBlob . copyBlob . mockReturnValue ( inlineBlob )
265+ message . getAttachments . mockReturnValue ( [ inlineBlob ] )
266+ const actual = adapter . generateMessagesHtml ( [ message ] , {
267+ embedInlineImages : true ,
268+ includeHeader : false ,
269+ } )
270+ expect ( actual ) . toContain ( `<img src="${ inlineImageDataUri } ">` )
271+ expect (
272+ ( message as MockProxy < GoogleAppsScript . Gmail . GmailMessage > )
273+ . getAttachments ,
274+ ) . toHaveBeenCalledWith ( {
275+ includeInlineImages : true ,
276+ includeAttachments : false ,
277+ } )
278+ expect ( utilities . base64Encode ) . toHaveBeenCalledWith ( inlineImageBytes )
279+ } )
280+
281+ it ( "should keep original src if remote image fetch fails" , ( ) => {
282+ const body = `<p>Remote image: <img src="${ failedImageUrl } "></p>`
283+ const message = GMailMocks . newMessageMock ( { body } )
284+ const actual = adapter . generateMessagesHtml ( [ message ] , {
285+ embedRemoteImages : true ,
286+ includeHeader : false ,
287+ } )
288+ expect ( actual ) . toContain ( `<img src="${ failedImageUrl } ">` )
289+ } )
290+
291+ it ( "should keep original src if inline image is missing" , ( ) => {
292+ const body = `<p>Inline image: <img src="${ inlineImageCid } "></p>`
293+ const message = GMailMocks . newMessageMock ( { body } )
294+ message . getAttachments . mockReturnValue ( [ ] ) // No inline attachments found
295+ const actual = adapter . generateMessagesHtml ( [ message ] , {
296+ embedInlineImages : true ,
297+ includeHeader : false ,
298+ } )
299+ expect ( actual ) . toContain ( `<img src="${ inlineImageCid } ">` )
300+ expect ( utilities . base64Encode ) . not . toHaveBeenCalledWith ( inlineImageBytes ) // Ensure it wasn't called for this image
301+ } )
302+ } )
303+
304+ // TODO: Add tests for Attachment Handling (includeAttachments, embedAttachments)
305+ // - Test embedding image attachments
306+ // - Test listing non-image attachments when embedding is on
307+ // - Test listing all attachments when embedding is off
0 commit comments