diff --git a/extension/chrome/elements/compose-modules/compose-send-btn-module.ts b/extension/chrome/elements/compose-modules/compose-send-btn-module.ts index 0beb214be20..c273834303a 100644 --- a/extension/chrome/elements/compose-modules/compose-send-btn-module.ts +++ b/extension/chrome/elements/compose-modules/compose-send-btn-module.ts @@ -2,9 +2,12 @@ 'use strict'; +import * as DOMPurify from 'dompurify'; + import { ApiErr } from '../../../js/common/api/shared/api-error.js'; import { Attachment } from '../../../js/common/core/attachment.js'; import { BrowserMsg } from '../../../js/common/browser/browser-msg.js'; +import { Buf } from '../../../js/common/core/buf.js'; import { Catch } from '../../../js/common/platform/catch.js'; import { ComposeSendBtnPopoverModule } from './compose-send-btn-popover-module.js'; import { GeneralMailFormatter } from './formatters/general-mail-formatter.js'; @@ -138,12 +141,57 @@ export class ComposeSendBtnModule extends ViewModule { a.type = 'application/octet-stream'; // so that Enigmail+Thunderbird does not attempt to display without decrypting } } + if (choices.richtext && !choices.encrypt && !choices.sign && msg.body['text/html']) { + // extract inline images of plain rich-text messages (#3256) + // todo - also apply to rich text signed-only messages + const { htmlWithCidImages, imgAttachments } = this.extractInlineImagesToAttachments(msg.body['text/html']); + msg.body['text/html'] = htmlWithCidImages; + msg.attachments.push(...imgAttachments); + } if (this.view.myPubkeyModule.shouldAttach()) { msg.attachments.push(Attachment.keyinfoAsPubkeyAtt(senderKi)); } await this.addNamesToMsg(msg); } + private extractInlineImagesToAttachments = (html: string) => { + const imgAttachments: Attachment[] = []; + DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (!node) { + return; + } + if ('src' in node) { + const img: Element = node; + const src = img.getAttribute('src') as string; + const { mimeType, data } = this.parseInlineImageSrc(src); + const imgAttachment = new Attachment({ + cid: Attachment.attachmentId(), + name: img.getAttribute('name') || '', + type: mimeType, + data: Buf.fromBase64Str(data), + inline: true + }); + img.setAttribute('src', `cid:${imgAttachment.cid}`); + imgAttachments.push(imgAttachment); + } + }); + const htmlWithCidImages = DOMPurify.sanitize(html); + DOMPurify.removeAllHooks(); + return { htmlWithCidImages, imgAttachments }; + } + + private parseInlineImageSrc = (src: string) => { + let mimeType; + let data = ''; + const parts = src.split(/[:;,]/); + if (parts.length === 4 && parts[0] === 'data' && parts[1].match(/^image\/\w+/) && parts[2] === 'base64') { + mimeType = parts[1]; + data = parts[3]; + } + return { mimeType, data }; + } + + private doSendMsg = async (msg: SendableMsg) => { // if this is a password-encrypted message, then we've already shown progress for uploading to backend // and this requests represents second half of uploadable effort. Else this represents all (no previous heavy requests) diff --git a/extension/js/common/core/attachment.ts b/extension/js/common/core/attachment.ts index 3894cbc90be..0a4d702113a 100644 --- a/extension/js/common/core/attachment.ts +++ b/extension/js/common/core/attachment.ts @@ -3,6 +3,7 @@ 'use strict'; import { Buf } from './buf.js'; +import { Str } from './common.js'; type Attachment$treatAs = "publicKey" | 'privateKey' | "encryptedMsg" | "hidden" | "signature" | "encryptedFile" | "plainFile"; export type AttachmentMeta = { @@ -43,6 +44,10 @@ export class Attachment { return trimmed.replace(/[\u0000\u002f\u005c]/g, '_').replace(/__+/g, '_'); } + public static attachmentId = (): string => { + return `f_${Str.sloppyRandom(30)}@flowcrypt`; + } + constructor({ data, type, name, length, url, inline, id, msgId, treatAs, cid, contentDescription }: AttachmentMeta) { if (typeof data === 'undefined' && typeof url === 'undefined' && typeof id === 'undefined') { throw new Error('Attachment: one of data|url|id has to be set'); diff --git a/extension/js/common/core/mime.ts b/extension/js/common/core/mime.ts index bd4f7bb5642..3294e2e04c5 100644 --- a/extension/js/common/core/mime.ts +++ b/extension/js/common/core/mime.ts @@ -312,7 +312,7 @@ export class Mime { private static createAttNode = (attachment: Attachment): any => { // todo: MimeBuilder types const type = `${attachment.type}; name="${attachment.name}"`; - const id = `f_${Str.sloppyRandom(30)}@flowcrypt`; + const id = attachment.cid || Attachment.attachmentId(); const header: Dict = {}; if (attachment.contentDescription) { header['Content-Description'] = attachment.contentDescription; diff --git a/test/source/mock/google/google-data.ts b/test/source/mock/google/google-data.ts index dafa247ee68..a4f3d814bcf 100644 --- a/test/source/mock/google/google-data.ts +++ b/test/source/mock/google/google-data.ts @@ -185,8 +185,9 @@ export class GoogleData { } } let body: GmailMsg$payload$body; - if (parsedMail.text) { - body = { data: parsedMail.text, size: parsedMail.text.length }; + const htmlOrText = parsedMail.html || parsedMail.text; + if (htmlOrText) { + body = { data: htmlOrText, size: htmlOrText.length }; } else if (bodyContentAtt) { body = { attachmentId: bodyContentAtt.id, size: bodyContentAtt.size }; } else { diff --git a/test/source/tests/compose.ts b/test/source/tests/compose.ts index 634cadf89c1..5880878c255 100644 --- a/test/source/tests/compose.ts +++ b/test/source/tests/compose.ts @@ -705,14 +705,18 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te expect(await composePage.readHtml('@input-body')).to.include('
مرحبا
'); })); - ava.default('compose - sending and rendering encrypted message with image ', testWithBrowser('compatibility', async (t, browser) => { + ava.default('compose - sending and rendering encrypted message with image', testWithBrowser('compatibility', async (t, browser) => { await sendImgAndVerifyPresentInSentMsg(t, browser, 'encrypt'); })); - ava.default('compose - sending and rendering signed message with image ', testWithBrowser('compatibility', async (t, browser) => { + ava.default('compose - sending and rendering signed message with image', testWithBrowser('compatibility', async (t, browser) => { await sendImgAndVerifyPresentInSentMsg(t, browser, 'sign'); })); + ava.default('compose - sending and rendering plain message with image', testWithBrowser('compatibility', async (t, browser) => { + await sendImgAndVerifyPresentInSentMsg(t, browser, 'plain'); + })); + ava.default('compose - sending and rendering message with U+10000 code points', testWithBrowser('compatibility', async (t, browser) => { const rainbow = '\ud83c\udf08'; await sendTextAndVerifyPresentInSentMsg(t, browser, rainbow, { sign: true, encrypt: false }); @@ -1030,10 +1034,11 @@ const pastePublicKeyManually = async (composeFrame: ControllableFrame, inboxPage await inboxPage.waitTillGone('@dialog-add-pubkey'); }; -const sendImgAndVerifyPresentInSentMsg = async (t: AvaContext, browser: BrowserHandle, sendingType: 'encrypt' | 'sign') => { +const sendImgAndVerifyPresentInSentMsg = async (t: AvaContext, browser: BrowserHandle, sendingType: 'encrypt' | 'sign' | 'plain') => { // send a message with image in it const imgBase64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAnElEQVR42u3RAQ0AAAgDIE1u9FvDOahAVzLFGS1ECEKEIEQIQoQgRIgQIQgRghAhCBGCECEIQYgQhAhBiBCECEEIQoQgRAhChCBECEIQIgQhQhAiBCFCEIIQIQgRghAhCBGCEIQIQYgQhAhBiBCEIEQIQoQgRAhChCAEIUIQIgQhQhAiBCEIEYIQIQgRghAhCBEiRAhChCBECEK+W3uw+TnWoJc/AAAAAElFTkSuQmCC'; - const subject = `Test Sending ${sendingType === 'sign' ? 'Signed' : 'Encrypted'} Message With Image ${Util.lousyRandom()}`; + const sendingTypeForHumans = sendingType === 'encrypt' ? 'Encrypted' : (sendingType === 'sign' ? 'Signed' : 'Plain'); + const subject = `Test Sending ${sendingTypeForHumans} Message With Image ${Util.lousyRandom()}`; const composePage = await ComposePageRecipe.openStandalone(t, browser, 'compatibility'); await ComposePageRecipe.fillMsg(composePage, { to: 'human@flowcrypt.com' }, subject, { richtext: true, sign: sendingType === 'sign', encrypt: sendingType === 'encrypt' }); // the following is a temporary hack - currently not able to directly paste an image with puppeteer @@ -1042,6 +1047,12 @@ const sendImgAndVerifyPresentInSentMsg = async (t: AvaContext, browser: BrowserH await ComposePageRecipe.sendAndClose(composePage); // get sent msg id from mock const sentMsg = new GoogleData('flowcrypt.compatibility@gmail.com').getMessageBySubject(subject)!; + if (sendingType === 'plain') { + expect(sentMsg.payload?.body?.data).to.match(/This is an automated puppeteer test: Test Sending Plain Message With Image/); + return; + // todo - this test case is a stop-gap. We need to implement rendering of such messages below, + // then let test plain messages with images in them (referenced by cid) just like other types of messages below + } let url = `chrome/dev/ci_pgp_host_page.htm?frameId=none&msgId=${encodeURIComponent(sentMsg.id)}&senderEmail=flowcrypt.compatibility%40gmail.com&isOutgoing=___cu_false___&acctEmail=flowcrypt.compatibility%40gmail.com`; if (sendingType === 'sign') { url += '&signature=___cu_true___'; diff --git a/test/source/tests/flaky.ts b/test/source/tests/flaky.ts index b7805568eb1..a76513660a7 100644 --- a/test/source/tests/flaky.ts +++ b/test/source/tests/flaky.ts @@ -120,7 +120,7 @@ export const defineFlakyTests = (testVariant: TestVariant, testWithBrowser: Test await fileInput!.uploadFile('test/samples/small.txt'); await ComposePageRecipe.sendAndClose(composePage, { password: msgPwd }); const msg = new GoogleData('flowcrypt.compatibility@gmail.com').getMessageBySubject(subject)!; - const webDecryptUrl = msg.payload!.body!.data!.match(/https:\/\/flowcrypt.com\/[a-z0-9A-Z]+/g)![0]; + const webDecryptUrl = msg.payload!.body!.data!.replace(///g, '/').match(/https:\/\/flowcrypt.com\/[a-z0-9A-Z]+/g)![0]; // while this test runs on a mock, it forwards the message/upload call to real backend - see `fwdToRealBackend` // that's why we are able to test the message on real flowcrypt.com/api and web. const webDecryptPage = await browser.newPage(t, webDecryptUrl); diff --git a/test/source/util/parse.ts b/test/source/util/parse.ts index ddd9c682c99..da54e44eb05 100644 --- a/test/source/util/parse.ts +++ b/test/source/util/parse.ts @@ -32,7 +32,7 @@ const strictParse = async (source: string): Promise => { }; const convertBase64ToMimeMsg = async (base64: string) => { - return await simpleParser(new Buffer(Buf.fromBase64Str(base64))); + return await simpleParser(new Buffer(Buf.fromBase64Str(base64)), { keepCidLinks: true /* #3256 */ }); }; export default { strictParse, convertBase64ToMimeMsg };