How to Implement a Message Filter Extension in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: Message Filter Extension, ILMessageFilterExtension, IdentityLookup Updated: May 12, 2026
TL;DR

Create a Message Filter Extension target in Xcode, subclass ILMessageFilterExtension, and implement handle(_:context:completion:) to classify incoming SMS as junk, promotion, or transaction — entirely offline, with no message content leaving the device.

import IdentityLookup

final class MessageFilterExtension: ILMessageFilterExtension {}

extension MessageFilterExtension: ILMessageFilterQueryHandling {
    func handle(
        _ request: ILMessageFilterQueryRequest,
        context: ILMessageFilterExtensionContext,
        completion: @escaping (ILMessageFilterQueryResponse) -> Void
    ) {
        let response = ILMessageFilterQueryResponse()
        let body = request.messageBody?.lowercased() ?? ""

        if body.contains("winner") || body.contains("you've won") {
            response.action = .junk
            response.junkSubAction = .junkScam
        } else if body.contains("% off") || body.contains("promo code") {
            response.action = .promotion
            response.promotionSubAction = .promotionOffer
        } else {
            response.action = .none
        }
        completion(response)
    }
}

Full implementation

The complete solution has three parts: the extension classifier that runs out-of-process for every incoming SMS, a lightweight shared store backed by an App Group UserDefaults container that the extension and host app both read, and a SwiftUI settings screen where users add custom blocked senders. iOS never lets the extension write to disk or reach the network during offline classification, so the design is privacy-safe by default.

// ─────────────────────────────────────────────────────────────────
// MARK: FilterRulesStore.swift
// Shared by BOTH the host app target AND the extension target.
// Add this file to both targets in Xcode (File Inspector → Target Membership).
// ─────────────────────────────────────────────────────────────────
import Foundation

final class FilterRulesStore {
    static let shared = FilterRulesStore()

    // Replace with your actual App Group identifier.
    private let defaults = UserDefaults(suiteName: "group.com.yourapp.messagefilter")!

    private init() {}

    var blockedSenders: Set<String> {
        get { Set(defaults.stringArray(forKey: "blockedSenders") ?? []) }
        set { defaults.set(Array(newValue), forKey: "blockedSenders") }
    }
}

// ─────────────────────────────────────────────────────────────────
// MARK: MessageFilterExtension.swift  (Extension target only)
// ─────────────────────────────────────────────────────────────────
import IdentityLookup

final class MessageFilterExtension: ILMessageFilterExtension {}

extension MessageFilterExtension: ILMessageFilterQueryHandling {

    func handle(
        _ queryRequest: ILMessageFilterQueryRequest,
        context: ILMessageFilterExtensionContext,
        completion: @escaping (ILMessageFilterQueryResponse) -> Void
    ) {
        let response = ILMessageFilterQueryResponse()

        guard let rawBody = queryRequest.messageBody else {
            response.action = .none
            completion(response)
            return
        }

        let body   = rawBody.lowercased()
        let sender = queryRequest.sender ?? ""

        switch classify(body: body, sender: sender) {
        case .junk(let sub):
            response.action = .junk
            response.junkSubAction = sub
        case .promotion(let sub):
            response.action = .promotion
            response.promotionSubAction = sub
        case .transaction(let sub):
            response.action = .transaction
            response.transactionSubAction = sub
        case .allow:
            response.action = .allow
        case .unknown:
            // Hand off to carrier / Apple network for uncertain messages.
            context.deferQueryRequestToNetwork()
            return
        }

        completion(response)
    }

    // MARK: - Classification

    private enum Classification {
        case junk(ILMessageFilterJunkAction)
        case promotion(ILMessageFilterPromotionAction)
        case transaction(ILMessageFilterTransactionAction)
        case allow
        case unknown
    }

