How to Implement a Message Filter Extension in SwiftUI
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
-
Out-of-process lifecycle.
When an SMS arrives, iOS launches your
MessageFilterExtensionXPC process independently of Messages.app and your host app. The extension receivesILMessageFilterQueryRequestcontainingsenderandmessageBody, calls your handler, then terminates — your code never runs inside Messages. -
Typed classification enum.
The private
Classificationenum keeps branching tidy: each case carries its associated sub-action value, so theswitchblock inhandle(_:context:completion:)maps directly toresponse.actionand the matching sub-action property with no stringly-typed branching. -
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. -
Network deferral for ambiguous messages.
Calling
context.deferQueryRequestToNetwork()instead of invokingcompletionpasses the decision to Apple's or the carrier's network service. Use it as a fallback for the.unknowncase — not as the primary path — to avoid unnecessary latency and preserve privacy. -
App Group shared container.
FilterRulesStorewrites toUserDefaults(suiteName: "group.com.yourapp.messagefilter"), a container that both the extension target and the host app can read. Users update their blocked sender list inFilterSettingsView, 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
-
Missing App Group entitlement on both targets.
The extension target and the host app target must each list the same App Group identifier under
Signing & Capabilities → App Groups, or
UserDefaults(suiteName:)returnsnilat runtime with no crash — just silent data loss. Double-check both .entitlements files after adding a new target. -
Memory budget is ≈ 6 MB — Core ML models will exceed it.
The extension runs in a tightly sandboxed XPC process. Loading even a small
MLModeltypically exceeds the limit, causing iOS to silently allow the message without calling your handler. Stick to in-memory keyword sets or lightweight trie structures compiled once at process startup. -
Sub-actions require iOS 16+, even inside an iOS 17+ deployment.
If your project contains a shared framework with a lower deployment target, the compiler may not
see sub-action properties. Annotate the extension target's minimum deployment with
@available(iOS 16, *)guards or set its minimum to iOS 16 to silence the warning cleanly.
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.