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,130 @@
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
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+ import platform
24
+ import sys
25
+ from dataclasses import dataclass
26
+ from importlib import metadata
27
+
28
+ _logger = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass
32
+ class _Variable:
33
+ """Dataclass describing an environment variable."""
34
+
35
+ name: str
36
+ """Variable name."""
37
+ value: str
38
+ """Variable value."""
39
+
40
+
41
+ @dataclass
42
+ class _Package:
43
+ """Dataclass describing a Python package."""
44
+
45
+ name: str
46
+ """Package name."""
47
+ version: str
48
+ """Package version."""
49
+
50
+
51
+ @dataclass
52
+ class _Environment:
53
+ """Dataclass to store environment information."""
54
+
55
+ interpreter_name: str
56
+ """Python interpreter name."""
57
+ interpreter_version: str
58
+ """Python interpreter version."""
59
+ interpreter_path: str
60
+ """Path to Python executable."""
61
+ platform: str
62
+ """Operating System."""
63
+ packages: list[_Package]
64
+ """Installed packages."""
65
+ variables: list[_Variable]
66
+ """Environment variables."""
67
+
68
+
69
+ def _interpreter_name_version() -> tuple[str, str]:
70
+ if hasattr(sys, "implementation"):
71
+ impl = sys.implementation.version
72
+ version = f"{impl.major}.{impl.minor}.{impl.micro}"
73
+ kind = impl.releaselevel
74
+ if kind != "final":
75
+ version += kind[0] + str(impl.serial)
76
+ return sys.implementation.name, version
77
+ return "", "0.0.0"
78
+
79
+
80
+ def _get_version(dist: str = "git-ew") -> str:
81
+ """Get version of the given distribution.
82
+
83
+ Parameters:
84
+ dist: A distribution name.
85
+
86
+ Returns:
87
+ A version number.
88
+ """
89
+ try:
90
+ return metadata.version(dist)
91
+ except metadata.PackageNotFoundError:
92
+ return "0.0.0"
93
+
94
+
95
+ def _get_debug_info() -> _Environment:
96
+ """Get debug/environment information.
97
+
98
+ Returns:
99
+ Environment information.
100
+ """
101
+ py_name, py_version = _interpreter_name_version()
102
+ packages = ["git-ew"]
103
+ variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("GIT_EW")]]
104
+ return _Environment(
105
+ interpreter_name=py_name,
106
+ interpreter_version=py_version,
107
+ interpreter_path=sys.executable,
108
+ platform=platform.platform(),
109
+ variables=[_Variable(var, val) for var in variables if (val := os.getenv(var))],
110
+ packages=[_Package(pkg, _get_version(pkg)) for pkg in packages],
111
+ )
112
+
113
+
114
+ def _format_debug_info() -> str:
115
+ """Format debug and environment information as Markdown."""
116
+ info = _get_debug_info()
117
+ lines = [
118
+ f"- __System__: {info.platform}",
119
+ f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})",
120
+ "- __Environment variables__:",
121
+ ]
122
+ lines.extend(f" - `{var.name}`: `{var.value}`" for var in info.variables)
123
+ lines.append("- __Installed packages__:")
124
+ lines.extend(f" - `{pkg.name}` v{pkg.version}" for pkg in info.packages)
125
+ return "\n".join(lines)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
130
+ _logger.info(_format_debug_info())
@@ -0,0 +1,301 @@
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
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import email
23
+ import imaplib
24
+ import logging
25
+ import mailbox
26
+ from datetime import date
27
+ from email import policy
28
+ from email.utils import getaddresses
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING, Any
31
+
32
+ from git_ew._internal.email_parser import ParsedEmail, parse_email
33
+ from git_ew._internal.secrets import resolve_password
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import AsyncIterator
37
+
38
+ _logger = logging.getLogger(__name__)
39
+
40
+
41
+ class EmailFetcher:
42
+ """Base class for email fetchers."""
43
+
44
+ def fetch_emails(self, since: str | None = None) -> AsyncIterator[ParsedEmail]:
45
+ """Fetch emails from the source.
46
+
47
+ Args:
48
+ since: Fetch emails since this date (ISO format).
49
+
50
+ Yields:
51
+ ParsedEmail objects.
52
+ """
53
+ raise NotImplementedError
54
+
55
+
56
+ class MaildirFetcher(EmailFetcher):
57
+ """Fetch emails from a Maildir directory."""
58
+
59
+ def __init__(self, maildir_path: str):
60
+ """Initialize Maildir fetcher.
61
+
62
+ Args:
63
+ maildir_path: Path to Maildir directory.
64
+ """
65
+ self.maildir_path = Path(maildir_path)
66
+ """Path to the Maildir directory."""
67
+
68
+ async def fetch_emails(self, since: str | None = None) -> AsyncIterator[ParsedEmail]: # noqa: ARG002
69
+ """Fetch emails from Maildir.
70
+
71
+ Args:
72
+ since: Fetch emails since this date (ISO format).
73
+
74
+ Yields:
75
+ ParsedEmail objects.
76
+ """
77
+ if not self.maildir_path.exists():
78
+ return
79
+
80
+ # Use mailbox.Maildir to read the maildir
81
+ mbox = mailbox.Maildir(str(self.maildir_path))
82
+ try:
83
+ for message in mbox:
84
+ try:
85
+ raw = message.as_bytes()
86
+ parsed = parse_email(raw)
87
+ yield parsed
88
+ except Exception as e: # noqa: BLE001
89
+ # Skip malformed emails
90
+ _logger.debug("Skipping malformed Maildir message: %s", e)
91
+ continue
92
+
93
+ # Allow event loop to process
94
+ await asyncio.sleep(0)
95
+ finally:
96
+ mbox.close()
97
+
98
+
99
+ class MboxFetcher(EmailFetcher):
100
+ """Fetch emails from an mbox file."""
101
+
102
+ def __init__(self, mbox_path: str):
103
+ """Initialize mbox fetcher.
104
+
105
+ Args:
106
+ mbox_path: Path to mbox file.
107
+ """
108
+ self.mbox_path = Path(mbox_path)
109
+ """Path to the mbox file."""
110
+
111
+ async def fetch_emails(self, since: str | None = None) -> AsyncIterator[ParsedEmail]: # noqa: ARG002
112
+ """Fetch emails from mbox.
113
+
114
+ Args:
115
+ since: Fetch emails since this date (ISO format).
116
+
117
+ Yields:
118
+ ParsedEmail objects.
119
+ """
120
+ if not self.mbox_path.exists():
121
+ return
122
+
123
+ mbox = mailbox.mbox(str(self.mbox_path))
124
+ try:
125
+ for message in mbox:
126
+ try:
127
+ raw = message.as_bytes()
128
+ parsed = parse_email(raw)
129
+ yield parsed
130
+ except Exception as e: # noqa: BLE001
131
+ # Skip malformed emails
132
+ _logger.debug("Skipping malformed mbox message: %s", e)
133
+ continue
134
+
135
+ # Allow event loop to process
136
+ await asyncio.sleep(0)
137
+ finally:
138
+ mbox.close()
139
+
140
+
141
+ class IMAPFetcher(EmailFetcher):
142
+ """Fetch emails from IMAP folders, including Fastmail accounts."""
143
+
144
+ def __init__(
145
+ self,
146
+ host: str,
147
+ username: str,
148
+ password: str,
149
+ *,
150
+ port: int = 993,
151
+ folders: list[str] | None = None,
152
+ mailing_list: dict[str, list[str]] | None = None,
153
+ ):
154
+ """Initialize an IMAP fetcher."""
155
+ self.host = host
156
+ """IMAP server hostname."""
157
+ self.port = port
158
+ """IMAP server port."""
159
+ self.username = username
160
+ """IMAP account username."""
161
+ self.password = password
162
+ """IMAP account password."""
163
+ self.folders = folders or ["INBOX", "Sent"]
164
+ """Folders to fetch messages from."""
165
+ self.mailing_list = mailing_list or {}
166
+ """Mailing list headers used to filter messages."""
167
+
168
+ async def fetch_emails(self, since: str | None = None) -> AsyncIterator[ParsedEmail]:
169
+ """Fetch and parse messages from configured IMAP folders."""
170
+ raw_messages = await asyncio.to_thread(self._fetch_messages, since)
171
+ for raw_message in raw_messages:
172
+ try:
173
+ yield parse_email(raw_message)
174
+ except Exception as e: # noqa: BLE001
175
+ _logger.debug("Skipping malformed IMAP message: %s", e)
176
+ await asyncio.sleep(0)
177
+
178
+ def _fetch_messages(self, since: str | None) -> list[bytes]:
179
+ """Fetch raw messages using a synchronous IMAP connection."""
180
+ messages: list[bytes] = []
181
+ with imaplib.IMAP4_SSL(self.host, self.port) as client:
182
+ client.login(self.username, self.password)
183
+ for folder in self.folders:
184
+ status, _ = client.select(folder, readonly=True)
185
+ if status != "OK":
186
+ raise RuntimeError(f"Unable to select IMAP folder {folder!r}")
187
+
188
+ criteria = "ALL"
189
+ if since:
190
+ since_date = date.fromisoformat(since).strftime("%d-%b-%Y")
191
+ criteria = f"SINCE {since_date}"
192
+ status, data = client.search(None, criteria)
193
+ if status != "OK":
194
+ raise RuntimeError(f"Unable to search IMAP folder {folder!r}")
195
+
196
+ for message_number in data[0].split():
197
+ if self.mailing_list:
198
+ status, fetched = client.fetch(message_number, "(BODY.PEEK[HEADER])")
199
+ if status != "OK":
200
+ _logger.warning("Unable to fetch headers for message %s from %s", message_number, folder)
201
+ continue
202
+ headers = b"".join(
203
+ item[1] for item in fetched if isinstance(item, tuple) and isinstance(item[1], bytes)
204
+ )
205
+ if not self._matches_mailing_list(headers):
206
+ continue
207
+
208
+ status, fetched = client.fetch(message_number, "(BODY.PEEK[])")
209
+ if status != "OK":
210
+ _logger.warning("Unable to fetch message %s from %s", message_number, folder)
211
+ continue
212
+ messages.extend(
213
+ item[1] for item in fetched if isinstance(item, tuple) and isinstance(item[1], bytes)
214
+ )
215
+ return messages
216
+
217
+ def _matches_mailing_list(self, raw_headers: bytes) -> bool:
218
+ """Return whether headers identify a configured mailing-list message."""
219
+ message = email.message_from_bytes(raw_headers, policy=policy.default)
220
+ addresses = {address.lower() for address in self.mailing_list.get("addresses", [])}
221
+ list_ids = {list_id.lower().strip("<>") for list_id in self.mailing_list.get("list_ids", [])}
222
+
223
+ if not addresses and not list_ids:
224
+ return True
225
+
226
+ address_headers = (
227
+ "To",
228
+ "Cc",
229
+ "Delivered-To",
230
+ "X-Original-To",
231
+ "Envelope-To",
232
+ "List-Post",
233
+ )
234
+ header_addresses = {
235
+ address.lower().removeprefix("mailto:")
236
+ for header in address_headers
237
+ for _, address in getaddresses(message.get_all(header, []))
238
+ if address
239
+ }
240
+ if addresses.intersection(header_addresses):
241
+ return True
242
+
243
+ return any(list_id in (message.get("List-Id", "").lower().strip("<>")) for list_id in list_ids)
244
+
245
+
246
+ class PublicInboxFetcher(EmailFetcher):
247
+ """Fetch emails from a public-inbox archive URL."""
248
+
249
+ def __init__(self, archive_url: str):
250
+ """Initialize public-inbox fetcher.
251
+
252
+ Args:
253
+ archive_url: Base URL of the public-inbox archive.
254
+ """
255
+ self.archive_url = archive_url.rstrip("/")
256
+ """Base URL of the public-inbox archive."""
257
+
258
+ def fetch_emails(self, since: str | None = None) -> AsyncIterator[ParsedEmail]:
259
+ """Fetch emails from public-inbox archive.
260
+
261
+ Args:
262
+ since: Fetch emails since this date (ISO format).
263
+
264
+ Yields:
265
+ ParsedEmail objects.
266
+ """
267
+ # This would require implementing HTTP requests to fetch mbox files
268
+ # from public-inbox archives. For now, this is a placeholder.
269
+ # Real implementation would use httpx to fetch and parse mbox files
270
+ raise NotImplementedError("Public-inbox fetching not yet implemented")
271
+
272
+
273
+ def get_fetcher(source_type: str, config: dict[str, Any]) -> EmailFetcher:
274
+ """Get an email fetcher based on source type.
275
+
276
+ Args:
277
+ source_type: Type of source (maildir, mbox, imap, public-inbox).
278
+ config: Configuration for the fetcher.
279
+
280
+ Returns:
281
+ EmailFetcher instance.
282
+
283
+ Raises:
284
+ ValueError: If source type is unknown.
285
+ """
286
+ if source_type == "maildir":
287
+ return MaildirFetcher(config["path"])
288
+ if source_type == "mbox":
289
+ return MboxFetcher(config["path"])
290
+ if source_type == "imap":
291
+ return IMAPFetcher(
292
+ host=config.get("host", "imap.fastmail.com"),
293
+ port=int(config.get("port", 993)),
294
+ username=config["username"],
295
+ password=resolve_password(config),
296
+ folders=config.get("folders"),
297
+ mailing_list=config.get("mailing_list"),
298
+ )
299
+ if source_type == "public-inbox":
300
+ return PublicInboxFetcher(config["url"])
301
+ raise ValueError(f"Unknown source type: {source_type}")
@@ -0,0 +1,274 @@
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 parsing utilities for git-ew.
20
+
21
+ from __future__ import annotations
22
+
23
+ import email
24
+ import re
25
+ from datetime import UTC, datetime
26
+ from email import policy
27
+ from email.utils import parseaddr, parsedate_to_datetime
28
+ from typing import TYPE_CHECKING
29
+
30
+ if TYPE_CHECKING:
31
+ from email.message import Message
32
+
33
+
34
+ class ParsedEmail:
35
+ """Represents a parsed email message."""
36
+
37
+ def __init__( # noqa: PLR0917
38
+ self,
39
+ message_id: str,
40
+ from_email: str,
41
+ from_name: str,
42
+ subject: str,
43
+ date: datetime,
44
+ body: str,
45
+ *,
46
+ in_reply_to: str | None = None,
47
+ references: list[str] | None = None,
48
+ is_patch: bool = False,
49
+ patch_content: str | None = None,
50
+ raw: str | None = None,
51
+ ):
52
+ """Initialize parsed email.
53
+
54
+ Args:
55
+ message_id: Unique message ID.
56
+ from_email: Sender email address.
57
+ from_name: Sender name.
58
+ subject: Email subject.
59
+ date: Email date.
60
+ body: Email body text.
61
+ in_reply_to: Message ID this replies to.
62
+ references: List of referenced message IDs.
63
+ is_patch: Whether this email contains a patch.
64
+ patch_content: Extracted patch content.
65
+ raw: Raw email content.
66
+ """
67
+ self.message_id = message_id
68
+ """Unique message ID."""
69
+ self.from_email = from_email
70
+ """Sender email address."""
71
+ self.from_name = from_name
72
+ """Sender name."""
73
+ self.subject = subject
74
+ """Email subject."""
75
+ self.date = date
76
+ """Email date."""
77
+ self.body = body
78
+ """Email body text."""
79
+ self.in_reply_to = in_reply_to
80
+ """Message ID this replies to."""
81
+ self.references = references or []
82
+ """List of referenced message IDs."""
83
+ self.is_patch = is_patch
84
+ """Whether this email contains a patch."""
85
+ self.patch_content = patch_content
86
+ """Extracted patch content."""
87
+ self.raw = raw
88
+ """Raw email content."""
89
+
90
+ def get_thread_id(self) -> str:
91
+ """Get the thread ID (first message in chain).
92
+
93
+ Returns:
94
+ Thread ID (the first message ID in the references chain).
95
+ """
96
+ if self.references:
97
+ return self.references[0]
98
+ if self.in_reply_to:
99
+ return self.in_reply_to
100
+ return self.message_id
101
+
102
+ @property
103
+ def clean_subject(self) -> str:
104
+ """Get subject with RE: and similar prefixes removed.
105
+
106
+ Returns:
107
+ Clean subject.
108
+ """
109
+ subject = self.subject
110
+ # Remove RE:, Re:, FWD:, Fwd: etc.
111
+ subject = re.sub(r"^(RE|Re|FW|Fw|FWD|Fwd):\s*", "", subject, flags=re.IGNORECASE)
112
+ subject = re.sub(r"^\[.*?\]\s*", "", subject) # Remove [tag] prefixes
113
+ return subject.strip()
114
+
115
+
116
+ def parse_email(raw_email: str | bytes) -> ParsedEmail:
117
+ """Parse a raw email message.
118
+
119
+ Args:
120
+ raw_email: Raw email content as string or bytes.
121
+
122
+ Returns:
123
+ ParsedEmail object.
124
+ """
125
+ raw_email_bytes = raw_email.encode("utf-8") if isinstance(raw_email, str) else raw_email
126
+
127
+ msg = email.message_from_bytes(raw_email_bytes, policy=policy.default)
128
+
129
+ # Extract basic headers
130
+ message_id = msg.get("Message-ID", "").strip("<>")
131
+ subject = msg.get("Subject", "No Subject")
132
+
133
+ # Parse from address
134
+ from_header = msg.get("From", "")
135
+ from_name, from_email = parseaddr(from_header)
136
+ if not from_name:
137
+ from_name = from_email
138
+
139
+ # Parse date
140
+ date_header = msg.get("Date")
141
+ try:
142
+ date = parsedate_to_datetime(date_header) if date_header else datetime.now(UTC)
143
+ except Exception: # noqa: BLE001
144
+ date = datetime.now(UTC)
145
+
146
+ # Get reply information
147
+ in_reply_to = msg.get("In-Reply-To", "").strip("<>") or None
148
+ references_header = msg.get("References", "")
149
+ references = [ref.strip("<>") for ref in references_header.split() if ref.strip("<>")]
150
+
151
+ body, patch_content = extract_body_and_patch(msg)
152
+ is_patch = bool(patch_content)
153
+
154
+ # Detect and extract patches
155
+ # Look for git diff format or unified diff format
156
+ patch_patterns = [
157
+ r"diff --git",
158
+ r"^---.*\n\+\+\+",
159
+ r"^Index:",
160
+ ]
161
+
162
+ for pattern in patch_patterns:
163
+ if re.search(pattern, body, re.MULTILINE):
164
+ is_patch = True
165
+ # Extract everything from the first diff marker
166
+ match = re.search(r"(diff --git.*|^---.*\n\+\+\+.*|^Index:.*)", body, re.MULTILINE | re.DOTALL)
167
+ if match:
168
+ patch_content = body[match.start() :]
169
+ break
170
+
171
+ # Also check for [PATCH] in subject
172
+ if "[PATCH]" in subject.upper() or "PATCH" in subject.upper():
173
+ is_patch = True
174
+
175
+ return ParsedEmail(
176
+ message_id=message_id,
177
+ from_email=from_email,
178
+ from_name=from_name,
179
+ subject=subject,
180
+ date=date,
181
+ body=body,
182
+ in_reply_to=in_reply_to,
183
+ references=references,
184
+ is_patch=is_patch,
185
+ patch_content=patch_content,
186
+ raw=raw_email_bytes.decode("utf-8", errors="replace"),
187
+ )
188
+
189
+
190
+ def _decode_part_payload(part: Message) -> str:
191
+ """Decode a MIME part payload to text."""
192
+ payload = part.get_payload(decode=True)
193
+ if isinstance(payload, bytes):
194
+ charset = part.get_content_charset() or "utf-8"
195
+ try:
196
+ return payload.decode(charset, errors="replace")
197
+ except (LookupError, TypeError):
198
+ return payload.decode("utf-8", errors="replace")
199
+ if isinstance(payload, str):
200
+ return payload
201
+ return ""
202
+
203
+
204
+ def extract_body_and_patch(message: Message) -> tuple[str, str | None]:
205
+ """Extract the plain-text body and any MIME patch attachment."""
206
+ body = ""
207
+ patch_content = None
208
+
209
+ for part in message.walk():
210
+ if part.is_multipart():
211
+ continue
212
+
213
+ content_type = part.get_content_type().lower()
214
+ filename = (part.get_filename() or "").lower()
215
+ content = _decode_part_payload(part)
216
+
217
+ if content_type in {
218
+ "text/x-patch",
219
+ "text/x-diff",
220
+ "application/x-patch",
221
+ "application/x-diff",
222
+ } or filename.endswith(
223
+ (".patch", ".diff"),
224
+ ):
225
+ if patch_content is None:
226
+ patch_content = content
227
+ elif content_type == "text/plain" and not body:
228
+ body = content
229
+
230
+ return body, patch_content
231
+
232
+
233
+ def extract_quoted_text(body: str) -> tuple[str, str]:
234
+ """Separate new content from quoted text.
235
+
236
+ Args:
237
+ body: Email body.
238
+
239
+ Returns:
240
+ Tuple of (new_content, quoted_content).
241
+ """
242
+ lines = body.split("\n")
243
+ new_lines = []
244
+ quoted_lines = []
245
+ in_quote = False
246
+
247
+ for line in lines:
248
+ stripped = line.strip()
249
+ # Common quote indicators
250
+ if stripped.startswith((">", "|")):
251
+ in_quote = True
252
+ quoted_lines.append(line)
253
+ elif stripped.startswith("On ") and " wrote:" in line:
254
+ # "On [date] [person] wrote:" pattern
255
+ in_quote = True
256
+ quoted_lines.append(line)
257
+ elif in_quote:
258
+ quoted_lines.append(line)
259
+ else:
260
+ new_lines.append(line)
261
+
262
+ return "\n".join(new_lines).strip(), "\n".join(quoted_lines).strip()
263
+
264
+
265
+ def normalize_message_id(message_id: str) -> str:
266
+ """Normalize a message ID by removing angle brackets.
267
+
268
+ Args:
269
+ message_id: Message ID.
270
+
271
+ Returns:
272
+ Normalized message ID.
273
+ """
274
+ return message_id.strip("<>")