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