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

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

6 

7""" 

8Filter decorators for marking the requested protocol options and actions used 

9""" 

10 

11from __future__ import annotations 

12 

13from collections import defaultdict 

14from enum import Flag 

15from enum import auto 

16from typing import Callable 

17from typing import Literal 

18from typing import NamedTuple 

19 

20from kilter.protocol.messages import ActionFlags 

21from kilter.protocol.messages import ProtocolFlags 

22from kilter.protocol.messages import Stage 

23 

24from .session import Filter 

25 

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] 

34 

35Decorator = Callable[[Filter], Filter] 

36SIZES = Literal[ProtocolFlags.NONE, ProtocolFlags.MDS_256K, ProtocolFlags.MDS_1M] 

37 

38FLAGS_ATTRIBUTE = "filter_flags" 

39MACRO_ATTRIBUTE = "filter_macros" 

40 

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 

52 

53 

54class CanRespond(Flag): 

55 """ 

56 Flags for fine indication of which stages during message sending a filter may respond at 

57 

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 """ 

62 

63 NEVER = 0 

64 BEFORE = auto() 

65 DURING = auto() 

66 AFTER = auto() 

67 ALL = BEFORE|DURING|AFTER 

68 

69 

70NEVER = CanRespond.NEVER 

71BEFORE = CanRespond.BEFORE 

72DURING = CanRespond.DURING 

73AFTER = CanRespond.AFTER 

74 

75 

76class FlagsTuple(NamedTuple): 

77 

78 unset_options: ProtocolFlags = ProtocolFlags.NONE 

79 set_options: ProtocolFlags = ProtocolFlags.NONE 

80 set_actions: ActionFlags = ActionFlags.NONE 

81 

82 

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 

95 

96 

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) 

103 

104 

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) 

118 

119 

120def _get_flags(filtr: Filter, default: FlagsTuple) -> FlagsTuple: 

121 assert isinstance(getattr(filtr, FLAGS_ATTRIBUTE, default), FlagsTuple) 

122 return getattr(filtr, FLAGS_ATTRIBUTE, default) 

123 

124 

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 

135 

136 

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 

147 

148 

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) 

154 

155 

156def examine_helo( 

157 can_respond: bool = False, 

158) -> Decorator: 

159 """ 

160 Mark a filter as needing to examine the HELO command 

161 

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) 

169 

170 

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 

177 

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. 

180 

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 ) 

191 

192 

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 

202 

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. 

205 

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. 

208 

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. 

212 

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) 

230 

231 

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 

240 

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. 

243 

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. 

247 

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) 

269 

270 

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 

278 

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. 

281 

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. 

285 

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 )