mailing.py 65 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import base64
  4. import hashlib
  5. import hmac
  6. import io
  7. import logging
  8. import lxml
  9. import random
  10. import re
  11. import requests
  12. import threading
  13. import werkzeug.urls
  14. from ast import literal_eval
  15. from dateutil.relativedelta import relativedelta
  16. from markupsafe import Markup
  17. from werkzeug.urls import url_join
  18. from PIL import Image, UnidentifiedImageError
  19. from odoo import api, fields, models, tools, _
  20. from odoo.addons.base_import.models.base_import import ImportValidationError
  21. from odoo.exceptions import UserError, ValidationError
  22. from odoo.osv import expression
  23. _logger = logging.getLogger(__name__)
  24. # Syntax of the data URL Scheme: https://tools.ietf.org/html/rfc2397#section-3
  25. # Used to find inline images
  26. image_re = re.compile(r"data:(image/[A-Za-z]+);base64,(.*)")
  27. DEFAULT_IMAGE_TIMEOUT = 3
  28. DEFAULT_IMAGE_MAXBYTES = 10 * 1024 * 1024 # 10MB
  29. DEFAULT_IMAGE_CHUNK_SIZE = 32768
  30. mso_re = re.compile(r"\[if mso\]>[\s\S]*<!\[endif\]")
  31. class MassMailing(models.Model):
  32. """ Mass Mailing models the sending of emails to a list of recipients for a mass mailing campaign."""
  33. _name = 'mailing.mailing'
  34. _description = 'Mass Mailing'
  35. _inherit = ['mail.thread',
  36. 'mail.activity.mixin',
  37. 'mail.render.mixin',
  38. 'utm.source.mixin'
  39. ]
  40. _order = 'calendar_date DESC'
  41. _rec_name = "subject"
  42. @api.model
  43. def default_get(self, fields_list):
  44. vals = super(MassMailing, self).default_get(fields_list)
  45. # field sent by the calendar view when clicking on a date block
  46. # we use it to setup the scheduled date of the created mailing.mailing
  47. default_calendar_date = self.env.context.get('default_calendar_date')
  48. if default_calendar_date and ('schedule_type' in fields_list and 'schedule_date' in fields_list) \
  49. and fields.Datetime.from_string(default_calendar_date) > fields.Datetime.now():
  50. vals.update({
  51. 'schedule_type': 'scheduled',
  52. 'schedule_date': default_calendar_date
  53. })
  54. if 'contact_list_ids' in fields_list and not vals.get('contact_list_ids') and vals.get('mailing_model_id'):
  55. if vals.get('mailing_model_id') == self.env['ir.model']._get('mailing.list').id:
  56. mailing_list = self.env['mailing.list'].search([], limit=2)
  57. if len(mailing_list) == 1:
  58. vals['contact_list_ids'] = [(6, 0, [mailing_list.id])]
  59. return vals
  60. @api.model
  61. def _get_default_mail_server_id(self):
  62. server_id = self.env['ir.config_parameter'].sudo().get_param('mass_mailing.mail_server_id')
  63. try:
  64. server_id = literal_eval(server_id) if server_id else False
  65. return self.env['ir.mail_server'].search([('id', '=', server_id)]).id
  66. except ValueError:
  67. return False
  68. active = fields.Boolean(default=True, tracking=True)
  69. subject = fields.Char(
  70. 'Subject', required=True, translate=False)
  71. preview = fields.Char(
  72. 'Preview', translate=False,
  73. help='Catchy preview sentence that encourages recipients to open this email.\n'
  74. 'In most inboxes, this is displayed next to the subject.\n'
  75. 'Keep it empty if you prefer the first characters of your email content to appear instead.')
  76. email_from = fields.Char(
  77. string='Send From',
  78. compute='_compute_email_from', readonly=False, required=True, store=True,
  79. default=lambda self: self.env.user.email_formatted)
  80. favorite = fields.Boolean('Favorite', copy=False, tracking=True)
  81. favorite_date = fields.Datetime(
  82. 'Favorite Date',
  83. compute='_compute_favorite_date', store=True,
  84. copy=False,
  85. help='When this mailing was added in the favorites')
  86. sent_date = fields.Datetime(string='Sent Date', copy=False)
  87. schedule_type = fields.Selection(
  88. [('now', 'Send now'), ('scheduled', 'Send on')],
  89. string='Schedule', default='now',
  90. readonly=True, required=True,
  91. states={'draft': [('readonly', False)], 'in_queue': [('readonly', False)]})
  92. schedule_date = fields.Datetime(
  93. string='Scheduled for',
  94. compute='_compute_schedule_date', readonly=True, store=True,
  95. copy=True, tracking=True,
  96. states={'draft': [('readonly', False)], 'in_queue': [('readonly', False)]})
  97. calendar_date = fields.Datetime(
  98. 'Calendar Date',
  99. compute='_compute_calendar_date', store=True,
  100. copy=False,
  101. help="Date at which the mailing was or will be sent.")
  102. # don't translate 'body_arch', the translations are only on 'body_html'
  103. body_arch = fields.Html(string='Body', translate=False, sanitize=False)
  104. body_html = fields.Html(string='Body converted to be sent by mail', render_engine='qweb', sanitize=False)
  105. # used to determine if the mail body is empty
  106. is_body_empty = fields.Boolean(compute="_compute_is_body_empty")
  107. attachment_ids = fields.Many2many(
  108. 'ir.attachment', 'mass_mailing_ir_attachments_rel',
  109. 'mass_mailing_id', 'attachment_id', string='Attachments')
  110. keep_archives = fields.Boolean(string='Keep Archives')
  111. campaign_id = fields.Many2one('utm.campaign', string='UTM Campaign', index=True, ondelete='set null')
  112. medium_id = fields.Many2one(
  113. 'utm.medium', string='Medium',
  114. compute='_compute_medium_id', readonly=False, store=True,
  115. ondelete='restrict',
  116. help="UTM Medium: delivery method (email, sms, ...)")
  117. state = fields.Selection(
  118. [('draft', 'Draft'), ('in_queue', 'In Queue'),
  119. ('sending', 'Sending'), ('done', 'Sent')],
  120. string='Status',
  121. default='draft', required=True,
  122. copy=False, tracking=True,
  123. group_expand='_group_expand_states')
  124. color = fields.Integer(string='Color Index')
  125. user_id = fields.Many2one(
  126. 'res.users', string='Responsible',
  127. tracking=True,
  128. default=lambda self: self.env.user)
  129. # mailing options
  130. mailing_type = fields.Selection([('mail', 'Email')], string="Mailing Type", default="mail", required=True)
  131. mailing_type_description = fields.Char('Mailing Type Description', compute="_compute_mailing_type_description")
  132. reply_to_mode = fields.Selection(
  133. [('update', 'Recipient Followers'), ('new', 'Specified Email Address')],
  134. string='Reply-To Mode',
  135. compute='_compute_reply_to_mode', readonly=False, store=True,
  136. help='Thread: replies go to target document. Email: replies are routed to a given email.')
  137. reply_to = fields.Char(
  138. string='Reply To',
  139. compute='_compute_reply_to', readonly=False, store=True,
  140. help='Preferred Reply-To Address')
  141. # recipients
  142. mailing_model_real = fields.Char(
  143. string='Recipients Real Model', compute='_compute_mailing_model_real')
  144. mailing_model_id = fields.Many2one(
  145. 'ir.model', string='Recipients Model',
  146. ondelete='cascade', required=True,
  147. domain=[('is_mailing_enabled', '=', True)],
  148. default=lambda self: self.env.ref('mass_mailing.model_mailing_list').id)
  149. mailing_model_name = fields.Char(
  150. string='Recipients Model Name',
  151. related='mailing_model_id.model', readonly=True, related_sudo=True)
  152. mailing_domain = fields.Char(
  153. string='Domain',
  154. compute='_compute_mailing_domain', readonly=False, store=True)
  155. mail_server_available = fields.Boolean(
  156. compute='_compute_mail_server_available',
  157. help="Technical field used to know if the user has activated the outgoing mail server option in the settings")
  158. mail_server_id = fields.Many2one('ir.mail_server', string='Mail Server',
  159. default=_get_default_mail_server_id,
  160. help="Use a specific mail server in priority. Otherwise Odoo relies on the first outgoing mail server available (based on their sequencing) as it does for normal mails.")
  161. contact_list_ids = fields.Many2many('mailing.list', 'mail_mass_mailing_list_rel', string='Mailing Lists')
  162. # Mailing Filter
  163. mailing_filter_id = fields.Many2one(
  164. 'mailing.filter', string='Favorite Filter',
  165. compute='_compute_mailing_filter_id', readonly=False, store=True,
  166. domain="[('mailing_model_name', '=', mailing_model_name)]")
  167. mailing_filter_domain = fields.Char('Favorite filter domain', related='mailing_filter_id.mailing_domain')
  168. mailing_filter_count = fields.Integer('# Favorite Filters', compute='_compute_mailing_filter_count')
  169. # A/B Testing
  170. ab_testing_completed = fields.Boolean(related='campaign_id.ab_testing_completed', store=True)
  171. ab_testing_description = fields.Html('A/B Testing Description', compute="_compute_ab_testing_description")
  172. ab_testing_enabled = fields.Boolean(
  173. string='Allow A/B Testing', default=False,
  174. help='If checked, recipients will be mailed only once for the whole campaign. '
  175. 'This lets you send different mailings to randomly selected recipients and test '
  176. 'the effectiveness of the mailings, without causing duplicate messages.')
  177. ab_testing_mailings_count = fields.Integer(related="campaign_id.ab_testing_mailings_count")
  178. ab_testing_pc = fields.Integer(
  179. string='A/B Testing percentage',
  180. default=10,
  181. help='Percentage of the contacts that will be mailed. Recipients will be chosen randomly.')
  182. ab_testing_schedule_datetime = fields.Datetime(
  183. related="campaign_id.ab_testing_schedule_datetime", readonly=False,
  184. default=lambda self: fields.Datetime.now() + relativedelta(days=1))
  185. ab_testing_winner_selection = fields.Selection(
  186. related="campaign_id.ab_testing_winner_selection", readonly=False,
  187. default="opened_ratio",
  188. copy=True)
  189. kpi_mail_required = fields.Boolean('KPI mail required', copy=False)
  190. # statistics data
  191. mailing_trace_ids = fields.One2many('mailing.trace', 'mass_mailing_id', string='Emails Statistics')
  192. total = fields.Integer(compute="_compute_total")
  193. scheduled = fields.Integer(compute="_compute_statistics")
  194. expected = fields.Integer(compute="_compute_statistics")
  195. canceled = fields.Integer(compute="_compute_statistics")
  196. sent = fields.Integer(compute="_compute_statistics")
  197. delivered = fields.Integer(compute="_compute_statistics")
  198. opened = fields.Integer(compute="_compute_statistics")
  199. clicked = fields.Integer(compute="_compute_statistics")
  200. replied = fields.Integer(compute="_compute_statistics")
  201. bounced = fields.Integer(compute="_compute_statistics")
  202. failed = fields.Integer(compute="_compute_statistics")
  203. received_ratio = fields.Integer(compute="_compute_statistics", string='Received Ratio')
  204. opened_ratio = fields.Integer(compute="_compute_statistics", string='Opened Ratio')
  205. replied_ratio = fields.Integer(compute="_compute_statistics", string='Replied Ratio')
  206. bounced_ratio = fields.Integer(compute="_compute_statistics", string='Bounced Ratio')
  207. clicks_ratio = fields.Integer(compute="_compute_clicks_ratio", string="Number of Clicks")
  208. next_departure = fields.Datetime(compute="_compute_next_departure", string='Scheduled date')
  209. # UX
  210. warning_message = fields.Char(
  211. 'Warning Message', compute='_compute_warning_message',
  212. help='Warning message displayed in the mailing form view')
  213. _sql_constraints = [(
  214. 'percentage_valid',
  215. 'CHECK(ab_testing_pc >= 0 AND ab_testing_pc <= 100)',
  216. 'The A/B Testing Percentage needs to be between 0 and 100%'
  217. )]
  218. @api.constrains('mailing_model_id', 'mailing_filter_id')
  219. def _check_mailing_filter_model(self):
  220. """Check that if the favorite filter is set, it must contain the same recipient model as mailing"""
  221. for mailing in self:
  222. if mailing.mailing_filter_id and mailing.mailing_model_id != mailing.mailing_filter_id.mailing_model_id:
  223. raise ValidationError(
  224. _("The saved filter targets different recipients and is incompatible with this mailing.")
  225. )
  226. @api.depends('mail_server_id')
  227. def _compute_email_from(self):
  228. user_email = self.env.user.email_formatted
  229. notification_email = self.env['ir.mail_server']._get_default_from_address()
  230. for mailing in self:
  231. server = mailing.mail_server_id
  232. if not server:
  233. mailing.email_from = mailing.email_from or user_email
  234. elif mailing.email_from and server._match_from_filter(mailing.email_from, server.from_filter):
  235. mailing.email_from = mailing.email_from
  236. elif server._match_from_filter(user_email, server.from_filter):
  237. mailing.email_from = user_email
  238. elif server._match_from_filter(notification_email, server.from_filter):
  239. mailing.email_from = notification_email
  240. else:
  241. mailing.email_from = mailing.email_from or user_email
  242. @api.depends('favorite')
  243. def _compute_favorite_date(self):
  244. favorited = self.filtered('favorite')
  245. (self - favorited).favorite_date = False
  246. favorited.filtered(lambda mailing: not mailing.favorite_date).favorite_date = fields.Datetime.now()
  247. def _compute_total(self):
  248. for mass_mailing in self:
  249. total = self.env[mass_mailing.mailing_model_real].search_count(mass_mailing._parse_mailing_domain())
  250. if total and mass_mailing.ab_testing_enabled and mass_mailing.ab_testing_pc < 100:
  251. total = max(int(total / 100.0 * mass_mailing.ab_testing_pc), 1)
  252. mass_mailing.total = total
  253. def _compute_clicks_ratio(self):
  254. self.env.cr.execute("""
  255. SELECT COUNT(DISTINCT(stats.id)) AS nb_mails, COUNT(DISTINCT(clicks.mailing_trace_id)) AS nb_clicks, stats.mass_mailing_id AS id
  256. FROM mailing_trace AS stats
  257. LEFT OUTER JOIN link_tracker_click AS clicks ON clicks.mailing_trace_id = stats.id
  258. WHERE stats.mass_mailing_id IN %s
  259. GROUP BY stats.mass_mailing_id
  260. """, [tuple(self.ids) or (None,)])
  261. mass_mailing_data = self.env.cr.dictfetchall()
  262. mapped_data = dict([(m['id'], 100 * m['nb_clicks'] / m['nb_mails']) for m in mass_mailing_data])
  263. for mass_mailing in self:
  264. mass_mailing.clicks_ratio = mapped_data.get(mass_mailing.id, 0)
  265. def _compute_statistics(self):
  266. """ Compute statistics of the mass mailing """
  267. for key in (
  268. 'scheduled', 'expected', 'canceled', 'sent', 'delivered', 'opened',
  269. 'clicked', 'replied', 'bounced', 'failed', 'received_ratio',
  270. 'opened_ratio', 'replied_ratio', 'bounced_ratio',
  271. ):
  272. self[key] = False
  273. if not self.ids:
  274. return
  275. # ensure traces are sent to db
  276. self.env['mailing.trace'].flush_model()
  277. self.env['mailing.mailing'].flush_model()
  278. self.env.cr.execute("""
  279. SELECT
  280. m.id as mailing_id,
  281. COUNT(s.id) AS expected,
  282. COUNT(s.sent_datetime) AS sent,
  283. COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'outgoing') AS scheduled,
  284. COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'cancel') AS canceled,
  285. COUNT(s.trace_status) FILTER (WHERE s.trace_status in ('sent', 'open', 'reply')) AS delivered,
  286. COUNT(s.trace_status) FILTER (WHERE s.trace_status in ('open', 'reply')) AS opened,
  287. COUNT(s.links_click_datetime) AS clicked,
  288. COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'reply') AS replied,
  289. COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'bounce') AS bounced,
  290. COUNT(s.trace_status) FILTER (WHERE s.trace_status = 'error') AS failed
  291. FROM
  292. mailing_trace s
  293. RIGHT JOIN
  294. mailing_mailing m
  295. ON (m.id = s.mass_mailing_id)
  296. WHERE
  297. m.id IN %s
  298. GROUP BY
  299. m.id
  300. """, (tuple(self.ids), ))
  301. for row in self.env.cr.dictfetchall():
  302. total = (row['expected'] - row['canceled']) or 1
  303. row['received_ratio'] = 100.0 * row['delivered'] / total
  304. row['opened_ratio'] = 100.0 * row['opened'] / total
  305. row['replied_ratio'] = 100.0 * row['replied'] / total
  306. row['bounced_ratio'] = 100.0 * row['bounced'] / total
  307. self.browse(row.pop('mailing_id')).update(row)
  308. def _compute_next_departure(self):
  309. # Schedule_date should only be False if schedule_type = "now" or
  310. # mass_mailing is canceled.
  311. # A cron.trigger is created when mailing is put "in queue"
  312. # so we can reasonably expect that the cron worker will
  313. # execute this based on the cron.trigger's call_at which should
  314. # be now() when clicking "Send" or schedule_date if scheduled
  315. for mass_mailing in self:
  316. if mass_mailing.schedule_date:
  317. # max in case the user schedules a date in the past
  318. mass_mailing.next_departure = max(mass_mailing.schedule_date, fields.datetime.now())
  319. else:
  320. mass_mailing.next_departure = fields.datetime.now()
  321. @api.depends('email_from', 'mail_server_id')
  322. def _compute_warning_message(self):
  323. for mailing in self:
  324. mail_server = mailing.mail_server_id
  325. if mail_server and not mail_server._match_from_filter(mailing.email_from, mail_server.from_filter):
  326. mailing.warning_message = _(
  327. 'This email from can not be used with this mail server.\n'
  328. 'Your emails might be marked as spam on the mail clients.'
  329. )
  330. else:
  331. mailing.warning_message = False
  332. @api.depends('mailing_type')
  333. def _compute_medium_id(self):
  334. for mailing in self:
  335. if mailing.mailing_type == 'mail' and not mailing.medium_id:
  336. mailing.medium_id = self.env.ref('utm.utm_medium_email').id
  337. @api.depends('mailing_model_id')
  338. def _compute_reply_to_mode(self):
  339. """ For main models not really using chatter to gather answers (contacts
  340. and mailing contacts), set reply-to as email-based. Otherwise answers
  341. by default go on the original discussion thread (business document). Note
  342. that mailing_model being mailing.list means contacting mailing.contact
  343. (see mailing_model_name versus mailing_model_real). """
  344. for mailing in self:
  345. if mailing.mailing_model_id.model in ['res.partner', 'mailing.list', 'mailing.contact']:
  346. mailing.reply_to_mode = 'new'
  347. else:
  348. mailing.reply_to_mode = 'update'
  349. @api.depends('reply_to_mode')
  350. def _compute_reply_to(self):
  351. for mailing in self:
  352. if mailing.reply_to_mode == 'new' and not mailing.reply_to:
  353. mailing.reply_to = self.env.user.email_formatted
  354. elif mailing.reply_to_mode == 'update':
  355. mailing.reply_to = False
  356. @api.depends('mailing_model_id', 'mailing_domain')
  357. def _compute_mailing_filter_count(self):
  358. filter_data = self.env['mailing.filter']._read_group([
  359. ('mailing_model_id', 'in', self.mailing_model_id.ids)
  360. ], ['mailing_model_id'], ['mailing_model_id'])
  361. mapped_data = {data['mailing_model_id'][0]: data['mailing_model_id_count'] for data in filter_data}
  362. for mailing in self:
  363. mailing.mailing_filter_count = mapped_data.get(mailing.mailing_model_id.id, 0)
  364. @api.depends('mailing_model_id')
  365. def _compute_mailing_model_real(self):
  366. for mailing in self:
  367. mailing.mailing_model_real = 'mailing.contact' if mailing.mailing_model_id.model == 'mailing.list' else mailing.mailing_model_id.model
  368. @api.depends('mailing_model_id', 'contact_list_ids', 'mailing_type', 'mailing_filter_id')
  369. def _compute_mailing_domain(self):
  370. for mailing in self:
  371. if not mailing.mailing_model_id:
  372. mailing.mailing_domain = ''
  373. elif mailing.mailing_filter_id:
  374. mailing.mailing_domain = mailing.mailing_filter_id.mailing_domain
  375. else:
  376. mailing.mailing_domain = repr(mailing._get_default_mailing_domain())
  377. @api.depends('mailing_model_name')
  378. def _compute_mailing_filter_id(self):
  379. for mailing in self:
  380. mailing.mailing_filter_id = False
  381. @api.depends('schedule_type')
  382. def _compute_schedule_date(self):
  383. for mailing in self:
  384. if mailing.schedule_type == 'now' or not mailing.schedule_date:
  385. mailing.schedule_date = False
  386. @api.depends('state', 'schedule_date', 'sent_date', 'next_departure')
  387. def _compute_calendar_date(self):
  388. for mailing in self:
  389. if mailing.state == 'done':
  390. mailing.calendar_date = mailing.sent_date
  391. elif mailing.state == 'in_queue':
  392. mailing.calendar_date = mailing.next_departure
  393. elif mailing.state == 'sending':
  394. mailing.calendar_date = fields.Datetime.now()
  395. else:
  396. mailing.calendar_date = False
  397. @api.depends('body_arch')
  398. def _compute_is_body_empty(self):
  399. for mailing in self:
  400. mailing.is_body_empty = tools.is_html_empty(mailing.body_arch)
  401. def _compute_mail_server_available(self):
  402. self.mail_server_available = self.env['ir.config_parameter'].sudo().get_param('mass_mailing.outgoing_mail_server')
  403. # Overrides of mail.render.mixin
  404. @api.depends('mailing_model_real')
  405. def _compute_render_model(self):
  406. for mailing in self:
  407. mailing.render_model = mailing.mailing_model_real
  408. @api.depends('mailing_type')
  409. def _compute_mailing_type_description(self):
  410. for mailing in self:
  411. mailing.mailing_type_description = dict(self._fields.get('mailing_type').selection).get(mailing.mailing_type)
  412. @api.depends(lambda self: self._get_ab_testing_description_modifying_fields())
  413. def _compute_ab_testing_description(self):
  414. mailing_ab_test = self.filtered('ab_testing_enabled')
  415. (self - mailing_ab_test).ab_testing_description = False
  416. for mailing in mailing_ab_test:
  417. mailing.ab_testing_description = self.env['ir.qweb']._render(
  418. 'mass_mailing.ab_testing_description',
  419. mailing._get_ab_testing_description_values()
  420. )
  421. def _get_ab_testing_description_modifying_fields(self):
  422. return ['ab_testing_enabled', 'ab_testing_pc', 'ab_testing_schedule_datetime', 'ab_testing_winner_selection', 'campaign_id']
  423. # ------------------------------------------------------
  424. # ORM
  425. # ------------------------------------------------------
  426. @api.model_create_multi
  427. def create(self, vals_list):
  428. ab_testing_cron = self.env.ref('mass_mailing.ir_cron_mass_mailing_ab_testing').sudo()
  429. for values in vals_list:
  430. if values.get('body_html'):
  431. values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
  432. if values.get('ab_testing_schedule_datetime'):
  433. at = fields.Datetime.from_string(values['ab_testing_schedule_datetime'])
  434. ab_testing_cron._trigger(at=at)
  435. mailings = super().create(vals_list)
  436. mailings._create_ab_testing_utm_campaigns()
  437. mailings._fix_attachment_ownership()
  438. return mailings
  439. def write(self, values):
  440. if values.get('body_html'):
  441. values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
  442. # If ab_testing is already enabled on a mailing and the campaign is removed, we raise a ValidationError
  443. if values.get('campaign_id') is False and any(mailing.ab_testing_enabled for mailing in self) and 'ab_testing_enabled' not in values:
  444. raise ValidationError(_("A campaign should be set when A/B test is enabled"))
  445. result = super(MassMailing, self).write(values)
  446. if values.get('ab_testing_enabled'):
  447. self._create_ab_testing_utm_campaigns()
  448. self._fix_attachment_ownership()
  449. if any(self.mapped('ab_testing_schedule_datetime')):
  450. schedule_date = min(m.ab_testing_schedule_datetime for m in self if m.ab_testing_schedule_datetime)
  451. ab_testing_cron = self.env.ref('mass_mailing.ir_cron_mass_mailing_ab_testing').sudo()
  452. ab_testing_cron._trigger(at=schedule_date)
  453. return result
  454. def _create_ab_testing_utm_campaigns(self):
  455. """ Creates the A/B test campaigns for the mailings that do not have campaign set already """
  456. campaign_vals = [
  457. mailing._get_default_ab_testing_campaign_values()
  458. for mailing in self.filtered(lambda mailing: mailing.ab_testing_enabled and not mailing.campaign_id)
  459. ]
  460. return self.env['utm.campaign'].create(campaign_vals)
  461. def _fix_attachment_ownership(self):
  462. for record in self:
  463. record.attachment_ids.write({'res_model': record._name, 'res_id': record.id})
  464. return self
  465. @api.returns('self', lambda value: value.id)
  466. def copy(self, default=None):
  467. self.ensure_one()
  468. default = dict(default or {}, contact_list_ids=self.contact_list_ids.ids)
  469. if self.mail_server_id and not self.mail_server_id.active:
  470. default['mail_server_id'] = self._get_default_mail_server_id()
  471. return super(MassMailing, self).copy(default=default)
  472. def _group_expand_states(self, states, domain, order):
  473. return [key for key, val in type(self).state.selection]
  474. # ------------------------------------------------------
  475. # ACTIONS
  476. # ------------------------------------------------------
  477. def action_set_favorite(self):
  478. """Add the current mailing in the favorites list."""
  479. self.favorite = True
  480. return {
  481. 'type': 'ir.actions.client',
  482. 'tag': 'display_notification',
  483. 'params': {
  484. 'message': _(
  485. 'Design added to the %s Templates!',
  486. ', '.join(self.mapped('mailing_model_id.name')),
  487. ),
  488. 'next': {'type': 'ir.actions.act_window_close'},
  489. 'sticky': False,
  490. 'type': 'info',
  491. }
  492. }
  493. def action_remove_favorite(self):
  494. """Remove the current mailing from the favorites list."""
  495. self.favorite = False
  496. return {
  497. 'type': 'ir.actions.client',
  498. 'tag': 'display_notification',
  499. 'params': {
  500. 'message': _(
  501. 'Design removed from the %s Templates!',
  502. ', '.join(self.mapped('mailing_model_id.name')),
  503. ),
  504. 'next': {'type': 'ir.actions.act_window_close'},
  505. 'sticky': False,
  506. 'type': 'info',
  507. }
  508. }
  509. def action_duplicate(self):
  510. self.ensure_one()
  511. mass_mailing_copy = self.copy()
  512. if mass_mailing_copy:
  513. context = dict(self.env.context)
  514. context['form_view_initial_mode'] = 'edit'
  515. action = {
  516. 'type': 'ir.actions.act_window',
  517. 'view_mode': 'form',
  518. 'res_model': 'mailing.mailing',
  519. 'res_id': mass_mailing_copy.id,
  520. 'context': context,
  521. }
  522. if self.mailing_type == 'mail':
  523. action['views'] = [
  524. (self.env.ref('mass_mailing.mailing_mailing_view_form_full_width').id, 'form'),
  525. ]
  526. return action
  527. return False
  528. def action_test(self):
  529. self.ensure_one()
  530. ctx = dict(self.env.context, default_mass_mailing_id=self.id, dialog_size='medium')
  531. return {
  532. 'name': _('Test Mailing'),
  533. 'type': 'ir.actions.act_window',
  534. 'view_mode': 'form',
  535. 'res_model': 'mailing.mailing.test',
  536. 'target': 'new',
  537. 'context': ctx,
  538. }
  539. def action_launch(self):
  540. self.write({'schedule_type': 'now'})
  541. return self.action_put_in_queue()
  542. def action_schedule(self):
  543. self.ensure_one()
  544. if self.schedule_date and self.schedule_date > fields.Datetime.now():
  545. return self.action_put_in_queue()
  546. action = self.env["ir.actions.actions"]._for_xml_id("mass_mailing.mailing_mailing_schedule_date_action")
  547. action['context'] = dict(self.env.context, default_mass_mailing_id=self.id, dialog_size='medium')
  548. return action
  549. def action_put_in_queue(self):
  550. self.write({'state': 'in_queue'})
  551. cron = self.env.ref('mass_mailing.ir_cron_mass_mailing_queue')
  552. cron._trigger(
  553. schedule_date or fields.Datetime.now()
  554. for schedule_date in self.mapped('schedule_date')
  555. )
  556. def action_cancel(self):
  557. self.write({'state': 'draft', 'schedule_date': False, 'schedule_type': 'now', 'next_departure': False})
  558. def action_retry_failed(self):
  559. failed_mails = self.env['mail.mail'].sudo().search([
  560. ('mailing_id', 'in', self.ids),
  561. ('state', '=', 'exception')
  562. ])
  563. failed_mails.mapped('mailing_trace_ids').unlink()
  564. failed_mails.unlink()
  565. self.action_put_in_queue()
  566. def action_view_traces_scheduled(self):
  567. return self._action_view_traces_filtered('scheduled')
  568. def action_view_traces_canceled(self):
  569. return self._action_view_traces_filtered('canceled')
  570. def action_view_traces_failed(self):
  571. return self._action_view_traces_filtered('failed')
  572. def action_view_traces_sent(self):
  573. return self._action_view_traces_filtered('sent')
  574. def _action_view_traces_filtered(self, view_filter):
  575. action = self.env["ir.actions.actions"]._for_xml_id("mass_mailing.mailing_trace_action")
  576. action['name'] = _('Sent Mailings')
  577. action['context'] = {'search_default_mass_mailing_id': self.id,}
  578. filter_key = 'search_default_filter_%s' % (view_filter)
  579. action['context'][filter_key] = True
  580. action['views'] = [
  581. (self.env.ref('mass_mailing.mailing_trace_view_tree_mail').id, 'tree'),
  582. (self.env.ref('mass_mailing.mailing_trace_view_form').id, 'form')
  583. ]
  584. return action
  585. def action_view_clicked(self):
  586. model_name = self.env['ir.model']._get('link.tracker').display_name
  587. recipient = self.env['ir.model']._get(self.mailing_model_real).display_name
  588. helper_header = _("No %s clicked your mailing yet!", recipient)
  589. helper_message = _("Link Trackers will measure how many times each link is clicked as well as "
  590. "the proportion of %s who clicked at least once in your mailing.", recipient)
  591. return {
  592. 'name': model_name,
  593. 'type': 'ir.actions.act_window',
  594. 'view_mode': 'tree',
  595. 'res_model': 'link.tracker',
  596. 'domain': [('mass_mailing_id', '=', self.id)],
  597. 'help': Markup('<p class="o_view_nocontent_smiling_face">%s</p><p>%s</p>') % (
  598. helper_header, helper_message,
  599. ),
  600. 'context': dict(self._context, create=False)
  601. }
  602. def action_view_opened(self):
  603. return self._action_view_documents_filtered('open')
  604. def action_view_replied(self):
  605. return self._action_view_documents_filtered('reply')
  606. def action_view_bounced(self):
  607. return self._action_view_documents_filtered('bounce')
  608. def action_view_delivered(self):
  609. return self._action_view_documents_filtered('delivered')
  610. def _action_view_documents_filtered(self, view_filter):
  611. model_name = self.env['ir.model']._get(self.mailing_model_real).display_name
  612. helper_header = None
  613. helper_message = None
  614. if view_filter == 'reply':
  615. found_traces = self.mailing_trace_ids.filtered(lambda trace: trace.trace_status == view_filter)
  616. helper_header = _("No %s replied to your mailing yet!", model_name)
  617. helper_message = _("To track how many replies this mailing gets, make sure "
  618. "its reply-to address belongs to this database.")
  619. elif view_filter == 'bounce':
  620. found_traces = self.mailing_trace_ids.filtered(lambda trace: trace.trace_status == view_filter)
  621. helper_header = _("No %s address bounced yet!", model_name)
  622. helper_message = _("Bounce happens when a mailing cannot be delivered (fake address, "
  623. "server issues, ...). Check each record to see what went wrong.")
  624. elif view_filter == 'open':
  625. found_traces = self.mailing_trace_ids.filtered(lambda trace: trace.trace_status in ('open', 'reply'))
  626. helper_header = _("No %s opened your mailing yet!", model_name)
  627. helper_message = _("Come back once your mailing has been sent to track who opened your mailing.")
  628. elif view_filter == 'delivered':
  629. found_traces = self.mailing_trace_ids.filtered(lambda trace: trace.trace_status in ('sent', 'open', 'reply'))
  630. helper_header = _("No %s received your mailing yet!", model_name)
  631. helper_message = _("Wait until your mailing has been sent to check how many recipients you managed to reach.")
  632. elif view_filter == 'sent':
  633. found_traces = self.mailing_trace_ids.filtered(lambda trace: trace.sent_datetime)
  634. else:
  635. found_traces = self.env['mailing.trace']
  636. res_ids = found_traces.mapped('res_id')
  637. action = {
  638. 'name': model_name,
  639. 'type': 'ir.actions.act_window',
  640. 'view_mode': 'tree',
  641. 'res_model': self.mailing_model_real,
  642. 'domain': [('id', 'in', res_ids)],
  643. 'context': dict(self._context, create=False),
  644. }
  645. if helper_header and helper_message:
  646. action['help'] = Markup('<p class="o_view_nocontent_smiling_face">%s</p><p>%s</p>') % (
  647. helper_header, helper_message,
  648. ),
  649. return action
  650. def action_view_mailing_contacts(self):
  651. """Show the mailing contacts who are in a mailing list selected for this mailing."""
  652. self.ensure_one()
  653. action = self.env['ir.actions.actions']._for_xml_id('mass_mailing.action_view_mass_mailing_contacts')
  654. if self.contact_list_ids:
  655. action['context'] = {
  656. 'default_mailing_list_ids': self.contact_list_ids[0].ids,
  657. 'default_subscription_list_ids': [(0, 0, {'list_id': self.contact_list_ids[0].id})],
  658. }
  659. action['domain'] = [('list_ids', 'in', self.contact_list_ids.ids)]
  660. return action
  661. @api.model
  662. def action_fetch_favorites(self, extra_domain=None):
  663. """Return all mailings set as favorite and skip mailings with empty body.
  664. Return archived mailing templates as well, so the user can archive the templates
  665. while keeping using it, without cluttering the Kanban view if they're a lot of
  666. templates.
  667. """
  668. domain = [('favorite', '=', True)]
  669. if extra_domain:
  670. domain = expression.AND([domain, extra_domain])
  671. values_list = self.with_context(active_test=False).search_read(
  672. domain=domain,
  673. fields=['id', 'subject', 'body_arch', 'user_id', 'mailing_model_id'],
  674. order='favorite_date DESC',
  675. )
  676. values_list = [
  677. values for values in values_list
  678. if not tools.is_html_empty(values['body_arch'])
  679. ]
  680. # You see first the mailings without responsible, then your mailings and then the others
  681. values_list.sort(
  682. key=lambda values:
  683. values['user_id'][0] != self.env.user.id if values['user_id'] else -1
  684. )
  685. return values_list
  686. def update_opt_out(self, email, list_ids, value):
  687. if len(list_ids) > 0:
  688. model = self.env['mailing.contact'].with_context(active_test=False)
  689. records = model.search([('email_normalized', '=', tools.email_normalize(email))])
  690. opt_out_records = self.env['mailing.contact.subscription'].search([
  691. ('contact_id', 'in', records.ids),
  692. ('list_id', 'in', list_ids),
  693. ('opt_out', '!=', value)
  694. ])
  695. opt_out_records.write({'opt_out': value})
  696. message = _('The recipient <strong>unsubscribed from %s</strong> mailing list(s)') \
  697. if value else _('The recipient <strong>subscribed to %s</strong> mailing list(s)')
  698. for record in records:
  699. # filter the list_id by record
  700. record_lists = opt_out_records.filtered(lambda rec: rec.contact_id.id == record.id)
  701. if len(record_lists) > 0:
  702. record.sudo().message_post(body=message % ', '.join(str(list.name) for list in record_lists.mapped('list_id')))
  703. # ------------------------------------------------------
  704. # A/B Test
  705. # ------------------------------------------------------
  706. def action_compare_versions(self):
  707. self.ensure_one()
  708. if not self.campaign_id:
  709. raise ValueError(_("No mailing campaign has been found"))
  710. action = {
  711. 'name': _('A/B Tests'),
  712. 'type': 'ir.actions.act_window',
  713. 'view_mode': 'tree,kanban,form,calendar,graph',
  714. 'res_model': 'mailing.mailing',
  715. 'domain': [('campaign_id', '=', self.campaign_id.id), ('ab_testing_enabled', '=', True), ('mailing_type', '=', self.mailing_type)],
  716. }
  717. if self.mailing_type == 'mail':
  718. action['views'] = [
  719. (False, 'tree'),
  720. (False, 'kanban'),
  721. (self.env.ref('mass_mailing.mailing_mailing_view_form_full_width').id, 'form'),
  722. (False, 'calendar'),
  723. (False, 'graph'),
  724. ]
  725. return action
  726. def action_send_winner_mailing(self):
  727. """Send the winner mailing based on the winner selection field.
  728. This action is used in 2 cases:
  729. - When the user clicks on a button to send the winner mailing. There is only one mailing in self
  730. - When the cron is executed to send winner mailing based on the A/B testing schedule datetime. In this
  731. case 'self' contains all the mailing for the campaigns so we just need to take the first to determine the
  732. winner.
  733. If the winner mailing is computed automatically, we sudo the mailings of the campaign in order to sort correctly
  734. the mailings based on the selection that can be used with sub-modules like CRM and Sales
  735. """
  736. if len(self.campaign_id) != 1:
  737. raise ValueError(_("To send the winner mailing the same campaign should be used by the mailings"))
  738. if any(mailing.ab_testing_completed for mailing in self):
  739. raise ValueError(_("To send the winner mailing the campaign should not have been completed."))
  740. final_mailing = self[0]
  741. sorted_by = final_mailing._get_ab_testing_winner_selection()['value']
  742. if sorted_by != 'manual':
  743. ab_testing_mailings = final_mailing._get_ab_testing_siblings_mailings().sudo()
  744. selected_mailings = ab_testing_mailings.filtered(lambda m: m.state == 'done').sorted(sorted_by, reverse=True)
  745. if selected_mailings:
  746. final_mailing = selected_mailings[0]
  747. else:
  748. raise ValidationError(_("No mailing for this A/B testing campaign has been sent yet! Send one first and try again later."))
  749. return final_mailing.action_select_as_winner()
  750. def action_select_as_winner(self):
  751. self.ensure_one()
  752. if not self.ab_testing_enabled:
  753. raise ValueError(_("A/B test option has not been enabled"))
  754. self.campaign_id.write({
  755. 'ab_testing_completed': True,
  756. })
  757. final_mailing = self.copy({
  758. 'ab_testing_pc': 100,
  759. })
  760. final_mailing.action_launch()
  761. action = self.env['ir.actions.act_window']._for_xml_id('mass_mailing.action_ab_testing_open_winner_mailing')
  762. action['res_id'] = final_mailing.id
  763. if self.mailing_type == 'mail':
  764. action['views'] = [
  765. (self.env.ref('mass_mailing.mailing_mailing_view_form_full_width').id, 'form'),
  766. ]
  767. return action
  768. def _get_ab_testing_description_values(self):
  769. self.ensure_one()
  770. other_ab_testing_mailings = self._get_ab_testing_siblings_mailings().filtered(lambda m: m.id != self.id)
  771. other_ab_testing_pc = sum([mailing.ab_testing_pc for mailing in other_ab_testing_mailings])
  772. return {
  773. 'mailing': self,
  774. 'ab_testing_winner_selection_description': self._get_ab_testing_winner_selection()['description'],
  775. 'other_ab_testing_pc': other_ab_testing_pc,
  776. 'remaining_ab_testing_pc': 100 - (other_ab_testing_pc + self.ab_testing_pc),
  777. }
  778. def _get_ab_testing_siblings_mailings(self):
  779. return self.campaign_id.mailing_mail_ids.filtered(lambda m: m.ab_testing_enabled)
  780. def _get_ab_testing_winner_selection(self):
  781. ab_testing_winner_selection_description = dict(
  782. self._fields.get('ab_testing_winner_selection').related_field.selection
  783. ).get(self.ab_testing_winner_selection)
  784. return {
  785. 'value': self.ab_testing_winner_selection,
  786. 'description': ab_testing_winner_selection_description,
  787. }
  788. def _get_default_ab_testing_campaign_values(self, values=None):
  789. values = values or dict()
  790. return {
  791. 'ab_testing_schedule_datetime': values.get('ab_testing_schedule_datetime') or self.ab_testing_schedule_datetime,
  792. 'ab_testing_winner_selection': values.get('ab_testing_winner_selection') or self.ab_testing_winner_selection,
  793. 'mailing_mail_ids': self.ids,
  794. 'name': _('A/B Test: %s', values.get('subject') or self.subject or fields.Datetime.now()),
  795. 'user_id': values.get('user_id') or self.user_id.id or self.env.user.id,
  796. }
  797. # ------------------------------------------------------
  798. # Email Sending
  799. # ------------------------------------------------------
  800. def _get_opt_out_list(self):
  801. """ Give list of opt-outed emails, depending on specific model-based
  802. computation if available.
  803. :return list: opt-outed emails, preferably normalized (aka not records)
  804. """
  805. self.ensure_one()
  806. opt_out = {}
  807. target = self.env[self.mailing_model_real]
  808. if hasattr(self.env[self.mailing_model_name], '_mailing_get_opt_out_list'):
  809. opt_out = self.env[self.mailing_model_name]._mailing_get_opt_out_list(self)
  810. _logger.info(
  811. "Mass-mailing %s targets %s, blacklist: %s emails",
  812. self, target._name, len(opt_out))
  813. else:
  814. _logger.info("Mass-mailing %s targets %s, no opt out list available", self, target._name)
  815. return opt_out
  816. def _get_link_tracker_values(self):
  817. self.ensure_one()
  818. vals = {'mass_mailing_id': self.id}
  819. if self.campaign_id:
  820. vals['campaign_id'] = self.campaign_id.id
  821. if self.source_id:
  822. vals['source_id'] = self.source_id.id
  823. if self.medium_id:
  824. vals['medium_id'] = self.medium_id.id
  825. return vals
  826. def _get_seen_list(self):
  827. """Returns a set of emails already targeted by current mailing/campaign (no duplicates)"""
  828. self.ensure_one()
  829. target = self.env[self.mailing_model_real]
  830. query = """
  831. SELECT s.email
  832. FROM mailing_trace s
  833. JOIN %(target)s t ON (s.res_id = t.id)
  834. %(join_domain)s
  835. WHERE s.email IS NOT NULL
  836. %(where_domain)s
  837. """
  838. if self.ab_testing_enabled:
  839. query += """
  840. AND s.campaign_id = %%(mailing_campaign_id)s;
  841. """
  842. else:
  843. query += """
  844. AND s.mass_mailing_id = %%(mailing_id)s
  845. AND s.model = %%(target_model)s;
  846. """
  847. join_domain, where_domain = self._get_seen_list_extra()
  848. query = query % {'target': target._table, 'join_domain': join_domain, 'where_domain': where_domain}
  849. params = {'mailing_id': self.id, 'mailing_campaign_id': self.campaign_id.id, 'target_model': self.mailing_model_real}
  850. self._cr.execute(query, params)
  851. seen_list = set(m[0] for m in self._cr.fetchall())
  852. _logger.info(
  853. "Mass-mailing %s has already reached %s %s emails", self, len(seen_list), target._name)
  854. return seen_list
  855. def _get_seen_list_extra(self):
  856. return ('', '')
  857. def _get_mass_mailing_context(self):
  858. """Returns extra context items with pre-filled blacklist and seen list for massmailing"""
  859. return {
  860. 'post_convert_links': self._get_link_tracker_values(),
  861. }
  862. def _get_recipients(self):
  863. mailing_domain = self._parse_mailing_domain()
  864. res_ids = self.env[self.mailing_model_real].search(mailing_domain).ids
  865. # randomly choose a fragment
  866. if self.ab_testing_enabled and self.ab_testing_pc < 100:
  867. contact_nbr = self.env[self.mailing_model_real].search_count(mailing_domain)
  868. topick = 0
  869. if contact_nbr:
  870. topick = max(int(contact_nbr / 100.0 * self.ab_testing_pc), 1)
  871. if self.campaign_id and self.ab_testing_enabled:
  872. already_mailed = self.campaign_id._get_mailing_recipients()[self.campaign_id.id]
  873. else:
  874. already_mailed = set([])
  875. remaining = set(res_ids).difference(already_mailed)
  876. if topick > len(remaining) or (len(remaining) > 0 and topick == 0):
  877. topick = len(remaining)
  878. res_ids = random.sample(sorted(remaining), topick)
  879. return res_ids
  880. def _get_remaining_recipients(self):
  881. res_ids = self._get_recipients()
  882. trace_domain = [('model', '=', self.mailing_model_real)]
  883. if self.ab_testing_enabled and self.ab_testing_pc == 100:
  884. trace_domain = expression.AND([trace_domain, [('mass_mailing_id', 'in', self._get_ab_testing_siblings_mailings().ids)]])
  885. else:
  886. trace_domain = expression.AND([trace_domain, [
  887. ('res_id', 'in', res_ids),
  888. ('mass_mailing_id', '=', self.id),
  889. ]])
  890. already_mailed = self.env['mailing.trace'].search_read(trace_domain, ['res_id'])
  891. done_res_ids = {record['res_id'] for record in already_mailed}
  892. return [rid for rid in res_ids if rid not in done_res_ids]
  893. def _get_unsubscribe_url(self, email_to, res_id):
  894. url = werkzeug.urls.url_join(
  895. self.get_base_url(), 'mail/mailing/%(mailing_id)s/unsubscribe?%(params)s' % {
  896. 'mailing_id': self.id,
  897. 'params': werkzeug.urls.url_encode({
  898. 'res_id': res_id,
  899. 'email': email_to,
  900. 'token': self._unsubscribe_token(res_id, email_to),
  901. }),
  902. }
  903. )
  904. return url
  905. def _get_view_url(self, email_to, res_id):
  906. url = werkzeug.urls.url_join(
  907. self.get_base_url(), 'mailing/%(mailing_id)s/view?%(params)s' % {
  908. 'mailing_id': self.id,
  909. 'params': werkzeug.urls.url_encode({
  910. 'res_id': res_id,
  911. 'email': email_to,
  912. 'token': self._unsubscribe_token(res_id, email_to),
  913. }),
  914. }
  915. )
  916. return url
  917. def action_send_mail(self, res_ids=None):
  918. author_id = self.env.user.partner_id.id
  919. # If no recipient is passed, we don't want to use the recipients of the first
  920. # mailing for all the others
  921. initial_res_ids = res_ids
  922. for mailing in self:
  923. if not initial_res_ids:
  924. res_ids = mailing._get_remaining_recipients()
  925. if not res_ids:
  926. raise UserError(_('There are no recipients selected.'))
  927. composer_values = {
  928. 'author_id': author_id,
  929. 'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids],
  930. 'body': mailing._prepend_preview(mailing.body_html, mailing.preview),
  931. 'subject': mailing.subject,
  932. 'model': mailing.mailing_model_real,
  933. 'email_from': mailing.email_from,
  934. 'record_name': False,
  935. 'composition_mode': 'mass_mail',
  936. 'mass_mailing_id': mailing.id,
  937. 'mailing_list_ids': [(4, l.id) for l in mailing.contact_list_ids],
  938. 'reply_to_force_new': mailing.reply_to_mode == 'new',
  939. 'template_id': None,
  940. 'mail_server_id': mailing.mail_server_id.id,
  941. }
  942. if mailing.reply_to_mode == 'new':
  943. composer_values['reply_to'] = mailing.reply_to
  944. composer = self.env['mail.compose.message'].with_context(active_ids=res_ids).create(composer_values)
  945. extra_context = mailing._get_mass_mailing_context()
  946. composer = composer.with_context(active_ids=res_ids, **extra_context)
  947. # auto-commit except in testing mode
  948. auto_commit = not getattr(threading.current_thread(), 'testing', False)
  949. composer._action_send_mail(auto_commit=auto_commit)
  950. mailing.write({
  951. 'state': 'done',
  952. 'sent_date': fields.Datetime.now(),
  953. # send the KPI mail only if it's the first sending
  954. 'kpi_mail_required': not mailing.sent_date,
  955. })
  956. return True
  957. def convert_links(self):
  958. res = {}
  959. for mass_mailing in self:
  960. html = mass_mailing.body_html if mass_mailing.body_html else ''
  961. vals = {'mass_mailing_id': mass_mailing.id}
  962. if mass_mailing.campaign_id:
  963. vals['campaign_id'] = mass_mailing.campaign_id.id
  964. if mass_mailing.source_id:
  965. vals['source_id'] = mass_mailing.source_id.id
  966. if mass_mailing.medium_id:
  967. vals['medium_id'] = mass_mailing.medium_id.id
  968. res[mass_mailing.id] = mass_mailing._shorten_links(html, vals, blacklist=['/unsubscribe_from_list', '/view'])
  969. return res
  970. @api.model
  971. def _process_mass_mailing_queue(self):
  972. mass_mailings = self.search([('state', 'in', ('in_queue', 'sending')), '|', ('schedule_date', '<', fields.Datetime.now()), ('schedule_date', '=', False)])
  973. for mass_mailing in mass_mailings:
  974. user = mass_mailing.write_uid or self.env.user
  975. mass_mailing = mass_mailing.with_context(**user.with_user(user).context_get())
  976. if len(mass_mailing._get_remaining_recipients()) > 0:
  977. mass_mailing.state = 'sending'
  978. mass_mailing.action_send_mail()
  979. else:
  980. mass_mailing.write({
  981. 'state': 'done',
  982. 'sent_date': fields.Datetime.now(),
  983. # send the KPI mail only if it's the first sending
  984. 'kpi_mail_required': not mass_mailing.sent_date,
  985. })
  986. if self.env['ir.config_parameter'].sudo().get_param('mass_mailing.mass_mailing_reports'):
  987. mailings = self.env['mailing.mailing'].search([
  988. ('kpi_mail_required', '=', True),
  989. ('state', '=', 'done'),
  990. ('sent_date', '<=', fields.Datetime.now() - relativedelta(days=1)),
  991. ('sent_date', '>=', fields.Datetime.now() - relativedelta(days=5)),
  992. ])
  993. if mailings:
  994. mailings._action_send_statistics()
  995. # ------------------------------------------------------
  996. # STATISTICS
  997. # ------------------------------------------------------
  998. def _action_send_statistics(self):
  999. """Send an email to the responsible of each finished mailing with the statistics."""
  1000. self.kpi_mail_required = False
  1001. mails_sudo = self.env['mail.mail'].sudo()
  1002. for mailing in self:
  1003. if mailing.user_id:
  1004. mailing = mailing.with_user(mailing.user_id).with_context(
  1005. lang=mailing.user_id.lang or self._context.get('lang')
  1006. )
  1007. mailing_type = mailing._get_pretty_mailing_type()
  1008. mail_user = mailing.user_id or self.env.user
  1009. mail_company = mail_user.company_id
  1010. link_trackers = self.env['link.tracker'].search(
  1011. [('mass_mailing_id', '=', mailing.id)]
  1012. ).sorted('count', reverse=True)
  1013. link_trackers_body = self.env['ir.qweb']._render(
  1014. 'mass_mailing.mass_mailing_kpi_link_trackers',
  1015. {
  1016. 'object': mailing,
  1017. 'link_trackers': link_trackers,
  1018. 'mailing_type': mailing_type,
  1019. },
  1020. )
  1021. rendering_data = {
  1022. 'body': tools.html_sanitize(link_trackers_body),
  1023. 'company': mail_company,
  1024. 'user': mail_user,
  1025. 'display_mobile_banner': True,
  1026. ** mailing._prepare_statistics_email_values(),
  1027. }
  1028. if mail_user.has_group('mass_mailing.group_mass_mailing_user'):
  1029. rendering_data['mailing_report_token'] = self._get_unsubscribe_token(mail_user.id)
  1030. rendering_data['user_id'] = mail_user.id
  1031. rendered_body = self.env['ir.qweb']._render(
  1032. 'digest.digest_mail_main',
  1033. rendering_data
  1034. )
  1035. full_mail = self.env['mail.render.mixin']._render_encapsulate(
  1036. 'digest.digest_mail_layout',
  1037. rendered_body,
  1038. )
  1039. mail_values = {
  1040. 'auto_delete': True,
  1041. 'author_id': mail_user.partner_id.id,
  1042. 'email_from': mail_user.email_formatted,
  1043. 'email_to': mail_user.email_formatted,
  1044. 'body_html': full_mail,
  1045. 'reply_to': mail_company.email_formatted or mail_user.email_formatted,
  1046. 'state': 'outgoing',
  1047. 'subject': _('24H Stats of %(mailing_type)s "%(mailing_name)s"',
  1048. mailing_type=mailing._get_pretty_mailing_type(),
  1049. mailing_name=mailing.subject
  1050. ),
  1051. }
  1052. mails_sudo += self.env['mail.mail'].sudo().create(mail_values)
  1053. return mails_sudo
  1054. def _prepare_statistics_email_values(self):
  1055. """Return some statistics that will be displayed in the mailing statistics email.
  1056. Each item in the returned list will be displayed as a table, with a title and
  1057. 1, 2 or 3 columns.
  1058. """
  1059. self.ensure_one()
  1060. mailing_type = self._get_pretty_mailing_type()
  1061. kpi = {}
  1062. if self.mailing_type == 'mail':
  1063. kpi = {
  1064. 'kpi_fullname': _('Engagement on %(expected)i %(mailing_type)s Sent',
  1065. expected=self.expected,
  1066. mailing_type=mailing_type
  1067. ),
  1068. 'kpi_col1': {
  1069. 'value': f'{self.received_ratio}%',
  1070. 'col_subtitle': _('RECEIVED (%i)', self.delivered),
  1071. },
  1072. 'kpi_col2': {
  1073. 'value': f'{self.opened_ratio}%',
  1074. 'col_subtitle': _('OPENED (%i)', self.opened),
  1075. },
  1076. 'kpi_col3': {
  1077. 'value': f'{self.replied_ratio}%',
  1078. 'col_subtitle': _('REPLIED (%i)', self.replied),
  1079. },
  1080. 'kpi_action': None,
  1081. 'kpi_name': self.mailing_type,
  1082. }
  1083. random_tip = self.env['digest.tip'].search(
  1084. [('group_id.category_id', '=', self.env.ref('base.module_category_marketing_email_marketing').id)]
  1085. )
  1086. if random_tip:
  1087. random_tip = random.choice(random_tip).tip_description
  1088. formatted_date = tools.format_datetime(
  1089. self.env, self.sent_date, self.user_id.tz, 'MMM dd, YYYY', self.user_id.lang
  1090. ) if self.sent_date else False
  1091. web_base_url = self.get_base_url()
  1092. return {
  1093. 'title': _('24H Stats of %(mailing_type)s "%(mailing_name)s"',
  1094. mailing_type=mailing_type,
  1095. mailing_name=self.subject
  1096. ),
  1097. 'top_button_label': _('More Info'),
  1098. 'top_button_url': url_join(web_base_url, f'/web#id={self.id}&model=mailing.mailing&view_type=form'),
  1099. 'kpi_data': [
  1100. kpi,
  1101. {
  1102. 'kpi_fullname': _('Business Benefits on %(expected)i %(mailing_type)s Sent',
  1103. expected=self.expected,
  1104. mailing_type=mailing_type
  1105. ),
  1106. 'kpi_action': None,
  1107. 'kpi_col1': {},
  1108. 'kpi_col2': {},
  1109. 'kpi_col3': {},
  1110. 'kpi_name': 'trace',
  1111. },
  1112. ],
  1113. 'tips': [random_tip] if random_tip else False,
  1114. 'formatted_date': formatted_date,
  1115. }
  1116. def _get_pretty_mailing_type(self):
  1117. return _('Emails')
  1118. def _get_unsubscribe_token(self, user_id):
  1119. """Generate a secure hash for this user. It allows to opt out from
  1120. mailing reports while keeping some security in that process. """
  1121. return tools.hmac(self.env(su=True), 'mailing-report-deactivated', user_id)
  1122. # ------------------------------------------------------
  1123. # TOOLS
  1124. # ------------------------------------------------------
  1125. def _convert_inline_images_to_urls(self, body_html):
  1126. """
  1127. Find inline base64 encoded images, make an attachement out of
  1128. them and replace the inline image with an url to the attachement.
  1129. Find VML v:image elements, crop their source images, make an attachement
  1130. out of them and replace their source with an url to the attachement.
  1131. """
  1132. root = lxml.html.fromstring(body_html)
  1133. did_modify_body = False
  1134. conversion_info = [] # list of tuples (image: base64 image, node: lxml node, old_url: string or None))
  1135. with requests.Session() as session:
  1136. for node in root.iter(lxml.etree.Element, lxml.etree.Comment):
  1137. if node.tag == 'img':
  1138. # Convert base64 images in img tags to attachments.
  1139. match = image_re.match(node.attrib.get('src', ''))
  1140. if match:
  1141. image = match.group(2).encode() # base64 image as bytes
  1142. conversion_info.append((image, node, None))
  1143. elif 'base64' in (node.attrib.get('style') or ''):
  1144. # Convert base64 images in inline styles to attachments.
  1145. for match in re.findall(r'data:image/[A-Za-z]+;base64,.+?(?=&\#34;|\"|\'|&quot;|\))', node.attrib.get('style')):
  1146. image = re.sub(r'data:image/[A-Za-z]+;base64,', '', match).encode() # base64 image as bytes
  1147. conversion_info.append((image, node, match))
  1148. elif mso_re.match(node.text or ''):
  1149. # Convert base64 images (in img tags or inline styles) in mso comments to attachments.
  1150. base64_in_element_regex = re.compile(r"""
  1151. (?:(?!^)|<)[^<>]*?(data:image/[A-Za-z]+;base64,[^<]+?)(?=&\#34;|\"|'|&quot;|\))(?=[^<]+>)
  1152. """, re.VERBOSE)
  1153. for match in re.findall(base64_in_element_regex, node.text):
  1154. image = re.sub(r'data:image/[A-Za-z]+;base64,', '', match).encode() # base64 image as bytes
  1155. conversion_info.append((image, node, match))
  1156. # Crop VML images.
  1157. for match in re.findall(r'<v:image[^>]*>', node.text):
  1158. url = re.search(r'src=\s*\"([^\"]+)\"', match)[1]
  1159. # Make sure we have an absolute URL by adding a scheme and host if needed.
  1160. absolute_url = url if '//' in url else f"{self.get_base_url()}{url if url.startswith('/') else f'/{url}'}"
  1161. target_width_match = re.search(r'width:\s*([0-9\.]+)\s*px', match)
  1162. target_height_match = re.search(r'height:\s*([0-9\.]+)\s*px', match)
  1163. if target_width_match and target_height_match:
  1164. target_width = float(target_width_match[1])
  1165. target_height = float(target_height_match[1])
  1166. try:
  1167. image = self._get_image_by_url(absolute_url, session)
  1168. except (ImportValidationError, UnidentifiedImageError):
  1169. # Url invalid or doesn't resolve to a valid image.
  1170. # Note: We choose to ignore errors so as not to
  1171. # break the entire process just for one image's
  1172. # responsive cropping behavior).
  1173. pass
  1174. else:
  1175. image_processor = tools.ImageProcess(image)
  1176. image = image_processor.crop_resize(target_width, target_height, 0, 0)
  1177. conversion_info.append((base64.b64encode(image.source), node, url))
  1178. # Apply the changes.
  1179. urls = self._create_attachments_from_inline_images([image for (image, _, _) in conversion_info])
  1180. for ((image, node, old_url), new_url) in zip(conversion_info, urls):
  1181. did_modify_body = True
  1182. if node.tag == 'img':
  1183. node.attrib['src'] = new_url
  1184. elif 'base64' in (node.attrib.get('style') or ''):
  1185. node.attrib['style'] = node.attrib['style'].replace(old_url, new_url)
  1186. else:
  1187. node.text = node.text.replace(old_url, new_url)
  1188. if did_modify_body:
  1189. return lxml.html.tostring(root, encoding='unicode')
  1190. return body_html
  1191. def _create_attachments_from_inline_images(self, b64images):
  1192. if not b64images:
  1193. return []
  1194. attachments = self.env['ir.attachment'].create([{
  1195. 'datas': b64image,
  1196. 'name': f"cropped_image_mailing_{self.id}_{i}",
  1197. 'type': 'binary',} for i, b64image in enumerate(b64images)])
  1198. urls = []
  1199. for attachment in attachments:
  1200. attachment.generate_access_token()
  1201. urls.append('/web/image/%s?access_token=%s' % (attachment.id, attachment.access_token))
  1202. return urls
  1203. def _get_default_mailing_domain(self):
  1204. mailing_domain = []
  1205. if hasattr(self.env[self.mailing_model_name], '_mailing_get_default_domain'):
  1206. mailing_domain = self.env[self.mailing_model_name]._mailing_get_default_domain(self)
  1207. if self.mailing_type == 'mail' and 'is_blacklisted' in self.env[self.mailing_model_name]._fields:
  1208. mailing_domain = expression.AND([[('is_blacklisted', '=', False)], mailing_domain])
  1209. return mailing_domain
  1210. def _get_image_by_url(self, url, session):
  1211. maxsize = int(tools.config.get("import_image_maxbytes", DEFAULT_IMAGE_MAXBYTES))
  1212. _logger.debug("Trying to import image from URL: %s", url)
  1213. try:
  1214. response = session.get(url, timeout=int(tools.config.get("import_image_timeout", DEFAULT_IMAGE_TIMEOUT)))
  1215. response.raise_for_status()
  1216. if response.headers.get('Content-Length') and int(response.headers['Content-Length']) > maxsize:
  1217. raise ImportValidationError(
  1218. _("File size exceeds configured maximum (%s bytes)", maxsize)
  1219. )
  1220. content = bytearray()
  1221. for chunk in response.iter_content(DEFAULT_IMAGE_CHUNK_SIZE):
  1222. content += chunk
  1223. if len(content) > maxsize:
  1224. raise ImportValidationError(
  1225. _("File size exceeds configured maximum (%s bytes)", maxsize)
  1226. )
  1227. image = Image.open(io.BytesIO(content))
  1228. w, h = image.size
  1229. if w * h > 42e6:
  1230. raise ImportValidationError(
  1231. _("Image size excessive, imported images must be smaller than 42 million pixel")
  1232. )
  1233. return content
  1234. except Exception as e:
  1235. _logger.exception(e)
  1236. raise ImportValidationError(_("Could not retrieve URL: %s", url)) from e
  1237. def _parse_mailing_domain(self):
  1238. self.ensure_one()
  1239. try:
  1240. mailing_domain = literal_eval(self.mailing_domain)
  1241. except Exception:
  1242. mailing_domain = [('id', 'in', [])]
  1243. return mailing_domain
  1244. def _unsubscribe_token(self, res_id, email):
  1245. """Generate a secure hash for this mailing list and parameters.
  1246. This is appended to the unsubscription URL and then checked at
  1247. unsubscription time to ensure no malicious unsubscriptions are
  1248. performed.
  1249. :param int res_id:
  1250. ID of the resource that will be unsubscribed.
  1251. :param str email:
  1252. Email of the resource that will be unsubscribed.
  1253. """
  1254. secret = self.env["ir.config_parameter"].sudo().get_param("database.secret")
  1255. token = (self.env.cr.dbname, self.id, int(res_id), tools.ustr(email))
  1256. return hmac.new(secret.encode('utf-8'), repr(token).encode('utf-8'), hashlib.sha512).hexdigest()