1
2
3 import re
4
5 from email.Message import Message
6 from email.Utils import formatdate
7 from email.MIMEText import MIMEText
8 from email.MIMEMultipart import MIMEMultipart
9 from StringIO import StringIO
10 import urllib
11
12 from zope.interface import implements
13 from twisted.internet import defer, reactor
14 from twisted.mail.smtp import sendmail, ESMTPSenderFactory
15 from twisted.python import log as twlog
16
17 try:
18 from twisted.internet import ssl
19 from OpenSSL.SSL import SSLv3_METHOD
20 except ImportError:
21 pass
22
23 from buildbot import interfaces, util
24 from buildbot.status import base
25 from buildbot.status.builder import FAILURE, SUCCESS, Results
26
27 VALID_EMAIL = re.compile("[a-zA-Z0-9\.\_\%\-\+]+@[a-zA-Z0-9\.\_\%\-]+.[a-zA-Z]{2,6}")
28
29 ENCODING = 'utf8'
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
112
114 """This is a status notifier which sends email to a list of recipients
115 upon the completion of each build. It can be configured to only send out
116 mail for certain builds, and only send messages when the build fails, or
117 when it transitions from success to failure. It can also be configured to
118 include various build logs in each message.
119
120 By default, the message will be sent to the Interested Users list, which
121 includes all developers who made changes in the build. You can add
122 additional recipients with the extraRecipients argument.
123
124 To get a simple one-message-per-build (say, for a mailing list), use
125 sendToInterestedUsers=False, extraRecipients=['listaddr@example.org']
126
127 Each MailNotifier sends mail to a single set of recipients. To send
128 different kinds of mail to different recipients, use multiple
129 MailNotifiers.
130 """
131
132 implements(interfaces.IEmailSender)
133
134 compare_attrs = ["extraRecipients", "lookup", "fromaddr", "mode",
135 "categories", "builders", "addLogs", "relayhost",
136 "subject", "sendToInterestedUsers", "customMesg",
137 "messageFormatter", "extraHeaders"]
138
139 - def __init__(self, fromaddr, mode="all", categories=None, builders=None,
140 addLogs=False, relayhost="localhost",
141 subject="buildbot %(result)s in %(projectName)s on %(builder)s",
142 lookup=None, extraRecipients=[],
143 sendToInterestedUsers=True, customMesg=None,
144 messageFormatter=defaultMessage, extraHeaders=None,
145 addPatch=True, useTls=False,
146 smtpUser=None, smtpPassword=None, smtpPort=25):
147 """
148 @type fromaddr: string
149 @param fromaddr: the email address to be used in the 'From' header.
150 @type sendToInterestedUsers: boolean
151 @param sendToInterestedUsers: if True (the default), send mail to all
152 of the Interested Users. If False, only
153 send mail to the extraRecipients list.
154
155 @type extraRecipients: tuple of string
156 @param extraRecipients: a list of email addresses to which messages
157 should be sent (in addition to the
158 InterestedUsers list, which includes any
159 developers who made Changes that went into this
160 build). It is a good idea to create a small
161 mailing list and deliver to that, then let
162 subscribers come and go as they please.
163
164 @type subject: string
165 @param subject: a string to be used as the subject line of the message.
166 %(builder)s will be replaced with the name of the
167 builder which provoked the message.
168
169 @type mode: string (defaults to all)
170 @param mode: one of:
171 - 'all': send mail about all builds, passing and failing
172 - 'failing': only send mail about builds which fail
173 - 'passing': only send mail about builds which succeed
174 - 'problem': only send mail about a build which failed
175 when the previous build passed
176 - 'change': only send mail about builds who change status
177
178 @type builders: list of strings
179 @param builders: a list of builder names for which mail should be
180 sent. Defaults to None (send mail for all builds).
181 Use either builders or categories, but not both.
182
183 @type categories: list of strings
184 @param categories: a list of category names to serve status
185 information for. Defaults to None (all
186 categories). Use either builders or categories,
187 but not both.
188
189 @type addLogs: boolean
190 @param addLogs: if True, include all build logs as attachments to the
191 messages. These can be quite large. This can also be
192 set to a list of log names, to send a subset of the
193 logs. Defaults to False.
194
195 @type addPatch: boolean
196 @param addPatch: if True, include the patch when the source stamp
197 includes one.
198
199 @type relayhost: string
200 @param relayhost: the host to which the outbound SMTP connection
201 should be made. Defaults to 'localhost'
202
203 @type lookup: implementor of {IEmailLookup}
204 @param lookup: object which provides IEmailLookup, which is
205 responsible for mapping User names (which come from
206 the VC system) into valid email addresses. If not
207 provided, the notifier will only be able to send mail
208 to the addresses in the extraRecipients list. Most of
209 the time you can use a simple Domain instance. As a
210 shortcut, you can pass as string: this will be
211 treated as if you had provided Domain(str). For
212 example, lookup='twistedmatrix.com' will allow mail
213 to be sent to all developers whose SVN usernames
214 match their twistedmatrix.com account names.
215
216 @type customMesg: func
217 @param customMesg: (this function is deprecated)
218
219 @type messageFormatter: func
220 @param messageFormatter: function taking (mode, name, build, result,
221 master_status ) and returning a dictionary
222 containing two required keys "body" and "type",
223 with a third optional key, "subject". The
224 "body" key gives a string that contains the
225 complete text of the message. The "type" key
226 is the message type ('plain' or 'html'). The
227 'html' type should be used when generating an
228 HTML message. The optional "subject" key
229 gives the subject for the email.
230
231 @type extraHeaders: dict
232 @param extraHeaders: A dict of extra headers to add to the mail. It's
233 best to avoid putting 'To', 'From', 'Date',
234 'Subject', or 'CC' in here. Both the names and
235 values may be WithProperties instances.
236
237 @type useTls: boolean
238 @param useTls: Send emails using TLS and authenticate with the
239 smtp host. Defaults to False.
240
241 @type smtpUser: string
242 @param smtpUser: The user that will attempt to authenticate with the
243 relayhost when useTls is True.
244
245 @type smtpPassword: string
246 @param smtpPassword: The password that smtpUser will use when
247 authenticating with relayhost.
248
249 @type smtpPort: int
250 @param smtpPort: The port that will be used when connecting to the
251 relayhost. Defaults to 25.
252 """
253
254 base.StatusReceiverMultiService.__init__(self)
255 assert isinstance(extraRecipients, (list, tuple))
256 for r in extraRecipients:
257 assert isinstance(r, str)
258 assert VALID_EMAIL.search(r)
259 self.extraRecipients = extraRecipients
260 self.sendToInterestedUsers = sendToInterestedUsers
261 self.fromaddr = fromaddr
262 assert mode in ('all', 'failing', 'problem', 'change', 'passing')
263 self.mode = mode
264 self.categories = categories
265 self.builders = builders
266 self.addLogs = addLogs
267 self.relayhost = relayhost
268 self.subject = subject
269 if lookup is not None:
270 if type(lookup) is str:
271 lookup = Domain(lookup)
272 assert interfaces.IEmailLookup.providedBy(lookup)
273 self.lookup = lookup
274 self.customMesg = customMesg
275 self.messageFormatter = messageFormatter
276 if extraHeaders:
277 assert isinstance(extraHeaders, dict)
278 self.extraHeaders = extraHeaders
279 self.addPatch = addPatch
280 self.useTls = useTls
281 self.smtpUser = smtpUser
282 self.smtpPassword = smtpPassword
283 self.smtpPort = smtpPort
284 self.watched = []
285 self.master_status = None
286
287
288 if self.builders != None and self.categories != None:
289 twlog.err("Please specify only builders to ignore or categories to include")
290 raise
291
292 if customMesg:
293 twlog.msg("customMesg is deprecated; please use messageFormatter instead")
294
301
305
311
313
314 if self.categories != None and builder.category not in self.categories:
315 return None
316
317 self.watched.append(builder)
318 return self
319
322
357
359
360
361
362
363 logs = list()
364 for logf in build.getLogs():
365 logStep = logf.getStep()
366 stepName = logStep.getName()
367 logStatus, dummy = logStep.getResults()
368 logName = logf.getName()
369 logs.append(('%s.%s' % (stepName, logName),
370 '%s/steps/%s/logs/%s' % (master_status.getURLForThing(build), stepName, logName),
371 logf.getText().splitlines(),
372 logStatus))
373
374 attrs = {'builderName': name,
375 'projectName': master_status.getProjectName(),
376 'mode': mode,
377 'result': Results[results],
378 'buildURL': master_status.getURLForThing(build),
379 'buildbotURL': master_status.getBuildbotURL(),
380 'buildText': build.getText(),
381 'buildProperties': build.getProperties(),
382 'slavename': build.getSlavename(),
383 'reason': build.getReason(),
384 'responsibleUsers': build.getResponsibleUsers(),
385 'branch': "",
386 'revision': "",
387 'patch': "",
388 'changes': [],
389 'logs': logs}
390
391 ss = build.getSourceStamp()
392 if ss:
393 attrs['branch'] = ss.branch
394 attrs['revision'] = ss.revision
395 attrs['patch'] = ss.patch
396 attrs['changes'] = ss.changes[:]
397
398 return attrs
399
400 - def createEmail(self, msgdict, builderName, projectName, results, build,
401 patch=None, logs=None):
402 text = msgdict['body'].encode(ENCODING)
403 type = msgdict['type']
404 if 'subject' in msgdict:
405 subject = msgdict['subject'].encode(ENCODING)
406 else:
407 subject = self.subject % { 'result': Results[results],
408 'projectName': projectName,
409 'builder': builderName,
410 }
411
412
413 assert type in ('plain', 'html'), "'%s' message type must be 'plain' or 'html'." % type
414
415 if patch or logs:
416 m = MIMEMultipart()
417 m.attach(MIMEText(text, type, ENCODING))
418 else:
419 m = Message()
420 m.set_payload(text, ENCODING)
421 m.set_type("text/%s" % type)
422
423 m['Date'] = formatdate(localtime=True)
424 m['Subject'] = subject
425 m['From'] = self.fromaddr
426
427
428 if patch:
429 a = MIMEText(patch[1].encode(ENCODING), _charset=ENCODING)
430 a.add_header('Content-Disposition', "attachment",
431 filename="source patch")
432 m.attach(a)
433 if logs:
434 for log in logs:
435 name = "%s.%s" % (log.getStep().getName(),
436 log.getName())
437 if self._shouldAttachLog(log.getName()) or self._shouldAttachLog(name):
438 a = MIMEText(log.getText().encode(ENCODING),
439 _charset=ENCODING)
440 a.add_header('Content-Disposition', "attachment",
441 filename=name)
442 m.attach(a)
443
444
445
446 if self.extraHeaders:
447 properties = build.getProperties()
448 for k,v in self.extraHeaders.items():
449 k = properties.render(k)
450 if k in m:
451 twlog("Warning: Got header " + k + " in self.extraHeaders "
452 "but it already exists in the Message - "
453 "not adding it.")
454 continue
455 m[k] = properties.render(v)
456
457 return m
458
460 if self.customMesg:
461
462 attrs = self.getCustomMesgData(self.mode, name, build, results, self.master_status)
463 text, type = self.customMesg(attrs)
464 msgdict = { 'body' : text, 'type' : type }
465 else:
466 msgdict = self.messageFormatter(self.mode, name, build, results, self.master_status)
467
468 patch = None
469 ss = build.getSourceStamp()
470 if ss and ss.patch and self.addPatch:
471 patch == ss.patch
472 logs = None
473 if self.addLogs:
474 logs = build.getLogs()
475 twlog.err("LOG: %s" % str(logs))
476 m = self.createEmail(msgdict, name, self.master_status.getProjectName(),
477 results, build, patch, logs)
478
479
480 dl = []
481 recipients = []
482 if self.sendToInterestedUsers and self.lookup:
483 for u in build.getInterestedUsers():
484 d = defer.maybeDeferred(self.lookup.getAddress, u)
485 d.addCallback(recipients.append)
486 dl.append(d)
487 d = defer.DeferredList(dl)
488 d.addCallback(self._gotRecipients, recipients, m)
489 return d
490
492 if type(self.addLogs) is bool:
493 return self.addLogs
494 return logname in self.addLogs
495
497 recipients = set()
498
499 for r in rlist:
500 if r is None:
501 continue
502
503
504
505 if r.count('@') > 1:
506 r = r[:r.rindex('@')]
507
508 if VALID_EMAIL.search(r):
509 recipients.add(r)
510 else:
511 twlog.msg("INVALID EMAIL: %r" + r)
512
513
514
515
516 if self.sendToInterestedUsers and len(recipients):
517 extra_recips = self.extraRecipients[:]
518 extra_recips.sort()
519 m['CC'] = ", ".join(extra_recips)
520 else:
521 [recipients.add(r) for r in self.extraRecipients[:]]
522
523 rlist = list(recipients)
524 rlist.sort()
525 m['To'] = ", ".join(rlist)
526
527
528 if self.sendToInterestedUsers:
529 for r in self.extraRecipients:
530 recipients.add(r)
531
532 return self.sendMessage(m, list(recipients))
533
535 client_factory = ssl.ClientContextFactory()
536 client_factory.method = SSLv3_METHOD
537
538 result = defer.Deferred()
539
540
541 sender_factory = ESMTPSenderFactory(
542 self.smtpUser, self.smtpPassword,
543 self.fromaddr, recipients, StringIO(s),
544 result, contextFactory=client_factory)
545
546 reactor.connectTCP(self.relayhost, self.smtpPort, sender_factory)
547
548 return result
549
551 s = m.as_string()
552 twlog.msg("sending mail (%d bytes) to" % len(s), recipients)
553 if self.useTls:
554 return self.tls_sendmail(s, recipients)
555 else:
556 return sendmail(self.relayhost, self.fromaddr, recipients, s,
557 port=self.smtpPort)
558