# Copyright - 2013-2024 Therp BV . # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import email import email.policy import logging from xmlrpc import client as xmlrpclib from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError from .. import match_algorithm _logger = logging.getLogger(__name__) class FetchmailServerFolder(models.Model): """Define folders (IMAP mailboxes) from which to fetch mail.""" _name = "fetchmail.server.folder" _description = __doc__ _rec_name = "path" _order = "sequence" server_id = fields.Many2one("fetchmail.server") sequence = fields.Integer() state = fields.Selection( [("draft", "Not Confirmed"), ("done", "Confirmed")], string="Status", readonly=True, required=True, copy=False, default="draft", ) path = fields.Char( required=True, help="The path to your mail folder." " Typically would be something like 'INBOX.myfolder'", ) archive_path = fields.Char( help="The path where successfully retrieved messages will be stored." ) model_id = fields.Many2one( comodel_name="ir.model", required=True, ondelete="cascade", help="The model to attach emails to", ) model_field = fields.Char( "Field (model)", help="The field in your model that contains the field to match against.\n" "Examples:\n" "'email' if your model is res.partner, or " "'partner_id.email' if you're matching sale orders", ) model_order = fields.Char( "Order (model)", help="Field(s) to order by, this mostly useful in conjunction " "with 'Use 1st match'", ) match_algorithm = fields.Selection( selection=[ ("odoo_standard", "Odoo standard"), ("email_domain", "Domain of email address"), ("email_exact", "Exact mailadress"), ], required=True, help="The algorithm used to determine which object an email matches.", ) mail_field = fields.Char( "Field (email)", help="The field in the email used for matching." " Typically this is 'to' or 'from'", ) delete_matching = fields.Boolean( "Delete matches", help="Delete matched emails from server" ) flag_nonmatching = fields.Boolean( default=True, help="Flag emails in the server that don't match any object in Odoo", ) match_first = fields.Boolean( "Use 1st match", help="If there are multiple matches, use the first one. If " "not checked, multiple matches count as no match at all", ) domain = fields.Char(help="Fill in a search filter to narrow down objects to match") msg_state = fields.Selection( selection=[("sent", "Sent"), ("received", "Received")], string="Message state", default="received", help="The state messages fetched from this folder should be assigned in Odoo", ) active = fields.Boolean(default=True) def button_confirm_folder(self): self.write({"state": "draft"}) for this in self: if not this.active: continue connection = this.server_id.connect() connection.select() if connection.select(this.path)[0] != "OK": raise ValidationError(_("Invalid folder %s!") % this.path) connection.close() this.write({"state": "done"}) def button_attach_mail_manually(self): self.ensure_one() return { "type": "ir.actions.act_window", "res_model": "fetchmail.attach.mail.manually", "target": "new", "context": dict(self.env.context, folder_id=self.id), "view_type": "form", "view_mode": "form", } def set_draft(self): self.write({"state": "draft"}) return True def fetch_mail(self): """Retrieve all mails for IMAP folders. We will use a separate connection for each folder. """ for this in self: if not this.active or this.state != "done": continue connection = None try: # New connection per folder connection = this.server_id.connect() this.check_imap_archive_folder(connection) this.retrieve_imap_folder(connection) connection.close() except Exception: _logger.error( ( "General failure when trying to connect to" " %(server_type)s server %(server)s." ), { "server_type": this.server_id.server_type, "server": this.server_id.name, }, exc_info=True, ) finally: if connection: connection.logout() def check_imap_archive_folder(self, connection): """If archive folder specified, check existance and create when needed.""" self.ensure_one() server = self.server_id if not self.archive_path: return if connection.select(self.archive_path)[0] != "OK": connection.create(self.archive_path) if connection.select(self.archive_path)[0] != "OK": raise UserError( _("Could not create archive folder %(folder)s on server %(server)s") % {"folder": self.archive_path, "server": server.name} ) def retrieve_imap_folder(self, connection): """Retrieve all mails for one IMAP folder.""" self.ensure_one() msgids = self.get_msgids(connection, "UNDELETED") for msgid in msgids[0].split(): # We will accept exceptions for single messages try: self.env.cr.execute("savepoint apply_matching") self.apply_matching(connection, msgid) self.env.cr.execute("release savepoint apply_matching") except Exception: self.env.cr.execute("rollback to savepoint apply_matching") _logger.exception( "Failed to fetch mail %(msgid)s from server %(server)s", {"msgid": msgid, "server": self.server_id.name}, ) def get_msgids(self, connection, criteria): """Return imap ids of messages to process""" self.ensure_one() server = self.server_id _logger.info( "start checking for emails in folder %(folder)s on server %(server)s", {"folder": self.path, "server": server.name}, ) if connection.select(self.path)[0] != "OK": raise UserError( _("Could not open folder %(folder)s on server %(server)s") % {"folder": self.path, "server": server.name} ) result, msgids = connection.search(None, criteria) if result != "OK": raise UserError( _("Could not search folder %(folder)s on server %(server)s") % {"folder": self.path, "server": server.name} ) _logger.info( "finished checking for emails in folder %(folder)s on server %(server)s", {"folder": self.path, "server": server.name}, ) return msgids def apply_matching(self, connection, msgid): """Return id of object matched (which will be the thread_id).""" self.ensure_one() thread_id = None thread_model = self.env["mail.thread"] message_org = self.fetch_msg(connection, msgid) if self.match_algorithm == "odoo_standard": thread_id = thread_model.message_process( self.model_id.model, message_org, save_original=self.server_id.original, strip_attachments=(not self.server_id.attach), ) else: message_dict = self._get_message_dict(message_org) if not self._check_message_already_present(message_dict): match = self._find_match(message_dict) if match: thread_id = match.id self.attach_mail(match, message_dict) matched = True if thread_id else False self.update_msg(connection, msgid, matched=matched) if self.archive_path: self._archive_msg(connection, msgid) return thread_id # Can be None if no match found. def fetch_msg(self, connection, msgid): """Select a single message from a folder.""" self.ensure_one() result, msgdata = connection.fetch(msgid, "(RFC822)") if result != "OK": raise UserError( _("Could not fetch %(msgid)s in folder %(folder)s on server %(server)s") % {"msgid": msgid, "folder": self.path, "server": self.server_id.name} ) message_org = msgdata[0][1] # rfc822 message source return message_org def update_msg(self, connection, msgid, matched=True, flagged=False): """Update msg in imap folder depending on match and settings.""" if matched: if self.delete_matching: connection.store(msgid, "+FLAGS", "\\DELETED") elif flagged and self.flag_nonmatching: connection.store(msgid, "-FLAGS", "\\FLAGGED") else: if self.flag_nonmatching: connection.store(msgid, "+FLAGS", "\\FLAGGED") def _archive_msg(self, connection, msgid): """Archive message. Folder should already have been created.""" self.ensure_one() connection.copy(msgid, self.archive_path) connection.store(msgid, "+FLAGS", "\\Deleted") connection.expunge() @api.model def _get_message_dict(self, message): """Get message_dict from original message. This uses some code copied from mail.thread.message_process, that unfortunately is not in a separate method. """ if isinstance(message, xmlrpclib.Binary): message = bytes(message.data) if isinstance(message, str): message = message.encode("utf-8") message = email.message_from_bytes(message, policy=email.policy.SMTP) thread_model = self.env["mail.thread"] message_dict = thread_model.message_parse( message, save_original=self.server_id.original ) return message_dict def _check_message_already_present(self, message_dict): """If message already handled, it should be ignored.""" message_id = message_dict["message_id"] if self.env["mail.message"].search([("message_id", "=", message_id)], limit=1): _logger.debug( "Message %(message_id)s already in database", {"message_id": message_id}, ) return True return False def _find_match(self, message_dict): """Try to find existing object to link mail to.""" self.ensure_one() matcher = self._get_algorithm() if not matcher: return None matches = matcher.search_matches(self, message_dict) if not matches: _logger.info( "No match found for message %(subject)s with msgid %(msgid)s", { "subject": message_dict.get("subject", "no subject"), "msgid": message_dict.get("message_id", "no msgid"), }, ) return None if len(matches) > 1: _logger.debug( "Multiple matches found: %(matches)s", { "matches": ", ".join( [str((match.id, match.display_name)) for match in matches] ), }, ) matched = len(matches) == 1 or self.match_first return matched and matches[0] or None def _get_algorithm(self): """Translate algorithm code to implementation class. We used to load this dynamically, but having it more or less hardcoded allows to adapt the UI to the selected algorithm, withouth needing the (deprecated) fields_view_get trickery we used in the past. """ self.ensure_one() if self.match_algorithm == "email_domain": return match_algorithm.email_domain.EmailDomain() if self.match_algorithm == "email_exact": return match_algorithm.email_exact.EmailExact() _logger.error( "Unknown algorithm %(algorithm)s", {"algorithm": self.match_algorithm} ) return None def attach_mail(self, match_object, message_dict): """Attach mail to match_object.""" self.ensure_one() partner = False model_name = self.model_id.model if model_name == "res.partner": partner = match_object elif "partner_id" in self.env[model_name]._fields: partner = match_object.partner_id message_model = self.env["mail.message"] msg_values = { key: val for key, val in message_dict.items() if key in message_model._fields } msg_values.update( { "author_id": partner and partner.id or False, "model": model_name, "res_id": match_object.id, "message_type": "email", } ) thread_model = self.env["mail.thread"] attachments = message_dict["attachments"] or [] attachment_ids = [] attachement_values = thread_model._message_post_process_attachments( attachments, attachment_ids, msg_values ) msg_values.update(attachement_values) message = message_model.create(msg_values) _logger.debug( "Message with id %(message_id)s created" " for %(model_name)s with id %(thread_id)s", { "message_id": message.id, "model_name": model_name, "thread_id": match_object.id, }, )