Recover the original text of an iMessage another user "unsent" on macOS — by reading the SQLite WAL before it gets checkpointed.
When someone unsends an iMessage on iOS 16+ / macOS 13+, Apple's client wipes the message body from ~/Library/Messages/chat.db on both ends within ~2 minutes. The text and attributedBody columns are nulled; message_summary_info keeps only metadata (length, retracted-part indices). Everything you'd want is gone from the canonical row.
But SQLite doesn't overwrite pages in place — it writes new page images to a write-ahead log (chat.db-wal) and only later checkpoints them into the main file. For a window of seconds-to-hours after the unsend, the pre-retract page image is still sitting in the WAL, with the original UTF-8 text inline. This repo's script extracts it.
| Platform | macOS 15 / Sequoia (verified on Darwin 24.x) |
| Languages | bash, python3 |
| Built-in deps | sqlite3, plutil |
| Optional deps | typedstream (pip), imessage-exporter (cargo) |
| Permission required | Full Disk Access for your terminal |
| License | MIT |
- macOS Sequoia gotcha
- How iMessage retraction actually works
- chat.db schema deep-dive
- The six recovery vectors
- Architecture diagrams
- Recovery workflow
- Why the WAL vector works (byte-level)
- Usage
- Run the daemon and menu bar app
- Working-app checklist
- Release-candidate smoke
- Daemon control socket
- Sanitized case study
- Modes — Recover vs Restore
- Limitations
- Prerequisites
- Privacy and legal
Warning
The message table in chat.db has a column called date_retracted that looks like the right place to detect unsends. It is not used on macOS Sequoia (Darwin 24.x). Apple records unsends via:
m.date_edited != 0 AND m.is_empty = 1
If your forensic tool filters on date_retracted != 0 (the obvious-but-wrong column), it will silently miss every retraction. The schema is misleading. Empirical truth is: unsend = "edited to empty".
When the sender taps "Undo Send" within the 2-minute window, the Messages client on every device that holds the conversation receives a retraction directive over APNS and applies an SQL UPDATE against the message row:
| Column | Before retraction | After retraction |
|---|---|---|
text |
the message string | NULL |
attributedBody |
typedstream NSAttributedString | 0 bytes / NULL |
is_empty |
0 | 1 |
date_edited |
0 | non-zero (Apple epoch) |
date_retracted |
0 | 0 (unused on Darwin 24) |
message_summary_info |
typically NULL |
small bplist (~100 B) |
The message_summary_info BLOB is a binary plist that records what was retracted, not what it said:
<dict>
<key>amc</key><integer>0</integer> <!-- associated message count -->
<key>otr</key> <!-- original text ranges -->
<dict>
<key>0</key> <!-- part index 0 -->
<dict>
<key>le</key><integer>95</integer> <!-- original length: 95 chars -->
<key>lo</key><integer>0</integer> <!-- original offset: 0 -->
</dict>
</dict>
<key>rp</key><array><integer>0</integer></array> <!-- retracted parts: [0] -->
<key>ust</key><true/> <!-- user-sent text marker -->
</dict>So the metadata tells you "a 95-character user-sent text was retracted in full" — but not what those 95 characters were. The bytes are scrubbed from the row.
Note
For edited messages (Apple's other "edit message" feature), this same plist gains an ec (edit chronology) key with prior versions — including their typedstream blobs. Edits preserve history; retractions do not.
Relevant columns of message on Darwin 24.6 (click to expand)
| Column | Type | Purpose |
|---|---|---|
ROWID |
INTEGER | Primary key |
guid |
TEXT | Stable message GUID (UUID format) |
text |
TEXT | Plain message text (often NULL when attributedBody populated) |
attributedBody |
BLOB | NSKeyedArchiver typedstream of NSAttributedString |
service |
TEXT | iMessage / SMS / RCS |
account |
TEXT | Account that received the message |
handle_id |
INTEGER | FK → handle.ROWID (the other party for inbound) |
date |
INTEGER | Send time. Apple-epoch nanoseconds (since 2001-01-01 UTC) |
date_read |
INTEGER | Read receipt time, same epoch. 0 is common even when is_read = 1 — see Vector 8 |
date_delivered |
INTEGER | Delivery time, same epoch. Same flag/timestamp split as date_read |
is_read |
INTEGER | Read flag. Independent of date_read; syncs via Messages in iCloud without the timestamp |
date_edited |
INTEGER | Non-zero on edits AND retractions (Apple-epoch ns) |
date_retracted |
INTEGER | Schema column reserved for retractions; unused on Darwin 24 |
is_empty |
INTEGER | 1 after retraction (also 1 for some empty system messages) |
is_from_me |
INTEGER | 1 if sent by the local user, 0 if received |
is_delivered |
INTEGER | Delivery flag |
message_summary_info |
BLOB | Binary plist with edit/retraction metadata |
payload_data |
BLOB | App-specific (sticker / link preview / business chat) |
associated_message_guid |
TEXT | For tapbacks/replies, points at the parent |
Other tables you'll need:
handle (ROWID, id, country, service, ...)—handle.idis the phone number (E.164) or Apple ID email.chat (ROWID, guid, chat_identifier, display_name, service_name, ...)—display_nameisNULLfor 1:1 chats and only set for named groups. Look up bychat_identifieror viachat_handle_join.chat_handle_join (chat_id, handle_id)— many-to-many.chat_message_join (chat_id, message_id, message_date)— many-to-many.
date / 1000000000 + 978307200 converts the integer to Unix epoch seconds. 978307200 is the Unix timestamp of 2001-01-01 00:00:00 UTC. SQLite expression:
datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime')Note
Older versions of macOS (< 10.13) stored date as Apple-epoch seconds not nanoseconds — irrelevant for current builds, but watch for it on archived databases.
On 1:1 conversations chat.display_name is NULL. The contact name shown in Messages.app is resolved on the fly from macOS Contacts (Contacts.app / AddressBook framework), not stored in chat.db. Search by handle:
SELECT c.ROWID
FROM chat c
JOIN chat_handle_join chj ON chj.chat_id = c.ROWID
JOIN handle h ON h.ROWID = chj.handle_id
WHERE h.id = '+15551234567' AND c.chat_identifier = '+15551234567'
ORDER BY c.ROWID LIMIT 1;Run them in order. Stop at the first hit. For the per-vector technical reference (exact code paths, files written, failure modes, byte-level subtleties), see docs/recovery-vectors.md.
The chat.db family is hot. Any new Messages activity rewrites the WAL; auto-checkpoint compacts older frames into the main file and discards them. Before doing anything else:
osascript -e 'quit app "Messages"'
# A snapshot of chat.db is a copy of every message — keep it owner-only.
WORK="$(mktemp -d "${TMPDIR:-/tmp}/imessage-recovery.XXXXXX")" # 0700, unpredictable
cp ~/Library/Messages/chat.db "$WORK/" && chmod 600 "$WORK/chat.db"
cp ~/Library/Messages/chat.db-wal "$WORK/" && chmod 600 "$WORK/chat.db-wal"
cp ~/Library/Messages/chat.db-shm "$WORK/" && chmod 600 "$WORK/chat.db-shm"(recover.sh does exactly this automatically — a private mktemp work dir it removes on exit, or a --work DIR you pass to keep the artifacts.)
All subsequent reads target the snapshot. Do not reopen Messages.app until you've extracted what you need.
The unsent message will have is_from_me = 0, date_edited != 0, is_empty = 1 for inbound retractions:
SELECT m.ROWID, m.guid,
datetime(m.date/1000000000 + 978307200,'unixepoch','localtime') AS sent_at,
datetime(m.date_edited/1000000000 + 978307200,'unixepoch','localtime') AS edited_at,
length(m.attributedBody) AS ab_len,
length(m.message_summary_info) AS msi_len
FROM message m
JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
WHERE cmj.chat_id = :chat_rowid
AND m.is_from_me = 0
AND m.date_edited != 0
AND m.is_empty = 1
ORDER BY m.date DESC LIMIT 10;Pick the row whose edited_at aligns with when you saw the "X unsent a message" placeholder.
Confirms the retraction and tells you the original length:
sqlite3 -readonly chat.db \
"SELECT writefile('msi.bin', message_summary_info) FROM message WHERE ROWID=:id;"
plutil -convert xml1 -o msi.xml msi.bin
plutil -p msi.bin # human-readableLook for rp (retracted parts), otr.0.le (original character length), ust = true (was user-sent text). For pure retractions, the original text bytes are not here — only metadata.
The attributedBody BLOB is not a plist. It's an old-school typedstream (NSKeyedArchiver in pre-plist format) containing an NSAttributedString. Decode in Python:
import typedstream # pip install typedstream
with open('ab.bin', 'rb') as f:
obj = typedstream.unarchive_from_data(f.read())
# obj is an NSAttributedString graph; walk for NSStringFor fully retracted messages this BLOB is NULL or 0 bytes. Confirm and move on.
This is where the original text usually lives. See Why the WAL vector works below for the byte-level explanation.
Quick check — does the candidate's GUID appear in the WAL?
GUID=$(sqlite3 -readonly chat.db "SELECT guid FROM message WHERE ROWID=:id;")
grep -aob "$GUID" chat.db-wal # lists offsetsIf you get hits, extract the bytes immediately following each occurrence (see Python snippet in the next section).
ReagentX/imessage-exporter parses attributedBody and message_summary_info and surfaces edits/unsends. It respects the same scrubbing Apple does — for full retractions it reports the message as Unsent without recovering text. Useful as a sanity check; don't expect new content from it.
imessage-exporter -f txt -o ./export -p ./chat.db -c full
grep -RIn "$GUID" export/If Vectors 0–5 fail and you genuinely need the text (click to expand)
- Time Machine —
tmutil listbackups. Mount an older backup and pull achat.dbfrom before the unsend. Most reliable when configured. - APFS local snapshots —
tmutil listlocalsnapshots /. Mount and copy. - iPhone backup —
~/Library/Application Support/MobileSync/Backup/<UUID>/3d/3d0d7e5fb2ce288813306e4d4636395e047a3d28is the iPhone'ssms.db. If the phone synced after the message arrived but before the unsend propagated, it's intact there. Encrypted backups must be decrypted outside this tool first. - iMazing / iExplorer / 3uTools — third-party iPhone backup managers store at their own paths.
- iCloud Backup — recoverable only via full restore, generally not worth it for one message.
A separate forensic vector for the related-but-distinct case where the sender edited a message instead of unsending it. Edits leave the row populated (is_empty = 0) so recover.sh skips them, but every prior version is preserved on the row in message_summary_info.ec as a typedstream-encoded NSAttributedString with per-edit timestamps.
python3 scripts/edit-history.py --since 7d --json
python3 scripts/edit-history.py --handle '+1XXXXXXXXXX' --rowid 12345Unlike the unsent-recovery flow, this vector doesn't race against WAL checkpointing — the chronology lives on the row itself. See docs/recovery-vectors.md § Vector 7 for the full technical reference.
Messages.app shows one boolean: "Read". chat.db stores a flag and a timestamp independently, and they disagree constantly — which is why "it says Read, but when?" has no answer in the UI. There are three states, not two:
| State | Predicate | Meaning |
|---|---|---|
timestamped |
date_read != 0 |
The exact receipt time is on this Mac. |
flagged_only |
is_read = 1 AND date_read = 0 |
Messages shows "Read" — the time was never written here. |
none |
is_read = 0 AND date_read = 0 |
No receipt: unread, or read receipts are off. |
flagged_only is the state the UI can't express, and it's common — on a real 412k-row chat.db it was 40% of read-flagged outgoing messages. It's the signature of a status that arrived via Messages in iCloud from another device: the boolean syncs, the receipt time doesn't.
python3 scripts/read-receipts.py --rowid 412318 # one message, in detail
python3 scripts/read-receipts.py --handle '+1XXXXXXXXXX' --since 30d
python3 scripts/read-receipts.py --audit --since 90d --json # per-thread receipt healthDirection matters: on your own messages date_read is their receipt; on theirs it's when you read it. --audit adds send→read latency (p50/p90) and a trend inference over recent un-timestamped outgoing messages — deliberately conservative, since a thread with receipts disabled and a thread nobody opened look identical in chat.db. Message bodies are withheld unless you pass --with-text.
--handle matches through chat membership, not just message.handle_id: Messages leaves that column unset on ~45% of outgoing rows, so filtering on it alone would drop most of what you're asking about.
See docs/recovery-vectors.md § Vector 8 for the failure modes — SMS carries no receipts at all, RCS is carrier-dependent, and group chats store one date_read per message rather than one per participant.
Recovery is best-effort and inherently racy. SQLite WAL is a rolling buffer, not an audit log: once iMessage commits a write and SQLite checkpoints the WAL into chat.db, the original page image is gone. The recovery vectors above each work some fraction of the time, and the daemon improves the odds, but none of them is a guarantee.
The cases this tool will reliably miss:
-
Long messages and slow unsends. The longer the gap between sending a message and unsending it, the higher the chance an intervening WAL checkpoint occurs. Composing a long message takes longer; SQLite's
wal_autocheckpoint(default ≈ 4 MB) can fire during that window. By the time you tap "Undo Send" the original page may already be inchat.db— and the unsend itself overwrites it. A short "Test" message unsent in 5 seconds is the easy case; a paragraph unsent after a minute often isn't. -
Recovery started after iMessage restarts. Messages composed in the previous session are not in the live WAL anymore — they're in
chat.db, mid-unsend, with the original text already gone. External backups (Vector 6) are your only path. -
Empty
attributedBodyafter retraction. iMessage clears bothtextandattributedBodyon retract. If the WAL no longer contains the pre-retract page, the attributedBody vector fails too. -
Group chats where another member retracts. The retract event may not flush new attributedBody / text bytes to the WAL on your device at all if the message was already considered delivered; the row update is small.
-
Attachments. This tool recovers text only. Image / file unsends are out of scope.
-
Anything that was unsent before you installed the daemon. The daemon starts watching from 5 minutes before its first launch and does not import history. This is deliberate: the WAL pages behind older retractions have almost always been checkpointed away, so a backfill is overwhelmingly likely to recover nothing while costing a full
chat.dbclone per event. On one real 412k-message database an unbounded first run produced 243 archives in 111 seconds, each cloning a ~900 MBchat.db, and recovered 0 of 243 (#160). Five minutes is a policy bound rather than a hard limit — the live WAL can still hold older frames — which is why the window is configurable.The fact that those older retractions happened is not lost — the rows are still in
chat.dband queryable at any time. Their text is very unlikely to still be reachable, but that is a matter of odds rather than a guarantee, which is why the window is adjustable rather than fixed. If you want a one-off backfill anyway, setmonitoring_grace_secondsin~/.config/imessage-unsent/config.tomlto something larger than the age of your database (it must be a top-level key, above any[section]header).0means "from this moment on"; the default is300.
Important
The setting is only consulted when the daemon starts from a fresh state — a first launch, or the first start after ~/.config/imessage-unsent/state.json is removed or quarantined. Config and state are both read once at startup, so editing either under a running daemon does nothing (and the running process can rewrite state.json from memory afterwards). The sequence that actually works:
launchctl bootout gui/$(id -u)/com.imu.watcher
# edit ~/.config/imessage-unsent/config.toml (top-level monitoring_grace_seconds)
rm ~/.config/imessage-unsent/state.json
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.imu.watcher.plistRe-reading the config on every launch is deliberate rather than on every change: silently re-scanning history because a value moved is how #160 produced 243 archives nobody asked for.
The daemon maintains a rolling snapshot buffer at ~/Library/Application Support/imessage-unsent/wal-history/ — every change to chat.db-wal is copied into the buffer (capped at 30 snapshots / 5 minutes by default). The buffer is copied into each archive before recover.sh runs, and the history scan executes whether or not the live WAL still has content — both were broken until #169. This lets recovery scan WAL frames the live file no longer contains, which is the mitigation for the dominant slow-unsend failure mode. It still will not help if the daemon wasn't running, lacked Full Disk Access, or wasn't installed when the message was written.
The buffer is implemented in daemon/Sources/IMUCore/WALSnapshotter.swift and merged into the recovery flow by scripts/lib/wal_merge_candidates.py. Full deep-dive in docs/recovery-vectors.md § Vector 4 § The WAL rolling snapshot buffer, including what the buffer can and can't fix and operational notes on its disk cost.
If recovery comes back empty, your fastest manual fallback is Vector 6 → APFS local snapshots:
tmutil listlocalsnapshots /
sudo mount_apfs -o nobrowse,rdonly -s com.apple.TimeMachine.<snap>.local / /tmp/imu-snap
ls /tmp/imu-snap/Users/<you>/Library/Messages/chat.db # an older copyThis diagram mirrors scripts/recover.sh: snapshot the live Messages database family, resolve the target handle and candidate row, dump the retraction metadata, scan chat.db-wal for GUID-adjacent pre-retract text, then cross-check length and exporter output when available.
SQLite in WAL journaling mode (the default for chat.db) writes new transactions to chat.db-wal instead of mutating the main file in place. The WAL has this structure:
+-------------------+
| WAL header (32 B) |
+-------------------+
| frame 1 header | 24 bytes: page_number, db_size_after, salts, checksum
+-------------------+
| frame 1 page | page_size bytes (default 4096) — full snapshot of the page
+-------------------+
| frame 2 header |
+-------------------+
| frame 2 page |
+-------------------+
...
Every transaction's modified pages are appended as full page images. Reads that follow walk the WAL backwards and use the most-recent frame for each page; the older frames stay in the file until SQLite checkpoints the WAL into the main .db (default trigger: ~1000 pages, ~4 MB at 4 KB page size, configurable via wal_autocheckpoint).
When a message is retracted, two transactions hit the same page:
- INSERT of the message row when it arrived. Page image written to WAL with
text = '...'andattributedBody = <typedstream>. - UPDATE moments later that nulls
text, zeroesattributedBody, setsis_empty=1, populatesmessage_summary_info. New page image written to WAL.
If the WAL hasn't checkpointed in between, both versions of that page coexist in the WAL right now. The latest is what SQLite reads via the API; the older one is forensic gold.
SQLite stores rows as records on B-tree pages. The message row layout in serialized form is roughly:
[record header: column types] [column values, in declared order]
Column types are encoded as varints; for our purposes the relevant column values are:
guid— 36-byte ASCII string (TEXT serial type =2*N + 13where N = 36)text— variable-length UTF-8 (TEXT serial type =2*N + 13)attributedBody— BLOB starting with\x04\x0bstreamtyped(typedstream magic)
So immediately after the GUID's 36 ASCII bytes, the text column's bytes are inline UTF-8. The attributedBody BLOB begins at \x04\x0bstreamtyped. Therefore:
# data[guid_offset+36 : guid_offset+36+text_length] → the original text bytes
# (text_length is recoverable from the record header, but it's easier to scan
# forward to the streamtyped magic which immediately follows)The recovery script does exactly this. For each occurrence of the GUID in the WAL:
after = data[guid_offset + 36 : guid_offset + 36 + WINDOW] # WINDOW default 8192
end = after.find(b'\x04\x0bstreamtyped')
text = after[:end].lstrip(b'\x00\x01\x02\x03').decode('utf-8')The scan window (wal_extract.py --window, default 8192 bytes) is bounded but generous enough for any message stored inline in a single SQLite leaf cell; the otr.0.le cross-check trims any over-capture back to the true length. A message whose text runs past the window logs a truncation warning rather than being dropped silently.
Multiple GUID hits = multiple page-image versions. Frames where text is empty correspond to the post-retraction state; frames where text contains real bytes are pre-retraction.
Tip
Length cross-check. The recovered string's len(text) should match otr.0.le from the message_summary_info plist. If they match exactly, you have the right bytes.
git clone git@github.com:tyhallcsu/imessage-unsent.git
cd imessage-unsent
# (one-time) grant your terminal Full Disk Access:
# System Settings → Privacy & Security → Full Disk Access → +
# Run against the contact's phone (E.164) or Apple ID email
./scripts/recover.sh --handle '+15551234567'
# or
./scripts/recover.sh --handle 'someone@icloud.com'
# Machine-readable output for automation
./scripts/recover.sh --handle '+15551234567' --json
# Also check an iPhone backup if WAL recovery misses
./scripts/recover.sh --handle '+15551234567' --include-iphone-backup --json
./scripts/recover.sh --handle '+15551234567' --include-iphone-backup /path/to/backup --json
# Batch scan all handles with recent inbound retractions
./scripts/recover.sh --all-handles --since 24h --json
# Batch scan a curated list, one handle per line
./scripts/recover.sh --handles-file handles.txt --since 7d --json
# Preview batch scope without quitting Messages or snapshotting chat.db
./scripts/recover.sh --handles-file handles.txt --dry-run
# Read the report (pass --work DIR to keep artifacts at a known path)
./scripts/recover.sh --handle '+15551234567' --work ./imu-out
cat ./imu-out/report.txt
# Optional: install the typedstream decoder for richer Vector 3 output
pip3 install --user typedstream
# Optional: install the cross-check tool for Vector 5
cargo install imessage-exporterBy default the script writes everything under a private, unpredictable mktemp -d work dir (owner-only, removed on exit). Pass --work /path to write to a directory you keep — it's hardened to 0700 but never auto-deleted:
| File | Content |
|---|---|
report.txt |
Full vector-by-vector log |
chat.db, chat.db-wal, chat.db-shm |
Forensic snapshot |
candidates.tsv |
Top 10 unsent candidates in the chat |
batch-handles.txt / batch-results.tsv |
Batch-mode scan inputs/results |
batch-state.tsv |
Batch-mode 60 second rate-limit state |
msi.bin / msi.xml |
The retraction metadata plist |
ab.bin |
The (usually empty) attributedBody BLOB |
wal-hits.txt |
Recovered text candidates from the WAL |
wal-candidates.json |
WAL candidates used to build --json output |
iphone-backup.json |
Optional iPhone backup vector result |
export/ (if installed) |
imessage-exporter output |
For third-party iPhone backup locations, create ~/.config/imessage-unsent/iphone-backup-paths.txt with one backup directory or direct sms.db path per line. --include-iphone-backup auto-discovery scans both MobileSync backups and those configured paths.
The CLI above is a one-shot recovery tool. The continuously-running watcher daemon + menu-bar app give you the same recovery automatically the moment a sender unsends a message — without you having to run anything by hand.
# Build + install the user LaunchAgent (writes to ~/Library/LaunchAgents and
# bootstraps it under your gui session). Idempotent — safe to re-run.
make daemon-install
# Build + launch the menu-bar app (no Dock icon, just a menu-bar status item).
make gui-runNow grant Full Disk Access to the installed daemon binary (not the build-tree binary) so it can read ~/Library/Messages/chat.db:
System Settings → Privacy & Security → Full Disk Access → +
~/Library/Application Support/imessage-unsent/bin/imu-watcher
The menu-bar icon flips from xmark.octagon (Daemon Down) to checkmark.circle (Watching) within ~2 seconds of the daemon being able to start. From there:
- "Open History" → window listing recovered messages, "Open archive" reveals the archive folder in Finder.
- "Open Settings" → daemon status, version, uptime, recovery count, last error, data dir.
- Native macOS notifications are posted by the menu bar app (not the daemon — a non-bundled CLI cannot use
UNUserNotificationCenter). Enable them via Settings → Notifications → "Enable notifications".
Where things live after make daemon-install:
| Path | Purpose |
|---|---|
~/Library/LaunchAgents/com.imu.watcher.plist |
LaunchAgent definition |
~/Library/Application Support/imessage-unsent/bin/imu-watcher |
Installed daemon binary |
~/Library/Application Support/imessage-unsent/scripts/ |
Copy of scripts/ used by recovery |
~/Library/Application Support/imessage-unsent/archives/ |
One subdirectory per recovery (mode 0700) |
~/Library/Application Support/imessage-unsent/daemon.sock |
Control socket (mode 0600; archive delete/compact only) |
~/Library/Logs/imessage-unsent/watcher.log |
Daemon log — handles redacted, rotated at 4 MB |
~/Library/Logs/imessage-unsent/watcher.err.log |
launchd stdout/stderr (crash output only) |
~/.config/imessage-unsent/config.toml |
Optional config (defaults are fine) |
Note
make daemon-uninstall removes the LaunchAgent and binary but intentionally
leaves archives/ (recovered plaintext), state.json, and logs behind so
forensic output survives an uninstall. Delete
~/Library/Application Support/imessage-unsent/ yourself to remove all data.
Tail the daemon log:
tail -f ~/Library/Logs/imessage-unsent/watcher.logNote
Correspondent handles are redacted to a salted fingerprint (handle=h:3f9a…) before
anything is written (#174).
The fingerprint is stable, so lines about the same person still correlate, and the
salt lives at ~/Library/Application Support/imessage-unsent/log-salt (0600) — an
unsalted hash of a phone number is a ~10^10 search space and would reverse in seconds.
rowid and guid are kept in the clear; they are what ties a line to its archive.
Logs written before this shipped are not redacted retroactively — rewriting them
in place would be a false promise about data that has already been backed up. Delete
watcher.log if the old contents matter to you.
Uninstall (launchctl bootout + remove plist + remove installed binary/scripts):
make daemon-uninstallOther useful targets:
make daemon-build # swift build daemon -c release (no install)
make gui-build # swift build gui -c release (no app bundle)
make swift-test # daemon + gui swift test in one shot
make icon # regenerate AppIcon.icns from assets/MacOS_AppIcon_iMessage_Unsent.pngThe macOS app icon source used by the build lives at assets/MacOS_AppIcon_iMessage_Unsent.png (1254×1254 PNG, RGBA with transparent corners outside the squircle). Vector design exports also live under assets/new-svg/, but the app bundle path still converts the PNG to AppIcon.icns (10 standard sizes, 16²–1024²) via scripts/build-app-icon.sh using sips + iconutil. The generated .icns is gitignored under gui/.build/icon/ and regenerated on every release / dev build — both scripts/build-release.sh (the iMessage Unsent.app produced by make release / make rc-smoke) and script/build_and_run.sh (make gui-run) invoke the generator and stage the icon into iMessage Unsent.app/Contents/Resources/. The bundle declares it via CFBundleIconFile = AppIcon in gui/Info.plist.
If you replace the source PNG: it MUST be RGBA with alpha=0 outside the rounded-square artwork. An RGB-only PNG with a baked-in black background will render as a black square in Finder/Get Info, because sips and iconutil faithfully preserve whatever's in the source. Verify with sips -g hasAlpha assets/MacOS_AppIcon_iMessage_Unsent.png (must report yes).
After a fresh build, Finder may briefly show the cached old icon — touch "dist/iMessage Unsent.app" (or killall Finder) clears it.
A six-step sanity loop for taking imessage-unsent from "fresh clone" to "trusted local install" — and verifying it before cutting a release:
make daemon-install— builds the daemon in release mode and bootstraps the LaunchAgent.- Grant Full Disk Access to the installed daemon binary at
~/Library/Application Support/imessage-unsent/bin/imu-watcher(System Settings → Privacy & Security → Full Disk Access). make gui-run— launches the menu-bar app.- Click Health Check… in the menu — every row should be
passorinfo. Fix anyfail/warnrows using the remediation text shown. - If the GUI itself can't launch (Gatekeeper, FDA prompt loop), run
make doctorfor the same diagnostic in your terminal. - Before cutting a release, run
make rc-smoketo prove the build/package/diagnose chain works end-to-end (see Release-candidate smoke below).
make rc-smoke runs a non-destructive local smoke that builds the daemon tarball + GUI .app zip, generates release notes, runs app_doctor.sh, and validates artifact integrity (sha256 sidecars, expected files inside the tarball/zip):
make rc-smoke # uses VERSION=v0.0.0-smoke and a temp dir
make rc-smoke VERSION=v0.4.0-rc1 # real RC string, still a temp dir
make rc-smoke VERSION=v0.4.0-rc1 OUTPUT_DIR=dist # keep artifacts in dist/It prints a [PASS]/[FAIL]/[SKIP] summary table at the end and exits nonzero on any failure. It deliberately does not install the LaunchAgent, request Full Disk Access, modify ~/Library/Messages, require Apple signing secrets, or publish a GitHub release. Useful overrides: IMU_RC_SKIP_SWIFT=1, IMU_RC_SKIP_SHELLCHECK=1, IMU_RC_KEEP_DIST=1.
When the watcher daemon is running it serves a Unix-domain socket at ~/Library/Application Support/imessage-unsent/daemon.sock (mode 0600, same-user only) with a hard op allowlist: ping/status/recent are read-only; delete and compact mutate archive directories only — no socket op can ever touch the live chat.db or daemon config (see SECURITY.md). The menu-bar app uses it to render status and the recovery history; you can also probe it directly:
# liveness check
echo '{"op":"ping"}' | nc -U ~/Library/Application\ Support/imessage-unsent/daemon.sock
# {"ok":true,"pong":true}
# daemon status
echo '{"op":"status"}' | nc -U ~/Library/Application\ Support/imessage-unsent/daemon.sock
# {"ok":true,"status":{"data_dir":"...","last_error":null,"last_wal_change_at":"...",
# "last_wal_size":4096,"notifications_show":true,"recovery_count":3,"started_at":"...",
# "state":"watching","uptime_seconds":42,"version":"0.6.2"}}
# most recent recoveries (limit 1..50, default 5)
echo '{"op":"recent","limit":3}' | nc -U ~/Library/Application\ Support/imessage-unsent/daemon.sock
# {"ok":true,"recoveries":[{"archive_path":"...","detected_at":"...","error":null,
# "handle":"+15551234567","id":"2026-04-30T120000Z-101","recovered":true,"rowid":101,
# "text":"the recovered message text"}, ...]}Wire format: newline-delimited JSON, one request → one response → server closes the connection. Legacy plaintext ping is still accepted for one release.
The dispatcher is a hard allowlist of ping, status, recent, delete, and compact. The two read-only ops (ping/status/recent) surface daemon state; delete removes a single recovery archive directory and compact drops the bulky chat.db family from one archive while keeping the recovered text + manifest. Both delete and compact mutate files under ~/Library/Application Support/imessage-unsent/archives/ — they never touch the live chat.db. The archive id is validated against an anchored regex (^\d{4}-\d{2}-\d{2}T\d{6}Z-\d+$) so it cannot traverse out of the archives directory. Any op outside the allowlist returns:
{"ok":false,"error":{"code":"read_only","message":"op X is not permitted: ..."}}The live-chat.db half of the Notify-only invariant still holds absolutely — no socket op (or any other code path) writes to ~/Library/Messages/chat.db. See How the guardrail is enforced for the file-layer detail.
A live recovery on macOS 24.6 (April 2026):
- Pre-flight.
chat.db869 MB,chat.db-wal3.3 MB (~812 frames at 4 KB pages). No Time Machine configured. WAL not yet auto-checkpointed. - Vector 1 (locate). Searching
chat.display_namefor the contact's name returned nothing (1:1 chat, NULL display_name). Pivoted to handle lookup; found the candidate row.is_empty=1,textempty,attributedBody0 bytes,message_summary_info101 bytes,date_edited30 seconds afterdate. - Vector 2 (msi). Decoded plist:
{ amc=0, otr={ 0={le=95, lo=0} }, rp=[0], ust=true }. Confirmed: a 95-character user-sent text in part 0 was retracted in full. Original bytes not preserved here. - Vector 3 (attributedBody). Empty. Skipped.
- Vector 4 (WAL). The candidate's GUID appeared at 20 byte offsets in
chat.db-wal. At 2 of those offsets the bytes immediately following the GUID decoded as a 95-character UTF-8 string (containing\xe2\x80\x99for the typographic apostrophes iOS auto-correct produces), terminating exactly at the\x04\x0bstreamtypedtypedstream marker. Length matchedotr.0.le=95exactly. Recovered. - Vector 5 (imessage-exporter). Confirmed the message as "Unsent" but did not surface text — as expected.
- Vector 6 (backups). Not needed.
Total time from realizing the message was unsent → recovered text: ~25 minutes (most of which was investigating before the script was written).
Important
Every release since v0.2 ships Recover (Notify-only) and only Recover. Restore mode is a research track that requires explicit opt-in and a per-invocation consent flow. The CLI and daemon never modify live chat.db in any released code path.
| Recover (Notify-only) | Restore (experimental, not yet shipped) | |
|---|---|---|
| Status | ✅ Shipped (since v0.2) | 🚧 Tracked by #16; not implemented yet |
Live chat.db writes |
Never. Read-only via sqlite3 -readonly and SQLITE_OPEN_READONLY. |
Permitted only after consent flow + explicit opt-in. |
| Apple Messages UI | Still shows "X unsent a message" | Would patch the row so the original text reappears |
| iCloud sync risk | None (no writes) | High — write may resync to other devices and overwrite |
| User opt-in | Default | experimental.restore_mode = true and consent dialog |
| Output | macOS notification + signed webhook + recovery archive | Same, plus modified chat.db |
| Tests guarding it | tests/bats/60-guardrail-no-chatdb-writes.bats (sha256 before/after); RestoreModeGuardTests (Swift) |
TBD with #16 |
Restoring text into chat.db is interesting in theory and dangerous in practice:
- iCloud Messages can sync the modified row back to other devices, including the sender's. That changes someone else's local data without their consent.
- Messages.app can crash or corrupt its index if rows mutate while the app is running. The recovery happens precisely when retractions arrive — i.e., when Messages is most likely to be running.
- Forensic value erodes the moment we mutate. A row whose
textwas nulled by Apple, then re-populated by us, is no longer evidence of either Apple's retraction or the original message — it's a hybrid that no court-admissible report can rely on.
For the v0.2 ship, these costs outweigh the convenience benefit. The detector observes, the archiver preserves, and the menu bar app surfaces. The user reads what was unsent. Apple's UI continues to truthfully say "X unsent a message." Nothing changes on the sender's device.
Two layers, both checked in CI:
- CLI /
recover.sh— every SQLite call uses-readonly; the snapshot step copies into a separate--workdirectory and never writes back. Bats test60-guardrail-no-chatdb-writes.batssha256s the live fixture before and after a full recovery run and asserts equality. - Daemon /
IMUCore—RetractionDetectoropenschat.dbwithSQLITE_OPEN_READONLY | SQLITE_OPEN_URIandmode=ro(seeRetractionDetector.swift:93);ArchivePipelineonlycopyItem(at: source, to: destination)(seeArchivePipeline.swift). Any future write path must callRestoreModeGuard.requireRestoreMode, which throws unlessexperimental.restore_mode = true.
If you find a code path that bypasses either layer, that's a bug — please file an issue.
- WAL volatility. SQLite auto-checkpoints when the WAL crosses ~1000 pages by default. Once a frame is checkpointed and overwritten by subsequent frames, it's gone. Recovery only works if you act before that. Quitting Messages.app before snapshotting is critical because Messages and its agents (
imagent,messagesAgent) hold the database open and can trigger checkpoints. - Speed matters. Every passing minute of normal Messages activity rewrites WAL frames. Aim to snapshot within minutes of the unsend, not hours.
- No remote recovery. This works on the device's local
chat.db. Messages already removed from iCloud and not synced back to this Mac are not recoverable here. - Edited != unsent. The
message_summary_info.ec(edit chronology) field preserves prior text for edits. For full unsends this script's WAL technique is the only path on local data. - Full Disk Access required. Without it, your terminal can't read
~/Library/Messages/chat.db. macOS will silently return permission errors that look like "file not found". - Not a forensics product. This is a tactical script for personal use. It does not produce a chain-of-custody artifact, does not hash inputs, does not generate a court-admissible report. If you need that, use a real forensic suite.
| Tool | Required? | Install |
|---|---|---|
| Full Disk Access | yes | System Settings → Privacy & Security → + |
sqlite3 |
yes | macOS built-in (/usr/bin/sqlite3) |
plutil |
yes | macOS built-in (/usr/bin/plutil) |
python3 |
yes | macOS built-in or brew install python |
typedstream (pip) |
optional | pip3 install --user typedstream |
imessage-exporter |
optional | cargo install imessage-exporter |
Verified on macOS 15 (Sequoia, Darwin 24.x). The retraction feature itself shipped in macOS 13 / iOS 16, so the schema-level technique is expected to apply from macOS 13+ — but column semantics (specifically the date_retracted vs date_edited distinction documented above) have only been confirmed on Darwin 24.x in this codebase. It has not been re-verified on macOS 26 / Darwin 25 or later, where the retraction predicate is assumed-but-unproven. Validate the date_edited != 0 AND is_empty = 1 predicate against a known unsend before relying on this on any macOS version other than Sequoia / Darwin 24.x.
This tool is intended for your own device and your own messages. Unauthorized access to another person's chat.db may violate the Computer Fraud and Abuse Act, the Electronic Communications Privacy Act, state wiretap laws, and equivalent statutes elsewhere. See SECURITY.md for the full scope statement.
The recovered text itself may also be subject to legal protections (e.g., if it constitutes content of an electronic communication where you were not the intended recipient). Use judgment.
MIT © 2026 sharmanhall