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