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