    private func classify(body: String, sender: String) -> Classification {
        // 1. Check user-managed blocked sender list (App Group container).
        if FilterRulesStore.shared.blockedSenders.contains(sender) {
            return .junk(.junkMarketing)
        }

        // 2. Junk / scam signals.
        let junkTerms = ["winner", "you've won", "prize", "click here now",
                         "free gift", "claim your reward", "urgent action required"]
        if junkTerms.contains(where: body.contains) {
            let isScam = body.contains("bank") || body.contains("verify") || body.contains("account")
            return .junk(isScam ? .junkScam : .junkMarketing)
        }

        // 3. Promotional signals.
        let promoTerms = ["% off", "discount", "sale ends", "coupon", "promo code",
                          "shop now", "deal of the day", "limited offer"]
        if promoTerms.contains(where: body.contains) {
            let hasCoupon = body.contains("code") || body.contains("coupon")
            return .promotion(hasCoupon ? .promotionCoupon : .promotionOffer)
        }

        // 4. Transactional signals.
        let txTerms = ["order confirmed", "has shipped", "out for delivery", "delivered",
                       "payment received", "your receipt", "appointment reminder", "invoice"]
        if txTerms.contains(where: body.contains) {
            let isReminder = body.contains("reminder") || body.contains("appointment")
            return .transaction(isReminder ? .transactionReminder : .transactionOrder)
        }

        return .unknown
    }
}

// ─────────────────────────────────────────────────────────────────
// MARK: FilterSettingsView.swift  (Host app target only)
// ─────────────────────────────────────────────────────────────────
import SwiftUI

struct FilterSettingsView: View {
    @State private var blockedSenders: [String] = []
    @State private var newSender = ""
    @FocusState private var fieldFocused: Bool

    var body: some View {
        NavigationStack {
            List {
                Section("Blocked Senders") {
                    HStack(spacing: 10) {
                        TextField("Phone number or short code", text: $newSender)
                            .keyboardType(.phonePad)
                            .focused($fieldFocused)
                            .submitLabel(.done)
                            .onSubmit(addSender)
                            .accessibilityLabel("New blocked sender number")
                        Button("Add", action: addSender)
                            .buttonStyle(.borderedProminent)
                            .controlSize(.small)
                            .disabled(newSender.trimmingCharacters(in: .whitespaces).isEmpty)
                    }

                    if blockedSenders.isEmpty {
                        Text("No blocked senders yet.")
                            .foregroundStyle(.secondary)
                            .font(.footnote)
                    } else {
                        ForEach(blockedSenders, id: \.self) { sender in
                            Text(sender)
                                .accessibilityLabel("Blocked sender: \(sender)")
                        }
                        .onDelete(perform: removeSenders)
                    }
                }

                Section {
                    Label {
                        Text("Messages are classified on-device. Your message content never leaves your iPhone.")
                            .font(.footnote)
                    } icon: {
                        Image(systemName: "lock.shield.fill")
                            .foregroundStyle(.green)
                    }
                }
            }
            .navigationTitle("Message Filter")
            .navigationBarTitleDisplayMode(.large)
            .toolbar { EditButton() }
            .onAppear { reload() }
        }
    }

    // MARK: - Helpers

    private func reload() {
        blockedSenders = Array(FilterRulesStore.shared.blockedSenders).sorted()
    }

    private func addSender() {
        let trimmed = newSender.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else { return }
        FilterRulesStore.shared.blockedSenders.insert(trimmed)
        reload()
        newSender = ""
        fieldFocused = false
    }

    private func removeSenders(at offsets: IndexSet) {
        let toRemove = offsets.map { blockedSenders[$0] }
        toRemove.forEach { FilterRulesStore.shared.blockedSenders.remove($0) }
        blockedSenders.remove(atOffsets: offsets)
    }
}

#Preview {
    // Seed some sample data so the preview shows a populated list.
    FilterRulesStore.shared.blockedSenders = ["+18005550199", "SPAMCO", "+14155550123"]
    return FilterSettingsView()
}

How it works

  1. Out-of-process lifecycle. When an SMS arrives, iOS launches your MessageFilterExtension XPC process independently of Messages.app and your host app. The extension receives ILMessageFilterQueryRequest containing sender and messageBody, calls your handler, then terminates — your code never runs inside Messages.
  2. Typed classification enum. The private Classification enum keeps branching tidy: each case carries its associated sub-action value, so the switch block in handle(_:context:completion:) maps directly to response.action and the matching sub-action property with no stringly-typed branching.
  3. Sub-actions for granular inbox folders (iOS 16+). Setting response.junkSubAction = .junkScam (or .promotionCoupon, .transactionReminder, etc.) allows Messages on iOS 16+ to route the filtered SMS into a named sub-folder rather than a single undifferentiated Junk pile — much friendlier for users reviewing flagged messages.
  4. Network deferral for ambiguous messages. Calling context.deferQueryRequestToNetwork() instead of invoking completion passes the decision to Apple's or the carrier's network service. Use it as a fallback for the .unknown case — not as the primary path — to avoid unnecessary latency and preserve privacy.
  5. App Group shared container. FilterRulesStore writes to UserDefaults(suiteName: "group.com.yourapp.messagefilter"), a container that both the extension target and the host app can read. Users update their blocked sender list in FilterSettingsView, and the next extension invocation picks up the change instantly — no IPC or server round-trip required.

