1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 import re
18
19 from email.Message import Message
20 from email.Utils import formatdate
21 from email.MIMEText import MIMEText
22 from email.MIMEMultipart import MIMEMultipart
23 from StringIO import StringIO
24 import urllib
25
26 from zope.interface import implements
27 from twisted.internet import defer, reactor
28 from twisted.mail.smtp import ESMTPSenderFactory
29 from twisted.python import log as twlog
30
31 have_ssl = True
32 try:
33 from twisted.internet import ssl
34 from OpenSSL.SSL import SSLv3_METHOD
35 except ImportError:
36 have_ssl = False
37
38 from buildbot import interfaces, util
39 from buildbot.status import base
40 from buildbot.status.builder import FAILURE, SUCCESS, Results
41
42 VALID_EMAIL = re.compile("[a-zA-Z0-9\.\_\%\-\+]+@[a-zA-Z0-9\.\_\%\-]+.[a-zA-Z]{2,6}")
43
44 ENCODING = 'utf8'
45
46 -class Domain(util.ComparableMixin):
47 implements(interfaces.IEmailLookup)
48 compare_attrs = ["domain"]
49
50 - def __init__(self, domain):
51 assert "@" not in domain
52 self.domain = domain
53
54 - def getAddress(self, name):
55 """If name is already an email address, pass it through."""
56 if '@' in name:
57 return name
58 return name + "@" + self.domain
59
60
129
131 """This is a status notifier which sends email to a list of recipients
132 upon the completion of each build. It can be configured to only send out
133 mail for certain builds, and only send messages when the build fails, or
134 when it transitions from success to failure. It can also be configured to
135 include various build logs in each message.
136
137 By default, the message will be sent to the Interested Users list, which
138 includes all developers who made changes in the build. You can add
139 additional recipients with the extraRecipients argument.
140
141 To get a simple one-message-per-build (say, for a mailing list), use
142 sendToInterestedUsers=False, extraRecipients=['listaddr@example.org']
143
144 Each MailNotifier sends mail to a single set of recipients. To send
145 different kinds of mail to different recipients, use multiple
146 MailNotifiers.
147 """
148
149 implements(interfaces.IEmailSender)
150
151 compare_attrs = ["extraRecipients", "lookup", "fromaddr", "mode",
152 "categories", "builders", "addLogs", "relayhost",
153 "subject", "sendToInterestedUsers", "customMesg",
154 "messageFormatter", "extraHeaders"]
155
156 - def __init__(self, fromaddr, mode="all", categories=None, builders=None,
157 addLogs=False, relayhost="localhost",
158 subject="buildbot %(result)s in %(projectName)s on %(builder)s",
159 lookup=None, extraRecipients=[],
160 sendToInterestedUsers=True, customMesg=None,
161 messageFormatter=defaultMessage, extraHeaders=None,
162 addPatch=True, useTls=False,
163 smtpUser=None, smtpPassword=None, smtpPort=25):
164 """
165 @type fromaddr: string
166 @param fromaddr: the email address to be used in the 'From' header.
167 @type sendToInterestedUsers: boolean
168 @param sendToInterestedUsers: if True (the default), send mail to all
169 of the Interested Users. If False, only
170 send mail to the extraRecipients list.
171
172 @type extraRecipients: tuple of string
173 @param extraRecipients: a list of email addresses to which messages
174 should be sent (in addition to the
175 InterestedUsers list, which includes any
176 developers who made Changes that went into this
177 build). It is a good idea to create a small
178 mailing list and deliver to that, then let
179 subscribers come and go as they please. The
180 addresses in this list are used literally (they
181 are not processed by lookup).
182
183 @type subject: string
184 @param subject: a string to be used as the subject line of the message.
185 %(builder)s will be replaced with the name of the
186 builder which provoked the message.
187
188 @type mode: string (defaults to all)
189 @param mode: one of:
190 - 'all': send mail about all builds, passing and failing
191 - 'failing': only send mail about builds which fail
192 - 'warnings': send mail if builds contain warnings or fail
193 - 'passing': only send mail about builds which succeed
194 - 'problem': only send mail about a build which failed
195 when the previous build passed
196 - 'change': only send mail about builds who change status
197
198 @type builders: list of strings
199 @param builders: a list of builder names for which mail should be
200 sent. Defaults to None (send mail for all builds).
201 Use either builders or categories, but not both.
202
203 @type categories: list of strings
204 @param categories: a list of category names to serve status
205 information for. Defaults to None (all
206 categories). Use either builders or categories,
207 but not both.
208
209 @type addLogs: boolean
210 @param addLogs: if True, include all build logs as attachments to the
211 messages. These can be quite large. This can also be
212 set to a list of log names, to send a subset of the
213 logs. Defaults to False.
214
215 @type addPatch: boolean
216 @param addPatch: if True, include the patch when the source stamp
217 includes one.
218
219 @type relayhost: string
220 @param relayhost: the host to which the outbound SMTP connection
221 should be made. Defaults to 'localhost'
222
223 @type lookup: implementor of {IEmailLookup}
224 @param lookup: object which provides IEmailLookup, which is
225 responsible for mapping User names for Interested
226 Users (which come from the VC system) into valid
227 email addresses. If not provided, the notifier will
228 only be able to send mail to the addresses in the
229 extraRecipients list. Most of the time you can use a
230 simple Domain instance. As a shortcut, you can pass
231 as string: this will be treated as if you had provided
232 Domain(str). For example, lookup='twistedmatrix.com'
233 will allow mail to be sent to all developers whose SVN
234 usernames match their twistedmatrix.com account names.
235
236 @type customMesg: func
237 @param customMesg: (this function is deprecated)
238
239 @type messageFormatter: func
240 @param messageFormatter: function taking (mode, name, build, result,
241 master_status) and returning a dictionary
242 containing two required keys "body" and "type",
243 with a third optional key, "subject". The
244 "body" key gives a string that contains the
245 complete text of the message. The "type" key
246 is the message type ('plain' or 'html'). The
247 'html' type should be used when generating an
248 HTML message. The optional "subject" key
249 gives the subject for the email.
250
251 @type extraHeaders: dict
252 @param extraHeaders: A dict of extra headers to add to the mail. It's
253 best to avoid putting 'To', 'From', 'Date',
254 'Subject', or 'CC' in here. Both the names and
255 values may be WithProperties instances.
256
257 @type useTls: boolean
258 @param useTls: Send emails using TLS and authenticate with the
259 smtp host. Defaults to False.
260
261 @type smtpUser: string
262 @param smtpUser: The user that will attempt to authenticate with the
263 relayhost when useTls is True.
264
265 @type smtpPassword: string
266 @param smtpPassword: The password that smtpUser will use when
267 authenticating with relayhost.
268
269 @type smtpPort: int
270 @param smtpPort: The port that will be used when connecting to the
271 relayhost. Defaults to 25.
272 """
273
274 base.StatusReceiverMultiService.__init__(self)
275 assert isinstance(extraRecipients, (list, tuple))
276 for r in extraRecipients:
277 assert isinstance(r, str)
278
279 assert VALID_EMAIL.search(r), "%s is not a valid email" % r
280 self.extraRecipients = extraRecipients
281 self.sendToInterestedUsers = sendToInterestedUsers
282 self.fromaddr = fromaddr
283 assert mode in ('all', 'failing', 'problem', 'change', 'passing', 'warnings')
284 self.mode = mode
285 self.categories = categories
286 self.builders = builders
287 self.addLogs = addLogs
288 self.relayhost = relayhost
289 self.subject = subject
290 if lookup is not None:
291 if type(lookup) is str:
292 lookup = Domain(lookup)
293 assert interfaces.IEmailLookup.providedBy(lookup)
294 self.lookup = lookup
295 self.customMesg = customMesg
296 self.messageFormatter = messageFormatter
297 if extraHeaders:
298 assert isinstance(extraHeaders, dict)
299 self.extraHeaders = extraHeaders
300 self.addPatch = addPatch
301 self.useTls = useTls
302 self.smtpUser = smtpUser
303 self.smtpPassword = smtpPassword
304 self.smtpPort = smtpPort
305 self.watched = []
306 self.master_status = None
307
308
309 if self.builders != None and self.categories != None:
310 twlog.err("Please specify only builders or categories to include not both.")
311 raise interfaces.ParameterError("Please specify only builders or categories to include not both.")
312
313 if customMesg:
314 twlog.msg("customMesg is deprecated; please use messageFormatter instead")
315
322
326
332
334
335 if self.categories != None and builder.category not in self.categories:
336 return None
337
338 self.watched.append(builder)
339 return self
340
343
380
382
383
384
385
386 logs = list()
387 for logf in build.getLogs():
388 logStep = logf.getStep()
389 stepName = logStep.getName()
390 logStatus, dummy = logStep.getResults()
391 logName = logf.getName()
392 logs.append(('%s.%s' % (stepName, logName),
393 '%s/steps/%s/logs/%s' % (master_status.getURLForThing(build), stepName, logName),
394 logf.getText().splitlines(),
395 logStatus))
396
397 attrs = {'builderName': name,
398 'projectName': master_status.getProjectName(),
399 'mode': mode,
400 'result': Results[results],
401 'buildURL': master_status.getURLForThing(build),
402 'buildbotURL': master_status.getBuildbotURL(),
403 'buildText': build.getText(),
404 'buildProperties': build.getProperties(),
405 'slavename': build.getSlavename(),
406 'reason': build.getReason(),
407 'responsibleUsers': build.getResponsibleUsers(),
408 'branch': "",
409 'revision': "",
410 'patch': "",
411 'changes': [],
412 'logs': logs}
413
414 ss = build.getSourceStamp()
415 if ss:
416 attrs['branch'] = ss.branch
417 attrs['revision'] = ss.revision
418 attrs['patch'] = ss.patch
419 attrs['changes'] = ss.changes[:]
420
421 return attrs
422
423 - def createEmail(self, msgdict, builderName, projectName, results, build,
424 patch=None, logs=None):
425 text = msgdict['body'].encode(ENCODING)
426 type = msgdict['type']
427 if 'subject' in msgdict:
428 subject = msgdict['subject'].encode(ENCODING)
429 else:
430 subject = self.subject % { 'result': Results[results],
431 'projectName': projectName,
432 'builder': builderName,
433 }
434
435
436 assert type in ('plain', 'html'), "'%s' message type must be 'plain' or 'html'." % type
437
438 if patch or logs:
439 m = MIMEMultipart()
440 m.attach(MIMEText(text, type, ENCODING))
441 else:
442 m = Message()
443 m.set_payload(text, ENCODING)
444 m.set_type("text/%s" % type)
445
446 m['Date'] = formatdate(localtime=True)
447 m['Subject'] = subject
448 m['From'] = self.fromaddr
449
450
451 if patch:
452 a = MIMEText(patch[1].encode(ENCODING), _charset=ENCODING)
453 a.add_header('Content-Disposition', "attachment",
454 filename="source patch")
455 m.attach(a)
456 if logs:
457 for log in logs:
458 name = "%s.%s" % (log.getStep().getName(),
459 log.getName())
460 if self._shouldAttachLog(log.getName()) or self._shouldAttachLog(name):
461 a = MIMEText(log.getText().encode(ENCODING),
462 _charset=ENCODING)
463 a.add_header('Content-Disposition', "attachment",
464 filename=name)
465 m.attach(a)
466
467
468
469 if self.extraHeaders:
470 properties = build.getProperties()
471 for k,v in self.extraHeaders.items():
472 k = properties.render(k)
473 if k in m:
474 twlog.msg("Warning: Got header " + k + " in self.extraHeaders "
475 "but it already exists in the Message - "
476 "not adding it.")
477 continue
478 m[k] = properties.render(v)
479
480 return m
481
483 if self.customMesg:
484
485 attrs = self.getCustomMesgData(self.mode, name, build, results, self.master_status)
486 text, type = self.customMesg(attrs)
487 msgdict = { 'body' : text, 'type' : type }
488 else:
489 msgdict = self.messageFormatter(self.mode, name, build, results, self.master_status)
490
491 patch = None
492 ss = build.getSourceStamp()
493 if ss and ss.patch and self.addPatch:
494 patch == ss.patch
495 logs = None
496 if self.addLogs:
497 logs = build.getLogs()
498 twlog.err("LOG: %s" % str(logs))
499 m = self.createEmail(msgdict, name, self.master_status.getProjectName(),
500 results, build, patch, logs)
501
502
503 dl = []
504 recipients = []
505 if self.sendToInterestedUsers and self.lookup:
506 for u in build.getInterestedUsers():
507 d = defer.maybeDeferred(self.lookup.getAddress, u)
508 d.addCallback(recipients.append)
509 dl.append(d)
510 d = defer.DeferredList(dl)
511 d.addCallback(self._gotRecipients, recipients, m)
512 return d
513
515 if type(self.addLogs) is bool:
516 return self.addLogs
517 return logname in self.addLogs
518
520 to_recipients = set()
521 cc_recipients = set()
522
523 for r in rlist:
524 if r is None:
525 continue
526
527
528
529 if r.count('@') > 1:
530 r = r[:r.rindex('@')]
531
532 if VALID_EMAIL.search(r):
533 to_recipients.add(r)
534 else:
535 twlog.msg("INVALID EMAIL: %r" + r)
536
537
538
539
540 if self.sendToInterestedUsers and to_recipients:
541 cc_recipients.update(self.extraRecipients)
542 else:
543 to_recipients.update(self.extraRecipients)
544
545 m['To'] = ", ".join(sorted(to_recipients))
546 if cc_recipients:
547 m['CC'] = ", ".join(sorted(cc_recipients))
548
549 return self.sendMessage(m, list(to_recipients | cc_recipients))
550
552 result = defer.Deferred()
553
554 if have_ssl and self.useTls:
555 client_factory = ssl.ClientContextFactory()
556 client_factory.method = SSLv3_METHOD
557 else:
558 client_factory = None
559
560 if self.smtpUser and self.smtpPassword:
561 useAuth = True
562 else:
563 useAuth = False
564
565 sender_factory = ESMTPSenderFactory(
566 self.smtpUser, self.smtpPassword,
567 self.fromaddr, recipients, StringIO(s),
568 result, contextFactory=client_factory,
569 requireTransportSecurity=self.useTls,
570 requireAuthentication=useAuth)
571
572 reactor.connectTCP(self.relayhost, self.smtpPort, sender_factory)
573
574 return result
575
577 s = m.as_string()
578 twlog.msg("sending mail (%d bytes) to" % len(s), recipients)
579 return self.sendmail(s, recipients)
580