import Foundation import UserNotifications /// Turns the relay's content-blind push ("New message" plus ids) into a real /// notification: sender, subject and preview, fetched over JMAP on this /// device with the account's own credentials. Nothing about the message ever /// passes through the relay or Apple. /// /// What the app shares through the keychain group (src/lib/ios-push-shared.ts): /// bulwark_push_account__ {credentialsKey, apiUrl} /// bulwark_push_settings {badge, activeAccountId, strings{...}} /// the account's stored credentials /// If anything is missing or the server can't be reached, the relay's /// placeholder text is shown, so the user still learns there is new mail. final class NotificationService: UNNotificationServiceExtension { private var contentHandler: ((UNNotificationContent) -> Void)? private var placeholder: UNMutableNotificationContent? private var work: Task? override func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { self.contentHandler = contentHandler guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { contentHandler(request.content) return } placeholder = content let push = request.content.userInfo["body"] as? [String: Any] work = Task { [weak self] in let enriched = await NotificationService.enrich(content, push: push) self?.finish(enriched) } } override func serviceExtensionTimeWillExpire() { work?.cancel() finish(placeholder) } private func finish(_ content: UNNotificationContent?) { guard let handler = contentHandler, let content else { return } contentHandler = nil handler(content) } static func sanitize(_ id: String) -> String { String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" }) } static func enrich(_ content: UNMutableNotificationContent, push: [String: Any]?) async -> UNMutableNotificationContent { guard let push, let accountId = push["accountId"] as? String, !accountId.isEmpty, let account = SharedKeychain.readJSON("bulwark_push_account__\(sanitize(accountId))"), let credentialsKey = account["credentialsKey"] as? String, let apiString = account["apiUrl"] as? String, let apiUrl = URL(string: apiString) else { return content } let emailIds = push["emailIds"] as? [String] ?? [] let settings = SharedKeychain.readJSON("bulwark_push_settings") ?? [:] let strings = settings["strings"] as? [String: String] ?? [:] content.threadIdentifier = accountId // The app reads userInfo.body when the notification is tapped. var body = push do { let authorization = try await Jmap.authorization(credentialsKey: credentialsKey) let result = try await Jmap.fetch(apiUrl: apiUrl, accountId: accountId, emailIds: emailIds, authorization: authorization) if let first = result.emails.first { content.title = first.sender content.subtitle = first.subject.isEmpty ? (strings["noSubject"] ?? "(no subject)") : first.subject content.body = first.preview if result.emails.count > 1 { let more = (strings["more"] ?? "and {count} more").replacingOccurrences(of: "{count}", with: String(result.emails.count - 1)) content.body = first.preview.isEmpty ? more : "\(first.preview)\n\(more)" } body["emailId"] = first.id body["threadId"] = first.threadId } // Badge: like the app, the active account's inbox unread count. A push // for another account leaves the badge as it is. if settings["badge"] as? Bool == true, settings["activeAccountId"] as? String == accountId, let unread = result.inboxUnread { content.badge = NSNumber(value: unread) } } catch { // Keep the placeholder. } var userInfo = content.userInfo userInfo["body"] = body content.userInfo = userInfo return content } }