Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions fetchmail_attach_from_folder/models/fetchmail_server_folder.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,25 +430,20 @@ def _get_algorithm(self):
)
return None

@api.model
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
}
partner = self._get_partner_from_object(match_object)
msg_values.update(
{
"author_id": partner and partner.id or False,
"model": model_name,
"model": match_object._name,
"res_id": match_object.id,
"message_type": "email",
}
Expand All @@ -463,10 +458,19 @@ def attach_mail(self, match_object, message_dict):
message = message_model.create(msg_values)
_logger.debug(
"Message with id %(message_id)s created"
" for %(model_name)s with id %(thread_id)s",
" for %(match_object._name)s with id %(thread_id)s",
{
"message_id": message.id,
"model_name": model_name,
"match_object._name": match_object._name,
"thread_id": match_object.id,
},
)

@api.model
def _get_partner_from_object(self, match_object):
"""Get partner from object."""
if match_object._name == "res.partner":
return match_object
if "partner_id" in match_object._fields:
return match_object.partner_id
return False
31 changes: 31 additions & 0 deletions fetchmail_attach_from_folder/tests/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright - 2015-2026 Therp BV <https://therp.nl>.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).


def get_message_body(email, subject):
"""Get Message Body, as returned by fetch() from connection.

fetch returns a list of tuples with the message information.
"""
return [
(
"1 (RFC822 {1149}",
"Return-Path: <ronald@acme.com>\r\n"
"Delivered-To: demo@yourcompany.example.com\r\n"
"Received: from localhost (localhost [127.0.0.1])\r\n"
"\tby vanaheim.acme.com (Postfix) with ESMTP id 14A3183163\r\n"
"\tfor <demo@yourcompany.example.com>;"
" Wed, 23 Jul 2025 16:03:52 +0200 (CEST)\r\n"
"To: Test User <nonexistingemail@yourcompany.example.com>\r\n"
f"From: Reynaert de Vos <{email}>\r\n"
f"Subject: {subject}\r\n"
"Message-ID: <485a8041-d560-a981-5afc-d31c1f136748@acme.com>\r\n"
"Date: Mon, 26 Mar 2018 16:03:51 +0200\r\n"
"User-Agent: Mock Test\r\n"
"MIME-Version: 1.0\r\n"
"Content-Type: text/plain; charset=utf-8\r\n"
"Content-Language: en-US\r\n"
"Content-Transfer-Encoding: 7bit\r\n\r\n"
"Hallo Wereld!\r\n",
)
]
49 changes: 21 additions & 28 deletions fetchmail_attach_from_folder/tests/test_attach_mail_manually.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,21 @@
# pylint: disable=method-required-super
from unittest.mock import MagicMock, patch

from odoo.fields import Command
from odoo.tests.common import TransactionCase

from odoo.addons.mail.models.fetchmail import FetchmailServer

from .common import get_message_body


def get_message_org(email, subject):
# get_message_body returns an array of tuples, similar to what
# IMAP4.fetch(...) would return. There will be a tuple
# for each message. The actual messages are in the second
# part of each tuple.
return get_message_body(email, subject)[0][1]


class TestAttachMailManually(TransactionCase):
@classmethod
Expand Down Expand Up @@ -57,15 +68,8 @@ def _mock_connection(self):
return mock_conn

def _mock_fetch_msg(self, connection, message_uid):
"""Return a tuple like the real fetch_msg: (dict, bytes)"""
mail_message = {
"subject": "Test",
"date": "2025-07-23 12:00:00",
"from": "test@example.com",
"body": "<p>Body</p>",
}
raw_message = b"Raw MIME message here"
return mail_message, raw_message
"""Return raw message body (bytes)."""
return get_message_org("test@example.com", "Test")

@patch.object(FetchmailServer, "connect")
def test_default_get_populates_mail_ids(self, mock_connect):
Expand All @@ -90,35 +94,24 @@ def test_attach_mails_only_with_object_id(self, mock_connect):
"""Only mails with object_id should be attached."""
mock_conn = self._mock_connection()
mock_connect.return_value = mock_conn
message_org = get_message_org("test@example.com", "With Object")
with patch.object(
self.folder.__class__,
"fetch_msg",
side_effect=lambda conn, message_uid: (
{
"subject": "With Object",
"date": "2025-07-23",
"from": "test@example.com",
"body": "<p>Body</p>",
},
b"raw_message",
),
side_effect=lambda conn, message_uid: message_org,
):
wizard = self.Wizard.create(
{
"folder_id": self.folder.id,
"mail_ids": [
(
0,
0,
Command.create(
{
"message_uid": "1",
"subject": "No Object",
"object_id": False,
},
),
(
0,
0,
Command.create(
{
"message_uid": "2",
"subject": "With Object",
Expand All @@ -139,28 +132,28 @@ def test_prepare_mail_returns_expected_dict(self):
"""Test _prepare_mail returns correct structure."""
folder = self.folder
message_uid = "123"
mail_message = {
message_dict = {
"subject": "Test",
"date": "2025-07-23",
"from": "test@example.com",
"body": "<p>Body</p>",
}
result = self.Wizard._prepare_mail(folder, message_uid, mail_message)
result = self.Wizard._prepare_mail(folder, message_uid, message_dict)
expected = {
"message_uid": "123",
"subject": "Test",
"date": "2025-07-23",
"body": "<p>Body</p>",
"email_from": "test@example.com",
"object_id": "res.partner,-1",
}
self.assertEqual(result, expected)

def test_wizard_name_is_translated(self):
"""Test that default name is translated."""
message_org = get_message_org("test@example.com", "With Object")
with (
patch.object(FetchmailServer, "connect", return_value=MagicMock()),
patch.object(self.folder.__class__, "fetch_msg", return_value=({}, b"raw")),
patch.object(self.folder.__class__, "fetch_msg", return_value=message_org),
patch.object(
self.folder.__class__, "get_message_uids", return_value=[b"1"]
),
Expand Down
34 changes: 1 addition & 33 deletions fetchmail_attach_from_folder/tests/test_match_algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,45 +9,13 @@
from odoo.tests.common import TransactionCase

from ..match_algorithm import email_domain
from .common import get_message_body

TEST_EMAIL = "reynaert@dutchsagas.nl"
TEST_SUBJECT = "Test subject"
MAIL_MESSAGE = {"subject": TEST_SUBJECT, "to": "demo@yourcompany.example.com"}


def get_message_body(email, subject):
"""Get Message Body, as returned by fetch() from connection.

fetch returns a list of tuples with the message information.
"""
return [
(
"1 (RFC822 {1149}",
"Return-Path: <ronald@acme.com>\r\n"
"Delivered-To: demo@yourcompany.example.com\r\n"
"Received: from localhost (localhost [127.0.0.1])\r\n"
"\tby vanaheim.acme.com (Postfix) with ESMTP id 14A3183163\r\n"
"\tfor <demo@yourcompany.example.com>;"
" Mon, 26 Mar 2018 16:03:52 +0200 (CEST)\r\n"
"To: Test User <nonexistingemail@yourcompany.example.com>\r\n"
"From: Reynaert de Vos <%(test_email)s>\r\n"
"Subject: %(test_subject)s\r\n"
"Message-ID: <485a8041-d560-a981-5afc-d31c1f136748@acme.com>\r\n"
"Date: Mon, 26 Mar 2018 16:03:51 +0200\r\n"
"User-Agent: Mock Test\r\n"
"MIME-Version: 1.0\r\n"
"Content-Type: text/plain; charset=utf-8\r\n"
"Content-Language: en-US\r\n"
"Content-Transfer-Encoding: 7bit\r\n\r\n"
"Hallo Wereld!\r\n"
% {
"test_email": email,
"test_subject": subject,
},
)
]


class MockConnection:
def select(self, path=None):
"""Mock selecting a folder."""
Expand Down
52 changes: 20 additions & 32 deletions fetchmail_attach_from_folder/wizard/attach_mail_manually.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Copyright 2013-2018 Therp BV <https://therp.nl>.
# Copyright 2013-2026 Therp BV <https://therp.nl>.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import logging

from odoo import _, api, fields, models
from odoo.fields import Command

_logger = logging.getLogger(__name__)

Expand All @@ -20,14 +21,13 @@ class AttachMailManually(models.TransientModel):
)

@api.model
def _prepare_mail(self, folder, message_uid, mail_message):
def _prepare_mail(self, folder, message_uid, message_dict):
return {
"message_uid": message_uid,
"subject": mail_message.get("subject", ""),
"date": mail_message.get("date") or False,
"body": mail_message.get("body", ""),
"email_from": mail_message.get("from", ""),
"object_id": f"{folder.model_id.model},-1",
"subject": message_dict.get("subject", ""),
"date": message_dict.get("date") or False,
"body": message_dict.get("body", ""),
"email_from": message_dict.get("from", ""),
}

@api.model
Expand All @@ -45,9 +45,10 @@ def default_get(self, fields_list):
criteria = "FLAGGED" if folder.flag_nonmatching else folder.get_criteria()
message_uids = folder.get_message_uids(connection, criteria)
for message_uid in message_uids[0].split():
mail_message, message_org = folder.fetch_msg(connection, message_uid)
message_org = folder.fetch_msg(connection, message_uid)
message_dict = folder._get_message_dict(message_org)
defaults["mail_ids"].append(
(0, 0, self._prepare_mail(folder, message_uid, mail_message))
Command.create(self._prepare_mail(folder, message_uid, message_dict))
)
connection.close()
return defaults
Expand All @@ -62,33 +63,15 @@ def attach_mails(self):
if not mail.object_id:
continue
message_uid = mail.message_uid
mail_message, message_org = folder.fetch_msg(connection, message_uid)
folder.attach_mail(mail.object_id, mail_message)
message_org = folder.fetch_msg(connection, message_uid)
message_dict = folder._get_message_dict(message_org)
folder.attach_mail(mail.object_id, message_dict)
folder.update_msg(
connection, message_uid, matched=True, flagged=folder.flag_nonmatching
)
connection.close()
return {"type": "ir.actions.act_window_close"}

@api.model
def fields_view_get(
self, view_id=None, view_type="form", toolbar=False, submenu=False
):
# TODO: Change or replace this...
result = super(AttachMailManually, self).fields_view_get(
view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu
)
if view_type != "form":
return result
folder_model = self.env["fetchmail.server.folder"]
folder_id = self.env.context.get("folder_id")
folder = folder_model.browse([folder_id])
form = result["fields"]["mail_ids"]["views"]["form"]
form["fields"]["object_id"]["selection"] = [
(folder.model_id.model, folder.model_id.name)
]
return result


class AttachMailManuallyMail(models.TransientModel):
"""Attach single mail to selected documents."""
Expand All @@ -97,11 +80,16 @@ class AttachMailManuallyMail(models.TransientModel):
_description = __doc__

wizard_id = fields.Many2one("fetchmail.attach.mail.manually", readonly=True)
message_uid = fields.Char("Message id", readonly=True)
message_uid = fields.Char("Message id")
subject = fields.Char(readonly=True)
date = fields.Datetime(readonly=True)
email_from = fields.Char("From", readonly=True)
body = fields.Html(readonly=True)
object_id = fields.Reference(
lambda self: [(m.model, m.name) for m in self.env["ir.model"].search([])]
selection=lambda self: self._get_model_selection(),
)

def _get_model_selection(self):
"""Selection from all models in the system."""
Model = self.env["ir.model"]
return [(m.model, m.name) for m in Model.search([("transient", "=", False)])]
2 changes: 2 additions & 0 deletions fetchmail_attach_from_folder/wizard/attach_mail_manually.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
<field name="folder_id" />
<field name="mail_ids" nolabel="1" colspan="4">
<tree create="0">
<field name="message_uid" invisible="1" />
<field name="email_from" />
<field name="subject" />
<field name="date" />
<field name="object_id" />
</tree>
<form>
<group>
<field name="message_uid" invisible="1" />
<field name="email_from" />
<field name="subject" />
<field name="date" />
Expand Down
Loading