footprinter-cli 1.0.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.
Files changed (134) hide show
  1. footprinter/__init__.py +8 -0
  2. footprinter/access.py +444 -0
  3. footprinter/api/__init__.py +1 -0
  4. footprinter/api/db.py +61 -0
  5. footprinter/api/entities.py +250 -0
  6. footprinter/api/search.py +47 -0
  7. footprinter/api/semantic.py +33 -0
  8. footprinter/api/server.py +66 -0
  9. footprinter/api/status.py +15 -0
  10. footprinter/bundled/__init__.py +0 -0
  11. footprinter/bundled/config.example.yaml +161 -0
  12. footprinter/bundled/patterns/context_patterns.yaml +18 -0
  13. footprinter/bundled/patterns/extensions.yaml +283 -0
  14. footprinter/bundled/patterns/filename_patterns.yaml +61 -0
  15. footprinter/bundled/patterns/mime_mappings.yaml +68 -0
  16. footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
  17. footprinter/bundled/patterns/security_patterns.yaml +27 -0
  18. footprinter/cli/__init__.py +128 -0
  19. footprinter/cli/__main__.py +6 -0
  20. footprinter/cli/_common.py +332 -0
  21. footprinter/cli/_policy_helpers.py +646 -0
  22. footprinter/cli/_prompt.py +220 -0
  23. footprinter/cli/api_cmd.py +32 -0
  24. footprinter/cli/connect.py +591 -0
  25. footprinter/cli/data.py +879 -0
  26. footprinter/cli/delete.py +128 -0
  27. footprinter/cli/ingest.py +579 -0
  28. footprinter/cli/mcp_cmd.py +750 -0
  29. footprinter/cli/mcp_setup.py +306 -0
  30. footprinter/cli/search.py +393 -0
  31. footprinter/cli/search_cmd.py +69 -0
  32. footprinter/cli/setup.py +1836 -0
  33. footprinter/cli/status.py +729 -0
  34. footprinter/cli/status_cmd.py +104 -0
  35. footprinter/cli/upsert.py +794 -0
  36. footprinter/cli/vectorize_cmd.py +215 -0
  37. footprinter/cli/view.py +322 -0
  38. footprinter/connectors/__init__.py +171 -0
  39. footprinter/connectors/config_utils.py +141 -0
  40. footprinter/db/__init__.py +37 -0
  41. footprinter/db/browser.py +198 -0
  42. footprinter/db/chats.py +610 -0
  43. footprinter/db/clients.py +307 -0
  44. footprinter/db/emails.py +279 -0
  45. footprinter/db/files.py +741 -0
  46. footprinter/db/folders.py +659 -0
  47. footprinter/db/messages.py +192 -0
  48. footprinter/db/policies.py +151 -0
  49. footprinter/db/projects.py +673 -0
  50. footprinter/db/search.py +573 -0
  51. footprinter/db/sql_utils.py +168 -0
  52. footprinter/db/status.py +320 -0
  53. footprinter/db/uploads.py +70 -0
  54. footprinter/ingest/__init__.py +0 -0
  55. footprinter/ingest/adapters/__init__.py +33 -0
  56. footprinter/ingest/adapters/browser.py +54 -0
  57. footprinter/ingest/adapters/chat.py +57 -0
  58. footprinter/ingest/adapters/ingest.py +146 -0
  59. footprinter/ingest/adapters/local_files.py +68 -0
  60. footprinter/ingest/adapters/local_folders.py +52 -0
  61. footprinter/ingest/adapters/protocol.py +174 -0
  62. footprinter/ingest/browser_indexer.py +216 -0
  63. footprinter/ingest/chat_dedup.py +156 -0
  64. footprinter/ingest/chat_indexer.py +515 -0
  65. footprinter/ingest/chat_parsers/__init__.py +8 -0
  66. footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
  67. footprinter/ingest/chat_parsers/claude_parser.py +161 -0
  68. footprinter/ingest/cli.py +827 -0
  69. footprinter/ingest/content_extractors.py +117 -0
  70. footprinter/ingest/database.py +36 -0
  71. footprinter/ingest/db/__init__.py +1 -0
  72. footprinter/ingest/db/connector_schema.py +47 -0
  73. footprinter/ingest/db/migration.py +328 -0
  74. footprinter/ingest/db/schema.py +1043 -0
  75. footprinter/ingest/db/security.py +6 -0
  76. footprinter/ingest/file_indexer.py +261 -0
  77. footprinter/ingest/file_scanner.py +277 -0
  78. footprinter/ingest/folder_indexer.py +226 -0
  79. footprinter/ingest/full_content_extractor.py +321 -0
  80. footprinter/ingest/orchestrator.py +125 -0
  81. footprinter/ingest/pipe_runner.py +217 -0
  82. footprinter/ingest/processing.py +165 -0
  83. footprinter/ingest/registry.py +201 -0
  84. footprinter/ingest/run_record.py +91 -0
  85. footprinter/ingest/status.py +346 -0
  86. footprinter/mcp/__init__.py +0 -0
  87. footprinter/mcp/__main__.py +5 -0
  88. footprinter/mcp/db.py +57 -0
  89. footprinter/mcp/errors.py +102 -0
  90. footprinter/mcp/extraction.py +226 -0
  91. footprinter/mcp/server.py +39 -0
  92. footprinter/mcp/tools/__init__.py +0 -0
  93. footprinter/mcp/tools/navigation.py +70 -0
  94. footprinter/mcp/tools/read.py +75 -0
  95. footprinter/mcp/tools/search.py +158 -0
  96. footprinter/mcp/tools/semantic.py +79 -0
  97. footprinter/mcp/tools/status.py +15 -0
  98. footprinter/paths.py +91 -0
  99. footprinter/permissions.py +1160 -0
  100. footprinter/semantic/__init__.py +13 -0
  101. footprinter/semantic/chunking.py +52 -0
  102. footprinter/semantic/embeddings.py +23 -0
  103. footprinter/semantic/hybrid_search.py +273 -0
  104. footprinter/semantic/vector_store.py +471 -0
  105. footprinter/services/__init__.py +49 -0
  106. footprinter/services/access_service.py +342 -0
  107. footprinter/services/chat_service.py +85 -0
  108. footprinter/services/client_service.py +267 -0
  109. footprinter/services/content_service.py +181 -0
  110. footprinter/services/email_service.py +89 -0
  111. footprinter/services/file_service.py +83 -0
  112. footprinter/services/folder_service.py +122 -0
  113. footprinter/services/includes.py +19 -0
  114. footprinter/services/ingest_service.py +231 -0
  115. footprinter/services/project_service.py +262 -0
  116. footprinter/services/roles.py +25 -0
  117. footprinter/services/search_service.py +177 -0
  118. footprinter/services/semantic_service.py +360 -0
  119. footprinter/services/status_service.py +18 -0
  120. footprinter/services/visit_service.py +65 -0
  121. footprinter/source_registry.py +194 -0
  122. footprinter/utils/__init__.py +7 -0
  123. footprinter/utils/hash_utils.py +59 -0
  124. footprinter/utils/logging_config.py +68 -0
  125. footprinter/utils/mime.py +30 -0
  126. footprinter/utils/text.py +6 -0
  127. footprinter/utils/time.py +11 -0
  128. footprinter/visibility.py +1272 -0
  129. footprinter_cli-1.0.0.dist-info/LICENSE +21 -0
  130. footprinter_cli-1.0.0.dist-info/METADATA +229 -0
  131. footprinter_cli-1.0.0.dist-info/RECORD +134 -0
  132. footprinter_cli-1.0.0.dist-info/WHEEL +5 -0
  133. footprinter_cli-1.0.0.dist-info/entry_points.txt +2 -0
  134. footprinter_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,307 @@
