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 +166 -0
- git_ew/__main__.py +32 -0
- git_ew/_internal/__init__.py +17 -0
- git_ew/_internal/app.py +418 -0
- git_ew/_internal/cli.py +246 -0
- git_ew/_internal/config.py +148 -0
- git_ew/_internal/database.py +297 -0
- git_ew/_internal/debug.py +130 -0
- git_ew/_internal/email_fetcher.py +301 -0
- git_ew/_internal/email_parser.py +274 -0
- git_ew/_internal/email_sender.py +220 -0
- git_ew/_internal/mailing_lists/__init__.py +19 -0
- git_ew/_internal/mailing_lists/zsh_workers/__init__.py +17 -0
- git_ew/_internal/mailing_lists/zsh_workers/ingest.py +427 -0
- git_ew/_internal/mailing_lists/zsh_workers/sync_archives.py +300 -0
- git_ew/_internal/models.py +149 -0
- git_ew/_internal/secrets.py +59 -0
- git_ew/_internal/sync.py +156 -0
- git_ew/_internal/thread_utils.py +185 -0
- git_ew/py.typed +0 -0
- git_ew/static/css/style.css +608 -0
- git_ew/static/js/main.js +47 -0
- git_ew/templates/base.html +38 -0
- git_ew/templates/index.html +44 -0
- git_ew/templates/thread.html +153 -0
- git_ew-0.1.0.dist-info/METADATA +291 -0
- git_ew-0.1.0.dist-info/RECORD +30 -0
- git_ew-0.1.0.dist-info/WHEEL +4 -0
- git_ew-0.1.0.dist-info/entry_points.txt +5 -0
- git_ew-0.1.0.dist-info/licenses/LICENSE +15 -0
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.
|
git_ew/_internal/app.py
ADDED
|
@@ -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
|