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 attrs = {'builderName': name, 375 'projectName': master_status.getProjectName(), 376 'mode': mode, 377 'result': Results[results], 378 'buildURL': master_status.getURLForThing(build), 379 'buildbotURL': master_status.getBuildbotURL(), 380 'buildText': build.getText(), 381 'buildProperties': build.getProperties(), 382 'slavename': build.getSlavename(), 383 'reason': build.getReason(), 384 'responsibleUsers': build.getResponsibleUsers(), 385 'branch': "", 386 'revision': "", 387 'patch': "", 388 'changes': [], 389 'logs': logs} 390 391 ss = build.getSourceStamp() 392 if ss: 393 attrs['branch'] = ss.branch 394 attrs['revision'] = ss.revision 395 attrs['patch'] = ss.patch 396 attrs['changes'] = ss.changes[:] 397 398 return attrs
399
400 - def createEmail(self, msgdict, builderName, projectName, results, build, 401 patch=None, logs=None):
402 text = msgdict['body'].encode(ENCODING) 403 type = msgdict['type'] 404 if 'subject' in msgdict: 405 subject = msgdict['subject'].encode(ENCODING) 406 else: 407 subject = self.subject % { 'result': Results[results], 408 'projectName': projectName, 409 'builder': builderName, 410 } 411 412 413 assert type in ('plain', 'html'), "'%s' message type must be 'plain' or 'html'." % type 414 415 if patch or logs: 416 m = MIMEMultipart() 417 m.attach(MIMEText(text, type, ENCODING)) 418 else: 419 m = Message() 420 m.set_payload(text, ENCODING) 421 m.set_type("text/%s" % type) 422 423 m['Date'] = formatdate(localtime=True) 424 m['Subject'] = subject 425 m['From'] = self.fromaddr 426 # m['To'] is added later 427 428 if patch: 429 a = MIMEText(patch[1].encode(ENCODING), _charset=ENCODING) 430 a.add_header('Content-Disposition', "attachment", 431 filename="source patch") 432 m.attach(a) 433 if logs: 434 for log in logs: 435 name = "%s.%s" % (log.getStep().getName(), 436 log.getName()) 437 if self._shouldAttachLog(log.getName()) or self._shouldAttachLog(name): 438 a = MIMEText(log.getText().encode(ENCODING), 439 _charset=ENCODING) 440 a.add_header('Content-Disposition', "attachment", 441 filename=name) 442 m.attach(a) 443 444 # Add any extra headers that were requested, doing WithProperties 445 # interpolation if necessary 446 if self.extraHeaders: 447 properties = build.getProperties() 448 for k,v in self.extraHeaders.items(): 449 k = properties.render(k) 450 if k in m: 451 twlog("Warning: Got header " + k + " in self.extraHeaders " 452 "but it already exists in the Message - " 453 "not adding it.") 454 continue 455 m[k] = properties.render(v) 456 457 return m
458
459 - def buildMessage(self, name, build, results):
460 if self.customMesg: 461 # the customMesg stuff can be *huge*, so we prefer not to load it 462 attrs = self.getCustomMesgData(self.mode, name, build, results, self.master_status) 463 text, type = self.customMesg(attrs) 464 msgdict = { 'body' : text, 'type' : type } 465 else: 466 msgdict = self.messageFormatter(self.mode, name, build, results, self.master_status) 467 468 patch = None 469 ss = build.getSourceStamp() 470 if ss and ss.patch and self.addPatch: 471 patch == ss.patch 472 logs = None 473 if self.addLogs: 474 logs = build.getLogs() 475 twlog.err("LOG: %s" % str(logs)) 476 m = self.createEmail(msgdict, name, self.master_status.getProjectName(), 477 results, build, patch, logs) 478 479 # now, who is this message going to? 480 dl = [] 481 recipients = [] 482 if self.sendToInterestedUsers and self.lookup: 483 for u in build.getInterestedUsers(): 484 d = defer.maybeDeferred(self.lookup.getAddress, u) 485 d.addCallback(recipients.append) 486 dl.append(d) 487 d = defer.DeferredList(dl) 488 d.addCallback(self._gotRecipients, recipients, m) 489 return d
490
491 - def _shouldAttachLog(self, logname):
492 if type(self.addLogs) is bool: 493 return self.addLogs 494 return logname in self.addLogs
495
496 - def _gotRecipients(self, res, rlist, m):
497 recipients = set() 498 499 for r in rlist: 500 if r is None: # getAddress didn't like this address 501 continue 502 503 # Git can give emails like 'User' <user@foo.com>@foo.com so check 504 # for two @ and chop the last 505 if r.count('@') > 1: 506 r = r[:r.rindex('@')] 507 508 if VALID_EMAIL.search(r): 509 recipients.add(r) 510 else: 511 twlog.msg("INVALID EMAIL: %r" + r) 512 513 # if we're sending to interested users move the extra's to the CC 514 # list so they can tell if they are also interested in the change 515 # unless there are no interested users 516 if self.sendToInterestedUsers and len(recipients): 517 extra_recips = self.extraRecipients[:] 518 extra_recips.sort() 519 m['CC'] = ", ".join(extra_recips) 520 else: 521 [recipients.add(r) for r in self.extraRecipients[:]] 522 523 rlist = list(recipients) 524 rlist.sort() 525 m['To'] = ", ".join(rlist) 526 527 # The extras weren't part of the TO list so add them now 528 if self.sendToInterestedUsers: 529 for r in self.extraRecipients: 530 recipients.add(r) 531 532 return self.sendMessage(m, list(recipients))
533
534 - def tls_sendmail(self, s, recipients):
535 client_factory = ssl.ClientContextFactory() 536 client_factory.method = SSLv3_METHOD 537 538 result = defer.Deferred() 539 540 541 sender_factory = ESMTPSenderFactory( 542 self.smtpUser, self.smtpPassword, 543 self.fromaddr, recipients, StringIO(s), 544 result, contextFactory=client_factory) 545 546 reactor.connectTCP(self.relayhost, self.smtpPort, sender_factory) 547 548 return result
549
550 - def sendMessage(self, m, recipients):
551 s = m.as_string() 552 twlog.msg("sending mail (%d bytes) to" % len(s), recipients) 553 if self.useTls: 554 return self.tls_sendmail(s, recipients) 555 else: 556 return sendmail(self.relayhost, self.fromaddr, recipients, s, 557 port=self.smtpPort)
558