diff --git a/addons/mail/i18n/mail.pot b/addons/mail/i18n/mail.pot
index 7917a703257..f7d091f1f35 100644
--- a/addons/mail/i18n/mail.pot
+++ b/addons/mail/i18n/mail.pot
@@ -7619,6 +7619,11 @@ msgstr ""
msgid "Wrong operation name (%s)"
msgstr ""
+#. module: mail
+#: model_terms:ir.ui.view,arch_db:mail.view_mail_form
+msgid "YYYY-MM-DD HH:MM:SS"
+msgstr ""
+
#. module: mail
#. openerp-web
#: code:addons/mail/static/src/models/tracking_value_item.js:0
diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py
index 86d8c8a478d..3d04974adfd 100644
--- a/addons/mail/models/mail_mail.py
+++ b/addons/mail/models/mail_mail.py
@@ -9,8 +9,10 @@ import psycopg2
import smtplib
import threading
import re
+import pytz
from collections import defaultdict
+from dateutil.parser import parse
from odoo import _, api, fields, models
from odoo import tools
@@ -127,7 +129,12 @@ class MailMail(models.Model):
for values in values_list:
if 'is_notification' not in values and values.get('mail_message_id'):
values['is_notification'] = True
-
+ if values.get('scheduled_date'):
+ parsed_datetime = self._parse_scheduled_datetime(values['scheduled_date'])
+ if parsed_datetime:
+ values['scheduled_date'] = parsed_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
+ else:
+ values['scheduled_date'] = False
new_mails = super(MailMail, self).create(values_list)
new_mails_w_attach = self
@@ -140,6 +147,12 @@ class MailMail(models.Model):
return new_mails
def write(self, vals):
+ if vals.get('scheduled_date'):
+ parsed_datetime = self._parse_scheduled_datetime(vals['scheduled_date'])
+ if parsed_datetime:
+ vals['scheduled_date'] = parsed_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
+ else:
+ vals['scheduled_date'] = False
res = super(MailMail, self).write(vals)
if vals.get('attachment_ids'):
for mail in self:
@@ -204,11 +217,13 @@ class MailMail(models.Model):
messages to send (by default all 'outgoing'
messages are sent).
"""
- filters = ['&',
- ('state', '=', 'outgoing'),
- '|',
- ('scheduled_date', '<', datetime.datetime.now()),
- ('scheduled_date', '=', False)]
+ filters = [
+ '&',
+ ('state', '=', 'outgoing'),
+ '|',
+ ('scheduled_date', '=', False),
+ ('scheduled_date', '<=', datetime.datetime.utcnow()),
+ ]
if 'filters' in self._context:
filters.extend(self._context['filters'])
# TODO: make limit configurable
@@ -270,6 +285,42 @@ class MailMail(models.Model):
return True
+ def _parse_scheduled_datetime(self, scheduled_datetime):
+ """ Taking an arbitrary datetime (either as a date, a datetime or a string)
+ try to parse it and return a datetime timezoned to UTC.
+
+ If no specific timezone information is given, we consider it as being
+ given in UTC, as all datetime values given to the server. Trying to
+ guess its timezone based on user or flow would be strange as this is
+ not standard. When manually creating datetimes for mail.mail scheduled
+ date, business code should ensure either a timezone info is set, either
+ it is converted into UTC.
+
+ Using yearfirst when parsing str datetimes eases parser's job when
+ dealing with the hard-to-parse trio (01/04/09 -> ?). In most use cases
+ year will be given first as this is the expected default formatting.
+
+ :return datetime: parsed datetime (or False if parser failed)
+ """
+ if isinstance(scheduled_datetime, datetime.datetime):
+ parsed_datetime = scheduled_datetime
+ elif isinstance(scheduled_datetime, datetime.date):
+ parsed_datetime = datetime.combine(scheduled_datetime, datetime.time.min)
+ else:
+ try:
+ parsed_datetime = parse(scheduled_datetime, yearfirst=True)
+ except (ValueError, TypeError):
+ parsed_datetime = False
+ if parsed_datetime:
+ if not parsed_datetime.tzinfo:
+ parsed_datetime = pytz.utc.localize(parsed_datetime)
+ else:
+ try:
+ parsed_datetime = parsed_datetime.astimezone(pytz.utc)
+ except Exception:
+ pass
+ return parsed_datetime
+
# ------------------------------------------------------
# mail_mail formatting, tools and send mechanism
# ------------------------------------------------------
diff --git a/addons/mail/views/mail_mail_views.xml b/addons/mail/views/mail_mail_views.xml
index 0f15d316147..16e9cc1b4c3 100644
--- a/addons/mail/views/mail_mail_views.xml
+++ b/addons/mail/views/mail_mail_views.xml
@@ -33,7 +33,7 @@
domain="[('type', '!=', 'private'), ('active', '=', True)]"/>
-
+
diff --git a/addons/test_mail/tests/test_mail_mail.py b/addons/test_mail/tests/test_mail_mail.py
index a23b0f22e74..a992e039f7b 100644
--- a/addons/test_mail/tests/test_mail_mail.py
+++ b/addons/test_mail/tests/test_mail_mail.py
@@ -2,18 +2,21 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import psycopg2
+import pytz
import smtplib
+from datetime import datetime, timedelta
+from freezegun import freeze_time
from OpenSSL.SSL import Error as SSLError
from socket import gaierror, timeout
from unittest.mock import call, patch
-from odoo import api, Command
+from odoo import api, Command, tools
from odoo.addons.base.models.ir_mail_server import MailDeliveryException
from odoo.addons.test_mail.tests.common import TestMailCommon
from odoo.exceptions import AccessError
from odoo.tests import common, tagged, users
-from odoo.tools import mute_logger
+from odoo.tools import mute_logger, DEFAULT_SERVER_DATETIME_FORMAT
@tagged('mail_mail')
@@ -169,6 +172,57 @@ class TestMailMail(TestMailCommon):
mail.send()
self.assertEqual(self._mails[0]['headers']['Return-Path'], '%s@%s' % (self.alias_bounce, self.alias_domain))
+ @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests')
+ def test_mail_mail_schedule(self):
+ """Test that a mail scheduled in the past/future are sent or not"""
+ now = datetime(2022, 6, 28, 14, 0, 0)
+ scheduled_datetimes = [
+ # falsy values
+ False, '', 'This is not a date format',
+ # datetimes (UTC/GMT +10 hours for Australia/Brisbane)
+ now, pytz.timezone('Australia/Brisbane').localize(now),
+ # string
+ (now - timedelta(days=1)).strftime(DEFAULT_SERVER_DATETIME_FORMAT),
+ (now + timedelta(days=1)).strftime(DEFAULT_SERVER_DATETIME_FORMAT),
+ (now + timedelta(days=1)).strftime("%H:%M:%S %d-%m-%Y"),
+ # tz: is actually 1 hour before now in UTC
+ (now + timedelta(hours=3)).strftime("%H:%M:%S %d-%m-%Y") + " +0400",
+ # tz: is actually 1 hour after now in UTC
+ (now + timedelta(hours=-3)).strftime("%H:%M:%S %d-%m-%Y") + " -0400",
+ ]
+ expected_datetimes = [
+ False, '', False,
+ now, now - pytz.timezone('Australia/Brisbane').utcoffset(now),
+ now - timedelta(days=1), now + timedelta(days=1), now + timedelta(days=1),
+ now + timedelta(hours=-1),
+ now + timedelta(hours=1),
+ ]
+ expected_states = [
+ # falsy values = send now
+ 'sent', 'sent', 'sent',
+ 'sent', 'sent',
+ 'sent', 'outgoing', 'outgoing',
+ 'sent', 'outgoing'
+ ]
+
+ mails = self.env['mail.mail'].create([
+ {'body_html': 'Test
',
+ 'email_to': 'test@example.com',
+ 'scheduled_date': scheduled_datetime,
+ } for scheduled_datetime in scheduled_datetimes
+ ])
+
+ for mail, expected_datetime, scheduled_datetime in zip(mails, expected_datetimes, scheduled_datetimes):
+ expected = expected_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) if expected_datetime else expected_datetime
+ self.assertEqual(mail.scheduled_date, expected,
+ 'Scheduled date: %s should be stored as %s, received %s' % (scheduled_datetime, expected, mail.scheduled_date))
+ self.assertEqual(mail.state, 'outgoing')
+
+ with freeze_time(now):
+ self.env['mail.mail'].process_email_queue()
+ for mail, expected_state in zip(mails, expected_states):
+ self.assertEqual(mail.state, expected_state)
+
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_mail_mail_send_exceptions_origin(self):
""" Test various use case with exceptions and errors and see how they are