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
|
@@ -0,0 +1,220 @@
|
|
|
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
|
+
# Email sending functionality for git-ew.
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import imaplib
|
|
24
|
+
import smtplib
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from email.message import EmailMessage
|
|
27
|
+
from email.utils import formatdate, make_msgid
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from git_ew._internal.secrets import resolve_password
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EmailSender:
|
|
34
|
+
"""Handle sending emails."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
smtp_host: str,
|
|
39
|
+
smtp_port: int,
|
|
40
|
+
from_email: str,
|
|
41
|
+
from_name: str,
|
|
42
|
+
*,
|
|
43
|
+
username: str | None = None,
|
|
44
|
+
password: str | None = None,
|
|
45
|
+
use_tls: bool = True,
|
|
46
|
+
):
|
|
47
|
+
"""Initialize email sender.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
smtp_host: SMTP server hostname.
|
|
51
|
+
smtp_port: SMTP server port.
|
|
52
|
+
from_email: Sender email address.
|
|
53
|
+
from_name: Sender name.
|
|
54
|
+
username: SMTP username (if required).
|
|
55
|
+
password: SMTP password (if required).
|
|
56
|
+
use_tls: Whether to use TLS.
|
|
57
|
+
"""
|
|
58
|
+
self.smtp_host = smtp_host
|
|
59
|
+
"""SMTP server hostname."""
|
|
60
|
+
self.smtp_port = smtp_port
|
|
61
|
+
"""SMTP server port."""
|
|
62
|
+
self.from_email = from_email
|
|
63
|
+
"""Sender email address."""
|
|
64
|
+
self.from_name = from_name
|
|
65
|
+
"""Sender name."""
|
|
66
|
+
self.username = username
|
|
67
|
+
"""SMTP username."""
|
|
68
|
+
self.password = password
|
|
69
|
+
"""SMTP password."""
|
|
70
|
+
self.use_tls = use_tls
|
|
71
|
+
"""Whether to use TLS."""
|
|
72
|
+
|
|
73
|
+
def send_email( # noqa: PLR0917
|
|
74
|
+
self,
|
|
75
|
+
to_email: str,
|
|
76
|
+
subject: str,
|
|
77
|
+
body: str,
|
|
78
|
+
in_reply_to: str | None = None,
|
|
79
|
+
references: list[str] | None = None,
|
|
80
|
+
cc: list[str] | None = None,
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Send a plain text email.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
to_email: Recipient email address.
|
|
86
|
+
subject: Email subject.
|
|
87
|
+
body: Email body (plain text).
|
|
88
|
+
in_reply_to: Message ID this replies to.
|
|
89
|
+
references: List of message IDs in the thread.
|
|
90
|
+
cc: List of CC recipients.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Message ID of sent email.
|
|
94
|
+
"""
|
|
95
|
+
msg = self.build_email(to_email, subject, body, in_reply_to, references, cc)
|
|
96
|
+
self.send_message(msg)
|
|
97
|
+
return msg["Message-ID"].strip("<>")
|
|
98
|
+
|
|
99
|
+
def build_email( # noqa: PLR0917
|
|
100
|
+
self,
|
|
101
|
+
to_email: str,
|
|
102
|
+
subject: str,
|
|
103
|
+
body: str,
|
|
104
|
+
in_reply_to: str | None = None,
|
|
105
|
+
references: list[str] | None = None,
|
|
106
|
+
cc: list[str] | None = None,
|
|
107
|
+
) -> EmailMessage:
|
|
108
|
+
"""Build an email without sending it."""
|
|
109
|
+
msg = EmailMessage()
|
|
110
|
+
msg.set_content(body)
|
|
111
|
+
|
|
112
|
+
# Set headers
|
|
113
|
+
msg["From"] = f"{self.from_name} <{self.from_email}>"
|
|
114
|
+
msg["To"] = to_email
|
|
115
|
+
msg["Subject"] = subject
|
|
116
|
+
msg["Date"] = formatdate(localtime=True)
|
|
117
|
+
msg["Message-ID"] = make_msgid(domain=self.from_email.split("@")[1])
|
|
118
|
+
|
|
119
|
+
if cc:
|
|
120
|
+
msg["Cc"] = ", ".join(cc)
|
|
121
|
+
|
|
122
|
+
if in_reply_to:
|
|
123
|
+
msg["In-Reply-To"] = f"<{in_reply_to}>"
|
|
124
|
+
|
|
125
|
+
if references:
|
|
126
|
+
msg["References"] = " ".join(f"<{ref}>" for ref in references)
|
|
127
|
+
|
|
128
|
+
return msg
|
|
129
|
+
|
|
130
|
+
def send_message(self, msg: EmailMessage) -> None:
|
|
131
|
+
"""Send a prepared email over SMTP."""
|
|
132
|
+
with smtplib.SMTP(self.smtp_host, self.smtp_port) as smtp:
|
|
133
|
+
if self.use_tls:
|
|
134
|
+
smtp.starttls()
|
|
135
|
+
|
|
136
|
+
if self.username and self.password:
|
|
137
|
+
smtp.login(self.username, self.password)
|
|
138
|
+
|
|
139
|
+
smtp.send_message(msg)
|
|
140
|
+
|
|
141
|
+
def send_reply( # noqa: PLR0917
|
|
142
|
+
self,
|
|
143
|
+
to_email: str,
|
|
144
|
+
subject: str,
|
|
145
|
+
body: str,
|
|
146
|
+
in_reply_to: str,
|
|
147
|
+
references: list[str] | None = None,
|
|
148
|
+
cc: list[str] | None = None,
|
|
149
|
+
) -> str:
|
|
150
|
+
"""Send a reply email.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
to_email: Recipient email address.
|
|
154
|
+
subject: Email subject (will be prefixed with "Re:" if not present).
|
|
155
|
+
body: Email body (plain text).
|
|
156
|
+
in_reply_to: Message ID this replies to.
|
|
157
|
+
references: List of message IDs in the thread.
|
|
158
|
+
cc: List of CC recipients.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Message ID of sent email.
|
|
162
|
+
"""
|
|
163
|
+
msg = self.build_reply(to_email, subject, body, in_reply_to, references, cc)
|
|
164
|
+
self.send_message(msg)
|
|
165
|
+
return msg["Message-ID"].strip("<>")
|
|
166
|
+
|
|
167
|
+
def build_reply( # noqa: PLR0917
|
|
168
|
+
self,
|
|
169
|
+
to_email: str,
|
|
170
|
+
subject: str,
|
|
171
|
+
body: str,
|
|
172
|
+
in_reply_to: str,
|
|
173
|
+
references: list[str] | None = None,
|
|
174
|
+
cc: list[str] | None = None,
|
|
175
|
+
) -> EmailMessage:
|
|
176
|
+
"""Build a reply email without sending it."""
|
|
177
|
+
if not subject.lower().startswith("re:"):
|
|
178
|
+
subject = f"Re: {subject}"
|
|
179
|
+
|
|
180
|
+
references = list(references or [])
|
|
181
|
+
if in_reply_to not in references:
|
|
182
|
+
references.append(in_reply_to)
|
|
183
|
+
|
|
184
|
+
return self.build_email(
|
|
185
|
+
to_email=to_email,
|
|
186
|
+
subject=subject,
|
|
187
|
+
body=body,
|
|
188
|
+
in_reply_to=in_reply_to,
|
|
189
|
+
references=references,
|
|
190
|
+
cc=cc,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def append_sent_message(msg: EmailMessage, config: dict[str, Any], folder: str = "Sent") -> None:
|
|
195
|
+
"""Append a sent message to an IMAP folder using a resolved password."""
|
|
196
|
+
host = config.get("host", "imap.fastmail.com")
|
|
197
|
+
port = int(config.get("port", 993))
|
|
198
|
+
with imaplib.IMAP4_SSL(host, port) as client:
|
|
199
|
+
client.login(config["username"], resolve_password(config))
|
|
200
|
+
status, _ = client.append(
|
|
201
|
+
folder,
|
|
202
|
+
"\\Seen",
|
|
203
|
+
imaplib.Time2Internaldate(datetime.now(UTC).timestamp()),
|
|
204
|
+
msg.as_bytes(),
|
|
205
|
+
)
|
|
206
|
+
if status != "OK":
|
|
207
|
+
raise RuntimeError(f"Unable to append sent message to IMAP folder {folder!r}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def create_email_sender(config: dict[str, Any]) -> EmailSender:
|
|
211
|
+
"""Create an email sender with a resolved password."""
|
|
212
|
+
return EmailSender(
|
|
213
|
+
smtp_host=config.get("smtp_host", "localhost"),
|
|
214
|
+
smtp_port=config.get("smtp_port", 587),
|
|
215
|
+
from_email=config["from_email"],
|
|
216
|
+
from_name=config.get("from_name", config["from_email"]),
|
|
217
|
+
username=config.get("username"),
|
|
218
|
+
password=resolve_password(config, required=False),
|
|
219
|
+
use_tls=config.get("use_tls", True),
|
|
220
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
# Mailing lists utilities.
|
|
@@ -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,427 @@
|
|
|
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
|
+
# Archive ingestion for zsh-workers mailing list archives.
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import email
|
|
24
|
+
import logging
|
|
25
|
+
import tarfile
|
|
26
|
+
from contextlib import suppress
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from email.header import decode_header
|
|
29
|
+
from email.utils import parseaddr, parsedate_to_datetime
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import TYPE_CHECKING, Protocol
|
|
32
|
+
|
|
33
|
+
from sqlalchemy import create_engine
|
|
34
|
+
from sqlalchemy.orm import sessionmaker
|
|
35
|
+
|
|
36
|
+
from git_ew._internal.email_parser import extract_body_and_patch
|
|
37
|
+
from git_ew._internal.models import Base, Message, Thread
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
41
|
+
from email.message import Message as EmailMessage
|
|
42
|
+
|
|
43
|
+
from sqlalchemy.orm import Session
|
|
44
|
+
|
|
45
|
+
_logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _SyncDatabaseProtocol(Protocol):
|
|
49
|
+
"""Describe the synchronous session factory used during ingestion."""
|
|
50
|
+
|
|
51
|
+
session_maker: sessionmaker[Session]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def decode_email_header(header_value: str) -> str:
|
|
55
|
+
"""Decode RFC 2047 encoded-word headers to plain text.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
header_value: Raw header value (may contain encoded-words like =?UTF-8?Q?...?=).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Decoded string.
|
|
62
|
+
"""
|
|
63
|
+
if not header_value:
|
|
64
|
+
return ""
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
# decode_header returns a list of tuples: (decoded_bytes, charset)
|
|
68
|
+
decoded_parts = []
|
|
69
|
+
for decoded_bytes, charset in decode_header(header_value):
|
|
70
|
+
if isinstance(decoded_bytes, bytes):
|
|
71
|
+
# If we got bytes, decode with the specified charset (or utf-8 as fallback)
|
|
72
|
+
try:
|
|
73
|
+
decoded_str = decoded_bytes.decode(charset or "utf-8", errors="replace")
|
|
74
|
+
except (TypeError, LookupError):
|
|
75
|
+
decoded_str = decoded_bytes.decode("utf-8", errors="replace")
|
|
76
|
+
else:
|
|
77
|
+
# Already a string
|
|
78
|
+
decoded_str = decoded_bytes or ""
|
|
79
|
+
decoded_parts.append(decoded_str)
|
|
80
|
+
return "".join(decoded_parts)
|
|
81
|
+
except Exception:
|
|
82
|
+
_logger.debug("Could not decode email header %r", header_value, exc_info=True)
|
|
83
|
+
return header_value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_email_address(from_header: str) -> tuple[str, str]:
|
|
87
|
+
"""Parse From header to extract name and email address.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
from_header: Raw From header value (may be "Name <email@example.com>" or just "email@example.com").
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Tuple of (name, email).
|
|
94
|
+
"""
|
|
95
|
+
# parseaddr handles both "Name <email>" and "email" formats
|
|
96
|
+
name, email = parseaddr(from_header)
|
|
97
|
+
|
|
98
|
+
# Decode the name if it contains encoded-words
|
|
99
|
+
if name:
|
|
100
|
+
name = decode_email_header(name)
|
|
101
|
+
|
|
102
|
+
# If name is empty, use email as name
|
|
103
|
+
if not name:
|
|
104
|
+
name = email or from_header
|
|
105
|
+
|
|
106
|
+
return name, email
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def extract_emails_from_archive(archive_path: Path) -> Iterator[tuple[str, EmailMessage]]:
|
|
110
|
+
"""Extract email files from a .tgz archive.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
archive_path: Path to the .tgz archive file.
|
|
114
|
+
|
|
115
|
+
Yields:
|
|
116
|
+
Tuples of (filename, email_message) for each email found.
|
|
117
|
+
"""
|
|
118
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
119
|
+
for member in tar.getmembers():
|
|
120
|
+
if member.isfile() and not member.name.startswith("."):
|
|
121
|
+
# Extract to temporary location and read
|
|
122
|
+
f = tar.extractfile(member)
|
|
123
|
+
if f:
|
|
124
|
+
try:
|
|
125
|
+
content = f.read()
|
|
126
|
+
msg = email.message_from_bytes(content)
|
|
127
|
+
yield (member.name, msg)
|
|
128
|
+
except Exception:
|
|
129
|
+
_logger.debug("Could not parse %s from %s", member.name, archive_path, exc_info=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_email_message_id(msg: EmailMessage) -> str | None:
|
|
133
|
+
"""Extract message ID from email header.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
msg: Email message object.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Message ID or None if not found.
|
|
140
|
+
"""
|
|
141
|
+
msg_id = msg.get("Message-ID")
|
|
142
|
+
if msg_id:
|
|
143
|
+
# Clean up message ID (remove angle brackets if present)
|
|
144
|
+
return msg_id.strip("<>")
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_email_in_reply_to(msg: EmailMessage) -> str | None:
|
|
149
|
+
"""Extract In-Reply-To from email header.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
msg: Email message object.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
In-Reply-To ID or None if not found.
|
|
156
|
+
"""
|
|
157
|
+
in_reply_to = msg.get("In-Reply-To")
|
|
158
|
+
if in_reply_to:
|
|
159
|
+
return in_reply_to.strip("<>")
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_email_references(msg: EmailMessage) -> list[str]:
|
|
164
|
+
"""Extract References from email header.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
msg: Email message object.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of reference message IDs.
|
|
171
|
+
"""
|
|
172
|
+
refs = msg.get("References")
|
|
173
|
+
if refs:
|
|
174
|
+
# References are space-separated message IDs
|
|
175
|
+
return [ref.strip("<>") for ref in refs.split() if ref.strip()]
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_email_xseq(msg: EmailMessage) -> int | None:
|
|
180
|
+
"""Extract X-seq number from email header.
|
|
181
|
+
|
|
182
|
+
The X-seq header contains the sequence number in the mailing list format:
|
|
183
|
+
"zsh-workers 44316" means message #44316.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
msg: Email message object.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Sequence number or None if not found.
|
|
190
|
+
"""
|
|
191
|
+
xseq = msg.get("X-seq")
|
|
192
|
+
if xseq:
|
|
193
|
+
# Format is typically "zsh-workers 44316"
|
|
194
|
+
parts = xseq.strip().split()
|
|
195
|
+
if len(parts) >= 2: # noqa: PLR2004
|
|
196
|
+
try:
|
|
197
|
+
return int(parts[-1]) # Last part should be the number
|
|
198
|
+
except ValueError:
|
|
199
|
+
pass
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def find_email_by_xseq(
|
|
204
|
+
archive_dir: Path,
|
|
205
|
+
xseq_number: int,
|
|
206
|
+
) -> tuple[EmailMessage, int] | None:
|
|
207
|
+
"""Find an email by its X-seq number by searching through archives.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
archive_dir: Directory containing .tgz archives.
|
|
211
|
+
xseq_number: The X-seq number to search for (e.g., 44316 for msg00353).
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Tuple of (email_message, xseq_number) if found, None otherwise.
|
|
215
|
+
"""
|
|
216
|
+
for archive_path in sorted(archive_dir.glob("*.tgz")):
|
|
217
|
+
try:
|
|
218
|
+
for _filename, msg in extract_emails_from_archive(archive_path):
|
|
219
|
+
xseq = get_email_xseq(msg)
|
|
220
|
+
if xseq == xseq_number:
|
|
221
|
+
return (msg, xseq)
|
|
222
|
+
except Exception:
|
|
223
|
+
_logger.debug("Could not search archive %s", archive_path, exc_info=True)
|
|
224
|
+
continue
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def ingest_archive(
|
|
229
|
+
archive_path: Path,
|
|
230
|
+
db: _SyncDatabaseProtocol,
|
|
231
|
+
on_email_found: Callable[[str, EmailMessage, str], object] | None = None,
|
|
232
|
+
) -> tuple[int, int]:
|
|
233
|
+
"""Ingest emails from an archive into the database.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
archive_path: Path to the .tgz archive.
|
|
237
|
+
db: Database instance.
|
|
238
|
+
on_email_found: Optional callback function called when an email is found.
|
|
239
|
+
Called with (filename, msg, message_id).
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Tuple of (inserted_count, skipped_count) for newly inserted vs existing emails.
|
|
243
|
+
"""
|
|
244
|
+
inserted = 0
|
|
245
|
+
skipped = 0
|
|
246
|
+
|
|
247
|
+
with db.session_maker() as session:
|
|
248
|
+
for filename, msg in extract_emails_from_archive(archive_path):
|
|
249
|
+
message_id = get_email_message_id(msg)
|
|
250
|
+
if not message_id:
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
# Callback for finding specific emails
|
|
254
|
+
if on_email_found:
|
|
255
|
+
on_email_found(filename, msg, message_id)
|
|
256
|
+
|
|
257
|
+
# Extract patch attachments before deduplication so existing messages
|
|
258
|
+
# can be enriched when the ingestion logic gains new MIME support.
|
|
259
|
+
body, patch_content = extract_body_and_patch(msg)
|
|
260
|
+
|
|
261
|
+
# Check if message already exists (deduplication)
|
|
262
|
+
existing = session.query(Message).filter_by(message_id=message_id).first()
|
|
263
|
+
if existing:
|
|
264
|
+
if patch_content and not existing.patch_content:
|
|
265
|
+
existing.patch_content = patch_content
|
|
266
|
+
existing.is_patch = True
|
|
267
|
+
skipped += 1
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
# Parse email metadata
|
|
271
|
+
from_header = msg.get("From", "unknown@example.com")
|
|
272
|
+
from_name, from_email = parse_email_address(from_header)
|
|
273
|
+
subject = decode_email_header(msg.get("Subject", "(no subject)"))
|
|
274
|
+
date_str = msg.get("Date", "")
|
|
275
|
+
in_reply_to = get_email_in_reply_to(msg)
|
|
276
|
+
|
|
277
|
+
# Parse date
|
|
278
|
+
try:
|
|
279
|
+
date = parsedate_to_datetime(date_str)
|
|
280
|
+
except (TypeError, ValueError):
|
|
281
|
+
date = datetime.now(UTC)
|
|
282
|
+
|
|
283
|
+
# Try to find or create thread
|
|
284
|
+
# Use the first message in References (original message) as thread root
|
|
285
|
+
# Fall back to in_reply_to, then to message_id if neither exist
|
|
286
|
+
references = get_email_references(msg)
|
|
287
|
+
intended_thread_root = None
|
|
288
|
+
if references:
|
|
289
|
+
# The first message in the References chain is the original message
|
|
290
|
+
intended_thread_root = references[0]
|
|
291
|
+
thread_root_id = references[0]
|
|
292
|
+
elif in_reply_to:
|
|
293
|
+
# If no References, use in_reply_to
|
|
294
|
+
intended_thread_root = in_reply_to
|
|
295
|
+
thread_root_id = in_reply_to
|
|
296
|
+
else:
|
|
297
|
+
# If neither References nor in_reply_to, this is the start of a new thread
|
|
298
|
+
thread_root_id = message_id
|
|
299
|
+
|
|
300
|
+
# Check if we already have a thread for this root
|
|
301
|
+
thread = session.query(Thread).filter_by(first_message_id=thread_root_id).first()
|
|
302
|
+
|
|
303
|
+
if not thread:
|
|
304
|
+
# Check if the intended thread root message exists in our database
|
|
305
|
+
if intended_thread_root and intended_thread_root != message_id:
|
|
306
|
+
root_message = session.query(Message).filter_by(message_id=intended_thread_root).first()
|
|
307
|
+
if root_message:
|
|
308
|
+
# Found the root message, use its thread instead
|
|
309
|
+
thread = session.query(Thread).filter_by(id=root_message.thread_id).first()
|
|
310
|
+
else:
|
|
311
|
+
# We don't have the actual first message, check if we have the in_reply_to message
|
|
312
|
+
# and if so, use its thread
|
|
313
|
+
if in_reply_to:
|
|
314
|
+
in_reply_message = session.query(Message).filter_by(message_id=in_reply_to).first()
|
|
315
|
+
if in_reply_message:
|
|
316
|
+
thread = session.query(Thread).filter_by(id=in_reply_message.thread_id).first()
|
|
317
|
+
|
|
318
|
+
if not thread:
|
|
319
|
+
# Still no thread, log a warning and create a new thread
|
|
320
|
+
_logger.warning(
|
|
321
|
+
"Thread first message is unavailable: %s. Using earliest available message %s (%s) as the thread root.",
|
|
322
|
+
intended_thread_root,
|
|
323
|
+
message_id,
|
|
324
|
+
date,
|
|
325
|
+
)
|
|
326
|
+
thread_root_id = message_id
|
|
327
|
+
|
|
328
|
+
if not thread:
|
|
329
|
+
# Create new thread
|
|
330
|
+
thread = Thread(
|
|
331
|
+
subject=subject,
|
|
332
|
+
first_message_id=thread_root_id,
|
|
333
|
+
created_at=date,
|
|
334
|
+
updated_at=date,
|
|
335
|
+
is_patch="patch" in subject.lower(),
|
|
336
|
+
status="open",
|
|
337
|
+
)
|
|
338
|
+
session.add(thread)
|
|
339
|
+
session.flush() # Ensure thread has an ID
|
|
340
|
+
else:
|
|
341
|
+
# Thread already exists, update its updated_at to the latest message date
|
|
342
|
+
# Handle timezone-aware and naive datetimes safely
|
|
343
|
+
# Aware and naive dates cannot be compared.
|
|
344
|
+
with suppress(TypeError):
|
|
345
|
+
thread.updated_at = max(thread.updated_at, date)
|
|
346
|
+
|
|
347
|
+
# Create message record
|
|
348
|
+
message = Message(
|
|
349
|
+
message_id=message_id,
|
|
350
|
+
in_reply_to=in_reply_to,
|
|
351
|
+
thread_id=thread.id,
|
|
352
|
+
from_email=from_email,
|
|
353
|
+
from_name=from_name,
|
|
354
|
+
subject=subject,
|
|
355
|
+
date=date,
|
|
356
|
+
body=body,
|
|
357
|
+
raw_email=str(msg),
|
|
358
|
+
is_patch=bool(patch_content) or "patch" in subject.lower() or "---" in body[:500],
|
|
359
|
+
patch_content=patch_content,
|
|
360
|
+
)
|
|
361
|
+
session.add(message)
|
|
362
|
+
inserted += 1
|
|
363
|
+
|
|
364
|
+
session.commit()
|
|
365
|
+
|
|
366
|
+
return inserted, skipped
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class _SyncDatabase:
|
|
370
|
+
"""Synchronous database adapter required by archive ingestion."""
|
|
371
|
+
|
|
372
|
+
def __init__(self, session_maker: sessionmaker[Session]) -> None:
|
|
373
|
+
self.session_maker = session_maker
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def ingest_archives(
|
|
377
|
+
archive_dir: Path = Path(".archives"),
|
|
378
|
+
database_url: str = "sqlite:///./git_ew.db",
|
|
379
|
+
*,
|
|
380
|
+
filenames: Iterable[str] | None = None,
|
|
381
|
+
verbose: bool = True,
|
|
382
|
+
) -> tuple[int, int]:
|
|
383
|
+
"""Ingest all zsh-workers archives in a directory.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
archive_dir: Directory containing ``.tgz`` archives.
|
|
387
|
+
database_url: SQLAlchemy URL for the target database.
|
|
388
|
+
filenames: Archive filenames to ingest. Ingest all local archives if omitted.
|
|
389
|
+
verbose: Whether to log per-archive progress.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
A tuple of newly inserted and duplicate message counts.
|
|
393
|
+
"""
|
|
394
|
+
engine = create_engine(database_url)
|
|
395
|
+
Base.metadata.create_all(engine)
|
|
396
|
+
db = _SyncDatabase(sessionmaker(bind=engine))
|
|
397
|
+
if filenames is None:
|
|
398
|
+
archives = sorted(archive_dir.glob("*.tgz"))
|
|
399
|
+
else:
|
|
400
|
+
archives = sorted(archive_dir / filename for filename in filenames)
|
|
401
|
+
archives = [archive for archive in archives if archive.is_file()]
|
|
402
|
+
|
|
403
|
+
if verbose:
|
|
404
|
+
_logger.info("Found %d archives to ingest", len(archives))
|
|
405
|
+
|
|
406
|
+
total_inserted = 0
|
|
407
|
+
total_skipped = 0
|
|
408
|
+
for archive_path in archives:
|
|
409
|
+
if verbose:
|
|
410
|
+
_logger.info("Ingesting %s", archive_path.name)
|
|
411
|
+
try:
|
|
412
|
+
inserted, skipped = ingest_archive(archive_path, db)
|
|
413
|
+
except Exception:
|
|
414
|
+
if verbose:
|
|
415
|
+
_logger.exception("Failed to ingest %s", archive_path.name)
|
|
416
|
+
raise
|
|
417
|
+
|
|
418
|
+
total_inserted += inserted
|
|
419
|
+
total_skipped += skipped
|
|
420
|
+
if verbose:
|
|
421
|
+
_logger.info("Ingested %s (%d new, %d skipped)", archive_path.name, inserted, skipped)
|
|
422
|
+
|
|
423
|
+
if verbose:
|
|
424
|
+
_logger.info("Total emails ingested: %d", total_inserted)
|
|
425
|
+
_logger.info("Total emails skipped as duplicates: %d", total_skipped)
|
|
426
|
+
|
|
427
|
+
return total_inserted, total_skipped
|