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.
@@ -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