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.builder 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.getProjectName() 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 - def __init__(self, fromaddr, mode="all", categories=None, builders=None, 157 addLogs=False, relayhost="localhost", 158 subject="buildbot %(result)s in %(projectName)s on %(builder)s", 159 lookup=None, extraRecipients=[], 160 sendToInterestedUsers=True, customMesg=None, 161 messageFormatter=defaultMessage, extraHeaders=None, 162 addPatch=True, useTls=False, 163 smtpUser=None, smtpPassword=None, smtpPort=25):
164 """ 165 @type fromaddr: string 166 @param fromaddr: the email address to be used in the 'From' header. 167 @type sendToInterestedUsers: boolean 168 @param sendToInterestedUsers: if True (the default), send mail to all 169 of the Interested Users. If False, only 170 send mail to the extraRecipients list. 171 172 @type extraRecipients: tuple of string 173 @param extraRecipients: a list of email addresses to which messages 174 should be sent (in addition to the 175 InterestedUsers list, which includes any 176 developers who made Changes that went into this 177 build). It is a good idea to create a small 178 mailing list and deliver to that, then let 179 subscribers come and go as they please. The 180 addresses in this list are used literally (they 181 are not processed by lookup). 182 183 @type subject: string 184 @param subject: a string to be used as the subject line of the message. 185 %(builder)s will be replaced with the name of the 186 builder which provoked the message. 187 188 @type mode: string (defaults to all) 189 @param mode: one of: 190 - 'all': send mail about all builds, passing and failing 191 - 'failing': only send mail about builds which fail 192 - 'warnings': send mail if builds contain warnings or fail 193 - 'passing': only send mail about builds which succeed 194 - 'problem': only send mail about a build which failed 195 when the previous build passed 196 - 'change': only send mail about builds who change status 197 198 @type builders: list of strings 199 @param builders: a list of builder names for which mail should be 200 sent. Defaults to None (send mail for all builds). 201 Use either builders or categories, but not both. 202 203 @type categories: list of strings 204 @param categories: a list of category names to serve status 205 information for. Defaults to None (all 206 categories). Use either builders or categories, 207 but not both. 208 209 @type addLogs: boolean 210 @param addLogs: if True, include all build logs as attachments to the 211 messages. These can be quite large. This can also be 212 set to a list of log names, to send a subset of the 213 logs. Defaults to False. 214 215 @type addPatch: boolean 216 @param addPatch: if True, include the patch when the source stamp 217 includes one. 218 219 @type relayhost: string 220 @param relayhost: the host to which the outbound SMTP connection 221 should be made. Defaults to 'localhost' 222 223 @type lookup: implementor of {IEmailLookup} 224 @param lookup: object which provides IEmailLookup, which is 225 responsible for mapping User names for Interested 226 Users (which come from the VC system) into valid 227 email addresses. If not provided, the notifier will 228 only be able to send mail to the addresses in the 229 extraRecipients list. Most of the time you can use a 230 simple Domain instance. As a shortcut, you can pass 231 as string: this will be treated as if you had provided 232 Domain(str). For example, lookup='twistedmatrix.com' 233 will allow mail to be sent to all developers whose SVN 234 usernames match their twistedmatrix.com account names. 235 236 @type customMesg: func 237 @param customMesg: (this function is deprecated) 238 239 @type messageFormatter: func 240 @param messageFormatter: function taking (mode, name, build, result, 241 master_status) and returning a dictionary 242 containing two required keys "body" and "type", 243 with a third optional key, "subject". The 244 "body" key gives a string that contains the 245 complete text of the message. The "type" key 246 is the message type ('plain' or 'html'). The 247 'html' type should be used when generating an 248 HTML message. The optional "subject" key 249 gives the subject for the email. 250 251 @type extraHeaders: dict 252 @param extraHeaders: A dict of extra headers to add to the mail. It's 253 best to avoid putting 'To', 'From', 'Date', 254 'Subject', or 'CC' in here. Both the names and 255 values may be WithProperties instances. 256 257 @type useTls: boolean 258 @param useTls: Send emails using TLS and authenticate with the 259 smtp host. Defaults to False. 260 261 @type smtpUser: string 262 @param smtpUser: The user that will attempt to authenticate with the 263 relayhost when useTls is True. 264 265 @type smtpPassword: string 266 @param smtpPassword: The password that smtpUser will use when 267 authenticating with relayhost. 268 269 @type smtpPort: int 270 @param smtpPort: The port that will be used when connecting to the 271 relayhost. Defaults to 25. 272 """ 273 274 base.StatusReceiverMultiService.__init__(self) 275 assert isinstance(extraRecipients, (list, tuple)) 276 for r in extraRecipients: 277 assert isinstance(r, str) 278 # require full email addresses, not User names 279 assert VALID_EMAIL.search(r), "%s is not a valid email" % r 280 self.extraRecipients = extraRecipients 281 self.sendToInterestedUsers = sendToInterestedUsers 282 self.fromaddr = fromaddr 283 assert mode in ('all', 'failing', 'problem', 'change', 'passing', 'warnings') 284 self.mode = mode 285 self.categories = categories 286 self.builders = builders 287 self.addLogs = addLogs 288 self.relayhost = relayhost 289 self.subject = subject 290 if lookup is not None: 291 if type(lookup) is str: 292 lookup = Domain(lookup) 293 assert interfaces.IEmailLookup.providedBy(lookup) 294 self.lookup = lookup 295 self.customMesg = customMesg 296 self.messageFormatter = messageFormatter 297 if extraHeaders: 298 assert isinstance(extraHeaders, dict) 299 self.extraHeaders = extraHeaders 300 self.addPatch = addPatch 301 self.useTls = useTls 302 self.smtpUser = smtpUser 303 self.smtpPassword = smtpPassword 304 self.smtpPort = smtpPort 305 self.watched = [] 306 self.master_status = None 307 308 # you should either limit on builders or categories, not both 309 if self.builders != None and self.categories != None: 310 twlog.err("Please specify only builders or categories to include not both.") 311 raise interfaces.ParameterError("Please specify only builders or categories to include not both.") 312 313 if customMesg: 314 twlog.msg("customMesg is deprecated; please use messageFormatter instead")
315
316 - def setServiceParent(self, parent):
317 """ 318 @type parent: L{buildbot.master.BuildMaster} 319 """ 320 base.StatusReceiverMultiService.setServiceParent(self, parent) 321 self.setup()
322
323 - def setup(self):
324 self.master_status = self.parent.getStatus() 325 self.master_status.subscribe(self)
326
327 - def disownServiceParent(self):
328 self.master_status.unsubscribe(self) 329 for w in self.watched: 330 w.unsubscribe(self) 331 return base.StatusReceiverMultiService.disownServiceParent(self)
332
333 - def builderAdded(self, name, builder):
334 # only subscribe to builders we are interested in 335 if self.categories != None and builder.category not in self.categories: 336 return None 337 338 self.watched.append(builder) 339 return self # subscribe to this builder
340
341 - def builderRemoved(self, name):
342 pass
343
344 - def builderChangedState(self, name, state):
345 pass
346 - def buildStarted(self, name, build):
347 pass
348 - def buildFinished(self, name, build, results):
349 # here is where we actually do something. 350 builder = build.getBuilder() 351 if self.builders is not None and name not in self.builders: 352 return # ignore this build 353 if self.categories is not None and \ 354 builder.category not in self.categories: 355 return # ignore this build 356 357 if self.mode == "warnings" and results == SUCCESS: 358 return 359 if self.mode == "failing" and results != FAILURE: 360 return 361 if self.mode == "passing" and results != SUCCESS: 362 return 363 if self.mode == "problem": 364 if results != FAILURE: 365 return 366 prev = build.getPreviousBuild() 367 if prev and prev.getResults() == FAILURE: 368 return 369 if self.mode == "change": 370 prev = build.getPreviousBuild() 371 if not prev or prev.getResults() == results: 372 return 373 # for testing purposes, buildMessage returns a Deferred that fires 374 # when the mail has been sent. To help unit tests, we return that 375 # Deferred here even though the normal IStatusReceiver.buildFinished 376 # signature doesn't do anything with it. If that changes (if 377 # .buildFinished's return value becomes significant), we need to 378 # rearrange this. 379 return self.buildMessage(name, build, results)
380
381 - def getCustomMesgData(self, mode, name, build, results, master_status):
382 # 383 # logs is a list of tuples that contain the log 384 # name, log url, and the log contents as a list of strings. 385 # 386 logs = list() 387 for logf in build.getLogs(): 388 logStep = logf.getStep() 389 stepName = logStep.getName() 390 logStatus, dummy = logStep.getResults() 391 logName = logf.getName() 392 logs.append(('%s.%s' % (stepName, logName), 393 '%s/steps/%s/logs/%s' % (master_status.getURLForThing(build), stepName, logName), 394 logf.getText().splitlines(), 395 logStatus)) 396 397 attrs = {'builderName': name, 398 'projectName': master_status.getProjectName(), 399 'mode': mode, 400 'result': Results[results], 401 'buildURL': master_status.getURLForThing(build), 402 'buildbotURL': master_status.getBuildbotURL(), 403 'buildText': build.getText(), 404 'buildProperties': build.getProperties(), 405 'slavename': build.getSlavename(), 406 'reason': build.getReason(), 407 'responsibleUsers': build.getResponsibleUsers(), 408 'branch': "", 409 'revision': "", 410 'patch': "", 411 'changes': [], 412 'logs': logs} 413 414 ss = build.getSourceStamp() 415 if ss: 416 attrs['branch'] = ss.branch 417 attrs['revision'] = ss.revision 418 attrs['patch'] = ss.patch 419 attrs['changes'] = ss.changes[:] 420 421 return attrs
422
423 - def createEmail(self, msgdict, builderName, projectName, results, build, 424 patch=None, logs=None):
425 text = msgdict['body'].encode(ENCODING) 426 type = msgdict['type'] 427 if 'subject' in msgdict: 428 subject = msgdict['subject'].encode(ENCODING) 429 else: 430 subject = self.subject % { 'result': Results[results], 431 'projectName': projectName, 432 'builder': builderName, 433 } 434 435 436 assert type in ('plain', 'html'), "'%s' message type must be 'plain' or 'html'." % type 437 438 if patch or logs: 439 m = MIMEMultipart() 440 m.attach(MIMEText(text, type, ENCODING)) 441 else: 442 m = Message() 443 m.set_payload(text, ENCODING) 444 m.set_type("text/%s" % type) 445 446 m['Date'] = formatdate(localtime=True) 447 m['Subject'] = subject 448 m['From'] = self.fromaddr 449 # m['To'] is added later 450 451 if patch: 452 a = MIMEText(patch[1].encode(ENCODING), _charset=ENCODING) 453 a.add_header('Content-Disposition', "attachment", 454 filename="source patch") 455 m.attach(a) 456 if logs: 457 for log in logs: 458 name = "%s.%s" % (log.getStep().getName(), 459 log.getName()) 460 if self._shouldAttachLog(log.getName()) or self._shouldAttachLog(name): 461 a = MIMEText(log.getText().encode(ENCODING), 462 _charset=ENCODING) 463 a.add_header('Content-Disposition', "attachment", 464 filename=name) 465 m.attach(a) 466 467 # Add any extra headers that were requested, doing WithProperties 468 # interpolation if necessary 469 if self.extraHeaders: 470 properties = build.getProperties() 471 for k,v in self.extraHeaders.items(): 472 k = properties.render(k) 473 if k in m: 474 twlog.msg("Warning: Got header " + k + " in self.extraHeaders " 475 "but it already exists in the Message - " 476 "not adding it.") 477 continue 478 m[k] = properties.render(v) 479 480 return m
481
482 - def buildMessage(self, name, build, results):
483 if self.customMesg: 484 # the customMesg stuff can be *huge*, so we prefer not to load it 485 attrs = self.getCustomMesgData(self.mode, name, build, results, self.master_status) 486 text, type = self.customMesg(attrs) 487 msgdict = { 'body' : text, 'type' : type } 488 else: 489 msgdict = self.messageFormatter(self.mode, name, build, results, self.master_status) 490 491 patch = None 492 ss = build.getSourceStamp() 493 if ss and ss.patch and self.addPatch: 494 patch == ss.patch 495 logs = None 496 if self.addLogs: 497 logs = build.getLogs() 498 twlog.err("LOG: %s" % str(logs)) 499 m = self.createEmail(msgdict, name, self.master_status.getProjectName(), 500 results, build, patch, logs) 501 502 # now, who is this message going to? 503 dl = [] 504 recipients = [] 505 if self.sendToInterestedUsers and self.lookup: 506 for u in build.getInterestedUsers(): 507 d = defer.maybeDeferred(self.lookup.getAddress, u) 508 d.addCallback(recipients.append) 509 dl.append(d) 510 d = defer.DeferredList(dl) 511 d.addCallback(self._gotRecipients, recipients, m) 512 return d
513
514 - def _shouldAttachLog(self, logname):
515 if type(self.addLogs) is bool: 516 return self.addLogs 517 return logname in self.addLogs
518
519 - def _gotRecipients(self, res, rlist, m):
520 to_recipients = set() 521 cc_recipients = set() 522 523 for r in rlist: 524 if r is None: # getAddress didn't like this address 525 continue 526 527 # Git can give emails like 'User' <user@foo.com>@foo.com so check 528 # for two @ and chop the last 529 if r.count('@') > 1: 530 r = r[:r.rindex('@')] 531 532 if VALID_EMAIL.search(r): 533 to_recipients.add(r) 534 else: 535 twlog.msg("INVALID EMAIL: %r" + r) 536 537 # If we're sending to interested users put the extras in the 538 # CC list so they can tell if they are also interested in the 539 # change: 540 if self.sendToInterestedUsers and to_recipients: 541 cc_recipients.update(self.extraRecipients) 542 else: 543 to_recipients.update(self.extraRecipients) 544 545 m['To'] = ", ".join(sorted(to_recipients)) 546 if cc_recipients: 547 m['CC'] = ", ".join(sorted(cc_recipients)) 548 549 return self.sendMessage(m, list(to_recipients | cc_recipients))
550
551 - def sendmail(self, s, recipients):
552 result = defer.Deferred() 553 554 if have_ssl and self.useTls: 555 client_factory = ssl.ClientContextFactory() 556 client_factory.method = SSLv3_METHOD 557 else: 558 client_factory = None 559 560 if self.smtpUser and self.smtpPassword: 561 useAuth = True 562 else: 563 useAuth = False 564 565 sender_factory = ESMTPSenderFactory( 566 self.smtpUser, self.smtpPassword, 567 self.fromaddr, recipients, StringIO(s), 568 result, contextFactory=client_factory, 569 requireTransportSecurity=self.useTls, 570 requireAuthentication=useAuth) 571 572 reactor.connectTCP(self.relayhost, self.smtpPort, sender_factory) 573 574 return result
575
576 - def sendMessage(self, m, recipients):
577 s = m.as_string() 578 twlog.msg("sending mail (%d bytes) to" % len(s), recipients) 579 return self.sendmail(s, recipients)
580