liblore 0.8.0__tar.gz → 0.8.2__tar.gz
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.
- {liblore-0.8.0 → liblore-0.8.2}/PKG-INFO +1 -1
- {liblore-0.8.0 → liblore-0.8.2}/pyproject.toml +1 -1
- {liblore-0.8.0 → liblore-0.8.2}/src/liblore/__init__.py +1 -1
- {liblore-0.8.0 → liblore-0.8.2}/src/liblore/utils.py +20 -2
- {liblore-0.8.0 → liblore-0.8.2}/src/liblore.egg-info/PKG-INFO +1 -1
- liblore-0.8.2/tests/test_message.py +149 -0
- {liblore-0.8.0 → liblore-0.8.2}/tests/test_thread.py +51 -0
- liblore-0.8.0/tests/test_message.py +0 -66
- {liblore-0.8.0 → liblore-0.8.2}/LICENSES/GPL-2.0-or-later.txt +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/README.md +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/setup.cfg +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/src/liblore/node.py +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/src/liblore/py.typed +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/src/liblore.egg-info/SOURCES.txt +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/src/liblore.egg-info/dependency_links.txt +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/src/liblore.egg-info/requires.txt +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/src/liblore.egg-info/top_level.txt +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/tests/test_auth_headers.py +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/tests/test_cache.py +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/tests/test_email_utils.py +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/tests/test_formatting.py +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/tests/test_mbox.py +0 -0
- {liblore-0.8.0 → liblore-0.8.2}/tests/test_node.py +0 -0
|
@@ -79,10 +79,21 @@ def clean_header(hdrval: str | None) -> str:
|
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
def get_clean_msgid(msg: EmailMessage, header: str = 'Message-Id') -> str | None:
|
|
82
|
-
"""Extract a clean message ID (without angle brackets) from a header.
|
|
82
|
+
"""Extract a clean message ID (without angle brackets) from a header.
|
|
83
|
+
|
|
84
|
+
Message-IDs are always ASCII and enclosed in angle brackets, and
|
|
85
|
+
RFC 2047 forbids encoded words in msg-id headers (Message-Id,
|
|
86
|
+
In-Reply-To, References). We therefore extract straight from the
|
|
87
|
+
raw value and deliberately avoid :func:`clean_header`, whose
|
|
88
|
+
address-parsing path would mangle a bare message-ID when the header
|
|
89
|
+
also carries an encoded-word comment (e.g. Gnus-style In-Reply-To).
|
|
90
|
+
|
|
91
|
+
This mirrors :func:`_clean_msgid_raw`, which does the same on raw
|
|
92
|
+
message bytes.
|
|
93
|
+
"""
|
|
83
94
|
raw = msg.get(header)
|
|
84
95
|
if raw:
|
|
85
|
-
matches = re.search(r'<([^>]+)>',
|
|
96
|
+
matches = re.search(r'<([^>]+)>', str(raw))
|
|
86
97
|
if matches:
|
|
87
98
|
return matches.groups()[0]
|
|
88
99
|
return None
|
|
@@ -383,6 +394,13 @@ def get_strict_thread(
|
|
|
383
394
|
for hval in msg.get_all(hdr_name, []):
|
|
384
395
|
raw_refs += re.findall(r'<([^>]+)>', str(hval))
|
|
385
396
|
|
|
397
|
+
# Some tooling emits messages that reference their own Message-ID.
|
|
398
|
+
# A self-reference is never meaningful for threading, so drop it
|
|
399
|
+
# before it can be mistaken for a parent.
|
|
400
|
+
if c_msgid in raw_refs:
|
|
401
|
+
logger.debug('Ignoring self-reference in %s', c_msgid)
|
|
402
|
+
raw_refs = [ref for ref in raw_refs if ref != c_msgid]
|
|
403
|
+
|
|
386
404
|
# If noparent, pretend our target message has no references
|
|
387
405
|
if noparent and msgid == c_msgid:
|
|
388
406
|
logger.info('Breaking thread to remove parents of %s', msgid)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
2
|
+
"""Tests for liblore.message."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from email.message import EmailMessage
|
|
7
|
+
|
|
8
|
+
from conftest import MsgFactory
|
|
9
|
+
|
|
10
|
+
from liblore.utils import clean_header, get_clean_msgid, parse_message
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestCleanHeader:
|
|
14
|
+
def test_plain_header(self) -> None:
|
|
15
|
+
assert clean_header('Hello World') == 'Hello World'
|
|
16
|
+
|
|
17
|
+
def test_none_returns_empty(self) -> None:
|
|
18
|
+
assert clean_header(None) == ''
|
|
19
|
+
|
|
20
|
+
def test_rfc2047_encoded(self) -> None:
|
|
21
|
+
encoded = '=?utf-8?q?Caf=C3=A9?='
|
|
22
|
+
assert clean_header(encoded) == 'Caf\u00e9'
|
|
23
|
+
|
|
24
|
+
def test_folded_whitespace(self) -> None:
|
|
25
|
+
assert clean_header('Hello\n World') == 'Hello World'
|
|
26
|
+
|
|
27
|
+
def test_encoded_address(self) -> None:
|
|
28
|
+
# formataddr re-encodes Unicode names, so we just check
|
|
29
|
+
# the address is preserved and the result is valid
|
|
30
|
+
hdr = '=?utf-8?q?=C3=89ric?= <eric@example.com>'
|
|
31
|
+
result = clean_header(hdr)
|
|
32
|
+
assert 'eric@example.com' in result
|
|
33
|
+
|
|
34
|
+
def test_special_chars_in_name(self) -> None:
|
|
35
|
+
hdr = '=?utf-8?q?O=27Brien=2C_Joe?= <joe@example.com>'
|
|
36
|
+
result = clean_header(hdr)
|
|
37
|
+
assert 'joe@example.com' in result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestGetCleanMsgid:
|
|
41
|
+
def test_extracts_msgid(self, make_msg: MsgFactory) -> None:
|
|
42
|
+
msg = make_msg(msgid='test123@example.com')
|
|
43
|
+
assert get_clean_msgid(msg) == 'test123@example.com'
|
|
44
|
+
|
|
45
|
+
def test_missing_header(self) -> None:
|
|
46
|
+
msg = EmailMessage()
|
|
47
|
+
assert get_clean_msgid(msg) is None
|
|
48
|
+
|
|
49
|
+
def test_custom_header(self, make_msg: MsgFactory) -> None:
|
|
50
|
+
msg = make_msg(in_reply_to='parent@example.com')
|
|
51
|
+
assert get_clean_msgid(msg, 'In-Reply-To') == 'parent@example.com'
|
|
52
|
+
|
|
53
|
+
def test_in_reply_to_with_encoded_comment(self) -> None:
|
|
54
|
+
"""Gnus appends an RFC 2047 encoded comment after the message-ID.
|
|
55
|
+
|
|
56
|
+
The encoded-word comment must not send us down clean_header's
|
|
57
|
+
address-parsing path, which would destroy the message-ID.
|
|
58
|
+
"""
|
|
59
|
+
msg = EmailMessage()
|
|
60
|
+
msg['In-Reply-To'] = (
|
|
61
|
+
'<patch-v1-1-abc123@example.com>'
|
|
62
|
+
' (=?utf-8?Q?=22Marc-Andr=C3=A9?= Lureau"\'s'
|
|
63
|
+
' message of "Mon, 20 Jul 2026 11:55:30 +0400")'
|
|
64
|
+
)
|
|
65
|
+
assert get_clean_msgid(msg, 'In-Reply-To') == 'patch-v1-1-abc123@example.com'
|
|
66
|
+
|
|
67
|
+
def test_in_reply_to_with_encoded_comment_parsed(self) -> None:
|
|
68
|
+
"""Same case, but via a message parsed under the modern policy."""
|
|
69
|
+
raw = (
|
|
70
|
+
b'From: x@example.com\r\n'
|
|
71
|
+
b'Message-Id: <child@example.com>\r\n'
|
|
72
|
+
b'In-Reply-To: <patch-v1-1-abc123@example.com>'
|
|
73
|
+
b' (=?utf-8?Q?=22Marc-Andr=C3=A9?= Lureau"\'s'
|
|
74
|
+
b' message of "Mon, 20 Jul 2026 11:55:30 +0400")\r\n'
|
|
75
|
+
b'\r\nbody\r\n'
|
|
76
|
+
)
|
|
77
|
+
msg = parse_message(raw)
|
|
78
|
+
assert get_clean_msgid(msg, 'In-Reply-To') == 'patch-v1-1-abc123@example.com'
|
|
79
|
+
|
|
80
|
+
# --- Non-compliant / malformed headers ---------------------------------
|
|
81
|
+
# These document how extraction copes with headers that violate
|
|
82
|
+
# RFC 5322. The contract is deliberately simple: we return the
|
|
83
|
+
# contents of the first ``<...>`` pair, or None when there isn't one.
|
|
84
|
+
|
|
85
|
+
def test_missing_angle_brackets(self) -> None:
|
|
86
|
+
"""A bare address without angle brackets yields no message-ID."""
|
|
87
|
+
msg = EmailMessage()
|
|
88
|
+
msg['Message-Id'] = 'bare@example.com'
|
|
89
|
+
assert get_clean_msgid(msg) is None
|
|
90
|
+
|
|
91
|
+
def test_empty_angle_brackets(self) -> None:
|
|
92
|
+
"""An empty ``<>`` has nothing to extract."""
|
|
93
|
+
msg = EmailMessage()
|
|
94
|
+
msg['In-Reply-To'] = '<>'
|
|
95
|
+
assert get_clean_msgid(msg, 'In-Reply-To') is None
|
|
96
|
+
|
|
97
|
+
def test_no_at_sign_inside_brackets(self) -> None:
|
|
98
|
+
"""We trust the angle brackets and do not require an ``@``."""
|
|
99
|
+
msg = EmailMessage()
|
|
100
|
+
msg['Message-Id'] = '<garbage-no-at>'
|
|
101
|
+
assert get_clean_msgid(msg) == 'garbage-no-at'
|
|
102
|
+
|
|
103
|
+
def test_leading_comment_before_id(self) -> None:
|
|
104
|
+
"""A comment preceding the message-ID must not confuse extraction."""
|
|
105
|
+
msg = EmailMessage()
|
|
106
|
+
msg['In-Reply-To'] = '(sent earlier) <parent@example.com>'
|
|
107
|
+
assert get_clean_msgid(msg, 'In-Reply-To') == 'parent@example.com'
|
|
108
|
+
|
|
109
|
+
def test_multiple_ids_returns_first(self) -> None:
|
|
110
|
+
"""Some broken clients cram several IDs into In-Reply-To."""
|
|
111
|
+
msg = EmailMessage()
|
|
112
|
+
msg['In-Reply-To'] = '<first@example.com> <second@example.com>'
|
|
113
|
+
assert get_clean_msgid(msg, 'In-Reply-To') == 'first@example.com'
|
|
114
|
+
|
|
115
|
+
def test_rfc2047_encoded_msgid(self) -> None:
|
|
116
|
+
"""A nonconformant fully RFC 2047-encoded ``<foo@bar>``.
|
|
117
|
+
|
|
118
|
+
RFC 2047 forbids encoded words in msg-id headers, but if one
|
|
119
|
+
shows up the modern EmailPolicy decodes it on read, so we still
|
|
120
|
+
recover the ID -- no clean_header fallback required.
|
|
121
|
+
"""
|
|
122
|
+
msg = EmailMessage()
|
|
123
|
+
msg['Message-Id'] = '=?utf-8?q?=3Cfoo=40bar=3E?='
|
|
124
|
+
assert get_clean_msgid(msg) == 'foo@bar'
|
|
125
|
+
|
|
126
|
+
def test_rfc2047_encoded_msgid_parsed(self) -> None:
|
|
127
|
+
"""Same nonconformant case, parsed under the real policy."""
|
|
128
|
+
raw = (
|
|
129
|
+
b'From: x@example.com\r\n'
|
|
130
|
+
b'Message-Id: =?utf-8?q?=3Cfoo=40bar=3E?=\r\n'
|
|
131
|
+
b'\r\nbody\r\n'
|
|
132
|
+
)
|
|
133
|
+
msg = parse_message(raw)
|
|
134
|
+
assert get_clean_msgid(msg) == 'foo@bar'
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TestParseMessage:
|
|
138
|
+
def test_roundtrip(self) -> None:
|
|
139
|
+
raw = (
|
|
140
|
+
b'From: test@example.com\r\n'
|
|
141
|
+
b'Subject: Test\r\n'
|
|
142
|
+
b'Message-Id: <test@example.com>\r\n'
|
|
143
|
+
b'\r\n'
|
|
144
|
+
b'Body text\r\n'
|
|
145
|
+
)
|
|
146
|
+
msg = parse_message(raw)
|
|
147
|
+
assert isinstance(msg, EmailMessage)
|
|
148
|
+
assert msg['Subject'] == 'Test'
|
|
149
|
+
assert msg['From'] == 'test@example.com'
|
|
@@ -47,6 +47,57 @@ class TestGetStrictThread:
|
|
|
47
47
|
assert 'child@x.com' in ids
|
|
48
48
|
assert 'grandchild@x.com' in ids
|
|
49
49
|
|
|
50
|
+
def test_noparent_self_referencing_cover(self, make_msg: MsgFactory) -> None:
|
|
51
|
+
# Some tooling emits a cover letter whose In-Reply-To/References point
|
|
52
|
+
# at its own Message-ID. Breaking the thread there must not treat the
|
|
53
|
+
# cover's own msgid as a reference to ignore, or every patch that
|
|
54
|
+
# legitimately replies to the cover gets dropped.
|
|
55
|
+
cover = make_msg(
|
|
56
|
+
msgid='cover@x.com',
|
|
57
|
+
subject='[PATCH 0/2] Something',
|
|
58
|
+
in_reply_to='cover@x.com',
|
|
59
|
+
references=['cover@x.com'],
|
|
60
|
+
)
|
|
61
|
+
patch1 = make_msg(
|
|
62
|
+
msgid='p1@x.com',
|
|
63
|
+
subject='[PATCH 1/2] First',
|
|
64
|
+
in_reply_to='cover@x.com',
|
|
65
|
+
references=['cover@x.com'],
|
|
66
|
+
)
|
|
67
|
+
patch2 = make_msg(
|
|
68
|
+
msgid='p2@x.com',
|
|
69
|
+
subject='[PATCH 2/2] Second',
|
|
70
|
+
in_reply_to='cover@x.com',
|
|
71
|
+
references=['cover@x.com'],
|
|
72
|
+
)
|
|
73
|
+
review = make_msg(
|
|
74
|
+
msgid='review@x.com',
|
|
75
|
+
subject='Re: [PATCH 2/2] Second',
|
|
76
|
+
in_reply_to='p2@x.com',
|
|
77
|
+
references=['cover@x.com', 'p2@x.com'],
|
|
78
|
+
)
|
|
79
|
+
want = {'cover@x.com', 'p1@x.com', 'p2@x.com', 'review@x.com'}
|
|
80
|
+
# Cover first in the list is the worst case: without the fix only the
|
|
81
|
+
# cover survives. Ordering must not matter, so check both.
|
|
82
|
+
for msgs in ([cover, patch1, patch2, review], [patch1, cover, patch2, review]):
|
|
83
|
+
result = get_strict_thread(msgs, 'cover@x.com', noparent=True)
|
|
84
|
+
assert result is not None
|
|
85
|
+
assert {get_clean_msgid(m) for m in result} == want
|
|
86
|
+
|
|
87
|
+
def test_self_reference_is_not_a_parent(self, make_msg: MsgFactory) -> None:
|
|
88
|
+
# A self-referencing message must not be treated as a reply to itself,
|
|
89
|
+
# even without noparent: it is a thread root, and starting the walk at
|
|
90
|
+
# its child still has to pull it in.
|
|
91
|
+
root = make_msg(
|
|
92
|
+
msgid='root@x.com',
|
|
93
|
+
in_reply_to='root@x.com',
|
|
94
|
+
references=['root@x.com'],
|
|
95
|
+
)
|
|
96
|
+
reply = make_msg(msgid='reply@x.com', in_reply_to='root@x.com')
|
|
97
|
+
result = get_strict_thread([root, reply], 'reply@x.com')
|
|
98
|
+
assert result is not None
|
|
99
|
+
assert {get_clean_msgid(m) for m in result} == {'root@x.com', 'reply@x.com'}
|
|
100
|
+
|
|
50
101
|
def test_references_chain(self, make_msg: MsgFactory) -> None:
|
|
51
102
|
msg1 = make_msg(msgid='a@x.com')
|
|
52
103
|
msg2 = make_msg(msgid='b@x.com', references=['a@x.com'])
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
2
|
-
"""Tests for liblore.message."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
from email.message import EmailMessage
|
|
7
|
-
|
|
8
|
-
from conftest import MsgFactory
|
|
9
|
-
|
|
10
|
-
from liblore.utils import clean_header, get_clean_msgid, parse_message
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class TestCleanHeader:
|
|
14
|
-
def test_plain_header(self) -> None:
|
|
15
|
-
assert clean_header('Hello World') == 'Hello World'
|
|
16
|
-
|
|
17
|
-
def test_none_returns_empty(self) -> None:
|
|
18
|
-
assert clean_header(None) == ''
|
|
19
|
-
|
|
20
|
-
def test_rfc2047_encoded(self) -> None:
|
|
21
|
-
encoded = '=?utf-8?q?Caf=C3=A9?='
|
|
22
|
-
assert clean_header(encoded) == 'Caf\u00e9'
|
|
23
|
-
|
|
24
|
-
def test_folded_whitespace(self) -> None:
|
|
25
|
-
assert clean_header('Hello\n World') == 'Hello World'
|
|
26
|
-
|
|
27
|
-
def test_encoded_address(self) -> None:
|
|
28
|
-
# formataddr re-encodes Unicode names, so we just check
|
|
29
|
-
# the address is preserved and the result is valid
|
|
30
|
-
hdr = '=?utf-8?q?=C3=89ric?= <eric@example.com>'
|
|
31
|
-
result = clean_header(hdr)
|
|
32
|
-
assert 'eric@example.com' in result
|
|
33
|
-
|
|
34
|
-
def test_special_chars_in_name(self) -> None:
|
|
35
|
-
hdr = '=?utf-8?q?O=27Brien=2C_Joe?= <joe@example.com>'
|
|
36
|
-
result = clean_header(hdr)
|
|
37
|
-
assert 'joe@example.com' in result
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class TestGetCleanMsgid:
|
|
41
|
-
def test_extracts_msgid(self, make_msg: MsgFactory) -> None:
|
|
42
|
-
msg = make_msg(msgid='test123@example.com')
|
|
43
|
-
assert get_clean_msgid(msg) == 'test123@example.com'
|
|
44
|
-
|
|
45
|
-
def test_missing_header(self) -> None:
|
|
46
|
-
msg = EmailMessage()
|
|
47
|
-
assert get_clean_msgid(msg) is None
|
|
48
|
-
|
|
49
|
-
def test_custom_header(self, make_msg: MsgFactory) -> None:
|
|
50
|
-
msg = make_msg(in_reply_to='parent@example.com')
|
|
51
|
-
assert get_clean_msgid(msg, 'In-Reply-To') == 'parent@example.com'
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class TestParseMessage:
|
|
55
|
-
def test_roundtrip(self) -> None:
|
|
56
|
-
raw = (
|
|
57
|
-
b'From: test@example.com\r\n'
|
|
58
|
-
b'Subject: Test\r\n'
|
|
59
|
-
b'Message-Id: <test@example.com>\r\n'
|
|
60
|
-
b'\r\n'
|
|
61
|
-
b'Body text\r\n'
|
|
62
|
-
)
|
|
63
|
-
msg = parse_message(raw)
|
|
64
|
-
assert isinstance(msg, EmailMessage)
|
|
65
|
-
assert msg['Subject'] == 'Test'
|
|
66
|
-
assert msg['From'] == 'test@example.com'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|