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

Source Code for Module buildbot.status.mail

  1  # This file is part of Buildbot.  Buildbot is free software: you can 
  2  # redistribute it and/or modify it under the terms of the GNU General Public 
  3  # License as published by the Free Software Foundation, version 2. 
  4  # 
  5  # This program is distributed in the hope that it will be useful, but WITHOUT 
  6  # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 
  7  # FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more 
  8  # details. 
  9  # 
 10  # You should have received a copy of the GNU General Public License along with 
 11  # this program; if not, write to the Free Software Foundation, Inc., 51 
 12  # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 
 13  # 
 14  # Copyright Buildbot Team Members 
 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.results 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
61 -def defaultMessage(mode, name, build, results, master_status):
62 """Generate a buildbot mail message and return a tuple of message text 63 and type.""" 64 result = Results[results] 65 ss = build.getSourceStamp() 66 67 text = "" 68 if mode == "all": 69 text += "The Buildbot has finished a build" 70 elif mode == "failing": 71 text += "The Buildbot has detected a failed build" 72 elif mode == "warnings": 73 text += "The Buildbot has detected a problem in the build" 74 elif mode == "passing": 75 text += "The Buildbot has detected a passing build" 76 elif mode == "change" and result == 'success': 77 text += "The Buildbot has detected a restored build" 78 else: 79 text += "The Buildbot has detected a new failure" 80 if ss and ss.project: 81 project = ss.project 82 else: 83 project = master_status.getTitle() 84 text += " on builder %s while building %s.\n" % (name, project) 85 if master_status.getURLForThing(build): 86 text += "Full details are available at:\n %s\n" % master_status.getURLForThing(build) 87 text += "\n" 88 89 if master_status.getBuildbotURL(): 90 text += "Buildbot URL: %s\n\n" % urllib.quote(master_status.getBuildbotURL(), '/:') 91 92 text += "Buildslave for this Build: %s\n\n" % build.getSlavename() 93 text += "Build Reason: %s\n" % build.getReason() 94 95 source = "" 96 if ss and ss.branch: 97 source += "[branch %s] " % ss.branch 98 if ss and ss.revision: 99 source += str(ss.revision) 100 else: 101 source += "HEAD" 102 if ss and ss.patch: 103 source += " (plus patch)" 104 105 text += "Build Source Stamp: %s\n" % source 106 107 text += "Blamelist: %s\n" % ",".join(build.getResponsibleUsers()) 108 109 text += "\n" 110 111 t = build.getText() 112 if t: 113 t = ": " + " ".join(t) 114 else: 115 t = "" 116 117 if result == 'success': 118 text += "Build succeeded!\n" 119 elif result == 'warnings': 120 text += "Build Had Warnings%s\n" % t 121 else: 122 text += "BUILD FAILED%s\n" % t 123 124 text += "\n" 125 text += "sincerely,\n" 126 text += " -The Buildbot\n" 127 text += "\n" 128 return { 'body' : text, 'type' : 'plain' }
129
130 -class MailNotifier(base.StatusReceiverMultiService):
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 possible_modes = ('all', 'failing', 'problem', 'change', 'passing', 'warnings') 157
158 - def __init__(self, fromaddr, mode="all", categories=None, builders=None, 159 addLogs=False, relayhost="localhost", buildSetSummary=False, 160 subject="buildbot %(result)s in %(title)s on %(builder)s", 161 lookup=None, extraRecipients=[], 162 sendToInterestedUsers=True, customMesg=None, 163 messageFormatter=defaultMessage, extraHeaders=None, 164 addPatch=True, useTls=False, 165 smtpUser=None, smtpPassword=None, smtpPort=25):
166 """ 167 @type fromaddr: string 168 @param fromaddr: the email address to be used in the 'From' header. 169 @type sendToInterestedUsers: boolean 170 @param sendToInterestedUsers: if True (the default), send mail to all 171 of the Interested Users. If False, only 172 send mail to the extraRecipients list. 173 174 @type extraRecipients: tuple of string 175 @param extraRecipients: a list of email addresses to which messages 176 should be sent (in addition to the 177 InterestedUsers list, which includes any 178 developers who made Changes that went into this 179 build). It is a good idea to create a small 180 mailing list and deliver to that, then let 181 subscribers come and go as they please. The 182 addresses in this list are used literally (they 183 are not processed by lookup). 184 185 @type subject: string 186 @param subject: a string to be used as the subject line of the message. 187 %(builder)s will be replaced with the name of the 188 builder which provoked the message. 189 190 @type mode: string (defaults to all) 191 @param mode: one of MailNotifer.possible_modes: 192 - 'all': send mail about all builds, passing and failing 193 - 'failing': only send mail about builds which fail 194 - 'warnings': send mail if builds contain warnings or fail 195 - 'passing': only send mail about builds which succeed 196 - 'problem': only send mail about a build which failed 197 when the previous build passed 198 - 'change': only send mail about builds who change status 199 200 @type builders: list of strings 201 @param builders: a list of builder names for which mail should be 202 sent. Defaults to None (send mail for all builds). 203 Use either builders or categories, but not both. 204 205 @type categories: list of strings 206 @param categories: a list of category names to serve status 207 information for. Defaults to None (all 208 categories). Use either builders or categories, 209 but not both. 210 211 @type addLogs: boolean 212 @param addLogs: if True, include all build logs as attachments to the 213 messages. These can be quite large. This can also be 214 set to a list of log names, to send a subset of the 215 logs. Defaults to False. 216 217 @type addPatch: boolean 218 @param addPatch: if True, include the patch when the source stamp 219 includes one. 220 221 @type relayhost: string 222 @param relayhost: the host to which the outbound SMTP connection 223 should be made. Defaults to 'localhost' 224 225 @type buildSetSummary: boolean 226 @param buildSetSummary: if True, this notifier will only send a summary 227 email when a buildset containing any of its 228 watched builds completes 229 230 @type lookup: implementor of {IEmailLookup} 231 @param lookup: object which provides IEmailLookup, which is 232 responsible for mapping User names for Interested 233 Users (which come from the VC system) into valid 234 email addresses. If not provided, the notifier will 235 only be able to send mail to the addresses in the 236 extraRecipients list. Most of the time you can use a 237 simple Domain instance. As a shortcut, you can pass 238 as string: this will be treated as if you had provided 239 Domain(str). For example, lookup='twistedmatrix.com' 240 will allow mail to be sent to all developers whose SVN 241 usernames match their twistedmatrix.com account names. 242 243 @type customMesg: func 244 @param customMesg: (this function is deprecated) 245 246 @type messageFormatter: func 247 @param messageFormatter: function taking (mode, name, build, result, 248 master_status) and returning a dictionary 249 containing two required keys "body" and "type", 250 with a third optional key, "subject". The 251 "body" key gives a string that contains the 252 complete text of the message. The "type" key 253 is the message type ('plain' or 'html'). The 254 'html' type should be used when generating an 255 HTML message. The optional "subject" key 256 gives the subject for the email. 257 258 @type extraHeaders: dict 259 @param extraHeaders: A dict of extra headers to add to the mail. It's 260 best to avoid putting 'To', 'From', 'Date', 261 'Subject', or 'CC' in here. Both the names and 262 values may be WithProperties instances. 263 264 @type useTls: boolean 265 @param useTls: Send emails using TLS and authenticate with the 266 smtp host. Defaults to False. 267 268 @type smtpUser: string 269 @param smtpUser: The user that will attempt to authenticate with the 270 relayhost when useTls is True. 271 272 @type smtpPassword: string 273 @param smtpPassword: The password that smtpUser will use when 274 authenticating with relayhost. 275 276 @type smtpPort: int 277 @param smtpPort: The port that will be used when connecting to the 278 relayhost. Defaults to 25. 279 """ 280 281 base.StatusReceiverMultiService.__init__(self) 282 assert isinstance(extraRecipients, (list, tuple)) 283 for r in extraRecipients: 284 assert isinstance(r, str) 285 # require full email addresses, not User names 286 assert VALID_EMAIL.search(r), "%s is not a valid email" % r 287 self.extraRecipients = extraRecipients 288 self.sendToInterestedUsers = sendToInterestedUsers 289 self.fromaddr = fromaddr 290 assert mode in MailNotifier.possible_modes 291 self.mode = mode 292 self.categories = categories 293 self.builders = builders 294 self.addLogs = addLogs 295 self.relayhost = relayhost 296 self.subject = subject 297 if lookup is not None: 298 if type(lookup) is str: 299 lookup = Domain(lookup) 300 assert interfaces.IEmailLookup.providedBy(lookup) 301 self.lookup = lookup 302 self.customMesg = customMesg 303 self.messageFormatter = messageFormatter 304 if extraHeaders: 305 assert isinstance(extraHeaders, dict) 306 self.extraHeaders = extraHeaders 307 self.addPatch = addPatch 308 self.useTls = useTls 309 self.smtpUser = smtpUser 310 self.smtpPassword = smtpPassword 311 self.smtpPort = smtpPort 312 self.buildSetSummary = buildSetSummary 313 self.buildSetSubscription = None 314 self.watched = [] 315 self.master_status = None 316 317 # you should either limit on builders or categories, not both 318 if self.builders != None and self.categories != None: 319 twlog.err("Please specify only builders or categories to include not both.") 320 raise interfaces.ParameterError("Please specify only builders or categories to include not both.") 321 322 if customMesg: 323 twlog.msg("customMesg is deprecated; please use messageFormatter instead")
324
325 - def setServiceParent(self, parent):
326 """ 327 @type parent: L{buildbot.master.BuildMaster} 328 """ 329 base.StatusReceiverMultiService.setServiceParent(self, parent) 330 self.setup()
331
332 - def setup(self):
333 self.master_status = self.parent.getStatus() 334 self.master_status.subscribe(self)
335 336
337 - def startService(self):
338 if self.buildSetSummary: 339 self.buildSetSubscription = \ 340 self.parent.subscribeToBuildsetCompletions(self.buildsetFinished) 341 342 base.StatusReceiverMultiService.startService(self)
343 344
345 - def stopService(self):
346 if self.buildSetSubscription is not None: 347 self.buildSetSubscription.unsubscribe() 348 self.buildSetSubscription = None 349 350 return base.StatusReceiverMultiService.stopService(self)
351
352 - def disownServiceParent(self):
353 self.master_status.unsubscribe(self) 354 for w in self.watched: 355 w.unsubscribe(self) 356 return base.StatusReceiverMultiService.disownServiceParent(self)
357
358 - def builderAdded(self, name, builder):
359 # only subscribe to builders we are interested in 360 if self.categories != None and builder.category not in self.categories: 361 return None 362 363 self.watched.append(builder) 364 return self # subscribe to this builder
365
366 - def builderRemoved(self, name):
367 pass
368
369 - def builderChangedState(self, name, state):
370 pass
371 - def buildStarted(self, name, build):
372 pass
373 - def isMailNeeded(self, build, results):
374 # here is where we actually do something. 375 builder = build.getBuilder() 376 if self.builders is not None and builder.name not in self.builders: 377 return False # ignore this build 378 if self.categories is not None and \ 379 builder.category not in self.categories: 380 return False # ignore this build 381 382 if self.mode == "warnings" and results == SUCCESS: 383 return False 384 if self.mode == "failing" and results != FAILURE: 385 return False 386 if self.mode == "passing" and results != SUCCESS: 387 return False 388 if self.mode == "problem": 389 if results != FAILURE: 390 return False 391 prev = build.getPreviousBuild() 392 if prev and prev.getResults() == FAILURE: 393 return False 394 if self.mode == "change": 395 prev = build.getPreviousBuild() 396 if not prev or prev.getResults() == results: 397 return False 398 399 return True
400
401 - def buildFinished(self, name, build, results):
402 if ( not self.buildSetSummary and 403 self.isMailNeeded(build, results) ): 404 # for testing purposes, buildMessage returns a Deferred that fires 405 # when the mail has been sent. To help unit tests, we return that 406 # Deferred here even though the normal IStatusReceiver.buildFinished 407 # signature doesn't do anything with it. If that changes (if 408 # .buildFinished's return value becomes significant), we need to 409 # rearrange this. 410 return self.buildMessage(name, [build], results) 411 return None
412
413 - def _gotBuilds(self, res, builddicts, buildset, builders):
414 builds = [] 415 for (builddictlist, builder) in zip(builddicts, builders): 416 for builddict in builddictlist: 417 build = builder.getBuild(builddict['number']) 418 if self.isMailNeeded(build, build.results): 419 builds.append(build) 420 421 self.buildMessage("Buildset Complete: " + buildset['reason'], builds, 422 buildset['results'])
423
424 - def _gotBuildRequests(self, breqs, buildset):
425 builddicts = [] 426 builders =[] 427 dl = [] 428 for breq in breqs: 429 buildername = breq['buildername'] 430 builders.append(self.master_status.getBuilder(buildername)) 431 d = self.parent.db.builds.getBuildsForRequest(breq['brid']) 432 d.addCallback(builddicts.append) 433 dl.append(d) 434 d = defer.DeferredList(dl) 435 d.addCallback(self._gotBuilds, builddicts, buildset, builders)
436
437 - def _gotBuildSet(self, buildset, bsid):
438 d = self.parent.db.buildrequests.getBuildRequests(bsid=bsid) 439 d.addCallback(self._gotBuildRequests, buildset)
440
441 - def buildsetFinished(self, bsid, result):
442 d = self.parent.db.buildsets.getBuildset(bsid=bsid) 443 d.addCallback(self._gotBuildSet, bsid) 444 445 return d
446
447 - def getCustomMesgData(self, mode, name, build, results, master_status):
448 # 449 # logs is a list of tuples that contain the log 450 # name, log url, and the log contents as a list of strings. 451 # 452 logs = list() 453 for logf in build.getLogs(): 454 logStep = logf.getStep() 455 stepName = logStep.getName() 456 logStatus, dummy = logStep.getResults() 457 logName = logf.getName() 458 logs.append(('%s.%s' % (stepName, logName), 459 '%s/steps/%s/logs/%s' % ( 460 master_status.getURLForThing(build), 461 stepName, logName), 462 logf.getText().splitlines(), 463 logStatus)) 464 465 attrs = {'builderName': name, 466 'title': master_status.getTitle(), 467 'mode': mode, 468 'result': Results[results], 469 'buildURL': master_status.getURLForThing(build), 470 'buildbotURL': master_status.getBuildbotURL(), 471 'buildText': build.getText(), 472 'buildProperties': build.getProperties(), 473 'slavename': build.getSlavename(), 474 'reason': build.getReason(), 475 'responsibleUsers': build.getResponsibleUsers(), 476 'branch': "", 477 'revision': "", 478 'patch': "", 479 'patch_info': "", 480 'changes': [], 481 'logs': logs} 482 483 ss = build.getSourceStamp() 484 if ss: 485 attrs['branch'] = ss.branch 486 attrs['revision'] = ss.revision 487 attrs['patch'] = ss.patch 488 attrs['patch_info'] = ss.patch_info 489 attrs['changes'] = ss.changes[:] 490 491 return attrs
492
493 - def createEmail(self, msgdict, builderName, title, results, builds=None, 494 patches=None, logs=None):
495 text = msgdict['body'].encode(ENCODING) 496 type = msgdict['type'] 497 if 'subject' in msgdict: 498 subject = msgdict['subject'].encode(ENCODING) 499 else: 500 subject = self.subject % { 'result': Results[results], 501 'projectName': title, 502 'title': title, 503 'builder': builderName, 504 } 505 506 507 assert type in ('plain', 'html'), \ 508 "'%s' message type must be 'plain' or 'html'." % type 509 510 if patches or logs: 511 m = MIMEMultipart() 512 m.attach(MIMEText(text, type, ENCODING)) 513 else: 514 m = Message() 515 m.set_payload(text, ENCODING) 516 m.set_type("text/%s" % type) 517 518 m['Date'] = formatdate(localtime=True) 519 m['Subject'] = subject 520 m['From'] = self.fromaddr 521 # m['To'] is added later 522 523 if patches: 524 for (i, patch) in enumerate(patches): 525 a = MIMEText(patch[1].encode(ENCODING), _charset=ENCODING) 526 a.add_header('Content-Disposition', "attachment", 527 filename="source patch " + str(i) ) 528 m.attach(a) 529 if logs: 530 for log in logs: 531 name = "%s.%s" % (log.getStep().getName(), 532 log.getName()) 533 if ( self._shouldAttachLog(log.getName()) or 534 self._shouldAttachLog(name) ): 535 a = MIMEText(log.getText().encode(ENCODING), 536 _charset=ENCODING) 537 a.add_header('Content-Disposition', "attachment", 538 filename=name) 539 m.attach(a) 540 541 #@todo: is there a better way to do this? 542 # Add any extra headers that were requested, doing WithProperties 543 # interpolation if only one build was given 544 if self.extraHeaders: 545 for k,v in self.extraHeaders.items(): 546 if len(builds) == 1: 547 k = interfaces.IProperties(builds[0]).render(k) 548 if k in m: 549 twlog.msg("Warning: Got header " + k + 550 " in self.extraHeaders " 551 "but it already exists in the Message - " 552 "not adding it.") 553 if len(builds) == 1: 554 m[k] = interfaces.IProperties(builds[0]).render(v) 555 else: 556 m[k] = v 557 558 return m
559
560 - def buildMessageDict(self, name, build, results):
561 if self.customMesg: 562 # the customMesg stuff can be *huge*, so we prefer not to load it 563 attrs = self.getCustomMesgData(self.mode, name, build, results, 564 self.master_status) 565 text, type = self.customMesg(attrs) 566 msgdict = { 'body' : text, 'type' : type } 567 else: 568 msgdict = self.messageFormatter(self.mode, name, build, results, 569 self.master_status) 570 571 return msgdict
572 573
574 - def buildMessage(self, name, builds, results):
575 patches = [] 576 logs = [] 577 msgdict = {"body":""} 578 579 for build in builds: 580 ss = build.getSourceStamp() 581 if ss and ss.patch and self.addPatch: 582 patches.append(ss.patch) 583 if self.addLogs: 584 logs.extend(build.getLogs()) 585 586 tmp = self.buildMessageDict(name=build.getBuilder().name, 587 build=build, results=build.results) 588 msgdict['body'] += tmp['body'] 589 msgdict['body'] += '\n\n' 590 msgdict['type'] = tmp['type'] 591 if "subject" in tmp: 592 msgdict['subject'] = tmp['subject'] 593 594 m = self.createEmail(msgdict, name, self.master_status.getTitle(), 595 results, builds, patches, logs) 596 597 # now, who is this message going to? 598 dl = [] 599 recipients = [] 600 if self.sendToInterestedUsers and self.lookup: 601 for build in builds: 602 for u in build.getInterestedUsers(): 603 d = defer.maybeDeferred(self.lookup.getAddress, u) 604 d.addCallback(recipients.append) 605 dl.append(d) 606 d = defer.DeferredList(dl) 607 d.addCallback(self._gotRecipients, recipients, m) 608 return d
609
610 - def _shouldAttachLog(self, logname):
611 if type(self.addLogs) is bool: 612 return self.addLogs 613 return logname in self.addLogs
614
615 - def _gotRecipients(self, res, rlist, m):
616 to_recipients = set() 617 cc_recipients = set() 618 619 for r in rlist: 620 if r is None: # getAddress didn't like this address 621 continue 622 623 # Git can give emails like 'User' <user@foo.com>@foo.com so check 624 # for two @ and chop the last 625 if r.count('@') > 1: 626 r = r[:r.rindex('@')] 627 628 if VALID_EMAIL.search(r): 629 to_recipients.add(r) 630 else: 631 twlog.msg("INVALID EMAIL: %r" + r) 632 633 # If we're sending to interested users put the extras in the 634 # CC list so they can tell if they are also interested in the 635 # change: 636 if self.sendToInterestedUsers and to_recipients: 637 cc_recipients.update(self.extraRecipients) 638 else: 639 to_recipients.update(self.extraRecipients) 640 641 m['To'] = ", ".join(sorted(to_recipients)) 642 if cc_recipients: 643 m['CC'] = ", ".join(sorted(cc_recipients)) 644 645 return self.sendMessage(m, list(to_recipients | cc_recipients))
646
647 - def sendmail(self, s, recipients):
648 result = defer.Deferred() 649 650 if have_ssl and self.useTls: 651 client_factory = ssl.ClientContextFactory() 652 client_factory.method = SSLv3_METHOD 653 else: 654 client_factory = None 655 656 if self.smtpUser and self.smtpPassword: 657 useAuth = True 658 else: 659 useAuth = False 660 661 sender_factory = ESMTPSenderFactory( 662 self.smtpUser, self.smtpPassword, 663 self.fromaddr, recipients, StringIO(s), 664 result, contextFactory=client_factory, 665 requireTransportSecurity=self.useTls, 666 requireAuthentication=useAuth) 667 668 reactor.connectTCP(self.relayhost, self.smtpPort, sender_factory) 669 670 return result
671
672 - def sendMessage(self, m, recipients):
673 s = m.as_string() 674 twlog.msg("sending mail (%d bytes) to" % len(s), recipients) 675 return self.sendmail(s, recipients)
676