def message_route(self, message, message_dict, model=None, thread_id=None, custom_values=None): """ Override to udpate mass mailing statistics based on bounce emails """ bounce_alias = self.env['ir.config_parameter'].get_param( "mail.bounce.alias") email_to = decode_message_header(message, 'To') email_to_localpart = (tools.email_split(email_to) or [''])[0].split('@', 1)[0].lower() if bounce_alias and bounce_alias in email_to_localpart: bounce_re = re.compile( "%s\+(\d+)-?([\w.]+)?-?(\d+)?" % re.escape(bounce_alias), re.UNICODE) bounce_match = bounce_re.search(email_to) if bounce_match: bounced_mail_id = bounce_match.group(1) self.env['mail.mail.statistics'].set_bounced( mail_mail_ids=[bounced_mail_id]) return super(MailThread, self).message_route(message, message_dict, model, thread_id, custom_values)
def message_route_process(self, message, message_dict, routes): """ Override to update the parent mail statistics. The parent is found by using the References header of the incoming message and looking for matching message_id in mail.mail.statistics. """ if routes: references = tools.decode_message_header(message, 'References') in_reply_to = tools.decode_message_header(message, 'In-Reply-To').strip() thread_references = references or in_reply_to # even if 'reply_to' in ref (cfr mail/mail_thread) that indicates a new thread redirection # (aka bypass alias configuration in gateway) consider it as a reply for statistics purpose references_msg_id_list = tools.mail_header_msgid_re.findall(thread_references) if references_msg_id_list: self.env['mail.mail.statistics'].set_opened(mail_message_ids=references_msg_id_list) self.env['mail.mail.statistics'].set_replied(mail_message_ids=references_msg_id_list) return super(MailThread, self).message_route_process(message, message_dict, routes)
def message_route_verify(self, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, allow_private=False, drop_alias=False): res = super(MailThread, self).message_route_verify( message, message_dict, route, update_author=update_author, assert_model=assert_model, create_fallback=create_fallback, allow_private=allow_private, drop_alias=drop_alias) if res: alias = route[4] email_from = decode_message_header(message, 'From') message_id = message.get('Message-Id') # Alias: check alias_contact settings for employees if alias and alias.alias_contact == 'employees': email_address = email_split(email_from)[0] employee = self.env['hr.employee'].search([('work_email', 'ilike', email_address)], limit=1) if not employee: employee = self.env['hr.employee'].search([('user_id.email', 'ilike', email_address)], limit=1) if not employee: mail_template = self.env.ref('hr.mail_template_data_unknown_employee_email_address') self._routing_warn(_('alias %s does not accept unknown employees') % alias.alias_name, _('skipping'), message_id, route, False) self._routing_create_bounce_email(email_from, mail_template.body_html, message) return False return res
def message_route_process(self, message, message_dict, routes): rcpt_tos = ','.join([ tools.decode_message_header(message, 'Delivered-To'), tools.decode_message_header(message, 'To'), tools.decode_message_header(message, 'Cc'), tools.decode_message_header(message, 'Resent-To'), tools.decode_message_header(message, 'Resent-Cc') ]) rcpt_tos_websiteparts = [ e.split('@')[1].lower() for e in tools.email_split(rcpt_tos) ] website = self.env['website'].sudo().search([('domain', 'in', rcpt_tos_websiteparts)]) if website: self = self.with_context(website_id=website[0].id) return super(MailThread, self).message_route_process(message, message_dict, routes)
def notify_bounce_partners(self, message, fetchmail, message_dict): message_id = message.get('Message-Id') email_from = tools.decode_message_header(message, 'From') email_from_localpart = ((tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower()) # same criteria used by odoo # see addons/mail/models/mail_thread.py if (message.get_content_type() == 'multipart/report' or email_from_localpart == 'mailer-daemon'): references = tools.decode_message_header(message, 'References') in_reply_to = tools.decode_message_header(message, 'In-Reply-To').strip() thread_references = references or in_reply_to msg_references = [ ref for ref in tools.mail_header_msgid_re.findall( thread_references) if 'reply_to' not in ref ] MailMessage = self.env['mail.message'] mail_messages = MailMessage.sudo().search( [('message_id', 'in', msg_references)], limit=1) recipients = mail_messages.mapped('author_id') recipients |= fetchmail.bounce_notify_partner_ids if not recipients: _logger.info( 'Not notifying bounce email from %s with Message-Id %s: ' 'no recipients found', email_from, message_id) return _logger.info('Notifying bounce email from %s with Message-Id %s', email_from, message_id) email = self.env['mail.mail'].create({ 'body_html': (u"%s<br/><br/><br/>%s<br/><br/>%s" % (ustr(message_dict['body']), _("Raw message:"), ustr(message.__str__()).replace("\n", "<br/>"))), 'subject': message_dict['subject'], 'recipient_ids': [(6, 0, [p.id for p in recipients])] }) email.send()
def message_route_process(self, message, message_dict, routes): rcpt_tos = ",".join([ tools.decode_message_header(message, "Delivered-To"), tools.decode_message_header(message, "To"), tools.decode_message_header(message, "Cc"), tools.decode_message_header(message, "Resent-To"), tools.decode_message_header(message, "Resent-Cc"), ]) rcpt_tos_websiteparts = [ e.split("@")[1].lower() for e in tools.email_split(rcpt_tos) ] website = (self.env["website"].sudo().search([ ("domain", "in", rcpt_tos_websiteparts) ])) if website: self = self.with_context(website_id=website[0].id) return super(MailThread, self).message_route_process(message, message_dict, routes)
def _alias_get_error_message(self, message, message_dict, alias): if alias.alias_contact == 'employees': email_from = tools.decode_message_header(message, 'From') email_address = tools.email_split(email_from)[0] employee = self.env['hr.employee'].search([('work_email', 'ilike', email_address)], limit=1) if not employee: employee = self.env['hr.employee'].search([('user_id.email', 'ilike', email_address)], limit=1) if not employee: return _('restricted to employees') return False return super(BaseModel, self)._alias_get_error_message(message, message_dict, alias)
def _alias_check_contact_on_record(self, record, message, message_dict, alias): if alias.alias_contact == 'employees': email_from = tools.decode_message_header(message, 'From') email_address = tools.email_split(email_from)[0] employee = self.env['hr.employee'].search([('work_email', 'ilike', email_address)], limit=1) if not employee: employee = self.env['hr.employee'].search([('user_id.email', 'ilike', email_address)], limit=1) if not employee: return _('restricted to employees') return True return super(AliasMixin, self)._alias_check_contact_on_record(record, message, message_dict, alias)
def message_route( self, message, message_dict, model=None, thread_id=None, custom_values=None, ): if not isinstance(message, Message): raise TypeError( "message must be an email.message.Message at this point") email_from = decode_message_header(message, "From") # Memberspace workflow if "<" in email_from: email_from = email_from.split("<")[1][:-1] user_send_email = self.env["res.partner"].search( [("email", "=", email_from)], limit=1) error_memberspace_aliases = self.env["memberspace.alias"] pass_memberspace_aliases = self.env["memberspace.alias"] # Check header with key = To alias_error, alias_pass = self.verify_memberspace_alias( message, "To", user_send_email, email_from) pass_memberspace_aliases |= alias_pass error_memberspace_aliases |= alias_error # Check header with key = Delivered-To alias_error, alias_pass = self.verify_memberspace_alias( message, "Delivered-To", user_send_email, email_from) pass_memberspace_aliases |= alias_pass error_memberspace_aliases |= alias_error # Check header with key = Cc alias_error, alias_pass = self.verify_memberspace_alias( message, "Cc", user_send_email, email_from) pass_memberspace_aliases |= alias_pass error_memberspace_aliases |= alias_error # Check header with key = Resent-To alias_error, alias_pass = self.verify_memberspace_alias( message, "Resent-To", user_send_email, email_from) pass_memberspace_aliases |= alias_pass error_memberspace_aliases |= alias_error # Check header with key = Resent-Cc alias_error, alias_pass = self.verify_memberspace_alias( message, "Resent-Cc", user_send_email, email_from) pass_memberspace_aliases |= alias_pass error_memberspace_aliases |= alias_error if not pass_memberspace_aliases: return [] return super(MailThread, self).message_route( message, message_dict, model=model, thread_id=thread_id, custom_values=custom_values, )
def verify_memberspace_alias(self, message, header, user_sent, email_from): MemberSpaceAlias = self.env["memberspace.alias"] Alias = self.env["mail.alias"] alias_error = MemberSpaceAlias alias_pass = MemberSpaceAlias mail_tmpl = self.env.ref( "coop_memberspace.email_inform_cannot_send_to_memberspace_alias") if header not in message: return alias_error, alias_pass rcpt_tos = ",".join([decode_message_header(message, header)]) local_parts = [ e.split("@")[0].lower() for e in tools.email_split(rcpt_tos) ] aliases = Alias.search([("alias_name", "in", local_parts)]) memberspace_aliases = MemberSpaceAlias.search([("alias_id", "in", aliases.ids)]) for memberspace_alias in memberspace_aliases: if not user_sent: alias_error |= memberspace_aliases break coordinators = memberspace_alias.shift_id.user_ids members = coordinators |\ memberspace_alias.shift_id.registration_ids.filtered( lambda r: r.is_current_participant ).mapped("partner_id") if (memberspace_alias.type == "team" and user_sent not in coordinators): alias_error |= memberspace_alias if user_sent in members and mail_tmpl: email_add = (memberspace_alias.alias_name + "@" + memberspace_alias.alias_domain) template_values = { "email_to": email_from, "email_from": self.env.user.company_id.email, "email_cc": False, "lang": user_sent.lang, "auto_delete": True, "partner_to": False, } mail_tmpl.write(template_values) mail_tmpl.with_context(email_add=email_add).send_mail( self.env.user.id, force_send=True) continue elif (memberspace_alias.type == "coordinator" and user_sent not in members): alias_error |= memberspace_alias continue alias_pass |= memberspace_alias if alias_error: new_header = ",".join( [ma.alias_name + "@" + ma.alias_domain for ma in alias_pass]) message.replace_header(header, new_header) return alias_error, alias_pass
def message_route(self, message, message_dict, model=None, thread_id=None, custom_values=None): """ Override to udpate mass mailing statistics based on bounce emails """ bounce_alias = self.env['ir.config_parameter'].sudo().get_param("mail.bounce.alias") email_to = decode_message_header(message, 'To') email_to_localpart = (tools.email_split(email_to) or [''])[0].split('@', 1)[0].lower() if bounce_alias and bounce_alias in email_to_localpart: bounce_re = re.compile("%s\+(\d+)-?([\w.]+)?-?(\d+)?" % re.escape(bounce_alias), re.UNICODE) bounce_match = bounce_re.search(email_to) if bounce_match: bounced_mail_id = bounce_match.group(1) self.env['mail.mail.statistics'].set_bounced(mail_mail_ids=[bounced_mail_id]) return super(MailThread, self).message_route(message, message_dict, model, thread_id, custom_values)
def _alias_check_contact(self, message, message_dict, alias): if alias.alias_contact == 'employees' and self.ids: email_from = tools.decode_message_header(message, 'From') email_address = tools.email_split(email_from)[0] employee = self.env['hr.employee'].search([('work_email', 'ilike', email_address)], limit=1) if not employee: employee = self.env['hr.employee'].search([('user_id.email', 'ilike', email_address)], limit=1) if not employee: return { 'error_message': 'restricted to employees', 'error_template': self.env.ref('hr.mail_template_data_unknown_employee_email_address').body_html, } return True return super(MailAlias, self)._alias_check_contact(message, message_dict, alias)
def message_route_verify(self, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, allow_private=False, drop_alias=False): res = super(MailThread, self).message_route_verify(message, message_dict, route, update_author=update_author, assert_model=assert_model, create_fallback=create_fallback, allow_private=allow_private, drop_alias=drop_alias) if res: alias = route[4] email_from = decode_message_header(message, 'From') message_id = message.get('Message-Id') # Alias: check alias_contact settings for employees if alias and alias.alias_contact == 'employees': email_address = email_split(email_from)[0] employee = self.env['hr.employee'].search( [('work_email', 'ilike', email_address)], limit=1) if not employee: employee = self.env['hr.employee'].search( [('user_id.email', 'ilike', email_address)], limit=1) if not employee: mail_template = self.env.ref( 'hr.mail_template_data_unknown_employee_email_address') self._routing_warn( _('alias %s does not accept unknown employees') % alias.alias_name, _('skipping'), message_id, route, False) self._routing_create_bounce_email(email_from, mail_template.body_html, message) return False return res
def _alias_check_contact(self, message, message_dict, alias): if alias.alias_contact == 'employees' and self.ids: email_from = tools.decode_message_header(message, 'From') email_address = email_split(email_from)[0] employee = self.env['hr.employee'].search( [('work_email', 'ilike', email_address)], limit=1) if not employee: employee = self.env['hr.employee'].search( [('user_id.email', 'ilike', email_address)], limit=1) if not employee: return { 'error_message': 'restricted to employees', 'error_template': self.env.ref( 'hr.mail_template_data_unknown_employee_email_address' ).body_html, } return True return super(MailAlias, self)._alias_check_contact(message, message_dict, alias)
def message_process(self, model, message, custom_values=None, save_original=False, strip_attachments=False, thread_id=None): if isinstance(message, xmlrpclib.Binary): message = str(message.data) if isinstance(message, unicode): message = message.encode('utf-8') msg_txt = email.message_from_string(message) email_to = decode_message_header(msg_txt, 'To') if '.amazonses.com' in msg_txt['Message-Id']: new_message_id = self.env['mail.mail'].browse( int(email_to.split('@')[0].split('+')[1].split('-') [0])).message_id msg_txt.replace_header('Message-Id', new_message_id) msg = self.message_parse(msg_txt, save_original=save_original) routes = self.message_route(msg_txt, msg, model, thread_id, custom_values) if 'bounce' in msg_txt['To']: message_id = self.env['mail.message'].search([ ('message_id', '=', msg_txt['Message-Id']) ]) if message_id: tracking_id = self.env['mail.tracking.email'].search([ ('mail_message_id', '=', message_id.id) ]) tracking_id.write({'state': 'bounced'}) tracking_id.event_create('hard_bounce', { 'bounce_type': 'hard_bounce', 'bounce_description': 'Bounced' }) thread_id = self.message_route_process(msg_txt, msg, routes) return thread_id
def message_route(self, message, message_dict, model=None, thread_id=None, custom_values=None): """ Override to udpate mass mailing statistics based on bounce emails """ bounce_alias = self.env['ir.config_parameter'].sudo().get_param( "mail.bounce.alias") alias_domain = self.env['ir.config_parameter'].sudo().get_param( "mail.catchall.domain") # activate strict alias domain check for stable, will be falsy by default to be backward compatible alias_domain_check = self.env['ir.config_parameter'].sudo().get_param( "mail.catchall.domain.strict") email_to = decode_message_header(message, 'To') email_to_localparts = [ e.split('@', 1)[0].lower() for e in (tools.email_split(email_to) or ['']) if not alias_domain_check or ( not alias_domain or e.endswith('@%s' % alias_domain)) ] if bounce_alias and any( email.startswith(bounce_alias) for email in email_to_localparts): bounce_re = re.compile( "%s\+(\d+)-?([\w.]+)?-?(\d+)?" % re.escape(bounce_alias), re.UNICODE) bounce_match = bounce_re.search(email_to) if bounce_match: bounced_mail_id = bounce_match.group(1) self.env['mail.mail.statistics'].set_bounced( mail_mail_ids=[bounced_mail_id]) return super(MailThread, self).message_route(message, message_dict, model, thread_id, custom_values)
def fetch_mail(self): for server in self: count, failed = 0, 0 pop_server = None if server.type == 'pop': _logger.info('Server tpye is POP') try: while True: pop_server = server.connect() (num_messages, total_size) = pop_server.stat() pop_server.list() _logger.info('Server tpye is POP inside while') _logger.info('total_size = %d', total_size) _logger.info('num_messages = %d', num_messages) for num in range( 1, min(MAX_POP_MESSAGES, num_messages) + 1): _logger.info( 'Server tpye is POP inside while INSIDE FOR') (header, messages, octets) = pop_server.retr(num) message = (b'\n').join(messages) res_id = None response = { 'errorCode': 100, 'message': 'File Uploaded Successfully' } try: if isinstance(message, xmlrpclib.Binary): message = bytes(message.data) if isinstance(message, pycompat.text_type): message = message.encode('utf-8') extract = getattr(email, 'message_from_bytes', email.message_from_string) message = extract(message) if not isinstance(message, Message): message = pycompat.to_native(message) message = email.message_from_string( message) email_to = tools.decode_message_header( message, 'To') match = re.search(r'[\w\.-]+@[\w\.-]+', email_to) email_to = str(match.group(0)) _logger.info('Email to %r', email_to) # if email_to == INCOMING_EMAIL_ID: _Attachment = namedtuple( 'Attachment', ('fname', 'content', 'info')) attachments = [] body = u'' email_from = tools.decode_message_header( message, 'From') _logger.info('Email from %r', email_from) match = re.search(r'[\w\.-]+@[\w\.-]+', email_from) email_from = str(match.group(0)) subject = tools.decode_message_header( message, 'Subject') tmpl_type = None if 'Inventory' in subject: tmpl_type = "Inventory" elif 'Requirement' in subject: tmpl_type = "Requirement" if message.get_content_maintype() != 'text': alternative = False for part in message.walk(): if part.get_content_type( ) == 'multipart/alternative': alternative = True if part.get_content_maintype( ) == 'multipart': continue # skip container filename = part.get_param( 'filename', None, 'content-disposition') if not filename: filename = part.get_param( 'name', None) if filename: if isinstance(filename, tuple): filename = email.utils.collapse_rfc2231_value( filename).strip() else: filename = tools.decode_smtp_header( filename) encoding = part.get_content_charset() if filename and part.get('content-id'): inner_cid = part.get( 'content-id').strip('><') attachments.append( _Attachment( filename, part.get_payload( decode=True), {'cid': inner_cid})) continue if filename or part.get( 'content-disposition', '' ).strip().startswith('attachment'): attachments.append( _Attachment( filename or 'attachment', part.get_payload( decode=True), {})) continue if part.get_content_type( ) == 'text/plain' and (not alternative or not body): body = tools.append_content_to_html( body, tools.ustr(part.get_payload( decode=True), encoding, errors='replace'), preserve=True) elif part.get_content_type( ) == 'text/html': body = tools.ustr( part.get_payload(decode=True), encoding, errors='replace') else: attachments.append( _Attachment( filename or 'attachment', part.get_payload( decode=True), {})) if len(attachments) > 0: encoding = message.get_content_charset( ) plain_text = html2text.HTML2Text() message_payload = plain_text.handle( tools.ustr(body, encoding, errors='replace')) if '- Forwarded message -' in message_payload: messages = message_payload.split( '- Forwarded message -') _logger.info( 'Forwarded message payload: %r', messages) total_parts = len(messages) originator_part = messages[ total_parts - 1] _logger.info( 'originator_part: %r', originator_part) match = re.search( r'[\w\.-]+@[\w\.-]+', originator_part) _logger.info('match: %r', match) if match: email_from_domain = re.search( "@[\w.]+", email_from).group(0) _logger.info( 'email_from_domain: %r', email_from_domain) email_to_domain = re.search( "@[\w.]+", email_to).group(0) _logger.info( 'email_to_domain: %r', email_to_domain) if email_to_domain != email_from_domain: email_from = None else: email_from = str( match.group(0)) _logger.info( 'email_to_domain email_from: %r', email_from) #_logger.info('message payload: %r %r', message_payload, email_from) if not email_from is None: users_model = self.env[ 'res.partner'].search([ ("email", "=", email_from) ]) if users_model: if len(users_model) == 1: user_attachment_dir = ATTACHMENT_DIR + str( datetime.now( ).strftime("%d%m%Y") ) + "/" + str( users_model.id) + "/" if not os.path.exists( os.path.dirname( user_attachment_dir )): try: os.makedirs( os.path. dirname( user_attachment_dir )) except OSError as exc: if exc.errno != errno.EEXIST: raise for attachment in attachments: filename = getattr( attachment, 'fname') if not filename is None: try: file_contents_bytes = getattr( attachment, 'content') file_path = user_attachment_dir + str( filename) file_ref = open( str(file_path ), "wb+") file_ref.write( file_contents_bytes ) file_ref.close( ) response = self.env[ 'sps.document.process'].process_document( users_model, file_path, tmpl_type, filename, 'Email' ) except Exception as e: _logger.info( str(e)) else: _logger.error( 'Presents Same Email Id for multiple users %r', email_from) response = dict( errorCode=101, message= 'Presents Same Email Id for multiple users : ' + str(email_from)) else: _logger.info( 'user not found for %r', email_from) response = dict( errorCode=102, message= 'User not found for : ' + str(email_from)) else: _logger.info( 'domain not matched for forwarded email' ) response = dict( errorCode=103, message= 'Domain not matched for forwarded email : ' + str(email_from)) else: _logger.info("No attachements found") response = dict( errorCode=104, message='No attachements found : ' + str(email_from)) else: _logger.info('Not a Multipart email') response = dict( errorCode=105, message='Not a Multipart email' + str(email_from)) pop_server.dele(num) if "errorCode" in response: self.send_mail( "Sending Email Response as " + str(response['message']) + " for user " + str(email_from)) except Exception: _logger.info( 'Failed to process mail from %s server %s.', server.type, server.name, exc_info=True) failed += 1 if res_id and server.action_id: server.action_id.with_context({ 'active_id': res_id, 'active_ids': [res_id], 'active_model': self.env.context.get( "thread_model", server.object_id.model) }).run() self.env.cr.commit() _logger.info('num_messages = %d', num_messages) if num_messages < MAX_POP_MESSAGES: break pop_server.quit() _logger.info( "Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", num_messages, server.type, server.name, (num_messages - failed), failed) except Exception: _logger.info( "General failure when trying to fetch mail from %s server %s.", server.type, server.name, exc_info=True) finally: _logger.info('Server tpye is POP inside finally') if pop_server: pop_server.quit() server.write({'date': fields.Datetime.now()}) return super(IncomingMailCronModel, self).fetch_mail()
def message_route(self, message, message_dict, model=None, thread_id=None, custom_values=None): if not isinstance(message, Message): raise TypeError( 'message must be an email.message.Message at this point') MailMessage = self.env['mail.message'] Alias, dest_aliases = self.env['mail.alias'], self.env['mail.alias'] bounce_alias = self.env['ir.config_parameter'].sudo().get_param( "mail.bounce.alias") fallback_model = model # get email.message.Message variables for future processing local_hostname = socket.gethostname() message_id = message.get('Message-Id') # compute references to find if message is a reply to an existing thread references = tools.decode_message_header(message, 'References') in_reply_to = tools.decode_message_header(message, 'In-Reply-To').strip() thread_references = references or in_reply_to reply_match, reply_model, reply_thread_id, reply_hostname, reply_private = tools.email_references( thread_references) # author and recipients email_from = tools.decode_message_header(message, 'From') email_from_localpart = (tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower() email_to = tools.decode_message_header(message, 'To') email_to_localpart = (tools.email_split(email_to) or [''])[0].split('@', 1)[0].lower() # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value. rcpt_tos = ','.join([ tools.decode_message_header(message, 'Delivered-To'), tools.decode_message_header(message, 'To'), tools.decode_message_header(message, 'Cc'), tools.decode_message_header(message, 'Resent-To'), tools.decode_message_header(message, 'Resent-Cc') ]) rcpt_tos_localparts = [ e.split('@')[0].lower() for e in tools.email_split(rcpt_tos) ] # 0. Verify whether this is a bounced email and use it to collect bounce data and update notifications for customers if bounce_alias and bounce_alias in email_to_localpart: # Bounce regex: typical form of bounce is bounce_alias+128-crm.lead-34@domain # group(1) = the mail ID; group(2) = the model (if any); group(3) = the record ID bounce_re = re.compile( "%s\+(\d+)-?([\w.]+)?-?(\d+)?" % re.escape(bounce_alias), re.UNICODE) bounce_match = bounce_re.search(email_to) if bounce_match: bounced_mail_id, bounced_model, bounced_thread_id = bounce_match.group( 1), bounce_match.group(2), bounce_match.group(3) email_part = next( (part for part in message.walk() if part.get_content_type() == 'message/rfc822'), None) dsn_part = next( (part for part in message.walk() if part.get_content_type() == 'message/delivery-status'), None) partners, partner_address = self.env['res.partner'], False if dsn_part and len(dsn_part.get_payload()) > 1: dsn = dsn_part.get_payload()[1] final_recipient_data = tools.decode_message_header( dsn, 'Final-Recipient') partner_address = final_recipient_data.split(';', 1)[1].strip() if partner_address: partners = partners.sudo().search([('email', 'like', partner_address)]) for partner in partners: partner.message_receive_bounce( partner_address, partner, mail_id=bounced_mail_id) mail_message = self.env['mail.message'] if email_part: email = email_part.get_payload()[0] bounced_message_id = tools.mail_header_msgid_re.findall( tools.decode_message_header(email, 'Message-Id')) mail_message = MailMessage.sudo().search([ ('message_id', 'in', bounced_message_id) ]) if partners and mail_message: notifications = self.env['mail.notification'].sudo( ).search([('mail_message_id', '=', mail_message.id), ('res_partner_id', 'in', partners.ids)]) notifications.write({'email_status': 'bounce'}) if bounced_model in self.env and hasattr( self.env[bounced_model], 'message_receive_bounce') and bounced_thread_id: self.env[bounced_model].browse( int(bounced_thread_id)).message_receive_bounce( partner_address, partners, mail_id=bounced_mail_id) _logger.info( 'Routing mail from %s to %s with Message-Id %s: bounced mail from mail %s, model: %s, thread_id: %s: dest %s (partner %s)', email_from, email_to, message_id, bounced_mail_id, bounced_model, bounced_thread_id, partner_address, partners) return [] # 0. First check if this is a bounce message or not. # See http://datatracker.ietf.org/doc/rfc3462/?include_text=1 # As all MTA does not respect this RFC (googlemail is one of them), # we also need to verify if the message come from "mailer-daemon" if message.get_content_type( ) == 'multipart/report' or email_from_localpart == 'mailer-daemon': _logger.info( 'Routing mail with Message-Id %s: not routing bounce email from %s to %s', message_id, email_from, email_to) try: self.env['mail.return_processing'].create({ 'body': message_dict.get('body'), 'subject': message._payload[1]._payload[0]._headers[7][1], 'email_from': message._payload[1]._payload[0]._headers[8][1], 'email_to': message._payload[1]._payload[0]._headers[10][1], 'message_id': message._payload[1]._payload[0]._headers[6][1], 'message_type': message_dict.get('message_type'), 'date_time': message_dict.get('date'), }) except Exception as e: x_list, x_dict = message._payload[1]._payload[ 0]._payload.split('\r\n'), {} for x in x_list[::-1]: value = x.split(': ') if len(value) == 2: if value[0] in ['From', 'Message-Id', 'Subject', 'To']: x_dict.update({value[0]: value[1]}) x_list.remove(x) self.env['mail.return_processing'].create({ 'body': message_dict.get('body'), 'subject': x_dict.get('Subject'), 'email_from': x_dict.get('From'), 'email_to': x_dict.get('To'), 'message_id': x_dict.get('Message-Id'), 'date_time': fields.datetime.now(), }) return [] # 1. Check if message is a reply on a thread msg_references = [ ref for ref in tools.mail_header_msgid_re.findall(thread_references) if 'reply_to' not in ref ] mail_messages = MailMessage.sudo().search( [('message_id', 'in', msg_references)], limit=1) is_a_reply = bool(mail_messages) # 1.1 Handle forward to an alias with a different model: do not consider it as a reply if reply_model and reply_thread_id: other_alias = Alias.search([ '&', ('alias_name', '!=', False), ('alias_name', '=', email_to_localpart) ]) if other_alias and other_alias.alias_model_id.model != reply_model: is_a_reply = False if is_a_reply: model, thread_id = mail_messages.model, mail_messages.res_id if not reply_private: # TDE note: not sure why private mode as no alias search, copying existing behavior dest_aliases = Alias.search( [('alias_name', 'in', rcpt_tos_localparts)], limit=1) route = self.message_route_verify( message, message_dict, (model, thread_id, custom_values, self._uid, dest_aliases), update_author=True, assert_model=reply_private, create_fallback=True, allow_private=reply_private, drop_alias=True) if route: _logger.info( 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s', email_from, email_to, message_id, model, thread_id, custom_values, self._uid) return [route] elif route is False: return [] # 2. Look for a matching mail.alias entry if rcpt_tos_localparts: # no route found for a matching reference (or reply), so parent is invalid message_dict.pop('parent_id', None) dest_aliases = Alias.search([('alias_name', 'in', rcpt_tos_localparts)]) if dest_aliases: routes = [] for alias in dest_aliases: user_id = alias.alias_user_id.id if not user_id: # TDE note: this could cause crashes, because no clue that the user # that send the email has the right to create or modify a new document # Fallback on user_id = uid # Note: recognized partners will be added as followers anyway # user_id = self._message_find_user_id(message) user_id = self._uid _logger.info('No matching user_id for the alias %s', alias.alias_name) route = (alias.alias_model_id.model, alias.alias_force_thread_id, safe_eval(alias.alias_defaults), user_id, alias) route = self.message_route_verify(message, message_dict, route, update_author=True, assert_model=True, create_fallback=True) if route: _logger.info( 'Routing mail from %s to %s with Message-Id %s: direct alias match: %r', email_from, email_to, message_id, route) routes.append(route) return routes # 5. Fallback to the provided parameters, if they work if fallback_model: # no route found for a matching reference (or reply), so parent is invalid message_dict.pop('parent_id', None) route = self.message_route_verify( message, message_dict, (fallback_model, thread_id, custom_values, self._uid, None), update_author=True, assert_model=True) if route: _logger.info( 'Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s', email_from, email_to, message_id, fallback_model, thread_id, custom_values, self._uid) return [route] # ValueError if no routes found and if no bounce occured raise ValueError( 'No possible route found for incoming message from %s to %s (Message-Id %s:). ' 'Create an appropriate mail.alias or force the destination model.' % (email_from, email_to, message_id))
def message_route(self, message, message_dict, model=None, thread_id=None, custom_values=None): """ Attempt to figure out the correct target model, thread_id, custom_values and user_id to use for an incoming message. Multiple values may be returned, if a message had multiple recipients matching existing mail.aliases, for example. The following heuristics are used, in this order: * if the message replies to an existing thread by having a Message-Id that matches an existing mail_message.message_id, we take the original message model/thread_id pair and ignore custom_value as no creation will take place * if the message replies to an existing thread by having In-Reply-To or References matching odoo model/thread_id Message-Id and if this thread has messages without message_id, take this model/thread_id pair and ignore custom_value as no creation will take place (6.1 compatibility) * look for a mail.alias entry matching the message recipients and use the corresponding model, thread_id, custom_values and user_id. This could lead to a thread update or creation depending on the alias * fallback on provided ``model``, ``thread_id`` and ``custom_values`` * raise an exception as no route has been found :param string message: an email.message instance :param dict message_dict: dictionary holding parsed message variables :param string model: the fallback model to use if the message does not match any of the currently configured mail aliases (may be None if a matching alias is supposed to be present) :type dict custom_values: optional dictionary of default field values to pass to ``message_new`` if a new record needs to be created. Ignored if the thread record already exists, and also if a matching mail.alias was found (aliases define their own defaults) :param int thread_id: optional ID of the record/thread from ``model`` to which this mail should be attached. Only used if the message does not reply to an existing thread and does not match any mail alias. :return: list of routes [(model, thread_id, custom_values, user_id, alias)] :raises: ValueError, TypeError """ if not isinstance(message, Message): raise TypeError( 'message must be an email.message.Message at this point') MailMessage = self.env['mail.message'] Alias, dest_aliases = self.env['mail.alias'], self.env['mail.alias'] catchall_alias = self.env['ir.config_parameter'].sudo().get_param( "mail.catchall.alias") bounce_alias = self.env['ir.config_parameter'].sudo().get_param( "mail.bounce.alias") fallback_model = model # get email.message.Message variables for future processing local_hostname = socket.gethostname() message_id = message.get('Message-Id') message # compute references to find if message is a reply to an existing thread references = tools.decode_message_header(message, 'References') in_reply_to = tools.decode_message_header(message, 'In-Reply-To').strip() thread_references = references or in_reply_to reply_match, reply_model, reply_thread_id, reply_hostname, reply_private = tools.email_references( thread_references) # author and recipients email_from = tools.decode_message_header(message, 'From') email_from_localpart = (tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower() email_to = tools.decode_message_header(message, 'To') email_to_localpart = (tools.email_split(email_to) or [''])[0].split('@', 1)[0].lower() # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value. rcpt_tos = ','.join([ tools.decode_message_header(message, 'Delivered-To'), tools.decode_message_header(message, 'To'), tools.decode_message_header(message, 'Cc'), tools.decode_message_header(message, 'Resent-To'), tools.decode_message_header(message, 'Resent-Cc') ]) rcpt_tos_localparts = [ e.split('@')[0].lower() for e in tools.email_split(rcpt_tos) ] # 0. Verify whether this is a bounced email and use it to collect bounce data and update notifications for customers if bounce_alias and bounce_alias in email_to_localpart: # Bounce regex: typical form of bounce is bounce_alias+128-crm.lead-34@domain # group(1) = the mail ID; group(2) = the model (if any); group(3) = the record ID bounce_re = re.compile( "%s\+(\d+)-?([\w.]+)?-?(\d+)?" % re.escape(bounce_alias), re.UNICODE) bounce_match = bounce_re.search(email_to) if bounce_match: bounced_mail_id, bounced_model, bounced_thread_id = bounce_match.group( 1), bounce_match.group(2), bounce_match.group(3) email_part = next( (part for part in message.walk() if part.get_content_type() == 'message/rfc822'), None) dsn_part = next( (part for part in message.walk() if part.get_content_type() == 'message/delivery-status'), None) partners, partner_address = self.env['res.partner'], False if dsn_part and len(dsn_part.get_payload()) > 1: dsn = dsn_part.get_payload()[1] final_recipient_data = tools.decode_message_header( dsn, 'Final-Recipient') partner_address = final_recipient_data.split(';', 1)[1].strip() if partner_address: partners = partners.sudo().search([('email', '=', partner_address)]) for partner in partners: partner.message_receive_bounce( partner_address, partner, mail_id=bounced_mail_id) mail_message = self.env['mail.message'] if email_part: email = email_part.get_payload()[0] bounced_message_id = tools.mail_header_msgid_re.findall( tools.decode_message_header(email, 'Message-Id')) mail_message = MailMessage.sudo().search([ ('message_id', 'in', bounced_message_id) ]) if partners and mail_message: notifications = self.env['mail.notification'].sudo( ).search([('mail_message_id', '=', mail_message.id), ('res_partner_id', 'in', partners.ids)]) notifications.write({'email_status': 'bounce'}) if bounced_model in self.env and hasattr( self.env[bounced_model], 'message_receive_bounce') and bounced_thread_id: self.env[bounced_model].browse( int(bounced_thread_id)).message_receive_bounce( partner_address, partners, mail_id=bounced_mail_id) _logger.info( 'Routing mail from %s to %s with Message-Id %s: bounced mail from mail %s, model: %s, thread_id: %s: dest %s (partner %s)', email_from, email_to, message_id, bounced_mail_id, bounced_model, bounced_thread_id, partner_address, partners) return [] # 0. First check if this is a bounce message or not. # See http://datatracker.ietf.org/doc/rfc3462/?include_text=1 # As all MTA does not respect this RFC (googlemail is one of them), # we also need to verify if the message come from "mailer-daemon" if message.get_content_type( ) == 'multipart/report' or email_from_localpart == 'mailer-daemon': _logger.info( 'Routing mail with Message-Id %s: not routing bounce email from %s to %s', message_id, email_from, email_to) return [] # 1. Check if message is a reply on a thread msg_references = [ ref for ref in tools.mail_header_msgid_re.findall(thread_references) if 'reply_to' not in ref ] mail_messages = MailMessage.sudo().search( [('message_id', 'in', msg_references)], limit=1) is_a_reply = bool(mail_messages) # 1.1 Handle forward to an alias with a different model: do not consider it as a reply if not reply_model or not reply_thread_id: other_alias = Alias.search([ '&', ('alias_name', '!=', False), ('alias_name', '=', email_to_localpart) ]) if other_alias and other_alias.alias_model_id.model != reply_model: is_a_reply = False if is_a_reply: model, thread_id = mail_messages.model, mail_messages.res_id if not reply_private: # TDE note: not sure why private mode as no alias search, copying existing behavior dest_aliases = Alias.search( [('alias_name', 'in', rcpt_tos_localparts)], limit=1) route = self.message_route_verify( message, message_dict, (model, thread_id, custom_values, self._uid, dest_aliases), update_author=True, assert_model=reply_private, create_fallback=True, allow_private=reply_private, drop_alias=True) if route: _logger.info( 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s', email_from, email_to, message_id, model, thread_id, custom_values, self._uid) return [route] elif route is False: return [] # 2. Look for a matching mail.alias entry if rcpt_tos_localparts: # no route found for a matching reference (or reply), so parent is invalid message_dict.pop('parent_id', None) # check it does not directly contact catchall if catchall_alias and catchall_alias in email_to_localpart: _logger.info( 'Routing mail from %s to %s with Message-Id %s: direct write to catchall, bounce', email_from, email_to, message_id) body = self.env.ref('mail.mail_bounce_catchall').render( { 'message': message, }, engine='ir.qweb') self._routing_create_bounce_email( email_from, body, message, reply_to=self.env.user.company_id.email) return [] dest_aliases = Alias.search([('alias_name', 'in', rcpt_tos_localparts)]) if dest_aliases: routes = [] for alias in dest_aliases: user_id = alias.alias_user_id.id if not user_id: # TDE note: this could cause crashes, because no clue that the user # that send the email has the right to create or modify a new document # Fallback on user_id = uid # Note: recognized partners will be added as followers anyway # user_id = self._message_find_user_id(message) user_id = self._uid _logger.info('No matching user_id for the alias %s', alias.alias_name) route = (alias.alias_model_id.model, alias.alias_force_thread_id, safe_eval(alias.alias_defaults), user_id, alias) route = self.message_route_verify(message, message_dict, route, update_author=True, assert_model=True, create_fallback=True) if route: _logger.info( 'Routing mail from %s to %s with Message-Id %s: direct alias match: %r', email_from, email_to, message_id, route) routes.append(route) return routes # 5. Fallback to the provided parameters, if they work if fallback_model: # no route found for a matching reference (or reply), so parent is invalid message_dict.pop('parent_id', None) route = self.message_route_verify( message, message_dict, (fallback_model, thread_id, custom_values, self._uid, None), update_author=True, assert_model=True) if route: _logger.info( 'Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s', email_from, email_to, message_id, fallback_model, thread_id, custom_values, self._uid) return [route] # ValueError if no routes found and if no bounce occured raise ValueError( 'No possible route found for incoming message from %s to %s (Message-Id %s:). ' 'Create an appropriate mail.alias or force the destination model.' % (email_from, email_to, message_id))
def test_02_message_route_verify(self): msg = email.message_from_string(MAIL_MESSAGE) route = ('res.users', 1, None, self.env.uid, '') self.env['mail.thread'].message_route_verify(msg, msg, route) email_from = decode_message_header(msg, 'From') self.assertEqual(email_from, '*****@*****.**')
def message_parse(self, message, save_original=False): email_to = tools.decode_message_header(message, "To") email_to_localpart = (tools.email_split(email_to) or [""])[0].split("@", 1)[0] config_params = self.env["ir.config_parameter"].sudo() # Check if the To part contains the prefix and a base32/64 encoded string # Remove the "24," part when migrating to Odoo 14. prefix_in_to = email_to_localpart and re.search( r".*" + MESSAGE_PREFIX + "(?P<odoo_id>.{24,32}$)", email_to_localpart) prioritize_replyto_over_headers = config_params.get_param( "email_headers.prioritize_replyto_over_headers", "True") prioritize_replyto_over_headers = ( True if prioritize_replyto_over_headers != "False" else False) # If the msg prefix part is found in the To part, find the parent # message and inject the Message-Id to the In-Reply-To part and # remove References because it by default takes priority over # In-Reply-To. We want the unique Reply-To address have the priority. if prefix_in_to and prioritize_replyto_over_headers: message_id_encrypted = prefix_in_to.group("odoo_id") try: message_id = decode_msg_id(message_id_encrypted, self.env) parent_id = self.env["mail.message"].browse(message_id) if parent_id: # See unit test test_reply_to_method_msg_id_priority del message["References"] del message["In-Reply-To"] message["In-Reply-To"] = parent_id.message_id else: _logger.warning( "Received an invalid mail.message database id in incoming " "email sent to {}. The email type (comment, note) might " "be wrong.".format(email_to)) except UnicodeDecodeError: _logger.warning( "Unique Reply-To address of an incoming email couldn't be " "decrypted. Falling back to default Odoo behavior.") res = super(MailThread, self).message_parse(message, save_original) strip_message_id = config_params.get_param( "email_headers.strip_mail_message_ids", "True") strip_message_id = True if strip_message_id != "False" else False if not strip_message_id == "True": return res # When Odoo compares message_id to the one stored in the database when determining # whether or not the incoming message is a reply to another one, the message_id search # parameter is stripped before the search. But Odoo does not do anything of the sort when # a message is created, meaning if some email software (for example Outlook, # for no particular reason) includes anything strippable at the start of the Message-Id, # any replies to that message in the future will not find their way correctly, as the # search yields nothing. # # Example of what happened before. The first one is the original Message-Id, and thus also # the ID that gets stored on the mail.message as the `message_id` # '\r\n <*****@*****.**>' # But when trying to find this message, Odoo takes the above message_id and strips it, # which results in: # '<*****@*****.**>' # And then the search is done for an exact match, which will fail. # # Odoo doesn't, so we must strip the message_ids before they are stored in the database mail_message_id = res.get("message_id", "") if mail_message_id: mail_message_id = mail_message_id.strip() res["message_id"] = mail_message_id return res
def message_parse(self, message, save_original=False): """ 解析表示RFC-2822电子邮件的 email.message.message,并返回包含消息详细信息的通用dict。 :param message: email to parse :type message: email.message.Message :param bool save_original: whether the returned dict should include an ``original`` attachment containing the source of the message :rtype: dict :return: A dict with the following structure, where each field may not be present if missing in original message:: { 'message_id': msg_id, 'subject': subject, 'email_from': from, 'to': to + delivered-to, 'cc': cc, 'recipients': delivered-to + to + cc + resent-to + resent-cc, 'partner_ids': partners found based on recipients emails, 'body': unified_body, 'references': references, 'in_reply_to': in-reply-to, 'parent_id': parent mail.message based on in_reply_to or references, 'is_internal': answer to an internal message (note), 'date': date, 'attachments': [('file1', 'bytes'), ('file2', 'bytes')} """ if not isinstance(message, EmailMessage): raise ValueError( _("Message should be a valid EmailMessage instance")) msg_dict = {"message_type": "email"} message_id = message.get("Message-Id") if not message_id: # 非常不寻常的情况,就是我们在这里应该容错 message_id = "<%s@localhost>" % time.time() _logger.debug( "Parsing Message without message-id, generating a random one: %s", message_id, ) msg_dict["message_id"] = message_id.strip() if message.get("Subject"): msg_dict["subject"] = tools.decode_message_header( message, "Subject") email_from = tools.decode_message_header(message, "From") email_cc = tools.decode_message_header(message, "cc") email_from_list = tools.email_split_and_format(email_from) email_cc_list = tools.email_split_and_format(email_cc) msg_dict["email_from"] = email_from_list[ 0] if email_from_list else email_from msg_dict["from"] = msg_dict[ "email_from"] # compatibility for message_new msg_dict["cc"] = ",".join(email_cc_list) if email_cc_list else email_cc # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value. msg_dict["recipients"] = ",".join( set(formatted_email for address in [ tools.decode_message_header(message, "Delivered-To"), tools.decode_message_header(message, "To"), tools.decode_message_header(message, "Cc"), tools.decode_message_header(message, "Resent-To"), tools.decode_message_header(message, "Resent-Cc"), ] if address for formatted_email in tools.email_split_and_format(address))) msg_dict["to"] = ",".join( set(formatted_email for address in [ tools.decode_message_header(message, "Delivered-To"), tools.decode_message_header(message, "To"), ] if address for formatted_email in tools.email_split_and_format(address))) partner_ids = [ x.id for x in self._mail_find_partner_from_emails( tools.email_split(msg_dict["recipients"]), records=self) if x ] msg_dict["partner_ids"] = partner_ids # compute references to find if email_message is a reply to an existing thread msg_dict["references"] = tools.decode_message_header( message, "References") msg_dict["in_reply_to"] = tools.decode_message_header( message, "In-Reply-To").strip() if message.get("Date"): try: date_hdr = tools.decode_message_header(message, "Date") parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True) if parsed_date.utcoffset() is None: # naive datetime, so we arbitrarily decide to make it # UTC, there's no better choice. Should not happen, # as RFC2822 requires timezone offset in Date headers. stored_date = parsed_date.replace(tzinfo=pytz.utc) else: stored_date = parsed_date.astimezone(tz=pytz.utc) except Exception: _logger.info( "Failed to parse Date header %r in incoming mail " "with message-id %r, assuming current date/time.", message.get("Date"), message_id, ) stored_date = datetime.datetime.now() msg_dict["date"] = stored_date.strftime( tools.DEFAULT_SERVER_DATETIME_FORMAT) parent_ids = False if msg_dict["in_reply_to"]: parent_ids = self.env["mail.message"].search( [("message_id", "=", msg_dict["in_reply_to"])], limit=1) if msg_dict["references"] and not parent_ids: references_msg_id_list = tools.mail_header_msgid_re.findall( msg_dict["references"]) parent_ids = self.env["mail.message"].search( [("message_id", "in", [x.strip() for x in references_msg_id_list])], limit=1, ) if parent_ids: msg_dict["parent_id"] = parent_ids.id msg_dict["is_internal"] = (parent_ids.subtype_id and parent_ids.subtype_id.internal or False) msg_dict.update( self._message_parse_extract_payload(message, save_original=save_original)) msg_dict.update(self._message_parse_extract_bounce(message, msg_dict)) return msg_dict
def message_route(self, message, message_dict, model=None, thread_id=None, custom_values=None): # NOTE! If you're going to backport this module to Odoo 11 or Odoo 10, # you will have to create the mail_bounce_catchall email template # because it was introduced only in Odoo 12. if not isinstance(message, Message): raise TypeError("message must be an " "email.message.Message at this point") try: route = super(MailThread, self).message_route(message, message_dict, model, thread_id, custom_values) except ValueError: # If the headers that connect the incoming message to a thread in # Odoo have disappeared at some point and the message was sent to # the catchall address (with a sub-addressing suffix), we will # skip the default catchall check and perform it here for # mail.catchall.alias.custom. We do this because the alias check # if done AFTER the catchall check by default and it may cause # Odoo to send a bounce message to the sender who sent the email to # the correct thread-specific address. catchall_alias = (self.env["ir.config_parameter"].sudo().get_param( "mail.catchall.alias.custom")) email_to = tools.decode_message_header(message, "To") email_to_localpart = ((tools.email_split(email_to) or [""])[0].split("@", 1)[0].lower()) message_id = message.get("Message-Id") email_from = tools.decode_message_header(message, "From") # check it does not directly contact catchall if catchall_alias and catchall_alias in email_to_localpart: _logger.info( "Routing mail from %s to %s with Message-Id %s: " "direct write to catchall, bounce", email_from, email_to, message_id, ) body = self.env.ref("mail.mail_bounce_catchall").render( {"message": message}, engine="ir.qweb") self._routing_create_bounce_email( email_from, body, message, reply_to=self.env.user.company_id.email) return [] else: raise return route