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 from buildbot import interfaces, util, config
45 from buildbot.process.users import users
46 from buildbot.status import base
47 from buildbot.status.results import FAILURE, SUCCESS, WARNINGS, Results
48
49 VALID_EMAIL = re.compile("[a-zA-Z0-9\.\_\%\-\+]+@[a-zA-Z0-9\.\_\%\-]+.[a-zA-Z]{2,6}")
50
51 ENCODING = 'utf8'
52 LOG_ENCODING = 'utf-8'
53
54 -class Domain(util.ComparableMixin):
55 implements(interfaces.IEmailLookup)
56 compare_attrs = ["domain"]
57
58 - def __init__(self, domain):
59 assert "@" not in domain
60 self.domain = domain
61
62 - def getAddress(self, name):
63 """If name is already an email address, pass it through."""
64 if '@' in name:
65 return name
66 return name + "@" + self.domain
67
140
142 """This is a status notifier which sends email to a list of recipients
143 upon the completion of each build. It can be configured to only send out
144 mail for certain builds, and only send messages when the build fails, or
145 when it transitions from success to failure. It can also be configured to
146 include various build logs in each message.
147
148 By default, the message will be sent to the Interested Users list, which
149 includes all developers who made changes in the build. You can add
150 additional recipients with the extraRecipients argument.
151
152 To get a simple one-message-per-build (say, for a mailing list), use
153 sendToInterestedUsers=False, extraRecipients=['listaddr@example.org']
154
155 Each MailNotifier sends mail to a single set of recipients. To send
156 different kinds of mail to different recipients, use multiple
157 MailNotifiers.
158 """
159
160 implements(interfaces.IEmailSender)
161
162 compare_attrs = ["extraRecipients", "lookup", "fromaddr", "mode",
163 "categories", "builders", "addLogs", "relayhost",
164 "subject", "sendToInterestedUsers", "customMesg",
165 "messageFormatter", "extraHeaders"]
166
167 possible_modes = ("change", "failing", "passing", "problem", "warnings")
168
169 - def __init__(self, fromaddr, mode=("failing", "passing", "warnings"),
170 categories=None, builders=None, addLogs=False,
171 relayhost="localhost", buildSetSummary=False,
172 subject="buildbot %(result)s in %(title)s on %(builder)s",
173 lookup=None, extraRecipients=[],
174 sendToInterestedUsers=True, customMesg=None,
175 messageFormatter=defaultMessage, extraHeaders=None,
176 addPatch=True, useTls=False,
177 smtpUser=None, smtpPassword=None, smtpPort=25):
178 """
179 @type fromaddr: string
180 @param fromaddr: the email address to be used in the 'From' header.
181 @type sendToInterestedUsers: boolean
182 @param sendToInterestedUsers: if True (the default), send mail to all
183 of the Interested Users. If False, only
184 send mail to the extraRecipients list.
185
186 @type extraRecipients: tuple of strings
187 @param extraRecipients: a list of email addresses to which messages
188 should be sent (in addition to the
189 InterestedUsers list, which includes any
190 developers who made Changes that went into this
191 build). It is a good idea to create a small
192 mailing list and deliver to that, then let
193 subscribers come and go as they please. The
194 addresses in this list are used literally (they
195 are not processed by lookup).
196
197 @type subject: string
198 @param subject: a string to be used as the subject line of the message.
199 %(builder)s will be replaced with the name of the
200 builder which provoked the message.
201
202 @type mode: list of strings
203 @param mode: a list of MailNotifer.possible_modes:
204 - "change": send mail about builds which change status
205 - "failing": send mail about builds which fail
206 - "passing": send mail about builds which succeed
207 - "problem": send mail about a build which failed
208 when the previous build passed
209 - "warnings": send mail if a build contain warnings
210 Defaults to ("failing", "passing", "warnings").
211
212 @type builders: list of strings
213 @param builders: a list of builder names for which mail should be
214 sent. Defaults to None (send mail for all builds).
215 Use either builders or categories, but not both.
216
217 @type categories: list of strings
218 @param categories: a list of category names to serve status
219 information for. Defaults to None (all
220 categories). Use either builders or categories,
221 but not both.
222
223 @type addLogs: boolean
224 @param addLogs: if True, include all build logs as attachments to the
225 messages. These can be quite large. This can also be
226 set to a list of log names, to send a subset of the
227 logs. Defaults to False.
228
229 @type addPatch: boolean
230 @param addPatch: if True, include the patch when the source stamp
231 includes one.
232
233 @type relayhost: string
234 @param relayhost: the host to which the outbound SMTP connection
235 should be made. Defaults to 'localhost'
236
237 @type buildSetSummary: boolean
238 @param buildSetSummary: if True, this notifier will only send a summary
239 email when a buildset containing any of its
240 watched builds completes
241
242 @type lookup: implementor of {IEmailLookup}
243 @param lookup: object which provides IEmailLookup, which is
244 responsible for mapping User names for Interested
245 Users (which come from the VC system) into valid
246 email addresses. If not provided, the notifier will
247 only be able to send mail to the addresses in the
248 extraRecipients list. Most of the time you can use a
249 simple Domain instance. As a shortcut, you can pass
250 as string: this will be treated as if you had provided
251 Domain(str). For example, lookup='twistedmatrix.com'
252 will allow mail to be sent to all developers whose SVN
253 usernames match their twistedmatrix.com account names.
254
255 @type customMesg: func
256 @param customMesg: (this function is deprecated)
257
258 @type messageFormatter: func
259 @param messageFormatter: function taking (mode, name, build, result,
260 master_status) and returning a dictionary
261 containing two required keys "body" and "type",
262 with a third optional key, "subject". The
263 "body" key gives a string that contains the
264 complete text of the message. The "type" key
265 is the message type ('plain' or 'html'). The
266 'html' type should be used when generating an
267 HTML message. The optional "subject" key
268 gives the subject for the email.
269
270 @type extraHeaders: dict
271 @param extraHeaders: A dict of extra headers to add to the mail. It's
272 best to avoid putting 'To', 'From', 'Date',
273 'Subject', or 'CC' in here. Both the names and
274 values may be WithProperties instances.
275
276 @type useTls: boolean
277 @param useTls: Send emails using TLS and authenticate with the
278 smtp host. Defaults to False.
279
280 @type smtpUser: string
281 @param smtpUser: The user that will attempt to authenticate with the
282 relayhost when useTls is True.
283
284 @type smtpPassword: string
285 @param smtpPassword: The password that smtpUser will use when
286 authenticating with relayhost.
287
288 @type smtpPort: int
289 @param smtpPort: The port that will be used when connecting to the
290 relayhost. Defaults to 25.
291 """
292 base.StatusReceiverMultiService.__init__(self)
293
294 if not isinstance(extraRecipients, (list, tuple)):
295 config.error("extraRecipients must be a list or tuple")
296 else:
297 for r in extraRecipients:
298 if not isinstance(r, str) or not VALID_EMAIL.search(r):
299 config.error(
300 "extra recipient %r is not a valid email" % (r,))
301 self.extraRecipients = extraRecipients
302 self.sendToInterestedUsers = sendToInterestedUsers
303 self.fromaddr = fromaddr
304 if isinstance(mode, basestring):
305 if mode == "all":
306 mode = ("failing", "passing", "warnings")
307 elif mode == "warnings":
308 mode = ("failing", "warnings")
309 else:
310 mode = (mode,)
311 for m in mode:
312 if m not in self.possible_modes:
313 config.error(
314 "mode %s is not a valid mode" % (m,))
315 self.mode = mode
316 self.categories = categories
317 self.builders = builders
318 self.addLogs = addLogs
319 self.relayhost = relayhost
320 if '\n' in subject:
321 config.error(
322 'Newlines are not allowed in email subjects')
323 self.subject = subject
324 if lookup is not None:
325 if type(lookup) is str:
326 lookup = Domain(lookup)
327 assert interfaces.IEmailLookup.providedBy(lookup)
328 self.lookup = lookup
329 self.customMesg = customMesg
330 self.messageFormatter = messageFormatter
331 if extraHeaders:
332 if not isinstance(extraHeaders, dict):
333 config.error("extraHeaders must be a dictionary")
334 self.extraHeaders = extraHeaders
335 self.addPatch = addPatch
336 self.useTls = useTls
337 self.smtpUser = smtpUser
338 self.smtpPassword = smtpPassword
339 self.smtpPort = smtpPort
340 self.buildSetSummary = buildSetSummary
341 self.buildSetSubscription = None
342 self.watched = []
343 self.master_status = None
344
345
346 if self.builders != None and self.categories != None:
347 config.error(
348 "Please specify only builders or categories to include - " +
349 "not both.")
350
351 if customMesg:
352 config.error(
353 "customMesg is deprecated; use messageFormatter instead")
354
363
370
377
384
386
387 if self.categories != None and builder.category not in self.categories:
388 return None
389
390 self.watched.append(builder)
391 return self
392
395
398
401
426
438
449
460
464
470
472
473
474
475
476 logs = list()
477 for logf in build.getLogs():
478 logStep = logf.getStep()
479 stepName = logStep.getName()
480 logStatus, dummy = logStep.getResults()
481 logName = logf.getName()
482 logs.append(('%s.%s' % (stepName, logName),
483 '%s/steps/%s/logs/%s' % (
484 master_status.getURLForThing(build),
485 stepName, logName),
486 logf.getText().splitlines(),
487 logStatus))
488
489 attrs = {'builderName': name,
490 'title': master_status.getTitle(),
491 'mode': mode,
492 'result': Results[results],
493 'buildURL': master_status.getURLForThing(build),
494 'buildbotURL': master_status.getBuildbotURL(),
495 'buildText': build.getText(),
496 'buildProperties': build.getProperties(),
497 'slavename': build.getSlavename(),
498 'reason': build.getReason(),
499 'responsibleUsers': build.getResponsibleUsers(),
500 'branch': "",
501 'revision': "",
502 'patch': "",
503 'patch_info': "",
504 'changes': [],
505 'logs': logs}
506
507 ss = build.getSourceStamp()
508 if ss:
509 attrs['branch'] = ss.branch
510 attrs['revision'] = ss.revision
511 attrs['patch'] = ss.patch
512 attrs['patch_info'] = ss.patch_info
513 attrs['changes'] = ss.changes[:]
514
515 return attrs
516
518
519
520
521
522 if type(patch[1]) != types.UnicodeType:
523 try:
524 unicode = patch[1].decode('utf8')
525 except UnicodeDecodeError:
526 unicode = None
527 else:
528 unicode = patch[1]
529
530 if unicode:
531 a = MIMEText(unicode.encode(ENCODING), _charset=ENCODING)
532 else:
533
534 a = MIMENonMultipart('application', 'octet-stream')
535 a.set_payload(patch[1])
536 a.add_header('Content-Disposition', "attachment",
537 filename="source patch " + str(index) )
538 return a
539
540 - def createEmail(self, msgdict, builderName, title, results, builds=None,
541 patches=None, logs=None):
542 text = msgdict['body'].encode(ENCODING)
543 type = msgdict['type']
544 if 'subject' in msgdict:
545 subject = msgdict['subject'].encode(ENCODING)
546 else:
547 subject = self.subject % { 'result': Results[results],
548 'projectName': title,
549 'title': title,
550 'builder': builderName,
551 }
552
553 assert '\n' not in subject, \
554 "Subject cannot contain newlines"
555
556 assert type in ('plain', 'html'), \
557 "'%s' message type must be 'plain' or 'html'." % type
558
559 if patches or logs:
560 m = MIMEMultipart()
561 m.attach(MIMEText(text, type, ENCODING))
562 else:
563 m = Message()
564 m.set_payload(text, ENCODING)
565 m.set_type("text/%s" % type)
566
567 m['Date'] = formatdate(localtime=True)
568 m['Subject'] = subject
569 m['From'] = self.fromaddr
570
571
572 if patches:
573 for (i, patch) in enumerate(patches):
574 a = self.patch_to_attachment(patch, i)
575 m.attach(a)
576 if logs:
577 for log in logs:
578 name = "%s.%s" % (log.getStep().getName(),
579 log.getName())
580 if ( self._shouldAttachLog(log.getName()) or
581 self._shouldAttachLog(name) ):
582 text = log.getText()
583 if not isinstance(text, unicode):
584 text = text.decode(LOG_ENCODING)
585 a = MIMEText(text.encode(ENCODING),
586 _charset=ENCODING)
587 a.add_header('Content-Disposition', "attachment",
588 filename=name)
589 m.attach(a)
590
591
592
593
594 if self.extraHeaders:
595 if len(builds) == 1:
596 extraHeaders = builds[0].render(self.extraHeaders)
597 else:
598 extraHeaders = self.extraHeaders
599 for k,v in extraHeaders.items():
600 if k in m:
601 twlog.msg("Warning: Got header " + k +
602 " in self.extraHeaders "
603 "but it already exists in the Message - "
604 "not adding it.")
605 m[k] = v
606
607 return m
608
610 if self.customMesg:
611
612 attrs = self.getCustomMesgData(self.mode, name, build, results,
613 self.master_status)
614 text, type = self.customMesg(attrs)
615 msgdict = { 'body' : text, 'type' : type }
616 else:
617 msgdict = self.messageFormatter(self.mode, name, build, results,
618 self.master_status)
619
620 return msgdict
621
622
660
667
676 contacts = []
677 for uid in uids:
678 d = users.getUserContact(self.master,
679 contact_type='email',
680 uid=uid)
681 d.addCallback(lambda contact: uidContactPair(contact, uid))
682 contacts.append(d)
683 return defer.gatherResults(contacts)
684 d.addCallback(getContacts)
685 def logNoMatch(contacts):
686 for pair in contacts:
687 contact, uid = pair
688 if contact is None:
689 twlog.msg("Unable to find email for uid: %r" % uid)
690 return [pair[0] for pair in contacts]
691 d.addCallback(logNoMatch)
692 def addOwners(recipients):
693 owners = [e for e in build.getInterestedUsers()
694 if e not in build.getResponsibleUsers()]
695 recipients.extend(owners)
696 return recipients
697 d.addCallback(addOwners)
698 dl.append(d)
699 d = defer.gatherResults(dl)
700 @d.addCallback
701 def gatherRecipients(res):
702 recipients = []
703 map(recipients.extend, res)
704 return recipients
705 return d
706
708 if type(self.addLogs) is bool:
709 return self.addLogs
710 return logname in self.addLogs
711
713 to_recipients = set()
714 cc_recipients = set()
715
716 for r in reduce(list.__add__, rlist, []):
717 if r is None:
718 continue
719
720
721
722 if r.count('@') > 1:
723 r = r[:r.rindex('@')]
724
725 if VALID_EMAIL.search(r):
726 to_recipients.add(r)
727 else:
728 twlog.msg("INVALID EMAIL: %r" + r)
729
730
731
732
733 if self.sendToInterestedUsers and to_recipients:
734 cc_recipients.update(self.extraRecipients)
735 else:
736 to_recipients.update(self.extraRecipients)
737
738 m['To'] = ", ".join(sorted(to_recipients))
739 if cc_recipients:
740 m['CC'] = ", ".join(sorted(cc_recipients))
741
742 return self.sendMessage(m, list(to_recipients | cc_recipients))
743
745 result = defer.Deferred()
746
747 if have_ssl and self.useTls:
748 client_factory = ssl.ClientContextFactory()
749 client_factory.method = SSLv3_METHOD
750 else:
751 client_factory = None
752
753 if self.smtpUser and self.smtpPassword:
754 useAuth = True
755 else:
756 useAuth = False
757
758 if not ESMTPSenderFactory:
759 raise RuntimeError("twisted-mail is not installed - cannot "
760 "send mail")
761 sender_factory = ESMTPSenderFactory(
762 self.smtpUser, self.smtpPassword,
763 self.fromaddr, recipients, StringIO(s),
764 result, contextFactory=client_factory,
765 requireTransportSecurity=self.useTls,
766 requireAuthentication=useAuth)
767
768 reactor.connectTCP(self.relayhost, self.smtpPort, sender_factory)
769
770 return result
771
773 s = m.as_string()
774 twlog.msg("sending mail (%d bytes) to" % len(s), recipients)
775 return self.sendmail(s, recipients)
776