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