From 082337feda98d0b0bc3a52557b930ebfc27fa628 Mon Sep 17 00:00:00 2001 From: Baptiste Griva Date: Tue, 23 Dec 2025 11:35:28 +0100 Subject: [PATCH 1/4] feat: Handle invisible characters in mail subjects --- .../MailboxManager+Thread.swift | 1 + MailCore/Models/Message.swift | 2 +- MailCore/Models/Thread.swift | 2 +- MailCore/Utils/NotificationsHelper.swift | 2 + MailCore/Utils/SubjectFormatter.swift | 65 +++++++++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 MailCore/Utils/SubjectFormatter.swift diff --git a/MailCore/Cache/MailboxManager/MailboxManager+Thread.swift b/MailCore/Cache/MailboxManager/MailboxManager+Thread.swift index 6db6b7efc8..de931df724 100644 --- a/MailCore/Cache/MailboxManager/MailboxManager+Thread.swift +++ b/MailCore/Cache/MailboxManager/MailboxManager+Thread.swift @@ -419,6 +419,7 @@ public extension MailboxManager { for message in messageByUids.messages { SentryDebug.captureIncorrectSnoozedMessageIfNecessary(message) + message.subject = message.formattedSubject if let existingMessage = writableRealm.object(ofType: Message.self, forPrimaryKey: message.uid) { if folder.shouldOverrideMessage { upsertMessage(message, oldMessage: existingMessage, threadsToUpdate: &threadsToUpdate, using: writableRealm) diff --git a/MailCore/Models/Message.swift b/MailCore/Models/Message.swift index a42e00849b..e9d2581d8e 100644 --- a/MailCore/Models/Message.swift +++ b/MailCore/Models/Message.swift @@ -250,7 +250,7 @@ public final class Message: Object, Decodable, ObjectKeyIdentifiable { } public var formattedSubject: String { - return subject ?? MailResourcesStrings.Localizable.noSubjectTitle + return subject?.formatted(.cleanSubject) ?? MailResourcesStrings.Localizable.noSubjectTitle } public var displayDate: DisplayDate { diff --git a/MailCore/Models/Thread.swift b/MailCore/Models/Thread.swift index ab73e359ac..7bc3a631bb 100644 --- a/MailCore/Models/Thread.swift +++ b/MailCore/Models/Thread.swift @@ -128,7 +128,7 @@ public class Thread: Object, Decodable, Identifiable { guard let subject, !subject.isEmpty else { return MailResourcesStrings.Localizable.noSubjectTitle } - return subject + return subject.formatted(.cleanSubject) } public var shouldPresentAsDraft: Bool { diff --git a/MailCore/Utils/NotificationsHelper.swift b/MailCore/Utils/NotificationsHelper.swift index 8015b64bb4..3e65f5e8fc 100644 --- a/MailCore/Utils/NotificationsHelper.swift +++ b/MailCore/Utils/NotificationsHelper.swift @@ -325,6 +325,8 @@ public enum NotificationsHelper { guard let liveMessage = realm.object(ofType: Message.self, forPrimaryKey: message.uid) else { return } + + liveMessage.subject = liveMessage.formattedSubject liveMessage.preview = String(preview.prefix(512)) } } diff --git a/MailCore/Utils/SubjectFormatter.swift b/MailCore/Utils/SubjectFormatter.swift new file mode 100644 index 0000000000..f7448afaed --- /dev/null +++ b/MailCore/Utils/SubjectFormatter.swift @@ -0,0 +1,65 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2025 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation + +public struct SubjectFormatter: FormatStyle { + public func format(_ value: String) -> String { + let normalizedSubject = value.precomposedStringWithCompatibilityMapping + + let cleanedSubject = String(normalizedSubject.unicodeScalars.map { scalar -> Character in + if scalar.isIllegalInSubject { + return " " + } else { + return Character(scalar) + } + }) + + return cleanedSubject + } +} + +public extension FormatStyle where Self == SubjectFormatter { + static var cleanSubject: SubjectFormatter { + return SubjectFormatter() + } +} + +extension UnicodeScalar { + var isInvisible: Bool { + properties.generalCategory == .format || + properties.generalCategory == .control + } + + var isBidiControl: Bool { + switch value { + case 0x202A ... 0x202E, 0x2066 ... 0x2069: + return true + default: + return false + } + } + + var isIllegalInSubject: Bool { + if isInvisible || properties.isJoinControl || isBidiControl { + return true + } + + return false + } +} From d09d4df1657e27e3288c8f11bf754ca47dcbd516 Mon Sep 17 00:00:00 2001 From: Baptiste Griva Date: Mon, 5 Jan 2026 13:45:52 +0100 Subject: [PATCH 2/4] fix: Remove the formattedSubject override on message subject --- MailCore/Cache/MailboxManager/MailboxManager+Thread.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/MailCore/Cache/MailboxManager/MailboxManager+Thread.swift b/MailCore/Cache/MailboxManager/MailboxManager+Thread.swift index de931df724..6db6b7efc8 100644 --- a/MailCore/Cache/MailboxManager/MailboxManager+Thread.swift +++ b/MailCore/Cache/MailboxManager/MailboxManager+Thread.swift @@ -419,7 +419,6 @@ public extension MailboxManager { for message in messageByUids.messages { SentryDebug.captureIncorrectSnoozedMessageIfNecessary(message) - message.subject = message.formattedSubject if let existingMessage = writableRealm.object(ofType: Message.self, forPrimaryKey: message.uid) { if folder.shouldOverrideMessage { upsertMessage(message, oldMessage: existingMessage, threadsToUpdate: &threadsToUpdate, using: writableRealm) From 196ebf0f3b518499415eccd55cf71111640c49db Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Tue, 6 Jan 2026 13:23:17 +0100 Subject: [PATCH 3/4] fix: Use SubjectFormatter everywhere --- MailCore/Models/Message.swift | 2 +- MailCore/Models/Thread.swift | 5 +---- MailCore/Utils/SubjectFormatter.swift | 17 +++++++++++++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/MailCore/Models/Message.swift b/MailCore/Models/Message.swift index e9d2581d8e..826aeb8d9a 100644 --- a/MailCore/Models/Message.swift +++ b/MailCore/Models/Message.swift @@ -250,7 +250,7 @@ public final class Message: Object, Decodable, ObjectKeyIdentifiable { } public var formattedSubject: String { - return subject?.formatted(.cleanSubject) ?? MailResourcesStrings.Localizable.noSubjectTitle + return SubjectFormatter.cleanNotNullOrEmptySubject.format(subject) } public var displayDate: DisplayDate { diff --git a/MailCore/Models/Thread.swift b/MailCore/Models/Thread.swift index 7bc3a631bb..73df6f7414 100644 --- a/MailCore/Models/Thread.swift +++ b/MailCore/Models/Thread.swift @@ -125,10 +125,7 @@ public class Thread: Object, Decodable, Identifiable { } public var formattedSubject: String { - guard let subject, !subject.isEmpty else { - return MailResourcesStrings.Localizable.noSubjectTitle - } - return subject.formatted(.cleanSubject) + return SubjectFormatter.cleanNotNullOrEmptySubject.format(subject) } public var shouldPresentAsDraft: Bool { diff --git a/MailCore/Utils/SubjectFormatter.swift b/MailCore/Utils/SubjectFormatter.swift index f7448afaed..1827b6928f 100644 --- a/MailCore/Utils/SubjectFormatter.swift +++ b/MailCore/Utils/SubjectFormatter.swift @@ -17,9 +17,10 @@ */ import Foundation +import MailResources public struct SubjectFormatter: FormatStyle { - public func format(_ value: String) -> String { + private func clean(_ value: String) -> String { let normalizedSubject = value.precomposedStringWithCompatibilityMapping let cleanedSubject = String(normalizedSubject.unicodeScalars.map { scalar -> Character in @@ -32,10 +33,22 @@ public struct SubjectFormatter: FormatStyle { return cleanedSubject } + + private func cleanNotNullOrEmpty(_ value: String?) -> String { + guard let value, !value.isEmpty else { + return MailResourcesStrings.Localizable.noSubjectTitle + } + + return clean(value) + } + + public func format(_ value: String?) -> String { + return cleanNotNullOrEmpty(value) + } } public extension FormatStyle where Self == SubjectFormatter { - static var cleanSubject: SubjectFormatter { + static var cleanNotNullOrEmptySubject: SubjectFormatter { return SubjectFormatter() } } From 9fedf5a7ec8e57fac9f48a092f6554291efda709 Mon Sep 17 00:00:00 2001 From: Philippe Weidmann Date: Tue, 6 Jan 2026 13:23:42 +0100 Subject: [PATCH 4/4] fix: Do not save formatted subject --- MailCore/Models/Thread.swift | 1 - MailCore/Utils/NotificationsHelper.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/MailCore/Models/Thread.swift b/MailCore/Models/Thread.swift index 73df6f7414..41b651a82e 100644 --- a/MailCore/Models/Thread.swift +++ b/MailCore/Models/Thread.swift @@ -18,7 +18,6 @@ import Foundation import InfomaniakDI -import MailResources import OrderedCollections import RealmSwift diff --git a/MailCore/Utils/NotificationsHelper.swift b/MailCore/Utils/NotificationsHelper.swift index 3e65f5e8fc..d603e0c13d 100644 --- a/MailCore/Utils/NotificationsHelper.swift +++ b/MailCore/Utils/NotificationsHelper.swift @@ -326,7 +326,6 @@ public enum NotificationsHelper { return } - liveMessage.subject = liveMessage.formattedSubject liveMessage.preview = String(preview.prefix(512)) } }