1# This file is part of Buildbot. Buildbot is free software: you can 2# redistribute it and/or modify it under the terms of the GNU General Public 3# License as published by the Free Software Foundation, version 2. 4# 5# This program is distributed in the hope that it will be useful, but WITHOUT 6# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 7# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 8# details. 9# 10# You should have received a copy of the GNU General Public License along with 11# this program; if not, write to the Free Software Foundation, Inc., 51 12# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 13# 14# Copyright Buildbot Team Members 15 16import re 17from email import charset 18from email import encoders 19from email.header import Header 20from email.message import Message 21from email.mime.multipart import MIMEMultipart 22from email.mime.text import MIMEText 23from email.utils import formatdate 24from email.utils import parseaddr 25from io import BytesIO 26 27from twisted.internet import defer 28from twisted.internet import reactor 29from twisted.python import log as twlog 30from zope.interface import implementer 31 32from buildbot import config 33from buildbot import interfaces 34from buildbot import util 35from buildbot.process.properties import Properties 36from buildbot.reporters.base import ENCODING 37from buildbot.reporters.base import ReporterBase 38from buildbot.reporters.generators.build import BuildStatusGenerator 39from buildbot.reporters.generators.worker import WorkerMissingGenerator 40from buildbot.util import ssl 41from buildbot.util import unicode2bytes 42 43from .utils import merge_reports_prop 44from .utils import merge_reports_prop_take_first 45 46# this incantation teaches email to output utf-8 using 7- or 8-bit encoding, 47# although it has no effect before python-2.7. 48# needs to match notifier.ENCODING 49charset.add_charset(ENCODING, charset.SHORTEST, None, ENCODING) 50 51try: 52 from twisted.mail.smtp import ESMTPSenderFactory 53 [ESMTPSenderFactory] # for pyflakes 54except ImportError: 55 ESMTPSenderFactory = None 56 57# Email parsing can be complex. We try to take a very liberal 58# approach. The local part of an email address matches ANY non 59# whitespace character. Rather allow a malformed email address than 60# croaking on a valid (the matching of domains should be correct 61# though; requiring the domain to not be a top level domain). With 62# these regular expressions, we can match the following: 63# 64# full.name@example.net 65# Full Name <full.name@example.net> 66# <full.name@example.net> 67VALID_EMAIL_ADDR = r"(?:\S+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+\.?)" 68VALID_EMAIL = re.compile(r"^(?:{0}|(.+\s+)?<{0}>\s*)$".format(VALID_EMAIL_ADDR)) 69VALID_EMAIL_ADDR = re.compile(VALID_EMAIL_ADDR) 70 71 72@implementer(interfaces.IEmailLookup) 73class Domain(util.ComparableMixin): 74 compare_attrs = ("domain") 75 76 def __init__(self, domain): 77 assert "@" not in domain 78 self.domain = domain 79 80 def getAddress(self, name): 81 """If name is already an email address, pass it through.""" 82 if '@' in name: 83 return name 84 return name + "@" + self.domain 85 86 87@implementer(interfaces.IEmailSender) 88class MailNotifier(ReporterBase): 89 secrets = ["smtpUser", "smtpPassword"] 90 91 def checkConfig(self, fromaddr, relayhost="localhost", lookup=None, extraRecipients=None, 92 sendToInterestedUsers=True, extraHeaders=None, useTls=False, useSmtps=False, 93 smtpUser=None, smtpPassword=None, smtpPort=25, 94 dumpMailsToLog=False, generators=None): 95 if ESMTPSenderFactory is None: 96 config.error("twisted-mail is not installed - cannot " 97 "send mail") 98 99 if generators is None: 100 generators = self._create_default_generators() 101 102 super().checkConfig(generators=generators) 103 104 if extraRecipients is None: 105 extraRecipients = [] 106 107 if not isinstance(extraRecipients, (list, tuple)): 108 config.error("extraRecipients must be a list or tuple") 109 else: 110 for r in extraRecipients: 111 if not isinstance(r, str) or not VALID_EMAIL.search(r): 112 config.error( 113 "extra recipient {} is not a valid email".format(r)) 114 115 if lookup is not None: 116 if not isinstance(lookup, str): 117 assert interfaces.IEmailLookup.providedBy(lookup) 118 119 if extraHeaders: 120 if not isinstance(extraHeaders, dict): 121 config.error("extraHeaders must be a dictionary") 122 123 if useSmtps: 124 ssl.ensureHasSSL(self.__class__.__name__) 125 126 @defer.inlineCallbacks 127 def reconfigService(self, fromaddr, relayhost="localhost", lookup=None, extraRecipients=None, 128 sendToInterestedUsers=True, extraHeaders=None, useTls=False, useSmtps=False, 129 smtpUser=None, smtpPassword=None, smtpPort=25, 130 dumpMailsToLog=False, generators=None): 131 132 if generators is None: 133 generators = self._create_default_generators() 134 135 yield super().reconfigService(generators=generators) 136 137 if extraRecipients is None: 138 extraRecipients = [] 139 self.extraRecipients = extraRecipients 140 self.sendToInterestedUsers = sendToInterestedUsers 141 self.fromaddr = fromaddr 142 self.relayhost = relayhost 143 if lookup is not None: 144 if isinstance(lookup, str): 145 lookup = Domain(str(lookup)) 146 self.lookup = lookup 147 self.extraHeaders = extraHeaders 148 self.useTls = useTls 149 self.useSmtps = useSmtps 150 self.smtpUser = smtpUser 151 self.smtpPassword = smtpPassword 152 self.smtpPort = smtpPort 153 self.dumpMailsToLog = dumpMailsToLog 154 155 def _create_default_generators(self): 156 return [ 157 BuildStatusGenerator(add_patch=True), 158 WorkerMissingGenerator(workers='all'), 159 ] 160 161 def patch_to_attachment(self, patch, index): 162 # patches are specifically converted to unicode before entering the db 163 a = MIMEText(patch['body'].encode(ENCODING), _charset=ENCODING) 164 # convert to base64 to conform with RFC 5322 2.1.1 165 del a['Content-Transfer-Encoding'] 166 encoders.encode_base64(a) 167 a.add_header('Content-Disposition', "attachment", 168 filename="source patch " + str(index)) 169 return a 170 171 @defer.inlineCallbacks 172 def createEmail(self, msgdict, title, results, builds=None, patches=None, logs=None): 173 text = msgdict['body'] 174 type = msgdict['type'] 175 subject = msgdict['subject'] 176 177 assert '\n' not in subject, \ 178 "Subject cannot contain newlines" 179 180 assert type in ('plain', 'html'), \ 181 "'{}' message type must be 'plain' or 'html'.".format(type) 182 183 if patches or logs: 184 m = MIMEMultipart() 185 txt = MIMEText(text, type, ENCODING) 186 m.attach(txt) 187 else: 188 m = Message() 189 m.set_payload(text, ENCODING) 190 m.set_type("text/{}".format(type)) 191 192 m['Date'] = formatdate(localtime=True) 193 m['Subject'] = subject 194 m['From'] = self.fromaddr 195 # m['To'] is added later 196 197 if patches: 198 for (i, patch) in enumerate(patches): 199 a = self.patch_to_attachment(patch, i) 200 m.attach(a) 201 if logs: 202 for log in logs: 203 # Use distinct filenames for the e-mail summary 204 name = "{}.{}".format(log['stepname'], log['name']) 205 if len(builds) > 1: 206 filename = "{}.{}".format(log['buildername'], name) 207 else: 208 filename = name 209 210 text = log['content']['content'] 211 a = MIMEText(text.encode(ENCODING), 212 _charset=ENCODING) 213 # convert to base64 to conform with RFC 5322 2.1.1 214 del a['Content-Transfer-Encoding'] 215 encoders.encode_base64(a) 216 a.add_header('Content-Disposition', "attachment", 217 filename=filename) 218 m.attach(a) 219 220 # @todo: is there a better way to do this? 221 # Add any extra headers that were requested, doing WithProperties 222 # interpolation if only one build was given 223 if self.extraHeaders: 224 extraHeaders = self.extraHeaders 225 if builds is not None and len(builds) == 1: 226 props = Properties.fromDict(builds[0]['properties']) 227 props.master = self.master 228 extraHeaders = yield props.render(extraHeaders) 229 230 for k, v in extraHeaders.items(): 231 if k in m: 232 twlog.msg("Warning: Got header " + k + 233 " in self.extraHeaders " 234 "but it already exists in the Message - " 235 "not adding it.") 236 m[k] = v 237 return m 238 239 @defer.inlineCallbacks 240 def sendMessage(self, reports): 241 body = merge_reports_prop(reports, 'body') 242 subject = merge_reports_prop_take_first(reports, 'subject') 243 type = merge_reports_prop_take_first(reports, 'type') 244 results = merge_reports_prop(reports, 'results') 245 builds = merge_reports_prop(reports, 'builds') 246 users = merge_reports_prop(reports, 'users') 247 patches = merge_reports_prop(reports, 'patches') 248 logs = merge_reports_prop(reports, 'logs') 249 worker = merge_reports_prop_take_first(reports, 'worker') 250 251 body = unicode2bytes(body) 252 msgdict = {'body': body, 'subject': subject, 'type': type} 253 254 # ensure message body ends with double carriage return 255 if not body.endswith(b"\n\n"): 256 msgdict['body'] = body + b'\n\n' 257 258 m = yield self.createEmail(msgdict, self.master.config.title, results, builds, 259 patches, logs) 260 261 # now, who is this message going to? 262 if worker is None: 263 recipients = yield self.findInterrestedUsersEmails(list(users)) 264 all_recipients = self.processRecipients(recipients, m) 265 else: 266 all_recipients = list(users) 267 yield self.sendMail(m, all_recipients) 268 269 @defer.inlineCallbacks 270 def findInterrestedUsersEmails(self, users): 271 recipients = set() 272 if self.sendToInterestedUsers: 273 if self.lookup: 274 dl = [] 275 for u in users: 276 dl.append(defer.maybeDeferred(self.lookup.getAddress, u)) 277 users = yield defer.gatherResults(dl) 278 279 for r in users: 280 if r is None: # getAddress didn't like this address 281 continue 282 283 # Git can give emails like 'User' <user@foo.com>@foo.com so check 284 # for two @ and chop the last 285 if r.count('@') > 1: 286 r = r[:r.rindex('@')] 287 288 if VALID_EMAIL.search(r): 289 recipients.add(r) 290 else: 291 twlog.msg("INVALID EMAIL: {}".format(r)) 292 293 return recipients 294 295 def formatAddress(self, addr): 296 r = parseaddr(addr) 297 if not r[0]: 298 return r[1] 299 return "\"{}\" <{}>".format(Header(r[0], 'utf-8').encode(), r[1]) 300 301 def processRecipients(self, blamelist, m): 302 to_recipients = set(blamelist) 303 cc_recipients = set() 304 305 # If we're sending to interested users put the extras in the 306 # CC list so they can tell if they are also interested in the 307 # change: 308 if self.sendToInterestedUsers and to_recipients: 309 cc_recipients.update(self.extraRecipients) 310 else: 311 to_recipients.update(self.extraRecipients) 312 313 m['To'] = ", ".join([self.formatAddress(addr) for addr in sorted(to_recipients)]) 314 if cc_recipients: 315 m['CC'] = ", ".join([self.formatAddress(addr) for addr in sorted(cc_recipients)]) 316 317 return list(to_recipients | cc_recipients) 318 319 def sendMail(self, m, recipients): 320 s = m.as_string() 321 twlog.msg("sending mail ({} bytes) to".format(len(s)), recipients) 322 if self.dumpMailsToLog: # pragma: no cover 323 twlog.msg("mail data:\n{0}".format(s)) 324 325 result = defer.Deferred() 326 327 useAuth = self.smtpUser and self.smtpPassword 328 329 s = unicode2bytes(s) 330 recipients = [parseaddr(r)[1] for r in recipients] 331 sender_factory = ESMTPSenderFactory( 332 unicode2bytes(self.smtpUser), unicode2bytes(self.smtpPassword), 333 parseaddr(self.fromaddr)[1], recipients, BytesIO(s), 334 result, requireTransportSecurity=self.useTls, 335 requireAuthentication=useAuth) 336 337 if self.useSmtps: 338 reactor.connectSSL(self.relayhost, self.smtpPort, 339 sender_factory, ssl.ClientContextFactory()) 340 else: 341 reactor.connectTCP(self.relayhost, self.smtpPort, sender_factory) 342 343 return result 344