Dynamically load the content of Message templates in CiviMail when selected rather...
[civicrm-core.git] / ang / crmMailing / services.js
1 (function (angular, $, _) {
2
3 // The representation of from/reply-to addresses is inconsistent in the mailing data-model,
4 // so the UI must do some adaptation. The crmFromAddresses provides a richer way to slice/dice
5 // the available "From:" addrs. Records are like the underlying OptionValues -- but add "email"
6 // and "author".
7 angular.module('crmMailing').factory('crmFromAddresses', function ($q, crmApi) {
8 var emailRegex = /^"(.*)" <([^@>]*@[^@>]*)>$/;
9 var addrs = _.map(CRM.crmMailing.fromAddress, function (addr) {
10 var match = emailRegex.exec(addr.label);
11 return angular.extend({}, addr, {
12 email: match ? match[2] : '(INVALID)',
13 author: match ? match[1] : '(INVALID)'
14 });
15 });
16
17 function first(array) {
18 return (array.length === 0) ? null : array[0];
19 }
20
21 return {
22 getAll: function getAll() {
23 return addrs;
24 },
25 getByAuthorEmail: function getByAuthorEmail(author, email, autocreate) {
26 var result = null;
27 _.each(addrs, function (addr) {
28 if (addr.author == author && addr.email == email) {
29 result = addr;
30 }
31 });
32 if (!result && autocreate) {
33 result = {
34 label: '(INVALID) "' + author + '" <' + email + '>',
35 author: author,
36 email: email
37 };
38 addrs.push(result);
39 }
40 return result;
41 },
42 getByEmail: function getByEmail(email) {
43 return first(_.where(addrs, {email: email}));
44 },
45 getByLabel: function (label) {
46 return first(_.where(addrs, {label: label}));
47 },
48 getDefault: function getDefault() {
49 return first(_.where(addrs, {is_default: "1"}));
50 }
51 };
52 });
53
54 angular.module('crmMailing').factory('crmMsgTemplates', function ($q, crmApi) {
55 var tpls = _.map(CRM.crmMailing.mesTemplate, function (tpl) {
56 return angular.extend({}, tpl, {
57 //id: tpl parseInt(tpl.id)
58 });
59 });
60 window.tpls = tpls;
61 var lastModifiedTpl = null;
62 return {
63 // Get a template
64 // @param id MessageTemplate id (per APIv3)
65 // @return Promise MessageTemplate (per APIv3)
66 get: function get(id) {
67 var dfr = $q.defer();
68 var tpl = CRM.api3('MessageTemplate', 'get', {
69 "sequential": 1,
70 "return": "id,msg_subject,msg_html,msg_title,msg_text",
71 "id": id
72 }).done(function(result) {
73 if (result.is_error) {
74 dfr.reject(id);
75 }
76 else {
77 dfr.resolve(result['values'][0]);
78 }
79 });
80 return dfr.promise;
81 },
82 // Save a template
83 // @param tpl MessageTemplate (per APIv3) For new templates, omit "id"
84 // @return Promise MessageTemplate (per APIv3)
85 save: function (tpl) {
86 return crmApi('MessageTemplate', 'create', tpl).then(function (response) {
87 if (!tpl.id) {
88 tpl.id = '' + response.id; //parseInt(response.id);
89 tpls.push(tpl);
90 }
91 lastModifiedTpl = tpl;
92 return tpl;
93 });
94 },
95 // @return Object MessageTemplate (per APIv3)
96 getLastModifiedTpl: function () {
97 return lastModifiedTpl;
98 },
99 getAll: function getAll() {
100 return tpls;
101 }
102 };
103 });
104
105 // The crmMailingMgr service provides business logic for loading, saving, previewing, etc
106 angular.module('crmMailing').factory('crmMailingMgr', function ($q, crmApi, crmFromAddresses, crmQueue) {
107 var qApi = crmQueue(crmApi);
108 var pickDefaultMailComponent = function pickDefaultMailComponent(type) {
109 var mcs = _.where(CRM.crmMailing.headerfooterList, {
110 component_type: type,
111 is_default: "1"
112 });
113 return (mcs.length >= 1) ? mcs[0].id : null;
114 };
115
116 return {
117 // @param scalar idExpr a number or the literal string 'new'
118 // @return Promise|Object Mailing (per APIv3)
119 getOrCreate: function getOrCreate(idExpr) {
120 return (idExpr == 'new') ? this.create() : this.get(idExpr);
121 },
122 // @return Promise Mailing (per APIv3)
123 get: function get(id) {
124 var crmMailingMgr = this;
125 var mailing;
126 return qApi('Mailing', 'getsingle', {id: id})
127 .then(function (getResult) {
128 mailing = getResult;
129 return $q.all([
130 crmMailingMgr._loadGroups(mailing),
131 crmMailingMgr._loadJobs(mailing)
132 ]);
133 })
134 .then(function () {
135 return mailing;
136 });
137 },
138 // Call MailingGroup.get and merge results into "mailing"
139 _loadGroups: function (mailing) {
140 return crmApi('MailingGroup', 'get', {mailing_id: mailing.id})
141 .then(function (groupResult) {
142 mailing.recipients = {};
143 mailing.recipients.groups = {include: [], exclude: [], base: []};
144 mailing.recipients.mailings = {include: [], exclude: []};
145 _.each(groupResult.values, function (mailingGroup) {
146 var bucket = (/^civicrm_group/.test(mailingGroup.entity_table)) ? 'groups' : 'mailings';
147 var entityId = parseInt(mailingGroup.entity_id);
148 mailing.recipients[bucket][mailingGroup.group_type.toLowerCase()].push(entityId);
149 });
150 });
151 },
152 // Call MailingJob.get and merge results into "mailing"
153 _loadJobs: function (mailing) {
154 return crmApi('MailingJob', 'get', {mailing_id: mailing.id, is_test: 0})
155 .then(function (jobResult) {
156 mailing.jobs = mailing.jobs || {};
157 angular.extend(mailing.jobs, jobResult.values);
158 });
159 },
160 // @return Object Mailing (per APIv3)
161 create: function create(params) {
162 var defaults = {
163 jobs: {}, // {jobId: JobRecord}
164 recipients: {
165 groups: {include: [], exclude: [], base: []},
166 mailings: {include: [], exclude: []}
167 },
168 name: "",
169 campaign_id: null,
170 replyto_email: "",
171 subject: "",
172 body_html: "",
173 body_text: ""
174 };
175 return angular.extend({}, defaults, params);
176 },
177
178 // @param mailing Object (per APIv3)
179 // @return Promise
180 'delete': function (mailing) {
181 if (mailing.id) {
182 return qApi('Mailing', 'delete', {id: mailing.id});
183 }
184 else {
185 var d = $q.defer();
186 d.resolve();
187 return d.promise;
188 }
189 },
190
191 // Search the body, header, and footer for required tokens.
192 // ex: var msgs = findMissingTokens(mailing, 'body_html');
193 findMissingTokens: function(mailing, field) {
194 var missing = {};
195 if (!_.isEmpty(mailing[field]) && !CRM.crmMailing.disableMandatoryTokensCheck) {
196 var body = '';
197 if (mailing.footer_id) {
198 var footer = _.where(CRM.crmMailing.headerfooterList, {id: mailing.footer_id});
199 body = body + footer[0][field];
200
201 }
202 body = body + mailing[field];
203 if (mailing.header_id) {
204 var header = _.where(CRM.crmMailing.headerfooterList, {id: mailing.header_id});
205 body = body + header[0][field];
206 }
207
208 angular.forEach(CRM.crmMailing.requiredTokens, function(value, token) {
209 if (!_.isObject(value)) {
210 if (body.indexOf('{' + token + '}') < 0) {
211 missing[token] = value;
212 }
213 }
214 else {
215 var count = 0;
216 angular.forEach(value, function(nestedValue, nestedToken) {
217 if (body.indexOf('{' + nestedToken + '}') >= 0) {
218 count++;
219 }
220 });
221 if (count === 0) {
222 angular.extend(missing, value);
223 }
224 }
225 });
226 }
227 return missing;
228 },
229
230 // Copy all data fields in (mailingFrom) to (mailingTgt) -- except for (excludes)
231 // ex: crmMailingMgr.mergeInto(newMailing, mailingTemplate, ['subject']);
232 mergeInto: function mergeInto(mailingTgt, mailingFrom, excludes) {
233 var MAILING_FIELDS = [
234 // always exclude: 'id'
235 'name',
236 'campaign_id',
237 'from_name',
238 'from_email',
239 'replyto_email',
240 'subject',
241 'dedupe_email',
242 'recipients',
243 'body_html',
244 'body_text',
245 'footer_id',
246 'header_id',
247 'visibility',
248 'url_tracking',
249 'dedupe_email',
250 'forward_replies',
251 'auto_responder',
252 'open_tracking',
253 'override_verp',
254 'optout_id',
255 'reply_id',
256 'resubscribe_id',
257 'unsubscribe_id'
258 ];
259 if (!excludes) {
260 excludes = [];
261 }
262 _.each(MAILING_FIELDS, function (field) {
263 if (!_.contains(excludes, field)) {
264 mailingTgt[field] = mailingFrom[field];
265 }
266 });
267 },
268
269 // @param mailing Object (per APIv3)
270 // @return Promise an object with "subject", "body_text", "body_html"
271 preview: function preview(mailing) {
272 if (CRM.crmMailing.workflowEnabled && !CRM.checkPerm('create mailings') && !CRM.checkPerm('access CiviMail')) {
273 return qApi('Mailing', 'preview', {id: mailing.id}).then(function(result) {
274 return result.values;
275 });
276 }
277 else {
278 // Protect against races in saving and previewing by chaining create+preview.
279 var params = angular.extend({}, mailing, mailing.recipients, {
280 options: {force_rollback: 1},
281 'api.Mailing.preview': {
282 id: '$value.id'
283 }
284 });
285 delete params.recipients; // the content was merged in
286 return qApi('Mailing', 'create', params).then(function(result) {
287 // changes rolled back, so we don't care about updating mailing
288 return result.values[result.id]['api.Mailing.preview'].values;
289 });
290 }
291 },
292
293 // @param mailing Object (per APIv3)
294 // @param int previewLimit
295 // @return Promise for a list of recipients (mailing_id, contact_id, api.contact.getvalue, api.email.getvalue)
296 previewRecipients: function previewRecipients(mailing, previewLimit) {
297 // To get list of recipients, we tentatively save the mailing and
298 // get the resulting recipients -- then rollback any changes.
299 var params = angular.extend({}, mailing, mailing.recipients, {
300 name: 'placeholder', // for previewing recipients on new, incomplete mailing
301 subject: 'placeholder', // for previewing recipients on new, incomplete mailing
302 options: {force_rollback: 1},
303 'api.mailing_job.create': 1, // note: exact match to API default
304 'api.MailingRecipients.get': {
305 mailing_id: '$value.id',
306 options: {limit: previewLimit},
307 'api.contact.getvalue': {'return': 'display_name'},
308 'api.email.getvalue': {'return': 'email'}
309 }
310 });
311 delete params.recipients; // the content was merged in
312 return qApi('Mailing', 'create', params).then(function (recipResult) {
313 // changes rolled back, so we don't care about updating mailing
314 return recipResult.values[recipResult.id]['api.MailingRecipients.get'].values;
315 });
316 },
317
318 previewRecipientCount: function previewRecipientCount(mailing) {
319 // To get list of recipients, we tentatively save the mailing and
320 // get the resulting recipients -- then rollback any changes.
321 var params = angular.extend({}, mailing, mailing.recipients, {
322 name: 'placeholder', // for previewing recipients on new, incomplete mailing
323 subject: 'placeholder', // for previewing recipients on new, incomplete mailing
324 options: {force_rollback: 1},
325 'api.mailing_job.create': 1, // note: exact match to API default
326 'api.MailingRecipients.getcount': {
327 mailing_id: '$value.id'
328 }
329 });
330 delete params.recipients; // the content was merged in
331 return qApi('Mailing', 'create', params).then(function (recipResult) {
332 // changes rolled back, so we don't care about updating mailing
333 return recipResult.values[recipResult.id]['api.MailingRecipients.getcount'];
334 });
335 },
336
337 // Save a (draft) mailing
338 // @param mailing Object (per APIv3)
339 // @return Promise
340 save: function(mailing) {
341 var params = angular.extend({}, mailing, mailing.recipients);
342
343 // Angular ngModel sometimes treats blank fields as undefined.
344 angular.forEach(mailing, function(value, key) {
345 if (value === undefined || value === null) {
346 mailing[key] = '';
347 }
348 });
349
350 // WORKAROUND: Mailing.create (aka CRM_Mailing_BAO_Mailing::create()) interprets scheduled_date
351 // as an *intent* to schedule and creates tertiary records. Saving a draft with a scheduled_date
352 // is therefore not allowed. Remove this after fixing Mailing.create's contract.
353 delete params.scheduled_date;
354
355 delete params.jobs;
356
357 delete params.recipients; // the content was merged in
358
359 return qApi('Mailing', 'create', params).then(function(result) {
360 if (result.id && !mailing.id) {
361 mailing.id = result.id;
362 } // no rollback, so update mailing.id
363 // Perhaps we should reload mailing based on result?
364 return mailing;
365 });
366 },
367
368 // Schedule/send the mailing
369 // @param mailing Object (per APIv3)
370 // @return Promise
371 submit: function (mailing) {
372 var crmMailingMgr = this;
373 var params = {
374 id: mailing.id,
375 approval_date: 'now',
376 scheduled_date: mailing.scheduled_date ? mailing.scheduled_date : 'now'
377 };
378 return qApi('Mailing', 'submit', params)
379 .then(function (result) {
380 angular.extend(mailing, result.values[result.id]); // Perhaps we should reload mailing based on result?
381 return crmMailingMgr._loadJobs(mailing);
382 })
383 .then(function () {
384 return mailing;
385 });
386 },
387
388 // Immediately send a test message
389 // @param mailing Object (per APIv3)
390 // @param to Object with either key "email" (string) or "gid" (int)
391 // @return Promise for a list of delivery reports
392 sendTest: function (mailing, recipient) {
393 var params = angular.extend({}, mailing, mailing.recipients, {
394 // options: {force_rollback: 1}, // Test mailings include tracking features, so the mailing must be persistent
395 'api.Mailing.send_test': {
396 mailing_id: '$value.id',
397 test_email: recipient.email,
398 test_group: recipient.gid
399 }
400 });
401
402 // WORKAROUND: Mailing.create (aka CRM_Mailing_BAO_Mailing::create()) interprets scheduled_date
403 // as an *intent* to schedule and creates tertiary records. Saving a draft with a scheduled_date
404 // is therefore not allowed. Remove this after fixing Mailing.create's contract.
405 delete params.scheduled_date;
406
407 delete params.jobs;
408
409 delete params.recipients; // the content was merged in
410
411 return qApi('Mailing', 'create', params).then(function (result) {
412 if (result.id && !mailing.id) {
413 mailing.id = result.id;
414 } // no rollback, so update mailing.id
415 return result.values[result.id]['api.Mailing.send_test'].values;
416 });
417 }
418 };
419 });
420
421 // The preview manager performs preview actions while putting up a visible UI (e.g. dialogs & status alerts)
422 angular.module('crmMailing').factory('crmMailingPreviewMgr', function (dialogService, crmMailingMgr, crmStatus) {
423 return {
424 // @param mode string one of 'html', 'text', or 'full'
425 // @return Promise
426 preview: function preview(mailing, mode) {
427 var templates = {
428 html: '~/crmMailing/PreviewMgr/html.html',
429 text: '~/crmMailing/PreviewMgr/text.html',
430 full: '~/crmMailing/PreviewMgr/full.html'
431 };
432 var result = null;
433 var p = crmMailingMgr
434 .preview(mailing)
435 .then(function (content) {
436 var options = CRM.utils.adjustDialogDefaults({
437 autoOpen: false,
438 title: ts('Subject: %1', {
439 1: content.subject
440 })
441 });
442 result = dialogService.open('previewDialog', templates[mode], content, options);
443 });
444 crmStatus({start: ts('Previewing...'), success: ''}, p);
445 return result;
446 },
447
448 // @param to Object with either key "email" (string) or "gid" (int)
449 // @return Promise
450 sendTest: function sendTest(mailing, recipient) {
451 var promise = crmMailingMgr.sendTest(mailing, recipient)
452 .then(function (deliveryInfos) {
453 var count = Object.keys(deliveryInfos).length;
454 if (count === 0) {
455 CRM.alert(ts('Could not identify any recipients. Perhaps the group is empty?'));
456 }
457 })
458 ;
459 return crmStatus({start: ts('Sending...'), success: ts('Sent')}, promise);
460 }
461 };
462 });
463
464 angular.module('crmMailing').factory('crmMailingStats', function (crmApi, crmLegacy) {
465 var statTypes = [
466 // {name: 'Recipients', title: ts('Intended Recipients'), searchFilter: '', eventsFilter: '&event=queue'},
467 {name: 'Delivered', title: ts('Successful Deliveries'), searchFilter: '&mailing_delivery_status=Y', eventsFilter: '&event=delivered'},
468 {name: 'Opened', title: ts('Tracked Opens'), searchFilter: '&mailing_open_status=Y', eventsFilter: '&event=opened'},
469 {name: 'Unique Clicks', title: ts('Click-throughs'), searchFilter: '&mailing_click_status=Y', eventsFilter: '&event=click&distinct=1'},
470 // {name: 'Forward', title: ts('Forwards'), searchFilter: '&mailing_forward=1', eventsFilter: '&event=forward'},
471 // {name: 'Replies', title: ts('Replies'), searchFilter: '&mailing_reply_status=Y', eventsFilter: '&event=reply'},
472 {name: 'Bounces', title: ts('Bounces'), searchFilter: '&mailing_delivery_status=N', eventsFilter: '&event=bounce'},
473 {name: 'Unsubscribers', title: ts('Unsubscribes'), searchFilter: '&mailing_unsubscribe=1', eventsFilter: '&event=unsubscribe'}
474 // {name: 'OptOuts', title: ts('Opt-Outs'), searchFilter: '&mailing_optout=1', eventsFilter: '&event=optout'}
475 ];
476
477 return {
478 getStatTypes: function() {
479 return statTypes;
480 },
481
482 /**
483 * @param mailingIds object
484 * List of mailing IDs ({a: 123, b: 456})
485 * @return Promise
486 * List of stats for each mailing
487 * ({a: ...object..., b: ...object...})
488 */
489 getStats: function(mailingIds) {
490 var params = {};
491 angular.forEach(mailingIds, function(mailingId, name) {
492 params[name] = ['Mailing', 'stats', {mailing_id: mailingId}];
493 });
494 return crmApi(params).then(function(result) {
495 var stats = {};
496 angular.forEach(mailingIds, function(mailingId, name) {
497 stats[name] = result[name].values[mailingId];
498 });
499 return stats;
500 });
501 },
502
503 /**
504 * Determine the legacy URL for a report about a given mailing and stat.
505 *
506 * @param mailing object
507 * @param statType object (see statTypes above)
508 * @param view string ('search', 'event', 'report')
509 * @param returnPath string|null Return path (relative to Angular base)
510 * @return string|null
511 */
512 getUrl: function getUrl(mailing, statType, view, returnPath) {
513 switch (view) {
514 case 'events':
515 var retParams = returnPath ? '&context=angPage&angPage=' + returnPath : '';
516 return crmLegacy.url('civicrm/mailing/report/event',
517 'reset=1&mid=' + mailing.id + statType.eventsFilter + retParams);
518
519 case 'search':
520 return crmLegacy.url('civicrm/contact/search/advanced',
521 'force=1&mailing_id=' + mailing.id + statType.searchFilter);
522
523 // TODO: case 'report':
524 default:
525 return null;
526 }
527 }
528 };
529 });
530
531 // crmMailingSimpleDirective is a template/factory-function for constructing very basic
532 // directives that accept a "mailing" argument. Please don't overload it. If one continues building
533 // this, it risks becoming a second system that violates Angular architecture (and prevents one
534 // from using standard Angular docs+plugins). So this really shouldn't do much -- it is really
535 // only for simple directives. For something complex, suck it up and write 10 lines of boilerplate.
536 angular.module('crmMailing').factory('crmMailingSimpleDirective', function ($q, crmMetadata, crmUiHelp) {
537 return function crmMailingSimpleDirective(directiveName, templateUrl) {
538 return {
539 scope: {
540 crmMailing: '@'
541 },
542 templateUrl: templateUrl,
543 link: function (scope, elm, attr) {
544 scope.$parent.$watch(attr.crmMailing, function(newValue){
545 scope.mailing = newValue;
546 });
547 scope.crmMailingConst = CRM.crmMailing;
548 scope.ts = CRM.ts(null);
549 scope.hs = crmUiHelp({file: 'CRM/Mailing/MailingUI'});
550 scope[directiveName] = attr[directiveName] ? scope.$parent.$eval(attr[directiveName]) : {};
551 $q.when(crmMetadata.getFields('Mailing'), function(fields) {
552 scope.mailingFields = fields;
553 });
554 }
555 };
556 };
557 });
558
559 })(angular, CRM.$, CRM._);