1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
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
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
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
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
331
335
336
343
344
351
357
359
360 if self.categories != None and builder.category not in self.categories:
361 return None
362
363 self.watched.append(builder)
364 return self
365
368
400
412
413 - def _gotBuilds(self, res, builddicts, buildset, builders):
423
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
440
446
448
449
450
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
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
542
543
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
561 if self.customMesg:
562
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
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
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
611 if type(self.addLogs) is bool:
612 return self.addLogs
613 return logname in self.addLogs
614
616 to_recipients = set()
617 cc_recipients = set()
618
619 for r in rlist:
620 if r is None:
621 continue
622
623
624
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
634
635
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
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
673 s = m.as_string()
674 twlog.msg("sending mail (%d bytes) to" % len(s), recipients)
675 return self.sendmail(s, recipients)
676