Coverage for kilter/service/options.py: 100.00%
115 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-07 13:26 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-07 13:26 +0000
1# Copyright 2023-2024 Dominik Sekotill <dom.sekotill@kodo.org.uk>
2#
3# This Source Code Form is subject to the terms of the Mozilla Public
4# License, v. 2.0. If a copy of the MPL was not distributed with this
5# file, You can obtain one at http://mozilla.org/MPL/2.0/.
7"""
8Filter decorators for marking the requested protocol options and actions used
9"""
11from __future__ import annotations
13from collections import defaultdict
14from enum import Flag
15from enum import auto
16from typing import Callable
17from typing import Literal
18from typing import NamedTuple
20from kilter.protocol.messages import ActionFlags
21from kilter.protocol.messages import ProtocolFlags
22from kilter.protocol.messages import Stage
24from .session import Filter
26__all__ = [
27 "CanRespond", "NEVER", "BEFORE", "DURING", "AFTER",
28 "responds_to_connect", "examine_helo",
29 "examine_sender", "examine_recipients",
30 "examine_headers", "examine_body",
31 "get_flags", "modify_flags",
32 "get_macros", "request_macros",
33]
35Decorator = Callable[[Filter], Filter]
36SIZES = Literal[ProtocolFlags.NONE, ProtocolFlags.MDS_256K, ProtocolFlags.MDS_1M]
38FLAGS_ATTRIBUTE = "filter_flags"
39MACRO_ATTRIBUTE = "filter_macros"
41DEFAULT_UNSET = \
42 ProtocolFlags.NO_CONNECT | ProtocolFlags.NO_HELO | \
43 ProtocolFlags.NO_SENDER | ProtocolFlags.NO_RECIPIENT | \
44 ProtocolFlags.NO_DATA | ProtocolFlags.NO_BODY | \
45 ProtocolFlags.NO_HEADERS | ProtocolFlags.NO_END_OF_HEADERS | \
46 ProtocolFlags.NO_UNKNOWN | \
47 ProtocolFlags.NR_CONNECT | ProtocolFlags.NR_HELO | \
48 ProtocolFlags.NR_SENDER | ProtocolFlags.NR_RECIPIENT | \
49 ProtocolFlags.NR_DATA | ProtocolFlags.NR_BODY | \
50 ProtocolFlags.NR_HEADER | ProtocolFlags.NR_END_OF_HEADERS | \
51 ProtocolFlags.NR_UNKNOWN
54class CanRespond(Flag):
55 """
56 Flags for fine indication of which stages during message sending a filter may respond at
58 Used with `examine_headers()` and `examine_body()` to further refine which stages during
59 message header and body transfer will synchronously block until a response of some kind
60 is received.
61 """
63 NEVER = 0
64 BEFORE = auto()
65 DURING = auto()
66 AFTER = auto()
67 ALL = BEFORE|DURING|AFTER
70NEVER = CanRespond.NEVER
71BEFORE = CanRespond.BEFORE
72DURING = CanRespond.DURING
73AFTER = CanRespond.AFTER
76class FlagsTuple(NamedTuple):
78 unset_options: ProtocolFlags = ProtocolFlags.NONE
79 set_options: ProtocolFlags = ProtocolFlags.NONE
80 set_actions: ActionFlags = ActionFlags.NONE
83def modify_flags(
84 set_options: ProtocolFlags = ProtocolFlags.NONE,
85 unset_options: ProtocolFlags = ProtocolFlags.NONE,
86 set_actions: ActionFlags = ActionFlags.NONE,
87) -> Decorator:
88 """
89 Return a decorator that modifies the given flags on a decorated filter
90 """
91 def decorator(filtr: Filter) -> Filter:
92 _set_flags(filtr, set_options, unset_options, set_actions)
93 return filtr
94 return decorator
97def get_flags(filtr: Filter) -> FlagsTuple:
98 """
99 Return the flags attached to a filter
100 """
101 default = FlagsTuple(unset_options=DEFAULT_UNSET, set_actions=ActionFlags.ALL)
102 return _get_flags(filtr, default)
105def _set_flags(
106 filtr: Filter,
107 set_options: ProtocolFlags = ProtocolFlags.NONE,
108 unset_options: ProtocolFlags = ProtocolFlags.NONE,
109 set_actions: ActionFlags = ActionFlags.NONE,
110) -> None:
111 flags = _get_flags(filtr, FlagsTuple())
112 flags = FlagsTuple(
113 flags.unset_options|unset_options,
114 flags.set_options|set_options,
115 flags.set_actions|set_actions,
116 )
117 setattr(filtr, FLAGS_ATTRIBUTE, flags)
120def _get_flags(filtr: Filter, default: FlagsTuple) -> FlagsTuple:
121 assert isinstance(getattr(filtr, FLAGS_ATTRIBUTE, default), FlagsTuple)
122 return getattr(filtr, FLAGS_ATTRIBUTE, default)
125def request_macros(stage: Stage, *names: str) -> Decorator:
126 """
127 Return a decorator that adds the given macro requests to a decorated filter
128 """
129 def decorator(filtr: Filter) -> Filter:
130 _set_flags(filtr, set_actions=ActionFlags.SETSYMLIST)
131 macros = get_macros(filtr)
132 macros[stage].update(names)
133 return filtr
134 return decorator
137def get_macros(filtr: Filter) -> defaultdict[Stage, set[str]]:
138 """
139 Return the requested macros attached to a filter
140 """
141 try:
142 macros = getattr(filtr, MACRO_ATTRIBUTE)
143 except AttributeError:
144 setattr(filtr, MACRO_ATTRIBUTE, (macros := defaultdict(set)))
145 assert isinstance(macros, defaultdict)
146 return macros
149def responds_to_connect() -> Decorator:
150 """
151 Mark a filter as possibly delivering a non-continue response to Connect events
152 """
153 return modify_flags(unset_options=ProtocolFlags.NR_CONNECT)
156def examine_helo(
157 can_respond: bool = False,
158) -> Decorator:
159 """
160 Mark a filter as needing to examine the HELO command
162 If `can_respond` is `False` the filter runner will attempt to negotiate faster event
163 delivery by disabling the need to respond to this event.
164 """
165 unset = ProtocolFlags.NO_HELO
166 if can_respond:
167 unset |= ProtocolFlags.NR_HELO
168 return modify_flags(unset_options=unset)
171def examine_sender(
172 can_respond: bool = False,
173 can_replace: bool = False,
174) -> Decorator:
175 """
176 Mark a filter as needing to examine and optionally replace the RCPT FROM sender
178 If `can_respond` is `False` the filter runner will attempt to negotiate faster event
179 delivery by disabling the need to respond to this event.
181 If `can_replace` is `True` but is not offered by the MTA an exception will be raised
182 during negotiation and the filter will be disabled.
183 """
184 unset = ProtocolFlags.NO_SENDER
185 if can_respond:
186 unset |= ProtocolFlags.NR_SENDER
187 return modify_flags(
188 unset_options=unset,
189 set_actions=ActionFlags.CHANGE_FROM if can_replace else ActionFlags.NONE,
190 )
193def examine_recipients(
194 can_respond: bool = False,
195 can_add: bool = False,
196 can_remove: bool = False,
197 include_rejected: bool = False,
198 with_parameters: bool = False,
199) -> Decorator:
200 """
201 Mark a filter as needing to examine and optionally modify the RCPT TO recipients
203 If `can_respond` is `False` the filter runner will attempt to negotiate faster event
204 delivery by disabling the need to respond to this event.
206 If `include_rejected` is `True` the recipients available to the filter will include any
207 that the MTA or another filter has already rejected.
209 The option `with_parameters` enables the use of RFC-1425 [section 6] extensions for
210 "MAIL" commands (ratified by RFC-5321) when adding recipients. The specific details of
211 any extension parameters will be dependent on the MTA.
213 If a requested option or update action is not offered by the MTA an exception will be
214 raised during negotiation and the filter will be disabled.
215 """
216 unset = ProtocolFlags.NO_RECIPIENT
217 opts = ProtocolFlags.NONE
218 acts = ActionFlags.NONE
219 if can_respond:
220 unset |= ProtocolFlags.NR_RECIPIENT
221 if can_add:
222 acts |= ActionFlags.ADD_RECIPIENT
223 if can_add and with_parameters:
224 acts |= ActionFlags.ADD_RECIPIENT_PAR
225 if can_remove:
226 acts |= ActionFlags.DELETE_RECIPIENT
227 if include_rejected:
228 opts |= ProtocolFlags.REJECTED_RECIPIENT
229 return modify_flags(unset_options=unset, set_options=opts, set_actions=acts)
232def examine_headers(
233 can_respond: bool|CanRespond = False,
234 can_add: bool = False,
235 can_modify: bool = False,
236 leading_space: bool = False,
237) -> Decorator:
238 """
239 Mark a filter as needing to examine and optionally add or modify message headers
241 If `can_respond` is `False` the filter runner will attempt to negotiate faster event
242 delivery by disabling the need to respond to this event.
244 If `leading_space` is `True` the headers will be delivered without any whitespace
245 removed from values (i.e. after the separating colon). This is for filters which need
246 the exact bytes contained in message headers.
248 If a requested option or update action is not offered by the MTA an exception will be
249 raised during negotiation and the filter will be disabled.
250 """
251 unset = ProtocolFlags.NO_HEADERS
252 opts = ProtocolFlags.NONE
253 acts = ActionFlags.NONE
254 if isinstance(can_respond, bool):
255 can_respond = CanRespond.ALL if can_respond else CanRespond.NEVER
256 if CanRespond.BEFORE in can_respond:
257 unset |= ProtocolFlags.NO_DATA | ProtocolFlags.NR_DATA
258 if CanRespond.DURING in can_respond:
259 unset |= ProtocolFlags.NR_HEADER
260 if CanRespond.AFTER in can_respond:
261 unset |= ProtocolFlags.NO_END_OF_HEADERS | ProtocolFlags.NR_END_OF_HEADERS
262 if can_add:
263 acts |= ActionFlags.ADD_HEADERS
264 if can_modify:
265 acts |= ActionFlags.CHANGE_HEADERS
266 if leading_space:
267 opts |= ProtocolFlags.HEADER_LEADING_SPACE
268 return modify_flags(unset_options=unset, set_options=opts, set_actions=acts)
271def examine_body(
272 can_respond: bool|CanRespond = False,
273 can_replace: bool = False,
274 data_size: SIZES = ProtocolFlags.NONE,
275) -> Decorator:
276 """
277 Mark a filter as needing to examine and optionally replace message bodies
279 If `can_respond` is `False` the filter runner will attempt to negotiate faster event
280 delivery by disabling the need to respond to this event.
282 The `data_size` option is a hint, and does not guarantee that the message will be
283 delivered in blocks of that size. If `ProtocolFlags.NONE` (the default) the MTA's
284 default will be used.
286 If `can_replace` is `True` but is not offered by the MTA an exception will be raised
287 during negotiation and the filter will be disabled.
288 """
289 unset = ProtocolFlags.NO_BODY
290 if isinstance(can_respond, bool):
291 can_respond = CanRespond.ALL if can_respond else CanRespond.NEVER
292 if CanRespond.BEFORE in can_respond:
293 unset |= ProtocolFlags.NO_END_OF_HEADERS | ProtocolFlags.NR_END_OF_HEADERS
294 if CanRespond.DURING in can_respond:
295 unset |= ProtocolFlags.NR_BODY
296 # CanRespond.AFTER is implicit
297 return modify_flags(
298 unset_options=unset, set_options=data_size,
299 set_actions=ActionFlags.CHANGE_BODY if can_replace else ActionFlags.NONE,
300 )