1#!/usr/bin/env python3 2# -*- coding: utf-8 -*- 3 4# Copyright 2019 The Chromium OS Authors. All rights reserved. 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 10from __future__ import print_function 11 12import base64 13import contextlib 14import datetime 15import getpass 16import json 17import os 18import smtplib 19import tempfile 20from email import encoders as Encoders 21from email.mime.base import MIMEBase 22from email.mime.multipart import MIMEMultipart 23from email.mime.text import MIMEText 24 25from cros_utils import command_executer 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(self, 53 subject, 54 identifier, 55 well_known_recipients=(), 56 direct_recipients=(), 57 text_body=None, 58 html_body=None): 59 """Enqueues an email in our x20 outbox. 60 61 These emails ultimately get sent by the machinery in 62 //depot/google3/googleclient/chrome/chromeos_toolchain/mailer/mail.go. This 63 kind of sending is intended for accounts that don't have smtp or gmr access 64 (e.g., role accounts), but can be used by anyone with x20 access. 65 66 All emails are sent from `mdb.c-compiler-chrome+${identifier}@google.com`. 67 68 Args: 69 subject: email subject. Must be nonempty. 70 identifier: email identifier, or the text that lands after the `+` in the 71 "From" email address. Must be nonempty. 72 well_known_recipients: a list of well-known recipients for the email. 73 These are translated into addresses by our mailer. 74 Current potential values for this are ('sheriff', 75 'cwp-team', 'cros-team', 'mage'). Either this or 76 direct_recipients must be a nonempty list. 77 direct_recipients: @google.com emails to send addresses to. Either this 78 or well_known_recipients must be a nonempty list. 79 text_body: a 'text/plain' email body to send. Either this or html_body 80 must be a nonempty string. Both may be specified 81 html_body: a 'text/html' email body to send. Either this or text_body 82 must be a nonempty string. Both may be specified 83 """ 84 # `str`s act a lot like tuples/lists. Ensure that we're not accidentally 85 # iterating over one of those (or anything else that's sketchy, for that 86 # matter). 87 if not isinstance(well_known_recipients, (tuple, list)): 88 raise ValueError('`well_known_recipients` is unexpectedly a %s' % 89 type(well_known_recipients)) 90 91 if not isinstance(direct_recipients, (tuple, list)): 92 raise ValueError( 93 '`direct_recipients` is unexpectedly a %s' % type(direct_recipients)) 94 95 if not subject or not identifier: 96 raise ValueError('both `subject` and `identifier` must be nonempty') 97 98 if not (well_known_recipients or direct_recipients): 99 raise ValueError('either `well_known_recipients` or `direct_recipients` ' 100 'must be specified') 101 102 for recipient in direct_recipients: 103 if not recipient.endswith('@google.com'): 104 raise ValueError('All recipients must end with @google.com') 105 106 if not (text_body or html_body): 107 raise ValueError('either `text_body` or `html_body` must be specified') 108 109 email_json = { 110 'email_identifier': identifier, 111 'subject': subject, 112 } 113 114 if well_known_recipients: 115 email_json['well_known_recipients'] = well_known_recipients 116 117 if direct_recipients: 118 email_json['direct_recipients'] = direct_recipients 119 120 if text_body: 121 email_json['body'] = text_body 122 123 if html_body: 124 email_json['html_body'] = html_body 125 126 # The name of this has two parts: 127 # - An easily sortable time, to provide uniqueness and let our emailer 128 # send things in the order they were put into the outbox. 129 # - 64 bits of entropy, so two racing email sends don't clobber the same 130 # file. 131 now = datetime.datetime.utcnow().isoformat('T', 'seconds') + 'Z' 132 entropy = base64.urlsafe_b64encode(os.getrandom(8)) 133 entropy_str = entropy.rstrip(b'=').decode('utf-8') 134 result_path = os.path.join(X20_PATH, now + '_' + entropy_str + '.json') 135 136 with AtomicallyWriteFile(result_path) as f: 137 json.dump(email_json, f) 138 139 def SendEmail(self, 140 email_to, 141 subject, 142 text_to_send, 143 email_cc=None, 144 email_bcc=None, 145 email_from=None, 146 msg_type='plain', 147 attachments=None): 148 """Choose appropriate email method and call it.""" 149 if os.path.exists('/usr/bin/sendgmr'): 150 self.SendGMREmail(email_to, subject, text_to_send, email_cc, email_bcc, 151 email_from, msg_type, attachments) 152 else: 153 self.SendSMTPEmail(email_to, subject, text_to_send, email_cc, email_bcc, 154 email_from, msg_type, attachments) 155 156 def SendSMTPEmail(self, email_to, subject, text_to_send, email_cc, email_bcc, 157 email_from, msg_type, attachments): 158 """Send email via standard smtp mail.""" 159 # Email summary to the current user. 160 msg = MIMEMultipart() 161 162 if not email_from: 163 email_from = os.path.basename(__file__) 164 165 msg['To'] = ','.join(email_to) 166 msg['Subject'] = subject 167 168 if email_from: 169 msg['From'] = email_from 170 if email_cc: 171 msg['CC'] = ','.join(email_cc) 172 email_to += email_cc 173 if email_bcc: 174 msg['BCC'] = ','.join(email_bcc) 175 email_to += email_bcc 176 177 msg.attach(MIMEText(text_to_send, msg_type)) 178 if attachments: 179 for attachment in attachments: 180 part = MIMEBase('application', 'octet-stream') 181 part.set_payload(attachment.content) 182 Encoders.encode_base64(part) 183 part.add_header('Content-Disposition', 184 'attachment; filename="%s"' % attachment.name) 185 msg.attach(part) 186 187 # Send the message via our own SMTP server, but don't include the 188 # envelope header. 189 s = smtplib.SMTP('localhost') 190 s.sendmail(email_from, email_to, msg.as_string()) 191 s.quit() 192 193 def SendGMREmail(self, email_to, subject, text_to_send, email_cc, email_bcc, 194 email_from, msg_type, attachments): 195 """Send email via sendgmr program.""" 196 ce = command_executer.GetCommandExecuter(log_level='none') 197 198 if not email_from: 199 email_from = getpass.getuser() + '@google.com' 200 201 to_list = ','.join(email_to) 202 203 if not text_to_send: 204 text_to_send = 'Empty message body.' 205 206 to_be_deleted = [] 207 try: 208 with tempfile.NamedTemporaryFile( 209 'w', encoding='utf-8', delete=False) as f: 210 f.write(text_to_send) 211 f.flush() 212 to_be_deleted.append(f.name) 213 214 # Fix single-quotes inside the subject. In bash, to escape a single quote 215 # (e.g 'don't') you need to replace it with '\'' (e.g. 'don'\''t'). To 216 # make Python read the backslash as a backslash rather than an escape 217 # character, you need to double it. So... 218 subject = subject.replace("'", "'\\''") 219 220 if msg_type == 'html': 221 command = ("sendgmr --to='%s' --from='%s' --subject='%s' " 222 "--html_file='%s' --body_file=/dev/null" % 223 (to_list, email_from, subject, f.name)) 224 else: 225 command = ("sendgmr --to='%s' --from='%s' --subject='%s' " 226 "--body_file='%s'" % (to_list, email_from, subject, f.name)) 227 228 if email_cc: 229 cc_list = ','.join(email_cc) 230 command += " --cc='%s'" % cc_list 231 if email_bcc: 232 bcc_list = ','.join(email_bcc) 233 command += " --bcc='%s'" % bcc_list 234 235 if attachments: 236 attachment_files = [] 237 for attachment in attachments: 238 if '<html>' in attachment.content: 239 report_suffix = '_report.html' 240 else: 241 report_suffix = '_report.txt' 242 with tempfile.NamedTemporaryFile( 243 'w', encoding='utf-8', delete=False, suffix=report_suffix) as f: 244 f.write(attachment.content) 245 f.flush() 246 attachment_files.append(f.name) 247 files = ','.join(attachment_files) 248 command += " --attachment_files='%s'" % files 249 to_be_deleted += attachment_files 250 251 # Send the message via our own GMR server. 252 status = ce.RunCommand(command) 253 return status 254 255 finally: 256 for f in to_be_deleted: 257 os.remove(f) 258