diff --git a/extension/chrome/elements/attachment.htm b/extension/chrome/elements/attachment.htm index b3f7e564dce..b5783e26766 100644 --- a/extension/chrome/elements/attachment.htm +++ b/extension/chrome/elements/attachment.htm @@ -20,7 +20,7 @@ -
+
diff --git a/extension/chrome/elements/compose-modules/compose-draft-module.ts b/extension/chrome/elements/compose-modules/compose-draft-module.ts index 408dd75bd13..57a8b43b54a 100644 --- a/extension/chrome/elements/compose-modules/compose-draft-module.ts +++ b/extension/chrome/elements/compose-modules/compose-draft-module.ts @@ -119,8 +119,8 @@ export class ComposeDraftModule extends ViewModule { if (this.hasBodyChanged(this.view.inputModule.squire.getHTML()) || this.hasSubjectChanged(String(this.view.S.cached('input_subject').val())) || forceSave) { this.currentlySavingDraft = true; try { - const msgData = this.view.inputModule.extractAll(); - const { pubkeys } = await this.view.storageModule.collectSingleFamilyKeys([], msgData.from, true); + const msgData = await this.view.inputModule.extractAll(); + const { pubkeys } = await this.view.storageModule.collectSingleFamilyKeys([], msgData.from.email, true); // collectSingleFamilyKeys filters out bad keys, but only if there are any good keys available // if no good keys available, it leaves bad keys so we can explain the issue here if (pubkeys.some(pub => pub.pubkey.expiration && pub.pubkey.expiration < Date.now())) { @@ -133,7 +133,7 @@ export class ComposeDraftModule extends ViewModule { throw new UnreportableError('Your account keys are not usable for encryption'); } msgData.pwd = undefined; // not needed for drafts - const sendable = await new EncryptedMsgMailFormatter(this.view, true).sendableMsg(msgData, pubkeys); + const sendable = await new EncryptedMsgMailFormatter(this.view, true).sendableNonPwdMsg(msgData, pubkeys); if (this.view.replyParams?.inReplyTo) { sendable.headers.References = this.view.replyParams.inReplyTo; sendable.headers['In-Reply-To'] = this.view.replyParams.inReplyTo; diff --git a/extension/chrome/elements/compose-modules/compose-err-module.ts b/extension/chrome/elements/compose-modules/compose-err-module.ts index d3d6b52b7cb..8b493005f1e 100644 --- a/extension/chrome/elements/compose-modules/compose-err-module.ts +++ b/extension/chrome/elements/compose-modules/compose-err-module.ts @@ -5,7 +5,7 @@ import { Browser } from '../../../js/common/browser/browser.js'; import { BrowserEventErrHandler, Ui } from '../../../js/common/browser/ui.js'; import { Catch } from '../../../js/common/platform/catch.js'; -import { NewMsgData, SendBtnTexts } from './compose-types.js'; +import { NewMsgData, SendBtnTexts, SendMsgsResult } from './compose-types.js'; import { ApiErr } from '../../../js/common/api/shared/api-error.js'; import { BrowserExtension } from '../../../js/common/browser/browser-extension.js'; import { BrowserMsg } from '../../../js/common/browser/browser-msg.js'; @@ -27,6 +27,11 @@ export class ComposeErrModule extends ViewModule { private debugId = Str.sloppyRandom(); + private static getErrSayingSomeMessagesHaveBeenSent = (sendMsgsResult: SendMsgsResult) => { + return 'Messages to some recipients were sent successfully, while messages to ' + + Str.formatEmailList(sendMsgsResult.failures.map(el => el.recipient)) + ' encountered '; + }; + public handle = (couldNotDoWhat: string): BrowserEventErrHandler => { return { network: async () => await Ui.modal.info(`Could not ${couldNotDoWhat} (network error). Please try again.`), @@ -61,11 +66,18 @@ export class ComposeErrModule extends ViewModule { } }; - public handleSendErr = async (e: any) => { + public handleSendErr = async (e: any, sendMsgsResult?: SendMsgsResult) => { this.view.errModule.debug(`handleSendErr: ${String(e)}`); if (ApiErr.isNetErr(e)) { - let netErrMsg = 'Could not send message due to network error. Please check your internet connection and try again.\n'; - netErrMsg += '(This may also be caused by missing extension permissions).'; + let netErrMsg: string | undefined; + if (sendMsgsResult?.success.length) { + // there were some successful sends + netErrMsg = ComposeErrModule.getErrSayingSomeMessagesHaveBeenSent(sendMsgsResult) + + 'network errors. Please check your internet connection and try again.'; + } else { + netErrMsg = 'Could not send message due to network error. Please check your internet connection and try again.\n' + + '(This may also be caused by missing extension permissions).'; + } await Ui.modal.error(netErrMsg, true); } else if (ApiErr.isAuthErr(e)) { BrowserMsg.send.notificationShowAuthPopupNeeded(this.view.parentTabId, { acctEmail: this.view.acctEmail }); @@ -73,10 +85,15 @@ export class ComposeErrModule extends ViewModule { } else if (ApiErr.isReqTooLarge(e)) { await Ui.modal.error(`Could not send: message or attachments too large.`); } else if (ApiErr.isBadReq(e)) { + let gmailErrMsg: string | undefined; + if (sendMsgsResult?.success.length) { + gmailErrMsg = ComposeErrModule.getErrSayingSomeMessagesHaveBeenSent(sendMsgsResult) + 'error(s) from Gmail'; + } if (e.resMsg === AjaxErrMsgs.GOOGLE_INVALID_TO_HEADER || e.resMsg === AjaxErrMsgs.GOOGLE_RECIPIENT_ADDRESS_REQUIRED) { - await Ui.modal.error('Error from google: Invalid recipients\n\nPlease remove recipients, add them back and re-send the message.'); + await Ui.modal.error((gmailErrMsg || 'Error from google') + ': Invalid recipients\n\nPlease remove recipients, add them back and re-send the message.'); } else { - if (await Ui.modal.confirm(`Google returned an error when sending message. Please help us improve FlowCrypt by reporting the error to us.`)) { + if (await Ui.modal.confirm((gmailErrMsg || 'Google returned an error when sending message') + + `. Please help us improve FlowCrypt by reporting the error to us.`)) { const page = '/chrome/settings/modules/help.htm'; const pageUrlParams = { bugReport: BrowserExtension.prepareBugReport(`composer: send: bad request (errMsg: ${e.resMsg})`, {}, e) }; await Browser.openSettingsPage('index.htm', this.view.acctEmail, page, pageUrlParams); @@ -126,7 +143,7 @@ export class ComposeErrModule extends ViewModule { if (!subject && ! await Ui.modal.confirm('Send without a subject?')) { throw new ComposerResetBtnTrigger(); } - let footer = await this.view.footerModule.getFooterFromStorage(from); + 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(); } diff --git a/extension/chrome/elements/compose-modules/compose-input-module.ts b/extension/chrome/elements/compose-modules/compose-input-module.ts index 4e297fe7192..1b7fd55fb8b 100644 --- a/extension/chrome/elements/compose-modules/compose-input-module.ts +++ b/extension/chrome/elements/compose-modules/compose-input-module.ts @@ -60,14 +60,14 @@ export class ComposeInputModule extends ViewModule { return this.view.S.cached('fineuploader').find('.qq-upload-file').toArray().map((el) => $(el).text().trim()); }; - public extractAll = (): NewMsgData => { + public extractAll = async (): Promise => { const recipients = this.mapRecipients(this.view.recipientsModule.getValidRecipients()); const subject = this.view.isReplyBox && this.view.replyParams ? this.view.replyParams.subject : String($('#input_subject').val() || ''); const plaintext = this.view.inputModule.extract('text', 'input_text'); const plainhtml = this.view.inputModule.extract('html', 'input_text'); const password = this.view.S.cached('input_password').val(); const pwd = typeof password === 'string' && password ? password : undefined; - const from = this.view.senderModule.getSender(); + const from = await this.view.storageModule.getEmailWithOptionalName(this.view.senderModule.getSender()); return { recipients, subject, plaintext, plainhtml, pwd, from }; }; diff --git a/extension/chrome/elements/compose-modules/compose-recipients-module.ts b/extension/chrome/elements/compose-modules/compose-recipients-module.ts index 990915b8e43..4ed8d045094 100644 --- a/extension/chrome/elements/compose-modules/compose-recipients-module.ts +++ b/extension/chrome/elements/compose-modules/compose-recipients-module.ts @@ -2,7 +2,7 @@ 'use strict'; -import { ChunkedCb, EmailProviderContact, RecipientType } from '../../../js/common/api/shared/api.js'; +import { Api, ChunkedCb, EmailProviderContact, RecipientType } from '../../../js/common/api/shared/api.js'; import { ContactInfoWithSortedPubkeys, KeyUtil, PubkeyInfo } from '../../../js/common/core/crypto/key.js'; import { PUBKEY_LOOKUP_RESULT_FAIL } from './compose-err-module.js'; import { ProviderContactsQuery, Recipients } from '../../../js/common/api/email-provider/email-provider-api.js'; @@ -160,9 +160,8 @@ export class ComposeRecipientsModule extends ViewModule { public addRecipients = async (recipients: Recipients, triggerCallback: boolean = true) => { const newRecipients: ValidRecipientElement[] = []; - for (const [key, value] of Object.entries(recipients)) { - if (['to', 'cc', 'bcc'].includes(key)) { - const sendingType = key as RecipientType; + for (const [sendingType, value] of Object.entries(recipients)) { + if (Api.isRecipientHeaderNameType(sendingType)) { if (value?.length) { const recipientsContainer = this.view.S.cached('input_addresses_container_outer').find(`#input-container-${sendingType}`); for (const email of value) { diff --git a/extension/chrome/elements/compose-modules/compose-render-module.ts b/extension/chrome/elements/compose-modules/compose-render-module.ts index e2683e1b49c..d35ef0718e2 100644 --- a/extension/chrome/elements/compose-modules/compose-render-module.ts +++ b/extension/chrome/elements/compose-modules/compose-render-module.ts @@ -8,8 +8,7 @@ import { BrowserMsg } from '../../../js/common/browser/browser-msg.js'; import { Catch } from '../../../js/common/platform/catch.js'; import { KeyImportUi } from '../../../js/common/ui/key-import-ui.js'; import { Lang } from '../../../js/common/lang.js'; -import { Recipients } from '../../../js/common/api/email-provider/email-provider-api.js'; -import { SendableMsg } from '../../../js/common/api/email-provider/sendable-msg.js'; +import { ParsedRecipients, Recipients } from '../../../js/common/api/email-provider/email-provider-api.js'; import { Str } from '../../../js/common/core/common.js'; import { Ui } from '../../../js/common/browser/ui.js'; import { Xss } from '../../../js/common/platform/xss.js'; @@ -114,7 +113,7 @@ export class ComposeRenderModule extends ViewModule { } }; - public renderReplySuccess = (msg: SendableMsg, msgId: string) => { + public renderReplySuccess = (attachments: Attachment[], recipients: ParsedRecipients, msgId: string) => { this.view.renderModule.renderReinsertReplyBox(msgId); if (!this.view.sendBtnModule.popover.choices.encrypt) { this.view.S.cached('replied_body').removeClass('pgp_secure'); @@ -125,13 +124,13 @@ export class ComposeRenderModule extends ViewModule { this.view.S.cached('replied_body').css('width', ($('table#compose').width() || 500) - 30); this.view.S.cached('compose_table').css('display', 'none'); this.view.S.cached('reply_msg_successful').find('div.replied_from').text(this.view.senderModule.getSender()); - this.view.S.cached('reply_msg_successful').find('div.replied_to span').text(msg.headers.To.replace(/,/g, ', ')); - if (msg.recipients.cc !== undefined && msg.recipients.cc.length > 0) { - this.view.S.cached('reply_msg_successful').find('div.replied_cc span').text(msg.recipients.cc.join(', ')); + this.view.S.cached('reply_msg_successful').find('div.replied_to span').text(Str.formatEmailList(recipients.to || [])); + if (recipients.cc !== undefined && recipients.cc.length > 0) { + this.view.S.cached('reply_msg_successful').find('div.replied_cc span').text(Str.formatEmailList(recipients.cc)); $('.replied_cc').show(); } - if (msg.recipients.bcc !== undefined && msg.recipients.bcc.length > 0) { - this.view.S.cached('reply_msg_successful').find('div.replied_bcc span').text(msg.recipients.bcc.join(', ')); + if (recipients.bcc !== undefined && recipients.bcc.length > 0) { + this.view.S.cached('reply_msg_successful').find('div.replied_bcc span').text(Str.formatEmailList(recipients.bcc)); $('.replied_bcc').show(); } const repliedBodyEl = this.view.S.cached('reply_msg_successful').find('div.replied_body'); @@ -141,7 +140,7 @@ export class ComposeRenderModule extends ViewModule { this.renderReplySuccessMimeAttachments(this.view.inputModule.extractAttachments()); } else { Xss.sanitizeRender(repliedBodyEl, Str.escapeTextAsRenderableHtml(this.view.inputModule.extract('text', 'input_text', 'SKIP-ADDONS'))); - this.renderReplySuccessAttachments(msg.attachments, msgId, this.view.sendBtnModule.popover.choices.encrypt); + this.renderReplySuccessAttachments(attachments, msgId, this.view.sendBtnModule.popover.choices.encrypt); } const t = new Date(); const time = ((t.getHours() !== 12) ? (t.getHours() % 12) : 12) + ':' + (t.getMinutes() < 10 ? '0' : '') + t.getMinutes() + ((t.getHours() >= 12) ? ' PM ' : ' AM ') + '(0 minutes ago)'; 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 bcddecd5a47..e334b5536c0 100644 --- a/extension/chrome/elements/compose-modules/compose-send-btn-module.ts +++ b/extension/chrome/elements/compose-modules/compose-send-btn-module.ts @@ -11,18 +11,17 @@ import { Buf } from '../../../js/common/core/buf.js'; import { Catch } from '../../../js/common/platform/catch.js'; import { ComposerUserError } from './compose-err-module.js'; import { ComposeSendBtnPopoverModule } from './compose-send-btn-popover-module.js'; -import { GeneralMailFormatter } from './formatters/general-mail-formatter.js'; +import { GeneralMailFormatter, MultipleMessages } from './formatters/general-mail-formatter.js'; import { GmailParser, GmailRes } from '../../../js/common/api/email-provider/gmail/gmail-parser.js'; import { KeyInfoWithIdentity } from '../../../js/common/core/crypto/key.js'; -import { getUniqueRecipientEmails, SendBtnTexts } from './compose-types.js'; +import { getUniqueRecipientEmails, SendBtnTexts, SendMsgsResult } from './compose-types.js'; import { SendableMsg } from '../../../js/common/api/email-provider/sendable-msg.js'; import { Ui } from '../../../js/common/browser/ui.js'; import { Xss } from '../../../js/common/platform/xss.js'; import { ViewModule } from '../../../js/common/view-module.js'; import { ComposeView } from '../compose.js'; import { ContactStore } from '../../../js/common/platform/store/contact-store.js'; -import { AcctStore } from '../../../js/common/platform/store/acct-store.js'; -import { Str } from '../../../js/common/core/common.js'; +import { EmailParts } from '../../../js/common/core/common.js'; export class ComposeSendBtnModule extends ViewModule { @@ -107,19 +106,41 @@ export class ComposeSendBtnModule extends ViewModule { this.view.S.cached('toggle_send_options').hide(); try { this.view.errModule.throwIfFormNotReady(); + this.isSendMessageInProgress = true; this.view.S.now('send_btn_text').text('Loading...'); Xss.sanitizeRender(this.view.S.now('send_btn_i'), Ui.spinner('white')); this.view.S.cached('send_btn_note').text(''); - const newMsgData = this.view.inputModule.extractAll(); + const newMsgData = await this.view.inputModule.extractAll(); await this.view.errModule.throwIfFormValsInvalid(newMsgData); const emails = getUniqueRecipientEmails(newMsgData.recipients); await ContactStore.update(undefined, emails, { lastUse: Date.now() }); const msgObj = await GeneralMailFormatter.processNewMsg(this.view, newMsgData); - await this.finalizeSendableMsg(msgObj); - await this.doSendMsg(msgObj.msg); + for (const msg of msgObj.msgs) { + await this.finalizeSendableMsg({ msg, senderKi: msgObj.senderKi }); + } + const result = await this.doSendMsgs(msgObj); + if (!result.failures.length) { + // toast isn't supported together with a confirmation/alert popup + if (result.supplementaryOperationsErrors.length) { + console.error(result.supplementaryOperationsErrors); + Catch.setHandledTimeout(() => { + Ui.toast(result.supplementaryOperationsErrors[0]); // tslint:disable-line:no-unsafe-any + }, 0); + } + BrowserMsg.send.notificationShow(this.view.parentTabId, { notification: `Your ${this.view.isReplyBox ? 'reply' : 'message'} has been sent.` }); + BrowserMsg.send.focusBody(this.view.parentTabId); // Bring focus back to body so Gmails shortcuts will work + if (this.view.isReplyBox) { + this.view.renderModule.renderReplySuccess(msgObj.renderSentMessage.attachments, msgObj.renderSentMessage.recipients, result.sentIds[0]); + } else { + this.view.renderModule.closeMsg(); + } + } else { + await this.view.errModule.handleSendErr(result.failures[0].e, result); + } } catch (e) { - await this.view.errModule.handleSendErr(e); + await this.view.errModule.handleSendErr(e, undefined); } finally { + this.isSendMessageInProgress = false; this.view.sendBtnModule.enableBtn(); this.view.S.cached('toggle_send_options').show(); } @@ -145,7 +166,6 @@ export class ComposeSendBtnModule extends ViewModule { if (this.view.myPubkeyModule.shouldAttach() && senderKi) { // todo: report on undefined? msg.attachments.push(Attachment.keyinfoAsPubkeyAttachment(senderKi)); } - msg.from = await this.formatSenderEmailAsMimeString(msg.from); }; private extractInlineImagesToAttachments = (html: string) => { @@ -194,65 +214,69 @@ export class ComposeSendBtnModule extends ViewModule { return { mimeType, data }; }; - - private doSendMsg = async (msg: SendableMsg) => { + private attemptSendMsg = async (msg: SendableMsg): Promise => { // 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) + // todo: this isn't correct when we're sending multiple messages const progressRepresents = this.view.pwdOrPubkeyContainerModule.isVisible() ? 'SECOND-HALF' : 'EVERYTHING'; - let msgSentRes: GmailRes.GmailMsgSend; try { - this.isSendMessageInProgress = true; - msgSentRes = await this.view.emailProvider.msgSend(msg, (p) => this.renderUploadProgress(p, progressRepresents)); + return await this.view.emailProvider.msgSend(msg, (p) => this.renderUploadProgress(p, progressRepresents)); } catch (e) { if (msg.thread && ApiErr.isNotFound(e) && this.view.threadId) { // cannot send msg because threadId not found - eg user since deleted it msg.thread = undefined; - msgSentRes = await this.view.emailProvider.msgSend(msg, (p) => this.renderUploadProgress(p, progressRepresents)); + // give it another try, this time without msg.thread + // todo: progressRepresents? + return await this.attemptSendMsg(msg); } else { - this.isSendMessageInProgress = false; throw e; } } - BrowserMsg.send.notificationShow(this.view.parentTabId, { notification: `Your ${this.view.isReplyBox ? 'reply' : 'message'} has been sent.` }); - BrowserMsg.send.focusBody(this.view.parentTabId); // Bring focus back to body so Gmails shortcuts will work - const operations = [this.view.draftModule.draftDelete()]; - if (msg.externalId) { - operations.push((async (externalId, id) => { - const gmailMsg = await this.view.emailProvider.msgGet(id, 'metadata'); - const messageId = GmailParser.findHeader(gmailMsg, 'message-id'); - if (messageId) { - await this.view.acctServer.messageGatewayUpdate(externalId, messageId); - } else { - Catch.report('Failed to extract Message-ID of sent message'); - } - })(msg.externalId, msgSentRes.id)); - } - await Promise.all(operations); - this.isSendMessageInProgress = false; - if (this.view.isReplyBox) { - this.view.renderModule.renderReplySuccess(msg, msgSentRes.id); - } else { - this.view.renderModule.closeMsg(); - } }; - private formatSenderEmailAsMimeString = async (email: string): Promise => { - const parsedEmail = Str.parseEmail(email); - if (!parsedEmail.email) { - throw new Error(`Recipient email ${email} is not valid`); + private bindMessageId = async (externalId: string, id: string, supplementaryOperationsErrors: any[]) => { + try { + const gmailMsg = await this.view.emailProvider.msgGet(id, 'metadata'); + const messageId = GmailParser.findHeader(gmailMsg, 'message-id'); + if (messageId) { + await this.view.acctServer.messageGatewayUpdate(externalId, messageId); + } else { + throw new Error('Failed to extract Message-ID of sent message'); + } + } catch (e) { + supplementaryOperationsErrors.push(`Failed to bind Gateway ID of the message: ${e}`); + Catch.reportErr(e); } - if (parsedEmail.name) { - return Str.formatEmailWithOptionalName({ email: parsedEmail.email, name: parsedEmail.name }); + }; + + private doSendMsgs = async (msgObj: MultipleMessages): Promise => { + const sentIds: string[] = []; + const supplementaryOperations: Promise[] = []; + const supplementaryOperationsErrors: any[] = []; // tslint:disable-line:no-unsafe-any + const success: EmailParts[] = []; + const failures: { recipient: EmailParts, e: any }[] = []; + for (const msg of msgObj.msgs) { + const msgRecipients = msg.getAllRecipients(); + try { + const msgSentRes = await this.attemptSendMsg(msg); + success.push(...msgRecipients); + sentIds.push(msgSentRes.id); + if (msg.externalId) { + supplementaryOperations.push(this.bindMessageId(msg.externalId, msgSentRes.id, supplementaryOperationsErrors)); + } + } catch (e) { + failures.push(...msgRecipients.map(recipient => { return { recipient, e }; })); + } } - const { sendAs } = await AcctStore.get(this.view.acctEmail, ['sendAs']); - let name: string | undefined; - if (sendAs && sendAs[email]?.name) { - name = sendAs[email].name!; - } else { - const contactWithPubKeys = await ContactStore.getOneWithAllPubkeys(undefined, email); - if (contactWithPubKeys && contactWithPubKeys.info.name) { - name = contactWithPubKeys.info.name; + try { + if (!failures.length) { + supplementaryOperations.push(this.view.draftModule.draftDelete()); } + await Promise.all(supplementaryOperations); + } catch (e) { + Catch.reportErr(e); + supplementaryOperationsErrors.push(e); } - return Str.formatEmailWithOptionalName({ email: parsedEmail.email, name }); + return { success, failures, supplementaryOperationsErrors, sentIds }; }; + } diff --git a/extension/chrome/elements/compose-modules/compose-storage-module.ts b/extension/chrome/elements/compose-modules/compose-storage-module.ts index 40a6f71858b..59ec8ae14ee 100644 --- a/extension/chrome/elements/compose-modules/compose-storage-module.ts +++ b/extension/chrome/elements/compose-modules/compose-storage-module.ts @@ -17,6 +17,8 @@ import { PassphraseStore } from '../../../js/common/platform/store/passphrase-st import { compareAndSavePubkeysToStorage } from '../../../js/common/shared.js'; import { KeyFamily } from '../../../js/common/core/crypto/key.js'; import { ParsedKeyInfo } from '../../../js/common/core/crypto/key-store-util.js'; +import { EmailParts, Str } from '../../../js/common/core/common.js'; +import { AcctStore } from '../../../js/common/platform/store/acct-store.js'; export class ComposeStorageModule extends ViewModule { @@ -176,6 +178,28 @@ export class ComposeStorageModule extends ViewModule { } }; + // searches in AcctStore and ContactStore to find name for this email (if it is missing in MIME string) + public getEmailWithOptionalName = async (emailInMimeFormat: string): Promise => { + const parsedEmail = Str.parseEmail(emailInMimeFormat); + if (!parsedEmail.email) { + throw new Error(`Recipient email ${emailInMimeFormat} is not valid`); + } + if (parsedEmail.name) { + return { email: parsedEmail.email, name: parsedEmail.name }; + } + const { sendAs } = await AcctStore.get(this.view.acctEmail, ['sendAs']); + let name: string | undefined; + if (sendAs && sendAs[parsedEmail.email]?.name) { + name = sendAs[parsedEmail.email].name!; + } else { + const contactWithPubKeys = await ContactStore.getOneWithAllPubkeys(undefined, parsedEmail.email); + if (contactWithPubKeys && contactWithPubKeys.info.name) { + name = contactWithPubKeys.info.name; + } + } + return { email: parsedEmail.email, name }; + }; + private collectSingleFamilyKeysInternal = async ( family: KeyFamily, senderEmail: string, diff --git a/extension/chrome/elements/compose-modules/compose-types.ts b/extension/chrome/elements/compose-modules/compose-types.ts index 49a1b57935a..e8498d414e6 100644 --- a/extension/chrome/elements/compose-modules/compose-types.ts +++ b/extension/chrome/elements/compose-modules/compose-types.ts @@ -51,7 +51,7 @@ export type CollectKeysResult = { pubkeys: PubkeyResult[], emailsWithoutPubkeys: export type PopoverOpt = 'encrypt' | 'sign' | 'richtext'; export type PopoverChoices = { [key in PopoverOpt]: boolean }; -export type NewMsgData = { recipients: ParsedRecipients, subject: string, plaintext: string, plainhtml: string, pwd: string | undefined, from: string }; +export type NewMsgData = { recipients: ParsedRecipients, subject: string, plaintext: string, plainhtml: string, pwd: string | undefined, from: EmailParts, replyTo?: string }; export class SendBtnTexts { public static readonly BTN_ENCRYPT_AND_SEND: string = "Encrypt and Send"; @@ -65,3 +65,10 @@ export class SendBtnTexts { export const getUniqueRecipientEmails = (recipients: ParsedRecipients) => { return Value.arr.unique(Object.values(recipients).reduce((a, b) => a.concat(b), []).filter(x => x.email).map(x => x.email)); }; + +export type SendMsgsResult = { + success: EmailParts[], + failures: { recipient: EmailParts, e: any }[], + supplementaryOperationsErrors: any[], + sentIds: string[] +}; diff --git a/extension/chrome/elements/compose-modules/formatters/base-mail-formatter.ts b/extension/chrome/elements/compose-modules/formatters/base-mail-formatter.ts index a485b1e9ca5..8448bccfe20 100644 --- a/extension/chrome/elements/compose-modules/formatters/base-mail-formatter.ts +++ b/extension/chrome/elements/compose-modules/formatters/base-mail-formatter.ts @@ -8,6 +8,7 @@ import { Key } from '../../../../js/common/core/crypto/key.js'; import { SmimeKey } from '../../../../js/common/core/crypto/smime/smime-key.js'; import { SendableMsg } from '../../../../js/common/api/email-provider/sendable-msg.js'; import { Buf } from '../../../../js/common/core/buf.js'; +import { Str } from '../../../../js/common/core/common.js'; export class BaseMailFormatter { @@ -22,7 +23,7 @@ export class BaseMailFormatter { } protected headers = (newMsg: NewMsgData) => { - return { from: newMsg.from, recipients: newMsg.recipients, subject: newMsg.subject, thread: this.view.threadId }; + return { from: Str.formatEmailWithOptionalName(newMsg.from), replyTo: newMsg.replyTo, recipients: newMsg.recipients, subject: newMsg.subject, thread: this.view.threadId }; }; protected signMimeMessage = async (signingPrv: Key, mimeEncodedMessage: string, newMsg: NewMsgData) => { diff --git a/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts b/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts index 7b788343aea..d35f408a5e9 100644 --- a/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts +++ b/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts @@ -6,7 +6,7 @@ import { BaseMailFormatter } from './base-mail-formatter.js'; import { ComposerResetBtnTrigger } from '../compose-err-module.js'; import { Mime, SendableMsgBody } from '../../../../js/common/core/mime.js'; import { getUniqueRecipientEmails, NewMsgData } from '../compose-types.js'; -import { Str, Value } from '../../../../js/common/core/common.js'; +import { EmailParts, Str, Value } from '../../../../js/common/core/common.js'; import { ApiErr } from '../../../../js/common/api/shared/api-error.js'; import { Attachment } from '../../../../js/common/core/attachment.js'; import { Buf } from '../../../../js/common/core/buf.js'; @@ -22,28 +22,107 @@ import { AcctStore } from '../../../../js/common/platform/store/acct-store.js'; import { FcUuidAuth } from '../../../../js/common/api/account-servers/flowcrypt-com-api.js'; import { SmimeKey } from '../../../../js/common/core/crypto/smime/smime-key.js'; import { PgpHash } from '../../../../js/common/core/crypto/pgp/pgp-hash.js'; +import { UploadedMessageData } from '../../../../js/common/api/account-server.js'; +import { ParsedKeyInfo } from '../../../../js/common/core/crypto/key-store-util.js'; +import { MultipleMessages } from './general-mail-formatter.js'; +import { Api, RecipientType } from '../../../../js/common/api/shared/api.js'; export class EncryptedMsgMailFormatter extends BaseMailFormatter { - public sendableMsg = async (newMsg: NewMsgData, pubkeys: PubkeyResult[], signingPrv?: Key): Promise => { + public sendableMsgs = async (newMsg: NewMsgData, pubkeys: PubkeyResult[], signingKey?: ParsedKeyInfo): Promise => { if (newMsg.pwd && !this.isDraft) { - // password-protected message, temporarily uploaded (already encrypted) to: - // - flowcrypt.com/api (consumers and customers without on-prem setup), or - // - FlowCrypt Enterprise Server (enterprise customers with on-prem setup) - // It will be served to recipient through web - const { url: msgUrl, externalId } = await this.prepareAndUploadPwdEncryptedMsg(newMsg); // encrypted for pwd only, pubkeys ignored - newMsg.pwd = undefined; - return await this.sendablePwdMsg(newMsg, pubkeys, { msgUrl, externalId }, signingPrv); // encrypted for pubkeys only, pwd ignored - } else if (this.richtext) { // rich text: PGP/MIME - https://tools.ietf.org/html/rfc3156#section-4 - // or S/MIME - return await this.sendableRichTextMsg(newMsg, pubkeys, signingPrv); - } else { // simple text: PGP or S/MIME Inline with attachments in separate files - // todo: #4046 check attachments for S/MIME - return await this.sendableSimpleTextMsg(newMsg, pubkeys, signingPrv); + return await this.formatSendablePwdMsgs(newMsg, pubkeys, signingKey); + } else { + const msg = await this.sendableNonPwdMsg(newMsg, pubkeys, signingKey?.key); + return { + senderKi: signingKey?.keyInfo, + msgs: [msg], + renderSentMessage: { + recipients: msg.recipients, + attachments: msg.attachments // todo: perhaps, we should hide technical attachments, like `encrypted.asc` and use collectedAttachments too? + } + }; } }; - private prepareAndUploadPwdEncryptedMsg = async (newMsg: NewMsgData): Promise<{ url: string; externalId?: string }> => { + public sendableNonPwdMsg = async (newMsg: NewMsgData, pubkeys: PubkeyResult[], signingPrv?: Key): Promise => { + if (!this.isDraft) { + // S/MIME drafts are currently formatted with inline armored data + const x509certs = pubkeys.map(entry => entry.pubkey).filter(pub => pub.family === 'x509'); + if (x509certs.length) { // s/mime + return await this.sendableSmimeMsg(newMsg, x509certs, signingPrv); + } + } + const textToEncrypt = this.richtext + ? await Mime.encode({ 'text/plain': newMsg.plaintext, 'text/html': newMsg.plainhtml }, { Subject: newMsg.subject }, + this.isDraft ? [] : await this.view.attachmentsModule.attachment.collectAttachments()) + : newMsg.plaintext; + const { data: encrypted } = await this.encryptDataArmor(Buf.fromUtfStr(textToEncrypt), undefined, pubkeys, signingPrv); + if (!this.richtext || this.isDraft) { + // draft richtext messages go inline as gmail makes it hard (or impossible) to render messages saved as https://tools.ietf.org/html/rfc3156 + return await SendableMsg.createInlineArmored(this.acctEmail, this.headers(newMsg), Buf.fromUint8(encrypted).toUtfStr(), + this.isDraft ? [] : await this.view.attachmentsModule.attachment.collectEncryptAttachments(pubkeys), + { isDraft: this.isDraft }); + } + // rich text: PGP/MIME - https://tools.ietf.org/html/rfc3156#section-4 + const attachments = this.formatEncryptedMimeDataAsPgpMimeMetaAttachments(encrypted); + return await SendableMsg.createPgpMime(this.acctEmail, this.headers(newMsg), attachments, { isDraft: this.isDraft }); + }; + + private formatSendablePwdMsgs = async (newMsg: NewMsgData, pubkeys: PubkeyResult[], signingKey?: ParsedKeyInfo) => { + // password-protected message, temporarily uploaded (already encrypted) to: + // - flowcrypt.com/api (consumers and customers without on-prem setup), or + // - FlowCrypt Enterprise Server (enterprise customers with on-prem setup) + // It will be served to recipient through web + const uploadedMessageData = await this.prepareAndUploadPwdEncryptedMsg(newMsg); // encrypted for pwd only, pubkeys ignored + // pwdRecipients that have their personal link + const individualPwdRecipients = Object.keys(uploadedMessageData.emailToExternalIdAndUrl ?? {}).filter(email => !pubkeys.some(p => p.email === email)); + const legacyPwdRecipients: { [type in RecipientType]?: EmailParts[] } = {}; + newMsg.pwd = undefined; + const encryptedAttachments = await this.view.attachmentsModule.attachment.collectEncryptAttachments(pubkeys); + const pubkeyRecipients: { [type in RecipientType]?: EmailParts[] } = {}; + for (const [sendingType, value] of Object.entries(newMsg.recipients)) { + if (Api.isRecipientHeaderNameType(sendingType)) { + pubkeyRecipients[sendingType] = value?.filter(emailPart => pubkeys.some(p => p.email === emailPart.email)); + legacyPwdRecipients[sendingType] = value?.filter( + emailPart => !pubkeys.some(p => p.email === emailPart.email) + && !individualPwdRecipients.includes(emailPart.email)); + } + } + const msgs: SendableMsg[] = []; + // pubkey recipients get one combined message. If there are not pubkey recpients, only password - protected messages will be sent + if (pubkeyRecipients.to?.length || pubkeyRecipients.cc?.length || pubkeyRecipients.bcc?.length) { + const uniquePubkeyRecipientToAndCCs = Value.arr.unique((pubkeyRecipients.to || []).concat(pubkeyRecipients.cc || []) + .map(recipient => recipient.email.toLowerCase())); + // pubkey recipients should be able to reply to "to" and "cc" pwd recipients + const replyToForMessageSentToPubkeyRecipients = (newMsg.recipients.to ?? []).concat(newMsg.recipients.cc ?? []) + .filter(recipient => !uniquePubkeyRecipientToAndCCs.includes(recipient.email.toLowerCase())); + const pubkeyMsgData = { + ...newMsg, + recipients: pubkeyRecipients, + // brackets are required for test emails like '@test:8001' + replyTo: replyToForMessageSentToPubkeyRecipients.length ? `${Str.formatEmailList([newMsg.from, ...replyToForMessageSentToPubkeyRecipients], true)}` + : undefined + }; + msgs.push(await this.sendableNonPwdMsg(pubkeyMsgData, pubkeys, signingKey?.key)); + } + // adding individual messages for each recipient that doesn't have a pubkey + for (const recipientEmail of individualPwdRecipients) { + const { url, externalId } = uploadedMessageData.emailToExternalIdAndUrl![recipientEmail]; + const foundParsedRecipient = (newMsg.recipients.to ?? []).concat(newMsg.recipients.cc ?? []).concat(newMsg.recipients.bcc ?? []). + find(r => r.email.toLowerCase() === recipientEmail.toLowerCase()); + // todo: since a message is allowed to have only `cc` or `bcc` without `to`, should we preserve the original placement(s) of the recipient? + const individualMsgData = { ...newMsg, recipients: { to: [foundParsedRecipient ?? { email: recipientEmail }] } }; + msgs.push(await this.sendablePwdMsg(individualMsgData, pubkeys, { msgUrl: url, externalId }, signingKey?.key)); + } + if (legacyPwdRecipients.to?.length || legacyPwdRecipients.cc?.length || legacyPwdRecipients.bcc?.length) { + const legacyPwdMsgData = { ...newMsg, recipients: legacyPwdRecipients }; + msgs.push(await this.sendablePwdMsg(legacyPwdMsgData, pubkeys, { msgUrl: uploadedMessageData.url }, signingKey?.key)); + } + return { senderKi: signingKey?.keyInfo, msgs, renderSentMessage: { recipients: newMsg.recipients, attachments: encryptedAttachments } }; + }; + + private prepareAndUploadPwdEncryptedMsg = async (newMsg: NewMsgData): Promise => { // PGP/MIME + included attachments (encrypted for password only) if (!newMsg.pwd) { throw new Error('password unexpectedly missing'); @@ -77,70 +156,45 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { const { bodyWithReplyToken, replyToken } = await this.getPwdMsgSendableBodyWithOnlineReplyMsgToken(authInfo, newMsg); const pgpMimeWithAttachments = await Mime.encode(bodyWithReplyToken, { Subject: newMsg.subject }, await this.view.attachmentsModule.attachment.collectAttachments()); const { data: pwdEncryptedWithAttachments } = await this.encryptDataArmor(Buf.fromUtfStr(pgpMimeWithAttachments), newMsg.pwd, []); // encrypted only for pwd, not signed - const { url, externalId } = await this.view.acctServer.messageUpload( + return await this.view.acctServer.messageUpload( authInfo.uuid ? authInfo : undefined, pwdEncryptedWithAttachments, replyToken, - newMsg.from, + newMsg.from.email, // todo: Str.formatEmailWithOptionalName? newMsg.recipients, (p) => this.view.sendBtnModule.renderUploadProgress(p, 'FIRST-HALF'), // still need to upload to Gmail later, this request represents first half of progress ); - return { url, externalId }; }; - private sendablePwdMsg = async (newMsg: NewMsgData, pubs: PubkeyResult[], { msgUrl, externalId }: { msgUrl: string, externalId?: string }, signingPrv?: Key) => { + private sendablePwdMsg = async ( + newMsg: NewMsgData, + pubs: PubkeyResult[], + { msgUrl, externalId }: { msgUrl: string, externalId?: string }, + signingPrv?: Key) => { // encoded as: PGP/MIME-like structure but with attachments as external files due to email size limit (encrypted for pubkeys only) const msgBody = this.richtext ? { 'text/plain': newMsg.plaintext, 'text/html': newMsg.plainhtml } : { 'text/plain': newMsg.plaintext }; const pgpMimeNoAttachments = await Mime.encode(msgBody, { Subject: newMsg.subject }, []); // no attachments, attached to email separately const { data: pubEncryptedNoAttachments } = await this.encryptDataArmor(Buf.fromUtfStr(pgpMimeNoAttachments), undefined, pubs, signingPrv); // encrypted only for pubs - const attachments = this.createPgpMimeAttachments(pubEncryptedNoAttachments). - concat(await this.view.attachmentsModule.attachment.collectEncryptAttachments(pubs)); // encrypted only for pubs const emailIntroAndLinkBody = await this.formatPwdEncryptedMsgBodyLink(msgUrl); - return await SendableMsg.createPwdMsg(this.acctEmail, this.headers(newMsg), emailIntroAndLinkBody, attachments, { isDraft: this.isDraft, externalId }); + return await SendableMsg.createPwdMsg(this.acctEmail, this.headers(newMsg), emailIntroAndLinkBody, + this.formatEncryptedMimeDataAsPgpMimeMetaAttachments(pubEncryptedNoAttachments), + { isDraft: this.isDraft, externalId }); }; - private sendableSimpleTextMsg = async (newMsg: NewMsgData, pubs: PubkeyResult[], signingPrv?: Key): Promise => { - const pubsForEncryption = pubs.map(entry => entry.pubkey); - if (this.isDraft) { - const { data: encrypted } = await this.encryptDataArmor(Buf.fromUtfStr(newMsg.plaintext), undefined, pubs, signingPrv); - return await SendableMsg.createInlineArmored(this.acctEmail, this.headers(newMsg), Buf.fromUint8(encrypted).toUtfStr(), [], { isDraft: this.isDraft }); - } - const x509certs = pubsForEncryption.filter(pub => pub.family === 'x509'); - if (x509certs.length) { // s/mime - const attachments: Attachment[] = this.isDraft ? [] : await this.view.attachmentsModule.attachment.collectAttachments(); // collects attachments - const msgBody = { 'text/plain': newMsg.plaintext }; - const mimeEncodedPlainMessage = await Mime.encode(msgBody, { Subject: newMsg.subject }, attachments); - let mimeData = Buf.fromUtfStr(mimeEncodedPlainMessage); - if (signingPrv) { - const signedMessage = await this.signMimeMessage(signingPrv, mimeEncodedPlainMessage, newMsg); - mimeData = Buf.fromUtfStr(await signedMessage.toMime()); - } - const encryptedMessage = await SmimeKey.encryptMessage({ pubkeys: x509certs, data: mimeData, armor: false }); - const data = encryptedMessage.data; - return await SendableMsg.createSMimeEncrypted(this.acctEmail, this.headers(newMsg), data, { isDraft: this.isDraft }); - } else { // openpgp - const attachments: Attachment[] = this.isDraft ? [] : await this.view.attachmentsModule.attachment.collectEncryptAttachments(pubs); - const encrypted = await this.encryptDataArmor(Buf.fromUtfStr(newMsg.plaintext), undefined, pubs, signingPrv); - return await SendableMsg.createInlineArmored(this.acctEmail, this.headers(newMsg), Buf.fromUint8(encrypted.data).toUtfStr(), attachments, { isDraft: this.isDraft }); + private sendableSmimeMsg = async (newMsg: NewMsgData, x509certs: Key[], signingPrv?: Key): Promise => { + const plainAttachments: Attachment[] = this.isDraft ? [] : await this.view.attachmentsModule.attachment.collectAttachments(); + const msgBody = { 'text/plain': newMsg.plaintext }; // todo: richtext #4047 + const mimeEncodedPlainMessage = await Mime.encode(msgBody, { Subject: newMsg.subject }, plainAttachments); + let mimeData = Buf.fromUtfStr(mimeEncodedPlainMessage); + if (signingPrv) { + const signedMessage = await this.signMimeMessage(signingPrv, mimeEncodedPlainMessage, newMsg); + mimeData = Buf.fromUtfStr(await signedMessage.toMime()); } + const encryptedMessage = await SmimeKey.encryptMessage({ pubkeys: x509certs, data: mimeData, armor: false }); + return await SendableMsg.createSMimeEncrypted(this.acctEmail, this.headers(newMsg), encryptedMessage.data, { isDraft: this.isDraft }); }; - private sendableRichTextMsg = async (newMsg: NewMsgData, pubs: PubkeyResult[], signingPrv?: Key) => { - // todo: pubs.type === 'x509' #4047 - const plainAttachments = this.isDraft ? [] : await this.view.attachmentsModule.attachment.collectAttachments(); - if (this.isDraft) { // this patch is needed as gmail makes it hard (or impossible) to render messages saved as https://tools.ietf.org/html/rfc3156 - const pgpMimeToEncrypt = await Mime.encode({ 'text/plain': newMsg.plaintext, 'text/html': newMsg.plainhtml }, { Subject: newMsg.subject }, plainAttachments); - const { data: encrypted } = await this.encryptDataArmor(Buf.fromUtfStr(pgpMimeToEncrypt), undefined, pubs, signingPrv); - return await SendableMsg.createInlineArmored(this.acctEmail, this.headers(newMsg), Buf.fromUint8(encrypted).toUtfStr(), plainAttachments, { isDraft: this.isDraft }); - } - const pgpMimeToEncrypt = await Mime.encode({ 'text/plain': newMsg.plaintext, 'text/html': newMsg.plainhtml }, { Subject: newMsg.subject }, plainAttachments); - // todo: don't armor S/MIME and decide what to do with attachments #4046 and #4047 - const { data: encrypted } = await this.encryptDataArmor(Buf.fromUtfStr(pgpMimeToEncrypt), undefined, pubs, signingPrv); - const attachments = this.createPgpMimeAttachments(encrypted); - return await SendableMsg.createPgpMime(this.acctEmail, this.headers(newMsg), attachments, { isDraft: this.isDraft }); - }; - - private createPgpMimeAttachments = (data: Uint8Array) => { + private formatEncryptedMimeDataAsPgpMimeMetaAttachments = (data: Uint8Array) => { const attachments: Attachment[] = []; attachments.push(new Attachment({ data: Buf.fromUtfStr('Version: 1'), type: 'application/pgp-encrypted', contentDescription: 'PGP/MIME version identification' })); attachments.push(new Attachment({ data, type: 'application/octet-stream', contentDescription: 'OpenPGP encrypted message', name: 'encrypted.asc', inline: true })); @@ -165,7 +219,7 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { 'class': 'cryptup_reply', 'cryptup-data': Str.htmlAttrEncode({ sender: newMsgData.from, - recipient: Value.arr.withoutVal(Value.arr.withoutVal(recipients, newMsgData.from), this.acctEmail), + recipient: Value.arr.withoutVal(Value.arr.withoutVal(recipients, newMsgData.from.email), this.acctEmail), subject: newMsgData.subject, token: response.replyToken, }) diff --git a/extension/chrome/elements/compose-modules/formatters/general-mail-formatter.ts b/extension/chrome/elements/compose-modules/formatters/general-mail-formatter.ts index a3e3ab1a848..ec260ac5df0 100644 --- a/extension/chrome/elements/compose-modules/formatters/general-mail-formatter.ts +++ b/extension/chrome/elements/compose-modules/formatters/general-mail-formatter.ts @@ -11,28 +11,38 @@ import { SignedMsgMailFormatter } from './signed-msg-mail-formatter.js'; import { ComposeView } from '../../compose.js'; import { KeyStoreUtil, ParsedKeyInfo } from "../../../../js/common/core/crypto/key-store-util.js"; import { UnreportableError } from '../../../../js/common/platform/catch.js'; +import { ParsedRecipients } from '../../../../js/common/api/email-provider/email-provider-api.js'; +import { Attachment } from '../../../../js/common/core/attachment.js'; + +export type MultipleMessages = { + msgs: SendableMsg[]; + senderKi: KeyInfoWithIdentity | undefined; + renderSentMessage: { recipients: ParsedRecipients, attachments: Attachment[] }; +}; export class GeneralMailFormatter { // returns undefined in case user cancelled decryption of the signing key - public static processNewMsg = async (view: ComposeView, newMsgData: NewMsgData): Promise<{ msg: SendableMsg, senderKi: KeyInfoWithIdentity | undefined }> => { + public static processNewMsg = async (view: ComposeView, newMsgData: NewMsgData): Promise => { const choices = view.sendBtnModule.popover.choices; const recipientsEmails = getUniqueRecipientEmails(newMsgData.recipients); if (!choices.encrypt && !choices.sign) { // plain view.S.now('send_btn_text').text('Formatting...'); - return { senderKi: undefined, msg: await new PlainMsgMailFormatter(view).sendableMsg(newMsgData) }; + const msg = await new PlainMsgMailFormatter(view).sendableMsg(newMsgData); + return { senderKi: undefined, msgs: [msg], renderSentMessage: { recipients: msg.recipients, attachments: msg.attachments } }; } if (!choices.encrypt && choices.sign) { // sign only view.S.now('send_btn_text').text('Signing...'); - const senderKis = await view.storageModule.getAccountKeys(newMsgData.from); + const senderKis = await view.storageModule.getAccountKeys(newMsgData.from.email); const signingKey = await GeneralMailFormatter.chooseSigningKeyAndDecryptIt(view, senderKis); if (!signingKey) { throw new UnreportableError('Could not find account key usable for signing this plain text message'); } - return { senderKi: signingKey!.keyInfo, msg: await new SignedMsgMailFormatter(view).sendableMsg(newMsgData, signingKey!.key) }; + const msg = await new SignedMsgMailFormatter(view).sendableMsg(newMsgData, signingKey!.key); + return { senderKi: signingKey!.keyInfo, msgs: [msg], renderSentMessage: { recipients: msg.recipients, attachments: msg.attachments } }; } // encrypt (optionally sign) - const singleFamilyKeys = await view.storageModule.collectSingleFamilyKeys(recipientsEmails, newMsgData.from, choices.sign); + const singleFamilyKeys = await view.storageModule.collectSingleFamilyKeys(recipientsEmails, newMsgData.from.email, choices.sign); if (singleFamilyKeys.emailsWithoutPubkeys.length) { await view.errModule.throwIfEncryptionPasswordInvalid(newMsgData); } @@ -46,7 +56,7 @@ export class GeneralMailFormatter { } } view.S.now('send_btn_text').text('Encrypting...'); - return { senderKi: signingKey?.keyInfo, msg: await new EncryptedMsgMailFormatter(view).sendableMsg(newMsgData, singleFamilyKeys.pubkeys, signingKey?.key) }; + return await new EncryptedMsgMailFormatter(view).sendableMsgs(newMsgData, singleFamilyKeys.pubkeys, signingKey); }; private static chooseSigningKeyAndDecryptIt = async ( diff --git a/extension/chrome/elements/compose.htm b/extension/chrome/elements/compose.htm index c6c043ece48..8ff4144760c 100644 --- a/extension/chrome/elements/compose.htm +++ b/extension/chrome/elements/compose.htm @@ -166,9 +166,9 @@

New Secure Message

-
to:
-
cc:
-
bcc:
+
to:
+
cc:
+
bcc:
@@ -181,7 +181,7 @@

New Secure Message

-
+
diff --git a/extension/js/common/api/account-server.ts b/extension/js/common/api/account-server.ts index 3c28c2104a7..cf7ace7fb93 100644 --- a/extension/js/common/api/account-server.ts +++ b/extension/js/common/api/account-server.ts @@ -8,6 +8,12 @@ import { BackendRes, FcUuidAuth, FlowCryptComApi, ProfileUpdate } from './accoun import { ParsedRecipients } from './email-provider/email-provider-api.js'; import { Api, ProgressCb } from './shared/api.js'; +export type UploadedMessageData = { + url: string, // both FES and FlowCryptComApi + externalId?: string, // legacy FES + emailToExternalIdAndUrl?: { [email: string]: { url: string, externalId: string } } // FES only +}; + /** * This may be calling to FlowCryptComApi or Enterprise Server (FES, customer on-prem) depending on * whether FES is deployed on the customer domain or not. @@ -58,7 +64,7 @@ export class AccountServer extends Api { from: string, recipients: ParsedRecipients, progressCb: ProgressCb - ): Promise<{ url: string, externalId?: string }> => { + ): Promise => { if (await this.isFesUsed()) { const fes = new EnterpriseServer(this.acctEmail); // Recipients are used to later cross-check replies from the web diff --git a/extension/js/common/api/account-servers/enterprise-server.ts b/extension/js/common/api/account-servers/enterprise-server.ts index 9ad068c124b..3ac53702658 100644 --- a/extension/js/common/api/account-servers/enterprise-server.ts +++ b/extension/js/common/api/account-servers/enterprise-server.ts @@ -23,7 +23,11 @@ type EventTag = 'compose' | 'decrypt' | 'setup' | 'settings' | 'import-pub' | 'i export namespace FesRes { export type ReplyToken = { replyToken: string }; - export type MessageUpload = { url: string; externalId: string }; + export type MessageUpload = { + url: string, // LEGACY + externalId: string, // LEGACY + emailToExternalIdAndUrl?: { [email: string]: { url: string, externalId: string } } + }; export type ServiceInfo = { vendor: string, service: string, orgId: string, version: string, apiVersion: string }; export type ClientConfiguration = { clientConfiguration: ClientConfigurationJson }; } diff --git a/extension/js/common/api/email-provider/sendable-msg.ts b/extension/js/common/api/email-provider/sendable-msg.ts index 3db6e0ce1ec..445c07c8eae 100644 --- a/extension/js/common/api/email-provider/sendable-msg.ts +++ b/extension/js/common/api/email-provider/sendable-msg.ts @@ -13,6 +13,7 @@ import { ParsedRecipients } from './email-provider-api.js'; type SendableMsgHeaders = { headers?: Dict; from: string; + replyTo?: string; recipients: ParsedRecipients; subject: string; thread?: string; @@ -88,12 +89,15 @@ export class SendableMsg { attachments: Attachment[], options: SendableMsgOptions ): Promise => { - const { from, recipients, subject, thread } = headers; + const { from, replyTo, recipients, subject, thread } = headers; const { type, isDraft, externalId } = options; - return await SendableMsg.create(acctEmail, { from, recipients, subject, thread, body, attachments, type, isDraft, externalId }); + return await SendableMsg.create(acctEmail, { from, replyTo, recipients, subject, thread, body, attachments, type, isDraft, externalId }); }; - private static create = async (acctEmail: string, { from, recipients, subject, thread, body, attachments, type, isDraft, externalId }: SendableMsgDefinition): Promise => { + private static create = async ( + acctEmail: string, + { from, replyTo, recipients, subject, thread, body, attachments, type, isDraft, externalId }: SendableMsgDefinition + ): Promise => { const mostUsefulPrv = KeyStoreUtil.chooseMostUseful( await KeyStoreUtil.parse(await KeyStore.getRequired(acctEmail)), 'EVEN-IF-UNUSABLE' @@ -107,6 +111,7 @@ export class SendableMsg { headers, isDraft === true, from, + replyTo, recipients, subject, body || {}, @@ -122,6 +127,7 @@ export class SendableMsg { public headers: Dict, isDraft: boolean, public from: string, + public replyTo: string | undefined, public recipients: ParsedRecipients, public subject: string, public body: SendableMsgBody, @@ -130,18 +136,24 @@ export class SendableMsg { public type: MimeEncodeType, public externalId?: string, // for binding a password-protected message ) { - const allEmails = [...recipients.to || [], ...recipients.cc || [], ...recipients.bcc || []]; + const allEmails = this.getAllRecipients(); if (!allEmails.length && !isDraft) { - throw new Error('The To: field is empty. Please add recipients and try again'); + throw new Error('The To:, Cc: and Bcc: fields are empty. Please add recipients and try again'); } const invalidEmails = allEmails.filter(email => !Str.isEmailValid(email.email)); + // todo: distinguish To:, Cc: and Bcc: in error report? if (invalidEmails.length) { throw new InvalidRecipientError(`The To: field contains invalid emails: ${invalidEmails.join(', ')}\n\nPlease check recipients and try again.`); } } + public getAllRecipients = () => [...this.recipients.to || [], ...this.recipients.cc || [], ...this.recipients.bcc || []]; + public toMime = async () => { this.headers.From = this.from; + if (this.replyTo) { + this.headers['Reply-To'] = this.replyTo; + } for (const [recipientType, value] of Object.entries(this.recipients)) { if (value && value!.length) { // todo - properly escape/encode this header using emailjs diff --git a/extension/js/common/api/shared/api.ts b/extension/js/common/api/shared/api.ts index 4eb3a70c044..64d085b481e 100644 --- a/extension/js/common/api/shared/api.ts +++ b/extension/js/common/api/shared/api.ts @@ -143,6 +143,10 @@ export class Api { return bytes.map(b => ('0' + (b & 0xFF).toString(16)).slice(-2)).join(''); }; + public static isRecipientHeaderNameType = (value: string): value is "to" | "cc" | "bcc" => { + return ['to', 'cc', 'bcc'].includes(value); + }; + protected static apiCall = async ( url: string, path: string, diff --git a/extension/js/common/core/common.ts b/extension/js/common/core/common.ts index 0884a817a5d..a3f0e68f5f7 100644 --- a/extension/js/common/core/common.ts +++ b/extension/js/common/core/common.ts @@ -48,8 +48,12 @@ export class Str { return str.replace(/[.~!$%^*=?]/gi, ''); }; - public static formatEmailWithOptionalName = ({ email, name }: EmailParts): string => { - return name ? `${Str.rmSpecialCharsKeepUtf(name, 'ALLOW-SOME')} <${email}>` : email; + public static formatEmailWithOptionalName = (emailParts: EmailParts): string => { + return Str.formatEmailWithOptionalNameEx(emailParts); + }; + + public static formatEmailList = (list: EmailParts[], forceBrackets?: boolean): string => { + return list.map(x => Str.formatEmailWithOptionalNameEx(x, forceBrackets)).join(', '); }; public static prettyPrint = (obj: any) => { @@ -170,6 +174,13 @@ export class Str { return rtlCount > lrtCount; }; + private static formatEmailWithOptionalNameEx = ({ email, name }: EmailParts, forceBrackets?: boolean): string => { + if (name) { + return `${Str.rmSpecialCharsKeepUtf(name, 'ALLOW-SOME')} <${email}>`; + } + return forceBrackets ? `<${email}>` : email; + }; + private static base64urlUtfEncode = (str: string) => { // https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings if (typeof str === 'undefined') { diff --git a/test/source/mock/fes/fes-endpoints.ts b/test/source/mock/fes/fes-endpoints.ts index 7a34cd25907..4e7bca40547 100644 --- a/test/source/mock/fes/fes-endpoints.ts +++ b/test/source/mock/fes/fes-endpoints.ts @@ -2,13 +2,175 @@ import { expect } from 'chai'; import { IncomingMessage } from 'http'; +import { Buf } from '../../core/buf'; +import { MsgUtil } from '../../core/crypto/pgp/msg-util'; import { HandlersDefinition } from '../all-apis-mock'; -import { HttpClientErr } from '../lib/api'; +import { HttpClientErr, Status } from '../lib/api'; import { MockJwt } from '../lib/oauth'; const standardFesUrl = 'fes.standardsubdomainfes.test:8001'; const issuedAccessTokens: string[] = []; +const processMessageFromUser = async (body: string) => { + expect(body).to.contain('-----BEGIN PGP MESSAGE-----'); + expect(body).to.contain('"associateReplyToken":"mock-fes-reply-token"'); + expect(body).to.contain('"to":["Mr To "]'); + expect(body).to.contain('"cc":[]'); + expect(body).to.contain('"bcc":["Mr Bcc "]'); + const encryptedData = Buf.fromUtfStr(body.match(/-----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE-----/s)![0]); + const decrypted = await MsgUtil.decryptMessage({ kisWithPp: [], msgPwd: 'lousy pwdgO0d-pwd', encryptedData, verificationPubs: [] }); + expect(decrypted.success).to.equal(true); + const decryptedMimeMsg = decrypted.content!.toUtfStr(); + expect(decryptedMimeMsg).to.contain('Content-Type: text/plain\r\n' + + 'Content-Transfer-Encoding: quoted-printable\r\n\r\n' + + 'PWD encrypted message with FES - ID TOKEN'); + // small.txt + expect(decryptedMimeMsg).to.contain('Content-Type: text/plain; name=small.txt\r\n' + + 'Content-Disposition: attachment; filename=small.txt'); + expect(decryptedMimeMsg).to.contain('Content-Transfer-Encoding: base64\r\n\r\n' + + 'c21hbGwgdGV4dCBmaWxlCm5vdCBtdWNoIGhlcmUKdGhpcyB3b3JrZWQK'); + const response = + { + // this url is required for pubkey encrypted message + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-ID`, + externalId: 'FES-MOCK-EXTERNAL-ID', + emailToExternalIdAndUrl: {} as { [email: string]: { url: string, externalId: string } } + }; + response.emailToExternalIdAndUrl['to@example.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-TO@EXAMPLE.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-TO@EXAMPLE.COM-ID' + }; + response.emailToExternalIdAndUrl['bcc@example.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-BCC@EXAMPLE.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-BCC@EXAMPLE.COM-ID' + }; + return response; +}; + +const processMessageFromUser2 = async (body: string) => { + expect(body).to.contain('-----BEGIN PGP MESSAGE-----'); + expect(body).to.contain('"associateReplyToken":"mock-fes-reply-token"'); + expect(body).to.contain('"to":["sender@domain.com","flowcrypt.compatibility@gmail.com","to@example.com","mock.only.pubkey@flowcrypt.com"]'); + expect(body).to.contain('"cc":[]'); + expect(body).to.contain('"bcc":[]'); + const encryptedData = Buf.fromUtfStr(body.match(/-----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE-----/s)![0]); + const decrypted = await MsgUtil.decryptMessage({ kisWithPp: [], msgPwd: 'gO0d-pwd', encryptedData, verificationPubs: [] }); + expect(decrypted.success).to.equal(true); + const decryptedMimeMsg = decrypted.content!.toUtfStr(); + // small.txt + expect(decryptedMimeMsg).to.contain('Content-Type: text/plain; name=small.txt\r\n' + + 'Content-Disposition: attachment; filename=small.txt'); + expect(decryptedMimeMsg).to.contain('Content-Transfer-Encoding: base64\r\n\r\n' + + 'c21hbGwgdGV4dCBmaWxlCm5vdCBtdWNoIGhlcmUKdGhpcyB3b3JrZWQK'); + // small.pdf + expect(decryptedMimeMsg).to.contain('Content-Type: application/pdf; name=small.pdf\r\n' + + 'Content-Disposition: attachment; filename=small.pdf'); + expect(decryptedMimeMsg).to.contain('Content-Transfer-Encoding: base64\r\n\r\n' + + 'JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURl'); + const response = { + // this url is required for pubkey encrypted message + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-ID`, + externalId: 'FES-MOCK-EXTERNAL-ID', + emailToExternalIdAndUrl: {} as { [email: string]: { url: string, externalId: string } } + }; + response.emailToExternalIdAndUrl['to@example.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-TO@EXAMPLE.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-TO@EXAMPLE.COM-ID' + }; + response.emailToExternalIdAndUrl['sender@domain.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-SENDER@DOMAIN.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-SENDER@DOMAIN.COM-ID' + }; + response.emailToExternalIdAndUrl['flowcrypt.compatibility@gmail.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-FLOWCRYPT.COMPATIBILITY@GMAIL.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-FLOWCRYPT.COMPATIBILITY@GMAIL.COM-ID' + }; + response.emailToExternalIdAndUrl['mock.only.pubkey@flowcrypt.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-MOCK.ONLY.PUBKEY@FLOWCRYPT.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-MOCK.ONLY.PUBKEY@FLOWCRYPT.COM-ID' + }; + return response; +}; + +const processMessageFromUser3 = async (body: string) => { + expect(body).to.contain('-----BEGIN PGP MESSAGE-----'); + expect(body).to.contain('"associateReplyToken":"mock-fes-reply-token"'); + expect(body).to.contain('"to":["to@example.com"]'); + expect(body).to.contain('"cc":[]'); + expect(body).to.contain('"bcc":["flowcrypt.compatibility@gmail.com"]'); + const encryptedData = Buf.fromUtfStr(body.match(/-----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE-----/s)![0]); + const decrypted = await MsgUtil.decryptMessage({ kisWithPp: [], msgPwd: 'gO0d-pwd', encryptedData, verificationPubs: [] }); + expect(decrypted.success).to.equal(true); + const decryptedMimeMsg = decrypted.content!.toUtfStr(); + // small.txt + expect(decryptedMimeMsg).to.contain('Content-Type: text/plain\r\n' + + 'Content-Transfer-Encoding: quoted-printable\r\n\r\n' + + 'PWD encrypted message with FES - pubkey recipient in bcc'); + const response = + { + // this url is required for pubkey encrypted message + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-ID`, + externalId: 'FES-MOCK-EXTERNAL-ID', + emailToExternalIdAndUrl: {} as { [email: string]: { url: string, externalId: string } } + }; + response.emailToExternalIdAndUrl['to@example.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-TO@EXAMPLE.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-TO@EXAMPLE.COM-ID' + }; + response.emailToExternalIdAndUrl['flowcrypt.compatibility@gmail.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-FLOWCRYPT.COMPATIBILITY@GMAIL.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-FLOWCRYPT.COMPATIBILITY@GMAIL.COM-ID' + }; + return response; +}; + +const processMessageFromUser4 = async (body: string) => { + const response = + { + // this url is required for pubkey encrypted message + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-ID`, + externalId: 'FES-MOCK-EXTERNAL-ID', + emailToExternalIdAndUrl: {} as { [email: string]: { url: string, externalId: string } } + }; + if (body.includes("to@example.com")) { + response.emailToExternalIdAndUrl['to@example.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-TO@EXAMPLE.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-TO@EXAMPLE.COM-ID' + }; + } + if (body.includes("invalid@example.com")) { + response.emailToExternalIdAndUrl['invalid@example.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-INVALID@EXAMPLE.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-INVALID@EXAMPLE.COM-ID' + }; + } + if (body.includes("timeout@example.com")) { + response.emailToExternalIdAndUrl['timeout@example.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-TIMEOUT@EXAMPLE.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-TIMEOUT@EXAMPLE.COM-ID' + }; + } + if (body.includes("Mr Cc ")) { + response.emailToExternalIdAndUrl['cc@example.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-CC@EXAMPLE.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-CC@EXAMPLE.COM-ID' + }; + } + if (body.includes("First Last ")) { + response.emailToExternalIdAndUrl['flowcrypt.compatibility@gmail.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-FLOWCRYPT.COMPATIBILITY@GMAIL.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-FLOWCRYPT.COMPATIBILITY@GMAIL.COM-ID' + }; + } + if (body.includes("gatewayfailure@example.com")) { + response.emailToExternalIdAndUrl['gatewayfailure@example.com'] = { + url: `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID`, + externalId: 'FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID' + }; + } + return response; +}; + export const mockFesEndpoints: HandlersDefinition = { // standard fes location at https://fes.domain.com '/api/': async ({ }, req) => { @@ -54,16 +216,21 @@ export const mockFesEndpoints: HandlersDefinition = { }, '/api/v1/message': async ({ body }, req) => { // body is a mime-multipart string, we're doing a few smoke checks here without parsing it - if (req.headers.host === standardFesUrl && req.method === 'POST') { + if (req.headers.host === standardFesUrl && req.method === 'POST' && typeof body === 'string') { // test: `compose - user@standardsubdomainfes.test:8001 - PWD encrypted message with FES web portal` authenticate(req, 'oidc'); - expect(body).to.contain('-----BEGIN PGP MESSAGE-----'); - expect(body).to.contain('"associateReplyToken":"mock-fes-reply-token"'); - expect(body).to.contain('"to":["Mr To "]'); - expect(body).to.contain('"cc":[]'); - expect(body).to.contain('"bcc":["Mr Bcc "]'); - expect(body).to.contain('"from":"user@standardsubdomainfes.test:8001"'); - return { 'url': `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-ID`, 'externalId': 'FES-MOCK-EXTERNAL-ID' }; + if (body.includes('"from":"user@standardsubdomainfes.test:8001"')) { + return await processMessageFromUser(body); + } + if (body.includes('"from":"user2@standardsubdomainfes.test:8001"')) { + return await processMessageFromUser2(body); + } + if (body.includes('"from":"user3@standardsubdomainfes.test:8001"')) { + return await processMessageFromUser3(body); + } + if (body.includes('"from":"user4@standardsubdomainfes.test:8001"')) { + return await processMessageFromUser4(body); + } } throw new HttpClientErr('Not Found', 404); }, @@ -75,7 +242,41 @@ export const mockFesEndpoints: HandlersDefinition = { return {}; } throw new HttpClientErr('Not Found', 404); - } + }, + '/api/v1/message/FES-MOCK-EXTERNAL-FOR-SENDER@DOMAIN.COM-ID/gateway': async ({ body }, req) => { + if (req.headers.host === standardFesUrl && req.method === 'POST') { + // test: `compose - user2@standardsubdomainfes.test:8001 - PWD encrypted message with FES - Reply rendering` + authenticate(req, 'oidc'); + expect(body).to.match(/{"emailGatewayMessageId":"<(.+)@standardsubdomainfes.test:8001>"}/); + return {}; + } + throw new HttpClientErr('Not Found', 404); + }, + '/api/v1/message/FES-MOCK-EXTERNAL-FOR-TO@EXAMPLE.COM-ID/gateway': async ({ body }, req) => { + if (req.headers.host === standardFesUrl && req.method === 'POST') { + // test: `compose - user@standardsubdomainfes.test:8001 - PWD encrypted message with FES web portal` + // test: `compose - user2@standardsubdomainfes.test:8001 - PWD encrypted message with FES - Reply rendering` + // test: `compose - user3@standardsubdomainfes.test:8001 - PWD encrypted message with FES web portal - pubkey recipient in bcc` + // test: `compose - user4@standardsubdomainfes.test:8001 - PWD encrypted message with FES web portal - some sends fail with BadRequest error` + authenticate(req, 'oidc'); + expect(body).to.match(/{"emailGatewayMessageId":"<(.+)@standardsubdomainfes.test:8001>"}/); + return {}; + } + throw new HttpClientErr('Not Found', 404); + }, + '/api/v1/message/FES-MOCK-EXTERNAL-FOR-BCC@EXAMPLE.COM-ID/gateway': async ({ body }, req) => { + if (req.headers.host === standardFesUrl && req.method === 'POST') { + // test: `compose - user@standardsubdomainfes.test:8001 - PWD encrypted message with FES web portal` + authenticate(req, 'oidc'); + expect(body).to.match(/{"emailGatewayMessageId":"<(.+)@standardsubdomainfes.test:8001>"}/); + return {}; + } + throw new HttpClientErr('Not Found', 404); + }, + '/api/v1/message/FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID/gateway': async () => { + // test: `user4@standardsubdomainfes.test:8001 - PWD encrypted message with FES web portal - a send fails with gateway update error` + throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); + }, }; const authenticate = (req: IncomingMessage, type: 'oidc' | 'fes'): string => { diff --git a/test/source/mock/google/exported-messages/message-export-1803be3182d1937b.json b/test/source/mock/google/exported-messages/message-export-1803be3182d1937b.json new file mode 100644 index 00000000000..1f85a37dd1f --- /dev/null +++ b/test/source/mock/google/exported-messages/message-export-1803be3182d1937b.json @@ -0,0 +1,97 @@ +{ + "acctEmail": "user2@standardsubdomainfes.test:8001", + "full": { + "id": "1803be3182d1937b", + "threadId": "1803be2e506153d2", + "labelIds": [ + "IMPORTANT", + "SENT", + "INBOX" + ], + "snippet": "some dummy text", + "payload": { + "partId": "", + "mimeType": "multipart/alternative", + "filename": "", + "headers": [ + { + "name": "MIME-Version", + "value": "1.0" + }, + { + "name": "Date", + "value": "Mon, 18 Apr 2022 11:56:18 +0300" + }, + { + "name": "Subject", + "value": "PWD encrypted message with FES - Reply rendering" + }, + { + "name": "From", + "value": "sender@domain.com" + }, + { + "name": "To", + "value": "flowcrypt.Compatibility@gmail.com, Mr To , mock.only.puBkey@flowcrypt.com" + }, + { + "name": "Content-Type", + "value": "multipart/alternative; boundary=\"0000000000001c389105dce9ef23\"" + } + ], + "body": { + "size": 0 + }, + "parts": [ + { + "partId": "0", + "mimeType": "text/plain", + "filename": "", + "headers": [ + { + "name": "Content-Type", + "value": "text/plain; charset=\"UTF-8\"" + } + ], + "body": { + "size": 17, + "data": "c29tZSBkdW1teSB0ZXh0DQo=" + } + }, + { + "partId": "1", + "mimeType": "text/html", + "filename": "", + "headers": [ + { + "name": "Content-Type", + "value": "text/html; charset=\"UTF-8\"" + } + ], + "body": { + "size": 38, + "data": "PGRpdiBkaXI9Imx0ciI-c29tZSBkdW1teSB0ZXh0PC9kaXY-DQo=" + } + } + ] + }, + "sizeEstimate": 595, + "historyId": "2271300", + "internalDate": "1650272178000" + }, + "attachments": {}, + "raw": { + "id": "1803be3182d1937b", + "threadId": "1803be2e506153d2", + "labelIds": [ + "IMPORTANT", + "SENT", + "INBOX" + ], + "snippet": "some dummy text", + "sizeEstimate": 595, + "raw": "TUlNRS1WZXJzaW9uOiAxLjANCkRhdGU6IE1vbiwgMTggQXByIDIwMjIgMTE6NTY6MTggKzAzMDANCk1lc3NhZ2UtSUQ6IDxDQU85Rlk5dnE5ejRqVXE3WnAzUUFuRWtNdC04Y05yQmlxS0hSQngxMTRWYmNMcHN5ZVFAbWFpbC5nbWFpbC5jb20-DQpTdWJqZWN0OiBzb21lIGR1bW15IG1lc3NhZ2UNCkZyb206IEdtYWlsIENJIFRlc3QgPGNpLnRlc3RzLmdtYWlsQGZsb3djcnlwdC5kZXY-DQpUbzogR21haWwgQ0kgVGVzdCA8Y2kudGVzdHMuZ21haWxAZmxvd2NyeXB0LmRldj4NCkNvbnRlbnQtVHlwZTogbXVsdGlwYXJ0L2FsdGVybmF0aXZlOyBib3VuZGFyeT0iMDAwMDAwMDAwMDAwMWMzODkxMDVkY2U5ZWYyMyINCg0KLS0wMDAwMDAwMDAwMDAxYzM4OTEwNWRjZTllZjIzDQpDb250ZW50LVR5cGU6IHRleHQvcGxhaW47IGNoYXJzZXQ9IlVURi04Ig0KDQpzb21lIGR1bW15IHRleHQNCg0KLS0wMDAwMDAwMDAwMDAxYzM4OTEwNWRjZTllZjIzDQpDb250ZW50LVR5cGU6IHRleHQvaHRtbDsgY2hhcnNldD0iVVRGLTgiDQoNCjxkaXYgZGlyPSJsdHIiPnNvbWUgZHVtbXkgdGV4dDwvZGl2Pg0KDQotLTAwMDAwMDAwMDAwMDFjMzg5MTA1ZGNlOWVmMjMtLQ==", + "historyId": "2271300", + "internalDate": "1650272178000" + } +} \ No newline at end of file diff --git a/test/source/mock/google/google-data.ts b/test/source/mock/google/google-data.ts index ddddb3e7615..84726f2db4a 100644 --- a/test/source/mock/google/google-data.ts +++ b/test/source/mock/google/google-data.ts @@ -4,6 +4,7 @@ import { AddressObject, ParsedMail, StructuredHeader } from 'mailparser'; import { Util } from '../../util/index'; import { readFile, readdir } from 'fs'; +import { ParseMsgResult } from '../../util/parse'; type GmailMsg$header = { name: string, value: string }; type GmailMsg$payload$body = { attachmentId?: string, size: number, data?: string }; @@ -177,8 +178,9 @@ export class GoogleData { } } - public storeSentMessage = (parsedMail: ParsedMail, base64Msg: string, id: string): string => { + public storeSentMessage = (parseResult: ParseMsgResult, id: string): string => { let bodyContentAtt: { data: string; size: number; filename?: string; id: string } | undefined; + const parsedMail = parseResult.mimeMsg; for (const attachment of parsedMail.attachments || []) { const attId = Util.lousyRandom(); const gmailAtt = { data: attachment.content.toString('base64'), size: attachment.size, filename: attachment.filename, id: attId }; @@ -198,7 +200,7 @@ export class GoogleData { } const barebonesGmailMsg: GmailMsg = { // todo - could be improved - very barebones id, - threadId: null, // tslint:disable-line:no-null-keyword + threadId: parseResult.threadId ?? null, // tslint:disable-line:no-null-keyword historyId: '', labelIds: ['SENT' as GmailMsg$labelId], payload: { @@ -208,7 +210,7 @@ export class GoogleData { ], body }, - raw: base64Msg + raw: parseResult.base64 }; DATA[this.acct].messages.push(barebonesGmailMsg); return barebonesGmailMsg.id; @@ -218,18 +220,6 @@ export class GoogleData { return DATA[this.acct].messages.find(m => m.id === id); }; - public getMessageBySubject = (subject: string): GmailMsg | undefined => { - return DATA[this.acct].messages.find(m => { - if (m.payload?.headers) { - const subjectHeader = m.payload.headers.find(x => x.name === 'Subject'); - if (subjectHeader) { - return subjectHeader.value.includes(subject); - } - } - return false; - }); - }; - public getMessagesAndDraftsByThread = (threadId: string) => { return this.getMessagesAndDrafts().filter(m => m.threadId === threadId); }; @@ -293,17 +283,17 @@ export class GoogleData { return threads; }; - // returns ordinary messages and drafts - private getMessagesAndDrafts = () => { - return DATA[this.acct].messages.concat(DATA[this.acct].drafts); - }; - - private searchMessagesBySubject = (subject: string) => { + public searchMessagesBySubject = (subject: string) => { subject = subject.trim().toLowerCase(); const messages = DATA[this.acct].messages.filter(m => GoogleData.msgSubject(m).toLowerCase().includes(subject)); return messages; }; + // returns ordinary messages and drafts + private getMessagesAndDrafts = () => { + return DATA[this.acct].messages.concat(DATA[this.acct].drafts); + }; + private searchMessagesByPeople = (includePeople: string[], excludePeople: string[]) => { includePeople = includePeople.map(person => person.trim().toLowerCase()); excludePeople = excludePeople.map(person => person.trim().toLowerCase()); diff --git a/test/source/mock/google/google-endpoints.ts b/test/source/mock/google/google-endpoints.ts index 4a9fa6b2502..41eab9ad714 100644 --- a/test/source/mock/google/google-endpoints.ts +++ b/test/source/mock/google/google-endpoints.ts @@ -15,9 +15,10 @@ type DraftSaveModel = { message: { raw: string, threadId: string } }; const allowedRecipients: Array = ['flowcrypt.compatibility@gmail.com', 'manualcopypgp@flowcrypt.com', 'censored@email.com', 'test@email.com', 'human@flowcrypt.com', 'human+nopgp@flowcrypt.com', 'expired.on.attester@domain.com', - 'ci.tests.gmail@flowcrypt.test', 'smime1@recipient.com', 'smime2@recipient.com', 'smime@recipient.com', - 'smime.attachment@recipient.com', 'auto.refresh.expired.key@recipient.com', 'to@example.com', 'cc@example.com', 'bcc@example.com', - 'flowcrypt.test.key.multiple.inbox1@gmail.com', 'flowcrypt.test.key.multiple.inbox2@gmail.com', 'mock.only.pubkey@flowcrypt.com']; + 'ci.tests.gmail@flowcrypt.test', 'smime1@recipient.com', 'smime2@recipient.com', 'smime@recipient.com', 'smime.attachment@recipient.com', + 'auto.refresh.expired.key@recipient.com', 'to@example.com', 'cc@example.com', 'bcc@example.com', 'gatewayfailure@example.com', + 'flowcrypt.test.key.multiple.inbox1@gmail.com', 'flowcrypt.test.key.multiple.inbox2@gmail.com', 'mock.only.pubkey@flowcrypt.com', + 'vladimir@flowcrypt.com', 'limon.monte@gmail.com', 'sweetalert2@gmail.com', 'sender@domain.com', 'invalid@example.com', 'timeout@example.com']; export const mockGoogleEndpoints: HandlersDefinition = { '/o/oauth2/auth': async ({ query: { client_id, response_type, access_type, state, redirect_uri, scope, login_hint, proceed } }, req) => { @@ -214,7 +215,7 @@ export const mockGoogleEndpoints: HandlersDefinition = { const id = `msg_id_${Util.lousyRandom()}`; try { const testingStrategyContext = new TestBySubjectStrategyContext(parseResult.mimeMsg.subject || ''); - await testingStrategyContext.test(parseResult.mimeMsg, parseResult.base64, id); + await testingStrategyContext.test(parseResult, id); } catch (e) { if (!(e instanceof UnsuportableStrategyError)) { // No such strategy for test throw e; // todo - should start throwing unsupported test strategies too, else changing subject will cause incomplete testing @@ -336,10 +337,8 @@ const validateMimeMsg = async (acct: string, mimeMsg: ParsedMail, threadId?: str if (!mimeMsg.text && !mimeMsg.attachments?.length) { throw new HttpClientErr('Error: Message body cannot be empty', 400); } - if ( - !parsedMailAddressObjectAsArray(mimeMsg.to).length && parsedMailAddressObjectAsArray(mimeMsg.to)[0].value.length - || parsedMailAddressObjectAsArray(mimeMsg.to)[0].value.find(em => !allowedRecipients.includes(em.address!)) - ) { + const recipients = parsedMailAddressObjectAsArray(mimeMsg.to).concat(parsedMailAddressObjectAsArray(mimeMsg.cc)).concat(parsedMailAddressObjectAsArray(mimeMsg.bcc)); + if (!recipients.length || recipients.some(addr => addr.value.some(em => !allowedRecipients.includes(em.address!)))) { throw new HttpClientErr('Error: You can\'t send a message to unexisting email address(es)'); } const aliases = [acct]; diff --git a/test/source/mock/google/strategies/send-message-strategy.ts b/test/source/mock/google/strategies/send-message-strategy.ts index 3786c14a220..347343b791e 100644 --- a/test/source/mock/google/strategies/send-message-strategy.ts +++ b/test/source/mock/google/strategies/send-message-strategy.ts @@ -1,15 +1,15 @@ /* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ import * as forge from 'node-forge'; -import { AddressObject, ParsedMail, StructuredHeader } from 'mailparser'; +import { AddressObject, StructuredHeader } from 'mailparser'; import { ITestMsgStrategy, UnsuportableStrategyError } from './strategy-base.js'; import { Buf } from '../../../core/buf'; import { Config } from '../../../util'; import { expect } from 'chai'; import { GoogleData } from '../google-data'; -import { HttpClientErr } from '../../lib/api'; +import { HttpClientErr, Status } from '../../lib/api'; import { MsgUtil } from '../../../core/crypto/pgp/msg-util'; -import Parse from '../../../util/parse'; +import Parse, { ParseMsgResult } from '../../../util/parse'; import { parsedMailAddressObjectAsArray } from '../google-endpoints.js'; import { Str } from '../../../core/common.js'; import { GMAIL_RECOVERY_EMAIL_SUBJECTS } from '../../../core/const.js'; @@ -18,13 +18,44 @@ import { testConstants } from '../../../tests/tooling/consts.js'; // TODO: Make a better structure of ITestMsgStrategy. Because this class doesn't test anything, it only saves message in the Mock class SaveMessageInStorageStrategy implements ITestMsgStrategy { - public test = async (mimeMsg: ParsedMail, base64Msg: string, id: string) => { - (await GoogleData.withInitializedData(mimeMsg.from!.value[0].address!)).storeSentMessage(mimeMsg, base64Msg, id); + public test = async (parseResult: ParseMsgResult, id: string) => { + (await GoogleData.withInitializedData(parseResult.mimeMsg.from!.value[0].address!)).storeSentMessage(parseResult, id); }; } +class PwdAndPubkeyEncryptedMessagesWithFlowCryptComApiTestStrategy implements ITestMsgStrategy { + public test = async (parseResult: ParseMsgResult, id: string) => { + const mimeMsg = parseResult.mimeMsg; + const senderEmail = Str.parseEmail(mimeMsg.from!.text).email; + await (new SaveMessageInStorageStrategy()).test(parseResult, id); + if (mimeMsg.cc) { + // this is a message to the pubkey recipient + expect((mimeMsg.cc as AddressObject).text!).to.include('flowcrypt.compatibility@gmail.com'); + expect(mimeMsg.text!).to.not.include('has sent you a password-encrypted email'); + expect(mimeMsg.text!).to.not.include('Follow this link to open it'); + const kisWithPp = await Config.getKeyInfo(['flowcrypt.compatibility.1pp1', 'flowcrypt.compatibility.2pp1']); + const encryptedData = Buf.fromUtfStr(mimeMsg.text!); + const decrypted = await MsgUtil.decryptMessage({ kisWithPp, encryptedData, verificationPubs: [] }); + // tslint:disable-next-line:no-unused-expression + expect(decrypted.success).to.be.true; // eslint-disable-line no-unused-expressions + expect(decrypted.content!.toUtfStr()).to.contain('PWD and pubkey encrypted messages with flowcrypt.com/api'); + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.bcc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.to).to.be.an.undefined; // eslint-disable-line no-unused-expressions + expect((mimeMsg.headers.get('reply-to') as AddressObject).text).to.equal('First Last , test@email.com'); + } else { + expect(mimeMsg.text!).to.contain(`${senderEmail} has sent you a password-encrypted email`); + expect(mimeMsg.text!).to.contain('Follow this link to open it'); + if (!mimeMsg.text?.match(/https:\/\/flowcrypt.com\/[a-z0-9A-Z]{10}/)) { + throw new HttpClientErr(`Error: cannot find pwd encrypted flowcrypt.com/api link in:\n\n${mimeMsg.text}`); + } + } + }; +} class PwdEncryptedMessageWithFlowCryptComApiTestStrategy implements ITestMsgStrategy { - public test = async (mimeMsg: ParsedMail) => { + public test = async (parseResult: ParseMsgResult) => { + const mimeMsg = parseResult.mimeMsg; const senderEmail = Str.parseEmail(mimeMsg.from!.text).email; if (!mimeMsg.text?.includes(`${senderEmail} has sent you a password-encrypted email`)) { throw new HttpClientErr(`Error checking sent text in:\n\n${mimeMsg.text}`); @@ -39,28 +70,141 @@ class PwdEncryptedMessageWithFlowCryptComApiTestStrategy implements ITestMsgStra } class PwdEncryptedMessageWithFesIdTokenTestStrategy implements ITestMsgStrategy { - public test = async (mimeMsg: ParsedMail, base64Msg: string, id: string) => { + public test = async (parseResult: ParseMsgResult, id: string) => { + const mimeMsg = parseResult.mimeMsg; const expectedSenderEmail = 'user@standardsubdomainfes.test:8001'; expect(mimeMsg.from!.text).to.equal(`First Last <${expectedSenderEmail}>`); - expect((mimeMsg.to as AddressObject).text).to.equal('Mr To '); - expect((mimeMsg.bcc as AddressObject).text).to.equal('Mr Bcc '); - if (!mimeMsg.text?.includes(`${expectedSenderEmail} has sent you a password-encrypted email`)) { - throw new HttpClientErr(`Error checking sent text in:\n\n${mimeMsg.text}`); - } - if (!mimeMsg.text?.includes('http://fes.standardsubdomainfes.test:8001/message/FES-MOCK-MESSAGE-ID')) { + if (mimeMsg.text?.includes('http://fes.standardsubdomainfes.test:8001/message/FES-MOCK-MESSAGE-FOR-TO@EXAMPLE.COM-ID')) { + expect((mimeMsg.to as AddressObject).text).to.equal('Mr To '); + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.cc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.bcc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + } else if (mimeMsg.text?.includes('http://fes.standardsubdomainfes.test:8001/message/FES-MOCK-MESSAGE-FOR-BCC@EXAMPLE.COM-ID')) { + expect((mimeMsg.to as AddressObject).text).to.equal('Mr Bcc '); + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.cc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.bcc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + } else { + // no pubkey recipients in this test throw new HttpClientErr(`Error: cannot find pwd encrypted FES link in:\n\n${mimeMsg.text}`); } - if (!mimeMsg.text?.includes('Follow this link to open it')) { - throw new HttpClientErr(`Error: cannot find pwd encrypted open link prompt in ${mimeMsg.text}`); + expect(mimeMsg.text!).to.include(`${expectedSenderEmail} has sent you a password-encrypted email`); + expect(mimeMsg.text!).to.include('Follow this link to open it'); + await new SaveMessageInStorageStrategy().test(parseResult, id); + }; +} + +class PwdEncryptedMessageWithFesPubkeyRecipientInBccTestStrategy implements ITestMsgStrategy { + public test = async (parseResult: ParseMsgResult, id: string) => { + const mimeMsg = parseResult.mimeMsg; + const expectedSenderEmail = 'user3@standardsubdomainfes.test:8001'; + expect(mimeMsg.from!.text).to.equal(`First Last <${expectedSenderEmail}>`); + if (mimeMsg.text?.includes('http://fes.standardsubdomainfes.test:8001/message/FES-MOCK-MESSAGE-FOR-TO@EXAMPLE.COM-ID')) { + expect(mimeMsg.text!).to.include(`${expectedSenderEmail} has sent you a password-encrypted email`); + expect(mimeMsg.text!).to.include('Follow this link to open it'); + expect((mimeMsg.to as AddressObject).text).to.equal('to@example.com'); + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.cc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.bcc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.headers.get('reply-to')).to.be.an.undefined; // eslint-disable-line no-unused-expressions + } else { + // this is a message to pubkey recipients + expect(mimeMsg.text!).to.not.include('has sent you a password-encrypted email'); + expect(mimeMsg.text!).to.not.include('Follow this link to open it'); + const kisWithPp = await Config.getKeyInfo(['flowcrypt.test.key.used.pgp']); + const encryptedData = Buf.fromUtfStr(mimeMsg.text!); + const decrypted = await MsgUtil.decryptMessage({ kisWithPp, encryptedData, verificationPubs: [] }); + // tslint:disable-next-line:no-unused-expression + expect(decrypted.success).to.be.true; // eslint-disable-line no-unused-expressions + expect(decrypted.content!.toUtfStr()).to.equal('PWD encrypted message with FES - pubkey recipient in bcc'); + expect((mimeMsg.bcc as AddressObject).text).to.equal('flowcrypt.compatibility@gmail.com'); + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.cc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.to).to.be.an.undefined; // eslint-disable-line no-unused-expressions + expect((mimeMsg.headers.get('reply-to') as AddressObject).text).to.equal('First Last , to@example.com'); } - await new SaveMessageInStorageStrategy().test(mimeMsg, base64Msg, id); + await new SaveMessageInStorageStrategy().test(parseResult, id); + }; +} + +class PwdEncryptedMessageWithFesReplyBadRequestTestStrategy implements ITestMsgStrategy { + public test = async (parseResult: ParseMsgResult, id: string) => { + const mimeMsg = parseResult.mimeMsg; + const expectedSenderEmail = 'user4@standardsubdomainfes.test:8001'; + expect(mimeMsg.from!.text).to.equal(`First Last <${expectedSenderEmail}>`); + const to = parsedMailAddressObjectAsArray(mimeMsg.to).concat(parsedMailAddressObjectAsArray(mimeMsg.cc)).concat(parsedMailAddressObjectAsArray(mimeMsg.bcc)); + expect(to.length).to.equal(1); + const recipientEmail = to[0].text; + if (recipientEmail === 'to@example.com') { + // success + await new SaveMessageInStorageStrategy().test(parseResult, id); + return; + } else if (recipientEmail === 'invalid@example.com') { + throw new HttpClientErr('Invalid to header', Status.BAD_REQUEST); + } else if (recipientEmail === 'timeout@example.com') { + throw new HttpClientErr('RequestTimeout', Status.BAD_REQUEST); + } else { + throw new HttpClientErr(`Vague failure for ${recipientEmail}`, Status.BAD_REQUEST); + } + }; +} + +class PwdEncryptedMessageWithFesReplyRenderingTestStrategy implements ITestMsgStrategy { + public test = async (parseResult: ParseMsgResult, id: string) => { + const mimeMsg = parseResult.mimeMsg; + const expectedSenderEmail = 'user2@standardsubdomainfes.test:8001'; + expect(mimeMsg.from!.text).to.equal(`First Last <${expectedSenderEmail}>`); + if (mimeMsg.text?.includes('http://fes.standardsubdomainfes.test:8001/message/FES-MOCK-MESSAGE-FOR-SENDER@DOMAIN.COM-ID')) { + expect(mimeMsg.text!).to.include(`${expectedSenderEmail} has sent you a password-encrypted email`); + expect(mimeMsg.text!).to.include('Follow this link to open it'); + expect((mimeMsg.to as AddressObject).text).to.equal('sender@domain.com'); + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.cc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.bcc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.headers.get('reply-to')).to.be.an.undefined; // eslint-disable-line no-unused-expressions + } else if (mimeMsg.text?.includes('http://fes.standardsubdomainfes.test:8001/message/FES-MOCK-MESSAGE-FOR-TO@EXAMPLE.COM-ID')) { + expect(mimeMsg.text!).to.include(`${expectedSenderEmail} has sent you a password-encrypted email`); + expect(mimeMsg.text!).to.include('Follow this link to open it'); + expect((mimeMsg.to as AddressObject).text).to.equal('to@example.com'); + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.cc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.bcc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.headers.get('reply-to')).to.be.an.undefined; // eslint-disable-line no-unused-expressions + } else { + // this is a message to pubkey recipients + expect(mimeMsg.text!).to.not.include('has sent you a password-encrypted email'); + expect(mimeMsg.text!).to.not.include('Follow this link to open it'); + const kisWithPp = await Config.getKeyInfo(['flowcrypt.test.key.used.pgp']); + const encryptedData = Buf.fromUtfStr(mimeMsg.text!); + const decrypted = await MsgUtil.decryptMessage({ kisWithPp, encryptedData, verificationPubs: [] }); + // tslint:disable-next-line:no-unused-expression + expect(decrypted.success).to.be.true; // eslint-disable-line no-unused-expressions + expect(decrypted.content!.toUtfStr()).to.include('> some dummy text'); + expect((mimeMsg.to as AddressObject).text).to.equal('flowcrypt.compatibility@gmail.com, mock.only.pubkey@flowcrypt.com'); + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.cc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + // tslint:disable-next-line:no-unused-expression + expect(mimeMsg.bcc).to.be.an.undefined; // eslint-disable-line no-unused-expressions + expect((mimeMsg.headers.get('reply-to') as AddressObject).text).to.equal('First Last , sender@domain.com, to@example.com'); + } + await new SaveMessageInStorageStrategy().test(parseResult, id); }; } class MessageWithFooterTestStrategy implements ITestMsgStrategy { private readonly footer = 'flowcrypt.compatibility test footer with an img'; - public test = async (mimeMsg: ParsedMail) => { + public test = async (parseResult: ParseMsgResult) => { + const mimeMsg = parseResult.mimeMsg; const keyInfo = await Config.getKeyInfo(["flowcrypt.compatibility.1pp1", "flowcrypt.compatibility.2pp1"]); const decrypted = await MsgUtil.decryptMessage({ kisWithPp: keyInfo!, encryptedData: Buf.fromUtfStr(mimeMsg.text || ''), verificationPubs: [] }); if (!decrypted.success) { @@ -77,7 +221,8 @@ class SignedMessageTestStrategy implements ITestMsgStrategy { private readonly expectedText = 'New Signed Message (Mock Test)'; private readonly signedBy = 'B6BE3C4293DDCF66'; // could potentially grab this from test-secrets.json file - public test = async (mimeMsg: ParsedMail) => { + public test = async (parseResult: ParseMsgResult) => { + const mimeMsg = parseResult.mimeMsg; const keyInfo = await Config.getKeyInfo(["flowcrypt.compatibility.1pp1", "flowcrypt.compatibility.2pp1"]); const decrypted = await MsgUtil.decryptMessage({ kisWithPp: keyInfo!, encryptedData: Buf.fromUtfStr(mimeMsg.text!), verificationPubs: [] }); if (!decrypted.success) { @@ -99,13 +244,18 @@ class SignedMessageTestStrategy implements ITestMsgStrategy { class PlainTextMessageTestStrategy implements ITestMsgStrategy { private readonly expectedText = 'New Plain Message'; - public test = async (mimeMsg: ParsedMail) => { + public test = async (parseResult: ParseMsgResult) => { + const mimeMsg = parseResult.mimeMsg; if (!mimeMsg.text?.includes(this.expectedText)) { throw new HttpClientErr(`Error: Msg Text is not matching expected. Current: '${mimeMsg.text}', expected: '${this.expectedText}'`); } }; } +class NoopTestStrategy implements ITestMsgStrategy { + public test = async () => { }; // tslint:disable-line:no-empty +} + class IncludeQuotedPartTestStrategy implements ITestMsgStrategy { private readonly quotedContent: string = [ 'On 2019-06-14 at 23:24, flowcrypt.compatibility@gmail.com wrote:', @@ -120,9 +270,9 @@ class IncludeQuotedPartTestStrategy implements ITestMsgStrategy { '> >> again double quote' ].join('\n'); - public test = async (mimeMsg: ParsedMail) => { + public test = async (parseResult: ParseMsgResult) => { const keyInfo = await Config.getKeyInfo(["flowcrypt.compatibility.1pp1", "flowcrypt.compatibility.2pp1"]); - const decrypted = await MsgUtil.decryptMessage({ kisWithPp: keyInfo!, encryptedData: Buf.fromUtfStr(mimeMsg.text!), verificationPubs: [] }); + const decrypted = await MsgUtil.decryptMessage({ kisWithPp: keyInfo!, encryptedData: Buf.fromUtfStr(parseResult.mimeMsg.text!), verificationPubs: [] }); if (!decrypted.success) { throw new HttpClientErr(`Error: can't decrypt message`); } @@ -134,7 +284,8 @@ class IncludeQuotedPartTestStrategy implements ITestMsgStrategy { } class NewMessageCCAndBCCTestStrategy implements ITestMsgStrategy { - public test = async (mimeMsg: ParsedMail) => { + public test = async (parseResult: ParseMsgResult) => { + const mimeMsg = parseResult.mimeMsg; const hasAtLeastOneRecipient = (ao: AddressObject[]) => ao && ao.length && ao[0].value && ao[0].value.length && ao[0].value[0].address; if (!hasAtLeastOneRecipient(parsedMailAddressObjectAsArray(mimeMsg.to))) { throw new HttpClientErr(`Error: There is no 'To' header.`, 400); @@ -149,7 +300,8 @@ class NewMessageCCAndBCCTestStrategy implements ITestMsgStrategy { } class SmimeEncryptedMessageStrategy implements ITestMsgStrategy { - public test = async (mimeMsg: ParsedMail) => { + public test = async (parseResult: ParseMsgResult) => { + const mimeMsg = parseResult.mimeMsg; expect((mimeMsg.headers.get('content-type') as StructuredHeader).value).to.equal('application/pkcs7-mime'); expect((mimeMsg.headers.get('content-type') as StructuredHeader).params.name).to.equal('smime.p7m'); expect((mimeMsg.headers.get('content-type') as StructuredHeader).params['smime-type']).to.equal('enveloped-data'); @@ -186,7 +338,8 @@ class SmimeEncryptedMessageStrategy implements ITestMsgStrategy { } class SmimeSignedMessageStrategy implements ITestMsgStrategy { - public test = async (mimeMsg: ParsedMail) => { + public test = async (parseResult: ParseMsgResult) => { + const mimeMsg = parseResult.mimeMsg; expect((mimeMsg.headers.get('content-type') as StructuredHeader).value).to.equal('application/pkcs7-mime'); expect((mimeMsg.headers.get('content-type') as StructuredHeader).params.name).to.equal('smime.p7m'); expect((mimeMsg.headers.get('content-type') as StructuredHeader).params['smime-type']).to.equal('signed-data'); @@ -219,8 +372,18 @@ export class TestBySubjectStrategyContext { this.strategy = new MessageWithFooterTestStrategy(); } else if (subject.includes('PWD encrypted message with flowcrypt.com/api')) { this.strategy = new PwdEncryptedMessageWithFlowCryptComApiTestStrategy(); + } else if (subject.includes('PWD and pubkey encrypted messages with flowcrypt.com/api')) { + this.strategy = new PwdAndPubkeyEncryptedMessagesWithFlowCryptComApiTestStrategy(); } else if (subject.includes('PWD encrypted message with FES - ID TOKEN')) { this.strategy = new PwdEncryptedMessageWithFesIdTokenTestStrategy(); + } else if (subject.includes('PWD encrypted message with FES - Reply rendering')) { + this.strategy = new PwdEncryptedMessageWithFesReplyRenderingTestStrategy(); + } else if (subject.includes('PWD encrypted message with FES - pubkey recipient in bcc')) { + this.strategy = new PwdEncryptedMessageWithFesPubkeyRecipientInBccTestStrategy(); + } else if (subject.includes('PWD encrypted message with FES web portal - some sends fail with BadRequest error')) { + this.strategy = new PwdEncryptedMessageWithFesReplyBadRequestTestStrategy(); + } else if (subject.includes('PWD encrypted message with FES web portal - a send fails with gateway update error')) { + this.strategy = new SaveMessageInStorageStrategy(); } else if (subject.includes('Message With Image')) { this.strategy = new SaveMessageInStorageStrategy(); } else if (subject.includes('Message With Test Text')) { @@ -235,12 +398,14 @@ export class TestBySubjectStrategyContext { this.strategy = new SmimeSignedMessageStrategy(); } else if (GMAIL_RECOVERY_EMAIL_SUBJECTS.includes(subject)) { this.strategy = new SaveMessageInStorageStrategy(); + } else if (subject.includes('Re: FROM: flowcrypt.compatibility@gmail.com, TO: flowcrypt.compatibility@gmail.com + vladimir@flowcrypt.com')) { + this.strategy = new NoopTestStrategy(); } else { throw new UnsuportableStrategyError(`There isn't any strategy for this subject: ${subject}`); } } - public test = async (mimeMsg: ParsedMail, base64Msg: string, id: string) => { - await this.strategy.test(mimeMsg, base64Msg, id); + public test = async (parseResult: ParseMsgResult, id: string) => { + await this.strategy.test(parseResult, id); }; } diff --git a/test/source/mock/google/strategies/strategy-base.ts b/test/source/mock/google/strategies/strategy-base.ts index 70abc7e3bab..9dfc61dbc5a 100644 --- a/test/source/mock/google/strategies/strategy-base.ts +++ b/test/source/mock/google/strategies/strategy-base.ts @@ -1,9 +1,9 @@ /* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ -import { ParsedMail } from 'mailparser'; +import { ParseMsgResult } from '../../../util/parse'; export interface ITestMsgStrategy { - test(mimeMsg: ParsedMail, base64Msg: string, id: string): Promise; + test(parseResult: ParseMsgResult, id: string): Promise; } export class UnsuportableStrategyError extends Error { } diff --git a/test/source/test.ts b/test/source/test.ts index 815bf11b106..6c07a2f452e 100644 --- a/test/source/test.ts +++ b/test/source/test.ts @@ -122,6 +122,8 @@ ava.default.after.always('evaluate Catch.reportErr errors', async t => { // our S/MIME implementation is still early so it throws "reportable" errors like this during tests const usefulErrors = mockBackendData.reportedErrors .filter(e => e.message !== 'Too few bytes to read ASN.1 value.') + // below for test "user4@standardsubdomainfes.test:8001 - PWD encrypted message with FES web portal - a send fails with gateway update error" + .filter(e => !e.message.includes('Test error')) // below for test "no.fes@example.com - skip FES on consumer, show friendly message on enterprise" .filter(e => !e.trace.includes('-1 when GET-ing https://fes.example.com')) // todo - ideally mock tests would never call this. But we do tests with human@flowcrypt.com so it's calling here diff --git a/test/source/tests/compose.ts b/test/source/tests/compose.ts index a4909c201ca..885097e7a33 100644 --- a/test/source/tests/compose.ts +++ b/test/source/tests/compose.ts @@ -2,7 +2,7 @@ import * as ava from 'ava'; -import { BrowserHandle, Controllable, ControllablePage } from './../browser'; +import { BrowserHandle, Controllable, ControllableFrame, ControllablePage } from './../browser'; import { Config, Util } from './../util'; import { writeFileSync } from 'fs'; import { AvaContext } from './tooling'; @@ -24,6 +24,7 @@ import { testConstants } from './tooling/consts'; import { MsgUtil } from '../core/crypto/pgp/msg-util'; import { Buf } from '../core/buf'; import { PubkeyInfoWithLastCheck } from '../core/crypto/key'; +import { Page } from 'puppeteer'; // tslint:disable:no-blank-lines-func // tslint:disable:no-unused-expression @@ -33,6 +34,19 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te if (testVariant !== 'CONSUMER-LIVE-GMAIL') { + ava.default('compose - send an encrypted message to a legacy pwd recipient and a pubkey recipient', testWithBrowser('compatibility', async (t, browser) => { + const acct = 'flowcrypt.compatibility@gmail.com'; + const msgPwd = 'super hard password for the message'; + const subject = 'PWD and pubkey encrypted messages with flowcrypt.com/api'; + const expectedNumberOfPassedMessages = (await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject).length + 2; + const composePage = await ComposePageRecipe.openStandalone(t, browser, 'compatibility'); + await composePage.selectOption('@input-from', acct); + await ComposePageRecipe.fillMsg(composePage, { to: 'test@email.com', cc: 'flowcrypt.compatibility@gmail.com' }, subject); + await ComposePageRecipe.sendAndClose(composePage, { password: msgPwd }); + expect((await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject).length).to.equal(expectedNumberOfPassedMessages); + // this test is using PwdAndPubkeyEncryptedMessagesWithFlowCryptComApiTestStrategy to check sent result based on subject "PWD and pubkey encrypted messages with flowcrypt.com/api" + })); + ava.default('compose - check for sender [flowcrypt.compatibility@gmail.com] from a password-protected email', testWithBrowser('compatibility', async (t, browser) => { const senderEmail = 'flowcrypt.compatibility@gmail.com'; const msgPwd = 'super hard password for the message'; @@ -815,6 +829,7 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te const fileInput = await composePage.target.$('input[type=file]'); await fileInput!.uploadFile(`test/samples/${attachmentFilename}`); await composePage.waitAndClick('@action-send', { delay: 1 }); + await composePage.waitForContent('@replied-to', 'to: censored@email.com'); const attachment = await composePage.getFrame(['attachment.htm', `name=${attachmentFilename}`]); await attachment.waitForSelTestState('ready'); const downloadedFiles = await composePage.awaitDownloadTriggeredByClicking(async () => { @@ -965,6 +980,7 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te await ComposePageRecipe.fillMsg(composePage, {}, undefined, bodyWithLeadingTabs); await composePage.click('@action-send'); await composePage.waitForContent('@container-reply-msg-successful', bodyWithLeadingTabs); + await composePage.waitForContent('@replied-to', 'to: First Last '); })); ava.default('compose - RTL subject', testWithBrowser('compatibility', async (t, browser) => { @@ -1017,7 +1033,7 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te await ComposePageRecipe.fillMsg(composePage, { to: 'flowcrypt.compatibility@gmail.com' }, subject, text, { sign: true, encrypt: true }); await ComposePageRecipe.sendAndClose(composePage); // get sent msg from mock - const sentMsg = (await GoogleData.withInitializedData('flowcrypt.compatibility@gmail.com')).getMessageBySubject(subject)!; + const sentMsg = (await GoogleData.withInitializedData('flowcrypt.compatibility@gmail.com')).searchMessagesBySubject(subject)[0]!; const message = sentMsg.payload!.body!.data!; const encrypted = message.match(/\-\-\-\-\-BEGIN PGP MESSAGE\-\-\-\-\-.*\-\-\-\-\-END PGP MESSAGE\-\-\-\-\-/s)![0]; const encryptedData = Buf.fromUtfStr(encrypted); @@ -1049,7 +1065,7 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te await ComposePageRecipe.fillMsg(composePage, { to: 'human@flowcrypt.com' }, subject, undefined, { sign: true }); await ComposePageRecipe.sendAndClose(composePage); // get sent msg from mock - const sentMsg = (await GoogleData.withInitializedData(acct)).getMessageBySubject(subject)!; + const sentMsg = (await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject)[0]!; const message = sentMsg.payload!.body!.data!; expect(message).to.include('-----BEGIN PGP MESSAGE-----'); expect(message).to.include('-----END PGP MESSAGE-----'); @@ -1549,6 +1565,12 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te to: [{ email: 'flowcrypt.compatibility@gmail.com', name: 'First Last' }, { email: 'vladimir@flowcrypt.com' }], cc: [{ email: 'limon.monte@gmail.com' }], bcc: [{ email: 'sweetalert2@gmail.com' }] }); + await composePage.waitAndType('@input-password', 'gO0d-pwd'); + await composePage.waitAndClick('@action-send', { delay: 1 }); + // test rendering of recipients after successful sending + await composePage.waitForContent('@replied-to', 'to: First Last , vladimir@flowcrypt.com'); + await composePage.waitForContent('@replied-cc', 'cc: limon.monte@gmail.com'); + await composePage.waitForContent('@replied-bcc', 'bcc: sweetalert2@gmail.com'); })); ava.default('compose - reply all - from !== acctEmail', testWithBrowser('compatibility', async (t, browser) => { @@ -1592,7 +1614,181 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te await composePage.waitAndType('@input-password', 'gO0d-pwd'); await composePage.waitAndClick('@action-send', { delay: 1 }); await ComposePageRecipe.closed(composePage); - // this test is using PwdEncryptedMessageWithFesIdTokenTestStrategy to check sent result based on subject "PWD encrypted message with flowcrypt.com/api" + const sentMsgs = (await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject); + expect(sentMsgs.length).to.equal(2); + // this test is using PwdEncryptedMessageWithFesIdTokenTestStrategy to check sent result based on subject "PWD encrypted message with FES - ID TOKEN" + // also see '/api/v1/message' in fes-endpoints.ts mock + })); + + /** + * You need the following lines in /etc/hosts: + * 127.0.0.1 standardsubdomainfes.test + * 127.0.0.1 fes.standardsubdomainfes.test + */ + ava.default('user2@standardsubdomainfes.test:8001 - PWD encrypted message with FES - Reply rendering', testWithBrowser(undefined, async (t, browser) => { + const acct = 'user2@standardsubdomainfes.test:8001'; // added port to trick extension into calling the mock + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct); + await SetupPageRecipe.manualEnter(settingsPage, 'flowcrypt.test.key.used.pgp', { submitPubkey: false, usedPgpBefore: false }, + { isSavePassphraseChecked: false, isSavePassphraseHidden: false }); + const appendUrl = 'threadId=1803be2e506153d2&skipClickPrompt=___cu_false___&ignoreDraft=___cu_false___&replyMsgId=1803be3182d1937b'; + const composePage = await ComposePageRecipe.openStandalone(t, browser, 'user2@standardsubdomainfes.test:8001', { appendUrl, hasReplyPrompt: true }); + await composePage.waitAndClick('@action-accept-reply-all-prompt', { delay: 2 }); + // we should have 4 recipients, 2 green and 2 gray + const container = (await composePage.waitAny('@container-to'))!; + const recipients = await container.$$('.recipients > span'); + expect(recipients.length).to.equal(4); + expect(await PageRecipe.getElementPropertyJson(recipients[0], 'textContent')).to.equal('sender@domain.com '); + expect(await PageRecipe.getElementPropertyJson(recipients[0], 'className')).to.equal('no_pgp'); + expect(await PageRecipe.getElementPropertyJson(recipients[1], 'textContent')).to.equal('flowcrypt.compatibility@gmail.com '); + expect(await PageRecipe.getElementPropertyJson(recipients[1], 'className')).to.equal('has_pgp'); + expect(await PageRecipe.getElementPropertyJson(recipients[2], 'textContent')).to.equal('to@example.com '); + expect(await PageRecipe.getElementPropertyJson(recipients[2], 'className')).to.equal('no_pgp'); + expect(await PageRecipe.getElementPropertyJson(recipients[3], 'textContent')).to.equal('mock.only.pubkey@flowcrypt.com '); + expect(await PageRecipe.getElementPropertyJson(recipients[3], 'className')).to.equal('has_pgp'); + const fileInput = await composePage.target.$('input[type=file]'); + await fileInput!.uploadFile('test/samples/small.txt'); + await fileInput!.uploadFile('test/samples/small.pdf'); + await composePage.waitAndType('@input-password', 'gO0d-pwd'); + await composePage.waitAndClick('@action-send', { delay: 1 }); + // this test is using PwdEncryptedMessageWithFesReplyRenderingTestStrategy to check sent result based on subject "PWD encrypted message with FES - Reply rendering" + // also see '/api/v1/message' in fes-endpoints.ts mock + const attachmentsContainer = (await composePage.waitAny('@replied-attachments'))!; + const attachments = await attachmentsContainer.$$('.pgp_attachment'); + expect(attachments.length).to.equal(2); + await composePage.waitForContent('@replied-to', 'to: sender@domain.com, flowcrypt.compatibility@gmail.com, to@example.com, mock.only.pubkey@flowcrypt.com'); + const sentMsgs = (await GoogleData.withInitializedData(acct)).getMessagesByThread('1803be2e506153d2'); + expect(sentMsgs.length).to.equal(4); // 1 original + 3 newly sent + const attachmentFrames = (composePage.target as Page).frames(); + expect(attachmentFrames.length).to.equal(3); // 1 pgp block + 2 attachments + expect(await Promise.all(attachmentFrames.filter(f => f.url().includes('attachment.htm')).map(async (frame) => + await PageRecipe.getElementPropertyJson(await new ControllableFrame(frame).waitAny("@attachment-name"), 'textContent')))). + to.eql(['small.txt.pgp', 'small.pdf.pgp']); + })); + + /** + * You need the following lines in /etc/hosts: + * 127.0.0.1 standardsubdomainfes.test + * 127.0.0.1 fes.standardsubdomainfes.test + */ + ava.default('user3@standardsubdomainfes.test:8001 - PWD encrypted message with FES web portal - pubkey recipient in bcc', testWithBrowser(undefined, async (t, browser) => { + const acct = 'user3@standardsubdomainfes.test:8001'; // added port to trick extension into calling the mock + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct); + await SetupPageRecipe.manualEnter(settingsPage, 'flowcrypt.test.key.used.pgp', { submitPubkey: false, usedPgpBefore: false }, + { isSavePassphraseChecked: false, isSavePassphraseHidden: false }); + const subject = 'PWD encrypted message with FES - pubkey recipient in bcc'; + const composePage = await ComposePageRecipe.openStandalone(t, browser, 'user3@standardsubdomainfes.test:8001'); + await ComposePageRecipe.fillMsg(composePage, { to: 'to@example.com', bcc: 'flowcrypt.compatibility@gmail.com' }, subject); + await composePage.waitAndType('@input-password', 'gO0d-pwd'); + await composePage.waitAndClick('@action-send', { delay: 1 }); + await ComposePageRecipe.closed(composePage); + const sentMsgs = (await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject); + expect(sentMsgs.length).to.equal(2); + // this test is using PwdEncryptedMessageWithFesPubkeyRecipientInBccTestStrategy to check sent result based on subject "PWD encrypted message with FES - pubkey recipient in bcc" + // also see '/api/v1/message' in fes-endpoints.ts mock + })); + + /** + * You need the following lines in /etc/hosts: + * 127.0.0.1 standardsubdomainfes.test + * 127.0.0.1 fes.standardsubdomainfes.test + */ + ava.default('user4@standardsubdomainfes.test:8001 - PWD encrypted message with FES web portal - some sends fail with BadRequest error', testWithBrowser(undefined, async (t, browser) => { + const acct = 'user4@standardsubdomainfes.test:8001'; // added port to trick extension into calling the mock + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct); + await SetupPageRecipe.manualEnter(settingsPage, 'flowcrypt.test.key.used.pgp', { submitPubkey: false, usedPgpBefore: false }, + { isSavePassphraseChecked: false, isSavePassphraseHidden: false }); + // add a name to one of the contacts + const dbPage = await browser.newPage(t, TestUrls.extension('chrome/dev/ci_unit_test.htm')); + await dbPage.page.evaluate(async () => { + const db = await (window as any).ContactStore.dbOpen(); + await (window as any).ContactStore.update(db, 'cc@example.com', { name: 'Mr Cc' }); + }); + await dbPage.close(); + const subject = 'PWD encrypted message with FES web portal - some sends fail with BadRequest error - ' + testVariant; + let expectedNumberOfPassedMessages = (await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject).length; + // 1. vague Gmail error with partial success + let composePage = await ComposePageRecipe.openStandalone(t, browser, 'user4@standardsubdomainfes.test:8001'); + await ComposePageRecipe.fillMsg(composePage, { to: 'to@example.com', cc: 'cc@example.com', bcc: 'flowcrypt.compatibility@gmail.com' }, subject); + await composePage.waitAndType('@input-password', 'gO0d-pwd'); + await composePage.waitAndClick('@action-send', { delay: 1 }); + await composePage.waitAndRespondToModal('confirm', 'cancel', + 'Messages to some recipients were sent successfully, while messages to flowcrypt.compatibility@gmail.com, Mr Cc ' + + 'encountered error(s) from Gmail. Please help us improve FlowCrypt by reporting the error to us.'); + await composePage.close(); + expect((await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject).length).to.equal(++expectedNumberOfPassedMessages); + // 2. vague Gmail error with all failures + composePage = await ComposePageRecipe.openStandalone(t, browser, 'user4@standardsubdomainfes.test:8001'); + await ComposePageRecipe.fillMsg(composePage, { cc: 'cc@example.com', bcc: 'flowcrypt.compatibility@gmail.com' }, subject); + await composePage.waitAndType('@input-password', 'gO0d-pwd'); + await composePage.waitAndClick('@action-send', { delay: 1 }); + await composePage.waitAndRespondToModal('confirm', 'cancel', + 'Google returned an error when sending message. ' + + 'Please help us improve FlowCrypt by reporting the error to us.'); + await composePage.close(); + expect((await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject).length).to.equal(expectedNumberOfPassedMessages); // + 0 messages + // 3. "invalid To" Gmail error with partial success + composePage = await ComposePageRecipe.openStandalone(t, browser, 'user4@standardsubdomainfes.test:8001'); + await ComposePageRecipe.fillMsg(composePage, { to: 'invalid@example.com', cc: 'to@example.com' }, subject); + await composePage.waitAndType('@input-password', 'gO0d-pwd'); + await composePage.waitAndClick('@action-send', { delay: 1 }); + await composePage.waitAndRespondToModal('error', 'confirm', + 'Messages to some recipients were sent successfully, while messages to invalid@example.com ' + + 'encountered error(s) from Gmail: Invalid recipients\n\nPlease remove recipients, add them back and re-send the message.'); + await composePage.close(); + expect((await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject).length).to.equal(++expectedNumberOfPassedMessages); + // 4. "invalid To" Gmail error with all failures + composePage = await ComposePageRecipe.openStandalone(t, browser, 'user4@standardsubdomainfes.test:8001'); + await ComposePageRecipe.fillMsg(composePage, { to: 'invalid@example.com', cc: 'cc@example.com' }, subject); + await composePage.waitAndType('@input-password', 'gO0d-pwd'); + await composePage.waitAndClick('@action-send', { delay: 1 }); + await composePage.waitAndRespondToModal('error', 'confirm', + 'Error from google: Invalid recipients\n\nPlease remove recipients, add them back and re-send the message.'); + await composePage.close(); + expect((await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject).length).to.equal(expectedNumberOfPassedMessages); // + 0 messages + // 5. "RequestTimeout" error with partial success + composePage = await ComposePageRecipe.openStandalone(t, browser, 'user4@standardsubdomainfes.test:8001'); + await ComposePageRecipe.fillMsg(composePage, { to: 'timeout@example.com', cc: 'to@example.com' }, subject); + await composePage.waitAndType('@input-password', 'gO0d-pwd'); + await composePage.waitAndClick('@action-send', { delay: 1 }); + await composePage.waitAndRespondToModal('error', 'confirm', + 'Messages to some recipients were sent successfully, while messages to timeout@example.com ' + + 'encountered network errors. Please check your internet connection and try again.'); + await composePage.close(); + expect((await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject).length).to.equal(++expectedNumberOfPassedMessages); + // 6. "RequestTimeout" error with all failures + composePage = await ComposePageRecipe.openStandalone(t, browser, 'user4@standardsubdomainfes.test:8001'); + await ComposePageRecipe.fillMsg(composePage, { to: 'timeout@example.com', cc: 'cc@example.com' }, subject); + await composePage.waitAndType('@input-password', 'gO0d-pwd'); + await composePage.waitAndClick('@action-send', { delay: 1 }); + await composePage.waitAndRespondToModal('error', 'confirm', + 'Could not send message due to network error. Please check your internet connection and try again. ' + + '(This may also be caused by missing extension permissions).'); + await composePage.close(); + expect((await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject).length).to.equal(expectedNumberOfPassedMessages); // + 0 messages + // this test is using PwdEncryptedMessageWithFesReplyBadRequestTestStrategy to check sent result based on subject "PWD encrypted message with FES web portal - some sends fail with BadRequest error" + // also see '/api/v1/message' in fes-endpoints.ts mock + })); + + /** + * You need the following lines in /etc/hosts: + * 127.0.0.1 standardsubdomainfes.test + * 127.0.0.1 fes.standardsubdomainfes.test + */ + ava.default('user4@standardsubdomainfes.test:8001 - PWD encrypted message with FES web portal - a send fails with gateway update error', testWithBrowser(undefined, async (t, browser) => { + const acct = 'user4@standardsubdomainfes.test:8001'; // added port to trick extension into calling the mock + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct); + await SetupPageRecipe.manualEnter(settingsPage, 'flowcrypt.test.key.used.pgp', { submitPubkey: false, usedPgpBefore: false }, + { isSavePassphraseChecked: false, isSavePassphraseHidden: false }); + const subject = 'PWD encrypted message with FES web portal - a send fails with gateway update error - ' + testVariant; + const expectedNumberOfPassedMessages = (await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject).length; + const composePage = await ComposePageRecipe.openStandalone(t, browser, 'user4@standardsubdomainfes.test:8001'); + await ComposePageRecipe.fillMsg(composePage, { to: 'gatewayfailure@example.com' }, subject); + await composePage.waitAndType('@input-password', 'gO0d-pwd'); + await composePage.waitAndClick('@action-send', { delay: 1 }); + await composePage.waitForContent('.ui-toast-title', 'Failed to bind Gateway ID of the message:'); + await composePage.close(); + expect((await GoogleData.withInitializedData(acct)).searchMessagesBySubject(subject).length).to.equal(expectedNumberOfPassedMessages + 1); + // this test is using PwdEncryptedMessageWithFesReplyGatewayErrorTestStrategy to check sent result based on subject "PWD encrypted message with FES web portal - a send fails with gateway update error" // also see '/api/v1/message' in fes-endpoints.ts mock })); @@ -1643,7 +1839,7 @@ const sendImgAndVerifyPresentInSentMsg = async (t: AvaContext, browser: BrowserH await composePage.page.evaluate((src: string) => { $('[data-test=action-insert-image]').val(src).click(); }, imgBase64); await ComposePageRecipe.sendAndClose(composePage); // get sent msg id from mock - const sentMsg = (await GoogleData.withInitializedData('flowcrypt.compatibility@gmail.com')).getMessageBySubject(subject)!; + const sentMsg = (await GoogleData.withInitializedData('flowcrypt.compatibility@gmail.com')).searchMessagesBySubject(subject)[0]!; if (sendingType === 'plain') { expect(sentMsg.payload?.body?.data).to.match(/Test Sending Plain Message With Image/); return; @@ -1675,7 +1871,7 @@ const sendTextAndVerifyPresentInSentMsg = async (t: AvaContext, await ComposePageRecipe.fillMsg(composePage, { to: 'human@flowcrypt.com' }, subject, text, sendingOpt); await ComposePageRecipe.sendAndClose(composePage); // get sent msg from mock - const sentMsg = (await GoogleData.withInitializedData('flowcrypt.compatibility@gmail.com')).getMessageBySubject(subject)!; + const sentMsg = (await GoogleData.withInitializedData('flowcrypt.compatibility@gmail.com')).searchMessagesBySubject(subject)[0]!; const message = encodeURIComponent(sentMsg.payload!.body!.data!); await BrowserRecipe.pgpBlockVerifyDecryptedContent(t, browser, { content: [text], diff --git a/test/source/tests/settings.ts b/test/source/tests/settings.ts index bce5c4e8eae..79e6fa204ec 100644 --- a/test/source/tests/settings.ts +++ b/test/source/tests/settings.ts @@ -633,7 +633,7 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T expect(await backupPage.isDisabled('[data-id="515431151DDD3EA232B37A4C98ACFA1EADAB5B92"]')).to.equal(false); await backupPage.waitAndClick('@action-backup-step3manual-continue'); await backupPage.waitAndRespondToModal('info', 'confirm', 'Your private keys have been successfully backed up'); - const sentMsg = (await GoogleData.withInitializedData(acctEmail)).getMessageBySubject('Your FlowCrypt Backup')!; + const sentMsg = (await GoogleData.withInitializedData(acctEmail)).searchMessagesBySubject('Your FlowCrypt Backup')[0]!; const mimeMsg = await Parse.convertBase64ToMimeMsg(sentMsg.raw!); const { keys } = await KeyUtil.readMany(Buf.concat(mimeMsg.attachments.map(a => a.content))); expect(keys.length).to.equal(2); @@ -814,7 +814,7 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T '&type=openpgp&id=515431151DDD3EA232B37A4C98ACFA1EADAB5B92&idToken=fakeheader.01')); await backupPage.waitAndClick('@action-backup-step3manual-continue'); await backupPage.waitAll('@action-step4done-account-settings'); - const sentMsg = (await GoogleData.withInitializedData(acctEmail)).getMessageBySubject('Your FlowCrypt Backup')!; + const sentMsg = (await GoogleData.withInitializedData(acctEmail)).searchMessagesBySubject('Your FlowCrypt Backup')[0]!; const mimeMsg = await Parse.convertBase64ToMimeMsg(sentMsg.raw!); const { keys } = await KeyUtil.readMany(new Buf(mimeMsg.attachments[0]!.content!)); expect(keys.length).to.equal(1);