tidy-gmail 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tidy_gmail/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Local-first, data-driven Gmail cleanup tool."""
tidy_gmail/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
tidy_gmail/auth.py ADDED
@@ -0,0 +1,71 @@
1
+ """Gmail OAuth authorization and local credential management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ import tempfile
8
+ from typing import Any
9
+
10
+ SCOPES = ["https://www.googleapis.com/auth/gmail.modify"]
11
+
12
+
13
+ def connect(root: Path) -> Any:
14
+ """Authorize the user's own desktop OAuth client, stored locally."""
15
+ from google.auth.transport.requests import Request
16
+ from google.oauth2.credentials import Credentials
17
+ from google_auth_oauthlib.flow import InstalledAppFlow
18
+ from googleapiclient.discovery import build
19
+
20
+ secret = root / ".local" / "credentials.json"
21
+ token = root / ".local" / "token.json"
22
+
23
+ if not secret.is_file():
24
+ raise FileNotFoundError(
25
+ f"Missing {secret}. Create a Google Cloud Desktop OAuth "
26
+ "client with Gmail API enabled and save its JSON here."
27
+ )
28
+
29
+ credentials = None
30
+
31
+ if token.is_file():
32
+ credentials = Credentials.from_authorized_user_file(
33
+ str(token),
34
+ SCOPES,
35
+ )
36
+
37
+ if not credentials or not credentials.valid:
38
+ if credentials and credentials.expired and credentials.refresh_token:
39
+ credentials.refresh(Request())
40
+ else:
41
+ credentials = InstalledAppFlow.from_client_secrets_file(
42
+ str(secret),
43
+ SCOPES,
44
+ ).run_local_server(port=0)
45
+
46
+ # WHY: Create private temporary storage before writing token contents.
47
+ temporary: str | None = None
48
+ try:
49
+ with tempfile.NamedTemporaryFile(
50
+ mode="w",
51
+ dir=token.parent,
52
+ prefix=".token-",
53
+ suffix=".tmp",
54
+ encoding="utf-8",
55
+ delete=False,
56
+ ) as stream:
57
+ temporary = stream.name
58
+ stream.write(credentials.to_json())
59
+ stream.flush()
60
+ os.fsync(stream.fileno())
61
+ os.replace(temporary, token)
62
+ finally:
63
+ if temporary and os.path.exists(temporary):
64
+ os.unlink(temporary)
65
+
66
+ return build(
67
+ "gmail",
68
+ "v1",
69
+ credentials=credentials,
70
+ cache_discovery=False,
71
+ )
tidy_gmail/classify.py ADDED
@@ -0,0 +1,34 @@
1
+ """Classify inventory using current protection rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .inventory import read_inventory
8
+ from .rules import Settings, eligible
9
+
10
+
11
+ def select_candidates(
12
+ root: Path,
13
+ settings: Settings,
14
+ protected: set[str],
15
+ decisions: dict[str, str],
16
+ ) -> list[dict[str, str]]:
17
+ """Select messages using current user-maintained rules."""
18
+ rows = read_inventory(root)
19
+
20
+ ids = [row["id"] for row in rows]
21
+
22
+ if len(ids) != len(set(ids)):
23
+ raise ValueError("Duplicate Gmail message IDs in inventory; scan again")
24
+
25
+ return [
26
+ row
27
+ for row in rows
28
+ if eligible(
29
+ row,
30
+ settings,
31
+ protected,
32
+ decisions,
33
+ )
34
+ ]
tidy_gmail/cleanup.py ADDED
@@ -0,0 +1,172 @@
1
+ """Execute explicitly confirmed Gmail cleanup operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from . import gmail_client as gmail
9
+ from . import storage
10
+ from .classify import select_candidates
11
+ from .rules import Settings, eligible, gmail_query, protection_reason, sender_approval
12
+
13
+
14
+ def confirm(action: str, count: int) -> bool:
15
+ """Require an explicit confirmation for a modifying batch."""
16
+ required = f"{action} {count}"
17
+
18
+ print(f"Type {required!r} to authorize this batch.")
19
+
20
+ return input("> ").strip() == required
21
+
22
+
23
+ def trash(
24
+ root: Path,
25
+ settings: Settings,
26
+ protected: set[str],
27
+ decisions: dict[str, str],
28
+ *,
29
+ execute: bool,
30
+ limit: int,
31
+ ) -> None:
32
+ """Revalidate approved messages before moving them to Trash."""
33
+ if not 1 <= limit <= settings.batch_size:
34
+ raise ValueError(
35
+ f"--limit must be 1..{settings.batch_size} (config batch_size)"
36
+ )
37
+
38
+ all_candidates = select_candidates(
39
+ root,
40
+ settings,
41
+ protected,
42
+ decisions,
43
+ )
44
+
45
+ proposal = all_candidates[:limit]
46
+
47
+ print(f"Proposed Trash batch: {len(proposal)} of {len(all_candidates)} candidates")
48
+
49
+ if not proposal:
50
+ return
51
+
52
+ if settings.dry_run or not execute:
53
+ print("DRY RUN. For execution, set cleanup.dry_run=false and pass --execute.")
54
+ return
55
+
56
+ api = gmail.connect(root)
57
+
58
+ # Recheck Gmail's current category, age, Inbox,
59
+ # starred, important, and attachment search conditions.
60
+ current_ids = set(
61
+ gmail.list_ids(
62
+ api,
63
+ gmail_query(settings),
64
+ )
65
+ )
66
+
67
+ approved: list[dict[str, str]] = []
68
+
69
+ for stored in proposal:
70
+ if stored["id"] not in current_ids:
71
+ continue
72
+
73
+ current = checked_metadata(api, stored, settings, protected, decisions)
74
+ if current is not None:
75
+ approved.append(current)
76
+
77
+ print(f"Still eligible after live Gmail recheck: {len(approved)}")
78
+
79
+ if not approved or not confirm(
80
+ "TRASH",
81
+ len(approved),
82
+ ):
83
+ print("Cancelled. No messages changed.")
84
+ return
85
+
86
+ log = root / "output" / "cleanup_log.csv"
87
+ count = 0
88
+
89
+ for stored in approved:
90
+ # WHY: Attachment and from:me protections rely on current Gmail search.
91
+ try:
92
+ matches = any(
93
+ message_id == stored["id"]
94
+ for message_id in gmail.list_ids(api, gmail_query(settings))
95
+ )
96
+ except Exception as exc: # noqa: BLE001
97
+ print(f"Skipped {stored['id']}: search_unavailable ({type(exc).__name__})")
98
+ continue
99
+ if not matches:
100
+ print(f"Skipped {stored['id']}: current Gmail protections")
101
+ continue
102
+ # REQ: Recheck after confirmation, immediately before each mutation.
103
+ row = checked_metadata(api, stored, settings, protected, decisions)
104
+ if row is None:
105
+ continue
106
+ # Log the intention before issuing the Gmail operation.
107
+ storage.audit(
108
+ log,
109
+ "trash_requested",
110
+ row,
111
+ )
112
+
113
+ try:
114
+ gmail.trash(
115
+ api,
116
+ row["id"],
117
+ )
118
+
119
+ except Exception as exc:
120
+ storage.audit(
121
+ log,
122
+ "trash_failed",
123
+ row,
124
+ type(exc).__name__,
125
+ )
126
+ raise
127
+
128
+ storage.audit(
129
+ log,
130
+ "trashed",
131
+ row,
132
+ )
133
+
134
+ count += 1
135
+
136
+ print(f"Moved {count} individual messages to Gmail Trash. Audit: {log}")
137
+
138
+
139
+ def checked_metadata(
140
+ api: Any,
141
+ stored: dict[str, str],
142
+ settings: Settings,
143
+ protected: set[str],
144
+ decisions: dict[str, str],
145
+ ) -> dict[str, str] | None:
146
+ """Skip messages whose current metadata cannot authorize cleanup safely."""
147
+ message_id = stored["id"]
148
+ try:
149
+ current = gmail.metadata(api, message_id)
150
+ reason = protection_reason(current)
151
+ if not reason and current.get("sender", "").strip().casefold() in protected:
152
+ reason = "sender_protected"
153
+ if not reason:
154
+ approval = sender_approval(current.get("sender", ""), decisions)
155
+ if approval.startswith("conflict:"):
156
+ reason = approval
157
+ if not reason and (
158
+ current.get("id") != message_id
159
+ or current.get("sender") != stored.get("sender")
160
+ or current.get("subject") != stored.get("subject")
161
+ ):
162
+ reason = "metadata_changed"
163
+ if not reason and not eligible(current, settings, protected, decisions):
164
+ reason = "existing_protection_or_ineligible"
165
+ except Exception as exc: # noqa: BLE001
166
+ # WHY: API failures and malformed responses are never permission to delete.
167
+ print(f"Skipped {message_id}: metadata_unavailable ({type(exc).__name__})")
168
+ return None
169
+ if reason:
170
+ print(f"Skipped {message_id}: {reason}")
171
+ return None
172
+ return current
tidy_gmail/cli.py ADDED
@@ -0,0 +1,224 @@
1
+ """cli.py - command line interface.
2
+
3
+ uvx tidy-gmail init
4
+ uvx tidy-gmail doctor
5
+ uvx tidy-gmail scan
6
+ uvx tidy-gmail report
7
+ uvx tidy-gmail candidates
8
+ uvx tidy-gmail trash
9
+ uvx tidy-gmail restore
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ from pathlib import Path
16
+ import sqlite3
17
+ import sys
18
+
19
+ from . import gmail_client as gmail # noqa: F401
20
+ from . import storage
21
+ from .classify import select_candidates # noqa: F401
22
+ from .cleanup import confirm, trash # noqa: F401
23
+ from .inventory import read_inventory, scan # noqa: F401
24
+ from .reports import candidates, report
25
+ from .restore import restorable, restore # noqa: F401
26
+ from .rules import (
27
+ Settings,
28
+ load_settings,
29
+ sender_rules,
30
+ )
31
+
32
+ APP_NAME = "tidy-gmail"
33
+
34
+
35
+ def prepare(
36
+ root: Path,
37
+ ) -> tuple[Settings, set[str], dict[str, str]]:
38
+ """Validate the existing workspace without repairing it."""
39
+ storage.check_repos_case(root)
40
+
41
+ for name in ("data", "output", ".local"):
42
+ if not (root / name).is_dir():
43
+ raise FileNotFoundError(f"Missing {root / name}; run tidy-gmail init")
44
+
45
+ settings = load_settings(root / "data" / "config.toml")
46
+
47
+ protected, decisions = sender_rules(root / "data")
48
+
49
+ return settings, protected, decisions
50
+
51
+
52
+ def build_parser() -> argparse.ArgumentParser:
53
+ """Build the CLI parser and register every command."""
54
+ parser = argparse.ArgumentParser(
55
+ prog=APP_NAME,
56
+ description="Conservative Gmail cleanup",
57
+ )
58
+
59
+ parser.add_argument(
60
+ "--version",
61
+ action="version",
62
+ version="tidy-gmail 0.1.0",
63
+ )
64
+
65
+ parser.add_argument(
66
+ "--workspace", type=Path, help="Use an existing local workspace"
67
+ )
68
+
69
+ sub = parser.add_subparsers(
70
+ dest="command",
71
+ required=True,
72
+ )
73
+
74
+ init_parser = sub.add_parser(
75
+ "init",
76
+ help=("Create the local workspace without overwriting existing files"),
77
+ )
78
+
79
+ init_parser.add_argument(
80
+ "--yes",
81
+ action="store_true",
82
+ help="Create only missing files without a prompt",
83
+ )
84
+
85
+ sub.add_parser(
86
+ "doctor",
87
+ help="Validate the local configuration",
88
+ )
89
+
90
+ scan_parser = sub.add_parser(
91
+ "scan",
92
+ help="Fetch Gmail metadata and discover senders",
93
+ )
94
+
95
+ scan_parser.add_argument(
96
+ "--limit",
97
+ type=int,
98
+ default=5000,
99
+ help="Maximum additional unique messages this invocation (automatically resumes)",
100
+ )
101
+
102
+ scan_parser.add_argument(
103
+ "--restart",
104
+ action="store_true",
105
+ help="Archive previous results and begin a fresh scan",
106
+ )
107
+
108
+ sub.add_parser(
109
+ "report",
110
+ help="Show sender counts and candidate totals",
111
+ )
112
+
113
+ sub.add_parser(
114
+ "candidates",
115
+ help="Save candidate CSV without Gmail modifications",
116
+ )
117
+
118
+ for command in ("trash", "restore"):
119
+ command_parser = sub.add_parser(
120
+ command,
121
+ help=f"{command.title()} logged Gmail messages",
122
+ )
123
+
124
+ command_parser.add_argument(
125
+ "--limit",
126
+ type=int,
127
+ default=None,
128
+ )
129
+
130
+ command_parser.add_argument(
131
+ "--execute",
132
+ action="store_true",
133
+ help="Request actual mailbox modification",
134
+ )
135
+
136
+ return parser
137
+
138
+
139
+ def main(argv: list[str] | None = None) -> int:
140
+ """Execute the requested CLI command."""
141
+ args = build_parser().parse_args(argv)
142
+ root = (
143
+ args.workspace.expanduser().resolve() if args.workspace else storage.workspace()
144
+ )
145
+
146
+ try:
147
+ if args.command == "init":
148
+ storage.init(
149
+ root,
150
+ confirm=args.yes,
151
+ )
152
+ return 0
153
+
154
+ settings, protected, decisions = prepare(root)
155
+
156
+ if args.command == "doctor":
157
+ print(f"Workspace valid: {root}")
158
+ print("OAuth not tested; no Gmail connection made")
159
+
160
+ elif args.command == "scan":
161
+ scan(
162
+ root,
163
+ settings,
164
+ limit=args.limit,
165
+ restart=args.restart,
166
+ )
167
+
168
+ elif args.command == "report":
169
+ report(
170
+ root,
171
+ settings,
172
+ protected,
173
+ decisions,
174
+ )
175
+
176
+ elif args.command == "candidates":
177
+ candidates(
178
+ root,
179
+ settings,
180
+ protected,
181
+ decisions,
182
+ )
183
+
184
+ elif args.command == "trash":
185
+ trash(
186
+ root,
187
+ settings,
188
+ protected,
189
+ decisions,
190
+ execute=args.execute,
191
+ limit=settings.batch_size if args.limit is None else args.limit,
192
+ )
193
+
194
+ elif args.command == "restore":
195
+ restore(
196
+ root,
197
+ execute=args.execute and not settings.dry_run,
198
+ limit=settings.batch_size if args.limit is None else args.limit,
199
+ )
200
+
201
+ except KeyboardInterrupt:
202
+ print(
203
+ "Interrupted. Completed scan work is saved; run scan again to resume.",
204
+ file=sys.stderr,
205
+ )
206
+ return 130
207
+ except (
208
+ sqlite3.Error,
209
+ OSError,
210
+ ValueError,
211
+ KeyError,
212
+ EOFError,
213
+ ) as exc:
214
+ print(
215
+ f"Error: {exc}",
216
+ file=sys.stderr,
217
+ )
218
+ return 1
219
+
220
+ return 0
221
+
222
+
223
+ if __name__ == "__main__":
224
+ sys.exit(main())