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.
- footprinter/__init__.py +8 -0
- footprinter/access.py +444 -0
- footprinter/api/__init__.py +1 -0
- footprinter/api/db.py +61 -0
- footprinter/api/entities.py +250 -0
- footprinter/api/search.py +47 -0
- footprinter/api/semantic.py +33 -0
- footprinter/api/server.py +66 -0
- footprinter/api/status.py +15 -0
- footprinter/bundled/__init__.py +0 -0
- footprinter/bundled/config.example.yaml +161 -0
- footprinter/bundled/patterns/context_patterns.yaml +18 -0
- footprinter/bundled/patterns/extensions.yaml +283 -0
- footprinter/bundled/patterns/filename_patterns.yaml +61 -0
- footprinter/bundled/patterns/mime_mappings.yaml +68 -0
- footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
- footprinter/bundled/patterns/security_patterns.yaml +27 -0
- footprinter/cli/__init__.py +128 -0
- footprinter/cli/__main__.py +6 -0
- footprinter/cli/_common.py +332 -0
- footprinter/cli/_policy_helpers.py +646 -0
- footprinter/cli/_prompt.py +220 -0
- footprinter/cli/api_cmd.py +32 -0
- footprinter/cli/connect.py +591 -0
- footprinter/cli/data.py +879 -0
- footprinter/cli/delete.py +128 -0
- footprinter/cli/ingest.py +579 -0
- footprinter/cli/mcp_cmd.py +750 -0
- footprinter/cli/mcp_setup.py +306 -0
- footprinter/cli/search.py +393 -0
- footprinter/cli/search_cmd.py +69 -0
- footprinter/cli/setup.py +1836 -0
- footprinter/cli/status.py +729 -0
- footprinter/cli/status_cmd.py +104 -0
- footprinter/cli/upsert.py +794 -0
- footprinter/cli/vectorize_cmd.py +215 -0
- footprinter/cli/view.py +322 -0
- footprinter/connectors/__init__.py +171 -0
- footprinter/connectors/config_utils.py +141 -0
- footprinter/db/__init__.py +37 -0
- footprinter/db/browser.py +198 -0
- footprinter/db/chats.py +610 -0
- footprinter/db/clients.py +307 -0
- footprinter/db/emails.py +279 -0
- footprinter/db/files.py +741 -0
- footprinter/db/folders.py +659 -0
- footprinter/db/messages.py +192 -0
- footprinter/db/policies.py +151 -0
- footprinter/db/projects.py +673 -0
- footprinter/db/search.py +573 -0
- footprinter/db/sql_utils.py +168 -0
- footprinter/db/status.py +320 -0
- footprinter/db/uploads.py +70 -0
- footprinter/ingest/__init__.py +0 -0
- footprinter/ingest/adapters/__init__.py +33 -0
- footprinter/ingest/adapters/browser.py +54 -0
- footprinter/ingest/adapters/chat.py +57 -0
- footprinter/ingest/adapters/ingest.py +146 -0
- footprinter/ingest/adapters/local_files.py +68 -0
- footprinter/ingest/adapters/local_folders.py +52 -0
- footprinter/ingest/adapters/protocol.py +174 -0
- footprinter/ingest/browser_indexer.py +216 -0
- footprinter/ingest/chat_dedup.py +156 -0
- footprinter/ingest/chat_indexer.py +515 -0
- footprinter/ingest/chat_parsers/__init__.py +8 -0
- footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
- footprinter/ingest/chat_parsers/claude_parser.py +161 -0
- footprinter/ingest/cli.py +827 -0
- footprinter/ingest/content_extractors.py +117 -0
- footprinter/ingest/database.py +36 -0
- footprinter/ingest/db/__init__.py +1 -0
- footprinter/ingest/db/connector_schema.py +47 -0
- footprinter/ingest/db/migration.py +328 -0
- footprinter/ingest/db/schema.py +1043 -0
- footprinter/ingest/db/security.py +6 -0
- footprinter/ingest/file_indexer.py +261 -0
- footprinter/ingest/file_scanner.py +277 -0
- footprinter/ingest/folder_indexer.py +226 -0
- footprinter/ingest/full_content_extractor.py +321 -0
- footprinter/ingest/orchestrator.py +125 -0
- footprinter/ingest/pipe_runner.py +217 -0
- footprinter/ingest/processing.py +165 -0
- footprinter/ingest/registry.py +201 -0
- footprinter/ingest/run_record.py +91 -0
- footprinter/ingest/status.py +346 -0
- footprinter/mcp/__init__.py +0 -0
- footprinter/mcp/__main__.py +5 -0
- footprinter/mcp/db.py +57 -0
- footprinter/mcp/errors.py +102 -0
- footprinter/mcp/extraction.py +226 -0
- footprinter/mcp/server.py +39 -0
- footprinter/mcp/tools/__init__.py +0 -0
- footprinter/mcp/tools/navigation.py +70 -0
- footprinter/mcp/tools/read.py +75 -0
- footprinter/mcp/tools/search.py +158 -0
- footprinter/mcp/tools/semantic.py +79 -0
- footprinter/mcp/tools/status.py +15 -0
- footprinter/paths.py +91 -0
- footprinter/permissions.py +1160 -0
- footprinter/semantic/__init__.py +13 -0
- footprinter/semantic/chunking.py +52 -0
- footprinter/semantic/embeddings.py +23 -0
- footprinter/semantic/hybrid_search.py +273 -0
- footprinter/semantic/vector_store.py +471 -0
- footprinter/services/__init__.py +49 -0
- footprinter/services/access_service.py +342 -0
- footprinter/services/chat_service.py +85 -0
- footprinter/services/client_service.py +267 -0
- footprinter/services/content_service.py +181 -0
- footprinter/services/email_service.py +89 -0
- footprinter/services/file_service.py +83 -0
- footprinter/services/folder_service.py +122 -0
- footprinter/services/includes.py +19 -0
- footprinter/services/ingest_service.py +231 -0
- footprinter/services/project_service.py +262 -0
- footprinter/services/roles.py +25 -0
- footprinter/services/search_service.py +177 -0
- footprinter/services/semantic_service.py +360 -0
- footprinter/services/status_service.py +18 -0
- footprinter/services/visit_service.py +65 -0
- footprinter/source_registry.py +194 -0
- footprinter/utils/__init__.py +7 -0
- footprinter/utils/hash_utils.py +59 -0
- footprinter/utils/logging_config.py +68 -0
- footprinter/utils/mime.py +30 -0
- footprinter/utils/text.py +6 -0
- footprinter/utils/time.py +11 -0
- footprinter/visibility.py +1272 -0
- footprinter_cli-1.0.0.dist-info/LICENSE +21 -0
- footprinter_cli-1.0.0.dist-info/METADATA +229 -0
- footprinter_cli-1.0.0.dist-info/RECORD +134 -0
- footprinter_cli-1.0.0.dist-info/WHEEL +5 -0
- footprinter_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
footprinter/db/emails.py
ADDED
|
@@ -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]
|