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 properties = build.getProperties()
375
376 attrs = {'builderName': name,
377 'projectName': master_status.getProjectName(),
378 'mode': mode,
379 'result': Results[results],
380 'buildURL': master_status.getURLForThing(build),
381 'buildbotURL': master_status.getBuildbotURL(),
382 'buildText': build.getText(),
383 'buildProperties': properties,
384 'slavename': build.getSlavename(),
385 'reason': build.getReason(),
386 'responsibleUsers': build.getResponsibleUsers(),
387 'branch': "",
388 'revision': "",
389 'patch': "",
390 'changes': [],
391 'logs': logs}
392
393 ss = build.getSourceStamp()
394 if ss:
395 attrs['branch'] = ss.branch
396 attrs['revision'] = ss.revision
397 attrs['patch'] = ss.patch
398 attrs['changes'] = ss.changes[:]
399
400 return attrs
401
402 - def createEmail(self, msgdict, builderName, projectName, results,
403 patch=None, logs=None):
404 text = msgdict['body'].encode(ENCODING)
405 type = msgdict['type']
406 if 'subject' in msgdict:
407 subject = msgdict['subject'].encode(ENCODING)
408 else:
409 subject = self.subject % { 'result': Results[results],
410 'projectName': projectName,
411 'builder': builderName,
412 }
413
414
415 assert type in ('plain', 'html'), "'%s' message type must be 'plain' or 'html'." % type
416
417 if patch or logs:
418 m = MIMEMultipart()
419 m.attach(MIMEText(text, type, ENCODING))
420 else:
421 m = Message()
422 m.set_payload(text, ENCODING)
423 m.set_type("text/%s" % type)
424
425 m['Date'] = formatdate(localtime=True)
426 m['Subject'] = subject
427 m['From'] = self.fromaddr
428
429
430 if patch:
431 a = MIMEText(patch[1].encode(ENCODING), _charset=ENCODING)
432 a.add_header('Content-Disposition', "attachment",
433 filename="source patch")
434 m.attach(a)
435 if logs:
436 for log in logs:
437 name = "%s.%s" % (log.getStep().getName(),
438 log.getName())
439 if self._shouldAttachLog(log.getName()) or self._shouldAttachLog(name):
440 a = MIMEText(log.getText().encode(ENCODING),
441 _charset=ENCODING)
442 a.add_header('Content-Disposition', "attachment",
443 filename=name)
444 m.attach(a)
445
446
447
448 if self.extraHeaders:
449 for k,v in self.extraHeaders.items():
450 k = properties.render(k)
451 if k in m:
452 twlog("Warning: Got header " + k + " in self.extraHeaders "
453 "but it already exists in the Message - "
454 "not adding it.")
455 continue
456 m[k] = properties.render(v)
457
458 return m
459
461 if self.customMesg:
462
463 attrs = self.getCustomMesgData(self.mode, name, build, results, self.master_status)
464 text, type = self.customMesg(attrs)
465 msgdict = { 'body' : text, 'type' : type }
466 else:
467 msgdict = self.messageFormatter(self.mode, name, build, results, self.master_status)
468
469 patch = None
470 ss = build.getSourceStamp()
471 if ss and ss.patch and self.addPatch:
472 patch == ss.patch
473 logs = None
474 if self.addLogs:
475 logs = build.getLogs()
476 twlog.err("LOG: %s" % str(logs))
477 m = self.createEmail(msgdict, name, self.master_status.getProjectName(),
478 results, patch, logs)
479
480
481 dl = []
482 recipients = []
483 if self.sendToInterestedUsers and self.lookup:
484 for u in build.getInterestedUsers():
485 d = defer.maybeDeferred(self.lookup.getAddress, u)
486 d.addCallback(recipients.append)
487 dl.append(d)
488 d = defer.DeferredList(dl)
489 d.addCallback(self._gotRecipients, recipients, m)
490 return d
491
493 if type(self.addLogs) is bool:
494 return self.addLogs
495 return logname in self.addLogs
496
498 recipients = set()
499
500 for r in rlist:
501 if r is None:
502 continue
503
504
505
506 if r.count('@') > 1:
507 r = r[:r.rindex('@')]
508
509 if VALID_EMAIL.search(r):
510 recipients.add(r)
511 else:
512 twlog.msg("INVALID EMAIL: %r" + r)
513
514
515
516
517 if self.sendToInterestedUsers and len(recipients):
518 extra_recips = self.extraRecipients[:]
519 extra_recips.sort()
520 m['CC'] = ", ".join(extra_recips)
521 else:
522 [recipients.add(r) for r in self.extraRecipients[:]]
523
524 rlist = list(recipients)
525 rlist.sort()
526 m['To'] = ", ".join(rlist)
527
528
529 if self.sendToInterestedUsers:
530 for r in self.extraRecipients:
531 recipients.add(r)
532
533 return self.sendMessage(m, list(recipients))
534
536 client_factory = ssl.ClientContextFactory()
537 client_factory.method = SSLv3_METHOD
538
539 result = defer.Deferred()
540
541
542 sender_factory = ESMTPSenderFactory(
543 self.smtpUser, self.smtpPassword,
544 self.fromaddr, recipients, StringIO(s),
545 result, contextFactory=client_factory)
546
547 reactor.connectTCP(self.relayhost, self.smtpPort, sender_factory)
548
549 return result
550
552 s = m.as_string()
553 twlog.msg("sending mail (%d bytes) to" % len(s), recipients)
554 if self.useTls:
555 return self.tls_sendmail(s, recipients)
556 else:
557 return sendmail(self.relayhost, self.fromaddr, recipients, s,
558 port=self.smtpPort)
559