emailsec 0.1.0__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.
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1 @@
1
+ 3.13
File without changes
emailsec-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Thomas Sileo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: emailsec
3
+ Version: 0.1.0
4
+ Summary: SPF (Sender Policy Framework) and DKIM parser and checker.
5
+ Author-email: Thomas Sileo <thomas.sileo@sent.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: Python
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: aiodns>=3.2.0
12
+ Requires-Dist: pyparsing>=3.2.3
13
+ Description-Content-Type: text/markdown
14
+
15
+ # emailsec
16
+
17
+ `emailsec` is a Python library that provides tools to parse and verify SPF (Sender Policy Framework) and DKIM (DomainKeys Identified Mail) records.
18
+
19
+ **This project is still in early development.**
20
+
21
+ ## Sender Policy Framework
22
+
23
+ [RFC 7208](https://datatracker.ietf.org/doc/html/rfc7208)-compliant parser and checker.
24
+
25
+ ### Parser
26
+
27
+ ```
28
+ >>> from emailsec.spf.parser import parse_record
29
+ >>> parse_record("v=spf1 +a mx/30 mx:example.org/30 -all")
30
+ [A(qualifier=<Qualifier.PASS: '+'>, domain_spec=None, cidr=None),
31
+ MX(qualifier=<Qualifier.PASS: '+'>, domain_spec=None, cidr='/30'),
32
+ MX(qualifier=<Qualifier.PASS: '+'>, domain_spec='example.org', cidr='/30'),
33
+ All(qualifier=<Qualifier.FAIL: '-'>)]
34
+ ```
35
+
36
+ ### Checker
37
+
38
+ ```
39
+ >>> import asyncio
40
+ >>> from emailsec.spf.checker import check_host
41
+ >>> asyncio.run(check_host(ip="192.0.2.10", sender="hello@example.com"))
42
+ (<Result.PASS: 'pass'>, '')
43
+ ```
44
+
45
+ ## Contribution
46
+
47
+ Contributions are welcome but please open an issue to start a discussion before starting something consequent.
48
+
49
+ ## License
50
+
51
+ Copyright (c) 2025 Thomas Sileo and contributors. Released under the MIT license.
@@ -0,0 +1,37 @@
1
+ # emailsec
2
+
3
+ `emailsec` is a Python library that provides tools to parse and verify SPF (Sender Policy Framework) and DKIM (DomainKeys Identified Mail) records.
4
+
5
+ **This project is still in early development.**
6
+
7
+ ## Sender Policy Framework
8
+
9
+ [RFC 7208](https://datatracker.ietf.org/doc/html/rfc7208)-compliant parser and checker.
10
+
11
+ ### Parser
12
+
13
+ ```
14
+ >>> from emailsec.spf.parser import parse_record
15
+ >>> parse_record("v=spf1 +a mx/30 mx:example.org/30 -all")
16
+ [A(qualifier=<Qualifier.PASS: '+'>, domain_spec=None, cidr=None),
17
+ MX(qualifier=<Qualifier.PASS: '+'>, domain_spec=None, cidr='/30'),
18
+ MX(qualifier=<Qualifier.PASS: '+'>, domain_spec='example.org', cidr='/30'),
19
+ All(qualifier=<Qualifier.FAIL: '-'>)]
20
+ ```
21
+
22
+ ### Checker
23
+
24
+ ```
25
+ >>> import asyncio
26
+ >>> from emailsec.spf.checker import check_host
27
+ >>> asyncio.run(check_host(ip="192.0.2.10", sender="hello@example.com"))
28
+ (<Result.PASS: 'pass'>, '')
29
+ ```
30
+
31
+ ## Contribution
32
+
33
+ Contributions are welcome but please open an issue to start a discussion before starting something consequent.
34
+
35
+ ## License
36
+
37
+ Copyright (c) 2025 Thomas Sileo and contributors. Released under the MIT license.
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "emailsec"
3
+ version = "0.1.0"
4
+ description = "SPF (Sender Policy Framework) and DKIM parser and checker."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Thomas Sileo", email = "thomas.sileo@sent.com" }
10
+ ]
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "aiodns>=3.2.0",
14
+ "pyparsing>=3.2.3",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Programming Language :: Python"
19
+ ]
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "mypy>=1.15.0",
28
+ "pytest>=8.3.5",
29
+ "pytest-asyncio>=0.26.0",
30
+ "pyyaml>=6.0.2",
31
+ ]
32
+
33
+ [tool.hatch.build.targets.sdist]
34
+ exclude = [
35
+ ".build.yml",
36
+ "uv.lock",
37
+ "tests/",
38
+ ]
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from spf-checker!"
@@ -0,0 +1,48 @@
1
+ import asyncio
2
+ import typing
3
+
4
+ from emailsec import errors
5
+
6
+ import aiodns
7
+ import pycares
8
+
9
+ _MAX_LOOKUPS = 10
10
+
11
+
12
+ class DNSResolver:
13
+ def __init__(self) -> None:
14
+ self.__lookups_counter = 0
15
+ self.__resolver = aiodns.DNSResolver(loop=asyncio.get_event_loop())
16
+
17
+ async def _query(self, name: str, query_type: str) -> typing.Any:
18
+ self.__lookups_counter += 1
19
+ if self.__lookups_counter > _MAX_LOOKUPS:
20
+ raise errors.Permerror("Max DNS lookups exceeded")
21
+
22
+ try:
23
+ return await self.__resolver.query(name, query_type)
24
+ except aiodns.error.DNSError as dns_error:
25
+ print(dns_error)
26
+ error_msg = dns_error.args[1]
27
+ match dns_error.args[0]:
28
+ case aiodns.error.ARES_ENOTFOUND: # type: ignore
29
+ return None
30
+ case aiodns.error.ARES_EBADQUERY: # type: ignore
31
+ raise errors.Permerror(f"DNS Error: {error_msg}")
32
+ case _:
33
+ raise errors.Temperror(f"DNS error: {error_msg}")
34
+
35
+ async def txt(self, name: str) -> list[pycares.ares_query_txt_result] | None:
36
+ return await self._query(name, "TXT")
37
+
38
+ async def mx(self, name: str) -> list[pycares.ares_query_mx_result] | None:
39
+ return await self._query(name, "MX")
40
+
41
+ async def a(self, name: str) -> list[pycares.ares_query_a_result] | None:
42
+ return await self._query(name, "A")
43
+
44
+ async def aaaa(self, name: str) -> list[pycares.ares_query_aaaa_result] | None:
45
+ return await self._query(name, "AAAA")
46
+
47
+ async def ptr(self, name: str) -> pycares.ares_query_ptr_result | None:
48
+ return await self._query(name, "PTR")
@@ -0,0 +1,10 @@
1
+ class SPFCheckerError(Exception):
2
+ """Base errror."""
3
+
4
+
5
+ class Permerror(Exception):
6
+ pass
7
+
8
+
9
+ class Temperror(Exception):
10
+ pass
File without changes
@@ -0,0 +1,236 @@
1
+ import typing
2
+ import enum
3
+ from pycares import ares_query_a_result, ares_query_aaaa_result
4
+ import ipaddress
5
+ from emailsec.spf import parser
6
+ from emailsec.spf.parser import parse_record, Qualifier
7
+ from emailsec.spf.expander import Expander
8
+ from emailsec import errors
9
+ from emailsec.dns_resolver import DNSResolver
10
+
11
+ IPVersion = typing.Literal[4, 6]
12
+
13
+
14
+ class Result(enum.StrEnum):
15
+ NONE = "none"
16
+ NEUTRAL = "neutral"
17
+ PASS = "pass"
18
+ FAIL = "fail"
19
+ SOFTFAIL = "softfail"
20
+ TEMPERROR = "temperror"
21
+ PERMERROR = "permerror"
22
+
23
+ @classmethod
24
+ def from_qualifier(cls, q: Qualifier) -> "Result":
25
+ match q:
26
+ case Qualifier.PASS:
27
+ return cls.PASS
28
+ case Qualifier.FAIL:
29
+ return cls.FAIL
30
+ case Qualifier.SOFTFAIL:
31
+ return cls.SOFTFAIL
32
+ case Qualifier.NEUTRAL:
33
+ return cls.NEUTRAL
34
+
35
+
36
+ async def _ip_networks_from_a_records(
37
+ dns_resolver: DNSResolver, name: str, ip_version: IPVersion, cidr: str | None = None
38
+ ) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
39
+ ip_networks = []
40
+
41
+ query_results: list[ares_query_a_result] | list[ares_query_aaaa_result] | None
42
+ if ip_version == 4:
43
+ query_results = await dns_resolver.a(name)
44
+ elif ip_version == 6:
45
+ query_results = await dns_resolver.aaaa(name)
46
+
47
+ if not query_results:
48
+ return []
49
+
50
+ for qres in query_results:
51
+ print(f"{qres.host}/{cidr=}")
52
+ ip_networks.append(ipaddress.ip_network(qres.host + (cidr or ""), False))
53
+
54
+ return ip_networks
55
+
56
+
57
+ async def check_host(ip: str, sender: str) -> tuple[Result, str]:
58
+ parsed_ip = ipaddress.ip_address(ip)
59
+ if "@" not in sender:
60
+ sender = f"postmaster@{sender}"
61
+ domain = sender.split("@", maxsplit=1)[-1]
62
+
63
+ dns_resolver = DNSResolver()
64
+
65
+ try:
66
+ res, explanation = await _rec_check_host(
67
+ dns_resolver, parsed_ip, sender, domain
68
+ )
69
+ except errors.Permerror as error:
70
+ return Result.PERMERROR, error.args[0]
71
+ except errors.Temperror as error:
72
+ return Result.TEMPERROR, error.args[0]
73
+ else:
74
+ return res, explanation
75
+
76
+
77
+ async def _rec_check_host(
78
+ dns_resolver: DNSResolver,
79
+ parsed_ip: ipaddress.IPv4Address | ipaddress.IPv6Address,
80
+ sender: str,
81
+ domain: str,
82
+ recursion=0,
83
+ ) -> tuple[Result, str]:
84
+ if recursion > 10:
85
+ raise errors.Permerror("Too much recursion")
86
+
87
+ end_result = None
88
+
89
+ expander = Expander(str(parsed_ip), sender, domain=domain)
90
+
91
+ spfs = []
92
+ txt_records = await dns_resolver.txt(domain)
93
+ if not txt_records:
94
+ return Result.NONE, ""
95
+ for txt_record in txt_records:
96
+ if txt_record.text.lower().startswith("v=spf1 "):
97
+ spfs.append(txt_record.text)
98
+ print(spfs)
99
+ if len(spfs) > 1:
100
+ raise errors.Permerror("Too many SPF records")
101
+
102
+ try:
103
+ parsed_spf = parse_record(spfs[0])
104
+ except ValueError:
105
+ raise errors.Permerror(f"Failed to parse SPF record {spfs[0]!r}")
106
+
107
+ modifiers = {}
108
+ for mechanism in parsed_spf:
109
+ match mechanism:
110
+ case parser.Modifier(name=name, value=value):
111
+ modifiers[name] = value
112
+
113
+ case parser.All(qualifier=qualifier):
114
+ end_result = Result.from_qualifier(qualifier)
115
+ break
116
+
117
+ case parser.Include():
118
+ target_name = expander.expand(mechanism.domain_spec)
119
+ # Recursive evaluation
120
+ # https://datatracker.ietf.org/doc/html/rfc7208#section-5.2
121
+ rec_result, _ = await _rec_check_host(
122
+ dns_resolver=dns_resolver,
123
+ parsed_ip=parsed_ip,
124
+ sender=sender,
125
+ domain=target_name,
126
+ recursion=recursion + 1,
127
+ )
128
+ match rec_result:
129
+ case Result.PASS:
130
+ end_result = rec_result
131
+ break
132
+ case Result.FAIL | Result.SOFTFAIL | Result.NEUTRAL:
133
+ continue
134
+ case Result.TEMPERROR | Result.PERMERROR | Result.NONE:
135
+ end_result = Result.PERMERROR
136
+ break
137
+
138
+ case parser.Exists():
139
+ target_name = expander.expand(mechanism.domain_spec)
140
+ if await dns_resolver.a(target_name):
141
+ end_result = Result.from_qualifier(mechanism.qualifier)
142
+ break
143
+
144
+ case parser.A():
145
+ target_name = (
146
+ expander.expand(mechanism.domain_spec)
147
+ if mechanism.domain_spec
148
+ else domain
149
+ )
150
+ ip_networks = await _ip_networks_from_a_records(
151
+ dns_resolver, target_name, parsed_ip.version, cidr=mechanism.cidr
152
+ )
153
+ if any(parsed_ip in ip_network for ip_network in ip_networks):
154
+ end_result = Result.from_qualifier(mechanism.qualifier)
155
+ break
156
+
157
+ case parser.IP4() | parser.IP6():
158
+ if parsed_ip in mechanism.ip_network:
159
+ end_result = Result.from_qualifier(mechanism.qualifier)
160
+ break
161
+
162
+ case parser.MX():
163
+ target_name = (
164
+ expander.expand(mechanism.domain_spec)
165
+ if mechanism.domain_spec
166
+ else domain
167
+ )
168
+ for mx_record in (await dns_resolver.mx(target_name)) or []:
169
+ ip_networks = await _ip_networks_from_a_records(
170
+ dns_resolver,
171
+ mx_record.host,
172
+ parsed_ip.version,
173
+ cidr=mechanism.cidr,
174
+ )
175
+ print(
176
+ f"MX {parsed_spf=} {mx_record=}/{target_name=}/{ip_networks=}/{parsed_ip=}"
177
+ )
178
+ if any(parsed_ip in ip_network for ip_network in ip_networks):
179
+ end_result = Result.from_qualifier(mechanism.qualifier)
180
+ break
181
+
182
+ if end_result:
183
+ break
184
+
185
+ case parser.PTR():
186
+ target_name = (
187
+ expander.expand(mechanism.domain_spec)
188
+ if mechanism.domain_spec
189
+ else domain
190
+ )
191
+ ptr_record = await dns_resolver.ptr(parsed_ip.reverse_pointer)
192
+ if not ptr_record:
193
+ continue
194
+ validated_domains = []
195
+ for alias in ptr_record.aliases[:10]:
196
+ ip_networks = await _ip_networks_from_a_records(
197
+ dns_resolver, alias, parsed_ip.version
198
+ )
199
+ if any(parsed_ip in ip_network for ip_network in ip_networks):
200
+ validated_domains.append(alias)
201
+
202
+ if target_name in validated_domains or any(
203
+ target_name.endswith(validated_domain)
204
+ for validated_domain in validated_domains
205
+ ):
206
+ end_result = Result.from_qualifier(mechanism.qualifier)
207
+ break
208
+
209
+ if end_result is None and "redirect" in modifiers:
210
+ # At this point, there's no "all" mechanism or it would have returned
211
+ # while processing it
212
+ rec_result, _ = await _rec_check_host(
213
+ dns_resolver=dns_resolver,
214
+ parsed_ip=parsed_ip,
215
+ sender=sender,
216
+ domain=expander.expand(modifiers["redirect"]),
217
+ recursion=recursion + 1,
218
+ )
219
+
220
+ explanation = ""
221
+ if end_result is Result.FAIL and "exp" in modifiers:
222
+ target_name = expander.expand(modifiers["exp"])
223
+ try:
224
+ txt_records = await dns_resolver.txt(target_name)
225
+ except Exception:
226
+ pass
227
+ else:
228
+ if txt_records:
229
+ explanation = "".join(txt_record.text for txt_record in txt_records)
230
+
231
+ # TODO: if result is "fail" look for the exp modifier
232
+
233
+ if end_result:
234
+ return end_result, explanation
235
+ else:
236
+ return Result.NEUTRAL, ""
@@ -0,0 +1,98 @@
1
+ """Macros related helpers as defined in RFC7208:
2
+ https://datatracker.ietf.org/doc/html/rfc7208#section-7"""
3
+
4
+ import time
5
+ import ipaddress
6
+ import re
7
+
8
+ macro_string_regex = re.compile(
9
+ (
10
+ r"%{(?P<letter>[slodipvhcrt])"
11
+ r"(?P<count>(\d+))?"
12
+ r"(?P<reverse>(r))?"
13
+ r"(?P<delimiter>([\.\-\+,\/_=]))?}"
14
+ ),
15
+ re.IGNORECASE,
16
+ )
17
+
18
+
19
+ class Expander:
20
+ def __init__(
21
+ self, ip: str, sender: str, domain: str | None = None, exp_mode: bool = False
22
+ ) -> None:
23
+ self.ip = ip
24
+ self.parsed_ip = ipaddress.ip_address(ip)
25
+ self.domain = domain if domain else sender.split("@", maxsplit=1)[-1]
26
+ self.sender = sender
27
+ self.exp_mode = exp_mode
28
+
29
+ def expand(self, value: str) -> str:
30
+ if "%" not in value:
31
+ return value
32
+
33
+ # Handle special cases first
34
+ value = value.replace("%_", " ").replace("%-", "%20").replace("%%", "%")
35
+
36
+ # Then look for regular macro strings
37
+ for match in re.finditer(macro_string_regex, value):
38
+ gd = match.groupdict()
39
+ res = self.expand_letter(gd["letter"])
40
+ if gd["reverse"] or gd["count"] or gd["delimiter"]:
41
+ delimiter = gd["delimiter"] or "."
42
+ parts = res.split(delimiter)
43
+
44
+ if gd["reverse"]:
45
+ parts = parts[::-1]
46
+
47
+ count = int(gd["count"]) if gd["count"] else len(parts)
48
+ res = ".".join(parts[-count:])
49
+
50
+ value = value.replace(match.group(), res, 1)
51
+
52
+ return value
53
+
54
+ def expand_letter(self, macro: str) -> str:
55
+ match macro.lower():
56
+ case "s":
57
+ return self.sender
58
+ case "l":
59
+ return self.sender.split("@", maxsplit=1)[0]
60
+ case "o":
61
+ return self.sender.split("@", maxsplit=1)[1]
62
+ case "d":
63
+ return self.domain
64
+ case "i":
65
+ if self.parsed_ip.version == 4:
66
+ return self.ip
67
+ else:
68
+ return self.parsed_ip.reverse_pointer.removesuffix(".ip6.arpa")[
69
+ ::-1
70
+ ]
71
+ case "p":
72
+ # p = the validated domain name of <ip> (do not use)
73
+ raise NotImplementedError()
74
+ case "v":
75
+ # v = the string "in-addr" if <ip> is ipv4, or "ip6" if <ip> is ipv6
76
+ if self.parsed_ip.version == 4:
77
+ return "in-addr"
78
+ else:
79
+ return "ip6"
80
+ case "h":
81
+ # h = HELO/EHLO domain
82
+ # TODO: implement
83
+ raise NotImplementedError()
84
+ case "c":
85
+ if not self.exp_mode:
86
+ raise ValueError("c only allowed in exp mode")
87
+ return self.ip
88
+ case "r":
89
+ # The "r" macro expands to the name of the receiving MTA.
90
+ # TODO: implement
91
+ return "unknown"
92
+ case "t":
93
+ if not self.exp_mode:
94
+ raise ValueError("c only allowed in exp mode")
95
+ # Timestamp
96
+ return str(int(time.time()))
97
+
98
+ raise RuntimeError()
@@ -0,0 +1,289 @@
1
+ import typing
2
+ from dataclasses import dataclass
3
+ import re
4
+ import pyparsing
5
+ import enum
6
+ from pyparsing import (
7
+ CaselessLiteral,
8
+ Combine,
9
+ Optional,
10
+ Literal,
11
+ Regex,
12
+ Word,
13
+ ZeroOrMore,
14
+ alphanums,
15
+ alphas,
16
+ nums,
17
+ printables,
18
+ )
19
+ from pyparsing import pyparsing_common
20
+ import ipaddress
21
+
22
+
23
+ class Qualifier(enum.StrEnum):
24
+ PASS = "+"
25
+ FAIL = "-"
26
+ SOFTFAIL = "~"
27
+ NEUTRAL = "?"
28
+
29
+
30
+ @dataclass
31
+ class Modifier:
32
+ name: str
33
+ value: str
34
+
35
+ @classmethod
36
+ def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
37
+ data = parse_result.as_dict()
38
+ return cls(
39
+ name=data["name"],
40
+ value=data["value"],
41
+ )
42
+
43
+
44
+ @dataclass(kw_only=True)
45
+ class _BaseMechanism:
46
+ qualifier: Qualifier = Qualifier.PASS
47
+
48
+
49
+ @dataclass
50
+ class All(_BaseMechanism):
51
+ @classmethod
52
+ def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
53
+ return cls()
54
+
55
+
56
+ @dataclass
57
+ class MX(_BaseMechanism):
58
+ domain_spec: str | None
59
+ cidr: str | None
60
+
61
+ @classmethod
62
+ def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
63
+ data = parse_result[0].as_dict()
64
+ return cls(
65
+ domain_spec=data.get("domain_spec"),
66
+ cidr=data.get("cidr"),
67
+ )
68
+
69
+
70
+ @dataclass
71
+ class A(_BaseMechanism):
72
+ domain_spec: str | None
73
+ cidr: str | None
74
+
75
+ @classmethod
76
+ def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
77
+ data = parse_result[0].as_dict()
78
+ return cls(
79
+ domain_spec=data.get("domain_spec"),
80
+ cidr=data.get("cidr"),
81
+ )
82
+
83
+
84
+ @dataclass
85
+ class _BaseIP(_BaseMechanism):
86
+ ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network
87
+
88
+ @classmethod
89
+ def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
90
+ data = parse_result.as_dict()
91
+ try:
92
+ return cls(
93
+ ip_network=ipaddress.ip_network(
94
+ data["ip_address"] + data.get("cidr", "")
95
+ )
96
+ )
97
+ except (ipaddress.AddressValueError, ipaddress.NetmaskValueError):
98
+ raise pyparsing.ParseException(f"Invalid IP network {data=}")
99
+
100
+
101
+ @dataclass
102
+ class IP4(_BaseIP):
103
+ pass
104
+
105
+
106
+ @dataclass
107
+ class IP6(_BaseIP):
108
+ pass
109
+
110
+
111
+ @dataclass
112
+ class _MechanismWithRequiredDomainSpec(_BaseMechanism):
113
+ domain_spec: str
114
+
115
+ @classmethod
116
+ def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
117
+ data = parse_result.as_dict()
118
+ return cls(
119
+ domain_spec=data["domain_spec"],
120
+ )
121
+
122
+
123
+ @dataclass
124
+ class Include(_MechanismWithRequiredDomainSpec):
125
+ pass
126
+
127
+
128
+ @dataclass
129
+ class Exists(_MechanismWithRequiredDomainSpec):
130
+ pass
131
+
132
+
133
+ @dataclass
134
+ class PTR(_BaseMechanism):
135
+ domain_spec: str | None
136
+
137
+ @classmethod
138
+ def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
139
+ data = parse_result.as_dict()
140
+ return cls(
141
+ domain_spec=data.get("domain_spec"),
142
+ )
143
+
144
+
145
+ macro_string_regex = re.compile(
146
+ (
147
+ r"%{(?P<letter>[slodipvhcrt])"
148
+ r"(?P<count>(\d+))?"
149
+ r"(?P<reverse>(r))?"
150
+ r"(?P<delimiter>([\.\-\+,\/_=]))?}"
151
+ ),
152
+ re.IGNORECASE,
153
+ )
154
+
155
+
156
+ version = CaselessLiteral("v=spf1")
157
+ qualifier = Word("+-?~", exact=1)
158
+ name = Word(alphas, alphanums + "-_.")
159
+
160
+ dual_cidr_length = Combine(Literal("/") + Word(nums))
161
+
162
+ macro_literal = Word(printables, exact=1, exclude_chars="%")
163
+ macro_expand = Regex(macro_string_regex) | Literal("%%") | Literal("%_") | Literal("%-")
164
+
165
+ domain_end = (
166
+ Combine(Literal(".") + Word(alphanums + "-") + Optional(Literal(".")))
167
+ | macro_expand
168
+ )
169
+
170
+ macro_string = Combine(
171
+ ZeroOrMore(macro_expand | macro_literal, stop_on=dual_cidr_length)
172
+ )
173
+
174
+
175
+ def _check_domain_end(toks: pyparsing.ParseResults) -> None:
176
+ domain_end = toks[0].removesuffix(".")
177
+ if "." in domain_end:
178
+ top_label = domain_end.split(".")[-1]
179
+ else:
180
+ top_label = domain_end
181
+
182
+ if not top_label:
183
+ raise pyparsing.ParseException("empty label")
184
+
185
+ try:
186
+ # Is the top label a macro expand
187
+ macro_expand.parse_string(top_label, parse_all=True)
188
+ except pyparsing.ParseException:
189
+ # Or a valid RFC1035/1123 DNS label
190
+ if not re.match(r"^(?!-)[a-zA-Z0-9-_]{1,63}(?<!-)$", top_label):
191
+ raise pyparsing.ParseException("invalid top label")
192
+
193
+ return None
194
+
195
+
196
+ domain_spec = macro_string.set_parse_action(_check_domain_end)
197
+
198
+
199
+ all_ = CaselessLiteral("all").set_parse_action(All.from_parse_results)
200
+
201
+ include = (
202
+ CaselessLiteral("include:").suppress() + domain_spec.set_results_name("domain_spec")
203
+ ).set_parse_action(Include.from_parse_results)
204
+
205
+ ip4_cidr_length = Combine(Literal("/") + Regex("3[0-2]|2[0-9]|1[0-9]|[1-9]"))
206
+ ip4 = (
207
+ CaselessLiteral("ip4:").suppress()
208
+ + pyparsing_common.ipv4_address.set_results_name("ip_address")
209
+ + Optional(ip4_cidr_length).set_results_name("cidr")
210
+ ).set_parse_action(IP4.from_parse_results)
211
+
212
+ ip6_cidr_length = Literal("/") + Word(nums)
213
+ ip6 = (
214
+ CaselessLiteral("ip6:").suppress()
215
+ + pyparsing_common.ipv6_address.set_results_name("ip_address")
216
+ + Optional(ip6_cidr_length).set_results_name("cidr")
217
+ ).set_parse_action(IP6.from_parse_results)
218
+
219
+ a = pyparsing.Group(
220
+ CaselessLiteral("a").suppress()
221
+ + Optional(
222
+ pyparsing.ungroup(Literal(":").suppress() + domain_spec)
223
+ ).set_results_name("domain_spec")
224
+ + Optional(dual_cidr_length).set_results_name("cidr")
225
+ ).set_parse_action(A.from_parse_results)
226
+
227
+
228
+ mx = pyparsing.Group(
229
+ CaselessLiteral("mx").suppress()
230
+ + Optional(
231
+ pyparsing.ungroup(Literal(":").suppress() + domain_spec)
232
+ ).set_results_name("domain_spec")
233
+ + Optional(dual_cidr_length).set_results_name("cidr")
234
+ ).set_parse_action(MX.from_parse_results)
235
+
236
+ ptr = (
237
+ CaselessLiteral("ptr").suppress()
238
+ + Optional(
239
+ pyparsing.ungroup(Literal(":").suppress() + domain_spec)
240
+ ).set_results_name("domain_spec")
241
+ ).set_parse_action(PTR.from_parse_results)
242
+
243
+ exists = (
244
+ CaselessLiteral("exists:").suppress() + domain_spec.set_results_name("domain_spec")
245
+ ).set_parse_action(lambda toks: Exists(toks))
246
+
247
+ unknown_modifier = (
248
+ name.set_results_name("name")
249
+ + Literal("=").suppress()
250
+ + name.set_results_name("value")
251
+ )
252
+ redirect = (
253
+ CaselessLiteral("redirect").set_results_name("name")
254
+ + Literal("=").suppress()
255
+ + domain_spec.set_results_name("value")
256
+ )
257
+ exp = (
258
+ CaselessLiteral("exp").set_results_name("name")
259
+ + Literal("=").suppress()
260
+ + domain_spec.set_results_name("value")
261
+ )
262
+ modifiers = (redirect | exp | unknown_modifier).set_parse_action(
263
+ Modifier.from_parse_results
264
+ )
265
+
266
+
267
+ def _merge_qualifier(toks):
268
+ group = toks[0]
269
+ if len(group) == 2:
270
+ group[1].qualifier = Qualifier(group[0])
271
+ return group[1]
272
+ return toks
273
+
274
+
275
+ mechanism = pyparsing.ungroup(
276
+ pyparsing.Group(
277
+ Optional(qualifier) + (all_ | include | ip4 | ip6 | a | mx | ptr | exists)
278
+ ).set_parse_action(_merge_qualifier)
279
+ )
280
+ record = version.suppress() + ZeroOrMore(mechanism | modifiers)
281
+
282
+ Mechanism = typing.TypeVar("Mechanism", bound=_BaseMechanism)
283
+
284
+
285
+ def parse_record(rec: str) -> list[Mechanism | Modifier]:
286
+ try:
287
+ return record.parse_string(rec, parse_all=True).as_list()
288
+ except pyparsing.ParseException as parse_error:
289
+ raise ValueError(f"Invalid record {rec!r}") from parse_error