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