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