• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3
4# Copyright 2019 The ChromiumOS Authors
5# Use of this source code is governed by a BSD-style license that can be
6# found in the LICENSE file.
7
8"""Utilities to send email either through SMTP or SendGMR."""
9
10
11import base64
12import contextlib
13import datetime
14from email import encoders as Encoders
15from email.mime.base import MIMEBase
16from email.mime.multipart import MIMEMultipart
17from email.mime.text import MIMEText
18import getpass
19import json
20import os
21import smtplib
22import tempfile
23
24from cros_utils import command_executer
25
26
27X20_PATH = "/google/data/rw/teams/c-compiler-chrome/prod_emails"
28
29
30@contextlib.contextmanager
31def AtomicallyWriteFile(file_path):
32    temp_path = file_path + ".in_progress"
33    try:
34        with open(temp_path, "w") as f:
35            yield f
36        os.rename(temp_path, file_path)
37    except:
38        os.remove(temp_path)
39        raise
40
41
42class EmailSender(object):
43    """Utility class to send email through SMTP or SendGMR."""
44
45    class Attachment(object):
46        """Small class to keep track of attachment info."""
47
48        def __init__(self, name, content):
49            self.name = name
50            self.content = content
51
52    def SendX20Email(
53        self,
54        subject,
55        identifier,
56        well_known_recipients=(),
57        direct_recipients=(),
58        text_body=None,
59        html_body=None,
60    ):
61        """Enqueues an email in our x20 outbox.
62
63        These emails ultimately get sent by the machinery in
64        //depot/google3/googleclient/chrome/chromeos_toolchain/mailer/mail.go. This
65        kind of sending is intended for accounts that don't have smtp or gmr access
66        (e.g., role accounts), but can be used by anyone with x20 access.
67
68        All emails are sent from `mdb.c-compiler-chrome+${identifier}@google.com`.
69
70        Args:
71          subject: email subject. Must be nonempty.
72          identifier: email identifier, or the text that lands after the `+` in the
73                      "From" email address. Must be nonempty.
74          well_known_recipients: a list of well-known recipients for the email.
75                                 These are translated into addresses by our mailer.
76                                 Current potential values for this are ('detective',
77                                 'cwp-team', 'cros-team', 'mage'). Either this or
78                                 direct_recipients must be a nonempty list.
79          direct_recipients: @google.com emails to send addresses to. Either this
80                             or well_known_recipients must be a nonempty list.
81          text_body: a 'text/plain' email body to send. Either this or html_body
82                     must be a nonempty string. Both may be specified
83          html_body: a 'text/html' email body to send. Either this or text_body
84                     must be a nonempty string. Both may be specified
85        """
86        # `str`s act a lot like tuples/lists. Ensure that we're not accidentally
87        # iterating over one of those (or anything else that's sketchy, for that
88        # matter).
89        if not isinstance(well_known_recipients, (tuple, list)):
90            raise ValueError(
91                "`well_known_recipients` is unexpectedly a %s"
92                % type(well_known_recipients)
93            )
94
95        if not isinstance(direct_recipients, (tuple, list)):
96            raise ValueError(
97                "`direct_recipients` is unexpectedly a %s"
98                % type(direct_recipients)
99            )
100
101        if not subject or not identifier:
102            raise ValueError("both `subject` and `identifier` must be nonempty")
103
104        if not (well_known_recipients or direct_recipients):
105            raise ValueError(
106                "either `well_known_recipients` or `direct_recipients` "
107                "must be specified"
108            )
109
110        for recipient in direct_recipients:
111            if not recipient.endswith("@google.com"):
112                raise ValueError("All recipients must end with @google.com")
113
114        if not (text_body or html_body):
115            raise ValueError(
116                "either `text_body` or `html_body` must be specified"
117            )
118
119        email_json = {
120            "email_identifier": identifier,
121            "subject": subject,
122        }
123
124        if well_known_recipients:
125            email_json["well_known_recipients"] = well_known_recipients
126
127        if direct_recipients:
128            email_json["direct_recipients"] = direct_recipients
129
130        if text_body:
131            email_json["body"] = text_body
132
133        if html_body:
134            email_json["html_body"] = html_body
135
136        # The name of this has two parts:
137        # - An easily sortable time, to provide uniqueness and let our emailer
138        #   send things in the order they were put into the outbox.
139        # - 64 bits of entropy, so two racing email sends don't clobber the same
140        #   file.
141        now = datetime.datetime.utcnow().isoformat("T", "seconds") + "Z"
142        entropy = base64.urlsafe_b64encode(os.getrandom(8))
143        entropy_str = entropy.rstrip(b"=").decode("utf-8")
144        result_path = os.path.join(X20_PATH, now + "_" + entropy_str + ".json")
145
146        with AtomicallyWriteFile(result_path) as f:
147            json.dump(email_json, f)
148
149    def SendEmail(
150        self,
151        email_to,
152        subject,
153        text_to_send,
154        email_cc=None,
155        email_bcc=None,
156        email_from=None,
157        msg_type="plain",
158        attachments=None,
159    ):
160        """Choose appropriate email method and call it."""
161        if os.path.exists("/usr/bin/sendgmr"):
162            self.SendGMREmail(
163                email_to,
164                subject,
165                text_to_send,
166                email_cc,
167                email_bcc,
168                email_from,
169                msg_type,
170                attachments,
171            )
172        else:
173            self.SendSMTPEmail(
174                email_to,
175                subject,
176                text_to_send,
177                email_cc,
178                email_bcc,
179                email_from,
180                msg_type,
181                attachments,
182            )
183
184    def SendSMTPEmail(
185        self,
186        email_to,
187        subject,
188        text_to_send,
189        email_cc,
190        email_bcc,
191        email_from,
192        msg_type,
193        attachments,
194    ):
195        """Send email via standard smtp mail."""
196        # Email summary to the current user.
197        msg = MIMEMultipart()
198
199        if not email_from:
200            email_from = os.path.basename(__file__)
201
202        msg["To"] = ",".join(email_to)
203        msg["Subject"] = subject
204
205        if email_from:
206            msg["From"] = email_from
207        if email_cc:
208            msg["CC"] = ",".join(email_cc)
209            email_to += email_cc
210        if email_bcc:
211            msg["BCC"] = ",".join(email_bcc)
212            email_to += email_bcc
213
214        msg.attach(MIMEText(text_to_send, msg_type))
215        if attachments:
216            for attachment in attachments:
217                part = MIMEBase("application", "octet-stream")
218                part.set_payload(attachment.content)
219                Encoders.encode_base64(part)
220                part.add_header(
221                    "Content-Disposition",
222                    'attachment; filename="%s"' % attachment.name,
223                )
224                msg.attach(part)
225
226        # Send the message via our own SMTP server, but don't include the
227        # envelope header.
228        s = smtplib.SMTP("localhost")
229        s.sendmail(email_from, email_to, msg.as_string())
230        s.quit()
231
232    def SendGMREmail(
233        self,
234        email_to,
235        subject,
236        text_to_send,
237        email_cc,
238        email_bcc,
239        email_from,
240        msg_type,
241        attachments,
242    ):
243        """Send email via sendgmr program."""
244        ce = command_executer.GetCommandExecuter(log_level="none")
245
246        if not email_from:
247            email_from = getpass.getuser() + "@google.com"
248
249        to_list = ",".join(email_to)
250
251        if not text_to_send:
252            text_to_send = "Empty message body."
253
254        to_be_deleted = []
255        try:
256            with tempfile.NamedTemporaryFile(
257                "w", encoding="utf-8", delete=False
258            ) as f:
259                f.write(text_to_send)
260                f.flush()
261            to_be_deleted.append(f.name)
262
263            # Fix single-quotes inside the subject. In bash, to escape a single quote
264            # (e.g 'don't') you need to replace it with '\'' (e.g. 'don'\''t'). To
265            # make Python read the backslash as a backslash rather than an escape
266            # character, you need to double it. So...
267            subject = subject.replace("'", "'\\''")
268
269            if msg_type == "html":
270                command = (
271                    "sendgmr --to='%s' --from='%s' --subject='%s' "
272                    "--html_file='%s' --body_file=/dev/null"
273                    % (to_list, email_from, subject, f.name)
274                )
275            else:
276                command = (
277                    "sendgmr --to='%s' --from='%s' --subject='%s' "
278                    "--body_file='%s'" % (to_list, email_from, subject, f.name)
279                )
280
281            if email_cc:
282                cc_list = ",".join(email_cc)
283                command += " --cc='%s'" % cc_list
284            if email_bcc:
285                bcc_list = ",".join(email_bcc)
286                command += " --bcc='%s'" % bcc_list
287
288            if attachments:
289                attachment_files = []
290                for attachment in attachments:
291                    if "<html>" in attachment.content:
292                        report_suffix = "_report.html"
293                    else:
294                        report_suffix = "_report.txt"
295                    with tempfile.NamedTemporaryFile(
296                        "w",
297                        encoding="utf-8",
298                        delete=False,
299                        suffix=report_suffix,
300                    ) as f:
301                        f.write(attachment.content)
302                        f.flush()
303                    attachment_files.append(f.name)
304                files = ",".join(attachment_files)
305                command += " --attachment_files='%s'" % files
306                to_be_deleted += attachment_files
307
308            # Send the message via our own GMR server.
309            status = ce.RunCommand(command)
310            return status
311
312        finally:
313            for f in to_be_deleted:
314                os.remove(f)
315