blob: d9b2c37a5bd02d2b180b568444ae5cae061f9233 [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
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020016import six
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020017from six.moves import urllib
Copybara854996b2021-09-07 19:36:02 +000018
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020019import flask
Copybara854996b2021-09-07 19:36:02 +000020import ezt
21
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020022from google.appengine.api import mail
23if six.PY2:
24 from google.appengine.ext.webapp.mail_handlers import BounceNotification
25else:
26 from google.appengine.api.mail import BounceNotification
Copybara854996b2021-09-07 19:36:02 +000027
Copybara854996b2021-09-07 19:36:02 +000028
29import settings
Copybara854996b2021-09-07 19:36:02 +000030from features import alert2issue
31from features import commitlogcommands
32from features import notify_helpers
33from framework import authdata
34from framework import emailfmt
35from framework import exceptions
36from framework import framework_constants
37from framework import monorailcontext
38from framework import permissions
39from framework import sql
40from framework import template_helpers
41from proto import project_pb2
Copybara854996b2021-09-07 19:36:02 +000042
43
44TEMPLATE_PATH_BASE = framework_constants.TEMPLATE_PATH
45
46MSG_TEMPLATES = {
47 'banned': 'features/inboundemail-banned.ezt',
48 'body_too_long': 'features/inboundemail-body-too-long.ezt',
49 'project_not_found': 'features/inboundemail-project-not-found.ezt',
50 'not_a_reply': 'features/inboundemail-not-a-reply.ezt',
51 'no_account': 'features/inboundemail-no-account.ezt',
52 'no_artifact': 'features/inboundemail-no-artifact.ezt',
53 'no_perms': 'features/inboundemail-no-perms.ezt',
54 'replies_disabled': 'features/inboundemail-replies-disabled.ezt',
55 }
56
57
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020058class InboundEmail(object):
Copybara854996b2021-09-07 19:36:02 +000059 """Servlet to handle inbound email messages."""
60
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020061 def __init__(self, services=None):
62 self.services = services or flask.current_app.config['services']
Copybara854996b2021-09-07 19:36:02 +000063 self._templates = {}
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020064 self.request = flask.request
Copybara854996b2021-09-07 19:36:02 +000065 for name, template_path in MSG_TEMPLATES.items():
66 self._templates[name] = template_helpers.MonorailTemplate(
67 TEMPLATE_PATH_BASE + template_path,
68 compress_whitespace=False, base_format=ezt.FORMAT_RAW)
69
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020070 def HandleInboundEmail(self, project_addr=None):
71 if self.request.method == 'POST':
72 self.post(project_addr)
73 elif self.request.method == 'GET':
74 self.get(project_addr)
75 return ''
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020076
Copybara854996b2021-09-07 19:36:02 +000077 def get(self, project_addr=None):
78 logging.info('\n\n\nGET for InboundEmail and project_addr is %r',
79 project_addr)
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020080 self.Handler(
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020081 mail.InboundEmailMessage(self.request.get_data()),
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020082 urllib.parse.unquote(project_addr))
Copybara854996b2021-09-07 19:36:02 +000083
84 def post(self, project_addr=None):
85 logging.info('\n\n\nPOST for InboundEmail and project_addr is %r',
86 project_addr)
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020087 self.Handler(
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020088 mail.InboundEmailMessage(self.request.get_data()),
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020089 urllib.parse.unquote(project_addr))
Copybara854996b2021-09-07 19:36:02 +000090
91 def Handler(self, inbound_email_message, project_addr):
92 """Process an inbound email message."""
93 msg = inbound_email_message.original
94 email_tasks = self.ProcessMail(msg, project_addr)
95
96 if email_tasks:
97 notify_helpers.AddAllEmailTasks(email_tasks)
98
99 def ProcessMail(self, msg, project_addr):
100 """Process an inbound email message."""
101 # TODO(jrobbins): If the message is HUGE, don't even try to parse
102 # it. Silently give up.
103
104 (from_addr, to_addrs, cc_addrs, references, incident_id, subject,
105 body) = emailfmt.ParseEmailMessage(msg)
106
107 logging.info('Proj addr: %r', project_addr)
108 logging.info('From addr: %r', from_addr)
109 logging.info('Subject: %r', subject)
110 logging.info('To: %r', to_addrs)
111 logging.info('Cc: %r', cc_addrs)
112 logging.info('References: %r', references)
113 logging.info('Incident Id: %r', incident_id)
114 logging.info('Body: %r', body)
115
116 # If message body is very large, reject it and send an error email.
117 if emailfmt.IsBodyTooBigToParse(body):
118 return _MakeErrorMessageReplyTask(
119 project_addr, from_addr, self._templates['body_too_long'])
120
121 # Make sure that the project reply-to address is in the To: line.
122 if not emailfmt.IsProjectAddressOnToLine(project_addr, to_addrs):
123 return None
124
125 project_name, verb, trooper_queue = emailfmt.IdentifyProjectVerbAndLabel(
126 project_addr)
127
128 is_alert = bool(verb and verb.lower() == 'alert')
129 error_addr = from_addr
130 local_id = None
131 author_addr = from_addr
132
133 if is_alert:
134 error_addr = settings.alert_escalation_email
135 author_addr = settings.alert_service_account
136 else:
137 local_id = emailfmt.IdentifyIssue(project_name, subject)
138 if not local_id:
139 logging.info('Could not identify issue: %s %s', project_addr, subject)
140 # No error message, because message was probably not intended for us.
141 return None
142
143 cnxn = sql.MonorailConnection()
144 if self.services.cache_manager:
145 self.services.cache_manager.DoDistributedInvalidation(cnxn)
146
147 project = self.services.project.GetProjectByName(cnxn, project_name)
148 # Authenticate the author_addr and perm check.
149 try:
150 mc = monorailcontext.MonorailContext(
151 self.services, cnxn=cnxn, requester=author_addr, autocreate=is_alert)
152 mc.LookupLoggedInUserPerms(project)
153 except exceptions.NoSuchUserException:
154 return _MakeErrorMessageReplyTask(
155 project_addr, error_addr, self._templates['no_account'])
156
157 # TODO(zhangtiff): Add separate email templates for alert error cases.
158 if not project or project.state != project_pb2.ProjectState.LIVE:
159 return _MakeErrorMessageReplyTask(
160 project_addr, error_addr, self._templates['project_not_found'])
161
162 if not project.process_inbound_email:
163 return _MakeErrorMessageReplyTask(
164 project_addr, error_addr, self._templates['replies_disabled'],
165 project_name=project_name)
166
167 # Verify that this is a reply to a notification that we could have sent.
168 is_development = os.environ['SERVER_SOFTWARE'].startswith('Development')
169 if not (is_alert or is_development):
170 for ref in references:
171 if emailfmt.ValidateReferencesHeader(ref, project, from_addr, subject):
172 break # Found a message ID that we could have sent.
173 if emailfmt.ValidateReferencesHeader(
174 ref, project, from_addr.lower(), subject):
175 break # Also match all-lowercase from-address.
176 else: # for-else: if loop completes with no valid reference found.
177 return _MakeErrorMessageReplyTask(
178 project_addr, from_addr, self._templates['not_a_reply'])
179
180 # Note: If the issue summary line is changed, a new thread is created,
181 # and replies to the old thread will no longer work because the subject
182 # line hash will not match, which seems reasonable.
183
184 if mc.auth.user_pb.banned:
185 logging.info('Banned user %s tried to post to %s',
186 from_addr, project_addr)
187 return _MakeErrorMessageReplyTask(
188 project_addr, error_addr, self._templates['banned'])
189
190 # If the email is an alert, switch to the alert handling path.
191 if is_alert:
192 alert2issue.ProcessEmailNotification(
193 self.services, cnxn, project, project_addr, from_addr,
194 mc.auth, subject, body, incident_id, msg, trooper_queue)
195 return None
196
197 # This email is a response to an email about a comment.
198 self.ProcessIssueReply(
199 mc, project, local_id, project_addr, body)
200
201 return None
202
203 def ProcessIssueReply(
204 self, mc, project, local_id, project_addr, body):
205 """Examine an issue reply email body and add a comment to the issue.
206
207 Args:
208 mc: MonorailContext with cnxn and the requester email, user_id, perms.
209 project: Project PB for the project containing the issue.
210 local_id: int ID of the issue being replied to.
211 project_addr: string email address used for outbound emails from
212 that project.
213 body: string email body text of the reply email.
214
215 Returns:
216 A list of follow-up work items, e.g., to notify other users of
217 the new comment, or to notify the user that their reply was not
218 processed.
219
220 Side-effect:
221 Adds a new comment to the issue, if no error is reported.
222 """
223 try:
224 issue = self.services.issue.GetIssueByLocalID(
225 mc.cnxn, project.project_id, local_id)
226 except exceptions.NoSuchIssueException:
227 issue = None
228
229 if not issue or issue.deleted:
230 # The referenced issue was not found, e.g., it might have been
231 # deleted, or someone messed with the subject line. Reject it.
232 return _MakeErrorMessageReplyTask(
233 project_addr, mc.auth.email, self._templates['no_artifact'],
234 artifact_phrase='issue %d' % local_id,
235 project_name=project.project_name)
236
237 can_view = mc.perms.CanUsePerm(
238 permissions.VIEW, mc.auth.effective_ids, project,
239 permissions.GetRestrictions(issue))
240 can_comment = mc.perms.CanUsePerm(
241 permissions.ADD_ISSUE_COMMENT, mc.auth.effective_ids, project,
242 permissions.GetRestrictions(issue))
243 if not can_view or not can_comment:
244 return _MakeErrorMessageReplyTask(
245 project_addr, mc.auth.email, self._templates['no_perms'],
246 artifact_phrase='issue %d' % local_id,
247 project_name=project.project_name)
248
249 # TODO(jrobbins): if the user does not have EDIT_ISSUE and the inbound
250 # email tries to make an edit, send back an error message.
251
252 lines = body.strip().split('\n')
253 uia = commitlogcommands.UpdateIssueAction(local_id)
254 uia.Parse(mc.cnxn, project.project_name, mc.auth.user_id, lines,
255 self.services, strip_quoted_lines=True)
256 uia.Run(mc, self.services)
257
258
259def _MakeErrorMessageReplyTask(
260 project_addr, sender_addr, template, **callers_page_data):
261 """Return a new task to send an error message email.
262
263 Args:
264 project_addr: string email address that the inbound email was delivered to.
265 sender_addr: string email address of user who sent the email that we could
266 not process.
267 template: EZT template used to generate the email error message. The
268 first line of this generated text will be used as the subject line.
269 callers_page_data: template data dict for body of the message.
270
271 Returns:
272 A list with a single Email task that can be enqueued to
273 actually send the email.
274
275 Raises:
276 ValueError: if the template does begin with a "Subject:" line.
277 """
278 email_data = {
279 'project_addr': project_addr,
280 'sender_addr': sender_addr
281 }
282 email_data.update(callers_page_data)
283
284 generated_lines = template.GetResponse(email_data)
285 subject, body = generated_lines.split('\n', 1)
286 if subject.startswith('Subject: '):
287 subject = subject[len('Subject: '):]
288 else:
289 raise ValueError('Email template does not begin with "Subject:" line.')
290
291 email_task = dict(to=sender_addr, subject=subject, body=body,
292 from_addr=emailfmt.NoReplyAddress())
293 logging.info('sending email error reply: %r', email_task)
294
295 return [email_task]
296
297
298BAD_WRAP_RE = re.compile('=\r\n')
299BAD_EQ_RE = re.compile('=3D')
300
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200301
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200302class BouncedEmail(object):
Copybara854996b2021-09-07 19:36:02 +0000303 """Handler to notice when email to given user is bouncing."""
304
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200305 # For docs on AppEngine's bounce email see:
306 # https://cloud.google.com/appengine/docs/standard/python3/reference
307 # /services/bundled/google/appengine/api/mail/BounceNotification
Copybara854996b2021-09-07 19:36:02 +0000308
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200309 def __init__(self, services=None):
310 self.services = services or flask.current_app.config['services']
311
312 def postBouncedEmail(self):
Copybara854996b2021-09-07 19:36:02 +0000313 try:
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200314 # Context: https://crbug.com/monorail/11083
315 bounce_message = BounceNotification(flask.request.form)
316 self.receive(bounce_message)
Copybara854996b2021-09-07 19:36:02 +0000317 except AttributeError:
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200318 # Context: https://crbug.com/monorail/2105
319 raw_message = flask.request.form.get('raw-message')
Copybara854996b2021-09-07 19:36:02 +0000320 logging.info('raw_message %r', raw_message)
321 raw_message = BAD_WRAP_RE.sub('', raw_message)
322 raw_message = BAD_EQ_RE.sub('=', raw_message)
323 logging.info('fixed raw_message %r', raw_message)
324 mime_message = email.message_from_string(raw_message)
325 logging.info('get_payload gives %r', mime_message.get_payload())
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200326 new_form_dict = flask.request.form.copy()
327 new_form_dict['raw-message'] = mime_message
328 # Retry with mime_message
329 bounce_message = BounceNotification(new_form_dict)
330 self.receive(bounce_message)
331 return ''
Copybara854996b2021-09-07 19:36:02 +0000332
333
334 def receive(self, bounce_message):
335 email_addr = bounce_message.original.get('to')
336 logging.info('Bounce was sent to: %r', email_addr)
337
338 # TODO(crbug.com/monorail/8727): The problem is likely no longer happening.
339 # but we are adding permanent logging so we don't have to keep adding
340 # expriring logpoints.
341 if '@intel' in email_addr: # both intel.com and intel-partner.
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200342 logging.info('bounce notification: %r', bounce_message.notification)
343 logging.info('bounce message original: %r', bounce_message.original)
344 # The original message's headers are the closest we get to the
345 # servers involved in the failed communication.
346 original_message = bounce_message.original_raw_message.original
347 if original_message is not None:
348 logging.info(
349 'bounce message original headers: %r', original_message.items())
Copybara854996b2021-09-07 19:36:02 +0000350
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200351 services = self.services
Copybara854996b2021-09-07 19:36:02 +0000352 cnxn = sql.MonorailConnection()
353
354 try:
355 user_id = services.user.LookupUserID(cnxn, email_addr)
356 user = services.user.GetUser(cnxn, user_id)
357 user.email_bounce_timestamp = int(time.time())
358 services.user.UpdateUser(cnxn, user_id, user)
359 except exceptions.NoSuchUserException:
360 logging.info('User %r not found, ignoring', email_addr)
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200361 logging.info('Received bounce post ... [%s]', flask.request)
Copybara854996b2021-09-07 19:36:02 +0000362 logging.info('Bounce original: %s', bounce_message.original)
363 logging.info('Bounce notification: %s', bounce_message.notification)