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