Summary
delete-staged --permanent can physically delete a message from the local archive, including
its body, raw MIME, recipients, labels, attachment metadata, and reactions. Whether this happens
depends on which Gmail execution path completes, so the same user-visible operation has two
opposite local outcomes.
This contradicts the guarantee in docs/usage/deletion.md:
Deletion only removes messages from the remote mail server. Your local archive is never
modified. (deletion.md#L10)
Your local copy is always preserved regardless of what you delete remotely.
(deletion.md#L196)
All references below are pinned to main @ 7cf18c370176fa39f006bc964a7079f931c8b6a2.
The two paths
On a successful Gmail batch delete, the local row is preserved with a tombstone —
MarkMessagesDeletedByGmailIDBatch issues UPDATE messages SET deleted_from_source_at = …
(messages.go#L2363).
On the individual path, deleteOne calls
MarkMessageDeletedBySourceMessageID(sourceID, method == MethodDelete, gmailID)
(executor.go#L136),
whose permanent branch is a physical delete
(messages.go#L2342):
if permanent {
_, err := s.db.Exec(`DELETE FROM messages WHERE source_message_id = ?`+sourceClause, args...)
return err
}
Reachability (narrower than it first appears, but real)
Worth stating precisely, because the normal permanent path is not affected:
delete-staged (trash, the default) routes to Execute with MethodTrash
(deletions.go#L1088-L1089) →
permanent == false → tombstone. Safe.
delete-staged --permanent routes to ExecuteBatch
(deletions.go#L1092) →
on success, the batch tombstone. Safe.
- The destructive branch is reached only when the batch call ultimately fails and
ExecuteBatch falls back to per-message deleteOne(…, MethodDelete)
(executor.go#L553),
and on resumed runs that re-enter the same fallback.
gmail.Client.request retries 429 and 5xx with backoff (up to maxBackoff = 600s), so a single
transient blip will not trigger it — this needs sustained failure or exhausted retries. That makes
it uncommon, not unreachable, and the consequence is unrecoverable.
Impact
Foreign keys are enforced in the shipped configuration — _foreign_keys=ON in
defaultSQLiteParams
(store.go#L78) —
so the cascade is real, not hypothetical. Deleting a messages row removes:
message_bodies (ON DELETE CASCADE)
message_raw — the archived MIME, i.e. the irreplaceable copy
message_recipients, message_labels, attachments, reactions
Two follow-on effects:
- Orphaned FTS rows.
messages_fts has no delete trigger — the three triggers in
schema_sqlite.sql are for document_chunks_fts, not messages — and nothing in the store's
deletion path removes the FTS entry. Search can return rows whose messages row no longer exists.
- Orphaned attachment blobs. The CAS files on disk outlive the metadata rows that referenced
them, with nothing left pointing at them.
Second failure mode: the delete can fail and still report success
messages.reply_to_message_id references messages(id) with no ON DELETE clause
(schema.sql#L366),
so it defaults to NO ACTION. With foreign keys on, deleting a message that another archived
message replies to raises a constraint violation and the DELETE fails.
deleteOne logs that at Warn and returns resultSuccess regardless
(executor.go#L136-L139).
So the message is permanently gone from Gmail while the local row is neither deleted nor
tombstoned, and the manifest counts it as succeeded.
The two outcomes are mutually exclusive per message, and both are wrong: unreferenced messages get
destroyed, referenced ones get silently un-tracked. A threaded mailbox will produce a mix.
Expected behavior
After any successful remote operation — trash or permanent, batch or individual, first attempt or
retry — the archived message should be preserved and its local source state recorded consistently.
The remote method can stay permanent; the local action should always be a tombstone.
A failed local state write should not be silently counted as success. It should remain
retryable, or the manifest should record that the remote delete succeeded while local
reconciliation is still pending.
Proposed minimal fix
Make the permanent branch of MarkMessageDeletedBySourceMessageID perform the same
non-destructive UPDATE … SET deleted_from_source_at as its batch sibling, and drop the
DELETE. Regression tests worth pinning:
- successful individual permanent deletion preserves the row, body, raw MIME, and related records;
- batch-fallback produces the same local state as a successful batch;
- retrying previously failed permanent IDs produces the same local state;
- a failed local tombstone write is not recorded as unqualified success;
- a message referenced by another message's
reply_to_message_id survives permanent deletion.
Policy clarification requested
Separate from the fix, it would help to have the intended local semantics stated and pinned by
tests, because the code currently answers these differently in different places:
- Should remote trash and remote permanent deletion produce the same local state
(deleted_from_source_at), or should trash have a distinct state? Today trash sets the tombstone,
which makes "deleted from source" true for a message still recoverable in Gmail for 30 days.
- If the remote operation succeeds but the local write fails, what should the manifest record?
- Can the documented invariant be treated as binding — remote deletion operations must never
physically delete archived local message content — so future paths can be tested against it?
Note on scope
Two other findings surfaced while auditing this and are deliberately not part of this issue,
since they have different causes and risk profiles: full-sync handling of upstream deletions after
Gmail history expiration, and inconsistent default visibility of source-deleted messages between
the CLI search path and the aggregate paths. Happy to file either separately if useful. This issue
is intentionally limited to preventing destruction of local archive content.
Summary
delete-staged --permanentcan physically delete a message from the local archive, includingits body, raw MIME, recipients, labels, attachment metadata, and reactions. Whether this happens
depends on which Gmail execution path completes, so the same user-visible operation has two
opposite local outcomes.
This contradicts the guarantee in
docs/usage/deletion.md:All references below are pinned to
main@7cf18c370176fa39f006bc964a7079f931c8b6a2.The two paths
On a successful Gmail batch delete, the local row is preserved with a tombstone —
MarkMessagesDeletedByGmailIDBatchissuesUPDATE messages SET deleted_from_source_at = …(
messages.go#L2363).On the individual path,
deleteOnecallsMarkMessageDeletedBySourceMessageID(sourceID, method == MethodDelete, gmailID)(
executor.go#L136),whose
permanentbranch is a physical delete(
messages.go#L2342):Reachability (narrower than it first appears, but real)
Worth stating precisely, because the normal permanent path is not affected:
delete-staged(trash, the default) routes toExecutewithMethodTrash(
deletions.go#L1088-L1089) →permanent == false→ tombstone. Safe.delete-staged --permanentroutes toExecuteBatch(
deletions.go#L1092) →on success, the batch tombstone. Safe.
ExecuteBatchfalls back to per-messagedeleteOne(…, MethodDelete)(
executor.go#L553),and on resumed runs that re-enter the same fallback.
gmail.Client.requestretries 429 and 5xx with backoff (up tomaxBackoff = 600s), so a singletransient blip will not trigger it — this needs sustained failure or exhausted retries. That makes
it uncommon, not unreachable, and the consequence is unrecoverable.
Impact
Foreign keys are enforced in the shipped configuration —
_foreign_keys=ONindefaultSQLiteParams(
store.go#L78) —so the cascade is real, not hypothetical. Deleting a
messagesrow removes:message_bodies(ON DELETE CASCADE)message_raw— the archived MIME, i.e. the irreplaceable copymessage_recipients,message_labels,attachments,reactionsTwo follow-on effects:
messages_ftshas no delete trigger — the three triggers inschema_sqlite.sqlare fordocument_chunks_fts, not messages — and nothing in the store'sdeletion path removes the FTS entry. Search can return rows whose
messagesrow no longer exists.them, with nothing left pointing at them.
Second failure mode: the delete can fail and still report success
messages.reply_to_message_idreferencesmessages(id)with noON DELETEclause(
schema.sql#L366),so it defaults to
NO ACTION. With foreign keys on, deleting a message that another archivedmessage replies to raises a constraint violation and the
DELETEfails.deleteOnelogs that atWarnand returnsresultSuccessregardless(
executor.go#L136-L139).So the message is permanently gone from Gmail while the local row is neither deleted nor
tombstoned, and the manifest counts it as succeeded.
The two outcomes are mutually exclusive per message, and both are wrong: unreferenced messages get
destroyed, referenced ones get silently un-tracked. A threaded mailbox will produce a mix.
Expected behavior
After any successful remote operation — trash or permanent, batch or individual, first attempt or
retry — the archived message should be preserved and its local source state recorded consistently.
The remote method can stay permanent; the local action should always be a tombstone.
A failed local state write should not be silently counted as success. It should remain
retryable, or the manifest should record that the remote delete succeeded while local
reconciliation is still pending.
Proposed minimal fix
Make the
permanentbranch ofMarkMessageDeletedBySourceMessageIDperform the samenon-destructive
UPDATE … SET deleted_from_source_atas its batch sibling, and drop theDELETE. Regression tests worth pinning:reply_to_message_idsurvives permanent deletion.Policy clarification requested
Separate from the fix, it would help to have the intended local semantics stated and pinned by
tests, because the code currently answers these differently in different places:
(
deleted_from_source_at), or should trash have a distinct state? Today trash sets the tombstone,which makes "deleted from source" true for a message still recoverable in Gmail for 30 days.
physically delete archived local message content — so future paths can be tested against it?
Note on scope
Two other findings surfaced while auditing this and are deliberately not part of this issue,
since they have different causes and risk profiles: full-sync handling of upstream deletions after
Gmail history expiration, and inconsistent default visibility of source-deleted messages between
the CLI search path and the aggregate paths. Happy to file either separately if useful. This issue
is intentionally limited to preventing destruction of local archive content.