Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1 @@
{"stat":{"id":8,"emailAddress":"test@test.com","ipAddress":{},"dateSent":"2015-08-26T01:34:37+00:00","isRead":true,"isFailed":false,"dateRead":"2015-08-26T01:35:53+00:00","retryCount":0,"source":"email","openCount":1,"lastOpened":"2015-08-26T01:35:53+00:00","sourceId":5,"trackingHash":"55dd17adace91","viewedInBrowser":false,"lead":{"id":26,"points":10,"color":"","fields":{"core":{"title":{"id":"1","label":"Title","alias":"title","type":"lookup","group":"core","value":""},"firstname":{"id":"2","label":"First Name","alias":"firstname","type":"text","group":"core","value":""},"lastname":{"id":"3","label":"Last Name","alias":"lastname","type":"text","group":"core","value":""},"company":{"id":"4","label":"Company","alias":"company","type":"lookup","group":"core","value":""},"position":{"id":"5","label":"Position","alias":"position","type":"text","group":"core","value":""},"email":{"id":"6","label":"Email","alias":"email","type":"email","group":"core","value":"test@test.com"},"phone":{"id":"7","label":"Phone","alias":"phone","type":"tel","group":"core","value":""},"mobile":{"id":"8","label":"Mobile","alias":"mobile","type":"tel","group":"core","value":""},"fax":{"id":"9","label":"Fax","alias":"fax","type":"text","group":"core","value":""},"address1":{"id":"10","label":"Address Line 1","alias":"address1","type":"text","group":"core","value":""},"address2":{"id":"11","label":"Address Line 2","alias":"address2","type":"text","group":"core","value":""},"city":{"id":"12","label":"City","alias":"city","type":"lookup","group":"core","value":""},"state":{"id":"13","label":"State","alias":"state","type":"region","group":"core","value":""},"zipcode":{"id":"14","label":"Zipcode","alias":"zipcode","type":"lookup","group":"core","value":""},"country":{"id":"15","label":"Country","alias":"country","type":"country","group":"core","value":""},"website":{"id":"16","label":"Website","alias":"website","type":"text","group":"core","value":""}},"social":{"twitter":{"id":"17","label":"Twitter","alias":"twitter","type":"text","group":"social","value":""},"facebook":{"id":"18","label":"Facebook","alias":"facebook","type":"text","group":"social","value":""},"skype":{"id":"20","label":"Skype","alias":"skype","type":"text","group":"social","value":""},"instagram":{"id":"21","label":"Instagram","alias":"instagram","type":"text","group":"social","value":""},"foursquare":{"id":"22","label":"Foursquare","alias":"foursquare","type":"text","group":"social","value":""}},"personal":[],"professional":[]}},"email":{"id":5,"name":"Email","subject":"Email","language":"en","category":null,"fromAddress":null,"fromName":null,"replyToAddress":null,"bccAddress":null,"publishUp":null,"publishDown":null,"readCount":1,"sentCount":3,"revision":1,"assetAttachments":[],"variantStartDate":null,"variantSentCount":0,"variantReadCount":0,"variantParent":null,"variantChildren":[]}}}

View File

@@ -0,0 +1,45 @@
/* EmailBundle */
.col-email-id {
width: 75px;
}
.email-builder .builder-panel .panel-body {
padding: 5px 0;
}
.table-bordered {
border-left: 0;
}
.table-bordered.email-list > thead > tr > th:first-child, .table-bordered.email-list > tbody > tr > td:first-child,
.table-bordered.email-template > thead > tr > th:first-child, .table-bordered.email-template > tbody > tr > td:first-child {
border-left: 0px;
}
.table-bordered.email-list > thead > tr > th:last-child, .table-bordered.email-list > tbody > tr > td:last-child,
.table-bordered.email-template > thead > tr > th:last-child, .table-bordered.email-template > tbody > tr > td:last-child {
border-right: 0px;
}
.email-filters {
}
.clickable-stat a { color: #fff; }
.clickable-stat a:hover { color: #fff; }
#emailGraphStats .spinner,
#reads-map-container .spinner{
text-align: center;
font-size: 32px;
}
.dynamic-content .nav-tabs {
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
flex-wrap: nowrap;
display: flex;
scrollbar-width: thin; /* Firefox */
}

View File

@@ -0,0 +1,170 @@
@media (min-width: 768px) {
.modal-dialog-heatmap {
width: 90%;
max-width: 1200px;
}
}
.modal-heatmap-close {
position: absolute;
top: 5px;
right: 20px;
color: #fff;
z-index: 1080;
float: none;
opacity: 0.75;
}
.modal-heatmap-close:hover,
.modal-heatmap-close:focus {
opacity: 1;
color: #fff;
}
.heatmap-iframe {
display: block;
width: 100%;
height: 600px;
border: 0;
margin: 0;
padding: 0;
}
@media (min-width: 768px) {
.heatmap-iframe {
height: 90vh;
}
}
body.heatmap-iframe-body a.heatmap-link {
position: relative;
z-index: 1010;
}
.heatmap-label {
position: absolute;
z-index: 1050;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.45);
}
.heatmap-label > p {
padding: 6px 6px 4px 6px !important;
margin: 0 !important;
font-family: "Open Sans", Helvetica, Arial, sans-serif !important;
font-size: 12px !important;
line-height: 1 !important;
color: #fff !important;
}
.heatmap-legend {
display: none;
position: absolute;
z-index: 1060;
right: 20px;
top: 30px;
color: #fff;
padding: 15px;
background-color: rgba(0, 0, 0, 0.85);
border-radius: 4px;
}
@media (min-width: 768px) {
.heatmap-legend {
display: block;
}
}
.heatmap-legend a {
color: rgba(255, 255, 255, 0.6);
}
.heatmap-legend a.active,
.heatmap-legend a:focus,
.heatmap-legend a:hover {
color: rgba(255, 255, 255, 1);
text-decoration: underline;
}
.heatmap-legend > p:nth-child(1),
.heatmap-legend > p:nth-child(3) {
color: #cccccc;
font-size: 18px;
line-height: 1.1;
}
.heatmap-legend > p:nth-child(1) {
margin-top: 0;
}
.heatmap-legend > p:nth-child(2) {
color: #fff;
font-size: 32px;
margin-bottom: 20px;
}
.heatmap-legend > p:nth-child(3) {
margin-bottom: 5px;
}
.heatmap-scale {
display: flex;
}
.heatmap-scale-header,
.heatmap-scale-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
font-size: 12px;
}
.heatmap-scale-footer {
padding-bottom: 0;
}
.heatmap-scale-header > div,
.heatmap-scale-footer > div {
}
.heatmap-scale-bar {
position: relative;
width: 47px;
height: 16px;
background: #fff;
}
.heatmap-scale-bar:nth-child(1) {
background: linear-gradient(to right, #2c3bb6, #0a85ff);
border-radius: 2px 0 0 2px;
}
.heatmap-scale-bar:nth-child(2) {
background: linear-gradient(to right, #0a85ff, #f0df42);
}
.heatmap-scale-bar:nth-child(3) {
background: linear-gradient(to right, #f0df42, #f8c344);
}
.heatmap-scale-bar:nth-child(4) {
background: linear-gradient(to right, #f8c344, #ff843a);
}
.heatmap-scale-bar:nth-child(5) {
background: linear-gradient(to right, #ff843a, #f83834);
border-radius: 0 2px 2px 0;
}
.heatmap-scale-bar:nth-child(-n+4):after {
content: "";
display: block;
width: 1px;
height: 24px;
background-color: rgba(255, 255, 255, 0.8);
position: absolute;
top: -4px;
right: -1px;
z-index: 1;
}

View File

@@ -0,0 +1,35 @@
//EmailBundle (Copied from app/bundles/LeadBundle/Assets/js/lead.js)
Mautic.emailBatchSubmit = function() {
if (Mautic.batchActionPrecheck("")) {
if (mQuery('#email_batch_newCategory').val()) {
const $emailBatchIds = mQuery('#email_batch_ids');
if ($emailBatchIds.length) {
$emailBatchIds.val(Mautic.getCheckedListIds(false, true));
}
return true;
}
}
return false;
};
function setCategory(id, newCategory) {
const tr = document.querySelector("#row_email_" + id);
const div = tr.querySelector("div.d-flex.ai-center.gap-xs");
const span = div.querySelector("span");
div.textContent = newCategory.name;
span.style = "background: #" + newCategory.color + ";"
div.prepend(span);
}
Mautic.emailBatchSubmitCallback = function( response ) {
mQuery('#MauticSharedModal').modal('hide');
console.log("Received: " + JSON.stringify(response));
response.affected.forEach( function(id){
setCategory(id, response.newCategory);
});
}

View File

@@ -0,0 +1,68 @@
Mautic.testMonitoredEmailServerConnection = function(mailbox) {
var data = {
host: mQuery('#config_emailconfig_monitored_email_' + mailbox + '_host').val(),
port: mQuery('#config_emailconfig_monitored_email_' + mailbox + '_port').val(),
encryption: mQuery('#config_emailconfig_monitored_email_' + mailbox + '_encryption').val(),
user: mQuery('#config_emailconfig_monitored_email_' + mailbox + '_user').val(),
password: mQuery('#config_emailconfig_monitored_email_' + mailbox + '_password').val(),
mailbox: mailbox
};
var abortCall = false;
if (!data.host) {
mQuery('#config_emailconfig_monitored_email_' + mailbox + '_host').parent().addClass('has-error');
abortCall = true;
} else {
mQuery('#config_emailconfig_monitored_email_' + mailbox + '_host').parent().removeClass('has-error');
}
if (!data.port) {
mQuery('#config_emailconfig_monitored_email_' + mailbox + '_port').parent().addClass('has-error');
abortCall = true;
} else {
mQuery('#config_emailconfig_monitored_email_' + mailbox + '_port').parent().removeClass('has-error');
}
if (abortCall) {
return;
}
mQuery('#' + mailbox + 'TestButtonContainer .ri-loader-3-line').removeClass('hide');
Mautic.ajaxActionRequest('email:testMonitoredEmailServerConnection', data, function(response) {
var theClass = (response.success) ? 'has-success' : 'has-error';
var theMessage = response.message;
mQuery('#' + mailbox + 'TestButtonContainer').removeClass('has-success has-error').addClass(theClass);
mQuery('#' + mailbox + 'TestButtonContainer .help-block').html(theMessage);
mQuery('#' + mailbox + 'TestButtonContainer .ri-loader-3-line').addClass('hide');
if (response.folders) {
if (mailbox == 'general') {
// Update applicable folders
mQuery('select[data-imap-folders]').each(
function(index) {
var thisMailbox = mQuery(this).data('imap-folders');
if (mQuery('#config_emailconfig_monitored_email_' + thisMailbox + '_override_settings_0').is(':checked')) {
var folder = '#config_emailconfig_monitored_email_' + thisMailbox + '_folder';
var curVal = mQuery(folder).val();
mQuery(folder).html(response.folders);
mQuery(folder).val(curVal);
mQuery(folder).trigger('chosen:updated');
}
}
);
} else {
// Find and update folder lists
var folder = '#config_emailconfig_monitored_email_' + mailbox + '_folder';
var curVal = mQuery(folder).val();
mQuery(folder).html(response.folders);
mQuery(folder).val(curVal);
mQuery(folder).trigger('chosen:updated');
}
}
});
};
Mautic.disableSendTestEmailButton = function(element) {
mQuery(element).closest('.tab-pane').find('.config-dsn-test-container').each(function () {Mautic.configDsnTestDisable(this)});
};

View File

@@ -0,0 +1,755 @@
/** EmailBundle **/
Mautic.emailOnLoad = function (container, response) {
Mautic.internalDynamicContentItemCreateListeners = [];
Mautic.internalDynamicContentFilterCreateListeners = [];
if (mQuery('#emailform_plainText').length) {
// @todo initiate the token dropdown
var plaintext = mQuery('#emailform_plainText');
Mautic.initAtWho(plaintext, plaintext.attr('data-token-callback'));
Mautic.initSelectTheme(mQuery('#emailform_template'));
Mautic.initEmailDynamicContent();
Mautic.prepareVersioning(
function (content) {
console.log('undo');
},
function (content) {
console.log('redo');
}
);
// Open the builder directly when saved from the builder
if (response && response.inBuilder) {
Mautic.isInBuilder = true;
Mautic.launchBuilder('emailform');
Mautic.processBuilderErrors(response);
}
} else if (mQuery(container + ' #list-search').length) {
Mautic.activateSearchAutocomplete('list-search', 'email');
}
if (mQuery('table.email-list').length) {
var ids = [];
mQuery('td.col-stats').each(function () {
var id = mQuery(this).attr('data-stats');
ids.push(id);
});
// Get all stats numbers in batches of 10
while (ids.length > 0) {
let batchIds = ids.splice(0, 1);
Mautic.ajaxActionRequest(
'email:getEmailCountStats',
{ids: batchIds},
function (response) {
if (response.success && response.stats) {
for (var i = 0; i < response.stats.length; i++) {
var stat = response.stats[i];
if (mQuery('#sent-count-' + stat.id).length) {
if (stat.pending) {
mQuery('#pending-' + stat.id + ' > a').html(stat.pending);
mQuery('#pending-' + stat.id).removeClass('hide');
}
if (stat.queued) {
mQuery('#queued-' + stat.id + ' > a').html(stat.queued);
mQuery('#queued-' + stat.id).removeClass('hide');
}
mQuery('#sent-count-' + stat.id + ' > a').html(stat.sentCount);
mQuery('#read-count-' + stat.id + ' > a').html(stat.readCount);
mQuery('#read-percent-' + stat.id + ' > a').html(stat.readPercent);
}
}
}
},
false,
true,
"GET"
);
}
}
if (mQuery('#emailGraphStats').length) {
// Email detail graph - loaded via AJAX not to block loading a whole page
var graphUrl = mQuery('#emailGraphStats').attr('data-graph-url');
mQuery("#emailGraphStats").load(graphUrl, function () {
Mautic.renderCharts();
Mautic.initDateRangePicker('#emailGraphStats #daterange_date_from', '#emailGraphStats #daterange_date_to');
});
}
var $loadDeliveredElements = mQuery('[data-email-stat-delivered-for]');
if ($loadDeliveredElements.length) {
$loadDeliveredElements.each(function(i, el) {
Mautic.loadEmailDeliveredStat(mQuery(el));
});
}
var $loadEmailUsage = mQuery('[data-fetch-email-usages]');
if ($loadEmailUsage.length) {
$loadEmailUsage.each(function(i, el) {
Mautic.loadEmailUsages(mQuery(el));
});
}
Mautic.initMailerIsOwnerGlobalCheck();
};
Mautic.emailOnUnload = function(id) {
if (id === '#app-content') {
delete Mautic.listCompareChart;
}
if (typeof Mautic.ajaxActionXhrQueue !== 'undefined') {
delete Mautic.ajaxActionXhrQueue['email:getEmailCountStats'];
}
};
Mautic.insertEmailBuilderToken = function(editorId, token) {
var editor = Mautic.getEmailBuilderEditorInstances();
editor[instance].insertText(token);
};
Mautic.getEmailAbTestWinnerForm = function(abKey) {
if (abKey && mQuery(abKey).val() && mQuery(abKey).closest('.form-group').hasClass('has-error')) {
mQuery(abKey).closest('.form-group').removeClass('has-error');
if (mQuery(abKey).next().hasClass('help-block')) {
mQuery(abKey).next().remove();
}
}
Mautic.activateLabelLoadingIndicator('emailform_variantSettings_winnerCriteria');
var emailId = mQuery('#emailform_sessionId').val();
var query = "action=email:getAbTestForm&abKey=" + mQuery(abKey).val() + "&emailId=" + emailId;
mQuery.ajax({
url: mauticAjaxUrl,
type: "POST",
data: query,
dataType: "json",
success: function (response) {
if (typeof response.html != 'undefined') {
if (mQuery('#emailform_variantSettings_properties').length) {
mQuery('#emailform_variantSettings_properties').replaceWith(response.html);
} else {
mQuery('#emailform_variantSettings').append(response.html);
}
if (response.html != '') {
Mautic.onPageLoad('#emailform_variantSettings_properties', response);
}
}
},
error: function (request, textStatus, errorThrown) {
Mautic.processAjaxError(request, textStatus, errorThrown);
},
complete: function() {
Mautic.removeLabelLoadingIndicator();
}
});
};
Mautic.submitSendForm = function () {
Mautic.dismissConfirmation();
mQuery('.btn-send').prop('disabled', true);
mQuery('form[name=\'batch_send\']').submit();
};
Mautic.emailSendOnLoad = function (container, response) {
if (mQuery('.email-send-progress').length) {
if (!mQuery('#emailSendProgress').length) {
Mautic.clearModeratedInterval('emailSendProgress');
} else {
Mautic.setModeratedInterval('emailSendProgress', 'sendEmailBatch', 2000);
}
}
};
Mautic.emailSendOnUnload = function () {
if (mQuery('.email-send-progress').length) {
Mautic.clearModeratedInterval('emailSendProgress');
if (typeof Mautic.sendEmailBatchXhr != 'undefined') {
Mautic.sendEmailBatchXhr.abort();
delete Mautic.sendEmailBatchXhr;
}
}
};
Mautic.sendEmailBatch = function () {
var data = 'id=' + mQuery('.progress-bar-send').data('email') + '&pending=' + mQuery('.progress-bar-send').attr('aria-valuemax') + '&batchlimit=' + mQuery('.progress-bar-send').data('batchlimit');
Mautic.sendEmailBatchXhr = Mautic.ajaxActionRequest('email:sendBatch', data, function (response) {
if (response.progress) {
if (response.progress[0] > 0) {
mQuery('.imported-count').html(response.progress[0]);
mQuery('.progress-bar-send').attr('aria-valuenow', response.progress[0]).css('width', response.percent + '%');
mQuery('.progress-bar-send span.sr-only').html(response.percent + '%');
}
if (response.progress[0] >= response.progress[1]) {
Mautic.clearModeratedInterval('emailSendProgress');
setTimeout(function () {
mQuery.ajax({
type: 'POST',
showLoadingBar: false,
url: window.location,
data: 'complete=1',
success: function (response) {
if (response.newContent) {
// It's done so pass to process page
Mautic.processPageContent(response);
}
}
});
}, 1000);
}
}
Mautic.moderatedIntervalCallbackIsComplete('emailSendProgress');
});
};
Mautic.autoGeneratePlaintext = function() {
mQuery('.plaintext-spinner').removeClass('hide');
Mautic.ajaxActionRequest(
'email:generatePlaintText',
{
id: mQuery('#emailform_sessionId').val(),
custom: mQuery('#emailform_customHtml').val()
},
function (response) {
mQuery('#emailform_plainText').val(response.text);
mQuery('.plaintext-spinner').addClass('hide');
}
);
};
Mautic.selectEmailType = function(emailType) {
if (emailType == 'list') {
mQuery('#leadList').removeClass('hide');
mQuery('#segmentTranslationParent').removeClass('hide');
mQuery('#templateTranslationParent').addClass('hide');
mQuery('.page-header h3').text(mauticLang.newListEmail);
} else {
mQuery('#segmentTranslationParent').addClass('hide');
mQuery('#templateTranslationParent').removeClass('hide');
mQuery('#leadList').addClass('hide');
mQuery('.page-header h3').text(mauticLang.newTemplateEmail);
}
mQuery('#emailform_emailType').val(emailType);
mQuery('body').removeClass('noscroll');
mQuery('.email-type-modal').remove();
mQuery('.email-type-modal-backdrop').remove();
};
Mautic.getTotalAttachmentSize = function() {
var assets = mQuery('#emailform_assetAttachments').val();
if (assets) {
assets = {
'assets': assets
};
Mautic.ajaxActionRequest('email:getAttachmentsSize', assets, function(response) {
mQuery('#attachment-size').text(response.size);
}, false, false, "GET");
} else {
mQuery('#attachment-size').text('0');
}
};
Mautic.standardEmailUrl = function(options) {
if (options && options.windowUrl && options.origin) {
var url = options.windowUrl;
var editEmailKey = '/emails/edit/emailId';
var previewEmailKey = '/email/preview/emailId';
if (url.indexOf(editEmailKey) > -1 ||
url.indexOf(previewEmailKey) > -1) {
options.windowUrl = url.replace('emailId', mQuery(options.origin).val());
}
}
return options;
};
/**
* Enables/Disables email preview and edit. Can be triggered from campaign or form actions
* @param opener
* @param origin
*/
Mautic.disabledEmailAction = function(opener, origin) {
if (typeof opener == 'undefined') {
opener = window;
}
var email = opener.mQuery(origin);
if (email.length == 0) return;
var emailId = email.val();
var disabled = emailId === '' || emailId === null;
opener.mQuery('[id$=_editEmailButton]').prop('disabled', disabled);
opener.mQuery('[id$=_previewEmailButton]').prop('disabled', disabled);
};
Mautic.initEmailDynamicContent = function() {
if (mQuery('#dynamic-content-container').length) {
mQuery('#emailFilters .remove-selected').each( function (index, el) {
mQuery(el).on('click', function () {
mQuery(this).closest('.panel').animate(
{'opacity': 0},
'fast',
function () {
mQuery(this).remove();
}
);
if (!mQuery('#emailFilters li:not(.placeholder)').length) {
mQuery('#emailFilters li.placeholder').removeClass('hide');
} else {
mQuery('#emailFilters li.placeholder').addClass('hide');
}
});
});
mQuery('#addNewDynamicContent').on('click', function (e) {
e.preventDefault();
Mautic.createNewDynamicContentItem();
});
Mautic.initDynamicContentItem();
}
};
Mautic.createNewDynamicContentItem = function(jQueryVariant) {
// To support the parent.mQuery from the builder
var mQuery = (typeof jQueryVariant != 'undefined') ? jQueryVariant : window.mQuery;
var tabHolder = mQuery('#dynamicContentTabs');
var filterHolder = mQuery('#dynamicContentContainer');
var dynamicContentPrototype = mQuery('#dynamicContentPrototype').data('prototype');
var dynamicContentIndex = tabHolder.find('li').length - 1;
while (mQuery('#emailform_dynamicContent_' + dynamicContentIndex).length > 0) {
dynamicContentIndex++; // prevent duplicate ids
}
var tabId = '#emailform_dynamicContent_' + dynamicContentIndex;
var tokenName = 'Dynamic Content ' + (dynamicContentIndex + 1);
var newForm = dynamicContentPrototype.replace(/__name__/g, dynamicContentIndex);
var newTab = mQuery('<li><a role="tab" data-toggle="tab" href="' + tabId + '">' + tokenName + '</a></li>');
tabHolder.append(newTab);
filterHolder.append(newForm);
var itemContainer = mQuery(tabId);
var textarea = itemContainer.find('.editor');
var firstInput = itemContainer.find('input[type="text"]').first();
if (Mautic.internalDynamicContentItemCreateListeners) {
Mautic.internalDynamicContentItemCreateListeners.forEach(function(callback) {
callback(textarea);
});
}
tabHolder.find('i').first().removeClass('ri-loader-3-line ri-spin').addClass('ri-add-line text-success');
newTab.find('a').tab('show');
firstInput.focus();
Mautic.updateDynamicContentDropdown();
Mautic.initDynamicContentItem(tabId, mQuery, tokenName);
return tabId;
};
Mautic.dynamicContentAddNewItemListener = function(callback) {
Mautic.internalDynamicContentItemCreateListeners.push(callback);
}
Mautic.createNewDynamicContentFilter = function(el, jQueryVariant) {
// To support the parent.mQuery from the builder
var mQuery = (typeof jQueryVariant != 'undefined') ? jQueryVariant : window.mQuery;
var $this = mQuery(el);
var parentElement = $this.parents('.panel');
var tabHolder = parentElement.find('.nav');
var filterHolder = parentElement.find('.tab-content');
var filterBlockPrototype = mQuery('#filterBlockPrototype');
var filterIndex = filterHolder.find('.tab-pane').length - 1;
var dynamicContentIndex = $this.parents('.tab-pane').attr('id').match(/\d+$/)[0];
var filterPrototype = filterBlockPrototype.data('prototype');
var filterContainerId = '#emailform_dynamicContent_' + dynamicContentIndex + '_filters_' + filterIndex ;
// prevent duplicate ids
while (mQuery(filterContainerId).length > 0) {
filterIndex++;
filterContainerId = '#emailform_dynamicContent_' + dynamicContentIndex + '_filters_' + filterIndex ;
}
var newTab = mQuery('<li><a role="tab" data-toggle="tab" href="' + filterContainerId + '">Variation ' + (filterIndex + 1) + '</a></li>');
var newForm = filterPrototype.replace(/__name__/g, filterIndex)
.replace(/dynamicContent_0_filters/g, 'dynamicContent_' + dynamicContentIndex + '_filters')
.replace(/dynamicContent]\[0]\[filters/g, 'dynamicContent][' + dynamicContentIndex + '][filters');
tabHolder.append(newTab);
filterHolder.append(newForm);
var filterContainer = mQuery(filterContainerId);
var availableFilters = filterContainer.find('select[data-mautic="available_filters"]');
var altTextarea = filterContainer.find('.editor');
var removeButton = filterContainer.find('.remove-item');
Mautic.activateChosenSelect(availableFilters, false, mQuery);
availableFilters.on('change', function() {
var $this = mQuery(this);
if ($this.val()) {
Mautic.addDynamicContentFilter($this.val(), mQuery);
$this.val('');
$this.trigger('chosen:updated');
}
});
if (Mautic.internalDynamicContentFilterCreateListeners) {
Mautic.internalDynamicContentFilterCreateListeners.forEach(function(callback) {
callback(altTextarea);
});
}
Mautic.initRemoveEvents(removeButton, mQuery);
newTab.find('a').tab('show');
return filterContainerId;
};
Mautic.dynamicContentAddNewFilterListener = function(callback) {
Mautic.internalDynamicContentFilterCreateListeners.push(callback);
}
Mautic.initDynamicContentItem = function (tabId, jQueryVariant, tokenName) {
// To support the parent.mQuery from the builder
var mQuery = (typeof jQueryVariant != 'undefined') ? jQueryVariant : window.mQuery;
var $el = mQuery('#dynamic-content-container');
if ($el.length === 0){
mQuery = parent.mQuery;
$el = mQuery('#dynamic-content-container');
}
if (tabId || typeof tabId != "undefined") {
$el = mQuery(tabId);
}
// add a click event listener for adding a new dynamic content variant
$el.find('.addNewDynamicContentFilter').on('click', function (e) {
e.preventDefault();
Mautic.createNewDynamicContentFilter(this);
});
if (typeof tokenName != 'undefined') {
$el.find('.dynamic-content-token-name').val(tokenName);
}
if ($el.find('.dynamic-content-token-name').val() === '') {
var dynamicContent = $el.attr('id').match(/\d+$/);
if (dynamicContent) {
var dynamicContentIndex = dynamicContent[0];
$el.find('.dynamic-content-token-name').val('Dynamic Content ' + dynamicContentIndex);
}
}
$el.find('a.remove-selected').on('click', function() {
mQuery(this).closest('.panel').animate(
{'opacity': 0},
'fast',
function () {
mQuery(this).remove();
}
);
});
$el.find('select[data-mautic="available_filters"]').on('change', function() {
var $this = mQuery(this);
if ($this.val()) {
Mautic.addDynamicContentFilter($this.val(), mQuery);
$this.val('');
$this.trigger('chosen:updated');
}
});
Mautic.initRemoveEvents($el.find('.remove-item'), mQuery);
};
Mautic.updateDynamicContentDropdown = function () {
var options = [];
mQuery('#dynamicContentTabs').find('a[data-toggle="tab"]').each(function () {
var prototype = '<li><a class="fr-command" data-cmd="dynamicContent" data-param1="__tokenName__">__tokenName__</a></li>';
var newOption = prototype.replace(/__tokenName__/g, mQuery(this).text());
options.push(newOption);
});
mQuery('button[data-cmd="dynamicContent"]').next().find('ul').html(options.join(''));
};
Mautic.initMailerIsOwnerGlobalCheck = function() {
let radioSelector = '.mailer-is-owner-local';
Mautic.toggleMailerIsOwnerWarningMessage(radioSelector);
mQuery(radioSelector).on('change', function() {
Mautic.toggleMailerIsOwnerWarningMessage(radioSelector);
});
}
Mautic.toggleMailerIsOwnerWarningMessage = function(radioSelector) {
let checkedRadio = mQuery(radioSelector+':checked');
let globalMailerIsOwnerValue = checkedRadio.attr('data-global-mailer-is-onwer') ? '1' : '0';
let warningMessageId = 'mailer-is-owner-waring';
mQuery('#'+warningMessageId).remove();
if (checkedRadio.val() !== globalMailerIsOwnerValue) {
let warning = mQuery('<div/>');
warning.attr('id', warningMessageId);
warning.html(checkedRadio.attr('data-warning'));
warning.addClass('alert alert-warning mt-md');
checkedRadio.closest('.form-group').append(warning);
}
}
Mautic.initRemoveEvents = function (elements, jQueryVariant) {
var mQuery = (typeof jQueryVariant != 'undefined') ? jQueryVariant : window.mQuery;
if (elements.hasClass('remove-selected')) {
elements.on('click', function() {
mQuery(this).closest('.panel').animate(
{'opacity': 0},
'fast',
function () {
mQuery(this).remove();
}
);
});
} else {
elements.on('click', function (e) {
e.preventDefault();
var $this = mQuery(this);
var parentElement = $this.parents('.tab-pane.dynamic-content');
if ($this.hasClass('remove-filter')) {
parentElement = $this.parents('.tab-pane.dynamic-content-filter');
}
var tabLink = mQuery('a[href="#' + parentElement.attr('id') + '"]').parent();
var tabContainer = tabLink.parent();
parentElement.remove();
tabLink.remove();
// if tabContainer is for variants, show the first one, if it is the DEC vertical list, show the second one
if (tabContainer.hasClass('tabs-left') || $this.hasClass('remove-filter')) {
tabContainer.find('li').first().next().find('a').tab('show');
} else {
tabContainer.find('li').first().find('a').tab('show');
}
Mautic.updateDynamicContentDropdown();
});
}
};
Mautic.addDynamicContentFilter = function (selectedFilter, jQueryVariant) {
var mQuery = (typeof jQueryVariant != 'undefined') ? jQueryVariant : window.mQuery;
var dynamicContentItems = mQuery('.tab-pane.dynamic-content');
var activeDynamicContent = dynamicContentItems.filter(':visible');
var dynamicContentIndex = activeDynamicContent.attr('id').match(/\d+$/)[0]; //dynamicContentItems.index(activeDynamicContent);
var dynamicContentFilterContainers = activeDynamicContent.find('div[data-filter-container]');
var activeDynamicContentFilterContainer = dynamicContentFilterContainers.filter(':visible');
var dynamicContentFilterIndex = dynamicContentFilterContainers.index(activeDynamicContentFilterContainer);
var selectedOption = mQuery('option[data-mautic="available_' + selectedFilter + '"]').first();
var label = selectedOption.text();
// create a new filter
var filterNum = activeDynamicContentFilterContainer.children('.panel').length;
var prototype = mQuery('#filterSelectPrototype').data('prototype');
var fieldObject = selectedOption.data('field-object');
var fieldType = selectedOption.data('field-type');
var isSpecial = (mQuery.inArray(fieldType, ['leadlist', 'assets', 'lead_email_received', 'tags', 'multiselect', 'boolean', 'select', 'country', 'timezone', 'region', 'stage', 'locale']) != -1);
// Update the prototype settings
prototype = prototype.replace(/__name__/g, filterNum)
.replace(/__label__/g, label)
.replace(/dynamicContent_0_filters/g, 'dynamicContent_' + dynamicContentIndex + '_filters')
.replace(/dynamicContent]\[0]\[filters/g, 'dynamicContent][' + dynamicContentIndex + '][filters')
.replace(/filters_0_filters/g, 'filters_' + dynamicContentFilterIndex + '_filters')
.replace(/filters]\[0]\[filters/g, 'filters][' + dynamicContentFilterIndex + '][filters');
if (filterNum === 0) {
prototype = prototype.replace(/in-group/g, '');
}
// Convert to DOM
prototype = mQuery(prototype);
if (fieldObject == 'company') {
prototype.find('.object-icon').removeClass('ri-user-6-fill').addClass('ri-building-2-line');
} else {
prototype.find('.object-icon').removeClass('ri-building-2-line').addClass('ri-user-6-fill');
}
var filterBase = "emailform[dynamicContent][" + dynamicContentIndex + "][filters][" + dynamicContentFilterIndex + "][filters][" + filterNum + "]";
var filterIdBase = "emailform_dynamicContent_" + dynamicContentIndex + "_filters_" + dynamicContentFilterIndex + "_filters_" + filterNum;
if (isSpecial) {
var templateField = fieldType;
if (fieldType == 'boolean' || fieldType == 'multiselect' || fieldType == 'leadlist') {
templateField = 'select';
}
var template = mQuery('#templates .' + templateField + '-template').clone();
var $template = mQuery(template);
var templateNameAttr = $template.attr('name').replace(/__name__/g, filterNum)
.replace(/__dynamicContentIndex__/g, dynamicContentIndex)
.replace(/__dynamicContentFilterIndex__/g, dynamicContentFilterIndex);
var templateIdAttr = $template.attr('id').replace(/__name__/g, filterNum)
.replace(/__dynamicContentIndex__/g, dynamicContentIndex)
.replace(/__dynamicContentFilterIndex__/g, dynamicContentFilterIndex);
$template.attr('name', templateNameAttr);
$template.attr('id', templateIdAttr);
prototype.find('input[name="' + filterBase + '[filter]"]').replaceWith(template);
}
if (activeDynamicContentFilterContainer.find('.panel').length == 0) {
// First filter so hide the glue footer
prototype.find(".panel-footer").addClass('hide');
}
prototype.find("input[name='" + filterBase + "[field]']").val(selectedFilter);
prototype.find("input[name='" + filterBase + "[type]']").val(fieldType);
prototype.find("input[name='" + filterBase + "[object]']").val(fieldObject);
var filterEl = (isSpecial) ? "select[name='" + filterBase + "[filter]']" : "input[name='" + filterBase + "[filter]']";
activeDynamicContentFilterContainer.append(prototype);
Mautic.initRemoveEvents(activeDynamicContentFilterContainer.find("a.remove-selected"), mQuery);
var filter = '#' + filterIdBase + '_filter';
var fieldOptions = fieldCallback = '';
//activate fields
if (isSpecial) {
if (fieldType == 'select' || fieldType == 'boolean' || fieldType == 'multiselect' || fieldType == 'leadlist') {
// Generate the options
fieldOptions = selectedOption.data("field-list");
mQuery.each(fieldOptions, function(index, val) {
mQuery('<option>').val(index).text(val).appendTo(filterEl);
});
}
} else if (fieldType == 'lookup') {
fieldCallback = selectedOption.data("field-callback");
if (fieldCallback && typeof Mautic[fieldCallback] == 'function') {
fieldOptions = selectedOption.data("field-list");
Mautic[fieldCallback](filterIdBase + '_filter', selectedFilter, fieldOptions);
}
} else if (fieldType == 'datetime') {
mQuery(filter).datetimepicker({
format: 'Y-m-d H:i',
lazyInit: true,
validateOnBlur: false,
allowBlank: true,
scrollMonth: false,
scrollInput: false
});
} else if (fieldType == 'date') {
mQuery(filter).datetimepicker({
timepicker: false,
format: 'Y-m-d',
lazyInit: true,
validateOnBlur: false,
allowBlank: true,
scrollMonth: false,
scrollInput: false,
closeOnDateSelect: true
});
} else if (fieldType == 'time') {
mQuery(filter).datetimepicker({
datepicker: false,
format: 'H:i',
lazyInit: true,
validateOnBlur: false,
allowBlank: true,
scrollMonth: false,
scrollInput: false
});
} else if (fieldType == 'lookup_id') {
//switch the filter and display elements
var oldFilter = mQuery(filterEl);
var newDisplay = mQuery(oldFilter).clone();
mQuery(newDisplay).attr('name', filterBase + '[display]')
.attr('id', filterIdBase + '_display');
var oldDisplay = mQuery(prototype).find("input[name='" + filterBase + "[display]']");
var newFilter = mQuery(oldDisplay).clone();
mQuery(newFilter).attr('name', filterBase + '[filter]')
.attr('id', filterIdBase + '_filter');
mQuery(oldFilter).replaceWith(newFilter);
mQuery(oldDisplay).replaceWith(newDisplay);
var fieldCallback = selectedOption.data("field-callback");
if (fieldCallback && typeof Mautic[fieldCallback] == 'function') {
fieldOptions = selectedOption.data("field-list");
Mautic[fieldCallback](filterIdBase + '_display', selectedFilter, fieldOptions, mQuery);
}
} else {
mQuery(filter).attr('type', fieldType);
}
var operators = mQuery(selectedOption).data('field-operators');
mQuery('#' + filterIdBase + '_operator').html('');
mQuery.each(operators, function (label, value) {
var newOption = mQuery('<option/>').val(value).text(label);
newOption.appendTo(mQuery('#' + filterIdBase + '_operator'));
});
// Convert based on first option in list
Mautic.convertDwcFilterInput('#' + filterIdBase + '_operator', mQuery);
};
Mautic.copySubjectToName = function(elemSubject) {
let elemName = mQuery("#emailform_name");
if (elemName.val() === "") {
elemName.val(elemSubject.val());
}
};
Mautic.loadEmailDeliveredStat = function($el) {
var emailId = $el.data('email-stat-delivered-for');
Mautic.ajaxActionRequest('email:getEmailDeliveredCount', {id: emailId}, function(response){
if (response.success) {
var delivered = response.delivered;
$el.html(delivered);
}
}, false, true, "GET");
};
Mautic.loadEmailUsages = function($el) {
var emailId = $el.data('fetch-email-usages');
Mautic.ajaxActionRequest('email:getEmailUsages', {id: emailId}, function(response){
var usagesHtml = response.usagesHtml;
$el.html(usagesHtml);
}, false, true, "GET");
};

View File

@@ -0,0 +1,255 @@
(function(window, document, Mautic, $, Math) {
class Heatmap {
constructor(emailId) {
this.emailId = emailId;
this.mode = 'total';
this.content = null;
this.clickStats = null;
this.$modal = null;
this.$iframe = null;
this.$iframeBody = null;
this.iframeDocument = null;
this.totalClicks = null;
this.totalUniqueClicks = null;
this.legendTemplate = null;
this.links = [];
this.gradient = [
[44, 59, 182], // #2c3bb6
[10, 133, 255], // #0a85ff
[240, 223, 66], // #f0df42
[248, 195, 68], // #f8c344
[255, 132, 58], // #ff843a
[248, 56, 52] // #f83834
];
}
init() {
this.fetchHeatmap(function() {
this.render();
}.bind(this));
}
render() {
this.renderModal();
this.bindEvents();
this.$modal.modal('show');
}
fetchHeatmap(callback) {
Mautic.ajaxActionRequest('email:heatmap', {id: this.emailId}, function(response){
this.content = response.content;
this.clickStats = response.clickStats;
this.totalClicks = response.totalClicks;
this.totalUniqueClicks = response.totalUniqueClicks;
this.legendTemplate = response.legendTemplate;
callback();
}.bind(this), false, true, "GET");
}
waitForIframeContent(callback) {
const self = this;
const interval = setInterval(function () {
const height = self.$iframeBody.height();
if (height > 0 && self.lastHeight === height) {
callback();
clearInterval(interval);
} else {
self.lastHeight = height;
}
}, 100);
}
bindEvents() {
const self = this;
self.$iframe[0].addEventListener('load', function() {
self.waitForIframeContent(function() {
self.renderLabels();
self.bindMouseEvents();
});
});
$(window).on('resize', function() {
self.labelPositions();
});
self.$modal.on('hidden.bs.modal', function () {
$(this).remove();
});
$('[data-toggle="heatmap-total"]').click(function(e) {
e.preventDefault();
if (self.mode === 'total') return;
self.mode = 'total';
$('[data-heatmap-clicks]').html(self.totalClicks);
$('[data-toggle="heatmap-unique"]').removeClass('active');
$(this).addClass('active');
self.removeLabels();
self.renderLabels();
});
$('[data-toggle="heatmap-unique"]').click(function(e) {
e.preventDefault();
if (self.mode === 'unique') return;
self.mode = 'unique';
$('[data-heatmap-clicks]').html(self.totalUniqueClicks);
$('[data-toggle="heatmap-total"]').removeClass('active');
$(this).addClass('active');
self.removeLabels();
self.renderLabels();
});
$('div.heatmap-legend').on('scroll mousewheel touchmove', function(e) {
e.preventDefault();
});
}
bindMouseEvents() {
const self = this;
const moveUp = function() {
const $label = $(this).hasClass('heatmap-link') ? $(this).data('heatmap-label') : $(this);
$label.css('z-index', 2050);
}
const moveDown = function() {
const $label = $(this).hasClass('heatmap-link') ? $(this).data('heatmap-label') : $(this);
$label.css('z-index', 1050);
}
self.$iframeBody.on('mouseenter focus', '.heatmap-label, a.heatmap-link', moveUp);
self.$iframeBody.on('mouseleave blur', '.heatmap-label, a.heatmap-link', moveDown);
}
renderModal() {
this.$modal = $("<div />").attr({"class": "modal fade heatmap-modal"});
const $modalDialogDiv = $("<div />").attr({"class": "modal-dialog modal-dialog-heatmap"});
const $modalContentDiv = $("<div />").attr({"class": "modal-content"});
this.$iframe = $('<iframe class="heatmap-iframe">' + this.content + '</iframe>');
$modalContentDiv.append(this.$iframe);
this.$modal.append($modalDialogDiv.append($modalContentDiv));
$('body').append(this.$modal);
this.iframeDocument = this.$iframe[0].contentDocument || this.$iframe[0].contentWindow.document;
this.iframeDocument.open();
this.iframeDocument.write(this.content);
const cssLink = document.createElement("link");
cssLink.href = "/app/bundles/EmailBundle/Assets/css/heatmap.css";
cssLink.rel = "stylesheet";
cssLink.type = "text/css";
this.iframeDocument.head.appendChild(cssLink);
this.$iframeBody = $('body', this.iframeDocument);
this.$iframeBody.addClass('heatmap-iframe-body');
$modalContentDiv.append(this.legendTemplate);
$modalContentDiv.append('<button type="button" class="modal-heatmap-close close" data-dismiss="modal"><span aria-hidden="true">×</span></button>');
this.iframeDocument.close();
}
renderLabels() {
const self = this;
self.clickStats.forEach(function(link) {
const $a = $('a[href="' + link.url + '"]', self.$iframeBody);
$a.addClass('heatmap-link');
$a.each(function() {
const $el = $(this);
self.links.push($el);
const rate = self.mode === 'total' ? link.hits_rate : link.unique_hits_rate;
const percent = Math.round(rate * 100);
const text = (self.mode === 'total' ? link.hits_text : link.unique_hits_text) + ' (' + percent.toString() + '%)';
const $label = $('<div class="heatmap-label"><p>' + text + '</p></div>');
const bgColor = self.interpolateColor(rate);
const bgColorLeft = self.interpolateColor(rate - 0.1);
const bgColorRight = self.interpolateColor(rate + 0.1);
$label.css({
'background-color': bgColor,
'background': 'linear-gradient(to right, ' +bgColorLeft+ ', '+ bgColorRight +')'
});
const $border = $('<div class="heatmap-label-border"></div>');
$border.css({
'border': '1px dashed ' + bgColor,
'border-bottom': 'none'
});
$label.append($border);
$label.attr('title', link.url);
self.$iframeBody.append($label);
$el.data('heatmap-label', $label);
$el.data('heatmap-label-border', $border);
$label.data('a', $a);
});
});
self.labelPositions();
}
removeLabels() {
if (!this.links.length) return;
$(this.links).each(function() {
$(this).data('heatmap-label').remove();
});
this.links = [];
}
labelPositions() {
const self = this;
$(self.links).each(function() {
const $el = $(this);
const $label = $el.data('heatmap-label');
const $border = $el.data('heatmap-label-border');
const position = $el.position();
$label.css({
position: 'absolute',
top: position.top + $el.outerHeight(),
left: position.left - 1,
'min-width': Math.max($el.outerWidth(), 60) + 2
});
$border.css({
position: 'absolute',
bottom: '100%',
left: 0,
width: $el.outerWidth(),
height: $el.outerHeight()
});
});
}
interpolateColor(rate) {
if (rate <= 0) {
return 'rgb(' + this.gradient[0].join(',') + ')';
}
if (rate >= 1) {
const lastIndex = this.gradient.length - 1;
return 'rgb(' + this.gradient[lastIndex].join(',') + ')';
}
const segmentCount = this.gradient.length - 1;
const segmentWidth = 1 / segmentCount;
const segmentIndex = Math.floor(rate / segmentWidth);
const segmentPercent = (rate - segmentIndex * segmentWidth) / segmentWidth;
const color1 = this.gradient[segmentIndex];
const color2 = this.gradient[segmentIndex + 1];
const r = Math.round(color1[0] + (color2[0] - color1[0]) * segmentPercent);
const g = Math.round(color1[1] + (color2[1] - color1[1]) * segmentPercent);
const b = Math.round(color1[2] + (color2[2] - color1[2]) * segmentPercent);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
}
$(document).ready(function() {
$('body').on('click', '[data-toggle="email-heatmap"]', function(e) {
const emailId = $(this).data('email');
const heatmap = new Heatmap(emailId);
heatmap.init();
e.preventDefault();
});
});
})(window, document, Mautic, mQuery, Math);

View File

@@ -0,0 +1,33 @@
/**
* Used in data-lookup-callback attr of form field in ExampleSendType
* Take a look at https://github.com/twitter/typeahead.js/
*/
Mautic.activateExampleContactLookupField = function(fieldOptions, filterId) {
const lookupElementId = 'example_send_contact';
const action = mQuery('#'+ lookupElementId).attr('data-chosen-lookup');
const options = {
limit: 20,
'searchKey': 'lead.lead',
};
Mautic.activateFieldTypeahead(lookupElementId, filterId, options, action);
mQuery('#'+ lookupElementId).on("change",function(event) {
if (event.target.value === '') {
// Delete selected contact ID from hidden field
mQuery('#example_send_contact_id').val('');
}
});
};
/**
* Used in data-lookup-callback attr of form field in ExampleSendType
*/
Mautic.updateExampleContactLookupListFilter = function(field, item) {
if (item && item.id) {
mQuery('#example_send_contact_id').val(item.id);
mQuery(field).val(item.value);
}
};

View File

@@ -0,0 +1,62 @@
<?php
namespace Mautic\EmailBundle\Command;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\EmailBundle\MonitoredEmail\Fetcher;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* CLI command to check for messages.
*/
#[AsCommand(
name: 'mautic:email:fetch',
description: 'Fetch and process monitored email.',
aliases: [
'mautic:emails:fetch',
]
)]
class ProcessFetchEmailCommand extends Command
{
public function __construct(
private CoreParametersHelper $parametersHelper,
private Fetcher $fetcher,
) {
parent::__construct();
}
protected function configure()
{
$this
->addOption('--message-limit', '-m', InputOption::VALUE_OPTIONAL, 'Limit number of messages to process at a time.')
->setHelp(
<<<'EOT'
The <info>%command.name%</info> command is used to fetch and process messages such as bounces and unsubscribe requests. Configure the Monitored Email settings in Mautic's Configuration.
<info>php %command.full_name%</info>
EOT
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$limit = $input->getOption('message-limit');
$limit = null === $limit ? null : (int) $limit;
$mailboxes = $this->parametersHelper->get('monitored_email');
unset($mailboxes['general']);
$mailboxes = array_keys($mailboxes);
$this->fetcher->setMailboxes($mailboxes)
->fetch($limit);
foreach ($this->fetcher->getLog() as $log) {
$output->writeln($log);
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,340 @@
<?php
return [
'routes' => [
'main' => [
'mautic_email_batch_categories_view' => [
'path' => '/emails/batch/categories/view',
'controller' => 'Mautic\EmailBundle\Controller\BatchEmailController::indexAction',
],
'mautic_email_batch_categories_set' => [
'path' => '/emails/batch/categories/set',
'controller' => 'Mautic\EmailBundle\Controller\BatchEmailController::execAction',
],
'mautic_email_index' => [
'path' => '/emails/{page}',
'controller' => 'Mautic\EmailBundle\Controller\EmailController::indexAction',
],
'mautic_email_graph_stats' => [
'path' => '/emails-graph-stats/{objectId}/{isVariant}/{dateFrom}/{dateTo}',
'controller' => 'Mautic\EmailBundle\Controller\EmailGraphStatsController::viewAction',
],
'mautic_email_map_stats' => [
'path' => '/emails-map-stats/{objectId}/{isVariant}/{dateFrom}/{dateTo}',
'controller' => 'Mautic\EmailBundle\Controller\EmailMapStatsController::viewAction',
],
'mautic_email_action' => [
'path' => '/emails/{objectAction}/{objectId}',
'controller' => 'Mautic\EmailBundle\Controller\EmailController::executeAction',
],
'mautic_email_contacts' => [
'path' => '/emails/view/{objectId}/contact/{page}',
'controller' => 'Mautic\EmailBundle\Controller\EmailController::contactsAction',
],
],
'api' => [
'mautic_api_emailstandard' => [
'standard_entity' => true,
'name' => 'emails',
'path' => '/emails',
'controller' => Mautic\EmailBundle\Controller\Api\EmailApiController::class,
],
'mautic_api_sendemail' => [
'path' => '/emails/{id}/send',
'controller' => 'Mautic\EmailBundle\Controller\Api\EmailApiController::sendAction',
'method' => 'POST',
],
'mautic_api_sendcontactemail' => [
'path' => '/emails/{id}/contact/{leadId}/send',
'controller' => 'Mautic\EmailBundle\Controller\Api\EmailApiController::sendLeadAction',
'method' => 'POST',
],
'mautic_api_reply' => [
'path' => '/emails/reply/{trackingHash}',
'controller' => 'Mautic\EmailBundle\Controller\Api\EmailApiController::replyAction',
'method' => 'POST',
],
],
'public' => [
'mautic_plugin_tracker' => [
'path' => '/plugin/{integration}/tracking.gif',
'controller' => 'Mautic\EmailBundle\Controller\PublicController::pluginTrackingGifAction',
'requirements' => [
'integration' => '.+',
],
],
'mautic_email_tracker' => [
'path' => '/email/{idHash}.gif',
'controller' => 'Mautic\EmailBundle\Controller\PublicController::trackingImageAction',
],
'mautic_email_webview' => [
'path' => '/email/view/{idHash}',
'controller' => 'Mautic\EmailBundle\Controller\PublicController::indexAction',
],
'mautic_email_unsubscribe' => [
'path' => '/email/unsubscribe/{idHash}/{urlEmail}/{secretHash}',
'controller' => 'Mautic\EmailBundle\Controller\PublicController::unsubscribeAction',
'defaults' => ['urlEmail' => null, 'secretHash' => null],
],
'mautic_email_unsubscribe_all' => [
'path' => '/email/dnc/{idHash}/{urlEmail}/{secretHash}',
'controller' => 'Mautic\EmailBundle\Controller\PublicController::unsubscribeAllAction',
'defaults' => ['urlEmail' => null, 'secretHash' => null],
],
'mautic_email_resubscribe' => [
'path' => '/email/resubscribe/{idHash}',
'controller' => 'Mautic\EmailBundle\Controller\PublicController::resubscribeAction',
],
'mautic_mailer_transport_callback' => [
'path' => '/mailer/callback',
'controller' => 'Mautic\EmailBundle\Controller\PublicController::mailerCallbackAction',
],
'mautic_email_preview' => [
'path' => '/email/preview/{objectId}/{objectType}',
'controller' => 'Mautic\EmailBundle\Controller\PublicController::previewAction',
'defaults' => [
'objectType' => null,
],
],
],
],
'menu' => [
'main' => [
'items' => [
'mautic.email.emails' => [
'route' => 'mautic_email_index',
'access' => ['email:emails:viewown', 'email:emails:viewother'],
'parent' => 'mautic.core.channels',
'priority' => 100,
],
],
],
],
'categories' => [
'email' => [
'class' => Mautic\EmailBundle\Entity\Email::class,
],
],
'services' => [
'other' => [
'mautic.di.env_processor.mailerdsn' => [
'class' => Mautic\EmailBundle\DependencyInjection\EnvProcessor\MailerDsnEnvVarProcessor::class,
'tag' => 'container.env_var_processor',
],
'mautic.message.search.contact' => [
'class' => Mautic\EmailBundle\MonitoredEmail\Search\ContactFinder::class,
'arguments' => [
'mautic.email.repository.stat',
'mautic.lead.repository.lead',
'monolog.logger.mautic',
],
],
'mautic.message.processor.unsubscribe' => [
'class' => Mautic\EmailBundle\MonitoredEmail\Processor\Unsubscribe::class,
'arguments' => [
'mailer.default_transport',
'mautic.message.search.contact',
'translator',
'monolog.logger.mautic',
'mautic.lead.model.dnc',
],
],
'mautic.message.processor.feedbackloop' => [
'class' => Mautic\EmailBundle\MonitoredEmail\Processor\FeedbackLoop::class,
'arguments' => [
'mautic.message.search.contact',
'translator',
'monolog.logger.mautic',
'mautic.lead.model.dnc',
],
],
'mautic.validator.email' => [
'class' => Mautic\EmailBundle\Helper\EmailValidator::class,
'arguments' => [
'translator',
'event_dispatcher',
],
],
'mautic.email.fetcher' => [
'class' => Mautic\EmailBundle\MonitoredEmail\Fetcher::class,
'arguments' => [
'mautic.helper.mailbox',
'event_dispatcher',
'translator',
],
],
'mautic.email.helper.stats_collection' => [
'class' => Mautic\EmailBundle\Helper\StatsCollectionHelper::class,
'arguments' => [
'mautic.email.stats.helper_container',
],
],
'mautic.email.stats.helper_container' => [
'class' => Mautic\EmailBundle\Stats\StatHelperContainer::class,
],
'mautic.email.stats.helper_bounced' => [
'class' => Mautic\EmailBundle\Stats\Helper\BouncedHelper::class,
'arguments' => [
'mautic.stats.aggregate.collector',
'doctrine.dbal.default_connection',
'mautic.generated.columns.provider',
'mautic.helper.user',
],
'tag' => 'mautic.email_stat_helper',
],
'mautic.email.stats.helper_clicked' => [
'class' => Mautic\EmailBundle\Stats\Helper\ClickedHelper::class,
'arguments' => [
'mautic.stats.aggregate.collector',
'doctrine.dbal.default_connection',
'mautic.generated.columns.provider',
'mautic.helper.user',
],
'tag' => 'mautic.email_stat_helper',
],
'mautic.email.stats.helper_failed' => [
'class' => Mautic\EmailBundle\Stats\Helper\FailedHelper::class,
'arguments' => [
'mautic.stats.aggregate.collector',
'doctrine.dbal.default_connection',
'mautic.generated.columns.provider',
'mautic.helper.user',
],
'tag' => 'mautic.email_stat_helper',
],
'mautic.email.stats.helper_opened' => [
'class' => Mautic\EmailBundle\Stats\Helper\OpenedHelper::class,
'arguments' => [
'mautic.stats.aggregate.collector',
'doctrine.dbal.default_connection',
'mautic.generated.columns.provider',
'mautic.helper.user',
],
'tag' => 'mautic.email_stat_helper',
],
'mautic.email.stats.helper_sent' => [
'class' => Mautic\EmailBundle\Stats\Helper\SentHelper::class,
'arguments' => [
'mautic.stats.aggregate.collector',
'doctrine.dbal.default_connection',
'mautic.generated.columns.provider',
'mautic.helper.user',
],
'tag' => 'mautic.email_stat_helper',
],
'mautic.email.stats.helper_unsubscribed' => [
'class' => Mautic\EmailBundle\Stats\Helper\UnsubscribedHelper::class,
'arguments' => [
'mautic.stats.aggregate.collector',
'doctrine.dbal.default_connection',
'mautic.generated.columns.provider',
'mautic.helper.user',
],
'tag' => 'mautic.email_stat_helper',
],
],
'validator' => [
'mautic.email.validator.multiple_emails_valid_validator' => [
'class' => Mautic\EmailBundle\Validator\MultipleEmailsValidValidator::class,
'arguments' => [
'mautic.validator.email',
],
'tag' => 'validator.constraint_validator',
],
'mautic.email.validator.email_or_token_list_validator' => [
'class' => Mautic\EmailBundle\Validator\EmailOrEmailTokenListValidator::class,
'arguments' => [
'mautic.validator.email',
'mautic.lead.validator.custom_field',
],
'tag' => 'validator.constraint_validator',
],
],
'fixtures' => [
'mautic.email.fixture.email' => [
'class' => Mautic\EmailBundle\DataFixtures\ORM\LoadEmailData::class,
'tag' => Doctrine\Bundle\FixturesBundle\DependencyInjection\CompilerPass\FixturesCompilerPass::FIXTURE_TAG,
'arguments' => ['mautic.email.model.email'],
],
],
],
'parameters' => [
'mailer_from_name' => 'Mautic',
'mailer_from_email' => 'email@yoursite.com',
'mailer_reply_to_email' => null,
'mailer_return_path' => null,
'mailer_address_length_limit' => 320,
'mailer_append_tracking_pixel' => true,
'mailer_convert_embed_images' => false,
'mailer_custom_headers' => [],
'mailer_dsn' => 'smtp://localhost:25',
'unsubscribe_text' => null,
'webview_text' => null,
'unsubscribe_message' => null,
'resubscribe_message' => null,
'monitored_email' => [
'general' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'use_attachments' => false,
],
'EmailBundle_bounces' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => null,
],
'EmailBundle_unsubscribes' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => null,
],
'EmailBundle_replies' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => null,
],
],
'mailer_is_owner' => false,
'default_signature_text' => null,
'email_frequency_number' => 0,
'email_frequency_time' => 'DAY',
'show_contact_preferences' => false,
'show_contact_frequency' => false,
'show_contact_pause_dates' => false,
'show_contact_preferred_channels' => false,
'show_contact_categories' => false,
'show_contact_segments' => false,
'disable_trackable_urls' => false,
'email_draft_enabled' => false,
'theme_email_default' => 'blank',
'mailer_memory_msg_limit' => 100,
Mautic\EmailBundle\Form\Type\ConfigType::MINIFY_EMAIL_HTML => false,
'bot_helper_bot_ratio_threshold' => 0.6,
'bot_helper_time_email_threshold' => 2, // seconds
'bot_helper_blocked_user_agents' => [
// Example of real-world user agents used by bots:
'Googlebot/2.1 (+http://www.google.com/bot.html)',
'LinkedInBot/1.0 (compatible; Mozilla/5.0; Jakarta Commons-HttpClient/3.1 +http://www.linkedin.com)',
],
'bot_helper_blocked_ip_addresses' => [],
],
];

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [
'OptionsAccessor',
'MonitoredEmail/Accessor',
'MonitoredEmail/Organizer',
'MonitoredEmail/Processor',
'Stat/Reference.php',
'Helper/DTO',
];
$services->load('Mautic\\EmailBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->load('Mautic\\EmailBundle\\Entity\\', '../Entity/*Repository.php')
->tag(Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass::REPOSITORY_SERVICE_TAG);
$services->alias(Mautic\CoreBundle\Doctrine\Provider\GeneratedColumnsProviderInterface::class, Mautic\CoreBundle\Doctrine\Provider\GeneratedColumnsProvider::class);
$services->set(Mautic\EmailBundle\Mailer\Transport\TransportFactory::class)
->decorate('mailer.transport_factory');
$services->set(Mautic\EmailBundle\MonitoredEmail\Processor\Bounce::class);
$services->set(Mautic\EmailBundle\MonitoredEmail\Processor\Reply::class);
$services->alias('mautic.email.model.email', Mautic\EmailBundle\Model\EmailModel::class);
$services->alias('mautic.email.model.send_email_to_user', Mautic\EmailBundle\Model\SendEmailToUser::class);
$services->alias('mautic.email.model.send_email_to_contacts', Mautic\EmailBundle\Model\SendEmailToContact::class);
$services->alias('mautic.email.model.transport_callback', Mautic\EmailBundle\Model\TransportCallback::class);
$services->alias('mautic.email.repository.email', Mautic\EmailBundle\Entity\EmailRepository::class);
$services->alias('mautic.email.repository.emailReply', Mautic\EmailBundle\Entity\EmailReplyRepository::class);
$services->alias('mautic.email.repository.stat', Mautic\EmailBundle\Entity\StatRepository::class);
$services->alias('mautic.helper.mailbox', Mautic\EmailBundle\MonitoredEmail\Mailbox::class);
$services->alias('mautic.helper.mailer', Mautic\EmailBundle\Helper\MailHelper::class);
$services->alias('mautic.message.processor.bounce', Mautic\EmailBundle\MonitoredEmail\Processor\Bounce::class);
$services->alias('mautic.message.processor.replier', Mautic\EmailBundle\MonitoredEmail\Processor\Reply::class);
$services->alias('mautic.email.helper.stat', Mautic\EmailBundle\Stat\StatHelper::class);
$services->get(Mautic\EmailBundle\EventListener\WebhookSubscriber::class)
->arg('$includeDetails', '%mautic.webhook_email_details%');
};

View File

@@ -0,0 +1,335 @@
<?php
namespace Mautic\EmailBundle\Controller;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\CacheBundle\Cache\CacheProvider;
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
use Mautic\CoreBundle\Controller\AjaxLookupControllerTrait;
use Mautic\CoreBundle\Controller\VariantAjaxControllerTrait;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\EmailBundle\Helper\PlainTextHelper;
use Mautic\EmailBundle\Mailer\Message\MauticMessage;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\EmailBundle\MonitoredEmail\Mailbox;
use Mautic\EmailBundle\Stats\EmailDependencies;
use Mautic\PageBundle\Form\Type\AbTestPropertiesType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\Address;
use Twig\Environment;
class AjaxController extends CommonAjaxController
{
use VariantAjaxControllerTrait;
use AjaxLookupControllerTrait;
public function getAbTestFormAction(Request $request, FormFactoryInterface $formFactory, EmailModel $emailModel, Environment $twig): JsonResponse
{
return $this->sendJsonResponse($this->getAbTestForm(
$request,
$emailModel,
fn ($formType, $formOptions) => $formFactory->create(AbTestPropertiesType::class, [], ['formType' => $formType, 'formTypeOptions' => $formOptions]),
fn ($form) => $this->renderView('@MauticEmail/AbTest/form.html.twig', ['form' => $this->setFormTheme($form, $twig, ['@MauticEmail/AbTest/form.html.twig', '@MauticEmail/FormTheme/Email/layout.html.twig'])]),
'email_abtest_settings',
'emailform'
));
}
public function sendBatchAction(Request $request): JsonResponse
{
$dataArray = ['success' => 0];
/** @var EmailModel $model */
$model = $this->getModel('email');
$objectId = $request->request->get('id', 0);
$pending = $request->request->get('pending', 0);
$limit = $request->request->get('batchlimit', 100);
if ($objectId && $entity = $model->getEntity($objectId)) {
$dataArray['success'] = 1;
$session = $request->getSession();
$progress = $session->get('mautic.email.send.progress', [0, (int) $pending]);
$stats = $session->get('mautic.email.send.stats', ['sent' => 0, 'failed' => 0, 'failedRecipients' => []]);
$inProgress = $session->get('mautic.email.send.active', false);
if ($pending && !$inProgress && $entity->isPublished()) {
$session->set('mautic.email.send.active', true);
[$batchSentCount, $batchFailedCount, $batchFailedRecipients] = $model->sendEmailToLists($entity, null, $limit);
$progress[0] += ($batchSentCount + $batchFailedCount);
$stats['sent'] += $batchSentCount;
$stats['failed'] += $batchFailedCount;
foreach ($batchFailedRecipients as $emails) {
$stats['failedRecipients'] = $stats['failedRecipients'] + $emails;
}
$session->set('mautic.email.send.progress', $progress);
$session->set('mautic.email.send.stats', $stats);
$session->set('mautic.email.send.active', false);
}
$dataArray['percent'] = ($progress[1]) ? ceil(($progress[0] / $progress[1]) * 100) : 100;
$dataArray['progress'] = $progress;
$dataArray['stats'] = $stats;
}
return $this->sendJsonResponse($dataArray);
}
/**
* Called by parent::getBuilderTokensAction().
*
* @return array
*/
protected function getBuilderTokens($query)
{
/** @var EmailModel $model */
$model = $this->getModel('email');
return $model->getBuilderComponents(null, ['tokens'], (string) $query);
}
public function generatePlaintTextAction(Request $request): JsonResponse
{
$custom = $request->request->get('custom');
$parser = new PlainTextHelper(
[
'base_url' => $request->getSchemeAndHttpHost().$request->getBasePath(),
]
);
$dataArray = [
'text' => $parser->setHtml($custom)->getText(),
];
return $this->sendJsonResponse($dataArray);
}
public function getAttachmentsSizeAction(Request $request, AssetModel $assetModel): JsonResponse
{
$assets = $request->query->all()['assets'] ?? [];
$size = 0;
if ($assets) {
$size = $assetModel->getTotalFilesize($assets);
}
return $this->sendJsonResponse(['size' => $size]);
}
/**
* Tests monitored email connection settings.
*/
public function testMonitoredEmailServerConnectionAction(Request $request, Mailbox $mailbox): JsonResponse
{
$dataArray = ['success' => 0, 'message' => ''];
if ($this->user->isAdmin()) {
$settings = $request->request->all();
if (empty($settings['password'])) {
$existingMonitoredSettings = $this->coreParametersHelper->get('monitored_email');
if (is_array($existingMonitoredSettings) && (!empty($existingMonitoredSettings[$settings['mailbox']]['password']))) {
$settings['password'] = $existingMonitoredSettings[$settings['mailbox']]['password'];
}
}
try {
$mailbox->setMailboxSettings($settings);
$folders = $mailbox->getListingFolders();
if (!empty($folders)) {
$dataArray['folders'] = '';
foreach ($folders as $folder) {
$dataArray['folders'] .= "<option value=\"$folder\">$folder</option>\n";
}
}
$dataArray['success'] = 1;
$dataArray['message'] = $this->translator->trans('mautic.core.success');
} catch (\Exception $e) {
$dataArray['message'] = $this->translator->trans($e->getMessage());
}
}
return $this->sendJsonResponse($dataArray);
}
public function sendTestEmailAction(TransportInterface $transport, UserHelper $userHelper, CoreParametersHelper $parametersHelper): Response
{
$user = $userHelper->getUser();
$email = (new MauticMessage())
->subject($this->translator->trans('mautic.email.config.mailer.transport.test_send.subject'))
->text($this->translator->trans('mautic.email.config.mailer.transport.test_send.body'))
->from(new Address($parametersHelper->get('mailer_from_email'), $parametersHelper->get('mailer_from_name') ?: ''))
->to(new Address($user->getEmail(), trim($user->getFirstName().' '.$user->getLastName()) ?: ''));
$success = 1;
$message = $this->translator->trans('mautic.core.success');
try {
$transport->send($email);
} catch (TransportExceptionInterface $e) {
$success = 0;
$message = $e->getMessage();
}
return $this->sendJsonResponse(['success' => $success, 'message' => $message]);
}
public function getEmailCountStatsAction(Request $request): JsonResponse
{
/** @var EmailModel $model */
$model = $this->getModel('email');
$id = $request->query->get('id');
$ids = $request->query->all()['ids'] ?? [];
// Support for legacy calls
if (!$ids && $id) {
$ids = [$id];
}
$data = [];
foreach ($ids as $id) {
if ($email = $model->getEntity($id)) {
$pending = $model->getPendingLeads($email, null, true);
$queued = $model->getQueuedCounts($email);
$data[] = [
'id' => $email->getId(),
'pending' => 'list' === $email->getEmailType() && $pending ? $this->translator->trans(
'mautic.email.stat.leadcount',
['%count%' => $pending]
) : 0,
'queued' => ($queued) ? $this->translator->trans('mautic.email.stat.queued', ['%count%' => $queued]) : 0,
'sentCount' => $this->translator->trans('mautic.email.stat.sentcount', ['%count%' => $email->getSentCount(true)]),
'readCount' => $this->translator->trans('mautic.email.stat.readcount', ['%count%' => $email->getReadCount(true)]),
'readPercent' => $this->translator->trans('mautic.email.stat.readpercent', ['%count%' => $email->getReadPercentage(true)]),
];
}
}
// Support for legacy calls
if ($request->get('id') && !empty($data[0])) {
$data = $data[0];
} else {
$data = [
'success' => 1,
'stats' => $data,
];
}
return new JsonResponse($data);
}
public function getEmailDeliveredCountAction(Request $request, CacheProvider $cacheProvider): JsonResponse
{
$emailId = (int) InputHelper::clean($request->query->get('id'));
if (0 === $emailId) {
return $this->sendJsonResponse([
'success' => 0,
'message' => $this->translator->trans('mautic.core.error.badrequest'),
], 400);
}
$cacheTimeout = (int) $this->coreParametersHelper->get('cached_data_timeout');
$cacheItem = $cacheProvider->getItem('email.stats.delivered.'.$emailId);
if ($cacheItem->isHit()) {
$deliveredCount = $cacheItem->get();
} else {
/** @var EmailModel $model */
$model = $this->getModel('email');
$email = $model->getEntity($emailId);
if (null === $email) {
return $this->sendJsonResponse([
'success' => 0,
'message' => $this->translator->trans('mautic.api.call.notfound'),
], 404);
}
$deliveredCount = $model->getDeliveredCount($email);
$cacheItem->set($deliveredCount);
$cacheItem->expiresAfter($cacheTimeout * 60);
$cacheProvider->save($cacheItem);
}
return $this->sendJsonResponse([
'success' => 1,
'delivered' => $deliveredCount,
]);
}
public function heatmapAction(Request $request, EmailModel $model): JsonResponse
{
$emailId = (int) $request->query->get('id');
$email = $model->getEntity($emailId);
if (null === $email) {
return $this->sendJsonResponse([
'message' => $this->translator->trans('mautic.api.call.notfound'),
], 404);
}
if (!$this->security->hasEntityAccess(
'email:emails:viewown',
'email:emails:viewother',
$email->getCreatedBy()
)
) {
return $this->accessDenied();
}
$content = $email->getCustomHtml();
$clickStats = $model->getEmailClickStats($emailId);
$totalUniqueClicks = array_sum(array_column($clickStats, 'unique_hits'));
$totalClicks = array_sum(array_column($clickStats, 'hits'));
foreach ($clickStats as &$stat) {
$stat['unique_hits_rate'] = round($totalUniqueClicks > 0 ? ($stat['unique_hits'] / $totalUniqueClicks) : 0, 4);
$stat['unique_hits_text'] = $this->translator->trans('mautic.email.heatmap.clicks', ['%count%' => $stat['unique_hits']]);
$stat['hits_rate'] = round($totalClicks > 0 ? ($stat['hits'] / $totalClicks) : 0, 4);
$stat['hits_text'] = $this->translator->trans('mautic.email.heatmap.clicks', ['%count%' => $stat['hits']]);
}
$legendTemplate = $this->renderView('@MauticEmail/Heatmap/heatmap_legend.html.twig', [
'totalClicks' => $totalClicks,
'totalUniqueClicks' => $totalUniqueClicks,
]);
return $this->sendJsonResponse([
'content' => $content,
'clickStats' => $clickStats,
'totalUniqueClicks' => $totalUniqueClicks,
'totalClicks' => $totalClicks,
'legendTemplate' => $legendTemplate,
]);
}
public function getEmailUsagesAction(Request $request, EmailDependencies $emailDependencies): JsonResponse
{
$emailId = (int) $request->query->get('id');
if (0 === $emailId) {
return $this->sendJsonResponse([
'message' => $this->translator->trans('mautic.core.error.badrequest'),
], 400);
}
$usagesHtml = $this->renderView('@MauticCore/Helper/usage.html.twig', [
'title' => $this->translator->trans('mautic.email.usages'),
'stats' => $emailDependencies->getChannelsIds($emailId),
'noUsages' => $this->translator->trans('mautic.email.no_usages'),
]);
return $this->sendJsonResponse([
'usagesHtml' => $usagesHtml,
]);
}
}

View File

@@ -0,0 +1,233 @@
<?php
namespace Mautic\EmailBundle\Controller\Api;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Controller\CommonApiController;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\RandomHelper\RandomHelperInterface;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Helper\MailHelper;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\EmailBundle\MonitoredEmail\Processor\Reply;
use Mautic\LeadBundle\Controller\LeadAccessTrait;
use Mautic\LeadBundle\Entity\Lead;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
/**
* @extends CommonApiController<Email>
*/
class EmailApiController extends CommonApiController
{
use LeadAccessTrait;
/**
* @var EmailModel|null
*/
protected $model;
/**
* @var array<string, mixed>
*/
protected $extraGetEntitiesArguments = ['ignoreListJoin' => true];
public function __construct(CorePermissions $security, Translator $translator, EntityResultHelper $entityResultHelper, RouterInterface $router, FormFactoryInterface $formFactory, AppVersion $appVersion, RequestStack $requestStack, ManagerRegistry $doctrine, ModelFactory $modelFactory, EventDispatcherInterface $dispatcher, CoreParametersHelper $coreParametersHelper)
{
$emailModel = $modelFactory->getModel('email');
\assert($emailModel instanceof EmailModel);
$this->model = $emailModel;
$this->entityClass = Email::class;
$this->entityNameOne = 'email';
$this->entityNameMulti = 'emails';
$this->serializerGroups = [
'emailDetails',
'categoryList',
'publishDetails',
'assetList',
'formList',
'leadListList',
];
$this->dataInputMasks = [
'customHtml' => 'html',
'dynamicContent' => [
'content' => 'html',
'filters' => [
'content' => 'html',
],
],
];
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
/**
* Obtains a list of emails.
*
* @return Response
*/
public function getEntitiesAction(Request $request, UserHelper $userHelper)
{
// get parent level only
$this->listFilters[] = [
'column' => 'e.variantParent',
'expr' => 'isNull',
];
return parent::getEntitiesAction($request, $userHelper);
}
/**
* Sends the email to it's assigned lists.
*
* @param int $id Email ID
*
* @return Response
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
*/
public function sendAction(Request $request, $id)
{
$entity = $this->model->getEntity($id);
if (null === $entity || !$entity->isPublished()) {
return $this->notFound();
}
if (!$this->checkEntityAccess($entity)) {
return $this->accessDenied();
}
$lists = $request->request->all()['lists'] ?? [];
$limit = $request->request->get('limit', null);
$batch = $request->request->get('batch', null);
[$count, $failed] = $this->model->sendEmailToLists($entity, $lists, $limit, $batch);
$view = $this->view(
[
'success' => 1,
'sentCount' => $count,
'failedRecipients' => $failed,
],
Response::HTTP_OK
);
return $this->handleView($view);
}
/**
* Sends the email to a specific lead.
*
* @param int $id Email ID
* @param int $leadId Lead ID
*
* @return Response
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
*/
public function sendLeadAction(Request $request, $id, $leadId)
{
$entity = $this->model->getEntity($id);
if (!$entity) {
return $this->notFound();
}
if (!$this->checkEntityAccess($entity)) {
return $this->accessDenied();
}
/** @var Lead $lead */
$lead = $this->checkLeadAccess($leadId, 'edit');
if ($lead instanceof Response) {
return $lead;
}
$post = $request->request->all();
$tokens = (!empty($post['tokens'])) ? $post['tokens'] : [];
$assetsIds = (!empty($post['assetAttachments'])) ? $post['assetAttachments'] : [];
$response = ['success' => false];
$cleanTokens = [];
foreach ($tokens as $token => $value) {
$value = InputHelper::html($value);
if (!preg_match('/^{.*?}$/', $token)) {
$token = '{'.$token.'}';
}
$cleanTokens[$token] = $value;
}
$leadFields = array_merge(['id' => $leadId], $lead->getProfileFields());
// Set owner_id to support the "Owner is mailer" feature
if ($lead->getOwner()) {
$leadFields['owner_id'] = $lead->getOwner()->getId();
}
$result = $this->model->sendEmail(
$entity,
$leadFields,
[
'source' => ['api', 0],
'tokens' => $cleanTokens,
'assetAttachments' => $assetsIds,
'return_errors' => true,
'ignoreDNC' => true,
'email_type' => MailHelper::EMAIL_TYPE_TRANSACTIONAL,
]
);
if (is_bool($result)) {
$response['success'] = $result;
} else {
$response['failed'] = $result;
}
$view = $this->view($response, Response::HTTP_OK);
return $this->handleView($view);
}
/**
* @param string $trackingHash
*
* @return Response
*/
public function replyAction(Reply $replyService, RandomHelperInterface $randomHelper, $trackingHash)
{
try {
$replyService->createReplyByHash($trackingHash, "api-{$randomHelper->generate()}");
} catch (EntityNotFoundException $e) {
return $this->notFound($e->getMessage());
}
return $this->handleView(
$this->view(['success' => true], Response::HTTP_CREATED)
);
}
protected function prepareParametersFromRequest(FormInterface $form, array &$params, ?object $entity = null, array $masks = [], array $fields = []): void
{
if (isset($params['publicPreview']) && $entity instanceof Email) {
$entity->setPublicPreview(InputHelper::boolean($params['publicPreview']) ?? false);
unset($params['publicPreview']);
}
parent::prepareParametersFromRequest($form, $params, $entity, $masks, $fields);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Controller;
use Mautic\CategoryBundle\Model\CategoryModel;
use Mautic\CoreBundle\Controller\AbstractFormController;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Form\Type\BatchCategoryType;
use Mautic\EmailBundle\Model\EmailActionModel;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class BatchEmailController extends AbstractFormController
{
/**
* Adds or removes categories to multiple emails defined by email ID.
*/
public function execAction(Request $request, EmailActionModel $actionModel, CategoryModel $categoryModel): JsonResponse
{
$params = $request->get('email_batch');
$ids = empty($params['ids']) ? [] : json_decode($params['ids']);
if ($ids && is_array($ids)) {
$newCategoryId = $params['newCategory'];
$newCategory = $categoryModel->getEntity($newCategoryId);
$affected = $actionModel->setCategory($ids, $newCategory);
$this->addFlashMessage('mautic.email.batch_emails_affected', [
'%count%' => count($affected),
]);
} else {
$this->addFlashMessage('mautic.core.error.ids.missing');
}
return new JsonResponse([
'closeModal' => true,
'flashes' => $this->getFlashContent(),
'affected' => !empty($affected) ? array_map(fn (Email $affected) => $affected->getId(), $affected) : [],
'newCategory' => [
'name' => !empty($newCategory) ? $newCategory->getTitle() : null,
'color' => !empty($newCategory) ? $newCategory->getColor() : null,
],
'callback' => 'emailBatchSubmitCallback',
]);
}
/**
* View the modal form for adding contacts into categories in batches.
*/
public function indexAction(): Response
{
$route = $this->generateUrl('mautic_email_batch_categories_set');
return $this->delegateView(
[
'viewParameters' => [
'form' => $this->createForm(
BatchCategoryType::class,
[],
[
'action' => $route,
'attr' => [
'data-submit-callback' => 'emailBatchSubmit',
],
]
)->createView(),
],
'contentTemplate' => '@MauticEmail/Batch/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_email_index',
'mauticContent' => 'emailBatch',
'route' => $route,
],
]
);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace Mautic\EmailBundle\Controller;
use Mautic\CoreBundle\Form\Type\DateRangeType;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\EmailBundle\Model\EmailModel;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class EmailGraphStatsController extends AbstractController
{
/**
* Loads a specific form into the detailed panel.
*
* @param int $objectId
* @param bool $isVariant
* @param string $dateFrom
* @param string $dateTo
*
* @throws \Exception
*/
public function viewAction(
Request $request,
EmailModel $model,
FormFactoryInterface $formFactory,
CorePermissions $security,
$objectId,
$isVariant,
$dateFrom = null,
$dateTo = null,
): \Symfony\Component\HttpFoundation\Response {
/** @var \Mautic\EmailBundle\Entity\Email $email */
$email = $model->getEntity($objectId);
// Init the date range filter form
$dateRangeValues = ['date_from' => $dateFrom, 'date_to' => $dateTo];
$action = $this->generateUrl('mautic_email_action', ['objectAction' => 'view', 'objectId' => $objectId]);
$dateRangeForm = $formFactory->create(DateRangeType::class, $dateRangeValues, ['action' => $action]);
if (null === $email || !$security->hasEntityAccess(
'email:emails:viewown',
'email:emails:viewother',
$email->getCreatedBy()
)) {
throw new AccessDeniedHttpException();
}
// get A/B test information
[$parent, $children] = $email->getVariants();
// get related translations
[$translationParent, $translationChildren] = $email->getTranslations();
// Prepare stats for bargraph
if ($chartStatsSource = $request->query->get('stats')) {
$includeVariants = ('all' === $chartStatsSource);
} else {
$includeVariants = (($email->isVariant() && $parent === $email) || ($email->isTranslation() && $translationParent === $email));
}
$dateFromObject = new \DateTime($dateFrom);
$dateToObject = new \DateTime($dateTo);
if ('template' === $email->getEmailType()) {
$stats = $model->getEmailGeneralStats(
$email,
$includeVariants,
null,
$dateFromObject,
$dateToObject
);
} else {
$stats = $model->getEmailListStats(
$email,
$includeVariants,
$dateFromObject,
$dateToObject
);
}
$statsDevices = $model->getEmailDeviceStats(
$email,
$includeVariants,
$dateFromObject,
$dateToObject
);
return $this->render(
'@MauticEmail/Email/graph.html.twig',
[
'email' => $email,
'stats' => $stats,
'statsDevices' => $statsDevices,
'showAllStats' => $includeVariants,
'dateRangeForm' => $dateRangeForm->createView(),
'isVariant' => $isVariant,
]
);
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Controller;
use Doctrine\DBAL\Exception;
use Mautic\CoreBundle\Helper\MapHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Model\EmailModel;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class EmailMapStatsController extends AbstractController
{
public const MAP_OPTIONS = [
'read_count' => [
'label' => 'mautic.email.stat.read',
'unit' => 'Read',
],
'clicked_through_count'=> [
'label' => 'mautic.email.clicked',
'unit' => 'Click',
],
];
public const LEGEND_TEXT = 'Total: %total (%withCountry with country)';
public function __construct(protected EmailModel $model)
{
}
/**
* @return array<string, array<int, array<string, int|string>>>
*
* @throws Exception
*/
public function getData(Email $entity, \DateTimeImmutable $dateFromObject, \DateTimeImmutable $dateToObject): array
{
// get A/B test information
$parent = $entity->getVariantParent();
// get translation parent
$translationParent = $entity->getTranslationParent();
$includeVariants = (($entity->isVariant() && empty($parent)) || ($entity->isTranslation() && empty($translationParent)));
return $this->model->getCountryStats(
$entity,
$dateFromObject,
$dateToObject,
$includeVariants,
);
}
public function hasAccess(CorePermissions $security, Email $entity): bool
{
return $security->hasEntityAccess(
'email:emails:viewown',
'email:emails:viewother',
$entity->getCreatedBy()
);
}
/**
* @return array<string, array<string, string>>
*/
public function getMapOptions(): array
{
return self::MAP_OPTIONS;
}
public function getMapOptionsTitle(): string
{
return 'mautic.email.stats.options.title';
}
/**
* @throws \Exception
*/
public function viewAction(
CorePermissions $security,
int $objectId,
string $dateFrom = '',
string $dateTo = '',
): Response {
$entity = $this->model->getEntity($objectId);
if (empty($entity) || !$this->hasAccess($security, $entity)) {
throw new AccessDeniedHttpException();
}
$statsCountries = $this->getData($entity, new \DateTimeImmutable($dateFrom), new \DateTimeImmutable($dateTo));
$mapData = MapHelper::buildMapData($statsCountries, $this->getMapOptions(), self::LEGEND_TEXT);
return $this->render(
'@MauticCore/Helper/map.html.twig',
[
'data' => $mapData[0]['data'],
'height' => 315,
'optionsEnabled' => true,
'optionsTitle' => $this->getMapOptionsTitle(),
'options' => $mapData,
'legendEnabled' => true,
'statUnit' => $mapData[0]['unit'],
]
);
}
}

View File

@@ -0,0 +1,814 @@
<?php
namespace Mautic\EmailBundle\Controller;
use Mautic\CoreBundle\Controller\FormController as CommonFormController;
use Mautic\CoreBundle\Helper\ThemeHelper;
use Mautic\CoreBundle\Helper\TrackingPixelHelper;
use Mautic\CoreBundle\Twig\Helper\AnalyticsHelper;
use Mautic\CoreBundle\Twig\Helper\AssetsHelper;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Event\EmailSendEvent;
use Mautic\EmailBundle\Event\TransportWebhookEvent;
use Mautic\EmailBundle\Helper\EmailConfig;
use Mautic\EmailBundle\Helper\MailHashHelper;
use Mautic\EmailBundle\Helper\MailHelper;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\FormBundle\Model\FormModel;
use Mautic\LeadBundle\Controller\FrequencyRuleTrait;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Helper\FakeContactHelper;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\MessengerBundle\Message\EmailHitNotification;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Event\PageDisplayEvent;
use Mautic\PageBundle\EventListener\BuilderSubscriber;
use Mautic\PageBundle\Model\PageModel;
use Mautic\PageBundle\PageEvents;
use Mautic\PluginBundle\Helper\IntegrationHelper;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class PublicController extends CommonFormController
{
use FrequencyRuleTrait;
/**
* @return Response
*/
public function indexAction(Request $request, AnalyticsHelper $analyticsHelper, $idHash)
{
/** @var EmailModel $model */
$model = $this->getModel('email');
$stat = $model->getEmailStatus($idHash);
if (!empty($stat)) {
if ($this->security->isAnonymous()) {
$model->hitEmail($stat, $request, true);
}
$tokens = $stat->getTokens();
if (is_array($tokens)) {
// Override tracking_pixel so as to not cause a double hit
$tokens['{tracking_pixel}'] = MailHelper::getBlankPixel();
}
if ($copy = $stat->getStoredCopy()) {
$subject = $copy->getSubject();
$content = $copy->getBody();
// Replace tokens
if (is_array($tokens)) {
$content = str_ireplace(array_keys($tokens), $tokens, $content);
$subject = str_ireplace(array_keys($tokens), $tokens, $subject);
}
} else {
$subject = '';
$content = '';
}
$content = $analyticsHelper->addCode($content);
// Add subject as title
if (!empty($subject)) {
if (str_contains($content, '<title></title>')) {
$content = str_replace('<title></title>', "<title>$subject</title>", $content);
} elseif (!str_contains($content, '<title>')) {
$content = str_replace('<head>', "<head>\n<title>$subject</title>", $content);
}
}
return new Response($content);
}
return $this->notFound();
}
public function trackingImageAction(
Request $request,
MessageBusInterface $messageBus,
LoggerInterface $logger,
string $idHash,
): Response {
try {
$messageBus->dispatch(new EmailHitNotification($idHash, $request));
} catch (\Exception $exception) {
$logger->error($exception->getMessage(), ['idHash' => $idHash]);
$emailModel = $this->getModel('email');
assert($emailModel instanceof EmailModel);
$emailModel->hitEmail($idHash, $request);
}
return TrackingPixelHelper::getResponse($request);
}
/**
* @return Response
*
* @throws \Exception
* @throws \Mautic\CoreBundle\Exception\FileNotFoundException
*/
public function unsubscribeAction(Request $request, ContactTracker $contactTracker, EmailModel $model, LeadModel $leadModel, FormModel $formModel, PageModel $pageModel, MailHashHelper $mailHash, ThemeHelper $themeHelper, $idHash, ?string $urlEmail = null, ?string $secretHash = null)
{
$stat = $model->getEmailStatus($idHash);
$message = '';
$email = null;
$lead = null;
$template = null;
$session = $request->getSession();
$isOneClickUnsubscribe = $request->isMethod(Request::METHOD_POST) && 'One-Click' === $request->get('List-Unsubscribe');
$isUnsubscribeAll = $request->get('unsubscribe_all');
$showContactPreferences = $this->coreParametersHelper->get('show_contact_preferences');
if (!empty($stat)) {
if ($isOneClickUnsubscribe) {
// RFC 8058 One-Click unsubscribe
$unsubscribeComment = $this->translator->trans('mautic.email.dnc.unsubscribed');
$model->setDoNotContact($stat, $unsubscribeComment, DoNotContact::UNSUBSCRIBED);
return new Response($this->translator->trans('mautic.lead.do.not.contact_unsubscribed'));
}
$email = $stat->getEmail();
}
$isCorrectHash = $secretHash && $urlEmail && $mailHash->getEmailHash($urlEmail) === $secretHash;
if ($email) {
$template = $email->getTemplate();
if ('mautic_code_mode' === $template) {
// Use system default
$template = null;
}
/** @var \Mautic\FormBundle\Entity\Form $unsubscribeForm */
$unsubscribeForm = $email->getUnsubscribeForm();
if (null != $unsubscribeForm && $unsubscribeForm->isPublished()) {
$formTemplate = $unsubscribeForm->getTemplate();
$formContent = '<div class="mautic-unsubscribeform">'.$formModel->getContent($unsubscribeForm).'</div>';
}
} else {
if ($isOneClickUnsubscribe) {
return new Response($this->translator->trans('mautic.email.stat_record.not_found'), Response::HTTP_NOT_FOUND);
}
}
if (empty($template) && empty($formTemplate)) {
$template = $this->coreParametersHelper->get('theme');
} elseif (!empty($formTemplate)) {
$template = $formTemplate;
}
$theme = $themeHelper->getTheme($template);
if ($theme->getTheme() != $template) {
$template = $theme->getTheme();
}
$contentTemplate = $themeHelper->checkForTwigTemplate('@themes/'.$template.'/html/message.html.twig');
if (!empty($stat) || $isCorrectHash) {
$successSessionName = 'mautic.email.prefscenter.success';
if (!empty($stat) && $lead = $stat->getLead()) {
// Set the lead as current lead
$contactTracker->setTrackedContact($lead);
// Set lead lang
if ($language = $lead->getPreferredLocale()) {
$this->translator->setLocale($language);
}
// Add contact ID to the session name in case more contacts
// share the same session/device and the contact is known.
$successSessionName .= ".{$lead->getId()}";
} elseif (empty($stat)) {
$leadRepo = $leadModel->getRepository();
$contacts = $leadRepo->getContactsByEmail($urlEmail);
$lead = null;
if (is_array($contacts) && count($contacts) > 0) {
$lead = array_pop($contacts);
} else {
$message = $this->translator->trans('mautic.email.stat_record.not_found');
}
}
if (!$showContactPreferences || $isUnsubscribeAll) {
if (!empty($stat)) {
$message = $this->getUnsubscribeMessage($idHash, $model, $stat, $this->translator);
} elseif ($lead && $lead instanceof Lead) {
$message = $this->getUnsubscribeMessageLead($idHash, $model, $lead, $this->translator, $urlEmail);
}
} elseif ($lead) {
$params = ['idHash' => $idHash, 'urlEmail' => $urlEmail];
if ($urlEmail) {
$params['secretHash'] = $mailHash->getEmailHash($urlEmail);
}
$action = $this->generateUrl('mautic_email_unsubscribe', $params);
$viewParameters = [
'lead' => $lead,
'idHash' => $idHash,
'showContactFrequency' => $this->coreParametersHelper->get('show_contact_frequency'),
'showContactPauseDates' => $this->coreParametersHelper->get('show_contact_pause_dates'),
'showContactPreferredChannels' => $this->coreParametersHelper->get('show_contact_preferred_channels'),
'showContactCategories' => $this->coreParametersHelper->get('show_contact_categories'),
'showContactSegments' => $this->coreParametersHelper->get('show_contact_segments'),
'dncUrl' => $this->generateUrl('mautic_email_unsubscribe_all', $params),
];
if ($session->get($successSessionName)) {
$viewParameters['successMessage'] = $this->translator->trans('mautic.email.preferences_center_success_message.text');
}
$form = $this->getFrequencyRuleForm($lead, $viewParameters, $data, true, $action, true);
if (true === $form) {
$session->set($successSessionName, 1);
return $this->postActionRedirect(
[
'returnUrl' => $action,
'viewParameters' => $viewParameters,
'contentTemplate' => $contentTemplate,
]
);
} else {
// success message should not persist on page refresh
$session->set($successSessionName, 0);
}
$formView = $form->createView();
/** @var Page $prefCenter */
if ($email && ($prefCenter = $email->getPreferenceCenter()) && $prefCenter->getIsPreferenceCenter()) {
// Set the page language if there is no lead preferred locale
if (empty($language) && $language = $prefCenter->getLanguage()) {
$this->translator->setLocale($language);
}
$html = $prefCenter->getCustomHtml();
// check if tokens are present
if (str_contains($html, BuilderSubscriber::saveprefsRegex)) {
// set custom tag to inject end form
// update show pref center tokens by looking for their presence in the html
$showParameters = $this->buildShowParametersBasedOnContent($html, $viewParameters);
$eventParameters = array_merge(
$viewParameters,
$showParameters,
[
'form' => $formView,
'startform' => $this->renderView('@MauticCore/Default/form.html.twig', ['form' => $formView]),
'custom_tag' => '<a name="end-'.$formView->vars['id'].'"></a>',
]
);
$event = new PageDisplayEvent($html, $prefCenter, $eventParameters);
$this->dispatcher->dispatch($event, PageEvents::PAGE_ON_DISPLAY);
$html = $event->getContent();
if (!$session->has($successSessionName)) {
$successMessageData = ['class="pref-successmessage"'];
$successMessageDataHidden = [];
foreach ($successMessageData as $successMessageData) {
$successMessageDataHidden[] = $successMessageData.' style=display:none';
}
$html = str_replace(
$successMessageData,
$successMessageDataHidden,
$html
);
} else {
$session->remove($successSessionName);
}
$html = preg_replace(
'/'.BuilderSubscriber::identifierToken.'/',
$lead->getPrimaryIdentifier(),
$html
);
$pageModel->hitPage($prefCenter, $request, 200, $lead);
} else {
unset($html);
}
}
if (empty($html)) {
$html = $this->render(
'@MauticEmail/Lead/preference_options.html.twig',
array_merge(
$viewParameters,
[
'form' => $formView,
'currentRoute' => $this->generateUrl(
'mautic_contact_action',
[
'objectAction' => 'contactFrequency',
'objectId' => $lead->getId(),
]
),
]
)
)->getContent();
}
$message = $html;
}
} else {
$message = $this->translator->trans('mautic.email.stat_record.not_found');
}
$config = $theme->getConfig();
$viewParams = [
'email' => $email,
'lead' => $lead,
'template' => $template,
'message' => $message,
];
if (!empty($formContent)) {
$viewParams['content'] = $formContent;
if (in_array('form', $config['features'])) {
$contentTemplate = $themeHelper->checkForTwigTemplate('@themes/'.$template.'/html/form.html.twig');
} else {
$viewParams['content'] = '';
$viewParams['message'] = $message.$formContent;
}
}
return $this->render($contentTemplate, $viewParams);
}
public function unsubscribeAllAction(Request $request, string $idHash, ?string $urlEmail = null, ?string $secretHash = null): Response
{
$request->attributes->set('unsubscribe_all', 1);
return $this->forward(static::class.'::unsubscribeAction', [
'request' => $request,
'idHash' => $idHash,
'urlEmail' => $urlEmail,
'secretHash' => $secretHash,
]);
}
/**
* @throws \Exception
* @throws \Mautic\CoreBundle\Exception\FileNotFoundException
*/
public function resubscribeAction(ContactTracker $contactTracker, EmailModel $model, MailHashHelper $mailHash, ThemeHelper $themeHelper, AssetsHelper $assetsHelper, AnalyticsHelper $analyticsHelper, $idHash): Response
{
$stat = $model->getEmailStatus($idHash);
if (!empty($stat)) {
$email = $stat->getEmail();
$lead = $stat->getLead();
if ($lead) {
// Set the lead as current lead
$contactTracker->setTrackedContact($lead);
if (!$this->translator instanceof LocaleAwareInterface) {
throw new \LogicException(sprintf('$this->translator must be an instance of "%s"', LocaleAwareInterface::class));
}
// Set lead lang
if ($lead->getPreferredLocale()) {
$this->translator->setLocale($lead->getPreferredLocale());
}
}
$model->removeDoNotContact($stat->getEmailAddress());
$message = $this->coreParametersHelper->get('resubscribe_message');
$toEmail = $stat->getEmailAddress();
$unsubscribeHash = $mailHash->getEmailHash($toEmail);
if (!$message) {
$message = $this->translator->trans(
'mautic.email.resubscribed.success',
[
'%unsubscribeUrl%' => '|URL|',
'%email%' => '|EMAIL|',
]
);
}
$message = str_replace(
[
'|URL|',
'|EMAIL|',
],
[
$this->generateUrl('mautic_email_unsubscribe', ['idHash' => $idHash, 'urlEmail' => $toEmail, 'secretHash' => $unsubscribeHash]),
$stat->getEmailAddress(),
],
$message
);
} else {
$email = $lead = false;
$message = $this->translator->trans('mautic.email.stat_record.not_found');
}
$template = (!empty($email) && 'mautic_code_mode' !== $email->getTemplate()) ? $email->getTemplate() : $this->coreParametersHelper->get('theme');
$theme = $themeHelper->getTheme($template);
if ($theme->getTheme() != $template) {
$template = $theme->getTheme();
}
// Ensure template still exists
$theme = $themeHelper->getTheme($template);
if (empty($theme) || $theme->getTheme() !== $template) {
$template = $this->coreParametersHelper->get('theme');
}
$analytics = $analyticsHelper->getCode();
if (!empty($analytics)) {
$assetsHelper->addCustomDeclaration($analytics);
}
$logicalName = $themeHelper->checkForTwigTemplate('@themes/'.$template.'/html/message.html.twig');
return $this->render(
$logicalName,
[
'message' => $message,
'type' => 'notice',
'email' => $email,
'lead' => $lead,
'template' => $template,
]
);
}
/**
* Handles mailer transport webhook post.
*/
public function mailerCallbackAction(Request $request): Response
{
$event = new TransportWebhookEvent($request);
$this->dispatcher->dispatch($event, EmailEvents::ON_TRANSPORT_WEBHOOK);
return $event->getResponse() ?? new Response('No email transport that could process this callback was found', Response::HTTP_NOT_FOUND);
}
/**
* Preview email.
*
* @return Response
*/
public function previewAction(
AnalyticsHelper $analyticsHelper,
ThemeHelper $themeHelper,
AssetsHelper $assetsHelper,
EmailConfig $emailConfig,
EmailModel $model,
Request $request,
LeadModel $leadModel,
FakeContactHelper $fakeLeadHelper,
string $objectId,
?string $objectType = null,
) {
$contactId = (int) $request->query->get('contactId');
$emailEntity = $model->getEntity($objectId);
if (null === $emailEntity) {
return $this->notFound();
}
$publicPreview = $emailEntity->isPublicPreview();
$draftEnabled = $emailConfig->isDraftEnabled();
if ('draft' === $objectType && $draftEnabled && $emailEntity->hasDraft()) {
$publicPreview = $emailEntity->getDraft()->isPublicPreview();
}
if (
($this->security->isAnonymous() && !$publicPreview)
|| (!$this->security->isAnonymous()
&& !$this->security->hasEntityAccess(
'email:emails:viewown',
'email:emails:viewother',
$emailEntity->getCreatedBy()
))
) {
return $this->accessDenied();
}
// bogus ID
if ($contactId && (
!$this->security->isAdmin()
&& !$this->security->hasEntityAccess('lead:leads:viewown', 'lead:leads:viewother')
)
) {
// disallow displaying contact information
$contactId = null;
}
// bogus ID
$idHash = 'xxxxxxxxxxxxxx';
$BCcontent = $emailEntity->getContent();
$content = $emailEntity->getCustomHtml();
if ('draft' === $objectType && $draftEnabled && $emailEntity->hasDraft()) {
$content = $emailEntity->getDraftContent();
}
if (empty($content) && !empty($BCcontent)) {
$template = $emailEntity->getTemplate();
$assetsHelper->addCustomDeclaration('<meta name="robots" content="noindex">');
$logicalName = $themeHelper->checkForTwigTemplate('@themes/'.$template.'/html/email.html.twig');
$response = $this->render(
$logicalName,
[
'inBrowser' => true,
'content' => $emailEntity->getContent(),
'email' => $emailEntity,
'lead' => null,
'template' => $template,
]
);
// replace tokens
$content = $response->getContent();
}
// Override tracking_pixel
$tokens = ['{tracking_pixel}' => ''];
// Prepare contact
if ($contactId) {
// We have one from request parameter
/** @var LeadModel $leadModel */
$contact = $leadModel->getRepository()->getLead($contactId);
$contact = $model->enrichedContactWithCompanies($contact);
} else {
// Make fake contact.
/** @var FakeContactHelper $fakeLeadHelper */
$contact = $fakeLeadHelper->prepareFakeContactWithPrimaryCompany();
}
// Generate and replace tokens
$event = new EmailSendEvent(
null,
[
'content' => $content,
'email' => $emailEntity,
'idHash' => $idHash,
'tokens' => $tokens,
'internalSend' => true,
'lead' => $contact,
]
);
$this->dispatcher->dispatch($event, EmailEvents::EMAIL_ON_DISPLAY);
$content = $event->getContent(true);
if ($this->security->isAnonymous()) {
$content = $analyticsHelper->addCode($content);
}
return new Response($content);
}
/**
* @throws \Exception
*/
private function doTracking(Request $request, IntegrationHelper $integrationHelper, MailHelper $mailer, LoggerInterface $mauticLogger, $integration): void
{
$logger = $mauticLogger;
// if additional data were sent with the tracking pixel
$query_string = $request->server->get('QUERY_STRING');
if (!$query_string) {
$logger->log('error', $integration.': query string is not available');
return;
}
if (str_starts_with($query_string, 'r=')) {
$query_string = substr($query_string, strpos($query_string, '?') + 1);
} // remove route variable
parse_str($query_string, $query);
// URL attr 'd' is encoded so let's decode it first.
if (!isset($query['d'], $query['sig'])) {
$logger->log('error', $integration.': query variables are not found');
return;
}
// get secret from plugin settings
$myIntegration = $integrationHelper->getIntegrationObject($integration);
if (!$myIntegration) {
$logger->log('error', $integration.': integration not found');
return;
}
$keys = $myIntegration->getDecryptedApiKeys();
// generate signature
$salt = $keys['secret'];
if (!str_contains($salt, '$1$')) {
$salt = '$1$'.$salt;
} // add MD5 prefix
$cr = crypt(urlencode($query['d']), $salt);
$mySig = hash('crc32b', $cr); // this hash type is used in c#
// compare signatures
if (hash_equals($mySig, $query['sig'])) {
// decode and parse query variables
$b64 = base64_decode($query['d']);
$gz = gzdecode($b64);
parse_str($gz, $query);
} else {
// signatures don't match: stop
$logger->log('error', $integration.': signatures don\'t match');
unset($query);
}
if (empty($query) || !isset($query['email'], $query['subject'], $query['body'])) {
$logger->log('error', $integration.': query variables are empty');
return;
}
if (MAUTIC_ENV === 'dev') {
$logger->log('error', $integration.': '.json_encode($query, JSON_PRETTY_PRINT));
}
/** @var EmailModel $model */
$model = $this->getModel('email');
// email is a semicolon delimited list of emails
$emails = explode(';', $query['email']);
$leadModel = $this->getModel('lead');
\assert($leadModel instanceof LeadModel);
$repo = $leadModel->getRepository();
foreach ($emails as $email) {
$lead = $repo->getLeadByEmail($email);
if (null === $lead) {
$lead = $this->createLead($email, $repo);
if (null === $lead) {
continue;
}
}
$idHash = hash('crc32', $email.$query['body']);
$idHash = substr($idHash.$idHash, 0, 13); // 13 bytes length
$stat = $model->getEmailStatus($idHash);
// stat doesn't exist, create one
if (null === $stat) {
$lead['email'] = $email; // needed for stat
$stat = $this->addStat($mailer, $lead, $email, $query, $idHash);
}
$stat->setSource('email.client');
if ($stat || 'Outlook' !== $integration) { // Outlook requests the tracking gif on send
$model->hitEmail($idHash, $request); // add email event
}
}
}
/**
* @return Response
*/
public function pluginTrackingGifAction(Request $request, IntegrationHelper $integrationHelper, MailHelper $mailer, LoggerInterface $mauticLogger, $integration)
{
$this->doTracking($request, $integrationHelper, $mailer, $mauticLogger, $integration);
return TrackingPixelHelper::getResponse($request); // send gif
}
private function addStat(MailHelper $mailer, $lead, $email, $query, $idHash): ?Stat
{
if (null !== $lead) {
// To lead
$mailer->addTo($email);
// sanitize variables to prevent malicious content
$from = filter_var($query['from'], FILTER_SANITIZE_EMAIL);
$mailer->setFrom($from, '');
// Set Content
$body = htmlspecialchars(filter_var($query['body'], FILTER_FLAG_STRIP_HIGH));
$mailer->setBody($body);
$mailer->parsePlainText($body);
// Set lead
$mailer->setLead($lead);
$mailer->setIdHash($idHash);
$subject = htmlspecialchars(filter_var($query['subject'], FILTER_FLAG_STRIP_HIGH));
$mailer->setSubject($subject);
return $mailer->createEmailStat();
}
return null;
}
private function createLead($email, $repo): ?Lead
{
$model = $this->getModel('lead.lead');
\assert($model instanceof LeadModel);
$lead = $model->getEntity();
// set custom field values
$data = ['email' => $email];
$model->setFieldValues($lead, $data, true);
// create lead
$model->saveEntity($lead);
// return entity
return $repo->getLeadByEmail($email);
}
public function getUnsubscribeMessage($idHash, $model, $stat, $translator): string
{
$model->setDoNotContact($stat, $translator->trans('mautic.email.dnc.unsubscribed'), DoNotContact::UNSUBSCRIBED);
return $this->getUnsubscribeText($translator, $stat->getEmailAddress(), $idHash);
}
public function getUnsubscribeMessageLead(string $idHash, EmailModel $model, Lead $lead, TranslatorInterface $translator, string $urlEmail): string
{
$model->setDoNotContactLead($lead, $translator->trans('mautic.email.dnc.unsubscribed'), DoNotContact::UNSUBSCRIBED);
return $this->getUnsubscribeText($translator, $urlEmail, $idHash);
}
private function getUnsubscribeText(TranslatorInterface $translator, string $email, string $idHash): string
{
$message = $this->coreParametersHelper->get('unsubscribe_message');
if (!$message) {
$message = $translator->trans(
'mautic.email.unsubscribed.success',
[
'%resubscribeUrl%' => '|URL|',
'%email%' => '|EMAIL|',
]
);
}
return str_replace(
[
'|URL|',
'|EMAIL|',
],
[
$this->generateUrl('mautic_email_resubscribe', ['idHash' => $idHash]),
$email,
],
$message
);
}
/**
* The $viewParameters here have already been used to build the $form.
* Fields that are set to show based on the app configuration are part
* of the form. If the field is not configured to show, but a token exists
* for that field in the content, then we need to keep the configuration
* value instead of letting the content determine if it should show. This
* is because of what was stated above - fields that are not configured to
* to show are not part of the form. Attempting to render them will result
* in an error.
*
* @param mixed[] $viewParameters
*
* @return mixed[]
*/
private function buildShowParametersBasedOnContent(string $content, array $viewParameters): array
{
/*
* Since we're going to be merging this with the $viewParameters, filter out `true` values. We do not
* want to change a configured value from `false` to `true` because a value of `false` in the $viewParameters
* means that the field is not configured to show and therefore is not part of the form. Attempting to
* render that field just because a token for it exists will result in an error.
*/
$showParamsBasedOnContent = array_filter([
'showContactFrequency' => str_contains($content, BuilderSubscriber::channelfrequency),
'showContactSegments' => str_contains($content, BuilderSubscriber::segmentListRegex),
'showContactCategories' => str_contains($content, BuilderSubscriber::categoryListRegex),
'showContactPreferredChannels' => str_contains($content, BuilderSubscriber::preferredchannel),
], fn (bool $value) =>!$value);
$showParamsBasedOnConfiguration = array_filter($viewParameters, fn ($key) => str_starts_with($key, 'show'), ARRAY_FILTER_USE_KEY);
return array_merge($showParamsBasedOnConfiguration, $showParamsBasedOnContent);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Mautic\EmailBundle\DataFixtures\ORM;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Mautic\CoreBundle\Helper\CsvHelper;
use Mautic\CoreBundle\Helper\Serializer;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Model\EmailModel;
class LoadEmailData extends AbstractFixture implements OrderedFixtureInterface
{
public function __construct(
private EmailModel $emailModel,
) {
}
public function load(ObjectManager $manager): void
{
$emails = CsvHelper::csv_to_array(__DIR__.'/fakeemaildata.csv');
foreach ($emails as $count => $rows) {
$email = new Email();
$email->setDateAdded(new \DateTime());
$key = $count + 1;
foreach ($rows as $col => $val) {
if ('NULL' != $val) {
$setter = 'set'.ucfirst($col);
if (in_array($col, ['content', 'variantSettings'])) {
$val = Serializer::decode(stripslashes($val));
}
$email->$setter($val);
}
}
$email->addList($this->getReference('lead-list'));
$this->emailModel->getRepository()->saveEntity($email);
$this->setReference('email-'.$key, $email);
}
}
/**
* @return int
*/
public function getOrder()
{
return 9;
}
}

View File

@@ -0,0 +1,3 @@
"name","emailType","subject","template","language","content","plainText","publishUp","publishDown","readCount","sentCount","revision","variantSettings","variantStartDate","variantSentCount","variantReadCount","customHtml"
"Kaleidoscope Conf List","list","Join us at the 2014 Kaleidoscope Conference!","blank","en","a:2:{s:4:\"body\";s:113:\"<div>Dear {contactfield=firstname},</div><div>&nbsp;</div><div>Join us at the 2014 Kaleidoscope Conference!</div>\";s:6:\"footer\";s:71:\"<div>{webview_text}</div><div>&nbsp;</div><div>{unsubscribe_text}</div>\";}","Join us at the 2014 Kaleidoscope Conference!",NULL,NULL,0,0,1,"a:0:{}",NULL,0,0,"<!DOCTYPE html><html><head><title>{subject}</title><meta http-equiv=""X-UA-Compatible"" content=""IE=edge""> <meta http-equiv=""Content-Type"" content=""text/html; charset=UTF-8""> <meta name=""viewport"" content=""width=device-width, initial-scale=1""><link href=""https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700"" rel=""stylesheet"" type=""text/css""><style type=""text/css"">#outlook a {padding: 0;}.ReadMsgBody {width: 100%;}.ExternalClass {width: 100%;}.ExternalClass * {line-height: 100%;}body {margin: 0;padding: 0;-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;}table,td {border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;}img {border: 0;height: auto;line-height: 100%;outline: none;text-decoration: none;-ms-interpolation-mode: bicubic;}p {display: block;margin: 13px 0;}@media only screen and (max-width:480px) {@-ms-viewport {width: 320px;}@viewport {width: 320px;}}body {font-family: ""Open Sans"", Helvetica, Arial, sans-serif !important;font-size:14px;line-height:1.6;text-align:left;color:#414141;}div[data-slot=""text""] {font-size:14px !important;line-height:1.6 !important;text-align:left !important;color:#414141 !important;margin-bottom: 10px !important;}div[style=""clear:both""] {margin-bottom: 20px !important;}.imagecard {background: #eeeeee !important;}.imagecard-caption {font-size:12px !important;line-height:1.6 !important;text-align:center !important;color:#414141 !important;background: #eeeeee !important;padding: 10px !important;}h1, h2, h3, h4, h5, h6 {margin: 0 !important;margin-bottom: 10px !important;}.outlook-group-fix { width:100% !important; }@media only screen and (min-width:480px) {.mj-column-per-100 {width: 100% !important;}}</style></head><body id=""i6gb""><div data-section-wrapper=""1"" id=""iveh""><div data-section=""1"" id=""idbj""><table align=""center"" border=""0"" cellpadding=""0"" cellspacing=""0"" role=""presentation"" id=""io7x""><tbody><tr><td id=""ixft""><div data-slot-container=""1"" class=""mj-column-per-100 outlook-group-fix"" id=""ibtf""><table background=""#FFFFFF"" border=""0"" cellpadding=""0"" cellspacing=""0"" role=""presentation"" width=""100%""><tbody><tr><td id=""iqneq""><table border=""0"" cellpadding=""0"" cellspacing=""0"" role=""presentation"" width=""100%""><tbody><tr><td align=""left"" id=""ig9zz""><div data-slot=""text"" id=""it3wc""><p>Dear <span class=""atwho-inserted"" data-fr-verified=""true"">{contactfield=firstname}</span>,</p><p><br data-cke-filler=""true""></p><p>Join us at the 2014 Kaleidoscope Conference!</p></div></td></tr></tbody></table></td></tr></tbody></table></div><div data-slot-container=""1"" class=""mj-column-per-100 outlook-group-fix"" id=""il9b3""><table border=""0"" cellpadding=""0"" cellspacing=""0"" role=""presentation"" width=""100%"" id=""id2w5""><tbody><tr><td align=""left"" id=""imf1o""><div data-slot=""text"" id=""ifpnn"">{unsubscribe_text} | {webview_text}</div></td></tr></tbody></table></div></td></tr></tbody></table></div></div><style>#iveh{background-color:#ffffff;}#idbj{Margin:0px auto;border-radius:4px;max-width:600px;}#io7x{width:100%;border-radius:4px;}#ixft{direction:ltr;padding:20px 0;text-align:center;vertical-align:top;}#ibtf{font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;}#iqneq{background-color:#FFFFFF;vertical-align:top;padding:20px 20px;}#ig9zz{padding:0;word-break:break-word;}#it3wc{font-family:'Open Sans', Helvetica, Arial, sans-serif;font-size:14px;line-height:1.6;text-align:left;color:#414141;}#il9b3{font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;}#id2w5{vertical-align:top;}#imf1o{padding:20px 20px;word-break:break-word;}#ifpnn{font-family:'Open Sans', Helvetica, Arial, sans-serif!important;font-size:12px!important;line-height:1.4!important;text-align:left!important;color:#999999!important;}</style></body></html>"
"Kaleidoscope Conf Campaign","template","Join us at the 2014 Kaleidoscope Conference!","blank","en","a:2:{s:4:\"body\";s:113:\"<div>Dear {contactfield=firstname},</div><div>&nbsp;</div><div>Join us at the 2014 Kaleidoscope Conference!</div>\";s:6:\"footer\";s:71:\"<div>{webview_text}</div><div>&nbsp;</div><div>{unsubscribe_text}</div>\";}","Join us at the 2014 Kaleidoscope Conference!",NULL,NULL,0,0,1,"a:0:{}",NULL,0,0,"<!DOCTYPE html><html><head><title>{subject}</title><meta http-equiv=""X-UA-Compatible"" content=""IE=edge""> <meta http-equiv=""Content-Type"" content=""text/html; charset=UTF-8""> <meta name=""viewport"" content=""width=device-width, initial-scale=1""><link href=""https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700"" rel=""stylesheet"" type=""text/css""><style type=""text/css"">#outlook a {padding: 0;}.ReadMsgBody {width: 100%;}.ExternalClass {width: 100%;}.ExternalClass * {line-height: 100%;}body {margin: 0;padding: 0;-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;}table,td {border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;}img {border: 0;height: auto;line-height: 100%;outline: none;text-decoration: none;-ms-interpolation-mode: bicubic;}p {display: block;margin: 13px 0;}@media only screen and (max-width:480px) {@-ms-viewport {width: 320px;}@viewport {width: 320px;}}body {font-family: ""Open Sans"", Helvetica, Arial, sans-serif !important;font-size:14px;line-height:1.6;text-align:left;color:#414141;}div[data-slot=""text""] {font-size:14px !important;line-height:1.6 !important;text-align:left !important;color:#414141 !important;margin-bottom: 10px !important;}div[style=""clear:both""] {margin-bottom: 20px !important;}.imagecard {background: #eeeeee !important;}.imagecard-caption {font-size:12px !important;line-height:1.6 !important;text-align:center !important;color:#414141 !important;background: #eeeeee !important;padding: 10px !important;}h1, h2, h3, h4, h5, h6 {margin: 0 !important;margin-bottom: 10px !important;}.outlook-group-fix { width:100% !important; }@media only screen and (min-width:480px) {.mj-column-per-100 {width: 100% !important;}}</style></head><body id=""i6gb""><div data-section-wrapper=""1"" id=""iveh""><div data-section=""1"" id=""idbj""><table align=""center"" border=""0"" cellpadding=""0"" cellspacing=""0"" role=""presentation"" id=""io7x""><tbody><tr><td id=""ixft""><div data-slot-container=""1"" class=""mj-column-per-100 outlook-group-fix"" id=""ibtf""><table background=""#FFFFFF"" border=""0"" cellpadding=""0"" cellspacing=""0"" role=""presentation"" width=""100%""><tbody><tr><td id=""iqneq""><table border=""0"" cellpadding=""0"" cellspacing=""0"" role=""presentation"" width=""100%""><tbody><tr><td align=""left"" id=""ig9zz""><div data-slot=""text"" id=""it3wc""><p>Dear <span class=""atwho-inserted"" data-fr-verified=""true"">{contactfield=firstname}</span>,</p><p><br data-cke-filler=""true""></p><p>Join us at the 2014 Kaleidoscope Conference!</p></div></td></tr></tbody></table></td></tr></tbody></table></div><div data-slot-container=""1"" class=""mj-column-per-100 outlook-group-fix"" id=""il9b3""><table border=""0"" cellpadding=""0"" cellspacing=""0"" role=""presentation"" width=""100%"" id=""id2w5""><tbody><tr><td align=""left"" id=""imf1o""><div data-slot=""text"" id=""ifpnn"">{unsubscribe_text} | {webview_text}</div></td></tr></tbody></table></div></td></tr></tbody></table></div></div><style>#iveh{background-color:#ffffff;}#idbj{Margin:0px auto;border-radius:4px;max-width:600px;}#io7x{width:100%;border-radius:4px;}#ixft{direction:ltr;padding:20px 0;text-align:center;vertical-align:top;}#ibtf{font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;}#iqneq{background-color:#FFFFFF;vertical-align:top;padding:20px 20px;}#ig9zz{padding:0;word-break:break-word;}#it3wc{font-family:'Open Sans', Helvetica, Arial, sans-serif;font-size:14px;line-height:1.6;text-align:left;color:#414141;}#il9b3{font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;}#id2w5{vertical-align:top;}#imf1o{padding:20px 20px;word-break:break-word;}#ifpnn{font-family:'Open Sans', Helvetica, Arial, sans-serif!important;font-size:12px!important;line-height:1.4!important;text-align:left!important;color:#999999!important;}</style></body></html>"
Can't render this file because it contains an unexpected character in line 2 and column 105.

View File

@@ -0,0 +1,21 @@
<?php
namespace Mautic\EmailBundle\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
class StatHelperPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$definition = $container->getDefinition('mautic.email.stats.helper_container');
$taggedServices = $container->findTaggedServiceIds('mautic.email_stat_helper');
foreach ($taggedServices as $id => $tags) {
$definition->addMethodCall('addHelper', [
new Reference($id),
]);
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\DependencyInjection\EnvProcessor;
use Mautic\CoreBundle\Helper\Dsn\Dsn;
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
class MailerDsnEnvVarProcessor implements EnvVarProcessorInterface
{
public function getEnv(string $prefix, string $name, \Closure $getEnv): string
{
$env = $getEnv($name);
try {
Dsn::fromString($env);
return str_replace('%%', '%', $env);
} catch (\InvalidArgumentException) {
return 'invalid://null';
}
}
public static function getProvidedTypes(): array
{
return [
'mailer' => 'string',
'urlencoded-dsn' => 'string',
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticEmailExtension extends Extension
{
/**
* @param mixed[] $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
$loader->load('services.php');
}
}

View File

@@ -0,0 +1,291 @@
<?php
namespace Mautic\EmailBundle;
/**
* Events available for EmailBundle.
*/
final class EmailEvents
{
/**
* The mautic.email_token_replacement event is thrown right before the content is returned.
*
* The event listener receives a
* Mautic\CoreBundle\Event\TokenReplacementEvent instance.
*
* @var string
*/
public const TOKEN_REPLACEMENT = 'mautic.email_token_replacement';
/**
* The mautic.email_address_token_replacement event is thrown right before a email address token needs replacement.
*
* The event listener receives a
* Mautic\CoreBundle\Event\TokenReplacementEvent instance.
*
* @var string
*/
public const ON_EMAIL_ADDRESS_TOKEN_REPLACEMENT = 'mautic.email_address_token_replacement';
/**
* The mautic.email_on_open event is dispatched when an email is opened.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailOpenEvent instance.
*
* @var string
*/
public const EMAIL_ON_OPEN = 'mautic.email_on_open';
/**
* The mautic.email_on_send event is dispatched when an email is sent.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailSendEvent instance.
*
* @var string
*/
public const EMAIL_ON_SEND = 'mautic.email_on_send';
/**
* The mautic.email_pre_send event is dispatched when an email is clicked.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailSendEvent instance.
*
* @var string
*/
public const EMAIL_PRE_SEND = 'mautic.email_pre_send';
/**
* The mautic.email_on_display event is dispatched when an email is viewed via a browser.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailSendEvent instance.
*
* @var string
*/
public const EMAIL_ON_DISPLAY = 'mautic.email_on_display';
/**
* The mautic.email_on_build event is dispatched before displaying the email builder form to allow adding of tokens.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailEvent instance.
*
* @var string
*/
public const EMAIL_ON_BUILD = 'mautic.email_on_build';
/**
* The mautic.email_pre_save event is dispatched right before a email is persisted.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailEvent instance.
*
* @var string
*/
public const EMAIL_PRE_SAVE = 'mautic.email_pre_save';
/**
* The mautic.email_post_save event is dispatched right after a email is persisted.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailEvent instance.
*
* @var string
*/
public const EMAIL_POST_SAVE = 'mautic.email_post_save';
/**
* The mautic.email_pre_delete event is dispatched prior to when a email is deleted.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailEvent instance.
*
* @var string
*/
public const EMAIL_PRE_DELETE = 'mautic.email_pre_delete';
/**
* The mautic.email_post_delete event is dispatched after a email is deleted.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailEvent instance.
*
* @var string
*/
public const EMAIL_POST_DELETE = 'mautic.email_post_delete';
/**
* The mautic.monitored_email_config event is dispatched during the configuration in order to inject custom folder locations.
*
* The event listener receives a Mautic\CoreBundle\Event\MonitoredEmailEvent instance.
*
* @var string
*/
public const MONITORED_EMAIL_CONFIG = 'mautic.monitored_email_config';
/**
* The mautic.on_email_parse event is dispatched when a monitored email box retrieves messages.
*
* The event listener receives a Mautic\EmailBundle\Event\ParseEmailEvent instance.
*
* @var string
*/
public const EMAIL_PARSE = 'mautic.on_email_parse';
/**
* The mautic.on_email_pre_fetch event is dispatched prior to fetching email through a configured monitored inbox in order to set
* search criteria for the mail to be fetched.
*
* The event listener receives a Mautic\EmailBundle\Event\ParseEmailEvent instance.
*
* @var string
*/
public const EMAIL_PRE_FETCH = 'mautic.on_email_pre_fetch';
/**
* The mautic.on_email_failed event is dispatched when an email has failed to clear the queue and is about to be deleted
* in order to give a bundle a chance to do an action based on failed email if required.
*
* The event listener receives a Mautic\EmailBundle\Event\QueueEmailEvent instance.
*
* @var string
*/
public const EMAIL_FAILED = 'mautic.on_email_failed';
/**
* The mautic.on_email_resend event is dispatched when an attempt to resend an email occurs
* in order to give a bundle a chance to do an action based on failed email if required.
*
* The event listener receives a Mautic\EmailBundle\Event\QueueEmailEvent instance.
*
* @var string
*/
public const EMAIL_RESEND = 'mautic.on_email_resend';
/**
* The mautic.email.on_campaign_batch_action event is dispatched when the campaign action triggers.
*
* The event listener receives a Mautic\CampaignBundle\Event\PendingEvent
*
* @var string
*/
public const ON_CAMPAIGN_BATCH_ACTION = 'mautic.email.on_campaign_batch_action';
/**
* The mautic.email.on_campaign_trigger_decision event is fired when the campaign action triggers.
*
* The event listener receives a
* Mautic\CampaignBundle\Event\CampaignExecutionEvent
*
* @var string
*/
public const ON_CAMPAIGN_TRIGGER_DECISION = 'mautic.email.on_campaign_trigger_decision';
/**
* The mautic.email.on_campaign_trigger_condition event is dispatched when the campaign condition triggers.
*
* The event listener receives a
* Mautic\CampaignBundle\Event\CampaignExecutionEvent
*
* @var string
*/
public const ON_CAMPAIGN_TRIGGER_CONDITION = 'mautic.email.on_campaign_trigger_condition';
/**
* The mautic.email_on_reply event is dispatched when an reply came to an email.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailOpenEvent instance.
*
* @var string
*/
public const EMAIL_ON_REPLY = 'mautic.email_on_reply';
/**
* The mautic.email.on_email_validation event is dispatched when an email is validated through the validator.
*
* The event listener receives a Mautic\EmailBundle\Event\EmailValidationEvent
*
* @var string
*/
public const ON_EMAIL_VALIDATION = 'mautic.email.on_email_validation';
/**
* The mautic.email.on_sent_email_to_user event is dispatched when email is sent to user.
*
* The event listener receives a
* Mautic\PointBundle\Events\TriggerExecutedEvent
*
* @var string
*/
public const ON_SENT_EMAIL_TO_USER = 'mautic.email.on_sent_email_to_user';
/**
* @deprecated 2.13.0; to be removed in 3.0. Listen to ON_CAMPAIGN_BATCH_ACTION instead.
*
* The mautic.email.on_campaign_trigger_action event is fired when the campaign action triggers.
*
* The event listener receives a
* Mautic\CampaignBundle\Event\CampaignExecutionEvent
*
* @var string
*/
public const ON_CAMPAIGN_TRIGGER_ACTION = 'mautic.email.on_campaign_trigger_action';
/**
* The mautic.email.on_transport_webhook event is fired when an email transport service sends Mautic a webhook request.
*
* The event listener receives a
* Mautic\EmailBundle\Event\TransportWebhookEvent
*
* @var string
*/
public const ON_TRANSPORT_WEBHOOK = 'mautic.email.on_transport_webhook';
/**
* The mautic.email.on_open_rate_winner event is fired when there is a need to determine open rate winner.
*
* The event listener receives a
* Mautic\CoreBundle\Event\DetermineWinnerEvent
*
* @var string
*/
public const ON_DETERMINE_OPEN_RATE_WINNER = 'mautic.email.on_open_rate_winner';
/**
* The mautic.email.on_open_rate_winner event is fired when there is a need to determine clickthrough rate winner.
*
* The event listener receives a
* Mautic\CoreBundles\Event\DetermineWinnerEvent
*
* @var string
*/
public const ON_DETERMINE_CLICKTHROUGH_RATE_WINNER = 'mautic.email.on_clickthrough_rate_winner';
/**
* The mautic.email.on_email_stat_pre_save event is fired before an email stat batch is saved.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailStatEvent
*/
public const ON_EMAIL_STAT_PRE_SAVE = 'mautic.email.on_email_stat_pre_save';
/**
* The mautic.email.on_email_stat_post_save event is fired after an email stat batch is saved.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailStatEvent
*/
public const ON_EMAIL_STAT_POST_SAVE = 'mautic.email.on_email_stat_post_save';
/**
* The mautic.email.on_edit_submit event is fired after an email edit is successfully submitted.
*
* The event listener receives a
* Mautic\EmailBundle\Event\EmailEditSubmitEvent
*/
public const ON_EMAIL_EDIT_SUBMIT = 'mautic.email.on_edit_submit';
}

View File

@@ -0,0 +1,145 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class Copy
{
/**
* MD5 hash of the content.
*
* @var string
*/
private $id;
/**
* @var \DateTimeInterface
*/
private $dateCreated;
/**
* @var string|null
*/
private $body;
private ?string $bodyText = null;
/**
* @var string|null
*/
private $subject;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('email_copies')
->setCustomRepositoryClass(CopyRepository::class);
$builder->createField('id', 'string')
->makePrimaryKey()
->length(32)
->build();
$builder->createField('dateCreated', 'datetime')
->columnName('date_created')
->build();
$builder->addNullableField('body', 'text');
$builder->addNullableField('bodyText', 'text', 'body_text');
$builder->addNullableField('subject', 'text');
}
/**
* @return $this
*/
public function setId($id)
{
$this->id = $id;
return $this;
}
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @return \DateTimeInterface
*/
public function getDateCreated()
{
return $this->dateCreated;
}
/**
* @param \DateTime $dateCreated
*
* @return Copy
*/
public function setDateCreated($dateCreated)
{
$this->dateCreated = $dateCreated;
return $this;
}
/**
* @return string
*/
public function getBody()
{
return $this->body;
}
/**
* @param string $body
*
* @return Copy
*/
public function setBody($body)
{
$this->body = $body;
return $this;
}
/**
* @return mixed
*/
public function getSubject()
{
return $this->subject;
}
/**
* @param mixed $subject
*
* @return Copy
*/
public function setSubject($subject)
{
$this->subject = $subject;
return $this;
}
public function getBodyText(): ?string
{
return $this->bodyText;
}
public function setBodyText(?string $bodyText): self
{
$this->bodyText = $bodyText;
return $this;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\ORM\NoResultException;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<Copy>
*/
class CopyRepository extends CommonRepository
{
/**
* @param string $hash
* @param string $subject
* @param string $body
* @param string $bodyText
*/
public function saveCopy($hash, $subject, $body, $bodyText): bool
{
$db = $this->getEntityManager()->getConnection();
try {
$db->insert(
MAUTIC_TABLE_PREFIX.'email_copies',
[
'id' => $hash,
'body' => $body,
'body_text' => $bodyText,
'subject' => $subject,
'date_created' => (new \DateTime())->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'),
]
);
return true;
} catch (\Exception $e) {
error_log($e);
return false;
}
}
/**
* @param string $string md5 hash or content
*
* @return array
*/
public function findByHash($string, $subject = null)
{
if (null !== $subject) {
// Combine subject with $string and hash together
$string = $subject.$string;
}
// Assume that $string is already a md5 hash if 32 characters
$hash = (32 !== strlen($string)) ? $hash = md5($string) : $string;
$q = $this->createQueryBuilder($this->getTableAlias());
$q->where(
$q->expr()->eq($this->getTableAlias().'.id', ':id')
)
->setParameter('id', $hash);
try {
$result = $q->getQuery()->getSingleResult();
} catch (NoResultException) {
$result = null;
}
return $result;
}
public function getTableAlias(): string
{
return 'ec';
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class EmailDraft
{
private int $id;
public function __construct(private Email $email, private ?string $html, private ?string $template, private ?bool $publicPreview = true)
{
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('emails_draft')
->setCustomRepositoryClass(EmailDraftRepository::class)
->addLifecycleEvent('cleanUrlsInContent', Events::preUpdate)
->addLifecycleEvent('cleanUrlsInContent', Events::prePersist);
$builder->addId();
$builder->addNullableField('html', Types::TEXT);
$builder->addNullableField('template', Types::STRING);
$builder->createField('publicPreview', Types::BOOLEAN)
->columnName('public_preview')
->nullable(false)
->option('default', 1)
->build();
$builder->createOneToOne('email', Email::class)
->inversedBy('draft')
->addJoinColumn('email_id', 'id', false)
->build();
}
/**
* Lifecycle callback to clean URLs in the content.
*/
public function cleanUrlsInContent(): void
{
$this->decodeAmpersands($this->html);
}
/**
* Check all links in content and decode &amp;
* This even works with double encoded ampersands.
*/
private function decodeAmpersands(string &$content): void
{
if (preg_match_all('/((https?|ftps?):\/\/)([a-zA-Z0-9-\.{}]*[a-zA-Z0-9=}]*)(\??)([^\s\"\]]+)?/i', $content, $matches)) {
foreach ($matches[0] as $url) {
$newUrl = $url;
while (str_contains($newUrl, '&amp;')) {
$newUrl = str_replace('&amp;', '&', $newUrl);
}
$content = str_replace($url, $newUrl, $content);
}
}
}
public function getId(): int
{
return $this->id;
}
public function getEmail(): Email
{
return $this->email;
}
public function getHtml(): string
{
return $this->html;
}
public function setEmail(Email $email): void
{
$this->email = $email;
}
public function setHtml(string $html): void
{
$this->html = $html;
}
public function getTemplate(): string
{
return $this->template;
}
public function setTemplate(string $template): void
{
$this->template = $template;
}
public function isPublicPreview(): bool
{
return $this->publicPreview;
}
public function getPublishStatus(): bool
{
return $this->publicPreview;
}
public function setPublicPreview(bool $publicPreview): void
{
$this->publicPreview = $publicPreview;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
class EmailDraftRepository extends CommonRepository
{
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Ramsey\Uuid\Uuid;
class EmailReply
{
private string $id;
private \DateTimeInterface $dateReplied;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('email_stat_replies')
->setCustomRepositoryClass(EmailReplyRepository::class)
->addIndex(['stat_id', 'message_id'], 'email_replies')
->addIndex(['date_replied'], 'date_email_replied');
$builder->addUuid();
$builder->createManyToOne('stat', Stat::class)
->inversedBy('replies')
->addJoinColumn('stat_id', 'id', false, false, 'CASCADE')
->build();
$builder->createField('dateReplied', 'datetime')
->columnName('date_replied')
->build();
$builder->createField('messageId', 'string')
->columnName('message_id')
->build();
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('emailReply')
->addProperties(
[
'uuid',
'dateReplied',
'messageId',
]
)
->build();
}
/**
* @param string $messageId
*/
public function __construct(
private Stat $stat,
private $messageId,
?\DateTime $dateReplied = null,
) {
$this->id = Uuid::uuid4()->toString();
$this->dateReplied = $dateReplied ?? new \DateTime();
}
public function getId(): string
{
return $this->id;
}
/**
* @return Stat
*/
public function getStat()
{
return $this->stat;
}
/**
* @return \DateTimeInterface
*/
public function getDateReplied()
{
return $this->dateReplied;
}
/**
* @return string
*/
public function getMessageId()
{
return $this->messageId;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\TimelineTrait;
/**
* @extends CommonRepository<EmailReply>
*/
final class EmailReplyRepository extends CommonRepository implements EmailReplyRepositoryInterface
{
use TimelineTrait;
/**
* @param int|Lead|null $leadId
*
* @return array
*/
public function getByLeadIdForTimeline($leadId, $options)
{
if ($leadId instanceof Lead) {
$leadId = $leadId->getId();
}
$qb = $this->_em->getConnection()->createQueryBuilder();
$qb->from(MAUTIC_TABLE_PREFIX.'email_stat_replies', 'reply')
->innerJoin('reply', MAUTIC_TABLE_PREFIX.'email_stats', 'stat', 'reply.stat_id = stat.id')
->leftJoin('stat', MAUTIC_TABLE_PREFIX.'emails', 'email', 'stat.email_id = email.id')
->leftJoin('stat', MAUTIC_TABLE_PREFIX.'email_copies', 'email_copy', 'stat.copy_id = email_copy.id');
if (null !== $leadId) {
$qb->andWhere('stat.lead_id = :leadId')
->setParameter('leadId', $leadId);
}
if (!empty($options['fromDate'])) {
/** @var \DateTime $fromDate */
$fromDate = $options['fromDate'];
$qb->andWhere('reply.date_replied >= :fromDate')
->setParameter('fromDate', $fromDate->format('Y-m-d H:i:s'));
}
if (!empty($options['toDate'])) {
/** @var \DateTime $toDate */
$toDate = $options['toDate'];
$qb->andWhere('reply.date_replied <= :toDate')
->setParameter('toDate', $toDate->format('Y-m-d H:i:s'));
}
$qb->addSelect('reply.id')
->addSelect('reply.date_replied')
->addSelect('stat.lead_id')
->addSelect('email.name AS email_name')
->addSelect('email.subject')
->addSelect('email_copy.subject AS storedSubject');
return $this->getTimelineResults(
$qb,
$options,
'storedSubject, email.subject',
'reply.id',
[],
['date_replied']
);
}
public function getTableAlias(): string
{
return 'reply';
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Mautic\LeadBundle\Entity\Lead;
/**
* Interface EmailReplyRepositoryInterface.
*/
interface EmailReplyRepositoryInterface
{
/**
* @param int|Lead $leadId
* @param array $options
*
* @return array
*/
public function getByLeadIdForTimeline($leadId, $options);
}

View File

@@ -0,0 +1,846 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\ORM\Query;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Mautic\ChannelBundle\Entity\MessageQueue;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\ProjectBundle\Entity\ProjectRepositoryTrait;
/**
* @extends CommonRepository<Email>
*/
class EmailRepository extends CommonRepository
{
use ProjectRepositoryTrait;
public const EMAILS_PREFIX = 'e';
public const DNC_PREFIX = 'dnc';
public const TRACKABLE_PREFIX = 'tr';
public const REDIRECT_PREFIX = 'pr';
/**
* Get an array of do not email.
*
* @param array $leadIds
*/
public function getDoNotEmailList($leadIds = []): array
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->select('l.id, l.email')
->from(MAUTIC_TABLE_PREFIX.'lead_donotcontact', 'dnc')
->leftJoin('dnc', MAUTIC_TABLE_PREFIX.'leads', 'l', 'l.id = dnc.lead_id')
->where($q->expr()->eq('dnc.channel', $q->expr()->literal('email')))
->andWhere($q->expr()->neq('l.email', $q->expr()->literal('')));
if ($leadIds) {
$q->andWhere(
$q->expr()->in('l.id', $leadIds)
);
}
$results = $q->executeQuery()->fetchAllAssociative();
$dnc = [];
foreach ($results as $r) {
$dnc[$r['id']] = strtolower($r['email']);
}
return $dnc;
}
/**
* Check to see if an email is set as do not contact.
*
* @param string $email
*
* @return false|array{id: numeric-string, unsubscribed: bool, bounced: bool, manual: bool, comments: string}
*/
public function checkDoNotEmail($email)
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->select('dnc.*')
->from(MAUTIC_TABLE_PREFIX.'lead_donotcontact', 'dnc')
->leftJoin('dnc', MAUTIC_TABLE_PREFIX.'leads', 'l', 'l.id = dnc.lead_id')
->where($q->expr()->eq('dnc.channel', $q->expr()->literal('email')))
->andWhere('l.email = :email')
->setParameter('email', $email);
$results = $q->executeQuery()->fetchAllAssociative();
$dnc = count($results) ? $results[0] : null;
if (null === $dnc) {
return false;
}
$dnc['reason'] = (int) $dnc['reason'];
return [
'id' => $dnc['id'],
'unsubscribed' => (DoNotContact::UNSUBSCRIBED === $dnc['reason']),
'bounced' => (DoNotContact::BOUNCED === $dnc['reason']),
'manual' => (DoNotContact::MANUAL === $dnc['reason']),
'comments' => $dnc['comments'],
];
}
/**
* Delete DNC row.
*
* @param int $id
*/
public function deleteDoNotEmailEntry($id): void
{
$this->getEntityManager()->getConnection()->delete(MAUTIC_TABLE_PREFIX.'lead_donotcontact', ['id' => (int) $id]);
}
/**
* Get a list of entities.
*
* @return Paginator
*/
public function getEntities(array $args = [])
{
$q = $this->getEntityManager()
->createQueryBuilder()
->select('e')
->from(Email::class, 'e', 'e.id');
if (empty($args['iterable_mode'])) {
$q->leftJoin('e.category', 'c');
if (empty($args['ignoreListJoin']) && (!isset($args['email_type']) || 'list' == $args['email_type'])) {
$q->leftJoin('e.lists', 'l');
}
}
$args['qb'] = $q;
return parent::getEntities($args);
}
/**
* Get amounts of sent and read emails.
*
* @return array
*/
public function getSentReadCount()
{
// Get entities
$q = $this->getEntityManager()->createQueryBuilder();
$q->select('SUM(e.sentCount) as sent_count, SUM(e.readCount) as read_count')
->from(Email::class, 'e');
$results = $q->getQuery()->getSingleResult(Query::HYDRATE_ARRAY);
if (!isset($results['sent_count'])) {
$results['sent_count'] = 0;
}
if (!isset($results['read_count'])) {
$results['read_count'] = 0;
}
return $results;
}
/**
* @param int $emailId
* @param int[]|null $variantIds
* @param int[]|null $listIds
* @param bool $countOnly
* @param int|null $limit
* @param int|null $minContactId
* @param int|null $maxContactId
* @param bool $countWithMaxMin
* @param \DateTime|null $maxDate
*
* @return QueryBuilder|int|array
*/
public function getEmailPendingQuery(
$emailId,
$variantIds = null,
$listIds = null,
$countOnly = false,
$limit = null,
$minContactId = null,
$maxContactId = null,
$countWithMaxMin = false,
$maxDate = null,
?int $maxThreads = null,
?int $threadId = null,
?\DateTimeInterface $sendStopDate = null,
) {
// Do not include leads in the do not contact table
$dncQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$dncQb->select('dnc.lead_id')
->from(MAUTIC_TABLE_PREFIX.'lead_donotcontact', 'dnc')
->where(
$dncQb->expr()->and(
$dncQb->expr()->eq('dnc.lead_id', 'l.id'),
$dncQb->expr()->eq('dnc.channel', $dncQb->expr()->literal('email'))
));
// Do not include contacts where the message is pending in the message queue
$mqQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$mqQb->select('mq.lead_id')
->from(MAUTIC_TABLE_PREFIX.'message_queue', 'mq')
->where(
$mqQb->expr()->and(
$mqQb->expr()->eq('mq.lead_id', 'l.id'),
$mqQb->expr()->neq('mq.status', $mqQb->expr()->literal(MessageQueue::STATUS_SENT)),
$mqQb->expr()->eq('mq.channel', $mqQb->expr()->literal('email'))
)
);
// Do not include leads that have already been emailed
$statQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$statQb->select('stat.lead_id')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 'stat');
$statQb->andWhere($statQb->expr()->isNotNull('stat.lead_id'));
if ($variantIds) {
if (!in_array($emailId, $variantIds)) {
$variantIds[] = (string) $emailId;
}
$statQb->andWhere($statQb->expr()->in('stat.email_id', $variantIds));
$mqQb->andWhere($mqQb->expr()->in('mq.channel_id', $variantIds));
} else {
$statQb->andWhere($statQb->expr()->eq('stat.email_id', (int) $emailId));
$mqQb->andWhere($mqQb->expr()->eq('mq.channel_id', (int) $emailId));
}
// Only include those who belong to the associated lead lists
if (is_null($listIds)) {
// Get a list of lists associated with this email
$lists = $this->getEntityManager()->getConnection()->createQueryBuilder()
->select('el.leadlist_id')
->from(MAUTIC_TABLE_PREFIX.'email_list_xref', 'el')
->where('el.email_id = '.(int) $emailId)
->executeQuery()
->fetchAllAssociative();
$listIds = array_column($lists, 'leadlist_id');
if (empty($listIds)) {
// Prevent fatal error
return ($countOnly) ? 0 : [];
}
} elseif (!is_array($listIds)) {
$listIds = [$listIds];
}
// Main query
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
// Only include those in associated segments
$segmentQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$segmentQb->select('ll.lead_id')
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'll')
->where(
$segmentQb->expr()->and(
$segmentQb->expr()->eq('ll.lead_id', 'l.id'),
$segmentQb->expr()->in('ll.leadlist_id', $listIds),
$segmentQb->expr()->eq('ll.manually_removed', ':false')
)
);
if (null !== $maxDate) {
$segmentQb->andWhere($segmentQb->expr()->lte('ll.date_added', ':max_date'));
$segmentQb->setParameter('max_date', $maxDate, \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE);
}
if ($sendStopDate) {
$segmentQb->andWhere($segmentQb->expr()->lt('ll.date_added', ':sendStopDate'));
$q->setParameter('sendStopDate', (new DateTimeHelper($sendStopDate))->toUtcString());
}
if ($countOnly) {
$q->select('count(*) as count');
if ($countWithMaxMin) {
$q->addSelect('MIN(l.id) as min_id, MAX(l.id) as max_id');
}
} else {
$q->select('l.*');
}
$q->from(MAUTIC_TABLE_PREFIX.'leads', 'l')
->andWhere(sprintf('l.id IN (%s)', $segmentQb->getSQL()))
->andWhere(sprintf('l.id NOT IN (%s)', $dncQb->getSQL()))
->andWhere(sprintf('l.id NOT IN (%s)', $statQb->getSQL()))
->andWhere(sprintf('l.id NOT IN (%s)', $mqQb->getSQL()))
->setParameter('false', false, 'boolean');
$excludedListQb = $this->getExcludedListQuery((int) $emailId);
if ($excludedListQb) {
$q->andWhere(sprintf('l.id NOT IN (%s)', $excludedListQb->getSQL()));
}
// Do not include leads which are not subscribed to the category set for email.
$unsubscribeLeadsQb = $this->getCategoryUnsubscribedLeadsQuery((int) $emailId);
$q->andWhere(sprintf('l.id NOT IN (%s)', $unsubscribeLeadsQb->getSQL()));
$q = $this->setMinMaxIds($q, 'l.id', $minContactId, $maxContactId);
// Has an email
$q->andWhere(
$q->expr()->and(
$q->expr()->isNotNull('l.email'),
$q->expr()->neq('l.email', $q->expr()->literal(''))
)
);
if ($threadId && $maxThreads) {
if ($threadId <= $maxThreads) {
$q->andWhere('MOD((l.id + :threadShift), :maxThreads) = 0')
->setParameter('threadShift', $threadId - 1, \Doctrine\DBAL\ParameterType::INTEGER)
->setParameter('maxThreads', $maxThreads, \Doctrine\DBAL\ParameterType::INTEGER);
}
}
if (!empty($limit)) {
$q->setFirstResult(0)
->setMaxResults($limit);
}
return $q;
}
/**
* @param int $emailId
* @param int[]|null $variantIds
* @param int[]|null $listIds
* @param bool $countOnly
* @param int|null $limit
* @param int|null $minContactId
* @param int|null $maxContactId
* @param bool $countWithMaxMin
*
* @return array|int
*/
public function getEmailPendingLeads(
$emailId,
$variantIds = null,
$listIds = null,
$countOnly = false,
$limit = null,
$minContactId = null,
$maxContactId = null,
$countWithMaxMin = false,
?int $maxThreads = null,
?int $threadId = null,
?\DateTimeInterface $sendStopDate = null,
) {
$q = $this->getEmailPendingQuery(
$emailId,
$variantIds,
$listIds,
$countOnly,
$limit,
$minContactId,
$maxContactId,
$countWithMaxMin,
null,
$maxThreads,
$threadId,
$sendStopDate
);
if (!($q instanceof QueryBuilder)) {
return $q;
}
$results = $q->executeQuery()->fetchAllAssociative();
if ($countOnly && $countWithMaxMin) {
// returns array in format ['count' => #, ['min_id' => #, 'max_id' => #]]
return $results[0];
} elseif ($countOnly) {
return (isset($results[0])) ? $results[0]['count'] : 0;
} else {
$leads = [];
foreach ($results as $r) {
$leads[$r['id']] = $r;
}
return $leads;
}
}
/**
* @param string|array<int|string> $search
* @param int $limit
* @param int $start
* @param bool $viewOther
* @param bool $topLevel
* @param string|null $emailType
* @param int|null $variantParentId
*
* @return array
*/
public function getEmailList($search = '', $limit = 10, $start = 0, $viewOther = false, $topLevel = false, $emailType = null, array $ignoreIds = [], $variantParentId = null)
{
$q = $this->createQueryBuilder('e');
$q->select('partial e.{id, subject, name, language}');
if (!empty($search)) {
if (is_array($search)) {
$search = array_map('intval', $search);
$q->andWhere($q->expr()->in('e.id', ':search'))
->setParameter('search', $search);
} else {
$q->andWhere($q->expr()->like('e.name', ':search'))
->setParameter('search', "%{$search}%");
}
}
if (!$viewOther) {
$q->andWhere($q->expr()->eq('e.createdBy', ':id'))
->setParameter('id', $this->currentUser->getId());
}
if ($topLevel) {
if (true === $topLevel || 'variant' == $topLevel) {
$q->andWhere($q->expr()->isNull('e.variantParent'));
} elseif ('translation' == $topLevel) {
$q->andWhere($q->expr()->isNull('e.translationParent'));
}
}
if ($variantParentId) {
$q->andWhere(
$q->expr()->andX(
$q->expr()->eq('IDENTITY(e.variantParent)', (int) $variantParentId),
$q->expr()->eq('e.id', (int) $variantParentId)
)
);
}
if (!empty($ignoreIds)) {
$q->andWhere($q->expr()->notIn('e.id', ':emailIds'))
->setParameter('emailIds', $ignoreIds);
}
if (!empty($emailType)) {
$q->andWhere(
$q->expr()->eq('e.emailType', $q->expr()->literal($emailType))
);
}
$q->orderBy('e.name');
if (!empty($limit)) {
$q->setFirstResult($start)
->setMaxResults($limit);
}
return $q->getQuery()->getArrayResult();
}
/**
* @return array<string, int>
*/
public function getSentReadNotReadCount(QueryBuilder $queryBuilder): array
{
$queryBuilder->resetQueryPart('groupBy');
$queryBuilder->resetQueryParts(['join']);
$queryBuilder->select('SUM( e.sent_count) as sent_count, SUM( e.read_count) as read_count');
$results = $queryBuilder->executeQuery()->fetchAssociative();
if ($results) {
$results['sent_count'] = (int) $results['sent_count'];
$results['read_count'] = (int) $results['read_count'];
$results['not_read'] = $results['sent_count'] - $results['read_count'];
} else {
$results = [];
$results['not_read'] = $results['sent_count'] = $results['read_count'] = 0;
}
return $results;
}
public function getUnsubscribedCount(QueryBuilder $queryBuilder): int
{
$queryBuilder->resetQueryParts(['join']);
$this->addDNCTableForEmails($queryBuilder);
$queryBuilder->select('e.id as email_id, dnc.lead_id');
$queryBuilder->andWhere('dnc.reason='.DoNotContact::UNSUBSCRIBED);
return $queryBuilder->executeQuery()->rowCount();
}
public function getUniqueClicks(QueryBuilder $queryBuilder): int
{
$this->addTrackableTablesForEmailStats($queryBuilder);
$queryBuilder->select('SUM( tr.unique_hits) as `unique_clicks`');
return (int) $queryBuilder->executeQuery()->fetchOne();
}
private function addTrackableTablesForEmailStats(QueryBuilder $qb): void
{
$trTable = MAUTIC_TABLE_PREFIX.'channel_url_trackables';
$prTable = MAUTIC_TABLE_PREFIX.'page_redirects';
if (!$this->isJoined($qb, $trTable, self::EMAILS_PREFIX, self::TRACKABLE_PREFIX)) {
$qb->leftJoin(
self::EMAILS_PREFIX,
$trTable,
self::TRACKABLE_PREFIX,
'e.id = tr.channel_id AND tr.channel = \'email\''
);
}
if (!$this->isJoined($qb, $prTable, self::TRACKABLE_PREFIX, self::REDIRECT_PREFIX)) {
$qb->leftJoin(
self::TRACKABLE_PREFIX,
$prTable,
self::REDIRECT_PREFIX,
'tr.redirect_id = pr.id'
);
}
}
/**
* Add the Do Not Contact table to the query builder.
*/
private function addDNCTableForEmails(QueryBuilder $qb): void
{
$table = MAUTIC_TABLE_PREFIX.'lead_donotcontact';
if (!$this->isJoined($qb, $table, self::EMAILS_PREFIX, self::DNC_PREFIX)) {
$qb->leftJoin(
self::EMAILS_PREFIX,
$table,
self::DNC_PREFIX,
'e.id = dnc.channel_id AND dnc.channel=\'email\''
);
}
}
private function isJoined(QueryBuilder $query, string $table, string $fromAlias, string $alias): bool
{
$joins = $query->getQueryParts()['join'][$fromAlias] ?? null;
if (empty($joins)) {
return false;
}
foreach ($joins[$fromAlias] as $join) {
if ($join['joinTable'] == $table && $join['joinAlias'] == $alias) {
return true;
}
}
return false;
}
/**
* @param \Doctrine\ORM\QueryBuilder|QueryBuilder $q
* @param object $filter
*/
protected function addCatchAllWhereClause($q, $filter): array
{
return $this->addStandardCatchAllWhereClause($q, $filter, [
'e.name',
'e.subject',
]);
}
/**
* @param \Doctrine\ORM\QueryBuilder|QueryBuilder $q
* @param object $filter
*/
protected function addSearchCommandWhereClause($q, $filter): array
{
[$expr, $parameters] = $this->addStandardSearchCommandWhereClause($q, $filter);
if ($expr) {
return [$expr, $parameters];
}
$command = $filter->command;
$unique = $this->generateRandomParameterName();
$returnParameter = false; // returning a parameter that is not used will lead to a Doctrine error
switch ($command) {
case $this->translator->trans('mautic.email.email.searchcommand.isexpired'):
case $this->translator->trans('mautic.email.email.searchcommand.isexpired', [], null, 'en_US'):
$expr = sprintf(
"(e.isPublished = :%1\$s AND e.publishDown IS NOT NULL AND e.publishDown <> '' AND e.publishDown < CURRENT_TIMESTAMP())",
$unique
);
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.email.email.searchcommand.ispending'):
case $this->translator->trans('mautic.email.email.searchcommand.ispending', [], null, 'en_US'):
$expr = sprintf(
"(e.isPublished = :%1\$s AND e.publishUp IS NOT NULL AND e.publishUp <> '' AND e.publishUp > CURRENT_TIMESTAMP())",
$unique
);
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.core.searchcommand.lang'):
$langUnique = $this->generateRandomParameterName();
$langValue = $filter->string.'_%';
$forceParameters = [
$langUnique => $langValue,
$unique => $filter->string,
];
$expr = '('.$q->expr()->eq('e.language', ":$unique").' OR '.$q->expr()->like('e.language', ":$langUnique").')';
$returnParameter = true;
break;
case $this->translator->trans('mautic.project.searchcommand.name'):
case $this->translator->trans('mautic.project.searchcommand.name', [], null, 'en_US'):
return $this->handleProjectFilter(
$this->_em->getConnection()->createQueryBuilder(),
'email_id',
'email_projects_xref',
$this->getTableAlias(),
$filter->string,
$filter->not
);
}
if ($expr && $filter->not) {
$expr = $q->expr()->not($expr);
}
if (!empty($forceParameters)) {
$parameters = $forceParameters;
} elseif ($returnParameter) {
$string = ($filter->strict) ? $filter->string : "%{$filter->string}%";
$parameters = ["$unique" => $string];
}
return [$expr, $parameters];
}
/**
* @return string[]
*/
public function getSearchCommands(): array
{
$commands = [
'mautic.core.searchcommand.ispublished',
'mautic.core.searchcommand.isunpublished',
'mautic.core.searchcommand.isuncategorized',
'mautic.core.searchcommand.ismine',
'mautic.email.email.searchcommand.isexpired',
'mautic.email.email.searchcommand.ispending',
'mautic.core.searchcommand.category',
'mautic.core.searchcommand.lang',
'mautic.project.searchcommand.name',
];
return array_merge($commands, parent::getSearchCommands());
}
/**
* @return array<array<string>>
*/
protected function getDefaultOrder(): array
{
return [
['e.name', 'ASC'],
];
}
public function getTableAlias(): string
{
return 'e';
}
/**
* Resets variant_start_date, variant_read_count, variant_sent_count.
*
* @param string[]|string|int $relatedIds
* @param string $date
*/
public function resetVariants($relatedIds, $date): void
{
if (!is_array($relatedIds)) {
$relatedIds = [(string) $relatedIds];
}
$qb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$qb->update(MAUTIC_TABLE_PREFIX.'emails')
->set('variant_read_count', 0)
->set('variant_sent_count', 0)
->set('variant_start_date', ':date')
->setParameter('date', $date)
->where(
$qb->expr()->in('id', $relatedIds)
)
->executeStatement();
}
public function upCountSent(int $id, int $increaseBy = 1, bool $variant = false): void
{
if ($increaseBy <= 0) {
return;
}
$connection = $this->getEntityManager()->getConnection();
$updateQuery = $connection->createQueryBuilder()
->update(MAUTIC_TABLE_PREFIX.'emails')
->set('sent_count', 'sent_count + :increaseBy')
->where('id = :id');
if ($variant) {
$updateQuery->set('variant_sent_count', 'variant_sent_count + :increaseBy');
}
$updateQuery
->setParameter('increaseBy', $increaseBy)
->setParameter('id', $id);
// Try to execute 3 times before throwing the exception
$retrialLimit = 3;
while ($retrialLimit >= 0) {
try {
$updateQuery->executeStatement();
return;
} catch (Exception $e) {
--$retrialLimit;
if (0 === $retrialLimit) {
throw $e;
}
}
}
}
public function incrementRead(int $emailId, string $statId, bool $isVariant = false): void
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$subQuery = $this->getEntityManager()->getConnection()->createQueryBuilder()
->select('es.email_id')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 'es')
->where('es.id = :statId')
->andWhere('es.is_read = 1');
$q->update(MAUTIC_TABLE_PREFIX.'emails', 'e')
->set('read_count', 'read_count + 1')
->where(
$q->expr()->and(
$q->expr()->eq('e.id', ':emailId'),
$q->expr()->notIn('e.id', $subQuery->getSQL())
)
)
->setParameter('emailId', $emailId)
->setParameter('statId', $statId);
if ($isVariant) {
$q->set('variant_read_count', 'variant_read_count + 1');
}
// Try to execute 3 times before throwing the exception
$retrialLimit = 3;
while ($retrialLimit >= 0) {
try {
$q->executeStatement();
return;
} catch (Exception $e) {
--$retrialLimit;
if (0 === $retrialLimit) {
throw $e;
}
}
}
}
/**
* @return iterable<Email>
*/
public function getPublishedBroadcastsIterable(?int $id = null): iterable
{
return $this->getPublishedBroadcastsQuery($id)->toIterable();
}
private function getPublishedBroadcastsQuery(?int $id = null): Query
{
$qb = $this->createQueryBuilder($this->getTableAlias());
$expr = $this->getPublishedByDateExpression($qb, null, true, true, false);
$expr->add(
$qb->expr()->eq($this->getTableAlias().'.emailType', $qb->expr()->literal('list'))
);
if (null !== $id && 0 !== $id) {
$expr->add(
$qb->expr()->eq($this->getTableAlias().'.id', (int) $id)
);
}
$qb->where($expr);
return $qb->getQuery();
}
/**
* Set Max and/or Min ID where conditions to the query builder.
*
* @param string $column
* @param int $minContactId
* @param int $maxContactId
*/
private function setMinMaxIds(QueryBuilder $q, $column, $minContactId, $maxContactId): QueryBuilder
{
if ($minContactId && is_numeric($minContactId)) {
$q->andWhere($column.' >= :minContactId');
$q->setParameter('minContactId', $minContactId);
}
if ($maxContactId && is_numeric($maxContactId)) {
$q->andWhere($column.' <= :maxContactId');
$q->setParameter('maxContactId', $maxContactId);
}
return $q;
}
private function getCategoryUnsubscribedLeadsQuery(int $emailId): QueryBuilder
{
$qb = $this->getEntityManager()->getConnection()
->createQueryBuilder();
return $qb->select('lc.lead_id')
->from(MAUTIC_TABLE_PREFIX.'lead_categories', 'lc')
->innerJoin('lc', MAUTIC_TABLE_PREFIX.'emails', 'e', 'e.category_id = lc.category_id')
->where($qb->expr()->eq('e.id', $emailId))
->andWhere('lc.manually_removed = 1');
}
private function getExcludedListQuery(int $emailId): ?QueryBuilder
{
$connection = $this->getEntityManager()
->getConnection();
$excludedListIds = $connection->createQueryBuilder()
->select('eel.leadlist_id')
->from(MAUTIC_TABLE_PREFIX.'email_list_excluded', 'eel')
->where('eel.email_id = :emailId')
->setParameter('emailId', $emailId)
->executeQuery()
->fetchFirstColumn();
if (!$excludedListIds) {
return null;
}
$queryBuilder = $connection->createQueryBuilder();
$queryBuilder->select('ll.lead_id')
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'll')
->where($queryBuilder->expr()->in('ll.leadlist_id', $excludedListIds));
return $queryBuilder;
}
}

View File

@@ -0,0 +1,653 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
class Stat
{
/**
* @var int Limit number of stored 'openDetails'
*/
public const MAX_OPEN_DETAILS = 1000;
public const TABLE_NAME = 'email_stats';
private ?string $id = null;
/**
* @var Email|null
*/
private $email;
/**
* @var Lead|null
*/
private $lead;
/**
* @var string
*/
private $emailAddress;
/**
* @var LeadList|null
*/
private $list;
/**
* @var IpAddress|null
*/
private $ipAddress;
private ?\DateTimeInterface $dateSent = null;
/**
* @var bool
*/
private $isRead = false;
/**
* @var bool
*/
private $isFailed = false;
/**
* @var bool
*/
private $viewedInBrowser = false;
/**
* @var \DateTimeInterface|null
*/
private $dateRead;
/**
* @var string|null
*/
private $trackingHash;
/**
* @var int|null
*/
private $retryCount = 0;
/**
* @var string|null
*/
private $source;
/**
* @var int|null
*/
private $sourceId;
/**
* @var array
*/
private $tokens = [];
/**
* @var Copy|null
*/
private $storedCopy;
/**
* @var int|null
*/
private $openCount = 0;
private ?\DateTimeInterface $lastOpened = null;
/**
* @var array
*/
private $openDetails = [];
/**
* @var ArrayCollection|EmailReply[]
*/
private $replies;
/**
* @var array<string,mixed[]>
*/
private $changes = [];
public function __construct()
{
$this->replies = new ArrayCollection();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(StatRepository::class)
->addIndex(['email_id', 'lead_id'], 'stat_email_search')
->addIndex(['lead_id', 'email_id'], 'stat_email_search2')
->addIndex(['is_failed'], 'stat_email_failed_search')
->addIndex(['is_read', 'date_sent'], 'is_read_date_sent')
->addIndex(['tracking_hash'], 'stat_email_hash_search')
->addIndex(['source', 'source_id'], 'stat_email_source_search')
->addIndex(['date_sent'], 'email_date_sent')
->addIndex(['date_read', 'lead_id'], 'email_date_read_lead')
->addIndex(['lead_id', 'date_sent'], 'stat_email_lead_id_date_sent')
->addIndex(['email_id', 'is_read'], 'stat_email_email_id_is_read');
$builder->addBigIntIdField();
$builder->createManyToOne('email', 'Email')
->inversedBy('stats')
->addJoinColumn('email_id', 'id', true, false, 'SET NULL')
->build();
$builder->addLead(true, 'SET NULL');
$builder->createField('emailAddress', 'string')
->columnName('email_address')
->build();
$builder->createManyToOne('list', LeadList::class)
->addJoinColumn('list_id', 'id', true, false, 'SET NULL')
->build();
$builder->addIpAddress(true);
$builder->createField('dateSent', 'datetime')
->columnName('date_sent')
->build();
$builder->createField('isRead', 'boolean')
->columnName('is_read')
->build();
$builder->createField('isFailed', 'boolean')
->columnName('is_failed')
->build();
$builder->createField('viewedInBrowser', 'boolean')
->columnName('viewed_in_browser')
->build();
$builder->createField('dateRead', 'datetime')
->columnName('date_read')
->nullable()
->build();
$builder->createField('trackingHash', 'string')
->columnName('tracking_hash')
->nullable()
->build();
$builder->createField('retryCount', 'integer')
->columnName('retry_count')
->nullable()
->build();
$builder->createField('source', 'string')
->nullable()
->build();
$builder->createField('sourceId', 'integer')
->columnName('source_id')
->nullable()
->build();
$builder->createField('tokens', 'array')
->nullable()
->build();
$builder->createManyToOne('storedCopy', Copy::class)
->addJoinColumn('copy_id', 'id', true, false, 'SET NULL')
->build();
$builder->addNullableField('openCount', 'integer', 'open_count');
$builder->addNullableField('lastOpened', 'datetime', 'last_opened');
$builder->addNullableField('openDetails', 'array', 'open_details');
$builder->createOneToMany('replies', EmailReply::class)
->mappedBy('stat')
->fetchExtraLazy()
->cascadeAll()
->build();
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('stat')
->addProperties(
[
'id',
'emailAddress',
'ipAddress',
'dateSent',
'isRead',
'isFailed',
'dateRead',
'retryCount',
'source',
'openCount',
'lastOpened',
'sourceId',
'trackingHash',
'viewedInBrowser',
'lead',
'email',
]
)
->build();
}
public function getDateRead(): ?\DateTimeInterface
{
return $this->dateRead;
}
public function setDateRead(?\DateTimeInterface $dateRead): void
{
$dateRead = $this->toDateTime($dateRead);
$this->addChange('dateRead', $this->dateRead, $dateRead);
$this->dateRead = $dateRead;
}
public function getDateSent(): ?\DateTimeInterface
{
return $this->dateSent;
}
public function setDateSent(?\DateTimeInterface $dateSent): void
{
$dateSent = $this->toDateTime($dateSent);
$this->addChange('dateSent', $this->dateSent, $dateSent);
$this->dateSent = $dateSent;
}
/**
* @return Email|null
*/
public function getEmail()
{
return $this->email;
}
public function setEmail(?Email $email = null): void
{
$this->email = $email;
}
public function getId(): ?string
{
return $this->id;
}
/**
* @return IpAddress|null
*/
public function getIpAddress()
{
return $this->ipAddress;
}
/**
* @param IpAddress|null $ip
*/
public function setIpAddress(IpAddress $ip): void
{
$this->ipAddress = $ip;
}
/**
* @return bool
*/
public function getIsRead()
{
return $this->isRead;
}
/**
* @return bool
*/
public function isRead()
{
return $this->getIsRead();
}
/**
* @param bool $isRead
*/
public function setIsRead($isRead): void
{
$this->addChange('isRead', $this->isRead, $isRead);
$this->isRead = $isRead;
}
/**
* @return Lead|null
*/
public function getLead()
{
return $this->lead;
}
public function setLead(?Lead $lead = null): void
{
$this->lead = $lead;
}
/**
* @return string|null
*/
public function getTrackingHash()
{
return $this->trackingHash;
}
/**
* @param string|null $trackingHash
*/
public function setTrackingHash($trackingHash): void
{
$this->trackingHash = $trackingHash;
}
/**
* @return LeadList|null
*/
public function getList()
{
return $this->list;
}
/**
* @param LeadList|null $list
*/
public function setList($list): void
{
$this->list = $list;
}
/**
* @return int
*/
public function getRetryCount()
{
return $this->retryCount;
}
/**
* @param int $retryCount
*/
public function setRetryCount($retryCount): void
{
$this->addChange('retryCount', $this->retryCount, $retryCount);
$this->retryCount = $retryCount;
}
/**
* Increase the retry count.
*/
public function upRetryCount(): void
{
$this->addChange('retryCount', $this->retryCount, $this->retryCount + 1);
++$this->retryCount;
}
/**
* @return bool
*/
public function getIsFailed()
{
return $this->isFailed;
}
/**
* @param bool $isFailed
*/
public function setIsFailed($isFailed): void
{
$this->addChange('isFailed', $this->isFailed, $isFailed);
$this->isFailed = $isFailed;
}
/**
* @return bool
*/
public function isFailed()
{
return $this->getIsFailed();
}
/**
* @return string|null
*/
public function getEmailAddress()
{
return $this->emailAddress;
}
/**
* @param string|null $emailAddress
*/
public function setEmailAddress($emailAddress): void
{
$this->addChange('emailAddress', $this->emailAddress, $emailAddress);
$this->emailAddress = $emailAddress;
}
/**
* @return bool
*/
public function getViewedInBrowser()
{
return $this->viewedInBrowser;
}
/**
* @param bool $viewedInBrowser
*/
public function setViewedInBrowser($viewedInBrowser): void
{
$this->addChange('viewedInBrowser', $this->viewedInBrowser, $viewedInBrowser);
$this->viewedInBrowser = $viewedInBrowser;
}
/**
* @return string|null
*/
public function getSource()
{
return $this->source;
}
/**
* @param string|null $source
*/
public function setSource($source): void
{
$this->addChange('source', $this->source, $source);
$this->source = $source;
}
/**
* @return int|null
*/
public function getSourceId()
{
return $this->sourceId;
}
/**
* @param int|null $sourceId
*/
public function setSourceId($sourceId): void
{
$this->addChange('sourceId', $this->sourceId, (int) $sourceId);
$this->sourceId = (int) $sourceId;
}
/**
* @return array|null
*/
public function getTokens()
{
return $this->tokens;
}
public function setTokens(array $tokens): void
{
$this->tokens = $tokens;
}
/**
* @return int
*/
public function getOpenCount()
{
return $this->openCount;
}
/**
* @param int $openCount
*
* @return Stat
*/
public function setOpenCount($openCount)
{
$this->addChange('openCount', $this->openCount, $openCount);
$this->openCount = $openCount;
return $this;
}
/**
* @param string $details
*/
public function addOpenDetails($details): void
{
if (self::MAX_OPEN_DETAILS > $this->getOpenCount()) {
$this->openDetails[] = $details;
}
++$this->openCount;
}
/**
* Up the sent count.
*
* @return Stat
*/
public function upOpenCount()
{
$count = (int) $this->openCount + 1;
$this->addChange('openCount', $this->openCount, $count);
$this->openCount = $count;
return $this;
}
public function getLastOpened(): ?\DateTimeInterface
{
return $this->lastOpened;
}
public function setLastOpened(?\DateTimeInterface $lastOpened): self
{
$lastOpened = $this->toDateTime($lastOpened);
$this->addChange('lastOpened', $this->lastOpened, $lastOpened);
$this->lastOpened = $lastOpened;
return $this;
}
/**
* @return array
*/
public function getOpenDetails()
{
return $this->openDetails;
}
/**
* @return Stat
*/
public function setOpenDetails(array $openDetails)
{
$this->openDetails = $openDetails;
return $this;
}
/**
* @return Copy|null
*/
public function getStoredCopy()
{
return $this->storedCopy;
}
/**
* @return Stat
*/
public function setStoredCopy(Copy $storedCopy)
{
$this->storedCopy = $storedCopy;
return $this;
}
/**
* @return ArrayCollection|EmailReply[]
*/
public function getReplies()
{
return $this->replies;
}
public function addReply(EmailReply $reply): void
{
$this->addChange('replyAdded', false, true);
$this->replies[] = $reply;
}
/**
* @return array<string,mixed[]>
*/
public function getChanges(): array
{
return $this->changes;
}
/**
* @param mixed $currentValue
* @param mixed $newValue
*/
private function addChange(string $property, $currentValue, $newValue): void
{
if ($currentValue === $newValue) {
return;
}
$this->changes[$property] = [$currentValue, $newValue];
}
/**
* @param \DateTime|\DateTimeImmutable|null $dateTime
*/
private function toDateTime($dateTime): ?\DateTime
{
return $dateTime instanceof \DateTimeImmutable ? \DateTime::createFromImmutable($dateTime) : $dateTime;
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\LeadBundle\Entity\LeadDevice;
class StatDevice
{
public const TABLE_NAME = 'email_stats_devices';
/**
* @var string
*/
private $id;
private ?Stat $stat;
/**
* @var LeadDevice|null
*/
private $device;
/**
* @var IpAddress|null
*/
private $ipAddress;
/**
* @var \DateTimeInterface
*/
private $dateOpened;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(StatDeviceRepository::class)
->addIndex(['date_opened'], 'date_opened_search');
$builder->addBigIntIdField();
$builder->createManyToOne('device', LeadDevice::class)
->addJoinColumn('device_id', 'id', true, false, 'CASCADE')
->build();
$builder->createManyToOne('stat', 'Stat')
->addJoinColumn('stat_id', 'id', true, false, 'CASCADE')
->build();
$builder->addIpAddress(true);
$builder->createField('dateOpened', 'datetime')
->columnName('date_opened')
->build();
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('stat')
->addProperties(
[
'id',
'device',
'ipAddress',
'stat',
]
)
->build();
}
public function getId(): int
{
return (int) $this->id;
}
/**
* @return IpAddress
*/
public function getIpAddress()
{
return $this->ipAddress;
}
/**
* @param mixed $ip
*/
public function setIpAddress(IpAddress $ip): void
{
$this->ipAddress = $ip;
}
public function getStat(): ?Stat
{
return $this->stat;
}
public function setStat(?Stat $stat): void
{
$this->stat = $stat;
}
/**
* @return mixed
*/
public function getDateOpened()
{
return $this->dateOpened;
}
/**
* @param mixed $dateOpened
*/
public function setDateOpened($dateOpened): void
{
$this->dateOpened = $dateOpened;
}
/**
* @return mixed
*/
public function getDevice()
{
return $this->device;
}
/**
* @param mixed $device
*/
public function setDevice(LeadDevice $device): void
{
$this->device = $device;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\DateTimeHelper;
/**
* @extends CommonRepository<StatDevice>
*/
class StatDeviceRepository extends CommonRepository
{
public function getDeviceStats($emailIds, ?\DateTime $fromDate = null, ?\DateTime $toDate = null): array
{
$qb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$qb->select('count(es.id) as count, d.device as device, es.list_id')
->from(MAUTIC_TABLE_PREFIX.'email_stats_devices', 'ed')
->join('ed', MAUTIC_TABLE_PREFIX.'lead_devices', 'd', 'd.id = ed.device_id')
->join('ed', MAUTIC_TABLE_PREFIX.'email_stats', 'es', 'es.id = ed.stat_id');
if (null != $emailIds) {
if (!is_array($emailIds)) {
$emailIds = [(int) $emailIds];
}
$qb->where(
$qb->expr()->in('es.email_id', $emailIds)
);
}
$qb->groupBy('es.list_id, d.device');
if (null !== $fromDate) {
// make sure the date is UTC
$dt = new DateTimeHelper($fromDate);
$qb->andWhere(
$qb->expr()->gte('es.date_read', $qb->expr()->literal($dt->toUtcString()))
);
}
if (null !== $toDate) {
// make sure the date is UTC
$dt = new DateTimeHelper($toDate);
$qb->andWhere(
$qb->expr()->lte('es.date_read', $qb->expr()->literal($dt->toUtcString()))
);
}
return $qb->executeQuery()->fetchAllAssociative();
}
}

View File

@@ -0,0 +1,826 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Entity\TimelineTrait;
/**
* @extends CommonRepository<Stat>
*/
class StatRepository extends CommonRepository
{
use TimelineTrait;
/**
* @return mixed
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getEmailStatus($trackingHash)
{
$q = $this->createQueryBuilder('s');
$q->select('s')
->leftJoin('s.lead', 'l')
->leftJoin('s.email', 'e')
->where(
$q->expr()->eq('s.trackingHash', ':hash')
)
->setParameter('hash', $trackingHash);
$result = $q->getQuery()->getResult();
return (!empty($result)) ? $result[0] : null;
}
/**
* @param int $contactId
* @param int $emailId
*
* @return array
*/
public function getUniqueClickedLinksPerContactAndEmail($contactId, $emailId)
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('distinct ph.url, ph.date_hit')
->from(MAUTIC_TABLE_PREFIX.'page_hits', 'ph')
->where('ph.email_id = :emailId')
->andWhere('ph.lead_id = :leadId')
->setParameter('leadId', $contactId)
->setParameter('emailId', $emailId);
$result = $q->executeQuery()->fetchAllAssociative();
foreach ($result as $row) {
$data[$row['date_hit']] = $row['url'];
}
return $data;
}
/**
* @param int $limit
* @param int|null $createdByUserId
* @param int|null $companyId
* @param int|null $campaignId
* @param int|null $segmentId
*/
public function getSentEmailToContactData(
$limit,
\DateTime $dateFrom,
\DateTime $dateTo,
$createdByUserId = null,
$companyId = null,
$campaignId = null,
$segmentId = null,
): array {
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->select('s.id, s.lead_id, s.email_address, s.is_read, s.email_id, s.date_sent, s.date_read')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 's')
->leftJoin('s', MAUTIC_TABLE_PREFIX.'emails', 'e', 's.email_id = e.id')
->addSelect('e.name AS email_name')
->leftJoin('s', MAUTIC_TABLE_PREFIX.'page_hits', 'ph', 'ph.source = \'email\' and ph.source_id = s.email_id and ph.lead_id = s.lead_id')
->addSelect('COUNT(ph.id) AS link_hits');
if (null !== $createdByUserId) {
$q->andWhere('e.created_by = :userId')
->setParameter('userId', $createdByUserId);
}
$q->andWhere('s.date_sent BETWEEN :dateFrom AND :dateTo')
->setParameter('dateFrom', $dateFrom->format('Y-m-d H:i:s'))
->setParameter('dateTo', $dateTo->format('Y-m-d H:i:s'));
$companyJoinOnExpr = $q->expr()->and(
$q->expr()->eq('s.lead_id', 'cl.lead_id')
);
if (!empty($companyId)) {
// Must force a one to one relationship
$companyJoinOnExpr->with(
$q->expr()->eq('cl.is_primary', 1)
);
}
$q->leftJoin('s', MAUTIC_TABLE_PREFIX.'companies_leads', 'cl', $companyJoinOnExpr)
->leftJoin('s', MAUTIC_TABLE_PREFIX.'companies', 'c', 'cl.company_id = c.id')
->addSelect('c.id AS company_id')
->addSelect('c.companyname AS company_name');
if (!empty($companyId)) {
$q->andWhere('cl.company_id = :companyId')
->setParameter('companyId', $companyId);
}
$q->leftJoin('s', MAUTIC_TABLE_PREFIX.'campaign_events', 'ce', 's.source = "campaign.event" and s.source_id = ce.id')
->leftJoin('ce', MAUTIC_TABLE_PREFIX.'campaigns', 'campaign', 'ce.campaign_id = campaign.id')
->addSelect('campaign.id AS campaign_id')
->addSelect('campaign.name AS campaign_name');
if (null !== $campaignId) {
$q->andWhere('ce.campaign_id = :campaignId')
->setParameter('campaignId', $campaignId);
}
$q->leftJoin('s', MAUTIC_TABLE_PREFIX.'lead_lists', 'll', 's.list_id = ll.id')
->addSelect('ll.id AS segment_id')
->addSelect('ll.name AS segment_name');
if (null !== $segmentId) {
$sb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$sb->select('null')
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'lll')
->where(
$sb->expr()->and(
$sb->expr()->eq('lll.leadlist_id', ':segmentId'),
$sb->expr()->eq('lll.lead_id', 'ph.lead_id'),
$sb->expr()->eq('lll.manually_removed', 0)
)
);
// Filter for both broadcasts and campaign related segments
$q->andWhere(
$q->expr()->or(
$q->expr()->eq('s.list_id', ':segmentId'),
$q->expr()->and(
$q->expr()->isNull('s.list_id'),
sprintf('EXISTS (%s)', $sb->getSQL())
)
)
)
->setParameter('segmentId', $segmentId);
}
$q->setMaxResults($limit);
$q->groupBy('s.id');
$q->orderBy('s.id', 'DESC');
return $q->executeQuery()->fetchAllAssociative();
}
/**
* @param array<int,int|string>|int|null $emailIds
* @param array<int,int|string>|int|true|null $listId
*/
public function getSentStats($emailIds, $listId = null): array
{
if (!is_array($emailIds)) {
$emailIds = [(int) $emailIds];
}
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('s.lead_id')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 's')
->where(
$q->expr()->in('s.email_id', $emailIds)
);
if ($listId) {
$q->andWhere('s.list_id = :list')
->setParameter('list', $listId);
}
$result = $q->executeQuery()->fetchAllAssociative();
// index by lead
$stats = [];
foreach ($result as $r) {
$stats[$r['lead_id']] = $r['lead_id'];
}
unset($result);
return $stats;
}
/**
* @param array<int,int|string>|int|null $emailIds
* @param array<int,int|string>|int|true|null $listId
* @param bool $combined
*
* @return array|int
*/
public function getSentCount($emailIds = null, $listId = null, ?ChartQuery $chartQuery = null, $combined = false)
{
return $this->getStatusCount('is_sent', $emailIds, $listId, $chartQuery, $combined);
}
/**
* @param array<int,int|string>|int|null $emailIds
* @param array<int,int|string>|int|null $listId
* @param bool $combined
*
* @return array|int
*/
public function getReadCount($emailIds = null, $listId = null, ?ChartQuery $chartQuery = null, $combined = false)
{
return $this->getStatusCount('is_read', $emailIds, $listId, $chartQuery, $combined);
}
/**
* @param array<int,int|string>|int|null $emailIds
* @param array<int,int|string>|int|true|null $listId
* @param bool $combined
*
* @return array|int
*/
public function getFailedCount($emailIds = null, $listId = null, ?ChartQuery $chartQuery = null, $combined = false)
{
return $this->getStatusCount('is_failed', $emailIds, $listId, $chartQuery, $combined);
}
/**
* @param string $column
* @param array<int,int|string>|int|null $emailIds
* @param array<int,int|string>|int|true|null $listId
* @param bool $combined
*
* @return array|int
*/
public function getStatusCount($column, $emailIds = null, $listId = null, ?ChartQuery $chartQuery = null, $combined = false)
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('count(s.id) as count')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 's');
if ($emailIds) {
if (!is_array($emailIds)) {
$emailIds = [(int) $emailIds];
}
$q->where(
$q->expr()->in('s.email_id', $emailIds)
);
}
if ($listId) {
if (!$combined) {
if (true === $listId) {
$q->addSelect('s.list_id')
->groupBy('s.list_id');
} elseif (is_array($listId)) {
$q->andWhere(
$q->expr()->in('s.list_id', ':segmentIds')
);
$q->setParameter('segmentIds', $listId, ArrayParameterType::INTEGER);
$q->addSelect('s.list_id')
->groupBy('s.list_id');
} else {
$q->andWhere('s.list_id = :list_id')
->setParameter('list_id', $listId);
}
} else {
$subQ = $this->getEntityManager()->getConnection()->createQueryBuilder();
$subQ->select('null')
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'list')
->andWhere(
$q->expr()->and(
$q->expr()->in('list.leadlist_id', array_map('intval', $listId)),
$q->expr()->eq('list.lead_id', 's.lead_id')
)
);
$q->andWhere(sprintf('EXISTS (%s)', $subQ->getSQL()));
}
}
if ('is_sent' === $column) {
$q->andWhere('s.is_failed = :false')
->setParameter('false', false, 'boolean');
} else {
$q->andWhere($column.' = :true')
->setParameter('true', true, 'boolean');
}
if ($chartQuery) {
if ('is_read' === $column) {
$chartQuery->applyDateFilters($q, 'date_read', 's');
} else {
$chartQuery->applyDateFilters($q, 'date_sent', 's');
}
}
$results = $q->executeQuery()->fetchAllAssociative();
if ((true === $listId || is_array($listId)) && !$combined) {
// Return list group of counts
$byList = [];
foreach ($results as $result) {
$byList[$result['list_id']] = $result['count'];
}
return $byList;
}
return (isset($results[0])) ? $results[0]['count'] : 0;
}
/**
* @param array<int,int|string>|int $emailIds
*/
public function getOpenedRates($emailIds, ?\DateTime $fromDate = null): array
{
$inIds = (!is_array($emailIds)) ? [$emailIds] : $emailIds;
$sq = $this->_em->getConnection()->createQueryBuilder();
$sq->select('e.email_id, count(e.id) as the_count')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 'e')
->where(
$sq->expr()->and(
$sq->expr()->eq('e.is_failed', ':false'),
$sq->expr()->in('e.email_id', $inIds)
)
)->setParameter('false', false, 'boolean');
if (null !== $fromDate) {
// make sure the date is UTC
$dt = new DateTimeHelper($fromDate);
$sq->andWhere(
$sq->expr()->gte('e.date_sent', $sq->expr()->literal($dt->toUtcString()))
);
}
$sq->groupBy('e.email_id');
// get a total number of sent emails first
$totalCounts = $sq->executeQuery()->fetchAllAssociative();
$return = [];
foreach ($inIds as $id) {
$return[$id] = [
'totalCount' => 0,
'readCount' => 0,
'readRate' => 0,
];
}
foreach ($totalCounts as $t) {
if (null != $t['email_id']) {
$return[$t['email_id']]['totalCount'] = (int) $t['the_count'];
}
}
// now get a read count
$sq->andWhere('e.is_read = :true')
->setParameter('true', true, 'boolean');
$readCounts = $sq->executeQuery()->fetchAllAssociative();
foreach ($readCounts as $r) {
$return[$r['email_id']]['readCount'] = (int) $r['the_count'];
$return[$r['email_id']]['readRate'] = ($return[$r['email_id']]['totalCount']) ?
round(($r['the_count'] / $return[$r['email_id']]['totalCount']) * 100, 2) :
0;
}
return (!is_array($emailIds)) ? $return[$emailIds] : $return;
}
/**
* @param array<int,int|string>|int $emailIds
*
* @return array<int, array<string, mixed>>
*/
public function getOpenedStatIds($emailIds = null, $listId = null): array
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('s.id')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 's');
if ($emailIds) {
if (!is_array($emailIds)) {
$emailIds = [(int) $emailIds];
}
$q->where(
$q->expr()->in('s.email_id', $emailIds)
);
}
$q->andWhere('open_count > 0');
if ($listId) {
$q->andWhere('s.list_id = '.(int) $listId);
}
return $q->executeQuery()->fetchAllAssociative();
}
/**
* Get a lead's email stat.
*
* @param int $leadId
*
* @return array
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getLeadStats($leadId, array $options = [])
{
$query = $this->getEntityManager()->getConnection()->createQueryBuilder();
$query->from(MAUTIC_TABLE_PREFIX.'email_stats', 's')
->leftJoin('s', MAUTIC_TABLE_PREFIX.'emails', 'e', 's.email_id = e.id')
->leftJoin('s', MAUTIC_TABLE_PREFIX.'email_copies', 'ec', 's.copy_id = ec.id');
if ($leadId) {
$query->andWhere('s.lead_id = :leadId')
->setParameter('leadId', $leadId);
}
if (!empty($options['basic_select'])) {
$query->select(
's.email_id, s.id, s.date_read as dateRead, s.date_sent as dateSent, e.subject, e.name as email_name, s.is_read as isRead, s.is_failed as isFailed, ec.subject as storedSubject'
);
} else {
$query->select(
's.email_id, s.id, s.date_read as dateRead, s.date_sent as dateSent,e.subject, e.name as email_name, s.is_read as isRead, s.is_failed as isFailed, s.viewed_in_browser as viewedInBrowser, s.retry_count as retryCount, s.list_id, l.name as list_name, s.tracking_hash as idHash, s.open_details as openDetails, ec.subject as storedSubject, s.lead_id'
)
->leftJoin('s', MAUTIC_TABLE_PREFIX.'lead_lists', 'l', 's.list_id = l.id');
}
$timestampColumn = 's.date_sent';
if (isset($options['state'])) {
$state = $options['state'];
if ('read' == $state) {
$timestampColumn = 's.date_read';
$query->andWhere(
$query->expr()->eq('s.is_read', 1)
);
} elseif ('failed' == $state) {
$query->andWhere(
$query->expr()->eq('s.is_failed', 1)
);
}
}
if (isset($options['search']) && $options['search']) {
$query->andWhere(
$query->expr()->or(
$query->expr()->like('ec.subject', ':search'),
$query->expr()->like('e.subject', ':search'),
$query->expr()->like('e.name', ':search')
)
)->setParameter('search', '%'.$options['search'].'%');
}
if (isset($options['fromDate']) && $options['fromDate']) {
$dt = new DateTimeHelper($options['fromDate']);
$query->andWhere(
$query->expr()->gte($timestampColumn, ':fromDate')
)->setParameter('fromDate', $dt->toUtcString());
}
$timeToReadParser = function (&$stat): void {
$dateSent = new DateTimeHelper($stat['dateSent']);
if (!empty($stat['dateSent']) && !empty($stat['dateRead'])) {
$stat['timeToRead'] = $dateSent->getDiff($stat['dateRead']);
} else {
$stat['timeToRead'] = false;
}
};
return $this->getTimelineResults(
$query,
$options,
'storedSubject, e.subject',
$timestampColumn,
['openDetails'],
['dateRead', 'dateSent'],
$timeToReadParser,
's.id'
);
}
/**
* Get counts for Sent, Read and Failed emails.
*
* @param QueryBuilder $query
*
* @return array
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getIgnoredReadFailed($query = null)
{
$query->select('count(es.id) as sent, count(CASE WHEN es.is_read THEN 1 ELSE null END) as "read", count(CASE WHEN es.is_failed THEN 1 ELSE null END) as failed');
$results = $query->executeQuery()->fetchAssociative();
if ($results) {
$results['ignored'] = $results['sent'] - $results['read'] - $results['failed'];
unset($results['sent']);
} else {
$results['ignored'] = $results['sent'] = $results['read'] = $results['failed'] = 0;
}
return $results;
}
/**
* Get pie graph data for Sent, Read and Failed email count.
*
* @param QueryBuilder $query
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getMostEmails($query, $limit = 10, $offset = 0): array
{
$query
->setMaxResults($limit)
->setFirstResult($offset);
return $query->executeQuery()->fetchAllAssociative();
}
/**
* Get sent counts based grouped by email Id.
*
* @param array $emailIds
*/
public function getSentCounts($emailIds = [], ?\DateTime $fromDate = null): array
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('e.email_id, count(e.id) as sentcount')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 'e')
->where(
$q->expr()->and(
$q->expr()->in('e.email_id', $emailIds),
$q->expr()->eq('e.is_failed', ':false')
)
)->setParameter('false', false, 'boolean');
if (null !== $fromDate) {
// make sure the date is UTC
$dt = new DateTimeHelper($fromDate);
$q->andWhere(
$q->expr()->gte('e.date_read', $q->expr()->literal($dt->toUtcString()))
);
}
$q->groupBy('e.email_id');
// get a total number of sent emails first
$results = $q->executeQuery()->fetchAllAssociative();
$counts = [];
foreach ($results as $r) {
$counts[$r['email_id']] = $r['sentcount'];
}
return $counts;
}
/**
* Updates lead ID (e.g. after a lead merge).
*/
public function updateLead($fromLeadId, $toLeadId): void
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'email_stats')
->set('lead_id', (int) $toLeadId)
->where('lead_id = '.(int) $fromLeadId)
->executeStatement();
}
/**
* Delete a stat.
*/
public function deleteStat($id): void
{
$this->getEntityManager()->getConnection()->delete(MAUTIC_TABLE_PREFIX.'email_stats', ['id' => (int) $id]);
}
public function deleteStats(array $ids): void
{
$qb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$qb->delete(MAUTIC_TABLE_PREFIX.'email_stats')
->where(
$qb->expr()->in('id', $ids)
)
->executeStatement();
}
public function getTableAlias(): string
{
return 's';
}
/**
* @return array
*/
public function findContactEmailStats($leadId, $emailId)
{
return $this->createQueryBuilder('s')
->where('IDENTITY(s.lead) = :leadId AND IDENTITY(s.email) = :emailId')
->setParameter('leadId', (int) $leadId)
->setParameter('emailId', (int) $emailId)
->getQuery()
->getResult();
}
public function checkContactSentEmail(int $contactId, int $emailId): bool
{
$query = $this->getEntityManager()->getConnection()->createQueryBuilder();
$query->from(MAUTIC_TABLE_PREFIX.'email_stats', 's');
$query->select('1')
->where('s.email_id = :emailId')
->andWhere('s.lead_id = :contactId')
->andWhere('is_failed = 0')
->setParameter('emailId', $emailId)
->setParameter('contactId', $contactId)
->setMaxResults(1);
return (bool) $query->executeQuery()->fetchOne();
}
/**
* @return array Formatted as [contactId => sentCount]
*/
public function getSentCountForContacts(array $contacts, $emailId): array
{
$query = $this->getEntityManager()->getConnection()->createQueryBuilder();
$query->from(MAUTIC_TABLE_PREFIX.'email_stats', 's');
$query->select('count(s.id) as sent_count, s.lead_id')
->where('s.email_id = :email')
->andWhere('s.lead_id in (:contacts)')
->andWhere('s.is_failed = 0')
->setParameter('email', $emailId)
->setParameter('contacts', $contacts, ArrayParameterType::INTEGER)
->groupBy('s.lead_id');
$results = $query->executeQuery()->fetchAllAssociative();
$contacts = [];
foreach ($results as $result) {
$contacts[$result['lead_id']] = $result['sent_count'];
}
return $contacts;
}
/**
* @param array<int> $contacts
*
* @return array<int, array<string, int|float>>
*
* @throws Exception
*/
public function getStatsSummaryForContacts(array $contacts): array
{
$queryBuilder = $this->getEntityManager()->getConnection()->createQueryBuilder();
$subQueryBuilder = $this->getEntityManager()->getConnection()->createQueryBuilder();
$leadAlias = 'l'; // leads
$statsAlias = 'es'; // email_stats
$subQueryAlias = 'sq'; // sub query
$cutAlias = 'cut'; // channel_url_trackables
$pageHitsAlias = 'ph'; // page_hits
// use sub query to get page hits for and unique page hits selected contacts
$subQueryBuilder->select(
"COUNT({$pageHitsAlias}.id) AS hits",
"COUNT(DISTINCT({$pageHitsAlias}.redirect_id)) AS unique_hits",
"{$cutAlias}.channel_id",
"{$pageHitsAlias}.lead_id"
)
->from(MAUTIC_TABLE_PREFIX.'channel_url_trackables', $cutAlias)
->join(
$cutAlias,
MAUTIC_TABLE_PREFIX.'page_hits',
$pageHitsAlias,
"{$cutAlias}.redirect_id = {$pageHitsAlias}.redirect_id AND {$cutAlias}.channel_id = {$pageHitsAlias}.source_id"
)
->where("{$cutAlias}.channel = 'email' AND {$pageHitsAlias}.source = 'email'")
->andWhere("{$pageHitsAlias}.lead_id in (:contacts)")
->setParameter('contacts', $contacts, ArrayParameterType::INTEGER)
->groupBy("{$cutAlias}.channel_id, {$pageHitsAlias}.lead_id");
// main query
$queryBuilder->select(
"{$leadAlias}.id AS `lead_id`",
"COUNT({$statsAlias}.id) AS `sent_count`",
"SUM(IF({$statsAlias}.is_read IS NULL, 0, {$statsAlias}.is_read)) AS `read_count`",
"SUM(IF({$subQueryAlias}.hits is NULL, 0, 1)) AS `clicked_through_count`",
)->from(MAUTIC_TABLE_PREFIX.'email_stats', $statsAlias)
->rightJoin(
$statsAlias,
MAUTIC_TABLE_PREFIX.'leads',
$leadAlias,
"{$statsAlias}.lead_id=l.id"
)->leftJoin(
$statsAlias,
"({$subQueryBuilder->getSQL()})",
$subQueryAlias,
"{$statsAlias}.email_id = {$subQueryAlias}.channel_id AND {$statsAlias}.lead_id = {$subQueryAlias}.lead_id"
)->andWhere("{$leadAlias}.id in (:contacts)")
->setParameter('contacts', $contacts, ArrayParameterType::INTEGER)
->groupBy("{$leadAlias}.id");
$results = $queryBuilder->executeQuery()->fetchAllAssociative();
$contacts = [];
foreach ($results as $result) {
$sentCount = (int) $result['sent_count'];
$readCount = (int) $result['read_count'];
$clickedCount = (int) $result['clicked_through_count'];
$contacts[(int) $result['lead_id']] = [
'sent_count' => $sentCount,
'read_count' => $readCount,
'clicked_count' => $clickedCount,
'open_rate' => round($sentCount > 0 ? ($readCount / $sentCount) : 0, 4),
'click_through_rate' => round($sentCount > 0 ? ($clickedCount / $sentCount) : 0, 4),
'click_through_open_rate' => round($readCount > 0 ? ($clickedCount / $readCount) : 0, 4),
];
}
return $contacts;
}
/**
* @param array<int|string> $emailsIds
* @param array<int> $eventsIds
*
* @return array<int, array<string, int|string>>
*
* @throws Exception
*/
public function getStatsSummaryByCountry(\DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo, array $emailsIds, string $sourceType = 'email', array $eventsIds = []): array
{
$queryBuilder = $this->getEntityManager()->getConnection()->createQueryBuilder();
$subQueryBuilder = $this->getEntityManager()->getConnection()->createQueryBuilder();
$leadAlias = 'l'; // leads
$statsAlias = 'es'; // email_stats
$subQueryAlias = 'sq'; // sub query
$cutAlias = 'cut'; // channel_url_trackables
$pageHitsAlias = 'ph'; // page_hits
// use sub query to get page hits for and unique page hits selected contacts
$subQueryBuilder->select(
"COUNT({$pageHitsAlias}.id) AS hits",
"COUNT(DISTINCT({$pageHitsAlias}.redirect_id)) AS unique_hits",
"{$cutAlias}.channel_id",
"{$pageHitsAlias}.lead_id"
)
->from(MAUTIC_TABLE_PREFIX.'channel_url_trackables', $cutAlias)
->join(
$cutAlias,
MAUTIC_TABLE_PREFIX.'page_hits',
$pageHitsAlias,
"{$cutAlias}.redirect_id = {$pageHitsAlias}.redirect_id AND {$cutAlias}.channel_id = {$pageHitsAlias}.source_id"
)
->where("{$cutAlias}.channel = 'email' AND {$pageHitsAlias}.source = 'email'")
->andWhere("{$cutAlias}.channel_id in (:emails)")
->groupBy("{$cutAlias}.channel_id, {$pageHitsAlias}.lead_id");
// main query
$queryBuilder->addSelect(
"COUNT({$statsAlias}.id) AS `sent_count`",
"SUM(IF({$statsAlias}.is_read IS NULL, 0, {$statsAlias}.is_read)) AS `read_count`",
"SUM(IF({$subQueryAlias}.hits is NULL, 0, 1)) AS `clicked_through_count`",
)->from(MAUTIC_TABLE_PREFIX.'email_stats', $statsAlias)
->rightJoin(
$statsAlias,
MAUTIC_TABLE_PREFIX.'leads',
$leadAlias,
"{$statsAlias}.lead_id=l.id"
)->leftJoin(
$statsAlias,
"({$subQueryBuilder->getSQL()})",
$subQueryAlias,
"{$statsAlias}.email_id = {$subQueryAlias}.channel_id AND {$statsAlias}.lead_id = {$subQueryAlias}.lead_id"
);
switch ($sourceType) {
case 'campaign':
$queryBuilder->addSelect("{$leadAlias}.country AS `country`")
->andWhere("{$statsAlias}.source_id in (:events)")
->andWhere("{$statsAlias}.source = :source")
->setParameter('emails', $emailsIds, ArrayParameterType::INTEGER)
->setParameter('events', $eventsIds, ArrayParameterType::INTEGER)
->setParameter('source', 'campaign.event');
break;
case 'email':
$queryBuilder->addSelect("{$leadAlias}.country AS `country`")
->andWhere("{$statsAlias}.email_id in (:emails)")
->setParameter('emails', $emailsIds, ArrayParameterType::INTEGER);
}
$queryBuilder->groupBy("{$leadAlias}.country")
->orderBy("{$leadAlias}.country", 'ASC');
$queryBuilder->andWhere("{$statsAlias}.date_sent BETWEEN :dateFrom AND :dateTo");
$queryBuilder->setParameter('dateFrom', $dateFrom->format(DateTimeHelper::FORMAT_DB));
$queryBuilder->setParameter('dateTo', $dateTo->setTime(23, 59, 59)->format('Y-m-d H:i:s'));
return $queryBuilder->executeQuery()->fetchAllAssociative();
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Mautic\EmailBundle\Event;
use Mautic\CoreBundle\Event\BuilderEvent;
use Mautic\EmailBundle\Entity\Email;
class EmailBuilderEvent extends BuilderEvent
{
/**
* @return Email|null
*/
public function getEmail()
{
return $this->entity;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\EmailBundle\Entity\Email;
class EmailEditSubmitEvent extends CommonEvent
{
public function __construct(
private Email $previousEmail,
private Email $currentEmail,
private bool $saveAndClose,
private bool $apply,
private bool $saveAsDraft,
private bool $applyDraft,
private bool $discardDraft,
) {
}
public function getPreviousEmail(): Email
{
return $this->previousEmail;
}
public function getCurrentEmail(): Email
{
return $this->currentEmail;
}
public function isSaveAndClose(): bool
{
return $this->saveAndClose;
}
public function isApply(): bool
{
return $this->apply;
}
public function isSaveAsDraft(): bool
{
return $this->saveAsDraft;
}
public function isApplyDraft(): bool
{
return $this->applyDraft;
}
public function isDiscardDraft(): bool
{
return $this->discardDraft;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Mautic\EmailBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\EmailBundle\Entity\Email;
class EmailEvent extends CommonEvent
{
/**
* @param bool $isNew
*/
public function __construct(Email &$email, $isNew = false)
{
$this->entity = &$email;
$this->isNew = $isNew;
}
/**
* Returns the Email entity.
*
* @return Email
*/
public function getEmail()
{
return $this->entity;
}
/**
* Sets the Email entity.
*/
public function setEmail(Email $email): void
{
$this->entity = $email;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Mautic\EmailBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\Stat;
use Symfony\Component\HttpFoundation\Request;
class EmailOpenEvent extends CommonEvent
{
private ?Email $email;
/**
* @param Request $request
* @param bool $firstTime
*/
public function __construct(
Stat $stat,
private $request,
private $firstTime = false,
) {
$this->entity = $stat;
$this->email = $stat->getEmail();
}
/**
* Returns the Email entity.
*
* @return Email
*/
public function getEmail()
{
return $this->email;
}
/**
* Get email request.
*
* @return string
*/
public function getRequest()
{
return $this->request;
}
/**
* @return Stat
*/
public function getStat()
{
return $this->entity;
}
/**
* Returns if this is first time the email is read.
*
* @return bool
*/
public function isFirstTime()
{
return $this->firstTime;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Mautic\EmailBundle\Event;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\Stat;
use Symfony\Contracts\EventDispatcher\Event;
class EmailReplyEvent extends Event
{
private ?Email $email;
public function __construct(
private Stat $stat,
) {
$this->email = $stat->getEmail();
}
/**
* Returns the Email entity.
*
* @return Email
*/
public function getEmail()
{
return $this->email;
}
/**
* @return Stat
*/
public function getStat()
{
return $this->stat;
}
}

View File

@@ -0,0 +1,387 @@
<?php
namespace Mautic\EmailBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Helper\MailHelper;
use Mautic\EmailBundle\Helper\PlainTextHelper;
use Mautic\LeadBundle\Entity\Lead;
class EmailSendEvent extends CommonEvent
{
/**
* @var Email|null
*/
private $email;
private string $content = '';
private string $plainText = '';
private string $subject = '';
/**
* @var string|null
*/
private $idHash;
/**
* @var Lead|mixed[]|null
*/
private $lead;
/**
* @var array
*/
private $source;
private array $tokens = [];
/**
* @var bool
*/
private $internalSend = false;
private array $textHeaders = [];
private bool $fatal = false;
private bool $skip = false;
/**
* @var array<string>
*/
private array $errors = [];
/**
* @param array $args
* @param bool $isDynamicContentParsing
*/
public function __construct(
private ?MailHelper $helper = null,
$args = [],
private $isDynamicContentParsing = false,
) {
$this->content = $args['content'] ?? '';
$this->plainText = $args['plainText'] ?? '';
$this->subject = $args['subject'] ?? '';
$this->email = $args['email'] ?? null;
$this->idHash = $args['idHash'] ?? null;
$this->lead = $args['lead'] ?? null;
$this->source = $args['source'] ?? [];
$this->tokens = $args['tokens'] ?? [];
$this->textHeaders = $args['textHeaders'] ?? [];
$this->errors = $args['errors'] ?? [];
$this->fatal = $args['fatal'] ?? false;
$this->skip = $args['skip'] ?? false;
if (!$this->subject && $this->email instanceof Email) {
$this->subject = (string) $args['email']->getSubject();
}
if (isset($args['internalSend'])) {
$this->internalSend = $args['internalSend'];
} elseif (null !== $helper) {
$this->internalSend = $helper->isInternalSend();
}
}
/**
* Check if this email is an internal send or to the lead; if an internal send, don't append lead tracking.
*
* @return bool
*/
public function isInternalSend()
{
return $this->internalSend;
}
/**
* Return if the transport and mailer is in batch mode (tokenized emails).
*/
public function inTokenizationMode(): bool
{
return null !== $this->helper && $this->helper->inTokenizationMode();
}
/**
* Returns the Email entity.
*
* @return ?Email
*/
public function getEmail()
{
return (null !== $this->helper) ? $this->helper->getEmail() : $this->email;
}
/**
* Get email content.
*
* @return string
*/
public function getContent($replaceTokens = false)
{
if (null !== $this->helper) {
$content = $this->helper->getBody();
} else {
$content = $this->content;
}
return ($replaceTokens) ? str_replace(array_keys($this->getTokens()), $this->getTokens(), $content) : $content;
}
/**
* Set email content.
*/
public function setContent($content): void
{
if (null !== $this->helper) {
$this->helper->setBody($content, 'text/html', null, true);
} else {
$this->content = $content;
}
$this->setGeneratedPlainText();
}
/**
* Get email content.
*
* @return string
*/
public function getPlainText()
{
if (null !== $this->helper) {
return $this->helper->getPlainText();
} else {
return $this->plainText;
}
}
public function setPlainText($content): void
{
if (null !== $this->helper) {
$this->helper->setPlainText($content);
} else {
$this->plainText = $content;
}
$this->setGeneratedPlainText();
}
/**
* Check if plain text is empty. If yes, generate it.
*/
private function setGeneratedPlainText(): void
{
$htmlContent = $this->getContent();
if ('' === $this->getPlainText() && '' !== $htmlContent) {
$parser = new PlainTextHelper();
$generatedPlainText = $parser->setHtml($htmlContent)->getText();
if ('' !== $generatedPlainText) {
$this->setPlainText($generatedPlainText);
}
}
}
/**
* @return string
*/
public function getSubject()
{
if (null !== $this->helper) {
return $this->helper->getSubject();
} else {
return $this->subject;
}
}
/**
* @param string $subject
*/
public function setSubject($subject): void
{
if (null !== $this->helper) {
$this->helper->setSubject($subject);
} else {
$this->subject = $subject;
}
}
/**
* Get the MailHelper object.
*
* @return MailHelper
*/
public function getHelper()
{
return $this->helper;
}
/**
* @return array|Lead|null
*/
public function getLead()
{
return (null !== $this->helper) ? $this->helper->getLead() : $this->lead;
}
/**
* @return string
*/
public function getIdHash()
{
return (null !== $this->helper) ? $this->helper->getIdHash() : $this->idHash;
}
/**
* @return array
*/
public function getSource()
{
return (null !== $this->helper) ? $this->helper->getSource() : $this->source;
}
public function addTokens(array $tokens): void
{
$this->tokens = array_merge($this->tokens, $tokens);
}
public function addToken($key, $value): void
{
$this->tokens[$key] = $value;
}
/**
* Get token array.
*/
public function getTokens($includeGlobal = true): array
{
$tokens = $this->tokens;
if ($includeGlobal && null !== $this->helper) {
$tokens = array_merge($this->helper->getGlobalTokens(), $tokens);
}
return $tokens;
}
public function addTextHeader($name, $value): void
{
if (null !== $this->helper) {
$this->helper->addCustomHeader($name, $value);
} else {
$this->textHeaders[$name] = $value;
}
}
public function getTextHeaders(): array
{
return (null !== $this->helper) ? $this->helper->getCustomHeaders() : $this->textHeaders;
}
/**
* Check if the listener should append it's own clickthrough in URLs or if the email tracking URL conversion process should take care of it.
*/
public function shouldAppendClickthrough(): bool
{
return !$this->isInternalSend() && null === $this->getEmail();
}
/**
* Generate a clickthrough array for URLs.
*/
public function generateClickthrough(): array
{
$source = $this->getSource();
$email = $this->getEmail();
$clickthrough = [
// what entity is sending the email?
'source' => $source,
// the email being sent to be logged in page hit if applicable
'email' => (null != $email) ? $email->getId() : null,
'stat' => $this->getIdHash(),
'sent_time' => time(),
];
$lead = $this->getLead();
if (null !== $lead) {
$clickthrough['lead'] = $lead['id'];
}
return $clickthrough;
}
/**
* Get the content hash to note if the content has been changed.
*
* @return string
*/
public function getContentHash()
{
if (null !== $this->helper) {
return $this->helper->getContentHash();
} else {
return md5($this->getContent().$this->getPlainText());
}
}
/**
* @return bool
*/
public function isDynamicContentParsing()
{
return $this->isDynamicContentParsing;
}
public function getCombinedContent(): string
{
$content = $this->getSubject();
$content .= $this->getContent();
$content .= $this->getPlainText();
$content .= $this->getEmail() ? $this->getEmail()->getCustomHtml() : '';
return $content.implode(' ', $this->getTextHeaders());
}
public function disableSkip(): void
{
$this->skip = false;
}
public function enableSkip(): void
{
$this->skip = true;
}
public function isSkip(): bool
{
return $this->skip;
}
public function enableFatal(): void
{
$this->fatal = true;
}
public function disableFatal(): void
{
$this->fatal = false;
}
public function isFatal(): bool
{
return $this->fatal;
}
public function addError(string $error): void
{
$this->errors[] = $error;
}
/**
* @return array<string>
*/
public function getErrors(): array
{
return $this->errors;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Event;
use Mautic\EmailBundle\Entity\Stat;
use Symfony\Contracts\EventDispatcher\Event;
final class EmailStatEvent extends Event
{
/**
* @param Stat[] $stats
*/
public function __construct(
private array $stats,
) {
}
/**
* @return Stat[]
*/
public function getStats(): array
{
return $this->stats;
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Mautic\EmailBundle\Event;
use Symfony\Contracts\EventDispatcher\Event;
class EmailValidationEvent extends Event
{
/**
* @var bool
*/
protected $isValid = true;
/**
* @var string|null
*/
protected $invalidReason;
/**
* @param string $address
*/
public function __construct(
protected $address,
) {
}
/**
* @return string
*/
public function getAddress()
{
return $this->address;
}
public function setInvalid($reason): void
{
$this->isValid = false;
$this->invalidReason = $reason;
$this->stopPropagation();
}
/**
* @return bool
*/
public function isValid()
{
return $this->isValid;
}
/**
* @return string|null
*/
public function getInvalidReason()
{
return $this->invalidReason;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Event;
use Mautic\EmailBundle\Entity\Email;
use Symfony\Contracts\EventDispatcher\Event;
final class ManualWinnerEvent extends Event
{
public function __construct(private Email $email)
{
}
public function getEmail(): Email
{
return $this->email;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Mautic\EmailBundle\Event;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\EventDispatcher\Event;
class MonitoredEmailEvent extends Event
{
private array $folders = [];
public function __construct(
private FormBuilderInterface $formBuilder,
private array $data,
) {
}
/**
* Get the FormBuilder for monitored_mailboxes FormType.
*
* @return FormBuilderInterface
*/
public function getFormBuilder()
{
return $this->formBuilder;
}
/**
* Insert a folder to configure.
*
* @param string $default
*/
public function addFolder($bundleKey, $folderKey, $label, $default = ''): void
{
$keyName = ($folderKey) ? $bundleKey.'_'.$folderKey : $bundleKey;
$this->folders[$keyName] = [
'label' => $label,
'default' => $default,
];
}
/**
* Get the value set for a specific bundle/folder.
*
* @return string
*/
public function getData($bundleKey, $folderKey, $default = '')
{
$keyName = $bundleKey.'_'.$folderKey;
return $this->data[$keyName] ?? $default;
}
/**
* Get array of folders.
*
* @return array
*/
public function getFolders()
{
return $this->folders;
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace Mautic\EmailBundle\Event;
use Symfony\Contracts\EventDispatcher\Event;
class ParseEmailEvent extends Event
{
/**
* @var mixed[]
*/
private array $criteriaRequests = [];
/**
* @var mixed[]
*/
private array $markAsSeen = [];
/**
* @param mixed[] $keys
*/
public function __construct(
private array $messages = [],
private array $keys = [],
) {
}
/**
* Get the array of messages.
*
* @return \Mautic\EmailBundle\MonitoredEmail\Message[]
*/
public function getMessages()
{
return $this->messages;
}
/**
* @return $this
*/
public function setMessages($messages)
{
$this->messages = $messages;
return $this;
}
/**
* @return array
*/
public function getKeys()
{
return $this->keys;
}
/**
* @param array $keys
*
* @return $this
*/
public function setKeys($keys)
{
$this->keys = $keys;
return $this;
}
/**
* Check if the set of messages is applicable and should be processed by the listener.
*/
public function isApplicable($bundleKey, $folderKeys): bool
{
if (!is_array($folderKeys)) {
$folderKeys = [$folderKeys];
}
foreach ($folderKeys as $folderKey) {
$key = $bundleKey.'_'.$folderKey;
if (in_array($key, $this->keys)) {
return true;
}
}
return false;
}
/**
* Set a criteria request for filtering fetched mail.
*
* @param string $bundleKey
* @param string $folderKeys
* @param string $criteria Should be a string using combinations of Mautic\EmailBundle\MonitoredEmail\Mailbox::CRITERIA_* constants
* @param bool $markAsSeen Mark the message as read after being processed
*/
public function setCriteriaRequest($bundleKey, $folderKeys, $criteria, $markAsSeen = true): void
{
if (!is_array($folderKeys)) {
$folderKeys = [$folderKeys];
}
foreach ($folderKeys as $folderKey) {
$key = $bundleKey.'_'.$folderKey;
$this->criteriaRequests[$key] = $criteria;
$this->markAsSeen[$key] = $markAsSeen;
}
}
/**
* @return array
*/
public function getCriteriaRequests()
{
return $this->criteriaRequests;
}
/**
* @return array
*/
public function getMarkAsSeenInstructions()
{
return $this->markAsSeen;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Mautic\EmailBundle\Event;
use Mautic\EmailBundle\Mailer\Message\MauticMessage;
use Symfony\Contracts\EventDispatcher\Event;
class QueueEmailEvent extends Event
{
private bool $retry = false;
public function __construct(
private MauticMessage $message,
) {
}
/**
* @return MauticMessage
*/
public function getMessage()
{
return $this->message;
}
/**
* Sets whether the sending of the message should be tried again.
*/
public function tryAgain(): void
{
$this->retry = true;
}
public function shouldTryAgain(): bool
{
return $this->retry;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Event;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Event triggered when a transport service send Mautic a webhook request.
*/
final class TransportWebhookEvent extends Event
{
private ?Response $response = null;
public function __construct(
private Request $request,
) {
}
public function getRequest(): Request
{
return $this->request;
}
public function getResponse(): ?Response
{
return $this->response;
}
public function setResponse(Response $response): void
{
$this->response = $response;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Doctrine\ORM\EntityManager;
use Mautic\ChannelBundle\ChannelEvents;
use Mautic\ChannelBundle\Event\ChannelBroadcastEvent;
use Mautic\EmailBundle\Model\EmailModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class BroadcastSubscriber implements EventSubscriberInterface
{
public function __construct(
private EmailModel $model,
private EntityManager $em,
private TranslatorInterface $translator,
) {
}
public static function getSubscribedEvents(): array
{
return [
ChannelEvents::CHANNEL_BROADCAST => ['onBroadcast', 0],
];
}
public function onBroadcast(ChannelBroadcastEvent $event): void
{
if (!$event->checkContext('email')) {
return;
}
// Get list of published broadcasts or broadcast if there is only a single ID
$emails = $this->model->getRepository()->getPublishedBroadcastsIterable($event->getId());
foreach ($emails as $email) {
$emailEntity = $email;
[$sentCount, $failedCount, $failedRecipientsByList] = $this->model->sendEmailToLists(
$emailEntity,
null,
$event->getLimit(),
$event->getBatch(),
$event->getOutput(),
$event->getMinContactIdFilter(),
$event->getMaxContactIdFilter(),
$event->getMaxThreads(),
$event->getThreadId()
);
if ($emailEntity->shouldCheckForUnpublishEmail()) {
$isNotParallelSending = !$event->getThreadId() || 1 === $event->getThreadId();
$totalPendingCount ??= $this->model->getPendingLeads($emailEntity, null, true);
// only If no pending and nothing was sent
if ($isNotParallelSending && !$totalPendingCount && !$sentCount) {
$emailEntity->setIsPublished(false);
$this->model->saveEntity($emailEntity);
$event->getOutput()->writeln('Email "'.$emailEntity->getName().'" has been unpublished as there are no more pending contacts to send to.');
}
}
$event->setResults(
$this->translator->trans('mautic.email.email').': '.$emailEntity->getName(),
$sentCount,
$failedCount,
$failedRecipientsByList
);
$this->em->detach($emailEntity);
}
}
}

View File

@@ -0,0 +1,280 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Doctrine\Persistence\Mapping\MappingException;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\EmojiHelper;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Event\EmailBuilderEvent;
use Mautic\EmailBundle\Event\EmailSendEvent;
use Mautic\EmailBundle\Helper\MailHashHelper;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Redirect;
use Mautic\PageBundle\Entity\Trackable;
use Mautic\PageBundle\Model\RedirectModel;
use Mautic\PageBundle\Model\TrackableModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class BuilderSubscriber implements EventSubscriberInterface
{
/**
* @var array<string, array{array{string, string}, Trackable[]|Redirect[]}>
*/
private array $convertedContent = [];
public function __construct(
private CoreParametersHelper $coreParametersHelper,
private EmailModel $emailModel,
private TrackableModel $pageTrackableModel,
private RedirectModel $pageRedirectModel,
private TranslatorInterface $translator,
private MailHashHelper $mailHash,
) {
}
public static function getSubscribedEvents(): array
{
return [
EmailEvents::EMAIL_ON_BUILD => ['onEmailBuild', 0],
EmailEvents::EMAIL_ON_SEND => [
['fixEmailAccessibility', 10000],
['onEmailGenerate', 0],
// Ensure this is done last in order to catch all tokenized URLs
['convertUrlsToTokens', -9999],
],
EmailEvents::EMAIL_ON_DISPLAY => [
['fixEmailAccessibility', 10000],
['onEmailGenerate', 0],
// Ensure this is done last in order to catch all tokenized URLs
['convertUrlsToTokens', -9999],
],
];
}
public function onEmailBuild(EmailBuilderEvent $event): void
{
if ($event->abTestWinnerCriteriaRequested()) {
// add AB Test Winner Criteria
$openRate = [
'group' => 'mautic.email.stats',
'label' => 'mautic.email.abtest.criteria.open',
'event' => EmailEvents::ON_DETERMINE_OPEN_RATE_WINNER,
];
$event->addAbTestWinnerCriteria('email.openrate', $openRate);
$clickThrough = [
'group' => 'mautic.email.stats',
'label' => 'mautic.email.abtest.criteria.clickthrough',
'event' => EmailEvents::ON_DETERMINE_CLICKTHROUGH_RATE_WINNER,
];
$event->addAbTestWinnerCriteria('email.clickthrough', $clickThrough);
}
$tokens = [
'{unsubscribe_text}' => $this->translator->trans('mautic.email.token.unsubscribe_text'),
'{webview_text}' => $this->translator->trans('mautic.email.token.webview_text'),
'{signature}' => $this->translator->trans('mautic.email.token.signature'),
'{brand=name}' => $this->translator->trans('mautic.core.token.brand_name'),
'{subject}' => $this->translator->trans('mautic.email.subject'),
];
if ($event->tokensRequested(array_keys($tokens))) {
$event->addTokens(
$event->filterTokens($tokens)
);
}
// these should not allow visual tokens
$tokens = [
'{unsubscribe_url}' => $this->translator->trans('mautic.email.token.unsubscribe_url'),
'{dnc_url}' => $this->translator->trans('mautic.email.token.unsubscribe_all_url'),
'{resubscribe_url}' => $this->translator->trans('mautic.email.token.resubscribe_url'),
'{webview_url}' => $this->translator->trans('mautic.email.token.webview_url'),
];
if ($event->tokensRequested(array_keys($tokens))) {
$event->addTokens(
$event->filterTokens($tokens)
);
}
}
public function fixEmailAccessibility(EmailSendEvent $event): void
{
if ($event->isDynamicContentParsing() || !$event->getEmail() instanceof Email) {
// prevent a loop
return;
}
$content = $event->getContent();
$subject = $event->getEmail()->getSubject();
// Add the empty <head/> tag if it's missing.
if (empty(preg_match('#<\s*?head\b[^>]*>(.*?)</head\b[^>]*>#s', $content, $matches))) {
$content = str_replace('<body', '<head></head><body', $content);
}
// Add the <title/> tag with email subject value into the <head/> tag if it's missing.
$content = preg_replace_callback(
"/<title>(.*?)<\/title>/is",
fn ($matches) => empty(trim($matches[1])) ? "<title>{$subject}</title>" : $matches[0],
$content,
-1,
$fixed
);
if (!$fixed) {
$content = str_replace('</head>', "<title>{$subject}</title></head>", $content);
}
// Add the lang attribute to the <html/> tag if it's missing.
$locale = empty($event->getEmail()->getLanguage()) ? $this->coreParametersHelper->get('locale') : $event->getEmail()->getLanguage();
preg_match_all("~<html.*lang\s*=\s*[\"']([^\"']+)[\"'][^>]*>~i", $content, $matches);
if (empty($matches[1])) {
$content = str_replace('<html', '<html lang="'.$locale.'"', $content);
}
$event->setContent($content);
}
public function onEmailGenerate(EmailSendEvent $event): void
{
$idHash = $event->getIdHash();
$lead = $event->getLead();
$email = $event->getEmail();
// Get email
$toEmail = null;
if (is_array($lead) && array_key_exists('email', $lead) && is_string($lead['email'])) {
$toEmail = $lead['email'];
} elseif ($lead instanceof Lead && is_string($lead->getEmail())) {
$toEmail = $lead->getEmail();
}
// Get email hash
$unsubscribeHash = null;
if ($toEmail) {
$unsubscribeHash = $this->mailHash->getEmailHash($toEmail);
}
if (null == $idHash) {
// Generate a bogus idHash to prevent errors for routes that may include it
$idHash = uniqid();
}
$unsubscribeText = $this->coreParametersHelper->get('unsubscribe_text');
if (!$unsubscribeText) {
$unsubscribeText = $this->translator->trans('mautic.email.unsubscribe.text', ['%link%' => '|URL|']);
}
// We will replace tokens in unsubscribe text too
$unsubscribeText = \Mautic\LeadBundle\Helper\TokenHelper::findLeadTokens($unsubscribeText, $lead, true);
$unsubscribeText = str_replace('|URL|', $this->emailModel->buildUrl('mautic_email_unsubscribe', ['idHash' => $idHash, 'urlEmail' => $toEmail, 'secretHash' => $unsubscribeHash]), $unsubscribeText);
$event->addToken('{unsubscribe_text}', EmojiHelper::toHtml($unsubscribeText));
$event->addToken('{unsubscribe_url}', $this->emailModel->buildUrl('mautic_email_unsubscribe', ['idHash' => $idHash, 'urlEmail' => $toEmail, 'secretHash' => $unsubscribeHash]));
$event->addToken('{dnc_url}', $this->emailModel->buildUrl('mautic_email_unsubscribe_all', ['idHash' => $idHash, 'urlEmail' => $toEmail, 'secretHash' => $unsubscribeHash]));
$event->addToken('{resubscribe_url}', $this->emailModel->buildUrl('mautic_email_resubscribe', ['idHash' => $idHash]));
$webviewText = $this->coreParametersHelper->get('webview_text');
if (!$webviewText) {
$webviewText = $this->translator->trans('mautic.email.webview.text', ['%link%' => '|URL|']);
}
$webviewText = str_replace('|URL|', $this->emailModel->buildUrl('mautic_email_webview', ['idHash' => $idHash]), $webviewText);
$event->addToken('{webview_text}', EmojiHelper::toHtml($webviewText));
// Show public email preview if the lead is not known to prevent 404
if (empty($lead['id']) && $email) {
$event->addToken('{webview_url}', $this->emailModel->buildUrl('mautic_email_preview', ['objectId' => $email->getId()]));
} else {
$event->addToken('{webview_url}', $this->emailModel->buildUrl('mautic_email_webview', ['idHash' => $idHash]));
}
$signatureText = (string) $this->coreParametersHelper->get('default_signature_text');
$fromName = $this->coreParametersHelper->get('mailer_from_name');
$signatureText = str_replace('|FROM_NAME|', $fromName, nl2br($signatureText));
$event->addToken('{signature}', EmojiHelper::toHtml($signatureText));
$event->addToken('{subject}', EmojiHelper::toHtml($event->getSubject()));
$event->addToken('{brand=name}', (string) $this->coreParametersHelper->get('brand_name'));
}
public function convertUrlsToTokens(EmailSendEvent $event): void
{
if ($event->isInternalSend() || $this->coreParametersHelper->get('disable_trackable_urls')) {
// Don't convert urls
return;
}
$shortenEnabled = $this->coreParametersHelper->get('shortener_email_enable', false);
$email = $event->getEmail();
$emailId = $email instanceof Email ? $email->getId() : null;
$utmTags = $email instanceof Email ? $email->getUtmTags() : [];
$clickthrough = $event->generateClickthrough();
$trackables = $this->parseContentForUrls($event, $emailId);
foreach ($trackables as $token => $trackable) {
$url = ($trackable instanceof Trackable)
?
$this->pageTrackableModel->generateTrackableUrl($trackable, $clickthrough, $shortenEnabled, $utmTags)
:
$this->pageRedirectModel->generateRedirectUrl($trackable, $clickthrough, $shortenEnabled, $utmTags);
$event->addToken($token, $url);
}
}
/**
* Parses the content for URLs and replaces them for trackables.
*
* @param ?int $emailId
*
* @return Trackable[]|Redirect[]
*
* @throws MappingException
*/
private function parseContentForUrls(EmailSendEvent $event, $emailId): array
{
$cacheKey = $event->getContentHash().'-'.$emailId;
// Prevent parsing the exact same content over and over
if (!isset($this->convertedContent[$cacheKey])) {
[$content, $trackables] = $this->pageTrackableModel->parseContentForTrackables(
[$event->getContent(), $event->getPlainText()],
$event->getTokens(),
($emailId) ? 'email' : null,
$emailId
);
$this->convertedContent[$cacheKey] = [$content, $trackables];
foreach ($trackables as $trackable) {
$trackableRepository = $this->pageTrackableModel->getRepository();
$redirectRepository = $this->pageRedirectModel->getRepository();
if ($trackable instanceof Trackable) {
$trackableRepository->detachEntity($trackable);
$redirectRepository->detachEntity($trackable->getRedirect());
$trackableRepository->detachEntities($trackable->getRedirect()->getTrackableList()->toArray());
} else {
$redirectRepository->detachEntity($trackable);
$trackableRepository->detachEntities($trackable->getTrackableList()->toArray());
}
}
}
[$content, $trackables] = $this->convertedContent[$cacheKey];
[$html, $text] = $content;
if ($html) {
$event->setContent($html);
}
if ($text) {
$event->setPlainText($text);
}
return $trackables;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Event\CampaignBuilderEvent;
use Mautic\CampaignBundle\Event\CampaignExecutionEvent;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Exception\InvalidEmailException;
use Mautic\EmailBundle\Helper\EmailValidator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
class CampaignConditionSubscriber implements EventSubscriberInterface
{
public function __construct(
private EmailValidator $validator,
) {
}
public static function getSubscribedEvents(): array
{
return [
CampaignEvents::CAMPAIGN_ON_BUILD => ['onCampaignBuild', 0],
EmailEvents::ON_CAMPAIGN_TRIGGER_CONDITION => ['onCampaignTriggerCondition', 0],
];
}
public function onCampaignBuild(CampaignBuilderEvent $event): void
{
$event->addCondition(
'email.validate.address',
[
'label' => 'mautic.email.campaign.event.validate_address',
'description' => 'mautic.email.campaign.event.validate_address_descr',
'eventName' => EmailEvents::ON_CAMPAIGN_TRIGGER_CONDITION,
]
);
}
public function onCampaignTriggerCondition(CampaignExecutionEvent $event)
{
try {
$this->validator->validate($event->getLead()->getEmail(), true);
} catch (UnexpectedValueException|InvalidEmailException) {
return $event->setResult(false);
}
return $event->setResult(true);
}
}

View File

@@ -0,0 +1,371 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Doctrine\ORM\ORMException;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Event\CampaignBuilderEvent;
use Mautic\CampaignBundle\Event\CampaignExecutionEvent;
use Mautic\CampaignBundle\Event\PendingEvent;
use Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogNotProcessedException;
use Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogPassedAndFailedException;
use Mautic\CampaignBundle\Executioner\Exception\CannotProcessEventException;
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
use Mautic\CampaignBundle\Executioner\RealTimeExecutioner;
use Mautic\CampaignBundle\Executioner\Scheduler\Exception\NotSchedulableException;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Event\EmailOpenEvent;
use Mautic\EmailBundle\Event\EmailReplyEvent;
use Mautic\EmailBundle\Exception\EmailCouldNotBeSentException;
use Mautic\EmailBundle\Form\Type\EmailClickDecisionType;
use Mautic\EmailBundle\Form\Type\EmailSendType;
use Mautic\EmailBundle\Form\Type\EmailToUserType;
use Mautic\EmailBundle\Helper\MailHelper;
use Mautic\EmailBundle\Helper\UrlMatcher;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\EmailBundle\Model\SendEmailToUser;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\PageBundle\Entity\Hit;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class CampaignSubscriber implements EventSubscriberInterface
{
public function __construct(
private EmailModel $emailModel,
private RealTimeExecutioner $realTimeExecutioner,
private SendEmailToUser $sendEmailToUser,
private TranslatorInterface $translator,
private LeadModel $leadModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
CampaignEvents::CAMPAIGN_ON_BUILD => ['onCampaignBuild', 0],
EmailEvents::EMAIL_ON_OPEN => ['onEmailOpen', 0],
EmailEvents::ON_CAMPAIGN_BATCH_ACTION => [
['onCampaignTriggerActionSendEmailToContact', 0],
['onCampaignTriggerActionSendEmailToUser', 1],
],
EmailEvents::ON_CAMPAIGN_TRIGGER_DECISION => ['onCampaignTriggerDecision', 0],
EmailEvents::EMAIL_ON_REPLY => ['onEmailReply', 0],
];
}
public function onCampaignBuild(CampaignBuilderEvent $event): void
{
$event->addDecision(
'email.open',
[
'label' => 'mautic.email.campaign.event.open',
'description' => 'mautic.email.campaign.event.open_descr',
'eventName' => EmailEvents::ON_CAMPAIGN_TRIGGER_DECISION,
'connectionRestrictions' => [
'source' => [
'action' => [
'email.send',
],
],
],
]
);
$event->addDecision(
'email.click',
[
'label' => 'mautic.email.campaign.event.click',
'description' => 'mautic.email.campaign.event.click_descr',
'eventName' => EmailEvents::ON_CAMPAIGN_TRIGGER_DECISION,
'formType' => EmailClickDecisionType::class,
'connectionRestrictions' => [
'source' => [
'action' => [
'email.send',
],
],
],
]
);
$event->addAction(
'email.send',
[
'label' => 'mautic.email.campaign.event.send',
'description' => 'mautic.email.campaign.event.send_descr',
'batchEventName' => EmailEvents::ON_CAMPAIGN_BATCH_ACTION,
'formType' => EmailSendType::class,
'formTypeOptions' => ['update_select' => 'campaignevent_properties_email', 'with_email_types' => true],
'formTheme' => '@MauticEmail/FormTheme/EmailSendList/emailsend_list_row.html.twig',
'channel' => 'email',
'channelIdField' => 'email',
]
);
$event->addDecision(
'email.reply',
[
'label' => 'mautic.email.campaign.event.reply',
'description' => 'mautic.email.campaign.event.reply_descr',
'eventName' => EmailEvents::ON_CAMPAIGN_TRIGGER_DECISION,
'connectionRestrictions' => [
'source' => [
'action' => [
'email.send',
],
],
],
]
);
$event->addAction(
'email.send.to.user',
[
'label' => 'mautic.email.campaign.event.send.to.user',
'description' => 'mautic.email.campaign.event.send.to.user_descr',
'batchEventName' => EmailEvents::ON_CAMPAIGN_BATCH_ACTION,
'formType' => EmailToUserType::class,
'formTypeOptions' => ['update_select' => 'campaignevent_properties_useremail_email'],
'formTheme' => '@MauticEmail/FormTheme/EmailSendList/email_to_user_row.html.twig',
'channel' => 'email',
'channelIdField' => 'email',
]
);
}
/**
* Trigger campaign event for opening of an email.
*
* @throws LogNotProcessedException
* @throws LogPassedAndFailedException
* @throws CannotProcessEventException
* @throws NotSchedulableException
*/
public function onEmailOpen(EmailOpenEvent $event): void
{
$email = $event->getEmail();
if (null !== $email) {
$this->realTimeExecutioner->execute('email.open', $email, 'email', $email->getId());
}
}
/**
* Trigger campaign event for reply to an email.
*
* @throws CannotProcessEventException
* @throws LogNotProcessedException
* @throws LogPassedAndFailedException
* @throws NotSchedulableException
*/
public function onEmailReply(EmailReplyEvent $event): void
{
$email = $event->getEmail();
if (null !== $email) {
$this->realTimeExecutioner->execute('email.reply', $email, 'email', $email->getId());
}
}
public function onCampaignTriggerDecision(CampaignExecutionEvent $event): CampaignExecutionEvent
{
/** @var Email $eventDetails */
$eventDetails = $event->getEventDetails();
$eventParent = $event->getEvent()['parent'];
$eventConfig = $event->getConfig();
if (null == $eventDetails) {
return $event->setResult(false);
}
// check to see if the parent event is a "send email" event and that it matches the current email opened or clicked
if (!empty($eventParent) && 'email.send' === $eventParent['type']) {
// click decision
if ($event->checkContext('email.click')) {
/** @var Hit $hit */
$hit = $eventDetails;
if (in_array((int) $eventParent['properties']['email'], $eventDetails->getEmail()->getRelatedEntityIds())) {
if (!empty($eventConfig['urls']['list'])) {
$limitToUrls = (array) $eventConfig['urls']['list'];
if (UrlMatcher::hasMatch($limitToUrls, $hit->getUrl())) {
return $event->setResult(true);
}
} else {
return $event->setResult(true);
}
}
return $event->setResult(false);
} elseif ($event->checkContext('email.open')) {
// open decision
return $event->setResult(in_array((int) $eventParent['properties']['email'], $eventDetails->getRelatedEntityIds()));
} elseif ($event->checkContext('email.reply')) {
// reply decision
return $event->setResult(in_array((int) $eventParent['properties']['email'], $eventDetails->getRelatedEntityIds()));
}
}
return $event->setResult(false);
}
/**
* Triggers the action which sends email to contacts.
*
* @throws ORMException
* @throws NoContactsFoundException
*/
public function onCampaignTriggerActionSendEmailToContact(PendingEvent $event): void
{
if (!$event->checkContext('email.send')) {
return;
}
$config = $event->getEvent()->getProperties();
$emailId = (int) $config['email'];
$email = $this->emailModel->getEntity($emailId);
if (!$email || !$email->isPublished()) {
$event->passAllWithError($this->translator->trans('mautic.email.campaign.event.failure_missing_email'));
return;
}
$event->setChannel('email', $emailId);
$type = $config['email_type'] ?? MailHelper::EMAIL_TYPE_TRANSACTIONAL;
$options = [
'source' => ['campaign.event', $event->getEvent()->getId()],
'email_attempts' => $config['attempts'] ?? 3,
'email_priority' => $config['priority'] ?? 2,
'email_type' => $type,
'return_errors' => true,
'dnc_as_error' => true,
'customHeaders' => [
'X-EMAIL-ID' => $emailId,
],
'ignoreDNC' => MailHelper::EMAIL_TYPE_TRANSACTIONAL === $type,
];
// Determine if this email is transactional/marketing
$pending = $event->getPending();
$contacts = $event->getContacts();
$contactIds = $event->getContactIds();
$credentialArray = [];
$emailCategory = $email->getCategory() ? $email->getCategory()->getId() : null;
foreach ($contacts as $logId => $contact) {
$leadCredentials = $contact->getProfileFields();
$leadCredentials['primaryIdentifier'] = $contact->getPrimaryIdentifier();
// Set owner_id to support the "Owner is mailer" feature
if ($contact->getOwner()) {
$leadCredentials['owner_id'] = $contact->getOwner()->getId();
}
if (empty($leadCredentials['email'])) {
// Pass with a note to the UI because no use retrying
$event->passWithError(
$pending->get($logId),
$this->translator->trans(
'mautic.email.contact_has_no_email',
['%contact%' => $contact->getPrimaryIdentifier()]
)
);
unset($contactIds[$contact->getId()]);
continue;
}
$categories = $this->leadModel->getUnsubscribedLeadCategoriesIds($contact);
if ($emailCategory && !empty($categories) && in_array($emailCategory, $categories)) {
// Pass with a note to the UI because no use retrying
$event->passWithError(
$pending->get($logId),
$this->translator->trans(
'mautic.email.contact_has_unsubscribed_from_category',
['%contact%' => $contact->getPrimaryIdentifier(), '%category%' => $emailCategory]
)
);
unset($contactIds[$contact->getId()]);
continue;
}
$credentialArray[$logId] = $leadCredentials;
}
if (MailHelper::EMAIL_TYPE_MARKETING == $type) {
$statRepository = $this->emailModel->getStatRepository();
// Determine if this lead has received the email before and if so, don't send it again
$stats = $statRepository->getSentCountForContacts($contactIds, $emailId);
// Merge stats from variant children
foreach ($email->getVariantChildren() as $child) {
$stats = array_merge(
$stats,
$statRepository->getSentCountForContacts($contactIds, $child->getId())
);
}
foreach ($stats as $contactId => $sentCount) {
/** @var LeadEventLog $log */
$log = $event->findLogByContactId($contactId);
// Pass with a note to the UI because no use retrying
$event->passWithError(
$log,
$this->translator->trans('mautic.email.contact_already_received_marketing_email', ['%contact%' => $credentialArray[$log->getId()]['primaryIdentifier']])
);
unset($credentialArray[$log->getId()]);
}
}
if (count($credentialArray)) {
$errors = $this->emailModel->sendEmail($email, $credentialArray, $options);
// Fail those that failed to send
foreach ($errors as $failedContactId => $reason) {
$log = $event->findLogByContactId($failedContactId);
unset($credentialArray[$log->getId()]);
if ($this->translator->trans('mautic.email.dnc') === $reason) {
// Do not log DNC as errors because they'll be retried rather just let the UI know
$event->passWithError($log, $reason);
continue;
}
$event->fail($log, $reason);
}
// Pass everyone else
foreach (array_keys($credentialArray) as $logId) {
$event->pass($pending->get($logId));
}
}
}
/**
* @throws ORMException
*/
public function onCampaignTriggerActionSendEmailToUser(PendingEvent $event): void
{
if (!$event->checkContext('email.send.to.user')) {
return;
}
$config = $event->getEvent()->getProperties();
$contacts = $event->getContacts();
$pending = $event->getPending();
/**
* @var int $logId
* @var Lead $contact
*/
foreach ($contacts as $logId => $contact) {
try {
$this->sendEmailToUser->sendEmailToUsers($config, $contact);
$event->pass($pending->get($logId));
} catch (EmailCouldNotBeSentException $e) {
$event->fail($pending->get($logId), $e->getMessage());
}
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\ChannelBundle\ChannelEvents;
use Mautic\ChannelBundle\Event\ChannelEvent;
use Mautic\ChannelBundle\Model\MessageModel;
use Mautic\EmailBundle\Form\Type\EmailListType;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\ReportBundle\Model\ReportModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
const CHANNEL_COLUMN_CATEGORY_ID = 'category_id';
const CHANNEL_COLUMN_NAME = 'name';
const CHANNEL_COLUMN_DESCRIPTION = 'description';
const CHANNEL_COLUMN_DATE_ADDED = 'date_added';
const CHANNEL_COLUMN_CREATED_BY = 'created_by';
const CHANNEL_COLUMN_CREATED_BY_USER = 'created_by_user';
class ChannelSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
ChannelEvents::ADD_CHANNEL => ['onAddChannel', 100],
];
}
public function onAddChannel(ChannelEvent $event): void
{
$event->addChannel(
'email',
[
MessageModel::CHANNEL_FEATURE => [
'campaignAction' => 'email.send',
'campaignDecisionsSupported' => [
'email.open',
'page.pagehit',
'asset.download',
'form.submit',
],
'lookupFormType' => EmailListType::class,
],
LeadModel::CHANNEL_FEATURE => [],
ReportModel::CHANNEL_FEATURE => [
'table' => 'emails',
],
]
);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\ConfigBundle\ConfigEvents;
use Mautic\ConfigBundle\Event\ConfigBuilderEvent;
use Mautic\ConfigBundle\Event\ConfigEvent;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\EmailBundle\Form\Type\ConfigType;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ConfigSubscriber implements EventSubscriberInterface
{
public function __construct(
private CoreParametersHelper $coreParametersHelper,
) {
}
public static function getSubscribedEvents(): array
{
return [
ConfigEvents::CONFIG_ON_GENERATE => ['onConfigGenerate', 0],
ConfigEvents::CONFIG_PRE_SAVE => ['onConfigBeforeSave', 0],
];
}
public function onConfigGenerate(ConfigBuilderEvent $event): void
{
$event->addForm([
'bundle' => 'EmailBundle',
'formType' => ConfigType::class,
'formAlias' => 'emailconfig',
'formTheme' => '@MauticEmail/FormTheme/Config/_config_emailconfig_widget.html.twig',
'parameters' => $event->getParametersFromConfig('MauticEmailBundle'),
]);
}
public function onConfigBeforeSave(ConfigEvent $event): void
{
$data = $event->getConfig('emailconfig');
// Get the original data so that passwords aren't lost
$monitoredEmail = $this->coreParametersHelper->get('monitored_email');
if (isset($data['monitored_email'])) {
foreach ($data['monitored_email'] as $key => $monitor) {
if (empty($monitor['password']) && !empty($monitoredEmail[$key]['password'])) {
$data['monitored_email'][$key]['password'] = $monitoredEmail[$key]['password'];
}
if ('general' != $key) {
if (empty($monitor['host']) || empty($monitor['address']) || empty($monitor['folder'])) {
// Reset to defaults
$data['monitored_email'][$key]['override_settings'] = 0;
$data['monitored_email'][$key]['address'] = null;
$data['monitored_email'][$key]['host'] = null;
$data['monitored_email'][$key]['user'] = null;
$data['monitored_email'][$key]['password'] = null;
$data['monitored_email'][$key]['encryption'] = '/ssl';
$data['monitored_email'][$key]['port'] = '993';
}
}
}
}
$event->setConfig($data, 'emailconfig');
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\CoreBundle\Helper\ArrayHelper;
use Mautic\DashboardBundle\Event\WidgetDetailEvent;
use Mautic\DashboardBundle\EventListener\DashboardSubscriber as MainDashboardSubscriber;
use Mautic\EmailBundle\Form\Type\DashboardBestHoursWidgetType;
use Mautic\EmailBundle\Model\EmailModel;
class DashboardBestHoursSubscriber extends MainDashboardSubscriber
{
/**
* Define the name of the bundle/category of the widget(s).
*
* @var string
*/
protected $bundle = 'email';
/**
* Define the widget(s).
*
* @var string
*/
protected $types = [
'emails.best.hours' => [
'formAlias' => DashboardBestHoursWidgetType::class,
],
];
/**
* Define permissions to see those widgets.
*
* @var array
*/
protected $permissions = [
'email:emails:viewown',
'email:emails:viewother',
];
public function __construct(
protected EmailModel $emailModel,
) {
}
/**
* Set a widget detail when needed.
*/
public function onWidgetDetailGenerate(WidgetDetailEvent $event): void
{
$this->checkPermissions($event);
$canViewOthers = $event->hasPermission('email:emails:viewother');
if ('emails.best.hours' == $event->getType()) {
$widget = $event->getWidget();
$params = $widget->getParams();
$filterKeys = ['companyId', 'campaignId', 'segmentId'];
if (!$event->isCached()) {
$event->setTemplateData([
'chartType' => 'bar',
'chartHeight' => $widget->getHeight() - 80,
'chartData' => $this->emailModel->getBestHours(
'date_read',
$params['dateFrom'],
$params['dateTo'],
ArrayHelper::select($filterKeys, $params),
$canViewOthers,
$params['timeFormat']
),
]);
}
$event->setTemplate('@MauticCore/Helper/chart.html.twig');
$event->stopPropagation();
}
}
}

View File

@@ -0,0 +1,351 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\CoreBundle\Helper\ArrayHelper;
use Mautic\DashboardBundle\Entity\Widget;
use Mautic\DashboardBundle\Event\WidgetDetailEvent;
use Mautic\DashboardBundle\EventListener\DashboardSubscriber as MainDashboardSubscriber;
use Mautic\EmailBundle\Form\Type\DashboardEmailsInTimeWidgetType;
use Mautic\EmailBundle\Form\Type\DashboardMostHitEmailRedirectsWidgetType;
use Mautic\EmailBundle\Form\Type\DashboardSentEmailToContactsWidgetType;
use Mautic\EmailBundle\Model\EmailModel;
use Symfony\Component\Routing\RouterInterface;
class DashboardSubscriber extends MainDashboardSubscriber
{
/**
* Define the name of the bundle/category of the widget(s).
*
* @var string
*/
protected $bundle = 'email';
/**
* Define the widget(s).
*
* @var string
*/
protected $types = [
'emails.in.time' => [
'formAlias' => DashboardEmailsInTimeWidgetType::class,
],
'sent.email.to.contacts' => [
'formAlias' => DashboardSentEmailToContactsWidgetType::class,
],
'most.hit.email.redirects' => [
'formAlias' => DashboardMostHitEmailRedirectsWidgetType::class,
],
'ignored.vs.read.emails' => [],
'upcoming.emails' => [],
'most.sent.emails' => [],
'most.read.emails' => [],
'created.emails' => [],
'device.granularity.email' => [],
];
/**
* Define permissions to see those widgets.
*
* @var array
*/
protected $permissions = [
'email:emails:viewown',
'email:emails:viewother',
];
public function __construct(
protected EmailModel $emailModel,
private RouterInterface $router,
) {
}
/**
* Set a widget detail when needed.
*/
public function onWidgetDetailGenerate(WidgetDetailEvent $event): void
{
$this->checkPermissions($event);
$canViewOthers = $event->hasPermission('email:emails:viewother');
$defaultLimit = $this->getDefaultLimit($event->getWidget());
if ('emails.in.time' == $event->getType()) {
$widget = $event->getWidget();
$params = $widget->getParams();
$filterKeys = ['flag', 'dataset', 'companyId', 'campaignId', 'segmentId'];
if (!$event->isCached()) {
$event->setTemplateData([
'chartType' => 'line',
'chartHeight' => $widget->getHeight() - 80,
'chartData' => $this->emailModel->getEmailsLineChartData(
$params['timeUnit'],
$params['dateFrom'],
$params['dateTo'],
$params['dateFormat'],
ArrayHelper::select($filterKeys, $params),
$canViewOthers
),
]);
}
$event->setTemplate('@MauticCore/Helper/chart.html.twig');
$event->stopPropagation();
}
if ('sent.email.to.contacts' == $event->getType()) {
$widget = $event->getWidget();
$params = $widget->getParams();
if (!$event->isCached()) {
$headItems = [
'mautic.dashboard.label.contact.id',
'mautic.dashboard.label.contact.email.address',
'mautic.dashboard.label.contact.open',
'mautic.dashboard.label.contact.click',
'mautic.dashboard.label.contact.links.clicked',
'mautic.dashboard.label.email.id',
'mautic.dashboard.label.email.name',
'mautic.dashboard.label.segment.id',
'mautic.dashboard.label.segment.name',
'mautic.dashboard.label.company.id',
'mautic.dashboard.label.company.name',
'mautic.dashboard.label.campaign.id',
'mautic.dashboard.label.campaign.name',
'mautic.dashboard.label.date.sent',
'mautic.dashboard.label.date.read',
];
$event->setTemplateData(
[
'headItems' => $headItems,
'bodyItems' => $this->emailModel->getSentEmailToContactData(
ArrayHelper::getValue('limit', $params, $defaultLimit),
$params['dateFrom'],
$params['dateTo'],
['groupBy' => 'sends', 'canViewOthers' => $canViewOthers],
ArrayHelper::getValue('companyId', $params),
ArrayHelper::getValue('campaignId', $params),
ArrayHelper::getValue('segmentId', $params)
),
]
);
}
$event->setTemplate('@MauticEmail/SubscribedEvents/Dashboard/Sent.email.to.contacts.html.twig');
$event->stopPropagation();
}
if ('most.hit.email.redirects' == $event->getType()) {
$widget = $event->getWidget();
$params = $widget->getParams();
if (!$event->isCached()) {
$event->setTemplateData([
'headItems' => [
'mautic.dashboard.label.url',
'mautic.dashboard.label.unique.hit.count',
'mautic.dashboard.label.total.hit.count',
'mautic.dashboard.label.email.id',
'mautic.dashboard.label.email.name',
],
'bodyItems' => $this->emailModel->getMostHitEmailRedirects(
ArrayHelper::getValue('limit', $params, $defaultLimit),
$params['dateFrom'],
$params['dateTo'],
['groupBy' => 'sends', 'canViewOthers' => $canViewOthers],
ArrayHelper::getValue('companyId', $params),
ArrayHelper::getValue('campaignId', $params),
ArrayHelper::getValue('segmentId', $params)
),
]);
}
$event->setTemplate('@MauticEmail/SubscribedEvents/Dashboard/Most.hit.email.redirects.html.twig');
$event->stopPropagation();
}
if ('ignored.vs.read.emails' == $event->getType()) {
$widget = $event->getWidget();
$params = $widget->getParams();
if (!$event->isCached()) {
$event->setTemplateData([
'chartType' => 'pie',
'chartHeight' => $widget->getHeight() - 80,
'chartData' => $this->emailModel->getIgnoredVsReadPieChartData($params['dateFrom'], $params['dateTo'], [], $canViewOthers),
]);
}
$event->setTemplate('@MauticCore/Helper/chart.html.twig');
$event->stopPropagation();
}
if ('upcoming.emails' == $event->getType()) {
$widget = $event->getWidget();
$params = $widget->getParams();
$height = $widget->getHeight();
$limit = round(($height - 80) / 60);
$upcomingEmails = $this->emailModel->getUpcomingEmails($limit, $canViewOthers);
$event->setTemplate('@MauticDashboard/Dashboard/upcomingemails.html.twig');
$event->setTemplateData(['upcomingEmails' => $upcomingEmails]);
$event->stopPropagation();
}
if ('most.sent.emails' == $event->getType()) {
if (!$event->isCached()) {
$params = $event->getWidget()->getParams();
$emails = $this->emailModel->getEmailStatList(
ArrayHelper::getValue('limit', $params, $defaultLimit),
$params['dateFrom'],
$params['dateTo'],
[],
['groupBy' => 'sends', 'canViewOthers' => $canViewOthers]
);
$items = [];
// Build table rows with links
foreach ($emails as &$email) {
$emailUrl = $this->router->generate('mautic_email_action', ['objectAction' => 'view', 'objectId' => $email['id']]);
$row = [
[
'value' => $email['name'],
'type' => 'link',
'link' => $emailUrl,
],
[
'value' => $email['count'],
],
];
$items[] = $row;
}
$event->setTemplateData([
'headItems' => [
'mautic.dashboard.label.title',
'mautic.email.label.sends',
],
'bodyItems' => $items,
'raw' => $emails,
]);
}
$event->setTemplate('@MauticCore/Helper/table.html.twig');
$event->stopPropagation();
}
if ('most.read.emails' == $event->getType()) {
if (!$event->isCached()) {
$params = $event->getWidget()->getParams();
$emails = $this->emailModel->getEmailStatList(
ArrayHelper::getValue('limit', $params, $defaultLimit),
$params['dateFrom'],
$params['dateTo'],
[],
['groupBy' => 'reads', 'canViewOthers' => $canViewOthers]
);
$items = [];
// Build table rows with links
foreach ($emails as &$email) {
$emailUrl = $this->router->generate('mautic_email_action', ['objectAction' => 'view', 'objectId' => $email['id']]);
$row = [
[
'value' => $email['name'],
'type' => 'link',
'link' => $emailUrl,
],
[
'value' => $email['count'],
],
];
$items[] = $row;
}
$event->setTemplateData([
'headItems' => [
'mautic.dashboard.label.title',
'mautic.email.label.reads',
],
'bodyItems' => $items,
'raw' => $emails,
]);
}
$event->setTemplate('@MauticCore/Helper/table.html.twig');
$event->stopPropagation();
}
if ('created.emails' == $event->getType()) {
if (!$event->isCached()) {
$params = $event->getWidget()->getParams();
$emails = $this->emailModel->getEmailList(
ArrayHelper::getValue('limit', $params, $defaultLimit),
$params['dateFrom'],
$params['dateTo'],
[],
['groupBy' => 'creations', 'canViewOthers' => $canViewOthers]
);
$items = [];
// Build table rows with links
foreach ($emails as &$email) {
$emailUrl = $this->router->generate(
'mautic_email_action',
[
'objectAction' => 'view',
'objectId' => $email['id'],
]
);
$row = [
[
'value' => $email['name'],
'type' => 'link',
'link' => $emailUrl,
],
];
$items[] = $row;
}
$event->setTemplateData([
'headItems' => [
'mautic.dashboard.label.title',
],
'bodyItems' => $items,
'raw' => $emails,
]);
}
$event->setTemplate('@MauticCore/Helper/table.html.twig');
$event->stopPropagation();
}
if ('device.granularity.email' == $event->getType()) {
$widget = $event->getWidget();
$params = $widget->getParams();
if (!$event->isCached()) {
$event->setTemplateData([
'chartType' => 'pie',
'chartHeight' => $widget->getHeight() - 80,
'chartData' => $this->emailModel->getDeviceGranularityPieChartData(
$params['dateFrom'],
$params['dateTo']
),
]);
}
$event->setTemplate('@MauticCore/Helper/chart.html.twig');
$event->stopPropagation();
}
}
/**
* Count the row limit from the widget height.
*/
private function getDefaultLimit(Widget $widget): float
{
return round((($widget->getHeight() - 80) / 35) - 1);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\CoreBundle\Helper\DateTime\DateTimeToken;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event\EmailBuilderEvent;
use Mautic\EmailBundle\Event\EmailSendEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class DateTimeTokenSubscriber implements EventSubscriberInterface
{
public function __construct(
private TranslatorInterface $translator,
private DateTimeToken $dateTokenHelper,
) {
}
public static function getSubscribedEvents(): array
{
return [
EmailEvents::EMAIL_ON_BUILD => ['onEmailBuild', 0],
EmailEvents::EMAIL_ON_SEND => ['onEmailGenerate', 0],
EmailEvents::EMAIL_ON_DISPLAY => ['onEmailDisplay', 0],
];
}
public function onEmailBuild(EmailBuilderEvent $event): void
{
$event->addToken('{today}', $this->translator->trans('mautic.email.token.today'));
}
public function onEmailDisplay(EmailSendEvent $event): void
{
$this->onEmailGenerate($event);
}
public function onEmailGenerate(EmailSendEvent $event): void
{
$content = $event->getSubject();
$content .= $event->getContent();
$content .= $event->getPlainText();
$content .= implode(' ', $event->getTextHeaders());
$leadArray = $event->getLead();
$contactTimezone = $event->isInternalSend() || !is_array($leadArray) ? null : ($leadArray['timezone'] ?? null);
$tokenList = $this->dateTokenHelper->getTokens($content, $contactTimezone);
if (count($tokenList)) {
$event->addTokens($tokenList);
unset($tokenList);
}
}
}

View File

@@ -0,0 +1,224 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Event\DetermineWinnerEvent;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\PageBundle\Entity\Hit;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class DetermineWinnerSubscriber implements EventSubscriberInterface
{
public function __construct(
private EntityManagerInterface $em,
private TranslatorInterface $translator,
) {
}
public static function getSubscribedEvents(): array
{
return [
EmailEvents::ON_DETERMINE_OPEN_RATE_WINNER => ['onDetermineOpenRateWinner', 0],
EmailEvents::ON_DETERMINE_CLICKTHROUGH_RATE_WINNER => ['onDetermineClickthroughRateWinner', 0],
];
}
/**
* Determines the winner of A/B test based on open rate.
*/
public function onDetermineOpenRateWinner(DetermineWinnerEvent $event): void
{
$parameters = $event->getParameters();
$parent = $parameters['parent'];
$children = $parameters['children'];
/** @var \Mautic\EmailBundle\Entity\StatRepository $repo */
$repo = $this->em->getRepository(Stat::class);
/** @var Email $parent */
$ids = $parent->getRelatedEntityIds();
$startDate = $parent->getVariantStartDate();
if (null != $startDate && !empty($ids)) {
// get their bounce rates
$counts = $repo->getOpenedRates($ids, $startDate);
$translator = $this->translator;
if ($counts) {
$rates = $support = $data = [];
$hasResults = [];
$parentId = $parent->getId();
foreach ($counts as $id => $stats) {
if ($id !== $parentId && !array_key_exists($id, $children)) {
continue;
}
$name = ($parentId === $id) ? $parent->getName()
: $children[$id]->getName();
$support['labels'][] = $name.' ('.$stats['readRate'].'%)';
$rates[$id] = $stats['readRate'];
$data[$translator->trans('mautic.email.abtest.label.opened')][] = $stats['readCount'];
$data[$translator->trans('mautic.email.abtest.label.sent')][] = $stats['totalCount'];
$hasResults[] = $id;
}
if (!in_array($parent->getId(), $hasResults)) {
// make sure that parent and published children are included
$support['labels'][] = $parent->getName().' (0%)';
$data[$translator->trans('mautic.email.abtest.label.opened')][] = 0;
$data[$translator->trans('mautic.email.abtest.label.sent')][] = 0;
}
foreach ($children as $c) {
if ($c->isPublished()) {
if (!in_array($c->getId(), $hasResults)) {
// make sure that parent and published children are included
$support['labels'][] = $c->getName().' (0%)';
$data[$translator->trans('mautic.email.abtest.label.opened')][] = 0;
$data[$translator->trans('mautic.email.abtest.label.sent')][] = 0;
}
}
}
$support['data'] = $data;
// set max for scales
$maxes = [];
foreach ($support['data'] as $data) {
$maxes[] = max($data);
}
$top = max($maxes);
$support['step_width'] = (ceil($top / 10) * 10);
// put in order from least to greatest just because
asort($rates);
// who's the winner?
$max = max($rates);
// get the page ids with the most number of downloads
$winners = ($max > 0) ? array_keys($rates, $max) : [];
$event->setAbTestResults([
'winners' => $winners,
'support' => $support,
'basedOn' => 'email.openrate',
'supportTemplate' => '@MauticPage/SubscribedEvents/AbTest/bargraph.html.twig',
]);
return;
}
}
$event->setAbTestResults([
'winners' => [],
'support' => [],
'basedOn' => 'email.openrate',
]);
}
/**
* Determines the winner of A/B test based on clickthrough rates.
*/
public function onDetermineClickthroughRateWinner(DetermineWinnerEvent $event): void
{
$parameters = $event->getParameters();
$parent = $parameters['parent'];
$children = $parameters['children'];
/** @var \Mautic\PageBundle\Entity\HitRepository $pageRepo */
$pageRepo = $this->em->getRepository(Hit::class);
/** @var \Mautic\EmailBundle\Entity\StatRepository $emailRepo */
$emailRepo = $this->em->getRepository(Stat::class);
/** @var Email $parent */
$ids = $parent->getRelatedEntityIds();
$startDate = $parent->getVariantStartDate();
if (null != $startDate && !empty($ids)) {
// get their bounce rates
$clickthroughCounts = $pageRepo->getEmailClickthroughHitCount($ids, $startDate);
$sentCounts = $emailRepo->getSentCounts($ids, $startDate);
$translator = $this->translator;
if ($clickthroughCounts) {
$rates = $support = $data = [];
$hasResults = [];
$parentId = $parent->getId();
foreach ($clickthroughCounts as $id => $count) {
if ($id !== $parentId && !array_key_exists($id, $children)) {
continue;
}
if (!isset($sentCounts[$id])) {
$sentCounts[$id] = 0;
}
$rates[$id] = $sentCounts[$id] ? round(($count / $sentCounts[$id]) * 100, 2) : 0;
$name = ($parentId === $id) ? $parent->getName() : $children[$id]->getName();
$support['labels'][] = $name.' ('.$rates[$id].'%)';
$data[$translator->trans('mautic.email.abtest.label.clickthrough')][] = $count;
$data[$translator->trans('mautic.email.abtest.label.opened')][] = $sentCounts[$id];
$hasResults[] = $id;
}
if (!in_array($parent->getId(), $hasResults)) {
// make sure that parent and published children are included
$support['labels'][] = $parent->getName().' (0%)';
$data[$translator->trans('mautic.email.abtest.label.clickthrough')][] = 0;
$data[$translator->trans('mautic.email.abtest.label.opened')][] = 0;
}
foreach ($children as $c) {
if ($c->isPublished()) {
if (!in_array($c->getId(), $hasResults)) {
// make sure that parent and published children are included
$support['labels'][] = $c->getName().' (0%)';
$data[$translator->trans('mautic.email.abtest.label.clickthrough')][] = 0;
$data[$translator->trans('mautic.email.abtest.label.opened')][] = 0;
}
}
}
$support['data'] = $data;
// set max for scales
$maxes = [];
foreach ($support['data'] as $data) {
$maxes[] = max($data);
}
$top = max($maxes);
$support['step_width'] = (ceil($top / 10) * 10);
// put in order from least to greatest just because
asort($rates);
// who's the winner?
$max = max($rates);
// get the page ids with the most number of downloads
$winners = ($max > 0) ? array_keys($rates, $max) : [];
$event->setAbTestResults([
'winners' => $winners,
'support' => $support,
'basedOn' => 'email.clickthrough',
'supportTemplate' => '@MauticPage/SubscribedEvents/AbTest/bargraph.html.twig',
]);
return;
}
}
$event->setAbTestResults([
'winners' => [],
'support' => [],
'basedOn' => 'email.clickthrough',
]);
}
}

View File

@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\EventListener;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\CoreBundle\Event\EntityExportEvent;
use Mautic\CoreBundle\Event\EntityImportAnalyzeEvent;
use Mautic\CoreBundle\Event\EntityImportEvent;
use Mautic\CoreBundle\Event\EntityImportUndoEvent;
use Mautic\CoreBundle\EventListener\ImportExportTrait;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\FormBundle\Entity\Form;
use Mautic\PageBundle\Entity\Page;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class EmailImportExportSubscriber implements EventSubscriberInterface
{
use ImportExportTrait;
public function __construct(
private EmailModel $emailModel,
private EntityManagerInterface $entityManager,
private EventDispatcherInterface $dispatcher,
private AuditLogModel $auditLogModel,
private IpLookupHelper $ipLookupHelper,
private DenormalizerInterface $serializer,
) {
}
public static function getSubscribedEvents(): array
{
return [
EntityExportEvent::class => ['onEmailExport', 0],
EntityImportEvent::class => ['onEmailImport', 0],
EntityImportUndoEvent::class => ['onUndoImport', 0],
EntityImportAnalyzeEvent::class => ['onDuplicationCheck', 0],
];
}
public function onEmailExport(EntityExportEvent $event): void
{
if (Email::ENTITY_NAME !== $event->getEntityName()) {
return;
}
$emailId = $event->getEntityId();
$email = $this->emailModel->getEntity($emailId);
if (!$email) {
return;
}
$emailData = [
'id' => $email->getId(),
'translation_parent_id'=> $email->getTranslationParent(),
'variant_parent_id' => $email->getVariantParent(),
'unsubscribeform_id' => $email->getUnsubscribeForm()?->getId(),
'preference_center_id' => $email->getPreferenceCenter()?->getId(),
'is_published' => $email->getIsPublished(),
'name' => $email->getName(),
'description' => $email->getDescription(),
'subject' => $email->getSubject(),
'preheader_text' => $email->getPreheaderText(),
'from_name' => $email->getFromName(),
'use_owner_as_mailer' => $email->getUseOwnerAsMailer(),
'template' => $email->getTemplate(),
'content' => $email->getContent(),
'utm_tags' => $email->getUtmTags(),
'plain_text' => $email->getPlainText(),
'custom_html' => $email->getCustomHtml(),
'email_type' => $email->getEmailType(),
'publish_up' => $email->getPublishUp()?->format(DATE_ATOM),
'publish_down' => $email->getPublishDown()?->format(DATE_ATOM),
'revision' => $email->getRevision(),
'lang' => $email->getLanguage(),
'variant_settings' => $email->getVariantSettings(),
'variant_start_date' => $email->getVariantStartDate()?->format(DATE_ATOM),
'dynamic_content' => $email->getDynamicContent(),
'headers' => $email->getHeaders(),
'public_preview' => $email->getPublicPreview(),
'uuid' => $email->getUuid(),
];
$event->addEntity(Email::ENTITY_NAME, $emailData);
$this->logAction('export', $emailId, $emailData);
$assets = $email->getAssetAttachments();
foreach ($assets as $asset) {
$subEvent = new EntityExportEvent(Asset::ENTITY_NAME, (int) $asset->getId());
$this->dispatcher->dispatch($subEvent);
$event->addEntities($subEvent->getEntities());
$event->addDependencyEntity(Email::ENTITY_NAME, [
Email::ENTITY_NAME => (int) $emailId,
Asset::ENTITY_NAME => (int) $asset->getId(),
]);
}
$form = $email->getUnsubscribeForm();
if ($form) {
$subEvent = new EntityExportEvent(Form::ENTITY_NAME, (int) $form->getId());
$this->dispatcher->dispatch($subEvent);
$event->addEntities($subEvent->getEntities());
$event->addDependencyEntity(Email::ENTITY_NAME, [
Email::ENTITY_NAME => (int) $emailId,
Form::ENTITY_NAME => (int) $form->getId(),
]);
}
$page = $email->getPreferenceCenter();
if ($page) {
$subEvent = new EntityExportEvent(Page::ENTITY_NAME, (int) $page->getId());
$this->dispatcher->dispatch($subEvent);
$event->addEntities($subEvent->getEntities());
$event->addDependencyEntity(Email::ENTITY_NAME, [
Email::ENTITY_NAME => (int) $emailId,
Page::ENTITY_NAME => (int) $page->getId(),
]);
}
}
public function onEmailImport(EntityImportEvent $event): void
{
if (Email::ENTITY_NAME !== $event->getEntityName() || !$event->getEntityData()) {
return;
}
$stats = [
EntityImportEvent::NEW => ['names' => [], 'ids' => [], 'count' => 0],
EntityImportEvent::UPDATE => ['names' => [], 'ids' => [], 'count' => 0],
];
foreach ($event->getEntityData() as $element) {
$email = $this->entityManager->getRepository(Email::class)->findOneBy(['uuid' => $element['uuid']]);
$isNew = !$email;
$email ??= new Email();
$unsubscribeForm = !empty($element['unsubscribeform_id'])
? $this->entityManager->getRepository(Form::class)->find($element['unsubscribeform_id'])
: null;
$preferenceCenter = !empty($element['preference_center_id'])
? $this->entityManager->getRepository(Page::class)->find($element['preference_center_id'])
: null;
$email->setUnsubscribeForm($unsubscribeForm);
$email->setPreferenceCenter($preferenceCenter);
$this->serializer->denormalize(
$element,
Email::class,
null,
['object_to_populate' => $email]
);
$this->emailModel->saveEntity($email);
$event->addEntityIdMap((int) $element['id'], $email->getId());
$status = $isNew ? EntityImportEvent::NEW : EntityImportEvent::UPDATE;
$stats[$status]['names'][] = $email->getName();
$stats[$status]['ids'][] = $email->getId();
++$stats[$status]['count'];
$this->logAction('import', $email->getId(), $element);
}
foreach ($stats as $status => $info) {
if ($info['count'] > 0) {
$event->setStatus($status, [Email::ENTITY_NAME => $info]);
}
}
}
public function onUndoImport(EntityImportUndoEvent $event): void
{
if (Email::ENTITY_NAME !== $event->getEntityName()) {
return;
}
$summary = $event->getSummary();
if (!isset($summary['ids']) || empty($summary['ids'])) {
return;
}
foreach ($summary['ids'] as $id) {
$entity = $this->entityManager->getRepository(Email::class)->find($id);
if ($entity) {
$this->entityManager->remove($entity);
$this->logAction('undo_import', $id, ['deletedEntity' => Email::class]);
}
}
$this->entityManager->flush();
}
public function onDuplicationCheck(EntityImportAnalyzeEvent $event): void
{
$this->performDuplicationCheck(
$event,
Email::ENTITY_NAME,
Email::class,
'name',
$this->entityManager
);
}
/**
* @param array<string, mixed> $details
*/
private function logAction(string $action, int $objectId, array $details): void
{
$this->auditLogModel->writeToLog([
'bundle' => 'email',
'object' => 'email',
'objectId' => $objectId,
'action' => $action,
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
]);
}
}

View File

@@ -0,0 +1,233 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Helper\EmojiHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Event as Events;
use Mautic\EmailBundle\Event\EmailEditSubmitEvent;
use Mautic\EmailBundle\Event\EmailEvent;
use Mautic\EmailBundle\Model\EmailDraftModel;
use Mautic\EmailBundle\Model\EmailModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Contracts\Translation\TranslatorInterface;
class EmailSubscriber implements EventSubscriberInterface
{
public const PREHEADER_HTML_ELEMENT_BEFORE = '<div class="preheader" style="font-size:1px;line-height:1px;display:none;color:#fff;max-height:0;max-width:0;opacity:0;overflow:hidden">';
public const PREHEADER_HTML_ELEMENT_AFTER = '</div>';
public const PREHEADER_HTML_SEARCH_PATTERN = '/<body[^>]*>.*?<div class="preheader"[^>]*>(.*?)<\/div>/s';
public const PREHEADER_HTML_REPLACE_PATTERN = '/<div class="preheader"[^>]*>(.*?)<\/div>/s';
private const RETRY_COUNT = 3;
public function __construct(
private IpLookupHelper $ipLookupHelper,
private AuditLogModel $auditLogModel,
private EmailModel $emailModel,
private TranslatorInterface $translator,
private EntityManager $entityManager,
private EmailDraftModel $emailDraftModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
EmailEvents::EMAIL_POST_SAVE => ['onEmailPostSave', 0],
EmailEvents::EMAIL_ON_SEND => ['onEmailSendAddPreheaderText', 200],
EmailEvents::EMAIL_ON_DISPLAY => ['onEmailSendAddPreheaderText', 200],
EmailEvents::EMAIL_POST_DELETE => ['onEmailDelete', 0],
EmailEvents::EMAIL_FAILED => ['onEmailFailed', 0],
EmailEvents::EMAIL_RESEND => ['onEmailResend', 0],
EmailEvents::ON_EMAIL_EDIT_SUBMIT => ['manageEmailDraft'],
EmailEvents::EMAIL_PRE_DELETE => ['deleteEmailDraft'],
];
}
/**
* Add an entry to the audit log.
*/
public function onEmailPostSave(EmailEvent $event): void
{
$email = $event->getEmail();
if ($details = $event->getChanges()) {
$log = [
'bundle' => 'email',
'object' => 'email',
'objectId' => $email->getId(),
'action' => ($event->isNew()) ? 'create' : 'update',
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
}
/**
* Add preheader text to email body.
*/
public function onEmailSendAddPreheaderText(Events\EmailSendEvent $event): void
{
$email = $event->getEmail();
$html = $event->getContent();
if ($email && $email->getPreheaderText()) {
$preheaderTextElement = self::PREHEADER_HTML_ELEMENT_BEFORE.$email->getPreheaderText().self::PREHEADER_HTML_ELEMENT_AFTER;
$preheaderExists = preg_match(self::PREHEADER_HTML_SEARCH_PATTERN, $html, $preheaderMatches);
if ($preheaderExists) {
$html = preg_replace(self::PREHEADER_HTML_REPLACE_PATTERN, $preheaderTextElement, $html);
} elseif (preg_match('/(<body[^\>]*>)/i', $html, $contentMatches)) {
$html = str_ireplace($contentMatches[0], $contentMatches[0]."\n".$preheaderTextElement, $html);
}
$event->setContent($html);
}
}
/**
* Add a delete entry to the audit log.
*/
public function onEmailDelete(EmailEvent $event): void
{
$email = $event->getEmail();
$log = [
'bundle' => 'email',
'object' => 'email',
'objectId' => $email->deletedId,
'action' => 'delete',
'details' => ['name' => $email->getName()],
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
/**
* Process if an email has failed.
*/
public function onEmailFailed(Events\QueueEmailEvent $event): void
{
$message = $event->getMessage();
$leadIdHash = $message->getLeadIdHash();
if (isset($leadIdHash)) {
$stat = $this->emailModel->getEmailStatus($leadIdHash);
if (null !== $stat) {
$reason = $this->translator->trans('mautic.email.dnc.failed', [
'%subject%' => EmojiHelper::toShort($message->getSubject()),
]);
$this->emailModel->setDoNotContact($stat, $reason);
}
}
}
/**
* Process if an email is resent.
*/
public function onEmailResend(Events\QueueEmailEvent $event): void
{
$message = $event->getMessage();
if (empty($message->getLeadIdHash())) {
return;
}
$stat = $this->emailModel->getEmailStatus($message->getLeadIdHash());
if (!$stat) {
return;
}
$stat->upRetryCount();
if ($stat->getRetryCount() > self::RETRY_COUNT) {
// tried too many times so just fail
$reason = $this->translator->trans('mautic.email.dnc.retries', [
'%subject%' => EmojiHelper::toShort($message->getSubject()),
]);
$this->emailModel->setDoNotContact($stat, $reason);
} else {
// set it to try again
$event->tryAgain();
}
$this->emailModel->saveEmailStat($stat);
}
public function manageEmailDraft(EmailEditSubmitEvent $event): void
{
$liveEmail = $event->getPreviousEmail();
$editedEmail = $event->getCurrentEmail();
if (
((true === $event->isSaveAndClose()) || (true === $event->isApply()))
&& $editedEmail->hasDraft()
) {
$emailDraft = $editedEmail->getDraft();
$emailDraft->setHtml($editedEmail->getCustomHtml());
$emailDraft->setTemplate($editedEmail->getTemplate());
$editedEmail->setCustomHtml($liveEmail->getCustomHtml());
$editedEmail->setTemplate($liveEmail->getTemplate());
$this->entityManager->persist($emailDraft);
$this->entityManager->persist($editedEmail);
}
if (true === $event->isSaveAsDraft()) {
$emailDraft = $this
->emailDraftModel
->createDraft($editedEmail, $editedEmail->getCustomHtml(), $editedEmail->getTemplate());
$editedEmail->setCustomHtml($liveEmail->getCustomHtml());
$editedEmail->setTemplate($liveEmail->getTemplate());
$editedEmail->setDraft($emailDraft);
$this->emailModel->saveEntity($editedEmail);
}
if (true === $event->isDiscardDraft()) {
$this->revertEmailModifications($liveEmail, $editedEmail);
$this->emailDraftModel->deleteDraft($editedEmail);
$editedEmail->setDraft(null);
$this->entityManager->persist($editedEmail);
}
if (true === $event->isApplyDraft()) {
$this->emailDraftModel->deleteDraft($editedEmail);
$editedEmail->setDraft(null);
}
$this->entityManager->flush();
}
public function deleteEmailDraft(EmailEvent $event): void
{
try {
$this->emailDraftModel->deleteDraft($event->getEmail());
} catch (NotFoundHttpException) {
// No associated draft found for deletion. We have nothing to do here. Return.
return;
}
}
private function revertEmailModifications(Email $liveEmail, Email $editedEmail): void
{
$liveEmailReflection = new \ReflectionObject($liveEmail);
$editedEmailReflection = new \ReflectionObject($editedEmail);
foreach ($liveEmailReflection->getProperties() as $property) {
if (in_array($property->getName(), ['id', 'emailType'])) {
continue;
}
$property->setAccessible(true);
$name = $property->getName();
$value = $property->getValue($liveEmail);
$editedEmailProperty = $editedEmailReflection->getProperty($name);
$editedEmailProperty->setAccessible(true);
$editedEmailProperty->setValue($editedEmail, $value);
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Exception\EmailCouldNotBeSentException;
use Mautic\EmailBundle\Model\SendEmailToUser;
use Mautic\PointBundle\Event\TriggerExecutedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class EmailToUserSubscriber implements EventSubscriberInterface
{
public function __construct(
private SendEmailToUser $sendEmailToUser,
) {
}
public static function getSubscribedEvents(): array
{
return [EmailEvents::ON_SENT_EMAIL_TO_USER => ['onEmailToUser', 0]];
}
public function onEmailToUser(TriggerExecutedEvent $event): TriggerExecutedEvent
{
$triggerEvent = $event->getTriggerEvent();
$config = $triggerEvent->getProperties();
$lead = $event->getLead();
try {
$this->sendEmailToUser->sendEmailToUsers($config, $lead);
$event->setSucceded();
} catch (EmailCouldNotBeSentException) {
$event->setFailed();
}
return $event;
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Doctrine\ORM\ORMException;
use Mautic\EmailBundle\Form\Type\EmailSendType;
use Mautic\EmailBundle\Form\Type\FormSubmitActionUserEmailType;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\FormBundle\Event\FormBuilderEvent;
use Mautic\FormBundle\Event\SubmissionEvent;
use Mautic\FormBundle\FormEvents;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class FormSubscriber implements EventSubscriberInterface
{
public function __construct(
private EmailModel $emailModel,
private ContactTracker $contactTracker,
) {
}
public static function getSubscribedEvents(): array
{
return [
FormEvents::FORM_ON_BUILD => ['onFormBuilder', 0],
FormEvents::ON_EXECUTE_SUBMIT_ACTION => [
['onFormSubmitActionSendEmail', 0],
],
];
}
/**
* Add a send email actions to available form submit actions.
*/
public function onFormBuilder(FormBuilderEvent $event): void
{
$event->addSubmitAction('email.send.user', [
'group' => 'mautic.email.actions',
'label' => 'mautic.email.form.action.sendemail.admin',
'description' => 'mautic.email.form.action.sendemail.admin.descr',
'formType' => FormSubmitActionUserEmailType::class,
'formTheme' => '@MauticEmail/FormTheme/FormAction/_formaction_properties_useremail_row.html.twig',
'eventName' => FormEvents::ON_EXECUTE_SUBMIT_ACTION,
'allowCampaignForm' => true,
'template' => '@MauticEmail/Action/email.html.twig',
]);
$event->addSubmitAction('email.send.lead', [
'group' => 'mautic.email.actions',
'label' => 'mautic.email.form.action.sendemail.lead',
'description' => 'mautic.email.form.action.sendemail.lead.descr',
'formType' => EmailSendType::class,
'formTypeOptions' => ['update_select' => 'formaction_properties_email'],
'formTheme' => '@MauticEmail/FormTheme/EmailSendList/emailsend_list_row.html.twig',
'eventName' => FormEvents::ON_EXECUTE_SUBMIT_ACTION,
'template' => '@MauticEmail/Action/email.html.twig',
]);
}
/**
* @throws ORMException
*/
public function onFormSubmitActionSendEmail(SubmissionEvent $event): void
{
if (false === $event->checkContext('email.send.user') && false === $event->checkContext('email.send.lead')) {
return;
}
$properties = $event->getAction()->getProperties();
$emailId = isset($properties['useremail']) ? (int) $properties['useremail']['email'] : (int) $properties['email'];
$email = $this->emailModel->getEntity($emailId);
if (null === $email || false === $email->isPublished()) {
return;
}
$currentLead = $this->getCurrentLead($event->getActionFeedback());
if (isset($properties['user_id']) && $properties['user_id']) {
$this->emailModel->sendEmailToUser($email, $properties['user_id'], $currentLead, $event->getTokens());
} elseif (isset($currentLead['email'])) {
$this->emailModel->sendEmail($email, $currentLead, [
'source' => ['form', $event->getAction()->getForm()->getId()],
'tokens' => $event->getTokens(),
'ignoreDNC' => true,
]);
}
}
private function getCurrentLead(array $feedback): ?array
{
// Deal with Lead email
if (!empty($feedback['lead.create']['lead'])) {
// the lead was just created via the lead.create action
$currentLead = $feedback['lead.create']['lead'];
} else {
$currentLead = $this->contactTracker->getContact();
}
if ($currentLead instanceof Lead) {
$currentLead = $currentLead->getProfileFields();
}
return $currentLead;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\EventListener;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\Doctrine\GeneratedColumn\GeneratedColumn;
use Mautic\CoreBundle\Event\GeneratedColumnsEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class GeneratedColumnSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
CoreEvents::ON_GENERATED_COLUMNS_BUILD => ['onGeneratedColumnsBuild', 0],
];
}
public function onGeneratedColumnsBuild(GeneratedColumnsEvent $event): void
{
$sentDate = new GeneratedColumn('email_stats', 'generated_sent_date', 'DATE', "CONCAT(YEAR(date_sent), '-', LPAD(MONTH(date_sent), 2, '0'), '-', LPAD(DAY(date_sent), 2, '0'))");
$sentDate->addIndexColumn('email_id');
$sentDate->setOriginalDateColumn('date_sent', 'd');
$event->addGeneratedColumn($sentDate);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\EmailBundle\Helper\StatsCollectionHelper;
use Mautic\StatsBundle\Event\AggregateStatRequestEvent;
use Mautic\StatsBundle\StatEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class GraphAggregateStatsSubscriber implements EventSubscriberInterface
{
public function __construct(
private StatsCollectionHelper $statsCollectionHelper,
) {
}
public static function getSubscribedEvents(): array
{
return [
StatEvents::AGGREGATE_STAT_REQUEST => ['onStatRequest', 0],
];
}
public function onStatRequest(AggregateStatRequestEvent $event): void
{
if (!$event->checkContextPrefix(StatsCollectionHelper::GENERAL_STAT_PREFIX.'-')) {
return;
}
$this->statsCollectionHelper->generateStats(
$event->getStatName(),
$event->getFromDateTime(),
$event->getToDateTime(),
$event->getOptions(),
$event->getStatCollection()
);
$event->statsCollected();
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\EmailBundle\Entity\EmailReplyRepository;
use Mautic\EmailBundle\Entity\StatRepository;
use Mautic\LeadBundle\Event\LeadMergeEvent;
use Mautic\LeadBundle\Event\LeadTimelineEvent;
use Mautic\LeadBundle\LeadEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class LeadSubscriber implements EventSubscriberInterface
{
public function __construct(
private EmailReplyRepository $emailReplyRepository,
private StatRepository $statRepository,
private TranslatorInterface $translator,
private RouterInterface $router,
) {
}
public static function getSubscribedEvents(): array
{
return [
LeadEvents::TIMELINE_ON_GENERATE => ['onTimelineGenerate', 0],
LeadEvents::LEAD_POST_MERGE => ['onLeadMerge', 0],
];
}
/**
* Compile events for the lead timeline.
*/
public function onTimelineGenerate(LeadTimelineEvent $event): void
{
$this->addEmailEvents($event, 'read');
$this->addEmailEvents($event, 'sent');
$this->addEmailEvents($event, 'failed');
$this->addEmailReplies($event);
}
public function onLeadMerge(LeadMergeEvent $event): void
{
$this->statRepository->updateLead(
$event->getLoser()->getId(),
$event->getVictor()->getId()
);
}
private function addEmailEvents(LeadTimelineEvent $event, $state): void
{
// Set available event types
$eventTypeKey = 'email.'.$state;
$eventTypeName = $this->translator->trans('mautic.email.'.$state);
$event->addEventType($eventTypeKey, $eventTypeName);
$event->addSerializerGroup('emailList');
// Decide if those events are filtered
if (!$event->isApplicable($eventTypeKey)) {
return;
}
$queryOptions = $event->getQueryOptions();
$queryOptions['state'] = $state;
$stats = $this->statRepository->getLeadStats($event->getLeadId(), $queryOptions);
// Add total to counter
$event->addToCounter($eventTypeKey, $stats);
if (!$event->isEngagementCount()) {
// Add the events to the event array
foreach ($stats['results'] as $stat) {
if (!empty($stat['email_name'])) {
$label = $stat['email_name'];
} elseif (!empty($stat['storedSubject'])) {
$label = $this->translator->trans('mautic.email.timeline.event.custom_email').': '.$stat['storedSubject'];
} else {
$label = $this->translator->trans('mautic.email.timeline.event.custom_email');
}
if (!empty($stat['idHash'])) {
$eventName = [
'label' => $label,
'href' => $this->router->generate('mautic_email_webview', ['idHash' => $stat['idHash']]),
'isExternal' => true,
];
} else {
$eventName = $label;
}
if ('failed' == $state or 'sent' == $state) { // this is to get the correct column for date dateSent
$dateSent = 'sent';
} else {
$dateSent = 'read';
}
$contactId = $stat['lead_id'];
unset($stat['lead_id']);
$event->addEvent(
[
'event' => $eventTypeKey,
'eventId' => $eventTypeKey.$stat['id'],
'eventLabel' => $eventName,
'eventType' => $eventTypeName,
'timestamp' => $stat['date'.ucfirst($dateSent)],
'extra' => [
'stat' => $stat,
'type' => $state,
],
'contentTemplate' => '@MauticEmail/SubscribedEvents/Timeline/index.html.twig',
'icon' => ('read' == $state) ? 'ri-mail-open-line' : 'ri-mail-unread-line',
'contactId' => $contactId,
]
);
}
}
}
private function addEmailReplies(LeadTimelineEvent $event): void
{
$eventTypeKey = 'email.replied';
$eventTypeName = $this->translator->trans('mautic.email.replied');
$event->addEventType($eventTypeKey, $eventTypeName);
$event->addSerializerGroup('emailList');
// Decide if those events are filtered
if (!$event->isApplicable($eventTypeKey)) {
return;
}
$options = $event->getQueryOptions();
$replies = $this->emailReplyRepository->getByLeadIdForTimeline($event->getLeadId(), $options);
if (!$event->isEngagementCount()) {
foreach ($replies['results'] as $reply) {
$label = $this->translator->trans('mautic.email.timeline.event.email_reply');
if (!empty($reply['email_name'])) {
$label .= ': '.$reply['email_name'];
} elseif (!empty($reply['storedSubject'])) {
$label .= ': '.$reply['storedSubject'];
}
$contactId = $reply['lead_id'];
unset($reply['lead_id']);
$event->addEvent(
[
'event' => $eventTypeKey,
'eventId' => $eventTypeKey.$reply['id'],
'eventLabel' => $label,
'eventType' => $eventTypeName,
'timestamp' => $reply['date_replied'],
'icon' => 'ri-mail-unread-line',
'contactId' => $contactId,
]
);
}
}
}
}

View File

@@ -0,0 +1,267 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\LeadBundle\Entity\LeadListRepository;
use Mautic\LeadBundle\Helper\FormFieldHelper;
use Mautic\LeadBundle\Segment\OperatorOptions;
trait MatchFilterForLeadTrait
{
protected function matchFilterForLead(array $filter, array $lead): bool
{
if (empty($lead['id'])) {
// Lead in generated for preview with faked data
return false;
}
$groups = [];
$groupNum = 0;
foreach ($filter as $data) {
$isCompanyField = (str_starts_with((string) $data['field'], 'company') && 'company' !== $data['field']);
$primaryCompany = ($isCompanyField && !empty($lead['companies'])) ? $lead['companies'][0] : null;
if ('leadlist' === $data['type'] && isset($this->segmentRepository) && $this->segmentRepository instanceof LeadListRepository) {
return $this->isContactSegmentRelationshipValid($this->segmentRepository, (int) $lead['id'], $data['operator'], $data['filter']);
}
if ($isCompanyField) {
if (empty($primaryCompany)) {
continue;
}
} else {
if (!array_key_exists($data['field'], $lead)) {
continue;
}
}
/*
* Split the filters into groups based on the glue.
* The first filter and any filters whose glue is
* "or" will start a new group.
*/
if (0 === $groupNum || 'or' === $data['glue']) {
++$groupNum;
$groups[$groupNum] = null;
}
/*
* If the group has been marked as false, there
* is no need to continue checking the others
* in the group.
*/
if (false === $groups[$groupNum]) {
continue;
}
/*
* If we are checking the first filter in a group
* assume that the group will not match.
*/
if (null === $groups[$groupNum]) {
$groups[$groupNum] = false;
}
$leadVal = ($isCompanyField ? $primaryCompany[$data['field']] : $lead[$data['field']]);
$filterVal = $data['filter'];
switch ($data['type']) {
case 'boolean':
if (null !== $leadVal) {
$leadVal = (bool) $leadVal;
}
if (null !== $filterVal) {
$filterVal = (bool) $filterVal;
}
break;
case 'datetime':
case 'time':
$leadValCount = substr_count($leadVal, ':');
$filterValCount = substr_count($filterVal, ':');
if (2 === $leadValCount && 1 === $filterValCount) {
$filterVal .= ':00';
}
break;
case 'tags':
case 'multiselect':
if (!is_array($leadVal)) {
$leadVal = explode('|', $leadVal);
}
if (!is_array($filterVal)) {
$filterVal = explode('|', $filterVal);
}
break;
case 'number':
$leadVal = (float) $leadVal;
$filterVal = (float) $filterVal;
break;
case 'region':
$regionChoices = FormFieldHelper::getRegionChoices();
$regions = [];
$currentIndex = is_array($filterVal) ? 1 : 0; // The index starts at 0 for single value, 1 for array
foreach ($regionChoices as $countryRegions) {
foreach ($countryRegions as $region) {
$regions[$currentIndex] = $region;
++$currentIndex;
}
}
if (is_numeric($filterVal) && isset($regions[$filterVal])) {
$filterVal = $regions[$filterVal];
}
if (is_array($filterVal)) {
foreach ($filterVal as $key => $value) {
if (is_numeric($value) && isset($regions[$value])) {
$filterVal[$key] = $regions[$value];
}
}
}
break;
case 'select':
if (!is_array($filterVal)) {
$filterVal = explode('|', $filterVal);
}
break;
}
switch ($data['operator']) {
case '=':
if ('boolean' === $data['type']) {
$groups[$groupNum] = $leadVal === $filterVal;
} else {
$groups[$groupNum] = $leadVal == $filterVal;
}
break;
case '!=':
if ('boolean' === $data['type']) {
$groups[$groupNum] = $leadVal !== $filterVal;
} else {
$groups[$groupNum] = $leadVal != $filterVal;
}
break;
case 'gt':
$groups[$groupNum] = $leadVal > $filterVal;
break;
case 'gte':
$groups[$groupNum] = $leadVal >= $filterVal;
break;
case 'lt':
$groups[$groupNum] = $leadVal < $filterVal;
break;
case 'lte':
$groups[$groupNum] = $leadVal <= $filterVal;
break;
case 'empty':
$groups[$groupNum] = empty($leadVal);
break;
case '!empty':
$groups[$groupNum] = !empty($leadVal);
break;
case 'like':
$filterVal = str_replace(['.', '*', '%'], ['\.', '\*', '.*'], $filterVal);
$groups[$groupNum] = 1 === preg_match('/'.$filterVal.'/', $leadVal);
break;
case '!like':
$filterVal = str_replace(['.', '*'], ['\.', '\*'], $filterVal);
$filterVal = str_replace('%', '.*', $filterVal);
$groups[$groupNum] = 1 !== preg_match('/'.$filterVal.'/', $leadVal);
break;
case OperatorOptions::INCLUDING_ANY:
$groups[$groupNum] = $this->checkLeadValueIsInFilter($leadVal, $filterVal, false);
break;
case OperatorOptions::EXCLUDING_ANY:
$groups[$groupNum] = $this->checkLeadValueIsInFilter($leadVal, $filterVal, true);
break;
case OperatorOptions::INCLUDING_ALL:
$groups[$groupNum] = $this->checkAllLeadValuesAreInFilter($leadVal, $filterVal, false);
break;
case OperatorOptions::EXCLUDING_ALL:
$groups[$groupNum] = $this->checkAllLeadValuesAreInFilter($leadVal, $filterVal, true);
break;
case 'regexp':
$groups[$groupNum] = 1 === preg_match('/'.$filterVal.'/i', $leadVal);
break;
case '!regexp':
$groups[$groupNum] = 1 !== preg_match('/'.$filterVal.'/i', $leadVal);
break;
case 'startsWith':
$groups[$groupNum] = str_starts_with($leadVal, $filterVal);
break;
case 'endsWith':
$endOfString = substr($leadVal, strlen($leadVal) - strlen($filterVal));
$groups[$groupNum] = 0 === strcmp($endOfString, $filterVal);
break;
case 'contains':
$groups[$groupNum] = str_contains((string) $leadVal, (string) $filterVal);
break;
}
}
return in_array(true, $groups);
}
/**
* @param mixed $leadVal
* @param mixed $filterVal
*/
private function checkLeadValueIsInFilter($leadVal, $filterVal, bool $defaultFlag): bool
{
$leadVal = !is_array($leadVal) ? [$leadVal] : $leadVal;
$filterVal = !is_array($filterVal) ? [$filterVal] : $filterVal;
$retFlag = $defaultFlag;
foreach ($leadVal as $v) {
if (in_array($v, $filterVal)) {
$retFlag = !$defaultFlag;
// Break once we find a match
break;
}
}
return $retFlag;
}
/**
* @param mixed $leadVal
* @param mixed $filterVal
*/
private function checkAllLeadValuesAreInFilter($leadVal, $filterVal, bool $defaultFlag): bool
{
$leadVal = !is_array($leadVal) ? [$leadVal] : $leadVal;
$filterVal = !is_array($filterVal) ? [$filterVal] : $filterVal;
$valuesMatched = 0;
foreach ($leadVal as $value) {
if (in_array($value, $filterVal)) {
++$valuesMatched;
}
}
return $valuesMatched === count($filterVal) ? !$defaultFlag : $defaultFlag;
}
/**
* Duplicate method. Needs refactoring.
*
* @see \Mautic\LeadBundle\EventListener\DynamicContentSubscriber::isContactSegmentRelationshipValid
*
* @param string $operator empty, !empty, in, !in
* @param int[] $segmentIds
*/
private function isContactSegmentRelationshipValid(LeadListRepository $segmentRepository, int $contactId, string $operator, ?array $segmentIds = null): bool
{
return match ($operator) {
OperatorOptions::EMPTY => $segmentRepository->isNotContactInAnySegment($contactId), // Contact is not in any segment
OperatorOptions::NOT_EMPTY => $segmentRepository->isContactInAnySegment($contactId), // Contact is in any segment
OperatorOptions::INCLUDING_ANY => $segmentRepository->isContactInSegments($contactId, $segmentIds), // Contact is in one of the segment provided in $segmentsIds
OperatorOptions::EXCLUDING_ANY => $segmentRepository->isNotContactInSegments($contactId, $segmentIds), // Contact is not in some segments provided in $segmentsIds
OperatorOptions::INCLUDING_ALL => $segmentRepository->isContactInAllSegments($contactId, $segmentIds), // Contact is in all segments provided in $segmentsIds
OperatorOptions::EXCLUDING_ALL => $segmentRepository->isNotContactInAllSegments($contactId, $segmentIds), // Contact is not in all segments provided in $segmentsIds
default => throw new \InvalidArgumentException(sprintf("Unexpected operator '%s'", $operator)),
};
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\ChannelBundle\ChannelEvents;
use Mautic\ChannelBundle\Entity\MessageQueue;
use Mautic\ChannelBundle\Event\MessageQueueBatchProcessEvent;
use Mautic\EmailBundle\Helper\MailHelper;
use Mautic\EmailBundle\Model\EmailModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class MessageQueueSubscriber implements EventSubscriberInterface
{
public function __construct(
private EmailModel $emailModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
ChannelEvents::PROCESS_MESSAGE_QUEUE_BATCH => ['onProcessMessageQueueBatch', 0],
];
}
/**
* Sends campaign emails.
*/
public function onProcessMessageQueueBatch(MessageQueueBatchProcessEvent $event): void
{
if (!$event->checkContext('email')) {
return;
}
$messages = $event->getMessages();
$emailId = $event->getChannelId();
$email = $this->emailModel->getEntity($emailId);
$sendTo = [];
$messagesByContact = [];
$options = [
'email_type' => MailHelper::EMAIL_TYPE_MARKETING,
];
/** @var MessageQueue $message */
foreach ($messages as $message) {
if (!($email && $message->getLead() && $email->isPublished())) {
$message->setFailed();
continue;
}
$contact = $message->getLead()->getProfileFields();
if (empty($contact['email'])) {
// No email so just let this slide
$message->setProcessed();
$message->setSuccess();
}
$sendTo[$contact['id']] = $contact;
$messagesByContact[$contact['id']] = $message;
}
if (count($sendTo)) {
$options['resend_message_queue'] = $messagesByContact;
$errors = $this->emailModel->sendEmail($email, $sendTo, $options);
// Let's see who was successful
foreach ($messagesByContact as $contactId => $message) {
// If the message is processed, it was rescheduled by sendEmail
if ($message->isProcessed()) {
continue;
}
$message->setProcessed();
if (empty($errors[$contactId])) {
$message->setSuccess();
continue;
}
// Setting it to failed so it could be rescheduled
// by MessageQueueModel::processMessageQueue.
// We will get job loops otherwise.
$message->setFailed();
}
}
$event->stopPropagation();
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\CampaignBundle\Executioner\RealTimeExecutioner;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\PageBundle\Event as Events;
use Mautic\PageBundle\PageEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
class PageSubscriber implements EventSubscriberInterface
{
public function __construct(
private EmailModel $emailModel,
private RealTimeExecutioner $realTimeExecutioner,
private RequestStack $requestStack,
) {
}
public static function getSubscribedEvents(): array
{
return [
PageEvents::PAGE_ON_HIT => ['onPageHit', 0],
];
}
/**
* Trigger point actions for page hits.
*/
public function onPageHit(Events\PageHitEvent $event): void
{
$hit = $event->getHit();
$redirect = $hit->getRedirect();
if ($redirect && $email = $hit->getEmail()) {
// click trigger condition
$this->realTimeExecutioner->execute('email.click', $hit, 'email', $email->getId());
// Check for an email stat
$clickthrough = $event->getClickthroughData();
if (isset($clickthrough['stat'])) {
$stat = $this->emailModel->getEmailStatus($clickthrough['stat']);
}
if (empty($stat)) {
if ($lead = $hit->getLead()) {
// Try searching by email and lead IDs
$stats = $this->emailModel->getEmailStati($hit->getSourceId(), $lead->getId());
if (count($stats)) {
$stat = $stats[0];
}
}
}
if (!empty($stat)) {
// Check to see if it has been marked as opened
if (!$stat->isRead()) {
// Mark it as read
$this->emailModel->hitEmail($stat, $this->requestStack->getCurrentRequest() ?: $event->getRequest());
}
}
}
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Doctrine\ORM\EntityManager;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event\EmailOpenEvent;
use Mautic\EmailBundle\Event\EmailSendEvent;
use Mautic\EmailBundle\Form\Type\EmailOpenType;
use Mautic\EmailBundle\Form\Type\EmailSendType;
use Mautic\EmailBundle\Form\Type\EmailToUserType;
use Mautic\EmailBundle\Helper\PointEventHelper;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PointBundle\Event\PointBuilderEvent;
use Mautic\PointBundle\Event\TriggerBuilderEvent;
use Mautic\PointBundle\Model\PointModel;
use Mautic\PointBundle\PointEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class PointSubscriber implements EventSubscriberInterface
{
public function __construct(
private PointModel $pointModel,
private EntityManager $entityManager,
private PointEventHelper $pointEventHelper,
) {
}
public static function getSubscribedEvents(): array
{
return [
PointEvents::POINT_ON_BUILD => ['onPointBuild', 0],
PointEvents::TRIGGER_ON_BUILD => ['onTriggerBuild', 0],
EmailEvents::EMAIL_ON_OPEN => ['onEmailOpen', 0],
EmailEvents::EMAIL_ON_SEND => ['onEmailSend', 0],
];
}
public function onPointBuild(PointBuilderEvent $event): void
{
$action = [
'group' => 'mautic.email.actions',
'label' => 'mautic.email.point.action.open',
'callback' => [PointEventHelper::class, 'validateEmail'],
'formType' => EmailOpenType::class,
];
$event->addAction('email.open', $action);
$action = [
'group' => 'mautic.email.actions',
'label' => 'mautic.email.point.action.send',
'callback' => [PointEventHelper::class, 'validateEmail'],
'formType' => EmailOpenType::class,
];
$event->addAction('email.send', $action);
}
public function onTriggerBuild(TriggerBuilderEvent $event): void
{
$sendEvent = [
'group' => 'mautic.email.point.trigger',
'label' => 'mautic.email.point.trigger.sendemail',
'callback' => [$this->pointEventHelper, 'sendEmail'],
'formType' => EmailSendType::class,
'formTypeOptions' => ['update_select' => 'pointtriggerevent_properties_email'],
'formTheme' => '@MauticEmail/FormTheme/EmailSendList/emailsend_list_row.html.twig',
];
$event->addEvent('email.send', $sendEvent);
$sendToOwnerEvent = [
'group' => 'mautic.email.point.trigger',
'label' => 'mautic.email.point.trigger.send_email_to_user',
'formType' => EmailToUserType::class,
'formTypeOptions' => ['update_select' => 'pointtriggerevent_properties_useremail_email'],
'formTheme' => '@MauticEmail/FormTheme/EmailSendList/email_to_user_row.html.twig',
'eventName' => EmailEvents::ON_SENT_EMAIL_TO_USER,
];
$event->addEvent('email.send_to_user', $sendToOwnerEvent);
}
/**
* Trigger point actions for email open.
*/
public function onEmailOpen(EmailOpenEvent $event): void
{
$this->pointModel->triggerAction('email.open', $event->getEmail());
}
/**
* Trigger point actions for email send.
*/
public function onEmailSend(EmailSendEvent $event): void
{
$leadArray = $event->getLead();
if ($leadArray && is_array($leadArray) && !empty($leadArray['id'])) {
$lead = $this->entityManager->getReference(Lead::class, $leadArray['id']);
} else {
return;
}
$this->pointModel->triggerAction('email.send', $event->getEmail(), null, $lead, true);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event\MonitoredEmailEvent;
use Mautic\EmailBundle\Event\ParseEmailEvent;
use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ProcessBounceSubscriber implements EventSubscriberInterface
{
public const BUNDLE = 'EmailBundle';
public const FOLDER_KEY = 'bounces';
public static function getSubscribedEvents(): array
{
return [
EmailEvents::MONITORED_EMAIL_CONFIG => ['onEmailConfig', 0],
EmailEvents::EMAIL_PARSE => ['onEmailParse', 0],
];
}
public function __construct(
private Bounce $bouncer,
) {
}
public function onEmailConfig(MonitoredEmailEvent $event): void
{
$event->addFolder(self::BUNDLE, self::FOLDER_KEY, 'mautic.email.config.monitored_email.bounce_folder');
}
public function onEmailParse(ParseEmailEvent $event): void
{
if ($event->isApplicable(self::BUNDLE, self::FOLDER_KEY)) {
// Process the messages
$messages = $event->getMessages();
foreach ($messages as $message) {
$this->bouncer->process($message);
}
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\CoreBundle\Helper\CacheStorageHelper;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event\MonitoredEmailEvent;
use Mautic\EmailBundle\Event\ParseEmailEvent;
use Mautic\EmailBundle\MonitoredEmail\Mailbox;
use Mautic\EmailBundle\MonitoredEmail\Processor\Reply;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ProcessReplySubscriber implements EventSubscriberInterface
{
public const BUNDLE = 'EmailBundle';
public const FOLDER_KEY = 'replies';
public const CACHE_KEY = self::BUNDLE.'_'.self::FOLDER_KEY;
public static function getSubscribedEvents(): array
{
return [
EmailEvents::MONITORED_EMAIL_CONFIG => ['onEmailConfig', 0],
EmailEvents::EMAIL_PRE_FETCH => ['onEmailPreFetch', 0],
EmailEvents::EMAIL_PARSE => ['onEmailParse', 1],
];
}
public function __construct(
private Reply $replier,
private CacheStorageHelper $cache,
) {
}
public function onEmailConfig(MonitoredEmailEvent $event): void
{
$event->addFolder(self::BUNDLE, self::FOLDER_KEY, 'mautic.email.config.monitored_email.reply_folder');
}
public function onEmailPreFetch(ParseEmailEvent $event): void
{
if (!$lastFetchedUID = $this->cache->get(self::CACHE_KEY)) {
return;
}
$startingUID = $lastFetchedUID + 1;
// Using * will return the last UID even if the starting UID doesn't exist so let's just use a highball number
$endingUID = $startingUID + 1_000_000_000;
$event->setCriteriaRequest(self::BUNDLE, self::FOLDER_KEY, Mailbox::CRITERIA_UID." $startingUID:$endingUID");
}
public function onEmailParse(ParseEmailEvent $event): void
{
if ($event->isApplicable(self::BUNDLE, self::FOLDER_KEY)) {
// Process the messages
if ($messages = $event->getMessages()) {
foreach ($messages as $message) {
$this->replier->process($message);
}
// Store the last UID
$this->cache->set(self::CACHE_KEY, $message->id);
}
}
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event\EmailSendEvent;
use Mautic\EmailBundle\Event\MonitoredEmailEvent;
use Mautic\EmailBundle\Event\ParseEmailEvent;
use Mautic\EmailBundle\MonitoredEmail\Processor\FeedbackLoop;
use Mautic\EmailBundle\MonitoredEmail\Processor\Unsubscribe;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ProcessUnsubscribeSubscriber implements EventSubscriberInterface
{
public const BUNDLE = 'EmailBundle';
public const FOLDER_KEY = 'unsubscribes';
public static function getSubscribedEvents(): array
{
return [
EmailEvents::MONITORED_EMAIL_CONFIG => ['onEmailConfig', 0],
EmailEvents::EMAIL_PARSE => ['onEmailParse', 0],
EmailEvents::EMAIL_ON_SEND => ['onEmailSend', 0],
];
}
public function __construct(
private Unsubscribe $unsubscriber,
private FeedbackLoop $looper,
) {
}
public function onEmailConfig(MonitoredEmailEvent $event): void
{
$event->addFolder(self::BUNDLE, self::FOLDER_KEY, 'mautic.email.config.monitored_email.unsubscribe_folder');
}
public function onEmailParse(ParseEmailEvent $event): void
{
if ($event->isApplicable(self::BUNDLE, self::FOLDER_KEY)) {
// Process the messages
$messages = $event->getMessages();
foreach ($messages as $message) {
if (!$this->unsubscriber->process($message)) {
$this->looper->process($message);
}
}
}
}
/**
* Add an unsubscribe email to the List-Unsubscribe header if applicable.
*/
public function onEmailSend(EmailSendEvent $event): void
{
$helper = $event->getHelper();
if ($helper && $unsubscribeEmail = $helper->generateUnsubscribeEmail()) {
$headers = $event->getTextHeaders();
$existing = $headers['List-Unsubscribe'] ?? '';
$unsubscribeEmail = "<mailto:$unsubscribeEmail>";
if ($existing) {
if (!str_contains($existing, $unsubscribeEmail)) {
$updatedHeader = $existing.', '.$unsubscribeEmail;
} else {
$updatedHeader = $existing;
}
} else {
$updatedHeader = $unsubscribeEmail;
}
$event->addTextHeader('List-Unsubscribe', $updatedHeader);
}
}
}

View File

@@ -0,0 +1,905 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\CoreBundle\Doctrine\Provider\GeneratedColumnsProviderInterface;
use Mautic\CoreBundle\Helper\Chart\BarChart;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\Chart\LineChart;
use Mautic\CoreBundle\Helper\Chart\PieChart;
use Mautic\CoreBundle\Helper\Chart\SeriesPieChart;
use Mautic\EmailBundle\Entity\EmailRepository;
use Mautic\EmailBundle\Entity\StatRepository;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\LeadBundle\Model\CompanyReportData;
use Mautic\LeadBundle\Report\DncReportService;
use Mautic\LeadBundle\Report\FieldsBuilder;
use Mautic\ReportBundle\Event\ReportBuilderEvent;
use Mautic\ReportBundle\Event\ReportDataEvent;
use Mautic\ReportBundle\Event\ReportGeneratorEvent;
use Mautic\ReportBundle\Event\ReportGraphEvent;
use Mautic\ReportBundle\ReportEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ReportSubscriber implements EventSubscriberInterface
{
public const CONTEXT_EMAILS = 'emails';
public const CONTEXT_EMAIL_STATS = 'email.stats';
public const EMAILS_PREFIX = 'e';
public const EMAIL_STATS_PREFIX = 'es';
public const EMAIL_VARIANT_PREFIX = 'vp';
public const DNC_PREFIX = 'dnc';
public const CLICK_PREFIX = 'cut';
public const TRACKABLE_PREFIX = 'tr';
public const REDIRECT_PREFIX = 'pr';
public const CLICK_THROUGH_PREFIX = 'ct';
public const DNC_COLUMNS = [
'unsubscribed' => [
'alias' => 'unsubscribed',
'label' => 'mautic.email.report.unsubscribed',
'type' => 'string',
'formula' => 'IFNULL((SELECT ROUND(SUM(IF('.self::DNC_PREFIX.'.id IS NOT NULL AND '.self::DNC_PREFIX.'.channel_id='.self::EMAILS_PREFIX.'.id AND dnc.reason='.DoNotContact::UNSUBSCRIBED.' , 1, 0)), 1) FROM '.MAUTIC_TABLE_PREFIX.'lead_donotcontact dnc), 0)',
],
'unsubscribed_ratio' => [
'alias' => 'unsubscribed_ratio',
'label' => 'mautic.email.report.unsubscribed_ratio',
'type' => 'string',
'formula' => 'IFNULL((SELECT ROUND((SUM(IF('.self::DNC_PREFIX.'.id IS NOT NULL AND '.self::DNC_PREFIX.'.channel_id='.self::EMAILS_PREFIX.'.id AND dnc.reason='.DoNotContact::UNSUBSCRIBED.' , 1, 0))/'.self::EMAILS_PREFIX.'.sent_count)*100, 1) FROM '.MAUTIC_TABLE_PREFIX.'lead_donotcontact dnc), \'0.0\')',
'suffix' => '%',
],
'bounced' => [
'alias' => 'bounced',
'label' => 'mautic.email.report.bounced',
'type' => 'string',
'formula' => 'IFNULL((SELECT ROUND(SUM(IF('.self::DNC_PREFIX.'.id IS NOT NULL AND '.self::DNC_PREFIX.'.channel_id='.self::EMAILS_PREFIX.'.id AND dnc.reason='.DoNotContact::BOUNCED.' , 1, 0)), 1) FROM '.MAUTIC_TABLE_PREFIX.'lead_donotcontact dnc), 0)',
],
'bounced_ratio' => [
'alias' => 'bounced_ratio',
'label' => 'mautic.email.report.bounced_ratio',
'type' => 'string',
'formula' => 'IFNULL((SELECT ROUND((SUM(IF('.self::DNC_PREFIX.'.id IS NOT NULL AND '.self::DNC_PREFIX.'.channel_id='.self::EMAILS_PREFIX.'.id AND dnc.reason='.DoNotContact::BOUNCED.' , 1, 0))/'.self::EMAILS_PREFIX.'.sent_count)*100, 1) FROM '.MAUTIC_TABLE_PREFIX.'lead_donotcontact dnc), \'0.0\')',
'suffix' => '%',
],
];
public const EMAIL_STATS_COLUMNS = [
self::EMAIL_STATS_PREFIX.'.email_address' => [
'label' => 'mautic.email.report.stat.email_address',
'type' => 'email',
],
self::EMAIL_STATS_PREFIX.'.date_sent' => [
'label' => 'mautic.email.report.stat.date_sent',
'type' => 'datetime',
'groupByFormula' => 'DATE('.self::EMAIL_STATS_PREFIX.'.date_sent)',
],
self::EMAIL_STATS_PREFIX.'.is_read' => [
'label' => 'mautic.email.report.stat.is_read',
'type' => 'bool',
],
self::EMAIL_STATS_PREFIX.'.is_failed' => [
'label' => 'mautic.email.report.stat.is_failed',
'type' => 'bool',
],
self::EMAIL_STATS_PREFIX.'.viewed_in_browser' => [
'label' => 'mautic.email.report.stat.viewed_in_browser',
'type' => 'bool',
],
self::EMAIL_STATS_PREFIX.'.date_read' => [
'label' => 'mautic.email.report.stat.date_read',
'type' => 'datetime',
'groupByFormula' => 'DATE('.self::EMAIL_STATS_PREFIX.'.date_read)',
],
self::EMAIL_STATS_PREFIX.'.retry_count' => [
'label' => 'mautic.email.report.stat.retry_count',
'type' => 'int',
],
self::EMAIL_STATS_PREFIX.'.source' => [
'label' => 'mautic.report.field.source',
'type' => 'string',
],
self::EMAIL_STATS_PREFIX.'.source_id' => [
'label' => 'mautic.report.field.source_id',
'type' => 'int',
],
];
public const EMAIL_VARIANT_COLUMNS = [
self::EMAIL_VARIANT_PREFIX.'.id' => [
'label' => 'mautic.email.report.variant_parent_id',
'type' => 'int',
],
self::EMAIL_VARIANT_PREFIX.'.subject' => [
'label' => 'mautic.email.report.variant_parent_subject',
'type' => 'string',
],
];
public const CLICK_COLUMNS = [
'hits' => [
'alias' => 'hits',
'label' => 'mautic.email.report.hits_count',
'type' => 'string',
'formula' => 'IFNULL('.self::CLICK_PREFIX.'.hits, 0)',
],
'unique_hits' => [
'alias' => 'unique_hits',
'label' => 'mautic.email.report.unique_hits_count',
'type' => 'string',
'formula' => 'IFNULL('.self::CLICK_PREFIX.'.unique_hits, 0)',
],
'hits_ratio' => [
'alias' => 'hits_ratio',
'label' => 'mautic.email.report.hits_ratio',
'type' => 'string',
'formula' => 'IFNULL(ROUND('.self::CLICK_PREFIX.'.hits/('.self::EMAILS_PREFIX.'.sent_count)*100, 1), \'0.0\')',
'suffix' => '%',
],
'unique_ratio' => [
'alias' => 'unique_ratio',
'label' => 'mautic.email.report.unique_ratio',
'type' => 'string',
'formula' => 'IFNULL(ROUND('.self::CLICK_PREFIX.'.unique_hits/('.self::EMAILS_PREFIX.'.sent_count)*100, 1), \'0.0\')',
'suffix' => '%',
],
];
public function __construct(
private Connection $db,
private CompanyReportData $companyReportData,
private StatRepository $statRepository,
private EmailRepository $emailRepository,
private GeneratedColumnsProviderInterface $generatedColumnsProvider,
private FieldsBuilder $fieldsBuilder,
private DncReportService $dncReportService,
) {
}
public static function getSubscribedEvents(): array
{
return [
ReportEvents::REPORT_ON_BUILD => ['onReportBuilder', 0],
ReportEvents::REPORT_ON_GENERATE => ['onReportGenerate', 0],
ReportEvents::REPORT_ON_GRAPH_GENERATE => ['onReportGraphGenerate', 0],
ReportEvents::REPORT_ON_DISPLAY => ['onReportDisplay', 0],
];
}
/**
* Add available tables and columns to the report builder lookup.
*/
public function onReportBuilder(ReportBuilderEvent $event): void
{
if (!$event->checkContext([self::CONTEXT_EMAILS, self::CONTEXT_EMAIL_STATS])) {
return;
}
$prefix = self::EMAILS_PREFIX.'.';
$columns = [
$prefix.'subject' => [
'label' => 'mautic.email.subject',
'type' => 'string',
],
$prefix.'lang' => [
'label' => 'mautic.core.language',
'type' => 'string',
],
$prefix.'read_count' => [
'label' => 'mautic.email.report.read_count',
'type' => 'int',
],
'read_ratio' => [
'alias' => 'read_ratio',
'label' => 'mautic.email.report.read_ratio',
'type' => 'string',
'formula' => 'IFNULL(ROUND(('.$prefix.'read_count/'.$prefix.'sent_count)*100, 1), \'0.0\')',
'suffix' => '%',
],
$prefix.'sent_count' => [
'label' => 'mautic.email.report.sent_count',
'type' => 'int',
],
$prefix.'revision' => [
'label' => 'mautic.email.report.revision',
'type' => 'int',
],
$prefix.'variant_start_date' => [
'label' => 'mautic.email.report.variant_start_date',
'type' => 'datetime',
'groupByFormula' => 'DATE('.$prefix.'variant_start_date)',
],
$prefix.'variant_sent_count' => [
'label' => 'mautic.email.report.variant_sent_count',
'type' => 'int',
],
$prefix.'variant_read_count' => [
'label' => 'mautic.email.report.variant_read_count',
'type' => 'int',
],
'click_through_count' => [
'alias' => 'click_through_count',
'label' => 'mautic.email.report.click_through_count',
'type' => 'string',
'formula' => 'IFNULL('.self::CLICK_THROUGH_PREFIX.'.click_through_count, 0)',
],
'click_through_rate' => [
'alias' => 'click_through_rate',
'label' => 'mautic.email.report.click_through_rate',
'type' => 'string',
'formula' => 'IFNULL(ROUND('.self::CLICK_THROUGH_PREFIX.'.click_through_count/'.$prefix.'sent_count * 100, 1), \'0.0\')',
'suffix' => '%',
],
'click_to_open_rate' => [
'alias' => 'click_to_open_rate',
'label' => 'mautic.email.report.click_to_open_rate',
'type' => 'string',
'formula' => 'IFNULL(ROUND('.self::CLICK_THROUGH_PREFIX.'.click_through_count/'.$prefix.'read_count * 100, 1), \'0.0\')',
'suffix' => '%',
],
];
$columns = array_merge(
$columns,
$event->getStandardColumns($prefix, [], 'mautic_email_action'),
$event->getCategoryColumns(),
self::DNC_COLUMNS,
self::EMAIL_VARIANT_COLUMNS,
self::CLICK_COLUMNS,
$this->dncReportService->getDncColumns()
);
$data = [
'display_name' => 'mautic.email.emails',
'columns' => $columns,
];
$event->addTable(self::CONTEXT_EMAILS, $data);
$context = self::CONTEXT_EMAILS;
$event->addGraph($context, 'pie', 'mautic.email.graph.pie.read.ingored.unsubscribed.bounced');
$event->addGraph($context, 'pie', 'mautic.email.graph.pie.sent.read.clicked.unsubscribed');
$event->addGraph($context, 'table', 'mautic.email.table.most.emails.clicks');
$event->addGraph($context, 'table', 'mautic.email.table.most.emails.table');
if ($event->checkContext(self::CONTEXT_EMAIL_STATS)) {
// Ratios are not applicable for individual stats
unset($columns['read_ratio'], $columns['unsubscribed_ratio'], $columns['bounced_ratio'], $columns['hits_ratio'], $columns['unique_ratio']);
// Click through value are not applicable for individual stats
unset($columns['click_through_count'], $columns['click_through_rate'], $columns['click_to_open_rate']);
// Email counts are not applicable for individual stats
unset($columns[$prefix.'read_count'], $columns[$prefix.'variant_sent_count'], $columns[$prefix.'variant_read_count']);
// Prevent null DNC records from filtering the results
$columns['unsubscribed']['type'] = 'bool';
$columns['unsubscribed']['formula'] = 'IF(dnc.id IS NOT NULL AND dnc.reason='.DoNotContact::UNSUBSCRIBED.', 1, 0)';
$columns['bounced']['type'] = 'bool';
$columns['bounced']['formula'] = 'IF(dnc.id IS NOT NULL AND dnc.reason='.DoNotContact::BOUNCED.', 1, 0)';
// clicked column for individual stats
$columns['is_hit'] = [
'alias' => 'is_hit',
'label' => 'mautic.email.report.is_hit',
'type' => 'bool',
'formula' => 'IF('.self::CLICK_PREFIX.'.hits is NULL, 0, 1)',
];
// time between sent and read
$columns['read_delay'] = [
'alias' => 'read_delay',
'label' => 'mautic.email.report.read.delay',
'type' => 'string',
'formula' => 'IF(es.date_read IS NOT NULL, TIMEDIFF(es.date_read, es.date_sent), \'-\')',
];
$columns = array_merge(
$columns,
self::EMAIL_STATS_COLUMNS,
$event->getCampaignByChannelColumns(),
$event->getLeadColumns(),
$event->getIpColumn(),
$this->companyReportData->getCompanyData()
);
$filters = array_merge(
$columns,
$this->fieldsBuilder->getLeadFilter('l.', 's.')
);
$data = [
'display_name' => 'mautic.email.stats.report.table',
'columns' => $columns,
'filters' => $filters,
];
$event->addTable(self::CONTEXT_EMAIL_STATS, $data, self::CONTEXT_EMAILS);
// Register Graphs
$context = self::CONTEXT_EMAIL_STATS;
$event->addGraph($context, 'line', 'mautic.email.graph.line.stats');
$event->addGraph($context, 'pie', 'mautic.email.graph.pie.ignored.read.failed');
$event->addGraph($context, 'table', 'mautic.email.table.most.emails.sent');
$event->addGraph($context, 'table', 'mautic.email.table.most.emails.read');
$event->addGraph($context, 'table', 'mautic.email.table.most.emails.read.percent');
$event->addGraph($context, 'table', 'mautic.email.table.most.emails.unsubscribed');
$event->addGraph($context, 'table', 'mautic.email.table.most.emails.bounced');
$event->addGraph($context, 'table', 'mautic.email.table.most.emails.failed');
$event->addGraph($context, 'table', 'mautic.email.table.most.emails.clicks');
}
}
/**
* Initialize the QueryBuilder object to generate reports from.
*/
public function onReportGenerate(ReportGeneratorEvent $event): void
{
$context = $event->getContext();
$qb = $event->getQueryBuilder();
$hasGroupBy = $event->hasGroupBy();
$qbcut = $this->db->createQueryBuilder(); // channel_url_trackables subquery
$qbct = $this->db->createQueryBuilder(); // click-though subquery
$useDncColumns = $event->usesColumn(array_keys(self::DNC_COLUMNS));
$useVariantColumns = $event->usesColumn(array_keys(self::EMAIL_VARIANT_COLUMNS));
$useClickColumns = $event->usesColumn(array_keys(self::CLICK_COLUMNS)) || $event->usesColumn('is_hit');
$useClickThroughColumns = $event->usesColumn(['click_through_count', 'click_through_rate', 'click_to_open_rate']);
switch ($context) {
case self::CONTEXT_EMAILS:
$qb->from(MAUTIC_TABLE_PREFIX.'emails', self::EMAILS_PREFIX)
->leftJoin(self::EMAILS_PREFIX, MAUTIC_TABLE_PREFIX.'emails', self::EMAIL_VARIANT_PREFIX, 'vp.id = e.variant_parent_id');
$event->addCategoryLeftJoin($qb, self::EMAILS_PREFIX)
->applyDateFilters($qb, 'date_added', self::EMAILS_PREFIX);
if (!$hasGroupBy) {
$qb->groupBy('e.id');
}
if ($useClickColumns) {
$qbcut->select(
'COUNT(cut2.channel_id) AS trackable_count, SUM(cut2.hits) AS hits',
'SUM(cut2.unique_hits) AS unique_hits',
'cut2.channel_id'
)
->from(MAUTIC_TABLE_PREFIX.'channel_url_trackables', 'cut2')
->where('cut2.channel = \'email\'')
->groupBy('cut2.channel_id');
$qb->leftJoin(self::EMAILS_PREFIX, sprintf('(%s)', $qbcut->getSQL()), self::CLICK_PREFIX, 'e.id = cut.channel_id');
}
if ($useDncColumns) {
$this->addDNCTableForEmails($qb);
}
if ($useClickThroughColumns) {
$qbct->select(
'COUNT(DISTINCT ph.lead_id) AS click_through_count',
'cut.channel_id',
)
->from(MAUTIC_TABLE_PREFIX.'page_hits', 'ph')
->innerJoin('ph', MAUTIC_TABLE_PREFIX.'channel_url_trackables', 'cut', 'cut.redirect_id = ph.redirect_id AND cut.channel_id = ph.source_id')
->groupBy('cut.channel_id');
$qb->leftJoin(self::EMAILS_PREFIX, sprintf('(%s)', $qbct->getSQL()), self::CLICK_THROUGH_PREFIX, 'e.id = ct.channel_id');
}
break;
case self::CONTEXT_EMAIL_STATS:
$qb->from(MAUTIC_TABLE_PREFIX.'email_stats', self::EMAIL_STATS_PREFIX);
if ($event->usesColumnWithPrefix(self::EMAILS_PREFIX)
|| $event->usesColumnWithPrefix(ReportGeneratorEvent::CATEGORY_PREFIX)
|| $useVariantColumns
) {
$qb->leftJoin(self::EMAIL_STATS_PREFIX, MAUTIC_TABLE_PREFIX.'emails', self::EMAILS_PREFIX, 'e.id = es.email_id');
}
if ($useVariantColumns) {
$qb->leftJoin(self::EMAILS_PREFIX, MAUTIC_TABLE_PREFIX.'emails', self::EMAIL_VARIANT_PREFIX, 'vp.id = e.variant_parent_id');
}
if ($useDncColumns) {
$this->addDNCTableForEmailStats($qb);
}
if ($event->hasFilter('s.leadlist_id')) {
$qb->join('l', MAUTIC_TABLE_PREFIX.'lead_lists_leads', 's', 's.lead_id = l.id AND s.manually_removed = 0');
}
$event->addCategoryLeftJoin($qb, self::EMAILS_PREFIX)
->addLeadLeftJoin($qb, self::EMAIL_STATS_PREFIX)
->addIpAddressLeftJoin($qb, self::EMAIL_STATS_PREFIX)
->applyDateFilters($qb, 'date_sent', self::EMAIL_STATS_PREFIX);
if ($useClickColumns) {
$qbcut->select(
'COUNT(ph.id) AS hits',
'COUNT(DISTINCT(ph.redirect_id)) AS unique_hits',
'cut2.channel_id',
'ph.lead_id'
)
->from(MAUTIC_TABLE_PREFIX.'channel_url_trackables', 'cut2')
->join(
'cut2',
MAUTIC_TABLE_PREFIX.'page_hits',
'ph',
'cut2.redirect_id = ph.redirect_id AND cut2.channel_id = ph.source_id'
)
->where('cut2.channel = \'email\' AND ph.source = \'email\'')
->groupBy('cut2.channel_id, ph.lead_id');
if ($event->hasFilter('e.id')) {
$filterParam = $event->createParameterName();
$qbcut->andWhere($qb->expr()->in('cut2.channel_id', ":{$filterParam}"));
$qb->setParameter($filterParam, $event->getFilterValues('e.id'), ArrayParameterType::INTEGER);
}
$qb->leftJoin(
self::EMAIL_STATS_PREFIX,
"({$qbcut->getSQL()})",
self::CLICK_PREFIX,
'es.email_id = cut.channel_id AND es.lead_id = cut.lead_id'
);
}
$event->addCampaignByChannelJoin(
$qb,
self::EMAIL_STATS_PREFIX,
'email',
ReportGeneratorEvent::CONTACT_PREFIX,
'email_id'
);
if ($this->companyReportData->eventHasCompanyColumns($event)) {
$event->addCompanyLeftJoin($qb);
}
if (!$event->hasGroupBy()) {
$qb->groupBy('es.id');
}
break;
}
$event->setQueryBuilder($qb);
}
/**
* Initialize the QueryBuilder object to generate reports from.
*/
public function onReportGraphGenerate(ReportGraphEvent $event): void
{
$graphs = $event->getRequestedGraphs();
if (!$event->checkContext([self::CONTEXT_EMAIL_STATS, self::CONTEXT_EMAILS])) {
return;
}
if ($event->checkContext(self::CONTEXT_EMAILS)
&& !in_array('mautic.email.graph.pie.read.ingored.unsubscribed.bounced', $graphs)
&& !in_array('mautic.email.graph.pie.sent.read.clicked.unsubscribed', $graphs)
&& !in_array('mautic.email.table.most.emails.clicks', $graphs)) {
return;
}
$qb = $event->getQueryBuilder();
foreach ($graphs as $g) {
$options = $event->getOptions($g);
$queryBuilder = clone $qb;
/** @var ChartQuery $chartQuery */
$chartQuery = clone $options['chartQuery'];
$origQuery = clone $queryBuilder;
// just limit date for contacts emails
if ($event->checkContext(self::CONTEXT_EMAIL_STATS)) {
$chartQuery->applyDateFilters($queryBuilder, 'date_sent', self::EMAIL_STATS_PREFIX);
}
switch ($g) {
case 'mautic.email.graph.line.stats':
$chartQuery->setGeneratedColumnProvider($this->generatedColumnsProvider);
$chart = new LineChart(null, $options['dateFrom'], $options['dateTo']);
$sendQuery = clone $queryBuilder;
$readQuery = clone $origQuery;
$readQuery->andWhere($qb->expr()->isNotNull('date_read'));
$failedQuery = clone $queryBuilder;
$failedQuery->andWhere($qb->expr()->eq('es.is_failed', ':true'));
$failedQuery->setParameter('true', true, 'boolean');
$chartQuery->applyDateFilters($readQuery, 'date_read', self::EMAIL_STATS_PREFIX);
$chartQuery->modifyTimeDataQuery($sendQuery, 'date_sent', self::EMAIL_STATS_PREFIX);
$chartQuery->modifyTimeDataQuery($readQuery, 'date_read', self::EMAIL_STATS_PREFIX);
$chartQuery->modifyTimeDataQuery($failedQuery, 'date_sent', self::EMAIL_STATS_PREFIX);
$sends = $chartQuery->loadAndBuildTimeData($sendQuery);
$reads = $chartQuery->loadAndBuildTimeData($readQuery);
$failes = $chartQuery->loadAndBuildTimeData($failedQuery);
$chart->setDataset($options['translator']->trans('mautic.email.sent.emails'), $sends);
$chart->setDataset($options['translator']->trans('mautic.email.read.emails'), $reads);
$chart->setDataset($options['translator']->trans('mautic.email.failed.emails'), $failes);
$data = $chart->render();
$data['name'] = $g;
$event->setGraph($g, $data);
break;
case 'mautic.email.graph.pie.ignored.read.failed':
$queryBuilder->resetQueryPart('groupBy');
$counts = $this->statRepository->getIgnoredReadFailed($queryBuilder);
$chart = new PieChart();
$chart->setDataset($options['translator']->trans('mautic.email.read.emails'), $counts['read']);
$chart->setDataset($options['translator']->trans('mautic.email.failed.emails'), $counts['failed']);
$chart->setDataset(
$options['translator']->trans('mautic.email.ignored.emails'),
$counts['ignored']
);
$event->setGraph(
$g,
[
'data' => $chart->render(),
'name' => $g,
'iconClass' => 'ri-flag-fill',
]
);
break;
case 'mautic.email.graph.pie.sent.read.clicked.unsubscribed':
$counts = $this->emailRepository->getSentReadNotReadCount($queryBuilder);
$clicked = $this->emailRepository->getUniqueClicks($queryBuilder);
$unsubscribed = $this->emailRepository->getUnsubscribedCount($queryBuilder);
$unsubCount = $this->countVsRead($unsubscribed, 'unsubscribed', $counts);
$clickedCount = $this->countVsRead($clicked, 'clicked', $counts);
$chart = new SeriesPieChart();
$chart->setTotalCount($counts['sent_count']);
$chart->setLabels([
$chart->buildFullLabel($options['translator']->trans('mautic.email.report.unsubscribed'), $unsubCount['unsubscribed']),
$chart->buildFullLabel($options['translator']->trans('mautic.email.clicked'), $clicked),
$chart->buildFullLabel($options['translator']->trans('mautic.email.stat.read'), $counts['read_count']),
$chart->buildFullLabel($options['translator']->trans('mautic.email.stat.notread'), $counts['not_read']),
$chart->buildFullLabel($options['translator']->trans('mautic.email.stat.sent'), $counts['sent_count']),
]);
$chart->setDataset([$unsubCount['unsubscribed'], 0, $unsubCount['vsRead'], $unsubCount['vsNotRead'], 0]);
$chart->setDataset([0, $clicked, $clickedCount['vsRead'], $clickedCount['vsNotRead'], 0]);
$chart->setDataset([0, 0, $counts['read_count'], $counts['not_read'], 0]);
$chart->setDataset([0, 0, 0, 0, $counts['sent_count']]);
$event->setGraph(
$g,
[
'data' => $chart->render(),
'name' => $g,
'iconClass' => 'ri-flag-line',
]
);
break;
case 'mautic.email.graph.bar.read.clicked.unsubscribed.bounced':
$queryBuilder->select('e.id, e.name, e.sent_count, e.read_count,
count(CASE WHEN '.self::DNC_PREFIX.'.id and '.self::DNC_PREFIX.'.reason = '.DoNotContact::UNSUBSCRIBED.' THEN 1 ELSE null END) as unsubscribed,
count(CASE WHEN '.self::DNC_PREFIX.'.id and '.self::DNC_PREFIX.'.reason = '.DoNotContact::BOUNCED.' THEN 1 ELSE null END) as bounced'
)
->groupBy('e.id');
$this->addDNCTableForEmails($queryBuilder);
$data = $queryBuilder->executeQuery()->fetchAllAssociative();
if (is_array($data)) {
$names = array_column($data, 'name');
$sentCount = array_column($data, 'sent_count');
$readCount = array_column($data, 'read_count');
$unsubscribed = array_column($data, 'unsubscribed');
$bounced = array_column($data, 'bounced');
$sentCount[] = 0;
$readCount[] = 0;
$unsubscribed[] = 0;
$bounced[] = 0;
$chart = new BarChart($names);
$chart->setDataset('Sent Count', $sentCount);
$chart->setDataset('Read Count', $readCount);
$chart->setDataset('Unsubscribed Count', $unsubscribed);
$chart->setDataset('Bounced Count', $bounced);
$event->setGraph(
$g,
[
'data' => $chart->render(),
'name' => $g,
'iconClass' => 'ri-flag-line',
]
);
}
break;
case 'mautic.email.graph.pie.read.ingored.unsubscribed.bounced':
$queryBuilder->select('SUM(DISTINCT e.sent_count) as sent_count,
SUM(DISTINCT e.read_count) as read_count,
count(CASE WHEN '.self::DNC_PREFIX.'.id and '.self::DNC_PREFIX.'.reason = '.DoNotContact::UNSUBSCRIBED.' THEN 1 ELSE null END) as unsubscribed,
count(CASE WHEN '.self::DNC_PREFIX.'.id and '.self::DNC_PREFIX.'.reason = '.DoNotContact::BOUNCED.' THEN 1 ELSE null END) as bounced'
);
$this->addDNCTableForEmails($queryBuilder);
$queryBuilder->resetQueryPart('groupBy');
$counts = $queryBuilder->executeQuery()->fetchAssociative();
$chart = new PieChart();
$chart->setDataset(
$options['translator']->trans('mautic.email.stat.read'),
$counts['read_count'] ?? 0
);
$chart->setDataset(
$options['translator']->trans('mautic.email.graph.pie.ignored.read.failed.ignored'),
($counts['sent_count'] ?? 0) - ($counts['read_count'] ?? 0)
);
$chart->setDataset(
$options['translator']->trans('mautic.email.unsubscribed'),
$counts['unsubscribed'] ?? 0
);
$chart->setDataset(
$options['translator']->trans('mautic.email.bounced'),
$counts['bounced'] ?? 0
);
$event->setGraph(
$g,
[
'data' => $chart->render(),
'name' => $g,
'iconClass' => 'ri-flag-fill',
]
);
break;
case 'mautic.email.table.most.emails.sent':
$this->joinEmailsTableIfMissing($queryBuilder, $event);
$queryBuilder->select('e.id, e.subject as title, SUM(DISTINCT e. sent_count) as sent')
->groupBy('e.id, e.subject')
->orderBy('sent', 'DESC');
$limit = 10;
$offset = 0;
$items = $this->statRepository->getMostEmails($queryBuilder, $limit, $offset);
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-send-plane-line';
$graphData['link'] = 'mautic_email_action';
$event->setGraph($g, $graphData);
break;
case 'mautic.email.table.most.emails.read':
$this->joinEmailsTableIfMissing($queryBuilder, $event);
$queryBuilder->select('e.id, e.subject as title, SUM(DISTINCT e. read_count) as opens')
->groupBy('e.id, e.subject')
->orderBy('opens', 'DESC');
$limit = 10;
$offset = 0;
$items = $this->statRepository->getMostEmails($queryBuilder, $limit, $offset);
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-eye-line';
$graphData['link'] = 'mautic_email_action';
$event->setGraph($g, $graphData);
break;
case 'mautic.email.table.most.emails.failed':
$this->joinEmailsTableIfMissing($queryBuilder, $event);
$queryBuilder->select(
'e.id, e.subject as title, count(CASE WHEN es.is_failed THEN 1 ELSE null END) as failed'
)
->andWhere('es.is_failed = 1')
->having('count(CASE WHEN es.is_failed THEN 1 ELSE null END) > 0')
->groupBy('e.id, e.subject')
->orderBy('failed', 'DESC');
$limit = 10;
$offset = 0;
$items = $this->statRepository->getMostEmails($queryBuilder, $limit, $offset);
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-alert-line';
$graphData['link'] = 'mautic_email_action';
$event->setGraph($g, $graphData);
break;
case 'mautic.email.table.most.emails.unsubscribed':
$this->joinEmailsTableIfMissing($queryBuilder, $event);
$this->addDNCTableForEmailStats($queryBuilder);
$queryBuilder->select(
'e.id, e.subject as title, count(CASE WHEN dnc.id and dnc.reason = '.DoNotContact::UNSUBSCRIBED.' THEN 1 ELSE null END) as unsubscribed'
)
->having(
'count(CASE WHEN dnc.id and dnc.reason = '.DoNotContact::UNSUBSCRIBED.' THEN 1 ELSE null END) > 0'
)
->groupBy('e.id, e.subject')
->orderBy('unsubscribed', 'DESC');
$limit = 10;
$offset = 0;
$items = $this->statRepository->getMostEmails($queryBuilder, $limit, $offset);
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-alert-line';
$graphData['link'] = 'mautic_email_action';
$event->setGraph($g, $graphData);
break;
case 'mautic.email.table.most.emails.bounced':
$this->joinEmailsTableIfMissing($queryBuilder, $event);
$this->addDNCTableForEmailStats($queryBuilder);
$queryBuilder->select(
'e.id, e.subject as title, count(CASE WHEN dnc.id and dnc.reason = '.DoNotContact::BOUNCED.' THEN 1 ELSE null END) as bounced'
)
->having(
'count(CASE WHEN dnc.id and dnc.reason = '.DoNotContact::BOUNCED.' THEN 1 ELSE null END) > 0'
)
->groupBy('e.id, e.subject')
->orderBy('bounced', 'DESC');
$limit = 10;
$offset = 0;
$items = $this->statRepository->getMostEmails($queryBuilder, $limit, $offset);
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-alert-line';
$graphData['link'] = 'mautic_email_action';
$event->setGraph($g, $graphData);
break;
case 'mautic.email.table.most.emails.read.percent':
$this->joinEmailsTableIfMissing($queryBuilder, $event);
$queryBuilder->select('e.id, e.subject as title, round(e.read_count / e.sent_count * 100) as ratio')
->groupBy('e.id, e.subject')
->orderBy('ratio', 'DESC');
$limit = 10;
$offset = 0;
$items = $this->statRepository->getMostEmails($queryBuilder, $limit, $offset);
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-speed-up-line';
$graphData['link'] = 'mautic_email_action';
$event->setGraph($g, $graphData);
break;
case 'mautic.email.table.most.emails.clicks':
$this->addTrackableTablesForEmailStats($queryBuilder);
$queryBuilder->select('e.id, e.subject as `title`, tr.hits as `clicks`, tr.unique_hits as `unique clicks`, pr.url as `URL`')
->andWhere('pr.url IS NOT NULL')
->groupBy('e.id, tr.redirect_id, tr.hits')
->orderBy('tr.hits', 'DESC')
->setMaxResults(10);
$items = $queryBuilder->executeQuery()->fetchAllAssociative();
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-external-link-line';
$graphData['link'] = 'mautic_email_action';
$event->setGraph($g, $graphData);
break;
}
unset($queryBuilder);
}
}
public function onReportDisplay(ReportDataEvent $event): void
{
$data = $event->getData();
if ($event->checkContext([self::CONTEXT_EMAIL_STATS])) {
$data = $this->dncReportService->processDncStatusDisplay($data);
}
$event->setData($data);
unset($data);
}
/**
* @param array<string, int> $emailCounts
*
* @return array<string, int>
*/
private function countVsRead(int $value, string $label, array $emailCounts): array
{
if (($emailCounts['read_count'] - $value) > 0) {
$result['vsRead'] = $emailCounts['read_count'] - $value;
$result['vsNotRead'] = $emailCounts['not_read'];
} else {
$result['vsRead'] = 0;
$result['vsNotRead'] = $emailCounts['sent_count'] - $value;
}
$result[$label] = $value;
return $result;
}
private function joinEmailsTableIfMissing(QueryBuilder $queryBuilder, ReportGraphEvent $event): void
{
if ($event->checkContext(self::CONTEXT_EMAIL_STATS) && !$this->isJoined($queryBuilder, MAUTIC_TABLE_PREFIX.'emails', self::EMAIL_STATS_PREFIX, self::EMAILS_PREFIX)) {
$queryBuilder->leftJoin(self::EMAIL_STATS_PREFIX, MAUTIC_TABLE_PREFIX.'emails', self::EMAILS_PREFIX, 'e.id = es.email_id');
}
}
/**
* Add the Do Not Contact table to the query builder.
*/
private function addDNCTableForEmails(QueryBuilder $qb): void
{
$table = MAUTIC_TABLE_PREFIX.'lead_donotcontact';
if (!$this->isJoined($qb, $table, self::EMAILS_PREFIX, self::DNC_PREFIX)) {
$qb->leftJoin(
self::EMAILS_PREFIX,
$table,
self::DNC_PREFIX,
'e.id = dnc.channel_id AND dnc.channel=\'email\''
);
}
}
private function addTrackableTablesForEmailStats(QueryBuilder $qb): void
{
$trTable = MAUTIC_TABLE_PREFIX.'channel_url_trackables';
$prTable = MAUTIC_TABLE_PREFIX.'page_redirects';
if (!$this->isJoined($qb, $trTable, self::EMAILS_PREFIX, self::TRACKABLE_PREFIX)) {
$qb->leftJoin(
self::EMAILS_PREFIX,
$trTable,
self::TRACKABLE_PREFIX,
'e.id = tr.channel_id AND tr.channel = \'email\''
);
}
if (!$this->isJoined($qb, $prTable, self::TRACKABLE_PREFIX, self::REDIRECT_PREFIX)) {
$qb->leftJoin(
self::TRACKABLE_PREFIX,
$prTable,
self::REDIRECT_PREFIX,
'tr.redirect_id = pr.id'
);
}
}
/**
* Add the Do Not Contact table to the query builder.
*/
private function addDNCTableForEmailStats(QueryBuilder $qb): void
{
$table = MAUTIC_TABLE_PREFIX.'lead_donotcontact';
if (!$this->isJoined($qb, $table, self::EMAIL_STATS_PREFIX, self::DNC_PREFIX)) {
$qb->leftJoin(
self::EMAIL_STATS_PREFIX,
$table,
self::DNC_PREFIX,
'es.email_id = dnc.channel_id AND dnc.channel=\'email\' AND es.lead_id = dnc.lead_id'
);
}
}
private function isJoined($query, $table, $fromAlias, $alias): bool
{
$joins = $query->getQueryParts()['join'];
if (empty($joins) || (!empty($joins) && empty($joins[$fromAlias]))) {
return false;
}
foreach ($joins[$fromAlias] as $join) {
if ($join['joinTable'] == $table && $join['joinAlias'] == $alias) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\EventListener;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\DTO\GlobalSearchFilterDTO;
use Mautic\CoreBundle\Event as MauticEvents;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\GlobalSearch;
use Mautic\EmailBundle\Model\EmailModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class SearchSubscriber implements EventSubscriberInterface
{
public function __construct(
private EmailModel $emailModel,
private CorePermissions $security,
private GlobalSearch $globalSearch,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::GLOBAL_SEARCH => ['onGlobalSearch', 0],
CoreEvents::BUILD_COMMAND_LIST => ['onBuildCommandList', 0],
];
}
public function onGlobalSearch(MauticEvents\GlobalSearchEvent $event): void
{
$filterDTO = new GlobalSearchFilterDTO($event->getSearchString());
$results = $this->globalSearch->performSearch(
$filterDTO,
$this->emailModel,
'@MauticEmail/SubscribedEvents/Search/global.html.twig'
);
if (!empty($results)) {
$event->addResults('mautic.email.emails', $results);
}
}
public function onBuildCommandList(MauticEvents\CommandListEvent $event): void
{
if ($this->security->isGranted(['email:emails:viewown', 'email:emails:viewother'], 'MATCH_ONE')) {
$event->addCommands(
'mautic.email.emails',
$this->emailModel->getCommandList()
);
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\EventListener\CommonStatsSubscriber;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\EmailBundle\Entity\EmailReply;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Entity\StatDevice;
use Mautic\EmailBundle\Entity\StatDeviceRepository;
use MauticPlugin\MauticFocusBundle\Entity\StatRepository;
class StatsSubscriber extends CommonStatsSubscriber
{
public function __construct(CorePermissions $security, EntityManager $entityManager)
{
parent::__construct($security, $entityManager);
/** @var StatDeviceRepository $repo */
$repo = $entityManager->getRepository(StatDevice::class);
$this->repositories[] = $repo;
$this->permissions[$repo->getTableName()] = ['stat.lead' => 'lead:leads'];
$this->addContactRestrictedRepositories([EmailReply::class]);
/** @var StatRepository $repo */
$repo = $entityManager->getRepository(Stat::class);
$this->repositories[] = $repo;
$statsTable = $repo->getTableName();
$this->permissions[$statsTable] = ['lead' => 'lead:leads'];
$this->selects[$statsTable] = [
'id',
'email_id',
'lead_id',
'list_id',
'ip_id',
'email_address',
'date_sent',
'is_read',
'is_failed',
'viewed_in_browser',
'date_read',
'tracking_hash',
'retry_count',
'source',
'source_id',
'open_count',
'last_opened',
'open_details',
];
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\CoreBundle\Event\TokenReplacementEvent;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Event\EmailSendEvent;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadListRepository;
use Mautic\LeadBundle\Helper\PrimaryCompanyHelper;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class TokenSubscriber implements EventSubscriberInterface
{
use MatchFilterForLeadTrait;
public function __construct(
private EventDispatcherInterface $dispatcher,
private PrimaryCompanyHelper $primaryCompanyHelper,
private LeadListRepository $segmentRepository,
) {
}
public static function getSubscribedEvents(): array
{
return [
EmailEvents::EMAIL_ON_SEND => ['decodeTokens', 254],
EmailEvents::EMAIL_ON_DISPLAY => ['decodeTokens', 254],
EmailEvents::TOKEN_REPLACEMENT => ['onTokenReplacement', -254],
];
}
public function decodeTokens(EmailSendEvent $event): void
{
if ($event->isDynamicContentParsing()) {
// prevent a loop
return;
}
// Find and replace encoded tokens for trackable URL conversion
$content = $event->getContent();
$content = $this->urlDecodeTokens($content);
$event->setContent($content);
if ($plainText = $event->getPlainText()) {
$plainText = $this->urlDecodeTokens($plainText);
$event->setPlainText($plainText);
}
$email = $event->getEmail();
if ($dynamicContentAsArray = $email instanceof Email ? $email->getDynamicContent() : null) {
$lead = $event->getLead();
$tokens = $event->getTokens();
$tokenEvent = new TokenReplacementEvent(
null,
$lead,
[
'tokens' => $tokens,
'lead' => null,
'dynamicContent' => $dynamicContentAsArray,
'idHash' => $event->getIdHash(),
],
$email,
$event->isInternalSend()
);
$this->dispatcher->dispatch($tokenEvent, EmailEvents::TOKEN_REPLACEMENT);
$event->addTokens($tokenEvent->getTokens());
}
}
public function onTokenReplacement(TokenReplacementEvent $event): void
{
$clickthrough = $event->getClickthrough();
if (!array_key_exists('dynamicContent', $clickthrough)) {
return;
}
$lead = $event->getLead();
$tokens = $clickthrough['tokens'];
$tokenData = $clickthrough['dynamicContent'];
if ($lead instanceof Lead) {
$lead = $this->primaryCompanyHelper->getProfileFieldsWithPrimaryCompany($lead);
} else {
$lead = $this->primaryCompanyHelper->mergePrimaryCompanyWithProfileFields($lead['id'], $lead);
}
foreach ($tokenData as $data) {
// Default content
$filterContent = $data['content'];
foreach ($data['filters'] as $filter) {
if ($this->matchFilterForLead($filter['filters'], $lead)) {
$filterContent = $filter['content'];
break;
}
}
// Replace lead tokens in dynamic content (but no recurrence on dynamic content to avoid infinite loop)
$emailSendEvent = new EmailSendEvent(
null,
[
'content' => $filterContent,
'email' => $event->getPassthrough(),
'idHash' => !empty($clickthrough['idHash']) ? $clickthrough['idHash'] : null,
'tokens' => $tokens,
'lead' => $lead,
],
true
);
$this->dispatcher->dispatch($emailSendEvent, EmailEvents::EMAIL_ON_DISPLAY);
$untokenizedContent = $emailSendEvent->getContent(!$event->isInternalSend());
$event->addToken('{dynamiccontent="'.$data['tokenName'].'"}', $untokenizedContent);
}
}
private function urlDecodeTokens(string $content): string
{
return preg_replace('/(%7B)(.*?)(%3D|=)(.*?)(%7D)/i', '{$2=$4}', $content);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Entity\StatRepository;
use Mautic\LeadBundle\Event\ContactIdentificationEvent;
use Mautic\LeadBundle\LeadEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class TrackingSubscriber implements EventSubscriberInterface
{
public function __construct(
private StatRepository $statRepository,
) {
}
public static function getSubscribedEvents(): array
{
return [
LeadEvents::ON_CLICKTHROUGH_IDENTIFICATION => ['onIdentifyContact', 0],
];
}
public function onIdentifyContact(ContactIdentificationEvent $event): void
{
$clickthrough = $event->getClickthrough();
// Nothing left to identify by so stick to the tracked lead
if (empty($clickthrough['stat'])) {
return;
}
/** @var Stat $stat */
$stat = $this->statRepository->findOneBy(['trackingHash' => $clickthrough['stat']]);
if (!$stat) {
// Stat doesn't exist so use the tracked lead
return;
}
if (isset($clickthrough['channel']['email']) && $stat->getEmail() && (int) $stat->getEmail()->getId() !== (int) $clickthrough['channel']['email']) {
// ID mismatch - fishy so use tracked lead
return;
}
if (!$contact = $stat->getLead()) {
return;
}
$event->setIdentifiedContact($contact, 'email');
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace Mautic\EmailBundle\EventListener;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event\EmailOpenEvent;
use Mautic\EmailBundle\Event\EmailSendEvent;
use Mautic\WebhookBundle\Event\WebhookBuilderEvent;
use Mautic\WebhookBundle\Event\WebhookQueueEvent;
use Mautic\WebhookBundle\Model\WebhookModel;
use Mautic\WebhookBundle\WebhookEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class WebhookSubscriber implements EventSubscriberInterface
{
public function __construct(
private WebhookModel $webhookModel,
private bool $includeDetails,
) {
}
public static function getSubscribedEvents(): array
{
return [
EmailEvents::EMAIL_ON_SEND => ['onEmailSend', 0],
EmailEvents::EMAIL_ON_OPEN => ['onEmailOpen', 0],
WebhookEvents::WEBHOOK_ON_BUILD => ['onWebhookBuild', 0],
WebhookEvents::WEBHOOK_QUEUE_ON_ADD => ['onWebhookQueueOnAdd', 0],
];
}
/**
* Add event triggers and actions.
*/
public function onWebhookBuild(WebhookBuilderEvent $event): void
{
// add checkbox to the webhook form for new leads
$mailSend= [
'label' => 'mautic.email.webhook.event.send',
'description' => 'mautic.email.webhook.event.send_desc',
];
$mailOpen = [
'label' => 'mautic.email.webhook.event.open',
'description' => 'mautic.email.webhook.event.open_desc',
];
// add it to the list
$event->addEvent(EmailEvents::EMAIL_ON_SEND, $mailSend);
$event->addEvent(EmailEvents::EMAIL_ON_OPEN, $mailOpen);
}
public function onEmailSend(EmailSendEvent $event): void
{
// Ignore test email sends.
if ($event->isInternalSend() || null === $event->getLead()) {
return;
}
$payload = [
'email' => $event->getEmail(),
'contact' => $event->getLead(),
'contentHash' => $event->getContentHash(),
'idHash' => $event->getIdHash(),
'subject' => $event->getSubject(),
'source' => $event->getSource(),
'headers' => $event->getTextHeaders(),
];
if ($this->includeDetails) {
$payload['content'] = $event->getContent();
$payload['tokens'] = $event->getTokens();
}
$this->webhookModel->queueWebhooksByType(EmailEvents::EMAIL_ON_SEND, $payload);
}
public function onEmailOpen(EmailOpenEvent $event): void
{
$this->webhookModel->queueWebhooksByType(
EmailEvents::EMAIL_ON_OPEN,
[
'stat' => $event->getStat(),
],
[
'statDetails',
'leadList',
'emailDetails',
]
);
}
public function onWebhookQueueOnAdd(WebhookQueueEvent $event): void
{
if ($this->includeDetails) {
return;
}
$webhookQueue = $event->getWebhookQueue();
$eventType = $webhookQueue->getEvent()->getEventType();
if (!in_array($eventType, [EmailEvents::EMAIL_ON_SEND, EmailEvents::EMAIL_ON_OPEN])) {
return;
}
$payload = json_decode($webhookQueue->getPayload(), true);
if (!is_array($payload)) {
return;
}
if (EmailEvents::EMAIL_ON_SEND === $eventType) {
unset($payload['email']['customHtml']);
unset($payload['email']['plainText']);
} elseif (EmailEvents::EMAIL_ON_OPEN === $eventType) {
unset($payload['stat']['email']['customHtml']);
unset($payload['stat']['email']['plainText']);
}
$webhookQueue->setPayload(json_encode($payload));
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\EmailBundle\Exception;
class EmailCouldNotBeSentException extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\EmailBundle\Exception;
class FailedToSendToContactException extends \Exception
{
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Mautic\EmailBundle\Exception;
use Mautic\CoreBundle\Exception\InvalidValueException;
class InvalidEmailException extends InvalidValueException
{
public function __construct(
protected string $emailAddress,
string $message = '',
int $code = 0,
?\Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
public function getEmailAddress(): string
{
return $this->emailAddress;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Mautic\EmailBundle\Exception;
class MailboxException extends \Exception
{
public function __construct($message = null, $code = 0, ?\Exception $previous = null)
{
if (null === $message) {
$message = 'Error communicating with the IMAP server';
if (function_exists('imap_last_error')) {
$message .= ': '.imap_last_error();
}
}
parent::__construct($message, $code, $previous);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Form\Type;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\ORM\QueryBuilder;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CategoryBundle\Entity\CategoryRepository;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class BatchCategoryType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'newCategory',
EntityType::class,
[
'class' => Category::class,
'choice_label' => 'title',
'required' => true,
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'query_builder' => function (CategoryRepository $cr): QueryBuilder {
$qb =$cr->createQueryBuilder('c');
return $qb->orderBy('c.title', 'ASC')
->where($qb->expr()->in('c.bundle', ':bundles'))
->setParameter('bundles', ['email', 'global'], ArrayParameterType::INTEGER);
},
]
);
$builder->add('ids', HiddenType::class);
$builder->add(
'buttons',
FormButtonsType::class,
[
'apply_text' => false,
'save_text' => 'mautic.core.form.save',
'cancel_onclick' => 'javascript:void(0);',
'cancel_attr' => [
'data-dismiss' => 'modal',
],
]
);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function getBlockPrefix(): string
{
return 'email_batch';
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class BatchSendType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event\MonitoredEmailEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class ConfigMonitoredEmailType extends AbstractType
{
public function __construct(
private EventDispatcherInterface $dispatcher,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
if (function_exists('imap_open')) {
$data = $options['data'];
$event = new MonitoredEmailEvent($builder, $data);
// Default email bundles
$event->addFolder('general', '', 'mautic.email.config.monitored_email.general');
$this->dispatcher->dispatch($event, EmailEvents::MONITORED_EMAIL_CONFIG);
$folderSettings = $event->getFolders();
foreach ($folderSettings as $key => $settings) {
$folderData = (array_key_exists($key, $data)) ? $data[$key] : [];
$builder->add(
$key,
ConfigMonitoredMailboxesType::class,
[
'label' => $settings['label'],
'mailbox' => $key,
'default_folder' => $settings['default'],
'data' => $folderData,
'required' => false,
'general_settings' => (array_key_exists('general', $data)) ? $data['general'] : [],
]
);
}
}
}
public function getBlockPrefix(): string
{
return 'monitored_email';
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\StandAloneButtonType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\EmailBundle\MonitoredEmail\Mailbox;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
/**
* @extends AbstractType<mixed>
*/
class ConfigMonitoredMailboxesType extends AbstractType
{
public function __construct(
private Mailbox $imapHelper,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$monitoredShowOn = ('general' == $options['mailbox']) ? '{}'
: '{"config_emailconfig_monitored_email_'.$options['mailbox'].'_override_settings_1": "checked"}';
$builder->add(
'address',
TextType::class,
[
'label' => 'mautic.email.config.monitored_email_address',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.monitored_email_address.tooltip',
'data-show-on' => $monitoredShowOn,
],
'constraints' => [
new Email(
[
'message' => 'mautic.core.email.required',
]
),
],
'required' => false,
]
);
$builder->add(
'host',
TextType::class,
[
'label' => 'mautic.email.config.monitored_email_host',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.monitored_email_host.tooltip',
'data-show-on' => $monitoredShowOn,
],
'required' => false,
]
);
$builder->add(
'port',
TextType::class,
[
'label' => 'mautic.email.config.monitored_email_port',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.monitored_email_port.tooltip',
'data-show-on' => $monitoredShowOn,
],
'required' => false,
'data' => (array_key_exists('port', $options['data']))
? $options['data']['port'] : 993,
]
);
if (extension_loaded('openssl')) {
$builder->add(
'encryption',
ChoiceType::class,
[
'choices' => [
'mautic.email.config.mailer_encryption.ssl' => '/ssl',
'mautic.email.config.monitored_email_encryption.ssl_novalidate' => '/ssl/novalidate-cert',
'mautic.email.config.mailer_encryption.tls' => '/tls',
'mautic.email.config.monitored_email_encryption.tls_novalidate' => '/tls/novalidate-cert',
],
'label' => 'mautic.email.config.monitored_email_encryption',
'required' => false,
'attr' => [
'class' => 'form-control',
'data-show-on' => $monitoredShowOn,
'tooltip' => 'mautic.email.config.monitored_email_encryption.tooltip',
],
'placeholder' => 'mautic.email.config.mailer_encryption.none',
'data' => $options['data']['encryption'] ?? '/ssl',
]
);
}
$builder->add(
'user',
TextType::class,
[
'label' => 'mautic.email.config.monitored_email_user',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.monitored_email_user.tooltip',
'autocomplete' => 'off',
'data-show-on' => $monitoredShowOn,
],
'required' => false,
]
);
$builder->add(
'password',
PasswordType::class,
[
'label' => 'mautic.email.config.monitored_email_password',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'placeholder' => 'mautic.user.user.form.passwordplaceholder',
'preaddon' => 'ri-lock-fill',
'tooltip' => 'mautic.email.config.monitored_email_password.tooltip',
'autocomplete' => 'off',
'data-show-on' => $monitoredShowOn,
],
'required' => false,
]
);
if ('general' != $options['mailbox']) {
$builder->add(
'override_settings',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.monitored_email_override_settings',
'label_attr' => ['class' => 'control-label'],
'data' => (array_key_exists('override_settings', $options['data']) && !empty($options['data']['override_settings'])) ? true : false,
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.monitored_email_override_settings.tooltip',
],
'required' => false,
]
);
$settings = (empty($options['data']['override_settings'])) ? $options['general_settings'] : $options['data'];
$this->imapHelper->setMailboxSettings($settings);
// Check for IMAP connection and get a folder list
$choices = [
'INBOX' => 'INBOX',
'Trash' => 'Trash',
];
if ($this->imapHelper->isConfigured()) {
try {
$folders = $this->imapHelper->getListingFolders();
$choices = array_combine($folders, $folders);
} catch (\Exception) {
// If the connection failed - add back the selected folder just in case it's a temporary connection issue
if (!empty($options['data']['folder'])) {
$choices[$options['data']['folder']] = $options['data']['folder'];
}
}
}
$builder->add(
'folder',
ChoiceType::class,
[
'choices' => $choices,
'label' => 'mautic.email.config.monitored_email_folder',
'label_attr' => ['class' => 'control-label'],
'attr' => array_merge(
[
'class' => 'form-control',
'tooltip' => 'mautic.email.config.monitored_email_folder.tooltip',
'data-imap-folders' => $options['mailbox'],
]
),
'data' => (array_key_exists('folder', $options['data']))
? $options['data']['folder'] : $options['default_folder'],
'required' => false,
]
);
}
$builder->add(
'test_connection_button',
StandAloneButtonType::class,
[
'label' => 'mautic.email.config.monitored_email.test_connection',
'required' => false,
'attr' => [
'class' => 'btn btn-tertiary btn-sm',
'onclick' => 'Mautic.testMonitoredEmailServerConnection(\''.$options['mailbox'].'\')',
],
]
);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired(['mailbox', 'default_folder', 'general_settings']);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['mailbox'] = $options['mailbox'];
}
public function getBlockPrefix(): string
{
return 'monitored_mailboxes';
}
}

View File

@@ -0,0 +1,531 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Form\Type;
use Mautic\ConfigBundle\Form\Type\DsnType;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\Type\SortableListType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\EmailBundle\Validator\Dsn;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @extends AbstractType<mixed>
*/
class ConfigType extends AbstractType
{
public const MINIFY_EMAIL_HTML = 'minify_email_html';
public function __construct(
private TranslatorInterface $translator,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(
new CleanFormSubscriber(
[
'mailer_from_email' => 'email',
'mailer_return_path' => 'email',
'default_signature_text' => 'html',
'unsubscribe_text' => 'html',
'unsubscribe_message' => 'html',
'resubscribe_message' => 'html',
'webview_text' => 'html',
// Encode special chars to keep congruent with Email entity custom headers
'mailer_custom_headers' => 'clean',
]
)
);
$builder->add(
'unsubscribe_text',
TextareaType::class,
[
'label' => 'mautic.email.config.unsubscribe_text',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.unsubscribe_text.tooltip',
],
'required' => false,
'data' => (array_key_exists('unsubscribe_text', $options['data']) && !empty($options['data']['unsubscribe_text']))
? $options['data']['unsubscribe_text']
: $this->translator->trans(
'mautic.email.unsubscribe.text',
['%link%' => '|URL|']
),
]
);
$builder->add(
'webview_text',
TextareaType::class,
[
'label' => 'mautic.email.config.webview_text',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.webview_text.tooltip',
],
'required' => false,
'data' => (array_key_exists('webview_text', $options['data']) && !empty($options['data']['webview_text']))
? $options['data']['webview_text']
: $this->translator->trans(
'mautic.email.webview.text',
['%link%' => '|URL|']
),
]
);
$builder->add(
'unsubscribe_message',
TextareaType::class,
[
'label' => 'mautic.email.config.unsubscribe_message',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.unsubscribe_message.tooltip',
],
'required' => false,
'data' => (array_key_exists('unsubscribe_message', $options['data']) && !empty($options['data']['unsubscribe_message']))
? $options['data']['unsubscribe_message']
: $this->translator->trans(
'mautic.email.unsubscribed.success',
[
'%resubscribeUrl%' => '|URL|',
'%email%' => '|EMAIL|',
]
),
]
);
$builder->add(
'resubscribe_message',
TextareaType::class,
[
'label' => 'mautic.email.config.resubscribe_message',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.resubscribe_message.tooltip',
],
'required' => false,
'data' => (array_key_exists('resubscribe_message', $options['data']) && !empty($options['data']['resubscribe_message']))
? $options['data']['resubscribe_message']
: $this->translator->trans(
'mautic.email.resubscribed.success',
[
'%unsubscribeUrl%' => '|URL|',
'%email%' => '|EMAIL|',
]
),
]
);
$builder->add(
'default_signature_text',
TextareaType::class,
[
'label' => 'mautic.email.config.default_signature_text',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.default_signature_text.tooltip',
],
'required' => false,
'data' => (!empty($options['data']['default_signature_text']))
? $options['data']['default_signature_text']
: $this->translator->trans(
'mautic.email.default.signature',
[
'%from_name%' => '|FROM_NAME|',
]
),
]
);
$builder->add(
'mailer_from_name',
TextType::class,
[
'label' => 'mautic.email.config.mailer.from.name',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.mailer.from.name.tooltip',
'onchange' => 'Mautic.disableSendTestEmailButton(this)',
],
'constraints' => [
new NotBlank(
[
'message' => 'mautic.core.value.required',
]
),
],
]
);
$builder->add(
'mailer_from_email',
TextType::class,
[
'label' => 'mautic.email.config.mailer.from.email',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.mailer.from.email.tooltip',
'onchange' => 'Mautic.disableSendTestEmailButton(this)',
],
'constraints' => [
new NotBlank(
[
'message' => 'mautic.core.email.required',
]
),
new Email(
[
'message' => 'mautic.core.email.required',
'mode' => Email::VALIDATION_MODE_HTML5,
]
),
],
]
);
$builder->add(
'mailer_reply_to_email',
TextType::class,
[
'label' => 'mautic.email.reply_to_email',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.reply_to_email.tooltip',
'onchange' => 'Mautic.disableSendTestEmailButton(this)',
],
'required' => false,
'constraints' => [
new Email(
[
'message' => 'mautic.core.email.required',
'mode' => Email::VALIDATION_MODE_HTML5,
]
),
],
]
);
$builder->add(
'mailer_reply_to_email',
TextType::class,
[
'label' => 'mautic.email.reply_to_email',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.reply_to_email.tooltip',
'onchange' => 'Mautic.disableSendTestEmailButton(this)',
],
'required' => false,
'constraints' => [
new Email(
[
'message' => 'mautic.core.email.required',
]
),
],
]
);
$builder->add(
'mailer_return_path',
TextType::class,
[
'label' => 'mautic.email.config.mailer.return.path',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.mailer.return.path.tooltip',
'onchange' => 'Mautic.disableSendTestEmailButton(this)',
],
'required' => false,
]
);
$builder->add(
'mailer_address_length_limit',
NumberType::class,
[
'scale' => 0,
'label' => 'mautic.email.config.mailer.address.length.limit',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.mailer.address.length.limit.tooltip',
],
'required' => true,
]
);
$builder->add(
'mailer_dsn',
DsnType::class,
[
'constraints' => [new Dsn()],
'test_button' => [
'action' => 'email:sendTestEmail',
'label' => $this->translator->trans('mautic.email.config.mailer.transport.test_send'),
],
]
);
$builder->add(
'mailer_convert_embed_images',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.mailer.convert.embed.images',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.mailer.convert.embed.images.tooltip',
],
'data' => empty($options['data']['mailer_convert_embed_images']) ? false : true,
'required' => false,
]
);
$builder->add(
'mailer_append_tracking_pixel',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.mailer.append.tracking.pixel',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.mailer.append.tracking.pixel.tooltip',
],
'data' => empty($options['data']['mailer_append_tracking_pixel']) ? false : true,
'required' => false,
]
);
$builder->add(
'disable_trackable_urls',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.mailer.disable.trackable.urls',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.mailer.disable.trackable.urls.tooltip',
],
'data' => empty($options['data']['disable_trackable_urls']) ? false : true,
'required' => false,
]
);
$builder->add(
self::MINIFY_EMAIL_HTML,
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.mailer.minify.html',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.mailer.minify.html.tooltip',
],
'data' => $options['data'][self::MINIFY_EMAIL_HTML] ?? false,
'required' => false,
]
);
$builder->add(
'mailer_custom_headers',
SortableListType::class,
[
'required' => false,
'label' => 'mautic.email.custom_headers',
'attr' => [
'tooltip' => 'mautic.email.custom_headers.config.tooltip',
'onchange' => 'Mautic.disableSendTestEmailButton(this)',
],
'option_required' => false,
'with_labels' => true,
'key_value_pairs' => true, // do not store under a `list` key and use label as the key
]
);
$builder->add(
'monitored_email',
ConfigMonitoredEmailType::class,
[
'label' => false,
'data' => (array_key_exists('monitored_email', $options['data'])) ? $options['data']['monitored_email'] : [],
'required' => false,
]
);
$builder->add(
'mailer_is_owner',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.mailer.is.owner',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.mailer.is.owner.tooltip',
],
'data' => empty($options['data']['mailer_is_owner']) ? false : true,
'required' => false,
]
);
$builder->add(
'email_frequency_number',
NumberType::class,
[
'scale' => 0,
'label' => 'mautic.lead.list.frequency.number',
'label_attr' => ['class' => 'control-label'],
'required' => false,
'attr' => [
'class' => 'form-control frequency',
],
]
);
$builder->add(
'email_frequency_time',
ChoiceType::class,
[
'choices' => [
'day' => 'DAY',
'week' => 'WEEK',
'month' => 'MONTH',
],
'label' => 'mautic.lead.list.frequency.times',
'label_attr' => ['class' => 'control-label'],
'required' => false,
'multiple' => false,
'attr' => [
'class' => 'form-control frequency',
],
]
);
$builder->add(
'show_contact_segments',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.show.contact.segments',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.show.contact.segments.tooltip',
],
'data' => empty($options['data']['show_contact_segments']) ? false : true,
'required' => false,
]
);
$builder->add(
'show_contact_preferences',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.show.preference.options',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.show.preference.options.tooltip',
],
'data' => empty($options['data']['show_contact_preferences']) ? false : true,
'required' => false,
]
);
$builder->add(
'show_contact_frequency',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.show.contact.frequency',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.show.contact.frequency.tooltip',
],
'data' => empty($options['data']['show_contact_frequency']) ? false : true,
'required' => false,
]
);
$builder->add(
'show_contact_pause_dates',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.show.contact.pause.dates',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.show.contact.pause.dates.tooltip',
],
'data' => empty($options['data']['show_contact_pause_dates']) ? false : true,
'required' => false,
]
);
$builder->add(
'show_contact_categories',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.show.contact.categories',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.show.contact.categories.tooltip',
],
'data' => empty($options['data']['show_contact_categories']) ? false : true,
'required' => false,
]
);
$builder->add(
'show_contact_preferred_channels',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.show.contact.preferred.channels',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.show.contact.preferred.channels',
],
'data' => empty($options['data']['show_contact_preferred_channels']) ? false : true,
'required' => false,
]
);
$builder->add(
'email_draft_enabled',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.config.enable.draft',
'label_attr' => ['class' => 'control-label'],
'data' => $options['data']['email_draft_enabled'] ?? false,
'required' => false,
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.config.enable.draft.tooltip',
],
]
);
}
public function getBlockPrefix(): string
{
return 'emailconfig';
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Mautic\CampaignBundle\Form\Type\CampaignListType;
use Mautic\CoreBundle\Form\Type\TimeFormatType;
use Mautic\LeadBundle\Form\Type\CompanyListType;
use Mautic\LeadBundle\Form\Type\LeadListType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class DashboardBestHoursWidgetType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'timeFormat',
TimeFormatType::class
);
$builder->add(
'companyId',
CompanyListType::class,
[
'label' => 'mautic.email.companyId.filter',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'empty_data' => '',
'required' => false,
'multiple' => false,
'modal_route' => null, // disable "Add new" option in ajax lookup
]
);
$builder->add(
'campaignId',
CampaignListType::class,
[
'label' => 'mautic.email.campaignId.filter',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'empty_data' => '',
'placeholder' => '',
'required' => false,
'multiple' => false,
]
);
$builder->add(
'segmentId',
LeadListType::class,
[
'label' => 'mautic.email.segmentId.filter',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'empty_data' => '',
'required' => false,
]
);
}
public function getBlockPrefix(): string
{
return 'email_dashboard_best_hours_widget';
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Mautic\CampaignBundle\Form\Type\CampaignListType;
use Mautic\LeadBundle\Form\Type\CompanyListType;
use Mautic\LeadBundle\Form\Type\LeadListType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class DashboardEmailsInTimeWidgetType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'flag',
ChoiceType::class,
[
'label' => 'mautic.email.flag.filter',
'choices' => [
'mautic.email.flag.sent' => '',
'mautic.email.flag.opened' => 'opened',
'mautic.email.flag.failed' => 'failed',
'mautic.email.flag.sent.and.opened' => 'sent_and_opened',
'mautic.email.flag.sent.and.opened.and.failed' => 'sent_and_opened_and_failed',
],
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'empty_data' => '',
'required' => false,
]
);
$builder->add(
'companyId',
CompanyListType::class,
[
'label' => 'mautic.email.companyId.filter',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'placeholder' => '',
'required' => false,
'multiple' => false,
'modal_route' => null,
]
);
$builder->add(
'campaignId',
CampaignListType::class,
[
'label' => 'mautic.email.campaignId.filter',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'empty_data' => '',
'placeholder' => '',
'required' => false,
'multiple' => false,
]
);
$builder->add(
'segmentId',
LeadListType::class,
[
'label' => 'mautic.email.segmentId.filter',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'placeholder' => '',
'required' => false,
]
);
}
public function getBlockPrefix(): string
{
return 'email_dashboard_emails_in_time_widget';
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Mautic\CampaignBundle\Form\Type\CampaignListType;
use Mautic\LeadBundle\Form\Type\CompanyListType;
use Mautic\LeadBundle\Form\Type\LeadListType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class DashboardMostHitEmailRedirectsWidgetType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'companyId',
CompanyListType::class,
[
'label' => 'mautic.email.companyId.filter',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'empty_data' => '',
'required' => false,
'multiple' => false,
'modal_route' => null, // disable "Add new" option in ajax lookup
]
);
$builder->add(
'campaignId',
CampaignListType::class,
[
'label' => 'mautic.email.campaignId.filter',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'empty_data' => '',
'placeholder' => '',
'required' => false,
'multiple' => false,
]
);
$builder->add(
'segmentId',
LeadListType::class,
[
'label' => 'mautic.email.segmentId.filter',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'empty_data' => '',
'required' => false,
]
);
}
public function getBlockPrefix(): string
{
return 'email_dashboard_most_hit_email_redirects_widget';
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Mautic\CampaignBundle\Form\Type\CampaignListType;
use Mautic\LeadBundle\Form\Type\CompanyListType;
use Mautic\LeadBundle\Form\Type\LeadListType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class DashboardSentEmailToContactsWidgetType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'companyId',
CompanyListType::class,
[
'label' => 'mautic.email.companyId.filter',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'empty_data' => '',
'required' => false,
'multiple' => false,
'modal_route' => null, // disable "Add new" option in ajax lookup
]
);
$builder->add(
'campaignId',
CampaignListType::class,
[
'label' => 'mautic.email.campaignId.filter',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'empty_data' => '',
'placeholder' => '',
'required' => false,
'multiple' => false,
]
);
$builder->add(
'segmentId',
LeadListType::class,
[
'label' => 'mautic.email.segmentId.filter',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'empty_data' => '',
'required' => false,
]
);
}
public function getBlockPrefix(): string
{
return 'email_dashboard_sent_email_to_contacts_widget';
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\SortableListType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class EmailClickDecisionType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'urls',
SortableListType::class,
[
'label' => 'mautic.email.click.urls.contains',
'option_required' => false,
'with_labels' => false,
'required' => false,
]
);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\EntityLookupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<mixed>
*/
class EmailListType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'required' => false,
'modal_route' => 'mautic_email_action',
// Email form UI too complicated for a modal so force a popup
'force_popup' => true,
'model' => 'email',
'multiple' => true,
'ajax_lookup_action' => function (Options $options): string {
$query = [
'email_type' => $options['email_type'],
'top_level' => $options['top_level'],
'variant_parent' => $options['variant_parent'],
'ignore_ids' => $options['ignore_ids'],
];
return 'email:getLookupChoiceList&'.http_build_query($query);
},
'model_lookup_method' => 'getLookupResultsWithIdName',
'lookup_arguments' => fn (Options $options): array => [
'type' => 'email',
'filter' => '$data',
'limit' => 0,
'start' => 0,
'options' => [
'email_type' => $options['email_type'],
'top_level' => $options['top_level'],
'variant_parent' => $options['variant_parent'],
'ignore_ids' => $options['ignore_ids'],
],
],
// 'modal_route_parameters' => 'template'
'email_type' => 'template',
'top_level' => 'variant',
'variant_parent' => null,
'ignore_ids' => [],
'email' => null,
]
);
}
public function getParent(): ?string
{
return EntityLookupType::class;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<mixed>
*/
class EmailOpenType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$defaultOptions = [
'label' => 'mautic.email.open.limittoemails',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.open.limittoemails_descr',
],
'required' => false,
'email_type' => null,
];
if (isset($options['list_options'])) {
if (isset($options['list_options']['attr'])) {
$defaultOptions['attr'] = array_merge($defaultOptions['attr'], $options['list_options']['attr']);
unset($options['list_options']['attr']);
}
$defaultOptions = array_merge($defaultOptions, $options['list_options']);
}
$builder->add('emails', EmailListType::class, $defaultOptions);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefined(['list_options']);
}
public function getBlockPrefix(): string
{
return 'emailopen_list';
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Mautic\ChannelBundle\Entity\MessageQueue;
use Mautic\CoreBundle\Form\Type\ButtonGroupType;
use Mautic\EmailBundle\Helper\MailHelper;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ButtonType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<mixed>
*/
class EmailSendType extends AbstractType
{
public function __construct(
private RouterInterface $router,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'email',
EmailListType::class,
[
'label' => 'mautic.email.send.selectemails',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.choose.emails_descr',
'onchange' => 'Mautic.disabledEmailAction(window, this)',
],
'multiple' => false,
'required' => true,
'constraints' => [
new NotBlank(
['message' => 'mautic.email.chooseemail.notblank']
),
],
]
);
if (!empty($options['with_email_types'])) {
$builder->add(
'email_type',
ButtonGroupType::class,
[
'choices' => [
'mautic.email.send.emailtype.transactional' => MailHelper::EMAIL_TYPE_TRANSACTIONAL,
'mautic.email.send.emailtype.marketing' => MailHelper::EMAIL_TYPE_MARKETING,
],
'label' => 'mautic.email.send.emailtype',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control email-type',
'tooltip' => 'mautic.email.send.emailtype.tooltip',
],
'data' => (!isset($options['data']['email_type'])) ? MailHelper::EMAIL_TYPE_MARKETING : $options['data']['email_type'],
]
);
}
if (!empty($options['update_select'])) {
$windowUrl = $this->router->generate(
'mautic_email_action',
[
'objectAction' => 'new',
'contentOnly' => 1,
'updateSelect' => $options['update_select'],
]
);
$builder->add(
'newEmailButton',
ButtonType::class,
[
'attr' => [
'class' => 'btn btn-tertiary btn-sm btn-nospin mr-xs',
'onclick' => 'Mautic.loadNewWindow({
"windowUrl": "'.$windowUrl.'"
})',
'icon' => 'ri-add-line',
],
'label' => 'mautic.email.send.new.email',
]
);
// create button edit email
$windowUrlEdit = $this->router->generate(
'mautic_email_action',
[
'objectAction' => 'edit',
'objectId' => 'emailId',
'contentOnly' => 1,
'updateSelect' => $options['update_select'],
]
);
$builder->add(
'editEmailButton',
ButtonType::class,
[
'attr' => [
'class' => 'btn btn-tertiary btn-sm btn-nospin mr-xs',
'onclick' => 'Mautic.loadNewWindow(Mautic.standardEmailUrl({"windowUrl": "'.$windowUrlEdit.'","origin":"#'.$options['update_select'].'"}))',
'disabled' => !isset($options['data']['email']) && !isset($options['attr']['email']),
'icon' => 'ri-edit-line',
],
'label' => 'mautic.email.send.edit.email',
]
);
// create button preview email
$windowUrlPreview = $this->router->generate('mautic_email_preview', ['objectId' => 'emailId']);
$builder->add(
'previewEmailButton',
ButtonType::class,
[
'attr' => [
'class' => 'btn btn-tertiary btn-sm btn-nospin mr-xs',
'onclick' => 'Mautic.loadNewWindow(Mautic.standardEmailUrl({"windowUrl": "'.$windowUrlPreview.'","origin":"#'.$options['update_select'].'"}))',
'disabled' => !isset($options['data']['email']) && !isset($options['attr']['email']),
'icon' => 'ri-external-link-line',
],
'label' => 'mautic.email.send.preview.email',
]
);
if (!empty($options['with_email_types'])) {
$data = (!isset($options['data']['priority'])) ? 2 : (int) $options['data']['priority'];
$builder->add(
'priority',
ChoiceType::class,
[
'choices' => [
'mautic.channel.message.send.priority.normal' => MessageQueue::PRIORITY_NORMAL,
'mautic.channel.message.send.priority.high' => MessageQueue::PRIORITY_HIGH,
],
'label' => 'mautic.channel.message.send.priority',
'required' => false,
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.channel.message.send.priority.tooltip',
'data-show-on' => '{"campaignevent_properties_email_type_1":"checked"}',
],
'data' => $data,
'placeholder' => false,
]
);
$data = (!isset($options['data']['attempts'])) ? 3 : (int) $options['data']['attempts'];
$builder->add(
'attempts',
NumberType::class,
[
'label' => 'mautic.channel.message.send.attempts',
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.channel.message.send.attempts.tooltip',
'data-show-on' => '{"campaignevent_properties_email_type_1":"checked"}',
],
'data' => $data,
'empty_data' => 0,
'required' => false,
]
);
}
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'with_email_types' => false,
]
);
$resolver->setDefined(['update_select', 'with_email_types']);
}
public function getBlockPrefix(): string
{
return 'emailsend_list';
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\EmailBundle\Validator\EmailOrEmailTokenList;
use Mautic\UserBundle\Form\Type\UserListType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<mixed>
*/
class EmailToUserType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('useremail',
EmailSendType::class, [
'label' => 'mautic.email.emails',
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.choose.emails_descr',
'email' => isset($options['data']) && isset($options['data']['useremail']) && isset($options['data']['useremail']['email']) ? $options['data']['useremail']['email'] : null,
],
'update_select' => empty($options['update_select']) ? 'formaction_properties_useremail_email' : $options['update_select'],
]
);
$builder->add(
'user_id',
UserListType::class,
[
'label' => 'mautic.email.form.users',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.core.help.autocomplete',
],
'required' => false,
]
);
$builder->add(
'to_owner',
YesNoButtonGroupType::class,
[
'label' => 'mautic.form.action.send.email.to.owner',
'data' => $options['data']['to_owner'] ?? false,
]
);
$builder->add(
'to',
TextType::class,
[
'label' => 'mautic.core.send.email.to',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'placeholder' => 'mautic.core.optional',
'tooltip' => 'mautic.core.send.email.to.multiple.addresses',
],
'required' => false,
'constraints' => new EmailOrEmailTokenList(),
]
);
$builder->add(
'cc',
TextType::class,
[
'label' => 'mautic.core.send.email.cc',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'placeholder' => 'mautic.core.optional',
'tooltip' => 'mautic.core.send.email.to.multiple.addresses',
],
'required' => false,
'constraints' => new EmailOrEmailTokenList(),
]
);
$builder->add(
'bcc',
TextType::class,
[
'label' => 'mautic.core.send.email.bcc',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'placeholder' => 'mautic.core.optional',
'tooltip' => 'mautic.core.send.email.to.multiple.addresses',
],
'required' => false,
'constraints' => new EmailOrEmailTokenList(),
]
);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'label' => false,
]);
$resolver->setDefined(['update_select']);
}
}

View File

@@ -0,0 +1,715 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\AssetBundle\Form\Type\AssetListType;
use Mautic\CategoryBundle\Form\Type\CategoryListType;
use Mautic\CoreBundle\Form\DataTransformer\IdToEntityModelTransformer;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\EventListener\FormExitSubscriber;
use Mautic\CoreBundle\Form\Type\DynamicContentFilterType;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\PublishDownDateType;
use Mautic\CoreBundle\Form\Type\PublishUpDateType;
use Mautic\CoreBundle\Form\Type\SortableListType;
use Mautic\CoreBundle\Form\Type\ThemeListType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\ThemeHelperInterface;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Helper\EmailConfigInterface;
use Mautic\FormBundle\Form\Type\FormListType;
use Mautic\LeadBundle\Form\Type\LeadListType;
use Mautic\LeadBundle\Helper\FormFieldHelper;
use Mautic\PageBundle\Form\Type\PreferenceCenterListType;
use Mautic\ProjectBundle\Form\Type\ProjectType;
use Mautic\StageBundle\Model\StageModel;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @extends AbstractType<Email>
*/
class EmailType extends AbstractType
{
private bool $isDraftEnabled;
public function __construct(
private TranslatorInterface $translator,
private EntityManagerInterface $em,
private StageModel $stageModel,
private CoreParametersHelper $coreParametersHelper,
private ThemeHelperInterface $themeHelper,
private CorePermissions $corePermissions,
EmailConfigInterface $emailConfig,
) {
$this->isDraftEnabled = $emailConfig->isDraftEnabled();
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(new CleanFormSubscriber(['content' => 'html', 'customHtml' => 'html', 'headers' => 'clean']));
$builder->addEventSubscriber(new FormExitSubscriber('email.email', $options));
$emailEntity = $options['data'];
\assert($emailEntity instanceof Email);
$builder->add(
'name',
TextType::class,
[
'label' => 'mautic.email.form.internal.name',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]
);
$builder->add(
'subject',
TextType::class,
[
'label' => 'mautic.email.subject',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'onBlur' => 'Mautic.copySubjectToName(mQuery(this))',
],
]
);
$builder->add(
'fromName',
TextType::class,
[
'label' => 'mautic.email.from_name',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'preaddon' => 'ri-user-6-fill',
'tooltip' => 'mautic.email.from_name.tooltip',
],
'required' => false,
]
);
$builder->add(
'fromAddress',
TextType::class,
[
'label' => 'mautic.email.from_email',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'preaddon' => 'ri-mail-line',
'tooltip' => 'mautic.email.from_email.tooltip',
],
'required' => false,
]
);
$builder->add(
'replyToAddress',
TextType::class,
[
'label' => 'mautic.email.reply_to_email',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'preaddon' => 'ri-mail-line',
'tooltip' => 'mautic.email.reply_to_email.tooltip',
],
'required' => false,
]
);
$builder->add(
'bccAddress',
TextType::class,
[
'label' => 'mautic.email.bcc',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'preaddon' => 'ri-mail-line',
'tooltip' => 'mautic.email.bcc.tooltip',
],
'required' => false,
]
);
$builder->add(
'useOwnerAsMailer',
YesNoButtonGroupType::class,
[
'label' => 'mautic.email.use.owner.as.mailer',
'label_attr' => ['class' => 'control-label'],
'data' => $this->getUseOwnerAsMailerOrDefaultValue($emailEntity),
'required' => false,
'attr' => [
'data-global-mailer-is-onwer' => (string) $this->getGlobalMailerIsOwner(),
'class' => 'form-control mailer-is-owner-local',
'tooltip' => 'mautic.email.use.owner.as.mailer.tooltip',
'data-warning' => $this->translator->trans(
'mautic.email.config.mailer.is.owner.local.warning',
['%value%' => $this->translator->trans($this->getGlobalMailerIsOwner() ? 'mautic.core.yes' : 'mautic.core.no')]
),
],
]
);
$builder->add(
'utmTags',
EmailUtmTagsType::class,
[
'label' => 'mautic.email.utm_tags',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.utm_tags.tooltip',
],
'required' => false,
]
);
$builder->add(
'headers',
SortableListType::class,
[
'required' => false,
'label' => 'mautic.email.custom_headers',
'attr' => [
'tooltip' => 'mautic.email.custom_headers.tooltip',
],
'option_required' => false,
'with_labels' => true,
'key_value_pairs' => true, // do not store under a `list` key and use label as the key
]
);
$template = $emailEntity->getTemplate() ?? 'blank';
if (true === $this->isDraftEnabled && $emailEntity->hasDraft() && !empty($emailEntity->getDraft()->getTemplate())) {
$template = $emailEntity->getDraft()->getTemplate();
}
// If theme does not exist, set empty
$template = $this->themeHelper->getCurrentTheme($template, 'email');
$builder->add(
'template',
ThemeListType::class,
[
'feature' => 'email',
'attr' => [
'class' => 'form-control not-chosen hidden',
'tooltip' => 'mautic.email.form.template.help',
],
'data' => $template,
]
);
$canPublish = $this->corePermissions->hasPublishAccessForEntity($emailEntity, 'email:emails:publishown', 'email:emails:publishother');
$isPublishOptions = [
'data' => $emailEntity->isNew() ? $canPublish : $emailEntity->getIsPublished(),
];
if (!$canPublish) {
$isPublishOptions['disabled'] = true; // Duplicated here for Symfony validations
$isPublishOptions['attr'] = ['disabled' => true]; // Duplicated here for the JS switch library
}
$builder->add('isPublished', YesNoButtonGroupType::class, $isPublishOptions);
$builder->add('publishUp', PublishUpDateType::class, ['disabled' => !$canPublish]);
$builder->add('publishDown', PublishDownDateType::class, ['disabled' => !$canPublish]);
$builder->add(
'plainText',
TextareaType::class,
[
'label' => 'mautic.email.form.plaintext',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'tooltip' => 'mautic.email.form.plaintext.help',
'class' => 'form-control',
'rows' => '15',
'data-token-callback' => 'email:getBuilderTokens',
'data-token-activator' => '{',
'data-token-visual' => 'false',
],
'required' => false,
]
);
$html = $emailEntity->getCustomHtml();
if (true === $this->isDraftEnabled && $emailEntity->hasDraft() && !empty($emailEntity->getDraft()->getHtml())) {
$html = $emailEntity->getDraft()->getHtml();
}
$builder->add(
'customHtml',
TextareaType::class,
[
'label' => 'mautic.email.form.body',
'label_attr' => ['class' => 'control-label'],
'required' => false,
'attr' => [
'tooltip' => 'mautic.email.form.body.help',
'class' => 'form-control editor-builder-tokens builder-html editor-email',
'data-token-callback' => 'email:getBuilderTokens',
'data-token-activator' => '{',
'rows' => '15',
],
'data' => $html,
]
);
$transformer = new IdToEntityModelTransformer($this->em, \Mautic\FormBundle\Entity\Form::class, 'id');
$builder->add(
$builder->create(
'unsubscribeForm',
FormListType::class,
[
'label' => 'mautic.email.form.unsubscribeform',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.form.unsubscribeform.tooltip',
'data-placeholder' => $this->translator->trans('mautic.core.form.chooseone'),
],
'required' => false,
'multiple' => false,
'placeholder' => '',
]
)
->addModelTransformer($transformer)
);
$transformer = new IdToEntityModelTransformer($this->em, \Mautic\PageBundle\Entity\Page::class, 'id');
$builder->add(
$builder->create(
'preferenceCenter',
PreferenceCenterListType::class,
[
'label' => 'mautic.email.form.preference_center',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.form.preference_center.tooltip',
'data-placeholder' => $this->translator->trans('mautic.core.form.chooseone'),
],
'required' => false,
'multiple' => false,
'placeholder' => '',
]
)
->addModelTransformer($transformer)
);
$transformer = new IdToEntityModelTransformer($this->em, Email::class);
$builder->add(
$builder->create(
'variantParent',
HiddenType::class
)->addModelTransformer($transformer)
);
$builder->add(
$builder->create(
'translationParent',
HiddenType::class
)->addModelTransformer($transformer)
);
$variantParent = $emailEntity->getVariantParent();
$translationParent = $emailEntity->getTranslationParent();
$builder->add(
'segmentTranslationParent',
EmailListType::class,
[
'label' => 'mautic.core.form.translation_parent',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.core.form.translation_parent.help',
],
'required' => false,
'multiple' => false,
'email_type' => 'list',
'placeholder' => 'mautic.core.form.translation_parent.empty',
'top_level' => 'translation',
'variant_parent' => $variantParent ? $variantParent->getId() : null,
'ignore_ids' => [(int) $emailEntity->getId()],
'mapped' => false,
'data' => $translationParent ? $translationParent->getId() : null,
]
);
$builder->add(
'templateTranslationParent',
EmailListType::class,
[
'label' => 'mautic.core.form.translation_parent',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.core.form.translation_parent.help',
],
'required' => false,
'multiple' => false,
'placeholder' => 'mautic.core.form.translation_parent.empty',
'top_level' => 'translation',
'variant_parent' => $variantParent ? $variantParent->getId() : null,
'email_type' => 'template',
'ignore_ids' => [(int) $emailEntity->getId()],
'mapped' => false,
'data' => $translationParent ? $translationParent->getId() : null,
]
);
$variantSettingsModifier = function (FormEvent $event, $isVariant): void {
if ($isVariant) {
$event->getForm()->add(
'variantSettings',
VariantType::class,
[
'label' => false,
]
);
}
};
// Building the form
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($variantSettingsModifier): void {
$variantSettingsModifier(
$event,
$event->getData()->getVariantParent()
);
}
);
// After submit
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($variantSettingsModifier): void {
$data = $event->getData();
$variantSettingsModifier(
$event,
!empty($data['variantParent'])
);
$emailType = $data['emailType'] ?? null;
if ('list' === $emailType && isset($data['segmentTranslationParent'])) {
$data['translationParent'] = $data['segmentTranslationParent'];
} elseif (isset($data['templateTranslationParent'])) {
$data['translationParent'] = $data['templateTranslationParent'];
}
$event->setData($data);
}
);
$builder->add(
'category',
CategoryListType::class,
[
'bundle' => 'email',
]
);
$transformer = new IdToEntityModelTransformer($this->em, \Mautic\LeadBundle\Entity\LeadList::class, 'id', true);
$builder->add(
$builder->create(
'lists',
LeadListType::class,
[
'label' => 'mautic.email.form.list',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'data-show-on' => '{"emailform_segmentTranslationParent":[""]}',
],
'multiple' => true,
'expanded' => false,
'required' => true,
]
)
->addModelTransformer($transformer)
);
$builder->add(
$builder->create(
'excludedLists',
LeadListType::class,
[
'label' => 'mautic.email.form.excluded_list',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'multiple' => true,
'expanded' => false,
]
)
->addModelTransformer($transformer)
);
$builder->add(
'language',
LocaleType::class,
[
'label' => 'mautic.core.language',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'required' => true,
]
);
$builder->add('projects', ProjectType::class);
$transformer = new IdToEntityModelTransformer(
$this->em,
\Mautic\AssetBundle\Entity\Asset::class,
'id',
true
);
$builder->add(
$builder->create(
'assetAttachments',
AssetListType::class,
[
'label' => 'mautic.email.attachments',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'onchange' => 'Mautic.getTotalAttachmentSize();',
'tooltip' => 'mautic.email.attachments.help',
],
'multiple' => true,
'expanded' => false,
]
)
->addModelTransformer($transformer)
);
$builder->add('sessionId', HiddenType::class);
$builder->add('emailType', HiddenType::class);
$extraButtons = [];
$extraButtons['pre_extra_buttons'] = [
[
'name' => 'builder',
'label' => 'mautic.core.builder',
'attr' => [
'class' => 'btn btn-tertiary btn-dnd btn-nospin text-interactive btn-builder',
'icon' => 'ri-layout-line',
'onclick' => "Mautic.launchBuilder('{$this->getBlockPrefix()}', 'email');",
],
],
];
$draftActionButtons = $this->getDraftActionButtons($emailEntity);
if (!empty($draftActionButtons)) {
$extraButtons['post_extra_buttons'] = $draftActionButtons;
}
$builder->add(
'buttons',
FormButtonsType::class,
$extraButtons
);
$builder->add(
$builder->create(
'preheaderText',
TextType::class,
[
'label' => 'mautic.email.preheader_text',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.preheader_text.tooltip',
],
'required' => false,
]
)
);
$builder->add(
$builder->create(
'preheaderText',
TextType::class,
[
'label' => 'mautic.email.preheader_text',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.preheader_text.tooltip',
],
'required' => false,
]
)
);
if (!empty($options['update_select'])) {
$builder->add(
'updateSelect',
HiddenType::class,
[
'data' => $options['update_select'],
'mapped' => false,
]
);
}
$this->addDynamicContentField($builder);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
/**
* @return mixed[]
*/
private function getDraftActionButtons(Email $email): array
{
$draftActionButtons = [];
if (false === $this->isDraftEnabled || empty($email->getId())) {
return $draftActionButtons;
}
if ($email->hasDraft()) {
$draftActionButtons[] = [
'name' => 'apply_draft',
'label' => 'mautic.core.applydraft',
'type' => SubmitType::class,
'attr' => [
'class' => 'btn btn-primary btn-apply-draft',
'icon' => 'fa fa-files-o text-success',
],
];
$draftActionButtons[] = [
'name' => 'discard_draft',
'label' => 'mautic.core.discarddraft',
'type' => SubmitType::class,
'attr' => [
'class' => 'btn btn-primary btn-discard-draft',
'icon' => 'fa fa-trash text-danger',
],
];
} else {
$draftActionButtons[] = [
'name' => 'save_draft',
'label' => 'mautic.core.saveasdraft',
'type' => SubmitType::class,
'attr' => [
'class' => 'btn btn-primary btn-save-draft',
'icon' => 'fa fa-file text-success',
],
];
}
return $draftActionButtons;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'data_class' => Email::class,
]
);
$resolver->setDefined(['update_select']);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$stages = $this->stageModel->getRepository()->getSimpleList();
$stageChoices = [];
foreach ($stages as $stage) {
$stageChoices[$stage['value']] = $stage['label'];
}
$view->vars['countries'] = FormFieldHelper::getCountryChoices();
$view->vars['regions'] = FormFieldHelper::getRegionChoices();
$view->vars['timezones'] = FormFieldHelper::getTimezonesChoices();
$view->vars['locales'] = FormFieldHelper::getLocaleChoices();
$view->vars['stages'] = $stageChoices;
}
public function getBlockPrefix(): string
{
return 'emailform';
}
/**
* The owner as mailer value will be taken from the email entity unless the email is new.
* If so, it will choose the value from the global configuration as default.
*/
private function getUseOwnerAsMailerOrDefaultValue(Email $email): bool
{
return $email->getId() ? ((bool) $email->getUseOwnerAsMailer()) : $this->getGlobalMailerIsOwner();
}
private function getGlobalMailerIsOwner(): bool
{
return (bool) $this->coreParametersHelper->get('mailer_is_owner');
}
private function addDynamicContentField(FormBuilderInterface $builder): void
{
$builder->add(
'dynamicContent',
CollectionType::class,
[
'entry_type' => DynamicContentFilterType::class,
'allow_add' => true,
'allow_delete' => true,
'label' => false,
'entry_options' => [
'label' => false,
],
]
);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event): void {
$data = $event->getData();
/** @var Email $entity */
$entity = $event->getForm()->getData();
if (empty($data['dynamicContent'])) {
$data['dynamicContent'] = $entity->getDefaultDynamicContent();
unset($data['dynamicContent'][0]['filters']['filter']);
}
foreach ($data['dynamicContent'] as $key => $dc) {
if (empty($dc['filters'])) {
$data['dynamicContent'][$key]['filters'] = $entity->getDefaultDynamicContent()[0]['filters'];
}
}
$event->setData($data);
}
);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Mautic\EmailBundle\Form\Type;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\LeadBundle\Validator\Constraints\Length;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class EmailUtmTagsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'utmSource',
TextType::class,
[
'label' => 'mautic.email.campaign_source',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'required' => false,
]
);
$builder->add(
'utmMedium',
TextType::class,
[
'label' => 'mautic.email.campaign_medium',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'required' => false,
]
);
$builder->add(
'utmCampaign',
TextType::class,
[
'label' => 'mautic.email.campaign_name',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'required' => false,
]
);
$builder->add(
'utmContent',
TextType::class,
[
'label' => 'mautic.email.campaign_content',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'required' => false,
'constraints' => new Length(['max' => ClassMetadataBuilder::MAX_VARCHAR_INDEXED_LENGTH]),
]
);
}
public function getBlockPrefix(): string
{
return 'utm_tags';
}
}

Some files were not shown because too many files have changed in this diff Show More