Initial commit: Odoo 18.0-20251222 extra-addons
This commit is contained in:
4
fetchmail_attach_from_folder/models/__init__.py
Executable file
4
fetchmail_attach_from_folder/models/__init__.py
Executable file
@@ -0,0 +1,4 @@
|
||||
# Copyright - 2013-2018 Therp BV <https://therp.nl>.
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
|
||||
from . import fetchmail_server
|
||||
from . import fetchmail_server_folder
|
||||
75
fetchmail_attach_from_folder/models/fetchmail_server.py
Executable file
75
fetchmail_attach_from_folder/models/fetchmail_server.py
Executable file
@@ -0,0 +1,75 @@
|
||||
# Copyright - 2013-2024 Therp BV <https://therp.nl>.
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
list_response_pattern = re.compile(
|
||||
r'\((?P<flags>.*?)\) "(?P<delimiter>.*)" (?P<name>.*)'
|
||||
)
|
||||
|
||||
|
||||
class FetchmailServer(models.Model):
|
||||
_inherit = "fetchmail.server"
|
||||
|
||||
def _compute_folders_available(self):
|
||||
"""Retrieve available folders from IMAP server."""
|
||||
|
||||
def parse_list_response(line):
|
||||
string_line = line.decode("utf-8")
|
||||
flags, delimiter, mailbox_name = list_response_pattern.match(
|
||||
string_line
|
||||
).groups()
|
||||
mailbox_name = mailbox_name.strip('"')
|
||||
return (flags, delimiter, mailbox_name)
|
||||
|
||||
for this in self:
|
||||
if this.state != "done":
|
||||
this.folders_available = _("Confirm connection first.")
|
||||
continue
|
||||
connection = this.connect()
|
||||
list_result = connection.list()
|
||||
if list_result[0] != "OK":
|
||||
this.folders_available = _("Unable to retrieve folders.")
|
||||
continue
|
||||
folders_available = []
|
||||
for folder_entry in list_result[1]:
|
||||
folders_available.append(parse_list_response(folder_entry)[2])
|
||||
this.folders_available = "\n".join(folders_available)
|
||||
connection.logout()
|
||||
|
||||
folders_available = fields.Text(
|
||||
string="Available folders",
|
||||
compute="_compute_folders_available",
|
||||
)
|
||||
folder_ids = fields.One2many(
|
||||
comodel_name="fetchmail.server.folder",
|
||||
inverse_name="server_id",
|
||||
string="Folders",
|
||||
context={"active_test": False},
|
||||
)
|
||||
folders_only = fields.Boolean(
|
||||
string="Only folders, not inbox",
|
||||
help="Check this field to leave imap inbox alone"
|
||||
" and only retrieve mail from configured folders.",
|
||||
)
|
||||
# Below existing fields, that are modified by this module.
|
||||
object_id = fields.Many2one(required=False) # comodel_name='ir.model'
|
||||
server_type = fields.Selection(default="imap")
|
||||
|
||||
@api.onchange("server_type", "is_ssl", "object_id")
|
||||
def onchange_server_type(self):
|
||||
result = super().onchange_server_type()
|
||||
self.state = "draft"
|
||||
return result
|
||||
|
||||
def fetch_mail(self):
|
||||
result = True
|
||||
for this in self:
|
||||
if not this.folders_only:
|
||||
result = result and super(FetchmailServer, this).fetch_mail()
|
||||
this.folder_ids.fetch_mail()
|
||||
return result
|
||||
409
fetchmail_attach_from_folder/models/fetchmail_server_folder.py
Executable file
409
fetchmail_attach_from_folder/models/fetchmail_server_folder.py
Executable file
@@ -0,0 +1,409 @@
|
||||
# Copyright - 2013-2024 Therp BV <https://therp.nl>.
|
||||
# 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)
|
||||
action_id = fields.Many2one(
|
||||
comodel_name="ir.actions.server",
|
||||
name="Server action",
|
||||
help="Optional custom server action to trigger for each incoming "
|
||||
"mail, on the record that was created or updated by this mail",
|
||||
)
|
||||
fetch_unseen_only = fields.Boolean(
|
||||
help="By default all undeleted emails are searched. Checking this "
|
||||
"field adds the unread condition.",
|
||||
)
|
||||
|
||||
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 get_criteria(self):
|
||||
return "UNDELETED" if not self.fetch_unseen_only else "UNSEEN UNDELETED"
|
||||
|
||||
def retrieve_imap_folder(self, connection):
|
||||
"""Retrieve all mails for one IMAP folder."""
|
||||
self.ensure_one()
|
||||
msgids = self.get_msgids(connection, self.get_criteria())
|
||||
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
|
||||
if matched:
|
||||
self.run_server_action(thread_id)
|
||||
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 run_server_action(self, matched_object_ids):
|
||||
action = self.action_id
|
||||
if not action:
|
||||
return
|
||||
records = self.env[self.model_id.model].browse(matched_object_ids)
|
||||
for record in records:
|
||||
if not record.exists():
|
||||
continue
|
||||
action.with_context(
|
||||
**{
|
||||
"active_id": record.id,
|
||||
"active_ids": record.ids,
|
||||
"active_model": self.model_id.model,
|
||||
}
|
||||
).run()
|
||||
|
||||
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._process_attachments_for_post(
|
||||
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,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user