diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-text-extractor.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-text-extractor.service.ts index 87c29c35de..4342c57550 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-text-extractor.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-text-extractor.service.ts @@ -4,10 +4,9 @@ import DOMPurify from 'dompurify'; import { convert } from 'html-to-text'; import { JSDOM } from 'jsdom'; import * as planer from 'planer'; +import { safeDecodeURIComponent } from 'twenty-shared/utils'; import { type Email as ParsedEmail } from 'postal-mime'; -import { safeDecodeURIComponent } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/safe-decode-uri-component.util'; - @Injectable() export class ImapMessageTextExtractorService { private readonly jsdomInstance: JSDOM; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 483d9c5bd9..7fe777613b 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -131,6 +131,7 @@ export { getUrlHostnameOrThrow } from './url/getUrlHostnameOrThrow'; export { isValidHostname } from './url/isValidHostname'; export { isValidUrl } from './url/isValidUrl'; export { lowercaseUrlOriginAndRemoveTrailingSlash } from './url/lowercaseUrlOriginAndRemoveTrailingSlash'; +export { safeDecodeURIComponent } from './url/safeDecodeURIComponent'; export { uuidToBase36 } from './uuidToBase36'; export { assertIsDefinedOrThrow } from './validation/assertIsDefinedOrThrow'; export { isDefined } from './validation/isDefined'; diff --git a/packages/twenty-shared/src/utils/url/__tests__/lowercaseUrlOriginAndRemoveTrailingSlash.test.ts b/packages/twenty-shared/src/utils/url/__tests__/lowercaseUrlOriginAndRemoveTrailingSlash.test.ts index a9a54dc279..897fc6d61a 100644 --- a/packages/twenty-shared/src/utils/url/__tests__/lowercaseUrlOriginAndRemoveTrailingSlash.test.ts +++ b/packages/twenty-shared/src/utils/url/__tests__/lowercaseUrlOriginAndRemoveTrailingSlash.test.ts @@ -38,6 +38,48 @@ describe('lowercaseUrlOriginAndRemoveTrailingSlash', () => { input: 'htTps://wwW.exAmple.coM/TEST#Hash', expected: 'https://www.example.com/TEST#Hash', }, + { + title: 'should preserve special characters in path', + input: 'https://test.test/frédéric-destombes-22219837', + expected: 'https://test.test/frédéric-destombes-22219837', + }, + { + title: 'should decode already encoded special characters in path', + input: 'https://test.test/fr%C3%A9d%C3%A9ric-destombes-22219837', + expected: 'https://test.test/frédéric-destombes-22219837', + }, + { + title: 'should preserve special characters in query params', + input: 'https://example.com/path?name=José', + expected: 'https://example.com/path?name=José', + }, + { + title: + 'should handle malformed percent-encoding gracefully (incomplete sequence)', + input: 'https://example.com/test%E0%A4%A', + expected: 'https://example.com/test%E0%A4%A', + }, + { + title: + 'should preserve double-encoded URLs (encoded percent signs stay encoded once)', + input: 'https://example.com/test%2520name', + expected: 'https://example.com/test%20name', + }, + { + title: 'should preserve special characters in hash fragments', + input: 'https://example.com/path#frédéric', + expected: 'https://example.com/path#fr%C3%A9d%C3%A9ric', + }, + { + title: 'should keep encoded characters in hash fragments as-is', + input: 'https://example.com/path#fr%C3%A9d%C3%A9ric', + expected: 'https://example.com/path#fr%C3%A9d%C3%A9ric', + }, + { + title: 'should handle mixed encoded and non-encoded in same URL', + input: 'https://example.com/path%2Fwith%2Fslashes?query=hello%20world', + expected: 'https://example.com/path/with/slashes?query=hello world', + }, ])('$title', ({ input, expected }) => { expect(lowercaseUrlOriginAndRemoveTrailingSlash(input)).toBe(expected); }); diff --git a/packages/twenty-shared/src/utils/url/index.ts b/packages/twenty-shared/src/utils/url/index.ts index 5b9cb1eb21..5400bf560e 100644 --- a/packages/twenty-shared/src/utils/url/index.ts +++ b/packages/twenty-shared/src/utils/url/index.ts @@ -4,3 +4,4 @@ export * from './getUrlHostnameOrThrow'; export * from './isValidHostname'; export * from './isValidUrl'; export * from './buildSignedPath'; +export * from './safeDecodeURIComponent'; diff --git a/packages/twenty-shared/src/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash.ts b/packages/twenty-shared/src/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash.ts index b21769c712..2575b5fdc7 100644 --- a/packages/twenty-shared/src/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash.ts +++ b/packages/twenty-shared/src/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash.ts @@ -1,5 +1,6 @@ import { getURLSafely } from '@/utils/getURLSafely'; import { isDefined } from '@/utils/validation'; +import { safeDecodeURIComponent } from './safeDecodeURIComponent'; export const lowercaseUrlOriginAndRemoveTrailingSlash = (rawUrl: string) => { const url = getURLSafely(rawUrl); @@ -9,7 +10,8 @@ export const lowercaseUrlOriginAndRemoveTrailingSlash = (rawUrl: string) => { } const lowercaseOrigin = url.origin.toLowerCase(); - const path = url.pathname + url.search + url.hash; + const path = + safeDecodeURIComponent(url.pathname) + safeDecodeURIComponent(url.search) + url.hash; return (lowercaseOrigin + path).replace(/\/$/, ''); }; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/utils/safe-decode-uri-component.util.ts b/packages/twenty-shared/src/utils/url/safeDecodeURIComponent.ts similarity index 100% rename from packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/utils/safe-decode-uri-component.util.ts rename to packages/twenty-shared/src/utils/url/safeDecodeURIComponent.ts