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 @@
- to:
- cc:
- bcc:
+ to:
+ cc:
+ bcc:
|
@@ -181,7 +181,7 @@
|
-
+
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