1
+ """Client queries.
2
+
3
+ Provides list, detail, create, and update functions for clients.
4
+ """
5
+
6
+ import json
7
+ import sqlite3
8
+ from typing import Optional
9
+
10
+ from footprinter.db.sql_utils import build_status_filter, paginate, paginated_response
11
+ from footprinter.utils.text import _make_slug
12
+
13
+ VALID_CLIENT_TYPES = {"external", "internal", "personal"}
14
+ VALID_STATUSES = frozenset({"active", "hidden", "removed"})
15
+
16
+
17
+ def list_clients(
18
+ conn: sqlite3.Connection, *, status: Optional[str | list[str]] = None, limit: int = 50, page: int = 1
19
+ ) -> dict:
20
+ """Return clients with project and file counts.
21
+
22
+ Parameters
23
+ ----------
24
+ conn : sqlite3.Connection
25
+ status : str, list[str], or None
26
+ ``None`` → active only (default).
27
+ ``"all"`` → no status filter.
28
+ Single string → exact match.
29
+ List of strings → ``WHERE status IN (...)``.
30
+ limit : int
31
+ Maximum rows per page (default 50).
32
+ page : int
33
+ 1-based page number (default 1).
34
+
35
+ Returns
36
+ -------
37
+ dict
38
+ ``{"clients": [...], "pagination": {page, limit, total, total_pages}}``
39
+ """
40
+ conditions: list[str] = []
41
+ params: list = []
42
+
43
+ status_conds, status_params = build_status_filter(
44
+ status,
45
+ column="client.status",
46
+ default_include=["active"],
47
+ )
48
+ conditions.extend(status_conds)
49
+ params.extend(status_params)
50
+
51
+ where = " WHERE " + " AND ".join(conditions) if conditions else ""
52
+
53
+ count_sql = f"SELECT COUNT(*) FROM clients client{where}"
54
+ fetch_sql = f"""
55
+ SELECT client.id, client.name, client.slug, client.client_type, client.status,
56
+ client.mcp_view, client.mcp_read, client.path_pattern,
57
+ (SELECT COUNT(*) FROM projects project WHERE project.client_id = client.id) as project_count,
58
+ (SELECT COUNT(*) FROM files file
59
+ JOIN projects project ON file.project_id = project.id
60
+ WHERE project.client_id = client.id AND file.status != 'removed') as file_count
61
+ FROM clients client
62
+ {where}
63
+ ORDER BY client.name
64
+ LIMIT ? OFFSET ?
65
+ """
66
+ rows, pagination = paginate(conn, count_sql, fetch_sql, params, page=page, limit=limit)
67
+
68
+ clients = [
69
+ {
70
+ "id": row["id"],
71
+ "name": row["name"],
72
+ "slug": row["slug"],
73
+ "client_type": row["client_type"],
74
+ "status": row["status"],
75
+ "project_count": row["project_count"],
76
+ "file_count": row["file_count"],
77
+ "mcp_view": row["mcp_view"] or "inherit",
78
+ "mcp_read": row["mcp_read"] or "inherit",
79
+ "path_pattern": row["path_pattern"] or "",
80
+ }
81
+ for row in rows
82
+ ]
83
+
84
+ return paginated_response("clients", clients, pagination)
85
+
86
+
87
+ def update_client(conn: sqlite3.Connection, client_id: int, **fields) -> Optional[bool]:
88
+ """Update a client's fields.
89
+
90
+ Returns True on success, None if client not found.
91
+ Raises ValueError on invalid input.
92
+ """
93
+ cursor = conn.cursor()
94
+ cursor.execute("SELECT id, name FROM clients WHERE id = ?", (client_id,))
95
+ if not cursor.fetchone():
96
+ return None
97
+
98
+ updatable = {"name", "client_type", "path_pattern", "status", "status_reason", "metadata"}
99
+ sql_fields = []
100
+ values = []
101
+ new_name = None
102
+
103
+ for key in updatable:
104
+ if key in fields:
105
+ val = fields[key]
106
+ if key == "metadata" and val is not None:
107
+ val = json.dumps(val)
108
+ if key == "client_type" and val not in VALID_CLIENT_TYPES:
109
+ valid = ", ".join(sorted(VALID_CLIENT_TYPES))
110
+ raise ValueError(f"Invalid client_type. Must be one of: {valid}")
111
+ if key == "name":
112
+ new_name = (val or "").strip()
113
+ if not new_name:
114
+ raise ValueError("Name cannot be empty")
115
+ val = new_name
116
+ sql_fields.append(f"{key} = ?")
117
+ values.append(val)
118
+
119
+ if not sql_fields:
120
+ return True
121
+
122
+ if new_name:
123
+ new_slug = _make_slug(new_name)
124
+ sql_fields.append("slug = ?")
125
+ values.append(new_slug)
126
+
127
+ values.append(client_id)
128
+ try:
129
+ cursor.execute(
130
+ f"UPDATE clients SET {', '.join(sql_fields)} WHERE id = ?",
131
+ values,
132
+ )
133
+ except sqlite3.IntegrityError:
134
+ raise ValueError("A client with that name or slug already exists")
135
+
136
+ if new_name:
137
+ cursor.execute(
138
+ "UPDATE projects SET client = ? WHERE client_id = ?",
139
+ (new_name, client_id),
140
+ )
141
+
142
+ conn.commit()
143
+ return True
144
+
145
+
146
+ def create_client(conn: sqlite3.Connection, *, name: str, client_type: str, path_pattern: Optional[str] = None) -> dict:
147
+ """Create a new client.
148
+
149
+ Returns dict with ``id`` and ``slug``.
150
+ Raises ValueError on invalid input or duplicate.
151
+ """
152
+ name = (name or "").strip()
153
+ if not name:
154
+ raise ValueError("Name is required")
155
+ if client_type not in VALID_CLIENT_TYPES:
156
+ valid = ", ".join(sorted(VALID_CLIENT_TYPES))
157
+ raise ValueError(f"Invalid client_type. Must be one of: {valid}")
158
+
159
+ slug = _make_slug(name)
160
+ cursor = conn.cursor()
161
+ try:
162
+ cursor.execute(
163
+ """INSERT INTO clients (name, slug, client_type, path_pattern, status)
164
+ VALUES (?, ?, ?, ?, 'active')""",
165
+ (name, slug, client_type, path_pattern),
166
+ )
167
+ conn.commit()
168
+ except sqlite3.IntegrityError:
169
+ raise ValueError(f"A client with name '{name}' or slug '{slug}' already exists")
170
+ return {"id": cursor.lastrowid, "slug": slug}
171
+
172
+
173
+ def get_client(conn: sqlite3.Connection, client_id: int) -> Optional[dict]:
174
+ """Fetch a single client with its projects and file count.
175
+
176
+ Returns a dict with client fields, ``projects`` list, and
177
+ ``file_count``, or ``None`` if not found.
178
+ """
179
+ cursor = conn.cursor()
180
+ cursor.execute(
181
+ """SELECT id, name, slug, client_type, status, path_pattern,
182
+ mcp_view, mcp_read
183
+ FROM clients WHERE id = ?""",
184
+ (client_id,),
185
+ )
186
+ row = cursor.fetchone()
187
+ if not row:
188
+ return None
189
+
190
+ client = {
191
+ "id": row["id"],
192
+ "name": row["name"],
193
+ "slug": row["slug"],
194
+ "client_type": row["client_type"],
195
+ "status": row["status"],
196
+ "path_pattern": row["path_pattern"] or "",
197
+ "mcp_view": row["mcp_view"] or "inherit",
198
+ "mcp_read": row["mcp_read"] or "inherit",
199
+ }
200
+
201
+ # Attached projects
202
+ cursor.execute(
203
+ """SELECT id, project_name, project_type, status
204
+ FROM projects WHERE client_id = ? ORDER BY project_name""",
205
+ (client_id,),
206
+ )
207
+ client["projects"] = [
208
+ {
209
+ "id": r["id"],
210
+ "project_name": r["project_name"],
211
+ "project_type": r["project_type"],
212
+ "status": r["status"],
213
+ }
214
+ for r in cursor.fetchall()
215
+ ]
216
+
217
+ # File count across all projects for this client
218
+ cursor.execute(
219
+ """SELECT COUNT(*) as cnt FROM files file
220
+ JOIN projects project ON file.project_id = project.id
221
+ WHERE project.client_id = ? AND file.status != 'removed'""",
222
+ (client_id,),
223
+ )
224
+ client["file_count"] = cursor.fetchone()["cnt"]
225
+
226
+ return client
227
+
228
+
229
+ def find_by_name_fuzzy(conn: sqlite3.Connection, name: str) -> list[dict]:
230
+ """Find clients matching name with LIKE %name%.
231
+
232
+ Returns all columns including mcp_view. Does NOT filter by visibility.
233
+ """
234
+ rows = conn.execute(
235
+ """SELECT id, name, slug, client_type, path_pattern, status,
236
+ created_at, mcp_view, mcp_read
237
+ FROM clients WHERE name LIKE ?""",
238
+ (f"%{name}%",),
239
+ ).fetchall()
240
+ return [dict(r) for r in rows]
241
+
242
+
243
+ def count_hidden_by_name(conn: sqlite3.Connection, name: str) -> int:
244
+ """Count hidden clients matching a fuzzy name query (for diagnostics)."""
245
+ row = conn.execute(
246
+ "SELECT COUNT(*) FROM clients WHERE name LIKE ? AND COALESCE(mcp_view, 'inherit') = 'hidden'",
247
+ (f"%{name}%",),
248
+ ).fetchone()
249
+ return row[0]
250
+
251
+
252
+ def get_client_navigation(conn: sqlite3.Connection, client_id: int, project_ids: list[int]) -> dict:
253
+ """Return navigation aggregates for an MCP client view."""
254
+ if not project_ids:
255
+ return {
256
+ "total_files": 0,
257
+ "total_size_bytes": 0,
258
+ "total_folders": 0,
259
+ "total_entities": {"emails": 0, "chats": 0, "visits": 0},
260
+ }
261
+
262
+ ph = ",".join("?" * len(project_ids))
263
+ _nh = "AND COALESCE(mcp_view, 'inherit') != 'hidden'"
264
+
265
+ stats = conn.execute(
266
+ f"""SELECT COUNT(*) as count, COALESCE(SUM(size_bytes), 0) as size
267
+ FROM files
268
+ WHERE project_id IN ({ph}) AND status != 'removed' {_nh}""",
269
+ project_ids,
270
+ ).fetchone()
271
+
272
+ folder_count = conn.execute(
273
+ f"SELECT COUNT(*) FROM folders WHERE project_id IN ({ph}) {_nh}",
274
+ project_ids,
275
+ ).fetchone()[0]
276
+
277
+ email_count = conn.execute(
278
+ f"SELECT COUNT(*) FROM emails WHERE project_id IN ({ph}) AND status != 'removed' {_nh}",
279
+ project_ids,
280
+ ).fetchone()[0]
281
+ chat_count = conn.execute(
282
+ f"SELECT COUNT(*) FROM chats WHERE project_id IN ({ph}) AND status != 'removed' {_nh}",
283
+ project_ids,
284
+ ).fetchone()[0]
285
+ browser_count = conn.execute(
286
+ f"SELECT COUNT(*) FROM visits WHERE project_id IN ({ph}) AND status != 'removed' {_nh}",
287
+ project_ids,
288
+ ).fetchone()[0]
289
+
290
+ return {
291
+ "total_files": stats["count"],
292
+ "total_size_bytes": stats["size"],
293
+ "total_folders": folder_count,
294
+ "total_entities": {
295
+ "emails": email_count,
296
+ "chats": chat_count,
297
+ "visits": browser_count,
298
+ },
299
+ }
300
+
301
+
302
+ def find_client_id_by_name(conn: sqlite3.Connection, name: str) -> Optional[int]:
303
+ """Return the client ID for the given name, or None if not found."""
304
+ cursor = conn.cursor()
305
+ cursor.execute("SELECT id FROM clients WHERE name = ?", (name,))
306
+ row = cursor.fetchone()
307
+ return row["id"] if row else None
@@ -0,0 +1,279 @@
1
+ """Email queries, write operations — list, detail, and insert.
2
+
3
+ Query and write layer for email data.
4
+ """
5
+
6
+ import json
7
+ import sqlite3
8
+ from typing import Any, Dict, Optional
9
+
10
+ from footprinter.db.sql_utils import paginate, paginated_response
11
+
12
+ SORT_WHITELIST = {"subject", "from_address", "account", "received_at", "has_attachments"}
13
+
14
+
15
+ def list_emails(
16
+ conn: sqlite3.Connection,
17
+ *,
18
+ page: int = 1,
19
+ limit: int = 50,
20
+ sort_by: str = "received_at",
21
+ order: str = "desc",
22
+ account: Optional[str] = None,
23
+ client_id: Optional[int] = None,
24
+ project_id: Optional[int] = None,
25
+ query: Optional[str] = None,
26
+ has_attachments: Optional[bool] = None,
27
+ ) -> dict:
28
+ """List emails with pagination, filtering, and sorting.
29
+
30
+ Returns dict with keys: emails, pagination.
31
+ """
32
+ sort_col = sort_by if sort_by in SORT_WHITELIST else "received_at"
33
+ sort_col_sql = f"email.{sort_col}"
34
+ order_sql = "ASC" if order.lower() == "asc" else "DESC"
35
+
36
+ # Build dynamic WHERE clause
37
+ conditions: list[str] = ["email.status != 'removed'"]
38
+ params: list = []
39
+
40
+ if account:
41
+ acct_list = [a.strip() for a in account.split(",") if a.strip()]
42
+ if len(acct_list) == 1:
43
+ conditions.append("email.account = ?")
44
+ params.append(acct_list[0])
45
+ elif acct_list:
46
+ placeholders = ",".join("?" for _ in acct_list)
47
+ conditions.append(f"email.account IN ({placeholders})")
48
+ params.extend(acct_list)
49
+
50
+ if client_id:
51
+ conditions.append("email.client_id = ?")
52
+ params.append(client_id)
53
+
54
+ if project_id:
55
+ conditions.append("email.project_id = ?")
56
+ params.append(project_id)
57
+
58
+ if has_attachments is not None:
59
+ if has_attachments:
60
+ conditions.append("email.has_attachments = 1")
61
+ else:
62
+ conditions.append("email.has_attachments = 0")
63
+
64
+ fts_join = ""
65
+ if query:
66
+ fts_join = "JOIN emails_fts fts ON fts.rowid = email.id"
67
+ fts_query = f'"{query}"*'
68
+ conditions.append("emails_fts MATCH ?")
69
+ params.append(fts_query)
70
+
71
+ where_clause = ""
72
+ if conditions:
73
+ where_clause = "WHERE " + " AND ".join(conditions)
74
+
75
+ count_sql = f"SELECT COUNT(*) FROM emails email {fts_join} {where_clause}"
76
+ fetch_sql = f"""
77
+ SELECT email.id, email.message_id, email.thread_id, email.account,
78
+ email.from_address, email.from_name, email.to_addresses, email.cc_addresses,
79
+ email.subject, email.body_preview, email.received_at,
80
+ email.labels, email.has_attachments, email.is_read,
81
+ email.client_id, email.project_id,
82
+ client.name AS client_name, project.project_name,
83
+ email.mcp_view, email.mcp_read
84
+ FROM emails email
85
+ {fts_join}
86
+ LEFT JOIN clients client ON email.client_id = client.id
87
+ LEFT JOIN projects project ON email.project_id = project.id
88
+ {where_clause}
89
+ ORDER BY {sort_col_sql} {order_sql}
90
+ LIMIT ? OFFSET ?
91
+ """
92
+ rows, pagination = paginate(conn, count_sql, fetch_sql, params, page=page, limit=limit)
93
+
94
+ emails = []
95
+ for row in rows:
96
+ emails.append(
97
+ {
98
+ "id": row["id"],
99
+ "message_id": row["message_id"],
100
+ "thread_id": row["thread_id"],
101
+ "account": row["account"] or "unknown",
102
+ "from_address": row["from_address"] or "",
103
+ "from_name": row["from_name"] or "",
104
+ "to_addresses": row["to_addresses"] or "",
105
+ "cc_addresses": row["cc_addresses"] or "",
106
+ "subject": row["subject"] or "(no subject)",
107
+ "body_preview": (row["body_preview"] or "")[:200],
108
+ "received_at": row["received_at"] or "",
109
+ "labels": row["labels"] or "",
110
+ "has_attachments": bool(row["has_attachments"]),
111
+ "is_read": bool(row["is_read"]),
112
+ "client_id": row["client_id"],
113
+ "client_name": row["client_name"],
114
+ "project_id": row["project_id"],
115
+ "project_name": row["project_name"],
116
+ "mcp_view": row["mcp_view"],
117
+ "mcp_read": row["mcp_read"],
118
+ }
119
+ )
120
+
121
+ return paginated_response("emails", emails, pagination)
122
+
123
+
124
+ def get_email(conn: sqlite3.Connection, email_id: int) -> Optional[dict]:
125
+ """Get full details for a single email.
126
+
127
+ Returns dict or None if not found.
128
+ """
129
+ cursor = conn.cursor()
130
+ cursor.execute(
131
+ """
132
+ SELECT email.*, client.name AS client_name, project.project_name
133
+ FROM emails email
134
+ LEFT JOIN clients client ON email.client_id = client.id
135
+ LEFT JOIN projects project ON email.project_id = project.id
136
+ WHERE email.id = ? AND email.status != 'removed'
137
+ """,
138
+ (email_id,),
139
+ )
140
+ row = cursor.fetchone()
141
+ if not row:
142
+ return None
143
+
144
+ return {
145
+ "id": row["id"],
146
+ "message_id": row["message_id"],
147
+ "thread_id": row["thread_id"],
148
+ "account": row["account"] or "unknown",
149
+ "from_address": row["from_address"] or "",
150
+ "from_name": row["from_name"] or "",
151
+ "to_addresses": row["to_addresses"] or "",
152
+ "cc_addresses": row["cc_addresses"] or "",
153
+ "subject": row["subject"] or "(no subject)",
154
+ "body_preview": row["body_preview"] or "",
155
+ "received_at": row["received_at"] or "",
156
+ "labels": row["labels"] or "",
157
+ "has_attachments": bool(row["has_attachments"]),
158
+ "is_read": bool(row["is_read"]),
159
+ "client_id": row["client_id"],
160
+ "client_name": row["client_name"],
161
+ "project_id": row["project_id"],
162
+ "project_name": row["project_name"],
163
+ "status": row["status"],
164
+ "metadata": row["metadata"] or "",
165
+ "mcp_view": row["mcp_view"] or "inherit",
166
+ "mcp_read": row["mcp_read"] or "inherit",
167
+ }
168
+
169
+
170
+ def update_email_relationships(
171
+ conn: sqlite3.Connection,
172
+ email_id: int,
173
+ *,
174
+ project_id: Optional[int] = None,
175
+ client_id: Optional[int] = None,
176
+ ) -> Optional[bool]:
177
+ """Update project and/or client assignment on an email.
178
+
179
+ Only updates fields that are passed (not None). Pass ``0`` to clear
180
+ a field (set to NULL). Stamps ``assignment_source = 'user'``
181
+ when the column exists (app-scope DBs only).
182
+ Returns True on success, None if email not found.
183
+ """
184
+ cursor = conn.execute("SELECT id FROM emails WHERE id = ?", (email_id,))
185
+ if cursor.fetchone() is None:
186
+ return None
187
+
188
+ if project_id is not None and project_id != 0:
189
+ proj = conn.execute("SELECT id FROM projects WHERE id = ?", (project_id,)).fetchone()
190
+ if not proj:
191
+ raise ValueError(f"No project with id {project_id}")
192
+ if client_id is not None and client_id != 0:
193
+ cli = conn.execute("SELECT id FROM clients WHERE id = ?", (client_id,)).fetchone()
194
+ if not cli:
195
+ raise ValueError(f"No client with id {client_id}")
196
+
197
+ sets: list[str] = []
198
+ params: list = []
199
+ if project_id is not None:
200
+ if project_id == 0:
201
+ sets.append("project_id = NULL")
202
+ else:
203
+ sets.append("project_id = ?")
204
+ params.append(project_id)
205
+ if client_id is not None:
206
+ if client_id == 0:
207
+ sets.append("client_id = NULL")
208
+ else:
209
+ sets.append("client_id = ?")
210
+ params.append(client_id)
211
+ if not sets:
212
+ return True
213
+
214
+ sets.append("assignment_source = 'user'")
215
+ params.append(email_id)
216
+ try:
217
+ conn.execute(f"UPDATE emails SET {', '.join(sets)} WHERE id = ?", params)
218
+ except sqlite3.OperationalError as e:
219
+ if "no such column" not in str(e):
220
+ raise
221
+ # assignment_source not present (tool-only DB)
222
+ sets.pop()
223
+ conn.execute(f"UPDATE emails SET {', '.join(sets)} WHERE id = ?", params)
224
+ conn.commit()
225
+ return True
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Write operations
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def insert_email(conn: sqlite3.Connection, email_data: Dict[str, Any]) -> int:
234
+ """Insert or update an email record, preserving the row id on conflict."""
235
+ cursor = conn.cursor()
236
+ cursor.execute(
237
+ """
238
+ INSERT INTO emails
239
+ (message_id, thread_id, account, from_address, from_name,
240
+ to_addresses, cc_addresses, subject, body_preview, received_at,
241
+ labels, has_attachments, is_read, metadata)
242
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
243
+ ON CONFLICT(message_id, account) DO UPDATE SET
244
+ thread_id = excluded.thread_id,
245
+ from_address = excluded.from_address,
246
+ from_name = excluded.from_name,
247
+ to_addresses = excluded.to_addresses,
248
+ cc_addresses = excluded.cc_addresses,
249
+ subject = excluded.subject,
250
+ body_preview = excluded.body_preview,
251
+ received_at = excluded.received_at,
252
+ labels = excluded.labels,
253
+ has_attachments = excluded.has_attachments,
254
+ is_read = excluded.is_read,
255
+ metadata = excluded.metadata,
256
+ updated_at = CURRENT_TIMESTAMP
257
+ """,
258
+ (
259
+ email_data["message_id"],
260
+ email_data["thread_id"],
261
+ email_data["account"],
262
+ email_data.get("from_address"),
263
+ email_data.get("from_name"),
264
+ email_data.get("to_addresses"),
265
+ email_data.get("cc_addresses"),
266
+ email_data.get("subject"),
267
+ email_data.get("body_preview"),
268
+ email_data["received_at"],
269
+ email_data.get("labels"),
270
+ email_data.get("has_attachments", False),
271
+ email_data.get("is_read", True),
272
+ json.dumps(email_data.get("metadata", {})),
273
+ ),
274
+ )
275
+ cursor.execute(
276
+ "SELECT id FROM emails WHERE message_id = ? AND account = ?",
277
+ (email_data["message_id"], email_data["account"]),
278
+ )
279
+ return cursor.fetchone()[0]