diff --git a/extension/chrome/elements/compose-modules/compose-err-module.ts b/extension/chrome/elements/compose-modules/compose-err-module.ts index f9cb31c2b48..f94c237d2c7 100644 --- a/extension/chrome/elements/compose-modules/compose-err-module.ts +++ b/extension/chrome/elements/compose-modules/compose-err-module.ts @@ -19,9 +19,9 @@ import { Lang } from '../../../js/common/lang.js'; import linkifyHtml from 'linkifyHtml'; import { MsgUtil } from '../../../js/common/core/crypto/pgp/msg-util.js'; -export class ComposerUserError extends Error {} -class ComposerNotReadyError extends ComposerUserError {} -export class ComposerResetBtnTrigger extends Error {} +export class ComposerUserError extends Error { } +class ComposerNotReadyError extends ComposerUserError { } +export class ComposerResetBtnTrigger extends Error { } export const PUBKEY_LOOKUP_RESULT_FAIL = 'fail'; @@ -147,16 +147,13 @@ export class ComposeErrModule extends ViewModule { throw new ComposerNotReadyError('Still working, please wait.'); }; - public throwIfFormValsInvalid = async ({ subject, plaintext, from }: NewMsgData) => { + public throwIfFormValsInvalid = async ({ subject, plaintext, plainhtml, from }: NewMsgData) => { if (!subject && !(await Ui.modal.confirm('Send without a subject?'))) { throw new ComposerResetBtnTrigger(); } - let footer = await this.view.footerModule.getFooterFromStorage(from.email); - if (footer) { - // format footer the way it would be in outgoing plaintext - footer = Xss.htmlUnescape(Xss.htmlSanitizeAndStripAllTags(this.view.footerModule.createFooterHtml(footer), '\n')).trim(); - } - if ((!plaintext.trim() || plaintext.trim() === footer?.trim()) && !(await Ui.modal.confirm('Send empty message?'))) { + const footer = await this.getFormattedFooter(from.email); + const hasContent = this.hasMessageContent(plaintext, plainhtml, footer); + if (!hasContent && !(await Ui.modal.confirm('Send empty message?'))) { throw new ComposerResetBtnTrigger(); } }; @@ -175,22 +172,22 @@ export class ComposeErrModule extends ViewModule { if (await this.view.storageModule.isPwdMatchingPassphrase(pwd)) { throw new ComposerUserError( 'Please do not use your private key passphrase as a password for this message.\n\n' + - 'You should come up with some other unique password that you can share with recipient.' + 'You should come up with some other unique password that you can share with recipient.' ); } if (subject.toLowerCase().includes(pwd.toLowerCase())) { throw new ComposerUserError( `Please do not include the password in the email subject. ` + - `Sharing password over email undermines password based encryption.\n\n` + - `You can ask the recipient to also install FlowCrypt, messages between FlowCrypt users don't need a password.` + `Sharing password over email undermines password based encryption.\n\n` + + `You can ask the recipient to also install FlowCrypt, messages between FlowCrypt users don't need a password.` ); } const intro = this.view.S.cached('input_intro').length ? this.view.inputModule.extract('text', 'input_intro') : ''; if (intro.toLowerCase().includes(pwd.toLowerCase())) { throw new ComposerUserError( 'Please do not include the password in the email intro. ' + - `Sharing password over email undermines password based encryption.\n\n` + - `You can ask the recipient to also install FlowCrypt, messages between FlowCrypt users don't need a password.` + `Sharing password over email undermines password based encryption.\n\n` + + `You can ask the recipient to also install FlowCrypt, messages between FlowCrypt users don't need a password.` ); } if (!this.view.pwdOrPubkeyContainerModule.isMessagePasswordStrong(pwd)) { @@ -202,4 +199,29 @@ export class ComposeErrModule extends ViewModule { throw new ComposerUserError("Some recipients don't have encryption set up. Please add a password."); } }; + + private hasMessageContent = (plaintext: string, plainhtml: string, footer: string): boolean => { + const textWithoutFooter = plaintext.trim() === footer.trim() ? '' : plaintext.trim(); + if (textWithoutFooter) { + return true; // Has text content + } + // Check for file attachments + if (this.view.attachmentsModule.attachment.hasAttachment()) { + return true; + } + // Check for embedded images in rich text mode + if (this.view.inputModule.isRichText() && plainhtml.includes(' => { + const footer = await this.view.footerModule.getFooterFromStorage(email); + if (!footer) { + return ''; + } + // Format footer the way it would be in outgoing plaintext + return Xss.htmlUnescape(Xss.htmlSanitizeAndStripAllTags(this.view.footerModule.createFooterHtml(footer), '\n')).trim(); + }; } diff --git a/extension/chrome/elements/pgp_block.ts b/extension/chrome/elements/pgp_block.ts index e47474f762e..5fe23d3ec5e 100644 --- a/extension/chrome/elements/pgp_block.ts +++ b/extension/chrome/elements/pgp_block.ts @@ -158,4 +158,4 @@ export class PgpBlockView extends View { }; } -View.run(PgpBlockView); +View.run(PgpBlockView); \ No newline at end of file diff --git a/test/source/tests/compose.ts b/test/source/tests/compose.ts index 20b9b1507fc..ae90ac186a3 100644 --- a/test/source/tests/compose.ts +++ b/test/source/tests/compose.ts @@ -1856,6 +1856,35 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te }) ); + test( + 'compose - send only image in rich text mode without empty message dialog', + testWithBrowser(async (t, browser) => { + await BrowserRecipe.setupCommonAcctWithAttester(t, browser, 'compatibility', { + attester: { includeHumanKey: true }, + }); + const imgBase64 = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAnElEQVR42u3RAQ0AAAgDIE1u9FvDOahAVzLFGS1ECEKEIEQIQoQgRIgQIQgRghAhCBGCECEIQYgQhAhBiBCECEEIQoQgRAhChCBECEIQIgQhQhAiBCFCEIIQIQgRghAhCBGCEIQIQYgQhAhBiBCEIEQIQoQgRAhChCAEIUIQIgQhQhAiBCEIEYIQIQgRghAhCBEiRAhChCBECEK+W3uw+TnWoJc/AAAAAElFTkSuQmCC'; + const composePage = await ComposePageRecipe.openStandalone(t, browser, 'compatibility'); + // Enable rich text mode and set up recipient + subject + await ComposePageRecipe.fillMsg(composePage, { to: 'human@flowcrypt.com' }, 'Test sending only image', '', { + richtext: true, + }); + // Clear the body completely (including any footer) - issue #3204 is about sending ONLY an image + await composePage.page.evaluate(() => { + $('#input_text').html(''); + }); + // Insert image - this is the only content + await composePage.page.evaluate((src: string) => { + $('[data-test=action-insert-image]').val(src).trigger('click'); + }, imgBase64); + await Util.sleep(0.5); + // Send should work without "Send empty message?" dialog + await ComposePageRecipe.sendAndClose(composePage); + }) + ); + + + test( 'compose - check existing draft not saved without changes', testWithBrowser(async (t, browser) => { @@ -3443,9 +3472,8 @@ const sendTextAndVerifyPresentInSentMsg = async ( text: string, sendingOpt: { encrypt?: boolean; sign?: boolean; richtext?: boolean } = {} ) => { - const subject = `Test Sending ${sendingOpt.sign ? 'Signed' : ''} ${ - sendingOpt.encrypt ? 'Encrypted' : '' - } Message With Test Text ${text} ${Util.lousyRandom()}`; + const subject = `Test Sending ${sendingOpt.sign ? 'Signed' : ''} ${sendingOpt.encrypt ? 'Encrypted' : '' + } Message With Test Text ${text} ${Util.lousyRandom()}`; const composePage = await ComposePageRecipe.openStandalone(t, browser, 'compatibility'); await ComposePageRecipe.fillMsg(composePage, { to: 'human@flowcrypt.com' }, subject, text, sendingOpt); const acctEmail = 'flowcrypt.compatibility@gmail.com';