1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 import re
18 import types
19 from email.Message import Message
20 from email.Utils import formatdate
21 from email.MIMEText import MIMEText
22 from email.MIMENonMultipart import MIMENonMultipart
23 from email.MIMEMultipart import MIMEMultipart
24 from StringIO import StringIO
25 import urllib
26
27 from zope.interface import implements
28 from twisted.internet import defer, reactor
29 from twisted.python import log as twlog
30
31 try:
32 from twisted.mail.smtp import ESMTPSenderFactory
33 ESMTPSenderFactory = ESMTPSenderFactory
34 except ImportError:
35 ESMTPSenderFactory = None
36
37 have_ssl = True
38 try:
39 from twisted.internet import ssl
40 from OpenSSL.SSL import SSLv3_METHOD
41 except ImportError:
42 have_ssl = False
43
44
45
46 from email import Charset
47 Charset.add_charset('utf-8', Charset.SHORTEST, None, 'utf-8')
48
49 from buildbot import interfaces, util, config
50 from buildbot.process.users import users
51 from buildbot.status import base
52 from buildbot.status.results import FAILURE, SUCCESS, WARNINGS, EXCEPTION, Results
53
54 VALID_EMAIL = re.compile("[a-zA-Z0-9\.\_\%\-\+]+@[a-zA-Z0-9\.\_\%\-]+.[a-zA-Z]{2,6}")
55
56 ENCODING = 'utf8'
57 LOG_ENCODING = 'utf-8'
58
59 -class Domain(util.ComparableMixin):
60 implements(interfaces.IEmailLookup)
61 compare_attrs = ["domain"]
62
63 - def __init__(self, domain):
64 assert "@" not in domain
65 self.domain = domain
66
67 - def getAddress(self, name):
68 """If name is already an email address, pass it through."""
69 if '@' in name:
70 return name
71 return name + "@" + self.domain
72
155
157 """This is a status notifier which sends email to a list of recipients
158 upon the completion of each build. It can be configured to only send out
159 mail for certain builds, and only send messages when the build fails, or
160 when it transitions from success to failure. It can also be configured to
161 include various build logs in each message.
162
163 By default, the message will be sent to the Interested Users list, which
164 includes all developers who made changes in the build. You can add
165 additional recipients with the extraRecipients argument.
166
167 To get a simple one-message-per-build (say, for a mailing list), use
168 sendToInterestedUsers=False, extraRecipients=['listaddr@example.org']
169
170 Each MailNotifier sends mail to a single set of recipients. To send
171 different kinds of mail to different recipients, use multiple
172 MailNotifiers.
173 """
174
175 implements(interfaces.IEmailSender)
176
177 compare_attrs = ["extraRecipients", "lookup", "fromaddr", "mode",
178 "categories", "builders", "addLogs", "relayhost",
179 "subject", "sendToInterestedUsers", "customMesg",
180 "messageFormatter", "extraHeaders"]
181
182 possible_modes = ("change", "failing", "passing", "problem", "warnings", "exception")
183
184 - def __init__(self, fromaddr, mode=("failing", "passing", "warnings"),
185 categories=None, builders=None, addLogs=False,
186 relayhost="localhost", buildSetSummary=False,
187 subject="buildbot %(result)s in %(title)s on %(builder)s",
188 lookup=None, extraRecipients=[],
189 sendToInterestedUsers=True, customMesg=None,
190 messageFormatter=defaultMessage, extraHeaders=None,
191 addPatch=True, useTls=False,
192 smtpUser=None, smtpPassword=None, smtpPort=25):
193 """
194 @type fromaddr: string
195 @param fromaddr: the email address to be used in the 'From' header.
196 @type sendToInterestedUsers: boolean
197 @param sendToInterestedUsers: if True (the default), send mail to all
198 of the Interested Users. If False, only
199 send mail to the extraRecipients list.
200
201 @type extraRecipients: tuple of strings
202 @param extraRecipients: a list of email addresses to which messages
203 should be sent (in addition to the
204 InterestedUsers list, which includes any
205 developers who made Changes that went into this
206 build). It is a good idea to create a small
207 mailing list and deliver to that, then let
208 subscribers come and go as they please. The
209 addresses in this list are used literally (they
210 are not processed by lookup).
211
212 @type subject: string
213 @param subject: a string to be used as the subject line of the message.
214 %(builder)s will be replaced with the name of the
215 builder which provoked the message.
216
217 @type mode: list of strings
218 @param mode: a list of MailNotifer.possible_modes:
219 - "change": send mail about builds which change status
220 - "failing": send mail about builds which fail
221 - "passing": send mail about builds which succeed
222 - "problem": send mail about a build which failed
223 when the previous build passed
224 - "warnings": send mail if a build contain warnings
225 - "exception": send mail if a build fails due to an exception
226 - "all": always send mail
227 Defaults to ("failing", "passing", "warnings").
228
229 @type builders: list of strings
230 @param builders: a list of builder names for which mail should be
231 sent. Defaults to None (send mail for all builds).
232 Use either builders or categories, but not both.
233
234 @type categories: list of strings
235 @param categories: a list of category names to serve status
236 information for. Defaults to None (all
237 categories). Use either builders or categories,
238 but not both.
239
240 @type addLogs: boolean
241 @param addLogs: if True, include all build logs as attachments to the
242 messages. These can be quite large. This can also be
243 set to a list of log names, to send a subset of the
244 logs. Defaults to False.
245
246 @type addPatch: boolean
247 @param addPatch: if True, include the patch when the source stamp
248 includes one.
249
250 @type relayhost: string
251 @param relayhost: the host to which the outbound SMTP connection
252 should be made. Defaults to 'localhost'
253
254 @type buildSetSummary: boolean
255 @param buildSetSummary: if True, this notifier will only send a summary
256 email when a buildset containing any of its
257 watched builds completes
258
259 @type lookup: implementor of {IEmailLookup}
260 @param lookup: object which provides IEmailLookup, which is
261 responsible for mapping User names for Interested
262 Users (which come from the VC system) into valid
263 email addresses. If not provided, the notifier will
264 only be able to send mail to the addresses in the
265 extraRecipients list. Most of the time you can use a
266 simple Domain instance. As a shortcut, you can pass
267 as string: this will be treated as if you had provided
268 Domain(str). For example, lookup='twistedmatrix.com'
269 will allow mail to be sent to all developers whose SVN
270 usernames match their twistedmatrix.com account names.
271
272 @type customMesg: func
273 @param customMesg: (this function is deprecated)
274
275 @type messageFormatter: func
276 @param messageFormatter: function taking (mode, name, build, result,
277 master_status) and returning a dictionary
278 containing two required keys "body" and "type",
279 with a third optional key, "subject". The
280 "body" key gives a string that contains the
281 complete text of the message. The "type" key
282 is the message type ('plain' or 'html'). The
283 'html' type should be used when generating an
284 HTML message. The optional "subject" key
285 gives the subject for the email.
286
287 @type extraHeaders: dict
288 @param extraHeaders: A dict of extra headers to add to the mail. It's
289 best to avoid putting 'To', 'From', 'Date',
290 'Subject', or 'CC' in here. Both the names and
291 values may be WithProperties instances.
292
293 @type useTls: boolean
294 @param useTls: Send emails using TLS and authenticate with the
295 smtp host. Defaults to False.
296
297 @type smtpUser: string
298 @param smtpUser: The user that will attempt to authenticate with the
299 relayhost when useTls is True.
300
301 @type smtpPassword: string
302 @param smtpPassword: The password that smtpUser will use when
303 authenticating with relayhost.
304
305 @type smtpPort: int
306 @param smtpPort: The port that will be used when connecting to the
307 relayhost. Defaults to 25.
308 """
309 base.StatusReceiverMultiService.__init__(self)
310
311 if not isinstance(extraRecipients, (list, tuple)):
312 config.error("extraRecipients must be a list or tuple")
313 else:
314 for r in extraRecipients:
315 if not isinstance(r, str) or not VALID_EMAIL.search(r):
316 config.error(
317 "extra recipient %r is not a valid email" % (r,))
318 self.extraRecipients = extraRecipients
319 self.sendToInterestedUsers = sendToInterestedUsers
320 self.fromaddr = fromaddr
321 if isinstance(mode, basestring):
322 if mode == "all":
323 mode = ("failing", "passing", "warnings", "exception")
324 elif mode == "warnings":
325 mode = ("failing", "warnings")
326 else:
327 mode = (mode,)
328 for m in mode:
329 if m not in self.possible_modes:
330 config.error(
331 "mode %s is not a valid mode" % (m,))
332 self.mode = mode
333 self.categories = categories
334 self.builders = builders
335 self.addLogs = addLogs
336 self.relayhost = relayhost
337 if '\n' in subject:
338 config.error(
339 'Newlines are not allowed in email subjects')
340 self.subject = subject
341 if lookup is not None:
342 if type(lookup) is str:
343 lookup = Domain(lookup)
344 assert interfaces.IEmailLookup.providedBy(lookup)
345 self.lookup = lookup
346 self.customMesg = customMesg
347 self.messageFormatter = messageFormatter
348 if extraHeaders:
349 if not isinstance(extraHeaders, dict):
350 config.error("extraHeaders must be a dictionary")
351 self.extraHeaders = extraHeaders
352 self.addPatch = addPatch
353 self.useTls = useTls
354 self.smtpUser = smtpUser
355 self.smtpPassword = smtpPassword
356 self.smtpPort = smtpPort
357 self.buildSetSummary = buildSetSummary
358 self.buildSetSubscription = None
359 self.watched = []
360 self.master_status = None
361
362
363 if self.builders != None and self.categories != None:
364 config.error(
365 "Please specify only builders or categories to include - " +
366 "not both.")
367
368 if customMesg:
369 config.error(
370 "customMesg is deprecated; use messageFormatter instead")
371
380
387
394
401
403
404 if self.categories != None and builder.category not in self.categories:
405 return None
406
407 self.watched.append(builder)
408 return self
409
412
415
418
445
457
469
481
485
491
493
494
495
496
497 logs = list()
498 for logf in build.getLogs():
499 logStep = logf.getStep()
500 stepName = logStep.getName()
501 logStatus, dummy = logStep.getResults()
502 logName = logf.getName()
503 logs.append(('%s.%s' % (stepName, logName),
504 '%s/steps/%s/logs/%s' % (
505 master_status.getURLForThing(build),
506 stepName, logName),
507 logf.getText().splitlines(),
508 logStatus))
509
510 attrs = {'builderName': name,
511 'title': master_status.getTitle(),
512 'mode': mode,
513 'result': Results[results],
514 'buildURL': master_status.getURLForThing(build),
515 'buildbotURL': master_status.getBuildbotURL(),
516 'buildText': build.getText(),
517 'buildProperties': build.getProperties(),
518 'slavename': build.getSlavename(),
519 'reason': build.getReason().replace('\n', ''),
520 'responsibleUsers': build.getResponsibleUsers(),
521 'branch': "",
522 'revision': "",
523 'patch': "",
524 'patch_info': "",
525 'changes': [],
526 'logs': logs}
527
528 ss = None
529 ss_list = build.getSourceStamps()
530
531 if ss_list:
532 if len(ss_list) == 1:
533 ss = ss_list[0]
534 if ss:
535 attrs['branch'] = ss.branch
536 attrs['revision'] = ss.revision
537 attrs['patch'] = ss.patch
538 attrs['patch_info'] = ss.patch_info
539 attrs['changes'] = ss.changes[:]
540 else:
541 for key in ['branch', 'revision', 'patch', 'patch_info', 'changes']:
542 attrs[key] = {}
543 for ss in ss_list:
544 attrs['branch'][ss.codebase] = ss.branch
545 attrs['revision'][ss.codebase] = ss.revision
546 attrs['patch'][ss.codebase] = ss.patch
547 attrs['patch_info'][ss.codebase] = ss.patch_info
548 attrs['changes'][ss.codebase] = ss.changes[:]
549
550 return attrs
551
553
554
555
556
557 if type(patch[1]) != types.UnicodeType:
558 try:
559 unicode = patch[1].decode('utf8')
560 except UnicodeDecodeError:
561 unicode = None
562 else:
563 unicode = patch[1]
564
565 if unicode:
566 a = MIMEText(unicode.encode(ENCODING), _charset=ENCODING)
567 else:
568
569 a = MIMENonMultipart('application', 'octet-stream')
570 a.set_payload(patch[1])
571 a.add_header('Content-Disposition', "attachment",
572 filename="source patch " + str(index) )
573 return a
574
575 - def createEmail(self, msgdict, builderName, title, results, builds=None,
576 patches=None, logs=None):
577 text = msgdict['body'].encode(ENCODING)
578 type = msgdict['type']
579 if 'subject' in msgdict:
580 subject = msgdict['subject'].encode(ENCODING)
581 else:
582 subject = self.subject % { 'result': Results[results],
583 'projectName': title,
584 'title': title,
585 'builder': builderName,
586 }
587
588 assert '\n' not in subject, \
589 "Subject cannot contain newlines"
590
591 assert type in ('plain', 'html'), \
592 "'%s' message type must be 'plain' or 'html'." % type
593
594 if patches or logs:
595 m = MIMEMultipart()
596 m.attach(MIMEText(text, type, ENCODING))
597 else:
598 m = Message()
599 m.set_payload(text, ENCODING)
600 m.set_type("text/%s" % type)
601
602 m['Date'] = formatdate(localtime=True)
603 m['Subject'] = subject
604 m['From'] = self.fromaddr
605
606
607 if patches:
608 for (i, patch) in enumerate(patches):
609 a = self.patch_to_attachment(patch, i)
610 m.attach(a)
611 if logs:
612 for log in logs:
613 name = "%s.%s" % (log.getStep().getName(),
614 log.getName())
615 if ( self._shouldAttachLog(log.getName()) or
616 self._shouldAttachLog(name) ):
617 text = log.getText()
618 if not isinstance(text, unicode):
619
620
621 text = text.decode(LOG_ENCODING, 'replace')
622 a = MIMEText(text.encode(ENCODING),
623 _charset=ENCODING)
624 a.add_header('Content-Disposition', "attachment",
625 filename=name)
626 m.attach(a)
627
628
629
630
631 if self.extraHeaders:
632 if len(builds) == 1:
633 d = builds[0].render(self.extraHeaders)
634 else:
635 d = defer.succeed(self.extraHeaders)
636 @d.addCallback
637 def addExtraHeaders(extraHeaders):
638 for k,v in extraHeaders.items():
639 if k in m:
640 twlog.msg("Warning: Got header " + k +
641 " in self.extraHeaders "
642 "but it already exists in the Message - "
643 "not adding it.")
644 m[k] = v
645 d.addCallback(lambda _: m)
646 return d
647
648 return defer.succeed(m)
649
651 if self.customMesg:
652
653 attrs = self.getCustomMesgData(self.mode, name, build, results,
654 self.master_status)
655 text, type = self.customMesg(attrs)
656 msgdict = { 'body' : text, 'type' : type }
657 else:
658 msgdict = self.messageFormatter(self.mode, name, build, results,
659 self.master_status)
660
661 return msgdict
662
663
704 return d
705
712
723 contacts = []
724 for uid in uids:
725 d = users.getUserContact(self.master,
726 contact_type='email',
727 uid=uid)
728 d.addCallback(lambda contact: uidContactPair(contact, uid))
729 contacts.append(d)
730 return defer.gatherResults(contacts)
731 d.addCallback(getContacts)
732 def logNoMatch(contacts):
733 for pair in contacts:
734 contact, uid = pair
735 if contact is None:
736 twlog.msg("Unable to find email for uid: %r" % uid)
737 return [pair[0] for pair in contacts]
738 d.addCallback(logNoMatch)
739 def addOwners(recipients):
740 owners = [e for e in build.getInterestedUsers()
741 if e not in build.getResponsibleUsers()]
742 recipients.extend(owners)
743 return recipients
744 d.addCallback(addOwners)
745 dl.append(d)
746 d = defer.gatherResults(dl)
747 @d.addCallback
748 def gatherRecipients(res):
749 recipients = []
750 map(recipients.extend, res)
751 return recipients
752 return d
753
755 if type(self.addLogs) is bool:
756 return self.addLogs
757 return logname in self.addLogs
758
760 to_recipients = set()
761 cc_recipients = set()
762
763 for r in reduce(list.__add__, rlist, []):
764 if r is None:
765 continue
766
767
768
769 if r.count('@') > 1:
770 r = r[:r.rindex('@')]
771
772 if VALID_EMAIL.search(r):
773 to_recipients.add(r)
774 else:
775 twlog.msg("INVALID EMAIL: %r" + r)
776
777
778
779
780 if self.sendToInterestedUsers and to_recipients:
781 cc_recipients.update(self.extraRecipients)
782 else:
783 to_recipients.update(self.extraRecipients)
784
785 m['To'] = ", ".join(sorted(to_recipients))
786 if cc_recipients:
787 m['CC'] = ", ".join(sorted(cc_recipients))
788
789 return self.sendMessage(m, list(to_recipients | cc_recipients))
790
792 result = defer.Deferred()
793
794 if have_ssl and self.useTls:
795 client_factory = ssl.ClientContextFactory()
796 client_factory.method = SSLv3_METHOD
797 else:
798 client_factory = None
799
800 if self.smtpUser and self.smtpPassword:
801 useAuth = True
802 else:
803 useAuth = False
804
805 if not ESMTPSenderFactory:
806 raise RuntimeError("twisted-mail is not installed - cannot "
807 "send mail")
808 sender_factory = ESMTPSenderFactory(
809 self.smtpUser, self.smtpPassword,
810 self.fromaddr, recipients, StringIO(s),
811 result, contextFactory=client_factory,
812 requireTransportSecurity=self.useTls,
813 requireAuthentication=useAuth)
814
815 reactor.connectTCP(self.relayhost, self.smtpPort, sender_factory)
816
817 return result
818
820 s = m.as_string()
821 twlog.msg("sending mail (%d bytes) to" % len(s), recipients)
822 return self.sendmail(s, recipients)
823