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