From 10c7c60fe57fe6a63516860002799fe64261744a Mon Sep 17 00:00:00 2001 From: Ioan Moldovan Date: Fri, 6 Feb 2026 04:40:05 -0800 Subject: [PATCH 1/5] feat: use presigned s3 urls for password protected messages --- .../encrypted-mail-msg-formatter.ts | 95 ++++++++++++++++++- extension/js/common/api/account-server.ts | 32 ++++++- .../api/account-servers/external-service.ts | 74 ++++++++++++++- extension/js/common/client-configuration.ts | 12 ++- .../mock/fes/customer-url-fes-endpoints.ts | 39 +++++++- .../mock/fes/shared-tenant-fes-endpoints.ts | 31 +++++- 6 files changed, 273 insertions(+), 10 deletions(-) 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 b0d29ae6e5a..a1b99395e5a 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 @@ -159,19 +159,77 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { if (!newMsg.pwd) { throw new Error('password unexpectedly missing'); } + + // Check if we should use the new pre-signed S3 URL flow + if (this.view.clientConfiguration.shouldUseFesPresignedUrls()) { + // New flow: allocation -> S3 upload -> create message + return await this.prepareAndUploadPwdEncryptedMsgWithPresignedUrl(newMsg, signingKey); + } else { + // Legacy flow: get token, then upload directly to FES + return await this.prepareAndUploadPwdEncryptedMsgLegacy(newMsg, signingKey); + } + }; + + private prepareAndUploadPwdEncryptedMsgWithPresignedUrl = async ( + newMsg: NewMsgData, + signingKey?: ParsedKeyInfo + ): Promise => { + // Step 1: Allocate storage and get pre-signed URL + reply token + const allocation = await this.view.acctServer.messageAllocation(); + const { storageFileName, replyToken, uploadUrl } = allocation; + + // Step 2: Prepare body with reply token and encrypt the message + const { bodyWithReplyToken } = await this.getPwdMsgSendableBodyWithReplyToken(newMsg, replyToken); + const pgpMimeWithAttachments = await Mime.encode( + bodyWithReplyToken, + { Subject: newMsg.subject }, // eslint-disable-line @typescript-eslint/naming-convention + await this.view.attachmentsModule.attachment.collectAttachments() + ); + const { data: pwdEncryptedWithAttachments } = await this.encryptDataArmor( + Buf.fromUtfStr(pgpMimeWithAttachments), + newMsg.pwd, + [], + signingKey?.key + ); + + // Step 3: Upload encrypted content to S3 + await this.view.acctServer.uploadToS3( + uploadUrl, + pwdEncryptedWithAttachments, + p => this.view.sendBtnModule.renderUploadProgress(p, 'FIRST-HALF') + ); + + // Step 4: Create message record in FES + return await this.view.acctServer.messageCreate( + storageFileName, + replyToken, + newMsg.from.email, + newMsg.recipients + ); + }; + + private prepareAndUploadPwdEncryptedMsgLegacy = async ( + newMsg: NewMsgData, + signingKey?: ParsedKeyInfo + ): Promise => { const { bodyWithReplyToken, replyToken } = await this.getPwdMsgSendableBodyWithOnlineReplyMsgToken(newMsg); const pgpMimeWithAttachments = await Mime.encode( bodyWithReplyToken, { Subject: newMsg.subject }, // eslint-disable-line @typescript-eslint/naming-convention await this.view.attachmentsModule.attachment.collectAttachments() ); - const { data: pwdEncryptedWithAttachments } = await this.encryptDataArmor(Buf.fromUtfStr(pgpMimeWithAttachments), newMsg.pwd, [], signingKey?.key); + const { data: pwdEncryptedWithAttachments } = await this.encryptDataArmor( + Buf.fromUtfStr(pgpMimeWithAttachments), + newMsg.pwd, + [], + signingKey?.key + ); return await this.view.acctServer.messageUpload( pwdEncryptedWithAttachments, replyToken, - newMsg.from.email, // todo: Str.formatEmailWithOptionalName? + newMsg.from.email, newMsg.recipients, - p => this.view.sendBtnModule.renderUploadProgress(p, 'FIRST-HALF') // still need to upload to Gmail later, this request represents first half of progress + p => this.view.sendBtnModule.renderUploadProgress(p, 'FIRST-HALF') ); }; @@ -250,6 +308,37 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { }); }; + /** + * Prepares the message body with a reply token that was provided (used by pre-signed URL flow). + */ + private getPwdMsgSendableBodyWithReplyToken = async ( + newMsgData: NewMsgData, + replyToken: string + ): Promise<{ bodyWithReplyToken: SendableMsgBody }> => { + const recipientsWithoutBcc = { ...newMsgData.recipients, bcc: [] }; + const recipients = getUniqueRecipientEmails(recipientsWithoutBcc); + const replyInfoRaw: ReplyInfoRaw = { + sender: newMsgData.from.email, + recipient: Value.arr.withoutVal(Value.arr.withoutVal(recipients, newMsgData.from.email), this.acctEmail), + subject: newMsgData.subject, + token: replyToken, + }; + const replyInfoDiv = Ui.e('div', { + style: 'display: none;', + class: 'cryptup_reply', + 'cryptup-data': Str.htmlAttrEncode(replyInfoRaw), + }); + return { + bodyWithReplyToken: { + 'text/plain': newMsgData.plaintext + '\n\n' + replyInfoDiv, + 'text/html': newMsgData.plainhtml + '

' + replyInfoDiv, + }, + }; + }; + + /** + * Gets a reply token from the server and prepares message body (used by legacy flow). + */ private getPwdMsgSendableBodyWithOnlineReplyMsgToken = async ( newMsgData: NewMsgData ): Promise<{ bodyWithReplyToken: SendableMsgBody; replyToken: string }> => { diff --git a/extension/js/common/api/account-server.ts b/extension/js/common/api/account-server.ts index 910b70d1d05..1e4d0d8e9f0 100644 --- a/extension/js/common/api/account-server.ts +++ b/extension/js/common/api/account-server.ts @@ -3,7 +3,7 @@ 'use strict'; import { isCustomerUrlFesUsed } from '../helpers.js'; -import { ExternalService } from './account-servers/external-service.js'; +import { ExternalService, FesRes } from './account-servers/external-service.js'; import { ParsedRecipients } from './email-provider/email-provider-api.js'; import { Api, ProgressCb } from './shared/api.js'; import { ClientConfigurationJson } from '../client-configuration.js'; @@ -61,7 +61,37 @@ export class AccountServer extends Api { await this.externalService.messageGatewayUpdate(externalId, emailGatewayMessageId); }; + /** + * Gets a reply token for password-protected messages (legacy flow). + */ public messageToken = async (): Promise<{ replyToken: string }> => { return await this.externalService.webPortalMessageNewReplyToken(); }; + + /** + * Allocates storage for a password-protected message using pre-signed S3 URL (new flow). + * Returns storage file name, reply token, and pre-signed upload URL. + */ + public messageAllocation = async (): Promise => { + return await this.externalService.webPortalMessageAllocation(); + }; + + /** + * Uploads encrypted content directly to S3 using a pre-signed URL (new flow). + */ + public uploadToS3 = async (uploadUrl: string, data: Uint8Array, progressCb: ProgressCb): Promise => { + await this.externalService.uploadToS3(uploadUrl, data, progressCb); + }; + + /** + * Creates a password-protected message record in FES after uploading content to S3 (new flow). + */ + public messageCreate = async ( + storageFileName: string, + associateReplyToken: string, + from: string, + recipients: ParsedRecipients + ): Promise => { + return await this.externalService.webPortalMessageCreate(storageFileName, associateReplyToken, from, recipients); + }; } diff --git a/extension/js/common/api/account-servers/external-service.ts b/extension/js/common/api/account-servers/external-service.ts index cc7b5fb3127..7fee6252d27 100644 --- a/extension/js/common/api/account-servers/external-service.ts +++ b/extension/js/common/api/account-servers/external-service.ts @@ -26,6 +26,11 @@ export namespace FesRes { externalId: string; // LEGACY emailToExternalIdAndUrl?: { [email: string]: { url: string; externalId: string } }; }; + export type MessageAllocation = { + storageFileName: string; + replyToken: string; + uploadUrl: string; + }; export type ServiceInfo = { vendor: string; service: string; orgId: string; version: string; apiVersion: string }; export type ClientConfiguration = { clientConfiguration: ClientConfigurationJson }; } @@ -159,9 +164,7 @@ export class ExternalService extends Api { JSON.stringify({ associateReplyToken, from, - to: (recipients.to || []).map(Str.formatEmailWithOptionalName).map(Xss.stripEmojis).map(Str.replaceAccentedChars), - cc: (recipients.cc || []).map(Str.formatEmailWithOptionalName).map(Xss.stripEmojis).map(Str.replaceAccentedChars), - bcc: (recipients.bcc || []).map(Str.formatEmailWithOptionalName).map(Xss.stripEmojis).map(Str.replaceAccentedChars), + ...this.prepareRecipientsForFes(recipients), }) ), }); @@ -178,6 +181,63 @@ export class ExternalService extends Api { }); }; + /** + * Allocates storage for a password-protected message using pre-signed S3 URL. + * Returns storage file name, reply token, and pre-signed upload URL. + */ + public webPortalMessageAllocation = async (): Promise => { + return await this.request(`/api/${this.apiVersion}/messages/allocation`, { fmt: 'JSON', data: {} }); + }; + + /** + * Uploads encrypted message content directly to S3 using a pre-signed URL. + */ + public uploadToS3 = async (uploadUrl: string, data: Uint8Array, progressCb: ProgressCb): Promise => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('PUT', uploadUrl, true); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + xhr.upload.onprogress = e => { + if (e.lengthComputable) { + const percent = Math.round((e.loaded / e.total) * 100); + progressCb(percent, e.loaded, e.total); + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`S3 upload failed with status ${xhr.status}: ${xhr.statusText}`)); + } + }; + xhr.onerror = () => { + reject(new Error('S3 upload failed due to network error')); + }; + xhr.send(new Blob([data.buffer as ArrayBuffer])); + }); + }; + + /** + * Creates a password-protected message record in FES after uploading content to S3. + * Uses storageFileName from allocation instead of uploading encrypted content directly. + */ + public webPortalMessageCreate = async ( + storageFileName: string, + associateReplyToken: string, + from: string, + recipients: ParsedRecipients + ): Promise => { + return await this.request(`/api/${this.apiVersion}/messages`, { + fmt: 'JSON', + data: { + storageFileName, + associateReplyToken, + from, + ...this.prepareRecipientsForFes(recipients), + }, + }); + }; + private request = async ( path: string, vals?: @@ -211,4 +271,12 @@ export class ExternalService extends Api { 'json' ); }; + private prepareRecipientsForFes(recipients: ParsedRecipients) { + const process = (list: ParsedRecipients['to']) => (list || []).map(Str.formatEmailWithOptionalName).map(Xss.stripEmojis).map(Str.replaceAccentedChars); + return { + to: process(recipients.to), + cc: process(recipients.cc), + bcc: process(recipients.bcc), + }; + } } diff --git a/extension/js/common/client-configuration.ts b/extension/js/common/client-configuration.ts index 76f8d84fb29..c03a470f2c4 100644 --- a/extension/js/common/client-configuration.ts +++ b/extension/js/common/client-configuration.ts @@ -18,7 +18,8 @@ type ClientConfiguration$flag = | 'DEFAULT_REMEMBER_PASS_PHRASE' | 'HIDE_ARMOR_META' | 'FORBID_STORING_PASS_PHRASE' - | 'DISABLE_FLOWCRYPT_HOSTED_PASSWORD_MESSAGES'; + | 'DISABLE_FLOWCRYPT_HOSTED_PASSWORD_MESSAGES' + | 'DISABLE_FES_PRESIGNED_URLS'; /* eslint-disable @typescript-eslint/naming-convention */ export type ClientConfigurationJson = { @@ -285,4 +286,13 @@ export class ClientConfiguration { public getPublicKeyForPrivateKeyBackupToDesignatedMailbox = (): string | undefined => { return this.clientConfigurationJson.prv_backup_to_designated_mailbox; }; + + /** + * When sending password-protected messages, by default pre-signed S3 URLs are used for uploading + * the encrypted message content. This allows for larger attachments (beyond ~5MB). + * If this flag is set, the legacy flow (direct upload to FES) will be used instead. + */ + public shouldUseFesPresignedUrls = (): boolean => { + return !(this.clientConfigurationJson.flags || []).includes('DISABLE_FES_PRESIGNED_URLS'); + }; } diff --git a/test/source/mock/fes/customer-url-fes-endpoints.ts b/test/source/mock/fes/customer-url-fes-endpoints.ts index a81ce15006c..b900507311d 100644 --- a/test/source/mock/fes/customer-url-fes-endpoints.ts +++ b/test/source/mock/fes/customer-url-fes-endpoints.ts @@ -64,10 +64,47 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H } throw new HttpClientErr('Not Found', 404); }, + // New pre-signed S3 URL flow endpoints + '/api/v1/messages/allocation': async ({}, req) => { + const port = parsePort(req); + if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { + authenticate(req, isCustomIDPUsed); + return { + storageFileName: 'mock-storage-file-name-' + Date.now(), + replyToken: 'mock-fes-reply-token', + uploadUrl: `http://localhost:${port}/mock-s3-upload`, + }; + } + throw new HttpClientErr('Not Found', 404); + }, + '/api/v1/messages': async ({ body }, req) => { + const port = parsePort(req); + // New endpoint that receives storageFileName instead of encrypted content + if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST' && typeof body === 'object') { + authenticate(req, isCustomIDPUsed); + const bodyObj = body as { storageFileName?: string; associateReplyToken?: string }; + expect(bodyObj.storageFileName).to.be.a('string'); + expect(bodyObj.associateReplyToken).to.equal('mock-fes-reply-token'); + if (config?.messagePostValidator) { + // Use the validator if provided, but with a mock body since we don't have the actual encrypted content + return { + url: `https://fes.standardsubdomainfes.localhost:${port}/message/mock-external-id`, + externalId: 'FES-MOCK-EXTERNAL-ID', + emailToExternalIdAndUrl: {} as { [email: string]: { url: string; externalId: string } }, + }; + } + return { + url: `https://fes.standardsubdomainfes.localhost:${port}/message/mock-external-id`, + externalId: 'FES-MOCK-EXTERNAL-ID', + emailToExternalIdAndUrl: {} as { [email: string]: { url: string; externalId: string } }, + }; + } + throw new HttpClientErr('Not Found', 404); + }, '/api/v1/message': async ({ body }, req) => { const port = parsePort(req); const fesUrl = standardFesUrl(port); - // body is a mime-multipart string, we're doing a few smoke checks here without parsing it + // Legacy endpoint - body is a mime-multipart string, we're doing a few smoke checks here without parsing it if (parseAuthority(req) === fesUrl && req.method === 'POST' && typeof body === 'string') { authenticate(req, isCustomIDPUsed); if (config?.messagePostValidator) { diff --git a/test/source/mock/fes/shared-tenant-fes-endpoints.ts b/test/source/mock/fes/shared-tenant-fes-endpoints.ts index 0d41016adf6..f6979e5daf3 100644 --- a/test/source/mock/fes/shared-tenant-fes-endpoints.ts +++ b/test/source/mock/fes/shared-tenant-fes-endpoints.ts @@ -34,6 +34,7 @@ type FesClientConfigurationFlag = | 'HIDE_ARMOR_META' | 'FORBID_STORING_PASS_PHRASE' | 'DISABLE_FES_ACCESS_TOKEN' + | 'DISABLE_FES_PRESIGNED_URLS' | 'SETUP_ENSURE_IMPORTED_PRV_MATCH_LDAP_PUB'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -139,8 +140,36 @@ export const getMockSharedTenantFesEndpoints = (config: FesConfig | undefined): } throw new HttpClientErr('Not Found', 404); }, + // New pre-signed S3 URL flow endpoints + '/shared-tenant-fes/api/v1/messages/allocation': async ({}, req) => { + if (req.method === 'POST') { + authenticate(req, 'oidc'); + const port = parsePort(req); + return { + storageFileName: 'mock-storage-file-name-' + Date.now(), + replyToken: 'mock-fes-reply-token', + uploadUrl: `http://localhost:${port}/mock-s3-upload`, + }; + } + throw new HttpClientErr('Not Found', 404); + }, + '/shared-tenant-fes/api/v1/messages': async ({ body }, req) => { + // New endpoint that receives storageFileName instead of encrypted content + if (req.method === 'POST' && typeof body === 'object') { + authenticate(req, 'oidc'); + const bodyObj = body as { storageFileName?: string; associateReplyToken?: string }; + expect(bodyObj.storageFileName).to.be.a('string'); + expect(bodyObj.associateReplyToken).to.equal('mock-fes-reply-token'); + return { + url: `https://flowcrypt.com/shared-tenant-fes/message/6da5ea3c-d2d6-4714-b15e-f29c805e5c6a`, + externalId: 'FES-MOCK-EXTERNAL-ID', + emailToExternalIdAndUrl: {} as { [email: string]: { url: string; externalId: string } }, + }; + } + throw new HttpClientErr('Not Found', 404); + }, '/shared-tenant-fes/api/v1/message': async ({ body }, req) => { - // body is a mime-multipart string, we're doing a few smoke checks here without parsing it + // Legacy endpoint - body is a mime-multipart string, we're doing a few smoke checks here without parsing it if (req.method === 'POST' && typeof body === 'string') { expect(body).to.contain('-----BEGIN PGP MESSAGE-----'); expect(body).to.contain('"associateReplyToken":"mock-fes-reply-token"'); From b375ca91a624977974776fcc11152bb4b6eab27a Mon Sep 17 00:00:00 2001 From: Ioan Moldovan Date: Sun, 8 Feb 2026 22:35:33 -0800 Subject: [PATCH 2/5] fix --- .../encrypted-mail-msg-formatter.ts | 63 ++++++------- .../api/account-servers/external-service.ts | 2 +- .../mock/fes/customer-url-fes-endpoints.ts | 31 ++++--- .../mock/fes/shared-tenant-fes-endpoints.ts | 93 +++++++++++++++++-- test/source/mock/lib/api.ts | 7 +- test/source/mock/s3/s3-endpoints.ts | 21 +++++ test/source/tests/compose.ts | 1 + 7 files changed, 159 insertions(+), 59 deletions(-) create mode 100644 test/source/mock/s3/s3-endpoints.ts 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 a1b99395e5a..704dbb3bd5a 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 @@ -227,9 +227,9 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { return await this.view.acctServer.messageUpload( pwdEncryptedWithAttachments, replyToken, - newMsg.from.email, + newMsg.from.email, // todo: Str.formatEmailWithOptionalName? newMsg.recipients, - p => this.view.sendBtnModule.renderUploadProgress(p, 'FIRST-HALF') + p => this.view.sendBtnModule.renderUploadProgress(p, 'FIRST-HALF') // still need to upload to Gmail later, this request represents first half of progress ); }; @@ -315,24 +315,8 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { newMsgData: NewMsgData, replyToken: string ): Promise<{ bodyWithReplyToken: SendableMsgBody }> => { - const recipientsWithoutBcc = { ...newMsgData.recipients, bcc: [] }; - const recipients = getUniqueRecipientEmails(recipientsWithoutBcc); - const replyInfoRaw: ReplyInfoRaw = { - sender: newMsgData.from.email, - recipient: Value.arr.withoutVal(Value.arr.withoutVal(recipients, newMsgData.from.email), this.acctEmail), - subject: newMsgData.subject, - token: replyToken, - }; - const replyInfoDiv = Ui.e('div', { - style: 'display: none;', - class: 'cryptup_reply', - 'cryptup-data': Str.htmlAttrEncode(replyInfoRaw), - }); return { - bodyWithReplyToken: { - 'text/plain': newMsgData.plaintext + '\n\n' + replyInfoDiv, - 'text/html': newMsgData.plainhtml + '

' + replyInfoDiv, - }, + bodyWithReplyToken: this.buildSendableBodyWithReplyToken(newMsgData, replyToken), }; }; @@ -342,26 +326,10 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { private getPwdMsgSendableBodyWithOnlineReplyMsgToken = async ( newMsgData: NewMsgData ): Promise<{ bodyWithReplyToken: SendableMsgBody; replyToken: string }> => { - const recipientsWithoutBcc = { ...newMsgData.recipients, bcc: [] }; - const recipients = getUniqueRecipientEmails(recipientsWithoutBcc); try { const response = await this.view.acctServer.messageToken(); - const replyInfoRaw: ReplyInfoRaw = { - sender: newMsgData.from.email, - recipient: Value.arr.withoutVal(Value.arr.withoutVal(recipients, newMsgData.from.email), this.acctEmail), - subject: newMsgData.subject, - token: response.replyToken, - }; - const replyInfoDiv = Ui.e('div', { - style: 'display: none;', - class: 'cryptup_reply', - 'cryptup-data': Str.htmlAttrEncode(replyInfoRaw), - }); return { - bodyWithReplyToken: { - 'text/plain': newMsgData.plaintext + '\n\n' + replyInfoDiv, - 'text/html': newMsgData.plainhtml + '

' + replyInfoDiv, - }, + bodyWithReplyToken: this.buildSendableBodyWithReplyToken(newMsgData, response.replyToken), replyToken: response.replyToken, }; } catch (msgTokenErr) { @@ -382,6 +350,29 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { } }; + /** + * Builds the sendable message body with embedded reply token information. + */ + private buildSendableBodyWithReplyToken = (newMsgData: NewMsgData, replyToken: string): SendableMsgBody => { + const recipientsWithoutBcc = { ...newMsgData.recipients, bcc: [] }; + const recipients = getUniqueRecipientEmails(recipientsWithoutBcc); + const replyInfoRaw: ReplyInfoRaw = { + sender: newMsgData.from.email, + recipient: Value.arr.withoutVal(Value.arr.withoutVal(recipients, newMsgData.from.email), this.acctEmail), + subject: newMsgData.subject, + token: replyToken, + }; + const replyInfoDiv = Ui.e('div', { + style: 'display: none;', + class: 'cryptup_reply', + 'cryptup-data': Str.htmlAttrEncode(replyInfoRaw), + }); + return { + 'text/plain': newMsgData.plaintext + '\n\n' + replyInfoDiv, + 'text/html': newMsgData.plainhtml + '

' + replyInfoDiv, + }; + }; + private encryptMsgAsOfDateIfSomeAreExpiredAndUserConfirmedModal = async (pubs: PubkeyResult[]): Promise => { if (!pubs.length) { return undefined; diff --git a/extension/js/common/api/account-servers/external-service.ts b/extension/js/common/api/account-servers/external-service.ts index 7fee6252d27..619ed6096e2 100644 --- a/extension/js/common/api/account-servers/external-service.ts +++ b/extension/js/common/api/account-servers/external-service.ts @@ -271,7 +271,7 @@ export class ExternalService extends Api { 'json' ); }; - private prepareRecipientsForFes(recipients: ParsedRecipients) { + private prepareRecipientsForFes = (recipients: ParsedRecipients) => { const process = (list: ParsedRecipients['to']) => (list || []).map(Str.formatEmailWithOptionalName).map(Xss.stripEmojis).map(Str.replaceAccentedChars); return { to: process(recipients.to), diff --git a/test/source/mock/fes/customer-url-fes-endpoints.ts b/test/source/mock/fes/customer-url-fes-endpoints.ts index b900507311d..4f31c0cf116 100644 --- a/test/source/mock/fes/customer-url-fes-endpoints.ts +++ b/test/source/mock/fes/customer-url-fes-endpoints.ts @@ -6,7 +6,13 @@ import { HandlersDefinition } from '../all-apis-mock'; import { HttpClientErr, Status } from '../lib/api'; import { messageIdRegex, parseAuthority, parsePort } from '../lib/mock-util'; import { MockJwt } from '../lib/oauth'; -import { FesConfig } from './shared-tenant-fes-endpoints'; +import { + FesConfig, + MessageCreateBody, + validateMessageCreateBody, + createMockBodyForValidator, + generateEmailToExternalIdAndUrl, +} from './shared-tenant-fes-endpoints'; const standardFesUrl = (port: string) => { return `fes.standardsubdomainfes.localhost:${port}`; @@ -72,31 +78,28 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H return { storageFileName: 'mock-storage-file-name-' + Date.now(), replyToken: 'mock-fes-reply-token', - uploadUrl: `http://localhost:${port}/mock-s3-upload`, + uploadUrl: `https://localhost:${port}/mock-s3-upload`, }; } throw new HttpClientErr('Not Found', 404); }, '/api/v1/messages': async ({ body }, req) => { const port = parsePort(req); + const fesUrl = standardFesUrl(port); + const baseUrl = `https://${fesUrl}`; // New endpoint that receives storageFileName instead of encrypted content - if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST' && typeof body === 'object') { + if (parseAuthority(req) === fesUrl && req.method === 'POST' && typeof body === 'object') { authenticate(req, isCustomIDPUsed); - const bodyObj = body as { storageFileName?: string; associateReplyToken?: string }; - expect(bodyObj.storageFileName).to.be.a('string'); - expect(bodyObj.associateReplyToken).to.equal('mock-fes-reply-token'); + const bodyObj = body as MessageCreateBody; + validateMessageCreateBody(bodyObj); + // Use the validator if provided if (config?.messagePostValidator) { - // Use the validator if provided, but with a mock body since we don't have the actual encrypted content - return { - url: `https://fes.standardsubdomainfes.localhost:${port}/message/mock-external-id`, - externalId: 'FES-MOCK-EXTERNAL-ID', - emailToExternalIdAndUrl: {} as { [email: string]: { url: string; externalId: string } }, - }; + return await config.messagePostValidator(createMockBodyForValidator(bodyObj), fesUrl); } return { - url: `https://fes.standardsubdomainfes.localhost:${port}/message/mock-external-id`, + url: `${baseUrl}/message/mock-external-id`, externalId: 'FES-MOCK-EXTERNAL-ID', - emailToExternalIdAndUrl: {} as { [email: string]: { url: string; externalId: string } }, + emailToExternalIdAndUrl: generateEmailToExternalIdAndUrl(bodyObj, baseUrl), }; } throw new HttpClientErr('Not Found', 404); diff --git a/test/source/mock/fes/shared-tenant-fes-endpoints.ts b/test/source/mock/fes/shared-tenant-fes-endpoints.ts index f6979e5daf3..53bccbd8fff 100644 --- a/test/source/mock/fes/shared-tenant-fes-endpoints.ts +++ b/test/source/mock/fes/shared-tenant-fes-endpoints.ts @@ -70,6 +70,74 @@ export interface FesMessageReturnType { externalId: string; emailToExternalIdAndUrl: { [email: string]: { url: string; externalId: string } }; } + +export interface MessageCreateBody { + storageFileName?: string; + associateReplyToken?: string; + from?: string; + to?: string[]; + cc?: string[]; + bcc?: string[]; +} + +/** + * Extracts email from a recipient string that may be in "Name " format. + */ +export const extractEmailFromRecipient = (recipient: string): string => { + const emailMatch = /<([^>]+)>/.exec(recipient); + return emailMatch ? emailMatch[1] : recipient; +}; + +/** + * Generates emailToExternalIdAndUrl mapping for all recipients. + */ +export const generateEmailToExternalIdAndUrl = ( + bodyObj: MessageCreateBody, + baseUrl: string +): { [email: string]: { url: string; externalId: string } } => { + const emailToExternalIdAndUrl: { [email: string]: { url: string; externalId: string } } = {}; + const allRecipients = [...(bodyObj.to || []), ...(bodyObj.cc || []), ...(bodyObj.bcc || [])]; + for (const recipient of allRecipients) { + const email = extractEmailFromRecipient(recipient); + const externalId = `FES-MOCK-EXTERNAL-FOR-${email.toUpperCase()}-ID`; + emailToExternalIdAndUrl[email] = { + url: `${baseUrl}/message/${externalId}`, + externalId, + }; + } + return emailToExternalIdAndUrl; +}; + +/** + * Validates the message create request body. + */ +export const validateMessageCreateBody = (bodyObj: MessageCreateBody): void => { + expect(bodyObj.storageFileName).to.be.a('string'); + expect(bodyObj.associateReplyToken).to.equal('mock-fes-reply-token'); + expect(bodyObj.from).to.be.a('string'); + expect(bodyObj.to).to.be.an('array'); + // cc and bcc are optional but should be arrays if present + if (bodyObj.cc !== undefined) { + expect(bodyObj.cc).to.be.an('array'); + } + if (bodyObj.bcc !== undefined) { + expect(bodyObj.bcc).to.be.an('array'); + } +}; + +/** + * Creates a mock body string for the messagePostValidator. + */ +export const createMockBodyForValidator = (bodyObj: MessageCreateBody): string => { + return JSON.stringify({ + associateReplyToken: bodyObj.associateReplyToken, + from: bodyObj.from, + to: bodyObj.to, + cc: bodyObj.cc, + bcc: bodyObj.bcc, + }); +}; + export interface FesConfig { returnError?: HttpClientErr; apiEndpointReturnError?: HttpClientErr; @@ -148,7 +216,7 @@ export const getMockSharedTenantFesEndpoints = (config: FesConfig | undefined): return { storageFileName: 'mock-storage-file-name-' + Date.now(), replyToken: 'mock-fes-reply-token', - uploadUrl: `http://localhost:${port}/mock-s3-upload`, + uploadUrl: `https://localhost:${port}/mock-s3-upload`, }; } throw new HttpClientErr('Not Found', 404); @@ -157,13 +225,17 @@ export const getMockSharedTenantFesEndpoints = (config: FesConfig | undefined): // New endpoint that receives storageFileName instead of encrypted content if (req.method === 'POST' && typeof body === 'object') { authenticate(req, 'oidc'); - const bodyObj = body as { storageFileName?: string; associateReplyToken?: string }; - expect(bodyObj.storageFileName).to.be.a('string'); - expect(bodyObj.associateReplyToken).to.equal('mock-fes-reply-token'); + const bodyObj = body as MessageCreateBody; + const baseUrl = 'https://flowcrypt.com/shared-tenant-fes'; + validateMessageCreateBody(bodyObj); + // Use the validator if provided + if (config?.messagePostValidator) { + return await config.messagePostValidator(createMockBodyForValidator(bodyObj), 'flowcrypt.com/shared-tenant-fes'); + } return { - url: `https://flowcrypt.com/shared-tenant-fes/message/6da5ea3c-d2d6-4714-b15e-f29c805e5c6a`, + url: `${baseUrl}/message/6da5ea3c-d2d6-4714-b15e-f29c805e5c6a`, externalId: 'FES-MOCK-EXTERNAL-ID', - emailToExternalIdAndUrl: {} as { [email: string]: { url: string; externalId: string } }, + emailToExternalIdAndUrl: generateEmailToExternalIdAndUrl(bodyObj, baseUrl), }; } throw new HttpClientErr('Not Found', 404); @@ -229,6 +301,15 @@ export const getMockSharedTenantFesEndpoints = (config: FesConfig | undefined): // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); }, + '/shared-tenant-fes/api/v1/message/FES-MOCK-EXTERNAL-FOR-NO-SIG@FLOWCRYPT.COM-ID/gateway': async ({ body }, req) => { + if (req.method === 'POST') { + // test: `compose - check correct color for unusable keys` + authenticate(req, 'oidc'); + expect(body).to.match(messageIdRegexForRequest(req)); + return {}; + } + throw new HttpClientErr('Not Found', 404); + }, }; }; diff --git a/test/source/mock/lib/api.ts b/test/source/mock/lib/api.ts index 79201e87882..f2b4df5e227 100644 --- a/test/source/mock/lib/api.ts +++ b/test/source/mock/lib/api.ts @@ -13,6 +13,7 @@ import { FesConfig, getMockSharedTenantFesEndpoints } from '../fes/shared-tenant import { WkdConfig, getMockWkdEndpoints } from '../wkd/wkd-endpoints'; import { SksConfig, getMockSksEndpoints } from '../sks/sks-endpoints'; import { getMockCustomerUrlFesEndpoints } from '../fes/customer-url-fes-endpoints'; +import { getMockS3Endpoints } from '../s3/s3-endpoints'; export class HttpAuthErr extends Error {} export class HttpClientErr extends Error { @@ -76,6 +77,7 @@ export class ConfigurationProvider implements ConfigurationProviderInterface { if ( req.url.startsWith('/upload/') || // gmail message send (req.url.startsWith('/attester/pub/') && req.method === 'POST') || // attester submit - req.url.startsWith('/api/v1/message') || // FES pwd msg - req.url.startsWith('/shared-tenant-fes/api/v1/message') // Shared TENANT FES pwd msg + (req.url.startsWith('/api/v1/message') && !req.url.startsWith('/api/v1/messages')) || // FES pwd msg (legacy only) + (req.url.startsWith('/shared-tenant-fes/api/v1/message') && !req.url.startsWith('/shared-tenant-fes/api/v1/messages')) || // Shared TENANT FES pwd msg (legacy only) + req.url.startsWith('/mock-s3-upload') // Mock S3 upload ) { parsedBody = body.toString(); } else { diff --git a/test/source/mock/s3/s3-endpoints.ts b/test/source/mock/s3/s3-endpoints.ts new file mode 100644 index 00000000000..8764f59e379 --- /dev/null +++ b/test/source/mock/s3/s3-endpoints.ts @@ -0,0 +1,21 @@ +/* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ + +import { expect } from 'chai'; +import { HandlersDefinition } from '../all-apis-mock'; + +export const getMockS3Endpoints = (): HandlersDefinition => { + return { + '/mock-s3-upload': async ({ body }, req) => { + // S3 PUT requests are simple binary uploads + if (req.method === 'PUT') { + // In a real S3 upload, the body is the file content. + // We can assert on the content if needed, but for now just acknowledging receipt is enough. + // The fact that we received the request means the URL handling logic works. + // Since we configured parseReqBody to return string for this endpoint, body should be string/buffer. + expect(body).to.exist; + return {}; // S3 returns 200 OK with empty body on successful PUT + } + throw new Error(`Unexpected method ${req.method} for /mock-s3-upload`); + }, + }; +}; diff --git a/test/source/tests/compose.ts b/test/source/tests/compose.ts index 4d08521046d..d91fe59cf4a 100644 --- a/test/source/tests/compose.ts +++ b/test/source/tests/compose.ts @@ -1146,6 +1146,7 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te await composePage.waitAny('@password-or-pubkey-container'); await composePage.waitAndType('@input-password', 'gO0d-pwd'); await composePage.waitAndClick('@action-send', { delay: 1 }); + await Util.sleep(1000); await ComposePageRecipe.closed(composePage); }) ); From b12d2bd89e1ee02f0350925d77e75be28e9ab711 Mon Sep 17 00:00:00 2001 From: Ioan Moldovan Date: Sun, 8 Feb 2026 23:18:43 -0800 Subject: [PATCH 3/5] fix: test --- test/source/mock/fes/shared-tenant-fes-endpoints.ts | 9 --------- .../mock/google/strategies/send-message-strategy.ts | 2 +- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/test/source/mock/fes/shared-tenant-fes-endpoints.ts b/test/source/mock/fes/shared-tenant-fes-endpoints.ts index 53bccbd8fff..8a728dad667 100644 --- a/test/source/mock/fes/shared-tenant-fes-endpoints.ts +++ b/test/source/mock/fes/shared-tenant-fes-endpoints.ts @@ -301,15 +301,6 @@ export const getMockSharedTenantFesEndpoints = (config: FesConfig | undefined): // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); }, - '/shared-tenant-fes/api/v1/message/FES-MOCK-EXTERNAL-FOR-NO-SIG@FLOWCRYPT.COM-ID/gateway': async ({ body }, req) => { - if (req.method === 'POST') { - // test: `compose - check correct color for unusable keys` - authenticate(req, 'oidc'); - expect(body).to.match(messageIdRegexForRequest(req)); - return {}; - } - throw new HttpClientErr('Not Found', 404); - }, }; }; diff --git a/test/source/mock/google/strategies/send-message-strategy.ts b/test/source/mock/google/strategies/send-message-strategy.ts index 2c80c1d497d..b24bad669ae 100644 --- a/test/source/mock/google/strategies/send-message-strategy.ts +++ b/test/source/mock/google/strategies/send-message-strategy.ts @@ -19,7 +19,7 @@ import { KeyUtil } from '../../../core/crypto/key.js'; import { PgpArmor } from '../../../core/crypto/pgp/pgp-armor.js'; const checkPwdEncryptedMessage = (message: string | undefined) => { - if (!message?.match(/https:\/\/flowcrypt.com\/shared-tenant-fes\/message\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)) { + if (!message?.match(/https:\/\/flowcrypt.com\/shared-tenant-fes\/messages?\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)) { throw new HttpClientErr(`Error: cannot find pwd encrypted flowcrypt.com/shared-tenant-fes link in:\n\n${message}`); } }; From 012a9003ca2012b7ac2bfe4ce2234a68f41776a5 Mon Sep 17 00:00:00 2001 From: Ioan Moldovan Date: Mon, 9 Feb 2026 00:52:08 -0800 Subject: [PATCH 4/5] fix: test --- test/source/mock/google/strategies/send-message-strategy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/source/mock/google/strategies/send-message-strategy.ts b/test/source/mock/google/strategies/send-message-strategy.ts index b24bad669ae..76ba82d626e 100644 --- a/test/source/mock/google/strategies/send-message-strategy.ts +++ b/test/source/mock/google/strategies/send-message-strategy.ts @@ -132,11 +132,11 @@ class PwdEncryptedMessageWithFesIdTokenTestStrategy implements ITestMsgStrategy const mimeMsg = parseResult.mimeMsg; const expectedSenderEmail = `user@standardsubdomainfes.localhost:${port}`; expect(mimeMsg.from!.text).to.equal(`"First Last" <${expectedSenderEmail}>`); - if (mimeMsg.text?.includes(`http://fes.standardsubdomainfes.localhost:${port}/message/FES-MOCK-MESSAGE-FOR-TO@EXAMPLE.COM-ID`)) { + if (mimeMsg.text?.includes(`http://fes.standardsubdomainfes.localhost:${port}/messages/FES-MOCK-MESSAGE-FOR-TO@EXAMPLE.COM-ID`)) { expect((mimeMsg.to as AddressObject).text).to.equal('"Mr To" '); expect(mimeMsg.cc).to.be.an.undefined; expect(mimeMsg.bcc).to.be.an.undefined; - } else if (mimeMsg.text?.includes(`http://fes.standardsubdomainfes.localhost:${port}/message/FES-MOCK-MESSAGE-FOR-BCC@EXAMPLE.COM-ID`)) { + } else if (mimeMsg.text?.includes(`http://fes.standardsubdomainfes.localhost:${port}/messages/FES-MOCK-MESSAGE-FOR-BCC@EXAMPLE.COM-ID`)) { expect((mimeMsg.to as AddressObject).text).to.equal('"Mr Bcc" '); expect(mimeMsg.cc).to.be.an.undefined; expect(mimeMsg.bcc).to.be.an.undefined; From ce723b080bf844e99cfbebc62fa10428b980ad5b Mon Sep 17 00:00:00 2001 From: Ioan Moldovan Date: Tue, 10 Feb 2026 15:18:32 -0800 Subject: [PATCH 5/5] fix: test --- .../mock/fes/customer-url-fes-endpoints.ts | 105 +++++----- .../mock/fes/shared-tenant-fes-endpoints.ts | 180 +++++++----------- .../strategies/send-message-strategy.ts | 6 +- test/source/mock/s3/s3-endpoints.ts | 30 ++- test/source/tests/compose.ts | 1 - 5 files changed, 143 insertions(+), 179 deletions(-) diff --git a/test/source/mock/fes/customer-url-fes-endpoints.ts b/test/source/mock/fes/customer-url-fes-endpoints.ts index 4f31c0cf116..4766ec12c08 100644 --- a/test/source/mock/fes/customer-url-fes-endpoints.ts +++ b/test/source/mock/fes/customer-url-fes-endpoints.ts @@ -6,13 +6,8 @@ import { HandlersDefinition } from '../all-apis-mock'; import { HttpClientErr, Status } from '../lib/api'; import { messageIdRegex, parseAuthority, parsePort } from '../lib/mock-util'; import { MockJwt } from '../lib/oauth'; -import { - FesConfig, - MessageCreateBody, - validateMessageCreateBody, - createMockBodyForValidator, - generateEmailToExternalIdAndUrl, -} from './shared-tenant-fes-endpoints'; +import { FesConfig, MessageCreateBody, createCombinedBodyForValidator } from './shared-tenant-fes-endpoints'; +import { getStoredS3Content } from '../s3/s3-endpoints'; const standardFesUrl = (port: string) => { return `fes.standardsubdomainfes.localhost:${port}`; @@ -75,10 +70,11 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H const port = parsePort(req); if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { authenticate(req, isCustomIDPUsed); + const storageFileName = 'mock-storage-file-name-' + Date.now(); return { - storageFileName: 'mock-storage-file-name-' + Date.now(), + storageFileName, replyToken: 'mock-fes-reply-token', - uploadUrl: `https://localhost:${port}/mock-s3-upload`, + uploadUrl: `https://localhost:${port}/mock-s3-upload/${storageFileName}`, }; } throw new HttpClientErr('Not Found', 404); @@ -86,84 +82,75 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H '/api/v1/messages': async ({ body }, req) => { const port = parsePort(req); const fesUrl = standardFesUrl(port); - const baseUrl = `https://${fesUrl}`; // New endpoint that receives storageFileName instead of encrypted content if (parseAuthority(req) === fesUrl && req.method === 'POST' && typeof body === 'object') { authenticate(req, isCustomIDPUsed); const bodyObj = body as MessageCreateBody; - validateMessageCreateBody(bodyObj); - // Use the validator if provided + // Retrieve the PGP content uploaded to S3 and combine with metadata for validation + const s3Content = getStoredS3Content(bodyObj.storageFileName); + const combinedBody = createCombinedBodyForValidator(s3Content, bodyObj); if (config?.messagePostValidator) { - return await config.messagePostValidator(createMockBodyForValidator(bodyObj), fesUrl); - } - return { - url: `${baseUrl}/message/mock-external-id`, - externalId: 'FES-MOCK-EXTERNAL-ID', - emailToExternalIdAndUrl: generateEmailToExternalIdAndUrl(bodyObj, baseUrl), - }; - } - throw new HttpClientErr('Not Found', 404); - }, - '/api/v1/message': async ({ body }, req) => { - const port = parsePort(req); - const fesUrl = standardFesUrl(port); - // Legacy endpoint - body is a mime-multipart string, we're doing a few smoke checks here without parsing it - if (parseAuthority(req) === fesUrl && req.method === 'POST' && typeof body === 'string') { - authenticate(req, isCustomIDPUsed); - if (config?.messagePostValidator) { - return await config.messagePostValidator(body, fesUrl); + return await config.messagePostValidator(combinedBody, fesUrl); } throw new HttpClientErr('Not Allowed', 405); } throw new HttpClientErr('Not Found', 404); }, - '/api/v1/message/FES-MOCK-EXTERNAL-ID/gateway': async ({ body }, req) => { + // Wildcard handler for /api/v1/messages/* sub-paths (gateway endpoints for new flow) + // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` + // test: `compose - user3@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - pubkey recipient in bcc` + // test: `compose - user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - some sends fail with BadRequest error` + // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` + '/api/v1/messages/?': async ({ body }, req) => { const port = parsePort(req); - if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { - // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` - authenticate(req, isCustomIDPUsed); - expect(body).to.match(messageIdRegex(port)); - return {}; - } - throw new HttpClientErr('Not Found', 404); - }, - '/api/v1/message/FES-MOCK-EXTERNAL-FOR-SENDER@DOMAIN.COM-ID/gateway': async ({ body }, req) => { - const port = parsePort(req); - if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { - // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` + const gatewayMatch = /\/api\/v1\/messages\/([^/]+)\/gateway/.exec(req.url); + if (gatewayMatch && parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { + const externalId = gatewayMatch[1]; + if (externalId === 'FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID') { + throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); + } authenticate(req, isCustomIDPUsed); - expect(body).to.match(messageIdRegex(port)); + const bodyStr = typeof body === 'string' ? body : JSON.stringify(body); + expect(bodyStr).to.match(messageIdRegex(port)); return {}; } throw new HttpClientErr('Not Found', 404); }, - '/api/v1/message/FES-MOCK-EXTERNAL-FOR-TO@EXAMPLE.COM-ID/gateway': async ({ body }, req) => { + // Legacy endpoint - body is a mime-multipart string + '/api/v1/message': async ({ body }, req) => { const port = parsePort(req); - if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { - // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` - // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` - // test: `compose - user3@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - pubkey recipient in bcc` - // test: `compose - user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - some sends fail with BadRequest error` + const fesUrl = standardFesUrl(port); + // body is a mime-multipart string, we're doing a few smoke checks here without parsing it + if (parseAuthority(req) === fesUrl && req.method === 'POST' && typeof body === 'string') { authenticate(req, isCustomIDPUsed); - expect(body).to.match(messageIdRegex(port)); - return {}; + if (config?.messagePostValidator) { + return await config.messagePostValidator(body, fesUrl); + } + throw new HttpClientErr('Not Allowed', 405); } throw new HttpClientErr('Not Found', 404); }, - '/api/v1/message/FES-MOCK-EXTERNAL-FOR-BCC@EXAMPLE.COM-ID/gateway': async ({ body }, req) => { + // Legacy wildcard handler for /api/v1/message/* sub-paths (gateway endpoints) + // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` + // test: `compose - user3@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - pubkey recipient in bcc` + // test: `compose - user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - some sends fail with BadRequest error` + // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` + '/api/v1/message/?': async ({ body }, req) => { const port = parsePort(req); - if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { - // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + const gatewayMatch = /\/api\/v1\/message\/([^/]+)\/gateway/.exec(req.url); + if (gatewayMatch && parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { + const externalId = gatewayMatch[1]; + if (externalId === 'FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID') { + throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); + } authenticate(req, isCustomIDPUsed); expect(body).to.match(messageIdRegex(port)); return {}; } throw new HttpClientErr('Not Found', 404); }, - '/api/v1/message/FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID/gateway': async () => { - // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` - throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); - }, }; }; diff --git a/test/source/mock/fes/shared-tenant-fes-endpoints.ts b/test/source/mock/fes/shared-tenant-fes-endpoints.ts index 8a728dad667..896cdf580d2 100644 --- a/test/source/mock/fes/shared-tenant-fes-endpoints.ts +++ b/test/source/mock/fes/shared-tenant-fes-endpoints.ts @@ -6,6 +6,7 @@ import { HandlersDefinition } from '../all-apis-mock'; import { HttpClientErr, Status } from '../lib/api'; import { MockJwt } from '../lib/oauth'; import { messageIdRegex, parseAuthority, parsePort } from '../lib/mock-util'; +import { getStoredS3Content } from '../s3/s3-endpoints'; export interface ReportedError { name: string; @@ -70,72 +71,31 @@ export interface FesMessageReturnType { externalId: string; emailToExternalIdAndUrl: { [email: string]: { url: string; externalId: string } }; } - export interface MessageCreateBody { - storageFileName?: string; - associateReplyToken?: string; - from?: string; - to?: string[]; - cc?: string[]; - bcc?: string[]; + storageFileName: string; + associateReplyToken: string; + from: string; + to: string[]; + cc: string[]; + bcc: string[]; } /** - * Extracts email from a recipient string that may be in "Name " format. - */ -export const extractEmailFromRecipient = (recipient: string): string => { - const emailMatch = /<([^>]+)>/.exec(recipient); - return emailMatch ? emailMatch[1] : recipient; -}; - -/** - * Generates emailToExternalIdAndUrl mapping for all recipients. - */ -export const generateEmailToExternalIdAndUrl = ( - bodyObj: MessageCreateBody, - baseUrl: string -): { [email: string]: { url: string; externalId: string } } => { - const emailToExternalIdAndUrl: { [email: string]: { url: string; externalId: string } } = {}; - const allRecipients = [...(bodyObj.to || []), ...(bodyObj.cc || []), ...(bodyObj.bcc || [])]; - for (const recipient of allRecipients) { - const email = extractEmailFromRecipient(recipient); - const externalId = `FES-MOCK-EXTERNAL-FOR-${email.toUpperCase()}-ID`; - emailToExternalIdAndUrl[email] = { - url: `${baseUrl}/message/${externalId}`, - externalId, - }; - } - return emailToExternalIdAndUrl; -}; - -/** - * Validates the message create request body. - */ -export const validateMessageCreateBody = (bodyObj: MessageCreateBody): void => { - expect(bodyObj.storageFileName).to.be.a('string'); - expect(bodyObj.associateReplyToken).to.equal('mock-fes-reply-token'); - expect(bodyObj.from).to.be.a('string'); - expect(bodyObj.to).to.be.an('array'); - // cc and bcc are optional but should be arrays if present - if (bodyObj.cc !== undefined) { - expect(bodyObj.cc).to.be.an('array'); - } - if (bodyObj.bcc !== undefined) { - expect(bodyObj.bcc).to.be.an('array'); - } -}; - -/** - * Creates a mock body string for the messagePostValidator. + * Creates a combined mock body string from S3 content and message metadata, + * matching the format expected by messagePostValidator functions. */ -export const createMockBodyForValidator = (bodyObj: MessageCreateBody): string => { - return JSON.stringify({ - associateReplyToken: bodyObj.associateReplyToken, - from: bodyObj.from, - to: bodyObj.to, - cc: bodyObj.cc, - bcc: bodyObj.bcc, - }); +export const createCombinedBodyForValidator = (s3Content: string, bodyObj: MessageCreateBody): string => { + return ( + s3Content + + '\n' + + JSON.stringify({ + associateReplyToken: bodyObj.associateReplyToken, + from: bodyObj.from, + to: bodyObj.to, + cc: bodyObj.cc, + bcc: bodyObj.bcc, + }) + ); }; export interface FesConfig { @@ -213,10 +173,11 @@ export const getMockSharedTenantFesEndpoints = (config: FesConfig | undefined): if (req.method === 'POST') { authenticate(req, 'oidc'); const port = parsePort(req); + const storageFileName = 'mock-storage-file-name-' + Date.now(); return { - storageFileName: 'mock-storage-file-name-' + Date.now(), + storageFileName, replyToken: 'mock-fes-reply-token', - uploadUrl: `https://localhost:${port}/mock-s3-upload`, + uploadUrl: `https://localhost:${port}/mock-s3-upload/${storageFileName}`, }; } throw new HttpClientErr('Not Found', 404); @@ -226,22 +187,47 @@ export const getMockSharedTenantFesEndpoints = (config: FesConfig | undefined): if (req.method === 'POST' && typeof body === 'object') { authenticate(req, 'oidc'); const bodyObj = body as MessageCreateBody; - const baseUrl = 'https://flowcrypt.com/shared-tenant-fes'; - validateMessageCreateBody(bodyObj); - // Use the validator if provided - if (config?.messagePostValidator) { - return await config.messagePostValidator(createMockBodyForValidator(bodyObj), 'flowcrypt.com/shared-tenant-fes'); + // Retrieve the PGP content uploaded to S3 and combine with metadata for validation + const s3Content = getStoredS3Content(bodyObj.storageFileName); + const combinedBody = createCombinedBodyForValidator(s3Content, bodyObj); + expect(combinedBody).to.contain('-----BEGIN PGP MESSAGE-----'); + expect(combinedBody).to.contain('"associateReplyToken":"mock-fes-reply-token"'); + if (combinedBody.includes('NameWithEmoji')) { + expect(combinedBody).to.not.include('⭐'); } - return { - url: `${baseUrl}/message/6da5ea3c-d2d6-4714-b15e-f29c805e5c6a`, + const response = { + // this url is required for pubkey encrypted message + url: `https://flowcrypt.com/shared-tenant-fes/message/6da5ea3c-d2d6-4714-b15e-f29c805e5c6a`, externalId: 'FES-MOCK-EXTERNAL-ID', - emailToExternalIdAndUrl: generateEmailToExternalIdAndUrl(bodyObj, baseUrl), + emailToExternalIdAndUrl: {} as { [email: string]: { url: string; externalId: string } }, }; + return response; + } + throw new HttpClientErr('Not Found', 404); + }, + // Wildcard handler for /shared-tenant-fes/api/v1/messages/* sub-paths (gateway endpoints for new flow) + // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` + // test: `compose - user3@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - pubkey recipient in bcc` + // test: `compose - user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - some sends fail with BadRequest error` + // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` + '/shared-tenant-fes/api/v1/messages/?': async ({ body }, req) => { + const gatewayMatch = /\/shared-tenant-fes\/api\/v1\/messages\/([^/]+)\/gateway/.exec(req.url); + if (gatewayMatch && req.method === 'POST') { + const externalId = gatewayMatch[1]; + if (externalId === 'FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID') { + throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); + } + authenticate(req, 'oidc'); + const bodyStr = typeof body === 'string' ? body : JSON.stringify(body); + expect(bodyStr).to.match(messageIdRegexForRequest(req)); + return {}; } throw new HttpClientErr('Not Found', 404); }, + // Legacy endpoint - body is a mime-multipart string '/shared-tenant-fes/api/v1/message': async ({ body }, req) => { - // Legacy endpoint - body is a mime-multipart string, we're doing a few smoke checks here without parsing it + // body is a mime-multipart string, we're doing a few smoke checks here without parsing it if (req.method === 'POST' && typeof body === 'string') { expect(body).to.contain('-----BEGIN PGP MESSAGE-----'); expect(body).to.contain('"associateReplyToken":"mock-fes-reply-token"'); @@ -258,49 +244,25 @@ export const getMockSharedTenantFesEndpoints = (config: FesConfig | undefined): } throw new HttpClientErr('Not Found', 404); }, - '/shared-tenant-fes/api/v1/message/FES-MOCK-EXTERNAL-ID/gateway': async ({ body }, req) => { - if (req.method === 'POST') { - // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` - authenticate(req, 'oidc'); - expect(body).to.match(messageIdRegexForRequest(req)); - return {}; - } - throw new HttpClientErr('Not Found', 404); - }, - '/shared-tenant-fes/api/v1/message/FES-MOCK-EXTERNAL-FOR-SENDER@DOMAIN.COM-ID/gateway': async ({ body }, req) => { - if (req.method === 'POST') { - // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` - authenticate(req, 'oidc'); - expect(body).to.match(messageIdRegexForRequest(req)); - return {}; - } - throw new HttpClientErr('Not Found', 404); - }, - '/shared-tenant-fes/api/v1/message/FES-MOCK-EXTERNAL-FOR-TO@EXAMPLE.COM-ID/gateway': async ({ body }, req) => { - if (req.method === 'POST') { - // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` - // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` - // test: `compose - user3@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - pubkey recipient in bcc` - // test: `compose - user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - some sends fail with BadRequest error` - authenticate(req, 'oidc'); - expect(body).to.match(messageIdRegexForRequest(req)); - return {}; - } - throw new HttpClientErr('Not Found', 404); - }, - '/shared-tenant-fes/api/v1/message/FES-MOCK-EXTERNAL-FOR-BCC@EXAMPLE.COM-ID/gateway': async ({ body }, req) => { - if (req.method === 'POST') { - // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + // Legacy wildcard handler for /shared-tenant-fes/api/v1/message/* sub-paths (gateway endpoints) + // test: `compose - user@standardsubdomainfes.localhost - PWD encrypted message with FES web portal` + // test: `compose - user2@standardsubdomainfes.localhost - PWD encrypted message with FES - Reply rendering` + // test: `compose - user3@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - pubkey recipient in bcc` + // test: `compose - user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - some sends fail with BadRequest error` + // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` + '/shared-tenant-fes/api/v1/message/?': async ({ body }, req) => { + const gatewayMatch = /\/shared-tenant-fes\/api\/v1\/message\/([^/]+)\/gateway/.exec(req.url); + if (gatewayMatch && req.method === 'POST') { + const externalId = gatewayMatch[1]; + if (externalId === 'FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID') { + throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); + } authenticate(req, 'oidc'); expect(body).to.match(messageIdRegexForRequest(req)); return {}; } throw new HttpClientErr('Not Found', 404); }, - '/shared-tenant-fes/api/v1/message/FES-MOCK-EXTERNAL-FOR-GATEWAYFAILURE@EXAMPLE.COM-ID/gateway': async () => { - // test: `user4@standardsubdomainfes.localhost - PWD encrypted message with FES web portal - a send fails with gateway update error` - throw new HttpClientErr(`Test error`, Status.BAD_REQUEST); - }, }; }; diff --git a/test/source/mock/google/strategies/send-message-strategy.ts b/test/source/mock/google/strategies/send-message-strategy.ts index 76ba82d626e..2c80c1d497d 100644 --- a/test/source/mock/google/strategies/send-message-strategy.ts +++ b/test/source/mock/google/strategies/send-message-strategy.ts @@ -19,7 +19,7 @@ import { KeyUtil } from '../../../core/crypto/key.js'; import { PgpArmor } from '../../../core/crypto/pgp/pgp-armor.js'; const checkPwdEncryptedMessage = (message: string | undefined) => { - if (!message?.match(/https:\/\/flowcrypt.com\/shared-tenant-fes\/messages?\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)) { + if (!message?.match(/https:\/\/flowcrypt.com\/shared-tenant-fes\/message\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)) { throw new HttpClientErr(`Error: cannot find pwd encrypted flowcrypt.com/shared-tenant-fes link in:\n\n${message}`); } }; @@ -132,11 +132,11 @@ class PwdEncryptedMessageWithFesIdTokenTestStrategy implements ITestMsgStrategy const mimeMsg = parseResult.mimeMsg; const expectedSenderEmail = `user@standardsubdomainfes.localhost:${port}`; expect(mimeMsg.from!.text).to.equal(`"First Last" <${expectedSenderEmail}>`); - if (mimeMsg.text?.includes(`http://fes.standardsubdomainfes.localhost:${port}/messages/FES-MOCK-MESSAGE-FOR-TO@EXAMPLE.COM-ID`)) { + if (mimeMsg.text?.includes(`http://fes.standardsubdomainfes.localhost:${port}/message/FES-MOCK-MESSAGE-FOR-TO@EXAMPLE.COM-ID`)) { expect((mimeMsg.to as AddressObject).text).to.equal('"Mr To" '); expect(mimeMsg.cc).to.be.an.undefined; expect(mimeMsg.bcc).to.be.an.undefined; - } else if (mimeMsg.text?.includes(`http://fes.standardsubdomainfes.localhost:${port}/messages/FES-MOCK-MESSAGE-FOR-BCC@EXAMPLE.COM-ID`)) { + } else if (mimeMsg.text?.includes(`http://fes.standardsubdomainfes.localhost:${port}/message/FES-MOCK-MESSAGE-FOR-BCC@EXAMPLE.COM-ID`)) { expect((mimeMsg.to as AddressObject).text).to.equal('"Mr Bcc" '); expect(mimeMsg.cc).to.be.an.undefined; expect(mimeMsg.bcc).to.be.an.undefined; diff --git a/test/source/mock/s3/s3-endpoints.ts b/test/source/mock/s3/s3-endpoints.ts index 8764f59e379..e5118d79295 100644 --- a/test/source/mock/s3/s3-endpoints.ts +++ b/test/source/mock/s3/s3-endpoints.ts @@ -3,17 +3,33 @@ import { expect } from 'chai'; import { HandlersDefinition } from '../all-apis-mock'; +/** + * In-memory storage for S3 uploaded content, keyed by storageFileName. + */ +const s3Storage = new Map(); + +/** + * Retrieves stored S3 content for a given storageFileName. + */ +export const getStoredS3Content = (storageFileName: string): string => { + const content = s3Storage.get(storageFileName); + if (!content) { + throw new Error(`S3 content not found for storageFileName: ${storageFileName}`); + } + return content; +}; + export const getMockS3Endpoints = (): HandlersDefinition => { return { - '/mock-s3-upload': async ({ body }, req) => { - // S3 PUT requests are simple binary uploads + '/mock-s3-upload/?': async ({ body }, req) => { if (req.method === 'PUT') { - // In a real S3 upload, the body is the file content. - // We can assert on the content if needed, but for now just acknowledging receipt is enough. - // The fact that we received the request means the URL handling logic works. - // Since we configured parseReqBody to return string for this endpoint, body should be string/buffer. + // Extract storage file name from URL path: /mock-s3-upload/ + const storageFileName = req.url.split('/mock-s3-upload/')[1]?.split('?')[0]; + expect(storageFileName).to.be.a('string').and.not.be.empty; expect(body).to.exist; - return {}; // S3 returns 200 OK with empty body on successful PUT + // Store the uploaded content (PGP encrypted message as string) + s3Storage.set(storageFileName, body as string); + return ''; // S3 returns empty body on successful PUT } throw new Error(`Unexpected method ${req.method} for /mock-s3-upload`); }, diff --git a/test/source/tests/compose.ts b/test/source/tests/compose.ts index d91fe59cf4a..4d08521046d 100644 --- a/test/source/tests/compose.ts +++ b/test/source/tests/compose.ts @@ -1146,7 +1146,6 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te await composePage.waitAny('@password-or-pubkey-container'); await composePage.waitAndType('@input-password', 'gO0d-pwd'); await composePage.waitAndClick('@action-send', { delay: 1 }); - await Util.sleep(1000); await ComposePageRecipe.closed(composePage); }) );