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 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
47 -def defaultMessage(mode, name, build, results, master_status):
48 """Generate a buildbot mail message and return a tuple of message text 49 and type.""" 50 result = Results[results] 51 ss = build.getSourceStamp() 52 53 text = "" 54 if mode == "all": 55 text += "The Buildbot has finished a build" 56 elif mode == "failing": 57 text += "The Buildbot has detected a failed build" 58 elif mode == "passing": 59 text += "The Buildbot has detected a passing build" 60 elif mode == "change" and result == 'success': 61 text += "The Buildbot has detected a restored build" 62 else: 63 text += "The Buildbot has detected a new failure" 64 if ss and ss.project: 65 project = ss.project 66 else: 67 project = master_status.getProjectName() 68 text += " on builder %s while building %s.\n" % (name, project) 69 if master_status.getURLForThing(build): 70 text += "Full details are available at:\n %s\n" % master_status.getURLForThing(build) 71 text += "\n" 72 73 if master_status.getBuildbotURL(): 74 text += "Buildbot URL: %s\n\n" % urllib.quote(master_status.getBuildbotURL(), '/:') 75 76 text += "Buildslave for this Build: %s\n\n" % build.getSlavename() 77 text += "Build Reason: %s\n" % build.getReason() 78 79 source = "" 80 if ss and ss.branch: 81 source += "[branch %s] " % ss.branch 82 if ss and ss.revision: 83 source += str(ss.revision) 84 else: 85 source += "HEAD" 86 if ss and ss.patch: 87 source += " (plus patch)" 88 89 text += "Build Source Stamp: %s\n" % source 90 91 text += "Blamelist: %s\n" % ",".join(build.getResponsibleUsers()) 92 93 text += "\n" 94 95 t = build.getText() 96 if t: 97 t = ": " + " ".join(t) 98 else: 99 t = "" 100 101 if result == 'success': 102 text += "Build succeeded!\n" 103 elif result == 'warnings': 104 text += "Build Had Warnings%s\n" % t 105 else: 106 text += "BUILD FAILED%s\n" % t 107 108 text += "\n" 109 text += "sincerely,\n" 110 text += " -The Buildbot\n" 111 text += "\n" 112 return { 'body' : text, 'type' : 'plain' }
113
114 -class MailNotifier(base.StatusReceiverMultiService):
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 # require full email addresses, not User names 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 # you should either limit on builders or categories, not both 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
299 - def setServiceParent(self, parent):
300 """ 301 @type parent: L{buildbot.master.BuildMaster} 302 """ 303 base.StatusReceiverMultiService.setServiceParent(self, parent) 304 self.setup()
305
306 - def setup(self):
307 self.master_status = self.parent.getStatus() 308 self.master_status.subscribe(self)
309
310 - def disownServiceParent(self):
311 self.master_status.unsubscribe(self) 312 for w in self.watched: 313 w.unsubscribe(self) 314 return base.StatusReceiverMultiService.disownServiceParent(self)
315
316 - def builderAdded(self, name, builder):
317 # only subscribe to builders we are interested in 318 if self.categories != None and builder.category not in self.categories: 319 return None 320 321 self.watched.append(builder) 322 return self # subscribe to this builder
323
324 - def builderRemoved(self, name):
325 pass
326
327 - def builderChangedState(self, name, state):
328 pass
329 - def buildStarted(self, name, build):
330 pass
331 - def buildFinished(self, name, build, results):
332 # here is where we actually do something. 333 builder = build.getBuilder() 334 if self.builders is not None and name not in self.builders: 335 return # ignore this build 336 if self.categories is not None and \ 337 builder.category not in self.categories: 338 return # ignore this build 339 340 if self.mode == "failing" and results != FAILURE: 341 return 342 if self.mode == "passing" and results != SUCCESS: 343 return 344 if self.mode == "problem": 345 if results != FAILURE: 346 return 347 prev = build.getPreviousBuild() 348 if prev and prev.getResults() == FAILURE: 349 return 350 if self.mode == "change": 351 prev = build.getPreviousBuild() 352 if not prev or prev.getResults() == results: 353 return 354 # for testing purposes, buildMessage returns a Deferred that fires 355 # when the mail has been sent. To help unit tests, we return that 356 # Deferred here even though the normal IStatusReceiver.buildFinished 357 # signature doesn't do anything with it. If that changes (if 358 # .buildFinished's return value becomes significant), we need to 359 # rearrange this. 360 return self.buildMessage(name, build, results)
361
362 - def getCustomMesgData(self, mode, name, build, results, master_status):
363 # 364 # logs is a list of tuples that contain the log 365 # name, log url, and the log contents as a list of strings. 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 # m['To'] is added later 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 # Add any extra headers that were requested, doing WithProperties 449 # interpolation if necessary 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
463 - def buildMessage(self, name, build, results):
464 if self.customMesg: 465 # the customMesg stuff can be *huge*, so we prefer not to load it 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 # now, who is this message going to? 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
495 - def _shouldAttachLog(self, logname):
496 if type(self.addLogs) is bool: 497 return self.addLogs 498 return logname in self.addLogs
499
500 - def _gotRecipients(self, res, rlist, m):
501 to_recipients = set() 502 cc_recipients = set() 503 504 for r in rlist: 505 if r is None: # getAddress didn't like this address 506 continue 507 508 # Git can give emails like 'User' <user@foo.com>@foo.com so check 509 # for two @ and chop the last 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 # If we're sending to interested users put the extras in the 519 # CC list so they can tell if they are also interested in the 520 # change: 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
532 - def sendmail(self, s, recipients):
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
557 - def sendMessage(self, m, recipients):
558 s = m.as_string() 559 twlog.msg("sending mail (%d bytes) to" % len(s), recipients) 560 return self.sendmail(s, recipients)
561