Package buildbot :: Package status :: Module mail
[frames] | no frames]

Source Code for Module buildbot.status.mail

  1  # -*- test-case-name: buildbot.test.test_status -*- 
  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 sendmail, ESMTPSenderFactory 
 15  from twisted.python import log as twlog 
 16   
 17  try: 
 18      from twisted.internet import ssl 
 19      from OpenSSL.SSL import SSLv3_METHOD 
 20  except ImportError: 
 21      pass 
 22   
 23  from buildbot import interfaces, util 
 24  from buildbot.status import base 
 25  from buildbot.status.builder import FAILURE, SUCCESS, Results 
 26   
 27  VALID_EMAIL = re.compile("[a-zA-Z0-9\.\_\%\-\+]+@[a-zA-Z0-9\.\_\%\-]+.[a-zA-Z]{2,6}") 
 28   
 29  ENCODING = 'utf8' 
 30   
31 -class Domain(util.ComparableMixin):
32 implements(interfaces.IEmailLookup) 33 compare_attrs = ["domain"] 34
35 - def __init__(self, domain):
36 assert "@" not in domain 37 self.domain = domain
38
39 - def getAddress(self, name):
40 """If name is already an email address, pass it through.""" 41 if '@' in name: 42 return name 43 return name + "@" + self.domain
44 45
46 -def defaultMessage(mode, name, build, results, master_status):
47 """Generate a buildbot mail message and return a tuple of message text 48 and type.""" 49 result = Results[results] 50 ss = build.getSourceStamp() 51 52 text = "" 53 if mode == "all": 54 text += "The Buildbot has finished a build" 55 elif mode == "failing": 56 text += "The Buildbot has detected a failed build" 57 elif mode == "passing": 58 text += "The Buildbot has detected a passing build" 59 elif mode == "change" and result == 'success': 60 text += "The Buildbot has detected a restored build" 61 else: 62 text += "The Buildbot has detected a new failure" 63 if ss and ss.project: 64 project = ss.project 65 else: 66 project = master_status.getProjectName() 67 text += " on builder %s while building %s.\n" % (name, project) 68 if master_status.getURLForThing(build): 69 text += "Full details are available at:\n %s\n" % master_status.getURLForThing(build) 70 text += "\n" 71 72 if master_status.getBuildbotURL(): 73 text += "Buildbot URL: %s\n\n" % urllib.quote(master_status.getBuildbotURL(), '/:') 74 75 text += "Buildslave for this Build: %s\n\n" % build.getSlavename() 76 text += "Build Reason: %s\n" % build.getReason() 77 78 source = "" 79 if ss and ss.branch: 80 source += "[branch %s] " % ss.branch 81 if ss and ss.revision: 82 source += str(ss.revision) 83 else: 84 source += "HEAD" 85 if ss and ss.patch: 86 source += " (plus patch)" 87 88 text += "Build Source Stamp: %s\n" % source 89 90 text += "Blamelist: %s\n" % ",".join(build.getResponsibleUsers()) 91 92 text += "\n" 93 94 t = build.getText() 95 if t: 96 t = ": " + " ".join(t) 97 else: 98 t = "" 99 100 if result == 'success': 101 text += "Build succeeded!\n" 102 elif result == 'warnings': 103 text += "Build Had Warnings%s\n" % t 104 else: 105 text += "BUILD FAILED%s\n" % t 106 107 text += "\n" 108 text += "sincerely,\n" 109 text += " -The Buildbot\n" 110 text += "\n" 111 return { 'body' : text, 'type' : 'plain' }
112
113 -class MailNotifier(base.StatusReceiverMultiService):
114 """This is a status notifier which sends email to a list of recipients 115 upon the completion of each build. It can be configured to only send out 116 mail for certain builds, and only send messages when the build fails, or 117 when it transitions from success to failure. It can also be configured to 118 include various build logs in each message. 119 120 By default, the message will be sent to the Interested Users list, which 121 includes all developers who made changes in the build. You can add 122 additional recipients with the extraRecipients argument. 123 124 To get a simple one-message-per-build (say, for a mailing list), use 125 sendToInterestedUsers=False, extraRecipients=['listaddr@example.org'] 126 127 Each MailNotifier sends mail to a single set of recipients. To send 128 different kinds of mail to different recipients, use multiple 129 MailNotifiers. 130 """ 131 132 implements(interfaces.IEmailSender) 133 134 compare_attrs = ["extraRecipients", "lookup", "fromaddr", "mode", 135 "categories", "builders", "addLogs", "relayhost", 136 "subject", "sendToInterestedUsers", "customMesg", 137 "messageFormatter", "extraHeaders"] 138
139 - def __init__(self, fromaddr, mode="all", categories=None, builders=None, 140 addLogs=False, relayhost="localhost", 141 subject="buildbot %(result)s in %(projectName)s on %(builder)s", 142 lookup=None, extraRecipients=[], 143 sendToInterestedUsers=True, customMesg=None, 144 messageFormatter=defaultMessage, extraHeaders=None, 145 addPatch=True, useTls=False, 146 smtpUser=None, smtpPassword=None, smtpPort=25):
147 """ 148 @type fromaddr: string 149 @param fromaddr: the email address to be used in the 'From' header. 150 @type sendToInterestedUsers: boolean 151 @param sendToInterestedUsers: if True (the default), send mail to all 152 of the Interested Users. If False, only 153 send mail to the extraRecipients list. 154 155 @type extraRecipients: tuple of string 156 @param extraRecipients: a list of email addresses to which messages 157 should be sent (in addition to the 158 InterestedUsers list, which includes any 159 developers who made Changes that went into this 160 build). It is a good idea to create a small 161 mailing list and deliver to that, then let 162 subscribers come and go as they please. 163 164 @type subject: string 165 @param subject: a string to be used as the subject line of the message. 166 %(builder)s will be replaced with the name of the 167 builder which provoked the message. 168 169 @type mode: string (defaults to all) 170 @param mode: one of: 171 - 'all': send mail about all builds, passing and failing 172 - 'failing': only send mail about builds which fail 173 - 'passing': only send mail about builds which succeed 174 - 'problem': only send mail about a build which failed 175 when the previous build passed 176 - 'change': only send mail about builds who change status 177 178 @type builders: list of strings 179 @param builders: a list of builder names for which mail should be 180 sent. Defaults to None (send mail for all builds). 181 Use either builders or categories, but not both. 182 183 @type categories: list of strings 184 @param categories: a list of category names to serve status 185 information for. Defaults to None (all 186 categories). Use either builders or categories, 187 but not both. 188 189 @type addLogs: boolean 190 @param addLogs: if True, include all build logs as attachments to the 191 messages. These can be quite large. This can also be 192 set to a list of log names, to send a subset of the 193 logs. Defaults to False. 194 195 @type addPatch: boolean 196 @param addPatch: if True, include the patch when the source stamp 197 includes one. 198 199 @type relayhost: string 200 @param relayhost: the host to which the outbound SMTP connection 201 should be made. Defaults to 'localhost' 202 203 @type lookup: implementor of {IEmailLookup} 204 @param lookup: object which provides IEmailLookup, which is 205 responsible for mapping User names (which come from 206 the VC system) into valid email addresses. If not 207 provided, the notifier will only be able to send mail 208 to the addresses in the extraRecipients list. Most of 209 the time you can use a simple Domain instance. As a 210 shortcut, you can pass as string: this will be 211 treated as if you had provided Domain(str). For 212 example, lookup='twistedmatrix.com' will allow mail 213 to be sent to all developers whose SVN usernames 214 match their twistedmatrix.com account names. 215 216 @type customMesg: func 217 @param customMesg: (this function is deprecated) 218 219 @type messageFormatter: func 220 @param messageFormatter: function taking (mode, name, build, result, 221 master_status ) and returning a dictionary 222 containing two required keys "body" and "type", 223 with a third optional key, "subject". The 224 "body" key gives a string that contains the 225 complete text of the message. The "type" key 226 is the message type ('plain' or 'html'). The 227 'html' type should be used when generating an 228 HTML message. The optional "subject" key 229 gives the subject for the email. 230 231 @type extraHeaders: dict 232 @param extraHeaders: A dict of extra headers to add to the mail. It's 233 best to avoid putting 'To', 'From', 'Date', 234 'Subject', or 'CC' in here. Both the names and 235 values may be WithProperties instances. 236 237 @type useTls: boolean 238 @param useTls: Send emails using TLS and authenticate with the 239 smtp host. Defaults to False. 240 241 @type smtpUser: string 242 @param smtpUser: The user that will attempt to authenticate with the 243 relayhost when useTls is True. 244 245 @type smtpPassword: string 246 @param smtpPassword: The password that smtpUser will use when 247 authenticating with relayhost. 248 249 @type smtpPort: int 250 @param smtpPort: The port that will be used when connecting to the 251 relayhost. Defaults to 25. 252 """ 253 254 base.StatusReceiverMultiService.__init__(self) 255 assert isinstance(extraRecipients, (list, tuple)) 256 for r in extraRecipients: 257 assert isinstance(r, str) 258 assert VALID_EMAIL.search(r) # require full email addresses, not User names 259 self.extraRecipients = extraRecipients 260 self.sendToInterestedUsers = sendToInterestedUsers 261 self.fromaddr = fromaddr 262 assert mode in ('all', 'failing', 'problem', 'change', 'passing') 263 self.mode = mode 264 self.categories = categories 265 self.builders = builders 266 self.addLogs = addLogs 267 self.relayhost = relayhost 268 self.subject = subject 269 if lookup is not None: 270 if type(lookup) is str: 271 lookup = Domain(lookup) 272 assert interfaces.IEmailLookup.providedBy(lookup) 273 self.lookup = lookup 274 self.customMesg = customMesg 275 self.messageFormatter = messageFormatter 276 if extraHeaders: 277 assert isinstance(extraHeaders, dict) 278 self.extraHeaders = extraHeaders 279 self.addPatch = addPatch 280 self.useTls = useTls 281 self.smtpUser = smtpUser 282 self.smtpPassword = smtpPassword 283 self.smtpPort = smtpPort 284 self.watched = [] 285 self.master_status = None 286 287 # you should either limit on builders or categories, not both 288 if self.builders != None and self.categories != None: 289 twlog.err("Please specify only builders to ignore or categories to include") 290 raise # FIXME: the asserts above do not raise some Exception either 291 292 if customMesg: 293 twlog.msg("customMesg is deprecated; please use messageFormatter instead")
294
295 - def setServiceParent(self, parent):
296 """ 297 @type parent: L{buildbot.master.BuildMaster} 298 """ 299 base.StatusReceiverMultiService.setServiceParent(self, parent) 300 self.setup()
301
302 - def setup(self):
303 self.master_status = self.parent.getStatus() 304 self.master_status.subscribe(self)
305
306 - def disownServiceParent(self):
307 self.master_status.unsubscribe(self) 308 for w in self.watched: 309 w.unsubscribe(self) 310 return base.StatusReceiverMultiService.disownServiceParent(self)
311
312 - def builderAdded(self, name, builder):
313 # only subscribe to builders we are interested in 314 if self.categories != None and builder.category not in self.categories: 315 return None 316 317 self.watched.append(builder) 318 return self # subscribe to this builder
319
320 - def builderRemoved(self, name):
321 pass
322
323 - def builderChangedState(self, name, state):
324 pass
325 - def buildStarted(self, name, build):
326 pass
327 - def buildFinished(self, name, build, results):
328 # here is where we actually do something. 329 builder = build.getBuilder() 330 if self.builders is not None and name not in self.builders: 331 return # ignore this build 332 if self.categories is not None and \ 333 builder.category not in self.categories: 334 return # ignore this build 335 336 if self.mode == "failing" and results != FAILURE: 337 return 338 if self.mode == "passing" and results != SUCCESS: 339 return 340 if self.mode == "problem": 341 if results != FAILURE: 342 return 343 prev = build.getPreviousBuild() 344 if prev and prev.getResults() == FAILURE: 345 return 346 if self.mode == "change": 347 prev = build.getPreviousBuild() 348 if not prev or prev.getResults() == results: 349 return 350 # for testing purposes, buildMessage returns a Deferred that fires 351 # when the mail has been sent. To help unit tests, we return that 352 # Deferred here even though the normal IStatusReceiver.buildFinished 353 # signature doesn't do anything with it. If that changes (if 354 # .buildFinished's return value becomes significant), we need to 355 # rearrange this. 356 return self.buildMessage(name, build, results)
357
358 - def getCustomMesgData(self, mode, name, build, results, master_status):
359 # 360 # logs is a list of tuples that contain the log 361 # name, log url, and the log contents as a list of strings. 362 # 363 logs = list() 364 for logf in build.getLogs(): 365 logStep = logf.getStep() 366 stepName = logStep.getName() 367 logStatus, dummy = logStep.getResults() 368 logName = logf.getName() 369 logs.append(('%s.%s' % (stepName, logName), 370 '%s/steps/%s/logs/%s' % (master_status.getURLForThing(build), stepName, logName), 371 logf.getText().splitlines(), 372 logStatus)) 373 374 properties = build.getProperties() 375 376 attrs = {'builderName': name, 377 'projectName': master_status.getProjectName(), 378 'mode': mode, 379 'result': Results[results], 380 'buildURL': master_status.getURLForThing(build), 381 'buildbotURL': master_status.getBuildbotURL(), 382 'buildText': build.getText(), 383 'buildProperties': properties, 384 'slavename': build.getSlavename(), 385 'reason': build.getReason(), 386 'responsibleUsers': build.getResponsibleUsers(), 387 'branch': "", 388 'revision': "", 389 'patch': "", 390 'changes': [], 391 'logs': logs} 392 393 ss = build.getSourceStamp() 394 if ss: 395 attrs['branch'] = ss.branch 396 attrs['revision'] = ss.revision 397 attrs['patch'] = ss.patch 398 attrs['changes'] = ss.changes[:] 399 400 return attrs
401
402 - def createEmail(self, msgdict, builderName, projectName, results, 403 patch=None, logs=None):
404 text = msgdict['body'].encode(ENCODING) 405 type = msgdict['type'] 406 if 'subject' in msgdict: 407 subject = msgdict['subject'].encode(ENCODING) 408 else: 409 subject = self.subject % { 'result': Results[results], 410 'projectName': projectName, 411 'builder': builderName, 412 } 413 414 415 assert type in ('plain', 'html'), "'%s' message type must be 'plain' or 'html'." % type 416 417 if patch or logs: 418 m = MIMEMultipart() 419 m.attach(MIMEText(text, type, ENCODING)) 420 else: 421 m = Message() 422 m.set_payload(text, ENCODING) 423 m.set_type("text/%s" % type) 424 425 m['Date'] = formatdate(localtime=True) 426 m['Subject'] = subject 427 m['From'] = self.fromaddr 428 # m['To'] is added later 429 430 if patch: 431 a = MIMEText(patch[1].encode(ENCODING), _charset=ENCODING) 432 a.add_header('Content-Disposition', "attachment", 433 filename="source patch") 434 m.attach(a) 435 if logs: 436 for log in logs: 437 name = "%s.%s" % (log.getStep().getName(), 438 log.getName()) 439 if self._shouldAttachLog(log.getName()) or self._shouldAttachLog(name): 440 a = MIMEText(log.getText().encode(ENCODING), 441 _charset=ENCODING) 442 a.add_header('Content-Disposition', "attachment", 443 filename=name) 444 m.attach(a) 445 446 # Add any extra headers that were requested, doing WithProperties 447 # interpolation if necessary 448 if self.extraHeaders: 449 for k,v in self.extraHeaders.items(): 450 k = properties.render(k) 451 if k in m: 452 twlog("Warning: Got header " + k + " in self.extraHeaders " 453 "but it already exists in the Message - " 454 "not adding it.") 455 continue 456 m[k] = properties.render(v) 457 458 return m
459
460 - def buildMessage(self, name, build, results):
461 if self.customMesg: 462 # the customMesg stuff can be *huge*, so we prefer not to load it 463 attrs = self.getCustomMesgData(self.mode, name, build, results, self.master_status) 464 text, type = self.customMesg(attrs) 465 msgdict = { 'body' : text, 'type' : type } 466 else: 467 msgdict = self.messageFormatter(self.mode, name, build, results, self.master_status) 468 469 patch = None 470 ss = build.getSourceStamp() 471 if ss and ss.patch and self.addPatch: 472 patch == ss.patch 473 logs = None 474 if self.addLogs: 475 logs = build.getLogs() 476 twlog.err("LOG: %s" % str(logs)) 477 m = self.createEmail(msgdict, name, self.master_status.getProjectName(), 478 results, patch, logs) 479 480 # now, who is this message going to? 481 dl = [] 482 recipients = [] 483 if self.sendToInterestedUsers and self.lookup: 484 for u in build.getInterestedUsers(): 485 d = defer.maybeDeferred(self.lookup.getAddress, u) 486 d.addCallback(recipients.append) 487 dl.append(d) 488 d = defer.DeferredList(dl) 489 d.addCallback(self._gotRecipients, recipients, m) 490 return d
491
492 - def _shouldAttachLog(self, logname):
493 if type(self.addLogs) is bool: 494 return self.addLogs 495 return logname in self.addLogs
496
497 - def _gotRecipients(self, res, rlist, m):
498 recipients = set() 499 500 for r in rlist: 501 if r is None: # getAddress didn't like this address 502 continue 503 504 # Git can give emails like 'User' <user@foo.com>@foo.com so check 505 # for two @ and chop the last 506 if r.count('@') > 1: 507 r = r[:r.rindex('@')] 508 509 if VALID_EMAIL.search(r): 510 recipients.add(r) 511 else: 512 twlog.msg("INVALID EMAIL: %r" + r) 513 514 # if we're sending to interested users move the extra's to the CC 515 # list so they can tell if they are also interested in the change 516 # unless there are no interested users 517 if self.sendToInterestedUsers and len(recipients): 518 extra_recips = self.extraRecipients[:] 519 extra_recips.sort() 520 m['CC'] = ", ".join(extra_recips) 521 else: 522 [recipients.add(r) for r in self.extraRecipients[:]] 523 524 rlist = list(recipients) 525 rlist.sort() 526 m['To'] = ", ".join(rlist) 527 528 # The extras weren't part of the TO list so add them now 529 if self.sendToInterestedUsers: 530 for r in self.extraRecipients: 531 recipients.add(r) 532 533 return self.sendMessage(m, list(recipients))
534
535 - def tls_sendmail(self, s, recipients):
536 client_factory = ssl.ClientContextFactory() 537 client_factory.method = SSLv3_METHOD 538 539 result = defer.Deferred() 540 541 542 sender_factory = ESMTPSenderFactory( 543 self.smtpUser, self.smtpPassword, 544 self.fromaddr, recipients, StringIO(s), 545 result, contextFactory=client_factory) 546 547 reactor.connectTCP(self.relayhost, self.smtpPort, sender_factory) 548 549 return result
550
551 - def sendMessage(self, m, recipients):
552 s = m.as_string() 553 twlog.msg("sending mail (%d bytes) to" % len(s), recipients) 554 if self.useTls: 555 return self.tls_sendmail(s, recipients) 556 else: 557 return sendmail(self.relayhost, self.fromaddr, recipients, s, 558 port=self.smtpPort)
559