OLD | NEW |
| (Empty) |
1 # -*- test-case-name: twisted.mail.test.test_mail -*- | |
2 # | |
3 # Copyright (c) 2001-2007 Twisted Matrix Laboratories. | |
4 # See LICENSE for details. | |
5 | |
6 | |
7 """ | |
8 Support for aliases(5) configuration files | |
9 | |
10 @author: U{Jp Calderone<mailto:exarkun@twistedmatrix.com>} | |
11 | |
12 TODO:: | |
13 Monitor files for reparsing | |
14 Handle non-local alias targets | |
15 Handle maildir alias targets | |
16 """ | |
17 | |
18 import os | |
19 import tempfile | |
20 | |
21 from twisted.mail import smtp | |
22 from twisted.internet import reactor | |
23 from twisted.internet import protocol | |
24 from twisted.internet import defer | |
25 from twisted.python import failure | |
26 from twisted.python import log | |
27 from zope.interface import implements, Interface | |
28 | |
29 | |
30 def handle(result, line, filename, lineNo): | |
31 parts = [p.strip() for p in line.split(':', 1)] | |
32 if len(parts) != 2: | |
33 fmt = "Invalid format on line %d of alias file %s." | |
34 arg = (lineNo, filename) | |
35 log.err(fmt % arg) | |
36 else: | |
37 user, alias = parts | |
38 result.setdefault(user.strip(), []).extend(map(str.strip, alias.split(',
'))) | |
39 | |
40 def loadAliasFile(domains, filename=None, fp=None): | |
41 """Load a file containing email aliases. | |
42 | |
43 Lines in the file should be formatted like so:: | |
44 | |
45 username: alias1,alias2,...,aliasN | |
46 | |
47 Aliases beginning with a | will be treated as programs, will be run, and | |
48 the message will be written to their stdin. | |
49 | |
50 Aliases without a host part will be assumed to be addresses on localhost. | |
51 | |
52 If a username is specified multiple times, the aliases for each are joined | |
53 together as if they had all been on one line. | |
54 | |
55 @type domains: C{dict} of implementor of C{IDomain} | |
56 @param domains: The domains to which these aliases will belong. | |
57 | |
58 @type filename: C{str} | |
59 @param filename: The filename from which to load aliases. | |
60 | |
61 @type fp: Any file-like object. | |
62 @param fp: If specified, overrides C{filename}, and aliases are read from | |
63 it. | |
64 | |
65 @rtype: C{dict} | |
66 @return: A dictionary mapping usernames to C{AliasGroup} objects. | |
67 """ | |
68 result = {} | |
69 if fp is None: | |
70 fp = file(filename) | |
71 else: | |
72 filename = getattr(fp, 'name', '<unknown>') | |
73 i = 0 | |
74 prev = '' | |
75 for line in fp: | |
76 i += 1 | |
77 line = line.rstrip() | |
78 if line.lstrip().startswith('#'): | |
79 continue | |
80 elif line.startswith(' ') or line.startswith('\t'): | |
81 prev = prev + line | |
82 else: | |
83 if prev: | |
84 handle(result, prev, filename, i) | |
85 prev = line | |
86 if prev: | |
87 handle(result, prev, filename, i) | |
88 for (u, a) in result.items(): | |
89 addr = smtp.Address(u) | |
90 result[u] = AliasGroup(a, domains, u) | |
91 return result | |
92 | |
93 class IAlias(Interface): | |
94 def createMessageReceiver(): | |
95 pass | |
96 | |
97 class AliasBase: | |
98 def __init__(self, domains, original): | |
99 self.domains = domains | |
100 self.original = smtp.Address(original) | |
101 | |
102 def domain(self): | |
103 return self.domains[self.original.domain] | |
104 | |
105 def resolve(self, aliasmap, memo=None): | |
106 if memo is None: | |
107 memo = {} | |
108 if str(self) in memo: | |
109 return None | |
110 memo[str(self)] = None | |
111 return self.createMessageReceiver() | |
112 | |
113 class AddressAlias(AliasBase): | |
114 """The simplest alias, translating one email address into another.""" | |
115 | |
116 implements(IAlias) | |
117 | |
118 def __init__(self, alias, *args): | |
119 AliasBase.__init__(self, *args) | |
120 self.alias = smtp.Address(alias) | |
121 | |
122 def __str__(self): | |
123 return '<Address %s>' % (self.alias,) | |
124 | |
125 def createMessageReceiver(self): | |
126 return self.domain().startMessage(str(self.alias)) | |
127 | |
128 def resolve(self, aliasmap, memo=None): | |
129 if memo is None: | |
130 memo = {} | |
131 if str(self) in memo: | |
132 return None | |
133 memo[str(self)] = None | |
134 try: | |
135 return self.domain().exists(smtp.User(self.alias, None, None, None),
memo)() | |
136 except smtp.SMTPBadRcpt: | |
137 pass | |
138 if self.alias.local in aliasmap: | |
139 return aliasmap[self.alias.local].resolve(aliasmap, memo) | |
140 return None | |
141 | |
142 class FileWrapper: | |
143 implements(smtp.IMessage) | |
144 | |
145 def __init__(self, filename): | |
146 self.fp = tempfile.TemporaryFile() | |
147 self.finalname = filename | |
148 | |
149 def lineReceived(self, line): | |
150 self.fp.write(line + '\n') | |
151 | |
152 def eomReceived(self): | |
153 self.fp.seek(0, 0) | |
154 try: | |
155 f = file(self.finalname, 'a') | |
156 except: | |
157 return defer.fail(failure.Failure()) | |
158 | |
159 f.write(self.fp.read()) | |
160 self.fp.close() | |
161 f.close() | |
162 | |
163 return defer.succeed(self.finalname) | |
164 | |
165 def connectionLost(self): | |
166 self.fp.close() | |
167 self.fp = None | |
168 | |
169 def __str__(self): | |
170 return '<FileWrapper %s>' % (self.finalname,) | |
171 | |
172 | |
173 class FileAlias(AliasBase): | |
174 | |
175 implements(IAlias) | |
176 | |
177 def __init__(self, filename, *args): | |
178 AliasBase.__init__(self, *args) | |
179 self.filename = filename | |
180 | |
181 def __str__(self): | |
182 return '<File %s>' % (self.filename,) | |
183 | |
184 def createMessageReceiver(self): | |
185 return FileWrapper(self.filename) | |
186 | |
187 | |
188 | |
189 class ProcessAliasTimeout(Exception): | |
190 """ | |
191 A timeout occurred while processing aliases. | |
192 """ | |
193 | |
194 | |
195 | |
196 class MessageWrapper: | |
197 """ | |
198 A message receiver which delivers content to a child process. | |
199 | |
200 @type completionTimeout: C{int} or C{float} | |
201 @ivar completionTimeout: The number of seconds to wait for the child | |
202 process to exit before reporting the delivery as a failure. | |
203 | |
204 @type _timeoutCallID: C{NoneType} or L{IDelayedCall} | |
205 @ivar _timeoutCallID: The call used to time out delivery, started when the | |
206 connection to the child process is closed. | |
207 | |
208 @type done: C{bool} | |
209 @ivar done: Flag indicating whether the child process has exited or not. | |
210 | |
211 @ivar reactor: An L{IReactorTime} provider which will be used to schedule | |
212 timeouts. | |
213 """ | |
214 implements(smtp.IMessage) | |
215 | |
216 done = False | |
217 | |
218 completionTimeout = 60 | |
219 _timeoutCallID = None | |
220 | |
221 reactor = reactor | |
222 | |
223 def __init__(self, protocol, process=None, reactor=None): | |
224 self.processName = process | |
225 self.protocol = protocol | |
226 self.completion = defer.Deferred() | |
227 self.protocol.onEnd = self.completion | |
228 self.completion.addBoth(self._processEnded) | |
229 | |
230 if reactor is not None: | |
231 self.reactor = reactor | |
232 | |
233 | |
234 def _processEnded(self, result): | |
235 """ | |
236 Record process termination and cancel the timeout call if it is active. | |
237 """ | |
238 self.done = True | |
239 if self._timeoutCallID is not None: | |
240 # eomReceived was called, we're actually waiting for the process to | |
241 # exit. | |
242 self._timeoutCallID.cancel() | |
243 self._timeoutCallID = None | |
244 else: | |
245 # eomReceived was not called, this is unexpected, propagate the | |
246 # error. | |
247 return result | |
248 | |
249 | |
250 def lineReceived(self, line): | |
251 if self.done: | |
252 return | |
253 self.protocol.transport.write(line + '\n') | |
254 | |
255 | |
256 def eomReceived(self): | |
257 """ | |
258 Disconnect from the child process, set up a timeout to wait for it to | |
259 exit, and return a Deferred which will be called back when the child | |
260 process exits. | |
261 """ | |
262 if not self.done: | |
263 self.protocol.transport.loseConnection() | |
264 self._timeoutCallID = self.reactor.callLater( | |
265 self.completionTimeout, self._completionCancel) | |
266 return self.completion | |
267 | |
268 | |
269 def _completionCancel(self): | |
270 """ | |
271 Handle the expiration of the timeout for the child process to exit by | |
272 terminating the child process forcefully and issuing a failure to the | |
273 completion deferred returned by L{eomReceived}. | |
274 """ | |
275 self._timeoutCallID = None | |
276 self.protocol.transport.signalProcess('KILL') | |
277 exc = ProcessAliasTimeout( | |
278 "No answer after %s seconds" % (self.completionTimeout,)) | |
279 self.protocol.onEnd = None | |
280 self.completion.errback(failure.Failure(exc)) | |
281 | |
282 | |
283 def connectionLost(self): | |
284 # Heh heh | |
285 pass | |
286 | |
287 | |
288 def __str__(self): | |
289 return '<ProcessWrapper %s>' % (self.processName,) | |
290 | |
291 | |
292 | |
293 class ProcessAliasProtocol(protocol.ProcessProtocol): | |
294 """ | |
295 Trivial process protocol which will callback a Deferred when the associated | |
296 process ends. | |
297 | |
298 @ivar onEnd: If not C{None}, a L{Deferred} which will be called back with | |
299 the failure passed to C{processEnded}, when C{processEnded} is called. | |
300 """ | |
301 | |
302 onEnd = None | |
303 | |
304 def processEnded(self, reason): | |
305 """ | |
306 Call back C{onEnd} if it is set. | |
307 """ | |
308 if self.onEnd is not None: | |
309 self.onEnd.errback(reason) | |
310 | |
311 | |
312 | |
313 class ProcessAlias(AliasBase): | |
314 """ | |
315 An alias which is handled by the execution of a particular program. | |
316 | |
317 @ivar reactor: An L{IReactorProcess} and L{IReactorTime} provider which | |
318 will be used to create and timeout the alias child process. | |
319 """ | |
320 implements(IAlias) | |
321 | |
322 reactor = reactor | |
323 | |
324 def __init__(self, path, *args): | |
325 AliasBase.__init__(self, *args) | |
326 self.path = path.split() | |
327 self.program = self.path[0] | |
328 | |
329 | |
330 def __str__(self): | |
331 """ | |
332 Build a string representation containing the path. | |
333 """ | |
334 return '<Process %s>' % (self.path,) | |
335 | |
336 | |
337 def spawnProcess(self, proto, program, path): | |
338 """ | |
339 Wrapper around C{reactor.spawnProcess}, to be customized for tests | |
340 purpose. | |
341 """ | |
342 return self.reactor.spawnProcess(proto, program, path) | |
343 | |
344 | |
345 def createMessageReceiver(self): | |
346 """ | |
347 Create a message receiver by launching a process. | |
348 """ | |
349 p = ProcessAliasProtocol() | |
350 m = MessageWrapper(p, self.program, self.reactor) | |
351 fd = self.spawnProcess(p, self.program, self.path) | |
352 return m | |
353 | |
354 | |
355 | |
356 class MultiWrapper: | |
357 """ | |
358 Wrapper to deliver a single message to multiple recipients. | |
359 """ | |
360 | |
361 implements(smtp.IMessage) | |
362 | |
363 def __init__(self, objs): | |
364 self.objs = objs | |
365 | |
366 def lineReceived(self, line): | |
367 for o in self.objs: | |
368 o.lineReceived(line) | |
369 | |
370 def eomReceived(self): | |
371 return defer.DeferredList([ | |
372 o.eomReceived() for o in self.objs | |
373 ]) | |
374 | |
375 def connectionLost(self): | |
376 for o in self.objs: | |
377 o.connectionLost() | |
378 | |
379 def __str__(self): | |
380 return '<GroupWrapper %r>' % (map(str, self.objs),) | |
381 | |
382 | |
383 | |
384 class AliasGroup(AliasBase): | |
385 """ | |
386 An alias which points to more than one recipient. | |
387 | |
388 @ivar processAliasFactory: a factory for resolving process aliases. | |
389 @type processAliasFactory: C{class} | |
390 """ | |
391 | |
392 implements(IAlias) | |
393 | |
394 processAliasFactory = ProcessAlias | |
395 | |
396 def __init__(self, items, *args): | |
397 AliasBase.__init__(self, *args) | |
398 self.aliases = [] | |
399 while items: | |
400 addr = items.pop().strip() | |
401 if addr.startswith(':'): | |
402 try: | |
403 f = file(addr[1:]) | |
404 except: | |
405 log.err("Invalid filename in alias file %r" % (addr[1:],)) | |
406 else: | |
407 addr = ' '.join([l.strip() for l in f]) | |
408 items.extend(addr.split(',')) | |
409 elif addr.startswith('|'): | |
410 self.aliases.append(self.processAliasFactory(addr[1:], *args)) | |
411 elif addr.startswith('/'): | |
412 if os.path.isdir(addr): | |
413 log.err("Directory delivery not supported") | |
414 else: | |
415 self.aliases.append(FileAlias(addr, *args)) | |
416 else: | |
417 self.aliases.append(AddressAlias(addr, *args)) | |
418 | |
419 def __len__(self): | |
420 return len(self.aliases) | |
421 | |
422 def __str__(self): | |
423 return '<AliasGroup [%s]>' % (', '.join(map(str, self.aliases))) | |
424 | |
425 def createMessageReceiver(self): | |
426 return MultiWrapper([a.createMessageReceiver() for a in self.aliases]) | |
427 | |
428 def resolve(self, aliasmap, memo=None): | |
429 if memo is None: | |
430 memo = {} | |
431 r = [] | |
432 for a in self.aliases: | |
433 r.append(a.resolve(aliasmap, memo)) | |
434 return MultiWrapper(filter(None, r)) | |
435 | |
OLD | NEW |