Variants

Custom server classification via network URL

Add ILMessageFilterSupportsNetworkURL to your extension's Info.plist, then conform to ILMessageFilterNetworkURLQueryHandling. iOS calls this method only when your offline handler triggers deferQueryRequestToNetwork(), giving you a chance to call your own API before the carrier's network makes the final call.

// Extension Info.plist — add inside the NSExtension dictionary:
// <key>ILMessageFilterSupportsNetworkURL</key>
// <string>https://api.yourapp.com/v1/filter</string>

import CryptoKit
import IdentityLookup

extension MessageFilterExtension: ILMessageFilterNetworkURLQueryHandling {

    func handle(
        _ queryRequest: ILMessageFilterQueryRequest,
        context: ILMessageFilterExtensionContext,
        networkURL: URL,
        completion: @escaping (ILMessageFilterQueryResponse) -> Void
    ) {
        // Hash the sender — never send raw phone numbers to your server.
        let senderHash = queryRequest.sender
            .map { SHA256.hash(data: Data($0.utf8)) }
            .map { $0.map { String(format: "%02x", $0) }.joined() }
            ?? ""

        var request = URLRequest(url: networkURL, timeoutInterval: 5)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: ["sh": senderHash])

        URLSession.shared.dataTask(with: request) { data, _, _ in
            let response = ILMessageFilterQueryResponse()
            if let data,
               let json = try? JSONSerialization.jsonObject(with: data) as? [String: String],
               json["action"] == "junk" {
                response.action = .junk
                response.junkSubAction = .junkOther
            } else {
                response.action = .none
            }
            completion(response)
        }.resume()
    }
}

Regex-based rules for power users

Store NSRegularExpression patterns serialised as strings in the App Group container instead of fixed keyword arrays. Compile each regex once at extension startup using a static property — regex compilation is expensive and the extension process may be reused across many messages. A lazy static var on FilterRulesStore that maps stored pattern strings to compiled NSRegularExpression objects keeps the hot-path allocation-free.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a message filter extension in SwiftUI for iOS 17+.
Use ILMessageFilterExtension and ILMessageFilterQueryHandling.
Classify incoming SMS as junk (.junkScam / .junkMarketing),
promotion (.promotionOffer / .promotionCoupon), or transaction
(.transactionOrder / .transactionReminder) using offline keyword sets.
Share blocked-sender rules via UserDefaults App Group container.
Make the settings view fully accessible (VoiceOver labels on all controls).
Add a #Preview with realistic pre-seeded sample data.

In Soarias, drop this prompt into the Build phase right after adding the extension target — Claude Code will wire up both .entitlements files, configure the App Group, and scaffold the classifier in a single pass, leaving you to tune the keyword lists for your specific use case.

Related

FAQ

Does this work on iOS 16?

Yes. The core ILMessageFilterExtension API has existed since iOS 11. The sub-action properties (junkSubAction, promotionSubAction, transactionSubAction) require iOS 16+, and the code in this guide targets iOS 17+, so everything compiles without availability guards. If you must support iOS 15, set only response.action and skip the sub-action lines inside an if #available(iOS 16, *) block.

Can the extension log or store the classified message body?

No — and intentionally so. The IdentityLookup framework's sandbox prevents the extension from writing to arbitrary file paths or opening network connections during the offline handle(_:context:completion:) call. Any data read from messageBody during classification stays in-process and is discarded when the XPC call ends. This design is what allows Apple to permit third-party message filtering at all.

What's the UIKit equivalent?

There is no UIKit equivalent — ILMessageFilterExtension is a pure app extension with zero UI of its own; it has no view controller lifecycle at all. The host app's settings screen (shown here as FilterSettingsView) can be built with either SwiftUI or a UITableViewController — the extension classification code is identical either way.

Last reviewed: 2026-05-12 by the Soarias team.