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