Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -138,12 +141,57 @@ export class ComposeSendBtnModule extends ViewModule<ComposeView> {
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also todo here for later

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)
Expand Down
5 changes: 5 additions & 0 deletions extension/js/common/core/attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion extension/js/common/core/mime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = {};
if (attachment.contentDescription) {
header['Content-Description'] = attachment.contentDescription;
Expand Down
5 changes: 3 additions & 2 deletions test/source/mock/google/google-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 15 additions & 4 deletions test/source/tests/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,14 +705,18 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te
expect(await composePage.readHtml('@input-body')).to.include('<div dir="rtl">مرحبا<br></div>');
}));

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 });
Expand Down Expand Up @@ -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
Expand All @@ -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(/<img src="cid:(.+)@flowcrypt">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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll make an issue for this

}
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___';
Expand Down
2 changes: 1 addition & 1 deletion test/source/tests/flaky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/&#x2F;/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);
Expand Down
2 changes: 1 addition & 1 deletion test/source/util/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const strictParse = async (source: string): Promise<ParseMsgResult> => {
};

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 };