Skip to content

delete-staged --permanent can physically delete the local archived message, contradicting the documented preservation guarantee #675

Description

@turian

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:

  1. successful individual permanent deletion preserves the row, body, raw MIME, and related records;
  2. batch-fallback produces the same local state as a successful batch;
  3. retrying previously failed permanent IDs produces the same local state;
  4. a failed local tombstone write is not recorded as unqualified success;
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions