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