git-ew 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.
git_ew/__init__.py ADDED
@@ -0,0 +1,166 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2026, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18
+
19
+ """git-ew package.
20
+
21
+ Git Email Workflow
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from git_ew._internal.app import (
27
+ CommentCreate,
28
+ ThreadUpdate,
29
+ app,
30
+ create_app,
31
+ db,
32
+ get_thread_api,
33
+ index,
34
+ lifespan,
35
+ list_threads,
36
+ post_comment,
37
+ static_dir,
38
+ sync_emails,
39
+ template_dir,
40
+ templates,
41
+ update_thread,
42
+ view_thread,
43
+ )
44
+ from git_ew._internal.cli import get_parser, main
45
+ from git_ew._internal.config import config_command
46
+ from git_ew._internal.database import Database
47
+ from git_ew._internal.email_fetcher import (
48
+ EmailFetcher,
49
+ IMAPFetcher,
50
+ MaildirFetcher,
51
+ MboxFetcher,
52
+ PublicInboxFetcher,
53
+ get_fetcher,
54
+ )
55
+ from git_ew._internal.email_parser import (
56
+ ParsedEmail,
57
+ extract_body_and_patch,
58
+ extract_quoted_text,
59
+ normalize_message_id,
60
+ parse_email,
61
+ )
62
+ from git_ew._internal.email_sender import EmailSender, append_sent_message, create_email_sender
63
+ from git_ew._internal.mailing_lists.zsh_workers.ingest import (
64
+ decode_email_header,
65
+ extract_emails_from_archive,
66
+ find_email_by_xseq,
67
+ get_email_in_reply_to,
68
+ get_email_message_id,
69
+ get_email_references,
70
+ get_email_xseq,
71
+ ingest_archive,
72
+ ingest_archives,
73
+ parse_email_address,
74
+ )
75
+ from git_ew._internal.mailing_lists.zsh_workers.sync_archives import (
76
+ BASE_URL,
77
+ LinkExtractor,
78
+ download_archive,
79
+ fetch_archive_list,
80
+ get_missing_archives,
81
+ )
82
+ from git_ew._internal.models import (
83
+ Base,
84
+ Configuration,
85
+ EmailSource,
86
+ Message,
87
+ Thread,
88
+ get_engine,
89
+ get_session_maker,
90
+ init_db,
91
+ )
92
+ from git_ew._internal.secrets import resolve_password
93
+ from git_ew._internal.sync import sync_all_sources, sync_command
94
+ from git_ew._internal.thread_utils import (
95
+ ThreadNode,
96
+ build_thread_tree,
97
+ render_markdown,
98
+ thread_to_nested_structure,
99
+ )
100
+
101
+ __all__: list[str] = [
102
+ "BASE_URL",
103
+ "Base",
104
+ "CommentCreate",
105
+ "Configuration",
106
+ "Database",
107
+ "EmailFetcher",
108
+ "EmailSender",
109
+ "EmailSource",
110
+ "IMAPFetcher",
111
+ "LinkExtractor",
112
+ "MaildirFetcher",
113
+ "MboxFetcher",
114
+ "Message",
115
+ "ParsedEmail",
116
+ "PublicInboxFetcher",
117
+ "Thread",
118
+ "ThreadNode",
119
+ "ThreadUpdate",
120
+ "app",
121
+ "append_sent_message",
122
+ "build_thread_tree",
123
+ "config_command",
124
+ "create_app",
125
+ "create_email_sender",
126
+ "db",
127
+ "decode_email_header",
128
+ "download_archive",
129
+ "extract_body_and_patch",
130
+ "extract_emails_from_archive",
131
+ "extract_quoted_text",
132
+ "fetch_archive_list",
133
+ "find_email_by_xseq",
134
+ "get_email_in_reply_to",
135
+ "get_email_message_id",
136
+ "get_email_references",
137
+ "get_email_xseq",
138
+ "get_engine",
139
+ "get_fetcher",
140
+ "get_missing_archives",
141
+ "get_parser",
142
+ "get_session_maker",
143
+ "get_thread_api",
144
+ "index",
145
+ "ingest_archive",
146
+ "ingest_archives",
147
+ "init_db",
148
+ "lifespan",
149
+ "list_threads",
150
+ "main",
151
+ "normalize_message_id",
152
+ "parse_email",
153
+ "parse_email_address",
154
+ "post_comment",
155
+ "render_markdown",
156
+ "resolve_password",
157
+ "static_dir",
158
+ "sync_all_sources",
159
+ "sync_command",
160
+ "sync_emails",
161
+ "template_dir",
162
+ "templates",
163
+ "thread_to_nested_structure",
164
+ "update_thread",
165
+ "view_thread",
166
+ ]
git_ew/__main__.py ADDED
@@ -0,0 +1,32 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2026, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18
+
19
+ """Entry-point module, in case you use `python -m git_ew`.
20
+
21
+ Why does this file exist, and why `__main__`? For more info, read:
22
+
23
+ - https://www.python.org/dev/peps/pep-0338/
24
+ - https://docs.python.org/3/using/cmdline.html#cmdoption-m
25
+ """
26
+
27
+ import sys
28
+
29
+ from git_ew._internal.cli import main
30
+
31
+ if __name__ == "__main__":
32
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,17 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2026, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,418 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2026, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18
+
19
+ # FastAPI web application for git-ew.
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import email
25
+ import imaplib
26
+ import json
27
+ from contextlib import asynccontextmanager
28
+ from datetime import UTC, datetime
29
+ from email import policy
30
+ from email.utils import getaddresses
31
+ from pathlib import Path
32
+ from typing import TYPE_CHECKING, Protocol
33
+
34
+ from fastapi import FastAPI, HTTPException, Request
35
+ from fastapi.responses import HTMLResponse, JSONResponse
36
+ from fastapi.staticfiles import StaticFiles
37
+ from fastapi.templating import Jinja2Templates
38
+ from pydantic import BaseModel
39
+
40
+ from git_ew._internal.database import Database
41
+ from git_ew._internal.email_sender import append_sent_message, create_email_sender
42
+ from git_ew._internal.sync import sync_all_sources
43
+ from git_ew._internal.thread_utils import build_thread_tree, thread_to_nested_structure
44
+
45
+ if TYPE_CHECKING:
46
+ from collections.abc import AsyncIterator
47
+
48
+
49
+ class _ReplySource(Protocol):
50
+ """Provide the message fields needed to prepare a reply."""
51
+
52
+ @property
53
+ def message_id(self) -> str: ...
54
+
55
+ @property
56
+ def raw_email(self) -> str | None: ...
57
+
58
+
59
+ # Global database instance
60
+ db: Database | None = None
61
+ """Global database instance."""
62
+
63
+
64
+ @asynccontextmanager
65
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: ARG001
66
+ """Application lifespan manager."""
67
+ global db # noqa: PLW0603
68
+ db = Database()
69
+ await db.init_db()
70
+ yield
71
+ # Cleanup if needed
72
+
73
+
74
+ app = FastAPI(title="git-ew", description="Git Email Workflow", lifespan=lifespan)
75
+ """FastAPI application instance."""
76
+
77
+ # Setup templates
78
+ template_dir = Path(__file__).parent.parent / "templates"
79
+ """Path to templates directory."""
80
+ template_dir.mkdir(exist_ok=True)
81
+ templates = Jinja2Templates(directory=str(template_dir))
82
+ """Jinja2 templates renderer."""
83
+
84
+ # Setup static files
85
+ static_dir = Path(__file__).parent.parent / "static"
86
+ """Path to static files directory."""
87
+ static_dir.mkdir(exist_ok=True)
88
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
89
+
90
+
91
+ # Pydantic models for API
92
+ class CommentCreate(BaseModel):
93
+ """Model for creating a comment."""
94
+
95
+ body: str
96
+ """Comment body text."""
97
+ in_reply_to: str | None = None
98
+ """Message ID this comment is replying to."""
99
+
100
+
101
+ class ThreadUpdate(BaseModel):
102
+ """Model for updating a thread."""
103
+
104
+ status: str | None = None
105
+ """New status for the thread (open or closed)."""
106
+
107
+
108
+ def _reply_all_metadata(
109
+ message: _ReplySource,
110
+ thread_root_id: str,
111
+ own_addresses: set[str],
112
+ ) -> tuple[str, list[str], list[str]]:
113
+ """Build Reply All recipients and references from a stored message."""
114
+ raw_email = message.raw_email or ""
115
+ parsed = email.message_from_string(raw_email, policy=policy.default)
116
+
117
+ def addresses(*headers: str) -> list[str]:
118
+ values = [
119
+ value.replace("<mailto:", "<").replace("mailto:", "")
120
+ for header in headers
121
+ for value in parsed.get_all(header, [])
122
+ ]
123
+ return [address.lower() for _, address in getaddresses(values) if address]
124
+
125
+ reply_targets = addresses("Reply-To") or addresses("From")
126
+ original_recipients = addresses("To", "Cc", "List-Post")
127
+ candidates = list(dict.fromkeys(reply_targets + original_recipients))
128
+ candidates = [address for address in candidates if address not in own_addresses]
129
+ if not candidates:
130
+ raise ValueError("No external recipients found for reply")
131
+
132
+ to_email = candidates[0]
133
+ cc = candidates[1:]
134
+ references = [
135
+ reference.strip("<>")
136
+ for header in parsed.get_all("References", [])
137
+ for reference in header.split()
138
+ if reference.strip("<>")
139
+ ]
140
+ message_id = message.message_id
141
+ if not references:
142
+ references.append(thread_root_id)
143
+ if message_id not in references:
144
+ references.append(message_id)
145
+ return to_email, cc, references
146
+
147
+
148
+ # Routes
149
+ @app.get("/", response_class=HTMLResponse)
150
+ async def index(request: Request) -> HTMLResponse:
151
+ """Show all email threads."""
152
+ assert db is not None # noqa: S101
153
+ threads = await db.get_threads()
154
+ return templates.TemplateResponse(
155
+ request,
156
+ "index.html",
157
+ {
158
+ "threads": threads,
159
+ },
160
+ )
161
+
162
+
163
+ @app.get("/thread/{thread_id}", response_class=HTMLResponse)
164
+ async def view_thread(request: Request, thread_id: int, *, flatten: bool = True) -> HTMLResponse:
165
+ """View a specific thread with all messages.
166
+
167
+ Args:
168
+ request: FastAPI request.
169
+ thread_id: Thread ID.
170
+ flatten: Whether to flatten linear chains.
171
+
172
+ Returns:
173
+ HTML response.
174
+ """
175
+ assert db is not None # noqa: S101
176
+ thread = await db.get_thread(thread_id)
177
+ if not thread:
178
+ raise HTTPException(status_code=404, detail="Thread not found")
179
+
180
+ # Build thread tree
181
+ tree = build_thread_tree(thread.messages)
182
+ nested_messages = thread_to_nested_structure(tree, flatten=flatten)
183
+
184
+ return templates.TemplateResponse(
185
+ request,
186
+ "thread.html",
187
+ {
188
+ "thread": thread,
189
+ "messages": nested_messages,
190
+ },
191
+ )
192
+
193
+
194
+ @app.post("/api/thread/{thread_id}/comment")
195
+ async def post_comment(thread_id: int, comment: CommentCreate) -> JSONResponse:
196
+ """Post a comment to a thread (sends an email).
197
+
198
+ Args:
199
+ thread_id: Thread ID.
200
+ comment: Comment data.
201
+
202
+ Returns:
203
+ JSON response with created message.
204
+ """
205
+ assert db is not None # noqa: S101
206
+ thread = await db.get_thread(thread_id)
207
+ if not thread:
208
+ raise HTTPException(status_code=404, detail="Thread not found")
209
+
210
+ # Get email configuration
211
+ email_config = await db.get_config("email_config")
212
+ if not email_config:
213
+ raise HTTPException(status_code=400, detail="Email not configured")
214
+
215
+ # Create email sender
216
+ sender = create_email_sender(email_config)
217
+
218
+ own_addresses = {
219
+ address.lower() for address in (email_config.get("from_email"), email_config.get("username")) if address
220
+ }
221
+
222
+ # Determine Reply All recipients and references.
223
+ if comment.in_reply_to:
224
+ # Get the message we're replying to
225
+ reply_to_msg = await db.get_message_by_id(comment.in_reply_to)
226
+ if not reply_to_msg:
227
+ raise HTTPException(status_code=404, detail="Reply-to message not found")
228
+
229
+ source_message = reply_to_msg
230
+ subject = reply_to_msg.subject
231
+ else:
232
+ # Reply to the first message in the thread
233
+ first_msg = next((m for m in thread.messages if m.message_id == thread.first_message_id), None)
234
+ if not first_msg:
235
+ raise HTTPException(status_code=404, detail="First message not found")
236
+
237
+ source_message = first_msg
238
+ subject = thread.subject
239
+
240
+ try:
241
+ to_email, cc, references = _reply_all_metadata(source_message, thread.first_message_id, own_addresses)
242
+ except ValueError as error:
243
+ raise HTTPException(status_code=400, detail=str(error)) from error
244
+
245
+ # Build and send the email.
246
+ outgoing = sender.build_reply(
247
+ to_email=to_email,
248
+ subject=subject,
249
+ body=comment.body,
250
+ in_reply_to=comment.in_reply_to or thread.first_message_id,
251
+ references=references,
252
+ cc=cc,
253
+ )
254
+ try:
255
+ sender.send_message(outgoing)
256
+ except Exception as e:
257
+ raise HTTPException(status_code=500, detail=f"Failed to send email: {e}") from e
258
+
259
+ # Store in database
260
+ message_id = outgoing["Message-ID"].strip("<>")
261
+ message = await db.create_message(
262
+ message_id=message_id,
263
+ thread_id=thread_id,
264
+ from_email=email_config["from_email"],
265
+ from_name=email_config.get("from_name", email_config["from_email"]),
266
+ subject=outgoing["Subject"],
267
+ date=datetime.now(UTC),
268
+ body=comment.body,
269
+ in_reply_to=comment.in_reply_to or thread.first_message_id,
270
+ raw_email=outgoing.as_string(),
271
+ )
272
+
273
+ sent_folder_saved = False
274
+ sent_folder_error = "No IMAP source configured"
275
+ for source in await db.get_email_sources():
276
+ if not source.enabled or source.source_type != "imap":
277
+ continue
278
+ imap_config = json.loads(source.config)
279
+ folders = imap_config.get("folders", [])
280
+ sent_folder = next((folder for folder in folders if folder.lower() == "sent"), "Sent")
281
+ try:
282
+ await asyncio.to_thread(append_sent_message, outgoing, imap_config, sent_folder)
283
+ sent_folder_saved = True
284
+ sent_folder_error = ""
285
+ except (imaplib.IMAP4.error, OSError, RuntimeError) as error:
286
+ sent_folder_error = str(error)
287
+ break
288
+
289
+ return JSONResponse(
290
+ content={
291
+ "message_id": message.message_id,
292
+ "success": True,
293
+ "sent_folder_saved": sent_folder_saved,
294
+ "sent_folder_error": sent_folder_error,
295
+ },
296
+ )
297
+
298
+
299
+ @app.post("/api/thread/{thread_id}")
300
+ async def update_thread(thread_id: int, update: ThreadUpdate) -> JSONResponse:
301
+ """Update a thread.
302
+
303
+ Args:
304
+ thread_id: Thread ID.
305
+ update: Update data.
306
+
307
+ Returns:
308
+ JSON response.
309
+ """
310
+ assert db is not None # noqa: S101
311
+ thread = await db.get_thread(thread_id)
312
+ if not thread:
313
+ raise HTTPException(status_code=404, detail="Thread not found")
314
+
315
+ if update.status:
316
+ await db.update_thread_status(thread_id, update.status)
317
+
318
+ return JSONResponse(content={"success": True})
319
+
320
+
321
+ @app.post("/api/sync")
322
+ async def sync_emails() -> JSONResponse:
323
+ """Sync emails from configured sources.
324
+
325
+ Returns:
326
+ JSON response with sync results.
327
+ """
328
+ assert db is not None # noqa: S101
329
+ stats = await sync_all_sources(db)
330
+
331
+ return JSONResponse(
332
+ content={
333
+ "success": not stats["errors"],
334
+ "synced": stats["new_messages"],
335
+ "errors": stats["errors"],
336
+ },
337
+ )
338
+
339
+
340
+ @app.get("/api/threads")
341
+ async def list_threads(status: str | None = None, limit: int = 50, offset: int = 0) -> JSONResponse:
342
+ """Get list of threads.
343
+
344
+ Args:
345
+ status: Filter by status.
346
+ limit: Maximum number of threads.
347
+ offset: Number of threads to skip.
348
+
349
+ Returns:
350
+ JSON response with threads.
351
+ """
352
+ assert db is not None # noqa: S101
353
+ threads = await db.get_threads(status=status, limit=limit, offset=offset)
354
+ return JSONResponse(
355
+ content={
356
+ "threads": [
357
+ {
358
+ "id": t.id,
359
+ "subject": t.subject,
360
+ "created_at": t.created_at.isoformat(),
361
+ "updated_at": t.updated_at.isoformat(),
362
+ "status": t.status,
363
+ "is_patch": t.is_patch,
364
+ }
365
+ for t in threads
366
+ ],
367
+ },
368
+ )
369
+
370
+
371
+ @app.get("/api/thread/{thread_id}")
372
+ async def get_thread_api(thread_id: int) -> JSONResponse:
373
+ """Get thread details.
374
+
375
+ Args:
376
+ thread_id: Thread ID.
377
+
378
+ Returns:
379
+ JSON response with thread and messages.
380
+ """
381
+ assert db is not None # noqa: S101
382
+ thread = await db.get_thread(thread_id)
383
+ if not thread:
384
+ raise HTTPException(status_code=404, detail="Thread not found")
385
+
386
+ return JSONResponse(
387
+ content={
388
+ "id": thread.id,
389
+ "subject": thread.subject,
390
+ "created_at": thread.created_at.isoformat(),
391
+ "updated_at": thread.updated_at.isoformat(),
392
+ "status": thread.status,
393
+ "is_patch": thread.is_patch,
394
+ "messages": [
395
+ {
396
+ "id": m.id,
397
+ "message_id": m.message_id,
398
+ "from_email": m.from_email,
399
+ "from_name": m.from_name,
400
+ "subject": m.subject,
401
+ "date": m.date.isoformat(),
402
+ "body": m.body,
403
+ "in_reply_to": m.in_reply_to,
404
+ "is_patch": m.is_patch,
405
+ }
406
+ for m in thread.messages
407
+ ],
408
+ },
409
+ )
410
+
411
+
412
+ def create_app() -> FastAPI:
413
+ """Create and configure the FastAPI application.
414
+
415
+ Returns:
416
+ FastAPI application instance.
417
+ """
418
+ return app