blob: 8ae095e3651d767abaca5b456fd5bcc876cbc57d [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2016 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style
3# license that can be found in the LICENSE file or at
4# https://developers.google.com/open-source/licenses/bsd
5
6"""Handler to process inbound email with issue comments and commands."""
7from __future__ import print_function
8from __future__ import division
9from __future__ import absolute_import
10
11import email
12import logging
13import os
14import re
15import time
16import urllib
17
18import ezt
19
20from google.appengine.api import mail
21from google.appengine.ext.webapp.mail_handlers import BounceNotificationHandler
22
23import webapp2
24
25import settings
26from businesslogic import work_env
27from features import alert2issue
28from features import commitlogcommands
29from features import notify_helpers
30from framework import authdata
31from framework import emailfmt
32from framework import exceptions
33from framework import framework_constants
34from framework import monorailcontext
35from framework import permissions
36from framework import sql
37from framework import template_helpers
38from proto import project_pb2
39from tracker import tracker_helpers
40
41
42TEMPLATE_PATH_BASE = framework_constants.TEMPLATE_PATH
43
44MSG_TEMPLATES = {
45 'banned': 'features/inboundemail-banned.ezt',
46 'body_too_long': 'features/inboundemail-body-too-long.ezt',
47 'project_not_found': 'features/inboundemail-project-not-found.ezt',
48 'not_a_reply': 'features/inboundemail-not-a-reply.ezt',
49 'no_account': 'features/inboundemail-no-account.ezt',
50 'no_artifact': 'features/inboundemail-no-artifact.ezt',
51 'no_perms': 'features/inboundemail-no-perms.ezt',
52 'replies_disabled': 'features/inboundemail-replies-disabled.ezt',
53 }
54
55
56class InboundEmail(webapp2.RequestHandler):
57 """Servlet to handle inbound email messages."""
58
59 def __init__(self, request, response, services=None, *args, **kwargs):
60 super(InboundEmail, self).__init__(request, response, *args, **kwargs)
61 self.services = services or self.app.config.get('services')
62 self._templates = {}
63 for name, template_path in MSG_TEMPLATES.items():
64 self._templates[name] = template_helpers.MonorailTemplate(
65 TEMPLATE_PATH_BASE + template_path,
66 compress_whitespace=False, base_format=ezt.FORMAT_RAW)
67
68 def get(self, project_addr=None):
69 logging.info('\n\n\nGET for InboundEmail and project_addr is %r',
70 project_addr)
71 self.Handler(mail.InboundEmailMessage(self.request.body),
72 urllib.unquote(project_addr))
73
74 def post(self, project_addr=None):
75 logging.info('\n\n\nPOST for InboundEmail and project_addr is %r',
76 project_addr)
77 self.Handler(mail.InboundEmailMessage(self.request.body),
78 urllib.unquote(project_addr))
79
80 def Handler(self, inbound_email_message, project_addr):
81 """Process an inbound email message."""
82 msg = inbound_email_message.original
83 email_tasks = self.ProcessMail(msg, project_addr)
84
85 if email_tasks:
86 notify_helpers.AddAllEmailTasks(email_tasks)
87
88 def ProcessMail(self, msg, project_addr):
89 """Process an inbound email message."""
90 # TODO(jrobbins): If the message is HUGE, don't even try to parse
91 # it. Silently give up.
92
93 (from_addr, to_addrs, cc_addrs, references, incident_id, subject,
94 body) = emailfmt.ParseEmailMessage(msg)
95
96 logging.info('Proj addr: %r', project_addr)
97 logging.info('From addr: %r', from_addr)
98 logging.info('Subject: %r', subject)
99 logging.info('To: %r', to_addrs)
100 logging.info('Cc: %r', cc_addrs)
101 logging.info('References: %r', references)
102 logging.info('Incident Id: %r', incident_id)
103 logging.info('Body: %r', body)
104
105 # If message body is very large, reject it and send an error email.
106 if emailfmt.IsBodyTooBigToParse(body):
107 return _MakeErrorMessageReplyTask(
108 project_addr, from_addr, self._templates['body_too_long'])
109
110 # Make sure that the project reply-to address is in the To: line.
111 if not emailfmt.IsProjectAddressOnToLine(project_addr, to_addrs):
112 return None
113
114 project_name, verb, trooper_queue = emailfmt.IdentifyProjectVerbAndLabel(
115 project_addr)
116
117 is_alert = bool(verb and verb.lower() == 'alert')
118 error_addr = from_addr
119 local_id = None
120 author_addr = from_addr
121
122 if is_alert:
123 error_addr = settings.alert_escalation_email
124 author_addr = settings.alert_service_account
125 else:
126 local_id = emailfmt.IdentifyIssue(project_name, subject)
127 if not local_id:
128 logging.info('Could not identify issue: %s %s', project_addr, subject)
129 # No error message, because message was probably not intended for us.
130 return None
131
132 cnxn = sql.MonorailConnection()
133 if self.services.cache_manager:
134 self.services.cache_manager.DoDistributedInvalidation(cnxn)
135
136 project = self.services.project.GetProjectByName(cnxn, project_name)
137 # Authenticate the author_addr and perm check.
138 try:
139 mc = monorailcontext.MonorailContext(
140 self.services, cnxn=cnxn, requester=author_addr, autocreate=is_alert)
141 mc.LookupLoggedInUserPerms(project)
142 except exceptions.NoSuchUserException:
143 return _MakeErrorMessageReplyTask(
144 project_addr, error_addr, self._templates['no_account'])
145
146 # TODO(zhangtiff): Add separate email templates for alert error cases.
147 if not project or project.state != project_pb2.ProjectState.LIVE:
148 return _MakeErrorMessageReplyTask(
149 project_addr, error_addr, self._templates['project_not_found'])
150
151 if not project.process_inbound_email:
152 return _MakeErrorMessageReplyTask(
153 project_addr, error_addr, self._templates['replies_disabled'],
154 project_name=project_name)
155
156 # Verify that this is a reply to a notification that we could have sent.
157 is_development = os.environ['SERVER_SOFTWARE'].startswith('Development')
158 if not (is_alert or is_development):
159 for ref in references:
160 if emailfmt.ValidateReferencesHeader(ref, project, from_addr, subject):
161 break # Found a message ID that we could have sent.
162 if emailfmt.ValidateReferencesHeader(
163 ref, project, from_addr.lower(), subject):
164 break # Also match all-lowercase from-address.
165 else: # for-else: if loop completes with no valid reference found.
166 return _MakeErrorMessageReplyTask(
167 project_addr, from_addr, self._templates['not_a_reply'])
168
169 # Note: If the issue summary line is changed, a new thread is created,
170 # and replies to the old thread will no longer work because the subject
171 # line hash will not match, which seems reasonable.
172
173 if mc.auth.user_pb.banned:
174 logging.info('Banned user %s tried to post to %s',
175 from_addr, project_addr)
176 return _MakeErrorMessageReplyTask(
177 project_addr, error_addr, self._templates['banned'])
178
179 # If the email is an alert, switch to the alert handling path.
180 if is_alert:
181 alert2issue.ProcessEmailNotification(
182 self.services, cnxn, project, project_addr, from_addr,
183 mc.auth, subject, body, incident_id, msg, trooper_queue)
184 return None
185
186 # This email is a response to an email about a comment.
187 self.ProcessIssueReply(
188 mc, project, local_id, project_addr, body)
189
190 return None
191
192 def ProcessIssueReply(
193 self, mc, project, local_id, project_addr, body):
194 """Examine an issue reply email body and add a comment to the issue.
195
196 Args:
197 mc: MonorailContext with cnxn and the requester email, user_id, perms.
198 project: Project PB for the project containing the issue.
199 local_id: int ID of the issue being replied to.
200 project_addr: string email address used for outbound emails from
201 that project.
202 body: string email body text of the reply email.
203
204 Returns:
205 A list of follow-up work items, e.g., to notify other users of
206 the new comment, or to notify the user that their reply was not
207 processed.
208
209 Side-effect:
210 Adds a new comment to the issue, if no error is reported.
211 """
212 try:
213 issue = self.services.issue.GetIssueByLocalID(
214 mc.cnxn, project.project_id, local_id)
215 except exceptions.NoSuchIssueException:
216 issue = None
217
218 if not issue or issue.deleted:
219 # The referenced issue was not found, e.g., it might have been
220 # deleted, or someone messed with the subject line. Reject it.
221 return _MakeErrorMessageReplyTask(
222 project_addr, mc.auth.email, self._templates['no_artifact'],
223 artifact_phrase='issue %d' % local_id,
224 project_name=project.project_name)
225
226 can_view = mc.perms.CanUsePerm(
227 permissions.VIEW, mc.auth.effective_ids, project,
228 permissions.GetRestrictions(issue))
229 can_comment = mc.perms.CanUsePerm(
230 permissions.ADD_ISSUE_COMMENT, mc.auth.effective_ids, project,
231 permissions.GetRestrictions(issue))
232 if not can_view or not can_comment:
233 return _MakeErrorMessageReplyTask(
234 project_addr, mc.auth.email, self._templates['no_perms'],
235 artifact_phrase='issue %d' % local_id,
236 project_name=project.project_name)
237
238 # TODO(jrobbins): if the user does not have EDIT_ISSUE and the inbound
239 # email tries to make an edit, send back an error message.
240
241 lines = body.strip().split('\n')
242 uia = commitlogcommands.UpdateIssueAction(local_id)
243 uia.Parse(mc.cnxn, project.project_name, mc.auth.user_id, lines,
244 self.services, strip_quoted_lines=True)
245 uia.Run(mc, self.services)
246
247
248def _MakeErrorMessageReplyTask(
249 project_addr, sender_addr, template, **callers_page_data):
250 """Return a new task to send an error message email.
251
252 Args:
253 project_addr: string email address that the inbound email was delivered to.
254 sender_addr: string email address of user who sent the email that we could
255 not process.
256 template: EZT template used to generate the email error message. The
257 first line of this generated text will be used as the subject line.
258 callers_page_data: template data dict for body of the message.
259
260 Returns:
261 A list with a single Email task that can be enqueued to
262 actually send the email.
263
264 Raises:
265 ValueError: if the template does begin with a "Subject:" line.
266 """
267 email_data = {
268 'project_addr': project_addr,
269 'sender_addr': sender_addr
270 }
271 email_data.update(callers_page_data)
272
273 generated_lines = template.GetResponse(email_data)
274 subject, body = generated_lines.split('\n', 1)
275 if subject.startswith('Subject: '):
276 subject = subject[len('Subject: '):]
277 else:
278 raise ValueError('Email template does not begin with "Subject:" line.')
279
280 email_task = dict(to=sender_addr, subject=subject, body=body,
281 from_addr=emailfmt.NoReplyAddress())
282 logging.info('sending email error reply: %r', email_task)
283
284 return [email_task]
285
286
287BAD_WRAP_RE = re.compile('=\r\n')
288BAD_EQ_RE = re.compile('=3D')
289
290class BouncedEmail(BounceNotificationHandler):
291 """Handler to notice when email to given user is bouncing."""
292
293 # For docs on AppEngine's bounce email handling, see:
294 # https://cloud.google.com/appengine/docs/python/mail/bounce
295 # Source code is in file:
296 # google_appengine/google/appengine/ext/webapp/mail_handlers.py
297
298 def post(self):
299 try:
300 super(BouncedEmail, self).post()
301 except AttributeError:
302 # Work-around for
303 # https://code.google.com/p/googleappengine/issues/detail?id=13512
304 raw_message = self.request.POST.get('raw-message')
305 logging.info('raw_message %r', raw_message)
306 raw_message = BAD_WRAP_RE.sub('', raw_message)
307 raw_message = BAD_EQ_RE.sub('=', raw_message)
308 logging.info('fixed raw_message %r', raw_message)
309 mime_message = email.message_from_string(raw_message)
310 logging.info('get_payload gives %r', mime_message.get_payload())
311 self.request.POST['raw-message'] = mime_message
312 super(BouncedEmail, self).post() # Retry with mime_message
313
314
315 def receive(self, bounce_message):
316 email_addr = bounce_message.original.get('to')
317 logging.info('Bounce was sent to: %r', email_addr)
318
319 # TODO(crbug.com/monorail/8727): The problem is likely no longer happening.
320 # but we are adding permanent logging so we don't have to keep adding
321 # expriring logpoints.
322 if '@intel' in email_addr: # both intel.com and intel-partner.
323 logging.info(
324 'bounce message: %s', bounce_message.notification.get('text'))
325
326 app_config = webapp2.WSGIApplication.app.config
327 services = app_config['services']
328 cnxn = sql.MonorailConnection()
329
330 try:
331 user_id = services.user.LookupUserID(cnxn, email_addr)
332 user = services.user.GetUser(cnxn, user_id)
333 user.email_bounce_timestamp = int(time.time())
334 services.user.UpdateUser(cnxn, user_id, user)
335 except exceptions.NoSuchUserException:
336 logging.info('User %r not found, ignoring', email_addr)
337 logging.info('Received bounce post ... [%s]', self.request)
338 logging.info('Bounce original: %s', bounce_message.original)
339 logging.info('Bounce notification: %s', bounce_message.notification)