ActionSchedule - Set locale during mail hooks / delivery / etc
[civicrm-core.git] / CRM / Contact / Form / Task / EmailTrait.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 *
14 * @package CRM
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
16 */
17
18 use Civi\Api4\Email;
19
20 /**
21 * This class provides the common functionality for tasks that send emails.
22 */
23 trait CRM_Contact_Form_Task_EmailTrait {
24
25 /**
26 * Are we operating in "single mode", i.e. sending email to one
27 * specific contact?
28 *
29 * @var bool
30 */
31 public $_single = FALSE;
32
33 /**
34 * All the existing templates in the system.
35 *
36 * @var array
37 */
38 public $_templates;
39
40 /**
41 * Store "to" contact details.
42 * @var array
43 */
44 public $_toContactDetails = [];
45
46 /**
47 * Store all selected contact id's, that includes to, cc and bcc contacts
48 * @var array
49 */
50 public $_allContactIds = [];
51
52 /**
53 * Store only "to" contact ids.
54 * @var array
55 */
56 public $_toContactIds = [];
57
58 /**
59 * Store only "cc" contact ids.
60 * @var array
61 */
62 public $_ccContactIds = [];
63
64 /**
65 * Store only "bcc" contact ids.
66 *
67 * @var array
68 */
69 public $_bccContactIds = [];
70
71 /**
72 * Is the form being loaded from a search action.
73 *
74 * @var bool
75 */
76 public $isSearchContext = TRUE;
77
78 public $contactEmails = [];
79
80 /**
81 * Contacts form whom emails could not be sent.
82 *
83 * An array of contact ids and the relevant message details.
84 *
85 * @var array
86 */
87 protected $suppressedEmails = [];
88
89 /**
90 * Getter for isSearchContext.
91 *
92 * @return bool
93 */
94 public function isSearchContext(): bool {
95 return $this->isSearchContext;
96 }
97
98 /**
99 * Setter for isSearchContext.
100 *
101 * @param bool $isSearchContext
102 */
103 public function setIsSearchContext(bool $isSearchContext) {
104 $this->isSearchContext = $isSearchContext;
105 }
106
107 /**
108 * Build all the data structures needed to build the form.
109 *
110 * @throws \CiviCRM_API3_Exception
111 * @throws \CRM_Core_Exception
112 */
113 public function preProcess() {
114 $this->traitPreProcess();
115 }
116
117 /**
118 * Call trait preProcess function.
119 *
120 * This function exists as a transitional arrangement so classes overriding
121 * preProcess can still call it. Ideally it will be melded into preProcess later.
122 *
123 * @throws \CiviCRM_API3_Exception
124 * @throws \CRM_Core_Exception
125 */
126 protected function traitPreProcess() {
127 CRM_Contact_Form_Task_EmailCommon::preProcessFromAddress($this);
128 if ($this->isSearchContext()) {
129 // Currently only the contact email form is callable outside search context.
130 parent::preProcess();
131 }
132 $this->setContactIDs();
133 $this->assign('single', $this->_single);
134 if (CRM_Core_Permission::check('administer CiviCRM')) {
135 $this->assign('isAdmin', 1);
136 }
137 }
138
139 /**
140 * Build the form object.
141 *
142 * @throws \CRM_Core_Exception
143 */
144 public function buildQuickForm() {
145 // Suppress form might not be required but perhaps there was a risk some other process had set it to TRUE.
146 $this->assign('suppressForm', FALSE);
147 $this->assign('emailTask', TRUE);
148
149 $toArray = [];
150 $suppressedEmails = 0;
151 //here we are getting logged in user id as array but we need target contact id. CRM-5988
152 $cid = $this->get('cid');
153 if ($cid) {
154 $this->_contactIds = explode(',', $cid);
155 }
156 if (count($this->_contactIds) > 1) {
157 $this->_single = FALSE;
158 }
159 $this->bounceIfSimpleMailLimitExceeded(count($this->_contactIds));
160
161 $emailAttributes = [
162 'class' => 'huge',
163 ];
164 $to = $this->add('text', 'to', ts('To'), $emailAttributes, TRUE);
165
166 $this->addEntityRef('cc_id', ts('CC'), [
167 'entity' => 'Email',
168 'multiple' => TRUE,
169 ]);
170
171 $this->addEntityRef('bcc_id', ts('BCC'), [
172 'entity' => 'Email',
173 'multiple' => TRUE,
174 ]);
175
176 if ($to->getValue()) {
177 $this->_toContactIds = $this->_contactIds = [];
178 }
179 $setDefaults = TRUE;
180 if (property_exists($this, '_context') && $this->_context === 'standalone') {
181 $setDefaults = FALSE;
182 }
183
184 $this->_allContactIds = $this->_toContactIds = $this->_contactIds;
185
186 if ($to->getValue()) {
187 foreach ($this->getEmails($to) as $value) {
188 $contactId = $value['contact_id'];
189 $email = $value['email'];
190 if ($contactId) {
191 $this->_contactIds[] = $this->_toContactIds[] = $contactId;
192 $this->_toContactEmails[] = $email;
193 $this->_allContactIds[] = $contactId;
194 }
195 }
196 $setDefaults = TRUE;
197 }
198
199 //get the group of contacts as per selected by user in case of Find Activities
200 if (!empty($this->_activityHolderIds)) {
201 $contact = $this->get('contacts');
202 $this->_allContactIds = $this->_contactIds = $contact;
203 }
204
205 // check if we need to setdefaults and check for valid contact emails / communication preferences
206 if (is_array($this->_allContactIds) && $setDefaults) {
207 // get the details for all selected contacts ( to, cc and bcc contacts )
208 $allContactDetails = civicrm_api3('Contact', 'get', [
209 'id' => ['IN' => $this->_allContactIds],
210 'return' => ['sort_name', 'email', 'do_not_email', 'is_deceased', 'on_hold', 'display_name', 'preferred_mail_format'],
211 'options' => ['limit' => 0],
212 ])['values'];
213
214 // The contact task supports passing in email_id in a url. It supports a single email
215 // and is marked as having been related to CiviHR.
216 // The array will look like $this->_toEmail = ['email' => 'x', 'contact_id' => 2])
217 // If it exists we want to use the specified email which might be different to the primary email
218 // that we have.
219 if (!empty($this->_toEmail['contact_id']) && !empty($allContactDetails[$this->_toEmail['contact_id']])) {
220 $allContactDetails[$this->_toEmail['contact_id']]['email'] = $this->_toEmail['email'];
221 }
222
223 // perform all validations on unique contact Ids
224 foreach ($allContactDetails as $contactId => $value) {
225 if ($value['do_not_email'] || empty($value['email']) || !empty($value['is_deceased']) || $value['on_hold']) {
226 $this->setSuppressedEmail($contactId, $value);
227 }
228 elseif (in_array($contactId, $this->_toContactIds)) {
229 $this->_toContactDetails[$contactId] = $this->_contactDetails[$contactId] = $value;
230 $toArray[] = [
231 'text' => '"' . $value['sort_name'] . '" <' . $value['email'] . '>',
232 'id' => "$contactId::{$value['email']}",
233 ];
234 }
235 }
236
237 if (empty($toArray)) {
238 CRM_Core_Error::statusBounce(ts('Selected contact(s) do not have a valid email address, or communication preferences specify DO NOT EMAIL, or they are deceased or Primary email address is On Hold.'));
239 }
240 }
241
242 $this->assign('toContact', json_encode($toArray));
243
244 $this->assign('suppressedEmails', count($this->suppressedEmails));
245
246 $this->assign('totalSelectedContacts', count($this->_contactIds));
247
248 $this->add('text', 'subject', ts('Subject'), ['size' => 50, 'maxlength' => 254], TRUE);
249
250 $this->add('select', 'from_email_address', ts('From'), $this->_fromEmails, TRUE);
251
252 CRM_Mailing_BAO_Mailing::commonCompose($this);
253
254 // add attachments
255 CRM_Core_BAO_File::buildAttachment($this, NULL);
256
257 if ($this->_single) {
258 // also fix the user context stack
259 if ($this->_caseId) {
260 $ccid = CRM_Core_DAO::getFieldValue('CRM_Case_DAO_CaseContact', $this->_caseId,
261 'contact_id', 'case_id'
262 );
263 $url = CRM_Utils_System::url('civicrm/contact/view/case',
264 "&reset=1&action=view&cid={$ccid}&id={$this->_caseId}"
265 );
266 }
267 elseif ($this->_context) {
268 $url = CRM_Utils_System::url('civicrm/dashboard', 'reset=1');
269 }
270 else {
271 $url = CRM_Utils_System::url('civicrm/contact/view',
272 "&show=1&action=browse&cid={$this->_contactIds[0]}&selectedChild=activity"
273 );
274 }
275
276 $session = CRM_Core_Session::singleton();
277 $session->replaceUserContext($url);
278 $this->addDefaultButtons(ts('Send Email'), 'upload', 'cancel');
279 }
280 else {
281 $this->addDefaultButtons(ts('Send Email'), 'upload');
282 }
283
284 $fields = [
285 'followup_assignee_contact_id' => [
286 'type' => 'entityRef',
287 'label' => ts('Assigned to'),
288 'attributes' => [
289 'multiple' => TRUE,
290 'create' => TRUE,
291 'api' => ['params' => ['is_deceased' => 0]],
292 ],
293 ],
294 'followup_activity_type_id' => [
295 'type' => 'select',
296 'label' => ts('Followup Activity'),
297 'attributes' => ['' => '- ' . ts('select activity') . ' -'] + CRM_Core_PseudoConstant::ActivityType(FALSE),
298 'extra' => ['class' => 'crm-select2'],
299 ],
300 'followup_activity_subject' => [
301 'type' => 'text',
302 'label' => ts('Subject'),
303 'attributes' => CRM_Core_DAO::getAttribute('CRM_Activity_DAO_Activity',
304 'subject'
305 ),
306 ],
307 ];
308
309 //add followup date
310 $this->add('datepicker', 'followup_date', ts('in'));
311
312 foreach ($fields as $field => $values) {
313 if (!empty($fields[$field])) {
314 $attribute = $values['attributes'] ?? NULL;
315 $required = !empty($values['required']);
316
317 if ($values['type'] === 'select' && empty($attribute)) {
318 $this->addSelect($field, ['entity' => 'activity'], $required);
319 }
320 elseif ($values['type'] === 'entityRef') {
321 $this->addEntityRef($field, $values['label'], $attribute, $required);
322 }
323 else {
324 $this->add($values['type'], $field, $values['label'], $attribute, $required, CRM_Utils_Array::value('extra', $values));
325 }
326 }
327 }
328
329 //Added for CRM-15984: Add campaign field
330 CRM_Campaign_BAO_Campaign::addCampaign($this);
331
332 $this->addFormRule([__CLASS__, 'saveTemplateFormRule'], $this);
333 CRM_Core_Resources::singleton()->addScriptFile('civicrm', 'templates/CRM/Contact/Form/Task/EmailCommon.js', 0, 'html-header');
334 }
335
336 /**
337 * Process the form after the input has been submitted and validated.
338 *
339 * @throws \CRM_Core_Exception
340 * @throws \CiviCRM_API3_Exception
341 * @throws \Civi\API\Exception\UnauthorizedException
342 * @throws \API_Exception
343 */
344 public function postProcess() {
345 $this->bounceIfSimpleMailLimitExceeded(count($this->_contactIds));
346
347 // check and ensure that
348 $formValues = $this->controller->exportValues($this->getName());
349 $this->submit($formValues);
350 }
351
352 /**
353 * Bounce if there are more emails than permitted.
354 *
355 * @param int $count
356 * The number of emails the user is attempting to send
357 */
358 protected function bounceIfSimpleMailLimitExceeded($count) {
359 $limit = Civi::settings()->get('simple_mail_limit');
360 if ($count > $limit) {
361 CRM_Core_Error::statusBounce(ts('Please do not use this task to send a lot of emails (greater than %1). Many countries have legal requirements when sending bulk emails and the CiviMail framework has opt out functionality and domain tokens to help meet these.',
362 [1 => $limit]
363 ));
364 }
365 }
366
367 /**
368 * Submit the form values.
369 *
370 * This is also accessible for testing.
371 *
372 * @param array $formValues
373 *
374 * @throws \CRM_Core_Exception
375 * @throws \CiviCRM_API3_Exception
376 * @throws \Civi\API\Exception\UnauthorizedException
377 * @throws \API_Exception
378 */
379 public function submit($formValues) {
380 $this->saveMessageTemplate($formValues);
381
382 $from = $formValues['from_email_address'] ?? NULL;
383 // dev/core#357 User Emails are keyed by their id so that the Signature is able to be added
384 // If we have had a contact email used here the value returned from the line above will be the
385 // numerical key where as $from for use in the sendEmail in Activity needs to be of format of "To Name" <toemailaddress>
386 $from = CRM_Utils_Mail::formatFromAddress($from);
387
388 $ccArray = $formValues['cc_id'] ? explode(',', $formValues['cc_id']) : [];
389 $cc = $this->getEmailString($ccArray);
390 $additionalDetails = empty($ccArray) ? '' : "\ncc : " . $this->getEmailUrlString($ccArray);
391
392 $bccArray = $formValues['bcc_id'] ? explode(',', $formValues['bcc_id']) : [];
393 $bcc = $this->getEmailString($bccArray);
394 $additionalDetails .= empty($bccArray) ? '' : "\nbcc : " . $this->getEmailUrlString($bccArray);
395
396 // format contact details array to handle multiple emails from same contact
397 $formattedContactDetails = [];
398 foreach ($this->_contactIds as $key => $contactId) {
399 // if we dont have details on this contactID, we should ignore
400 // potentially this is due to the contact not wanting to receive email
401 if (!isset($this->_contactDetails[$contactId])) {
402 continue;
403 }
404 $email = $this->_toContactEmails[$key];
405 // prevent duplicate emails if same email address is selected CRM-4067
406 // we should allow same emails for different contacts
407 $details = $this->_contactDetails[$contactId];
408 $details['email'] = $email;
409 unset($details['email_id']);
410 $formattedContactDetails["{$contactId}::{$email}"] = $details;
411 }
412
413 // send the mail
414 list($sent, $activityIds) = CRM_Activity_BAO_Activity::sendEmail(
415 $formattedContactDetails,
416 $this->getSubject($formValues['subject']),
417 $formValues['text_message'],
418 $formValues['html_message'],
419 NULL,
420 NULL,
421 $from,
422 $this->getAttachments($formValues),
423 $cc,
424 $bcc,
425 array_keys($this->_toContactDetails),
426 $additionalDetails,
427 $this->getVar('_contributionIds') ?? [],
428 CRM_Utils_Array::value('campaign_id', $formValues),
429 $this->getVar('_caseId')
430 );
431
432 if ($sent) {
433 // Only use the first activity id if there's multiple.
434 // If there's multiple recipients the idea behind multiple activities
435 // is to record the token value replacements separately, but that
436 // has no meaning for followup activities, and this doesn't prevent
437 // creating more manually if desired.
438 $followupStatus = $this->createFollowUpActivities($formValues, $activityIds[0]);
439 $count_success = count($this->_toContactDetails);
440 CRM_Core_Session::setStatus(ts('One message was sent successfully. ', [
441 'plural' => '%count messages were sent successfully. ',
442 'count' => $count_success,
443 ]) . $followupStatus, ts('Message Sent', ['plural' => 'Messages Sent', 'count' => $count_success]), 'success');
444 }
445
446 if (!empty($this->suppressedEmails)) {
447 $status = '(' . ts('because no email address on file or communication preferences specify DO NOT EMAIL or Contact is deceased or Primary email address is On Hold') . ')<ul><li>' . implode('</li><li>', $this->suppressedEmails) . '</li></ul>';
448 CRM_Core_Session::setStatus($status, ts('One Message Not Sent', [
449 'count' => count($this->suppressedEmails),
450 'plural' => '%count Messages Not Sent',
451 ]), 'info');
452 }
453 }
454
455 /**
456 * Save the template if update selected.
457 *
458 * @param array $formValues
459 *
460 * @throws \CiviCRM_API3_Exception
461 * @throws \Civi\API\Exception\UnauthorizedException
462 */
463 protected function saveMessageTemplate($formValues) {
464 if (!empty($formValues['saveTemplate']) || !empty($formValues['updateTemplate'])) {
465 $messageTemplate = [
466 'msg_text' => $formValues['text_message'],
467 'msg_html' => $formValues['html_message'],
468 'msg_subject' => $formValues['subject'],
469 'is_active' => TRUE,
470 ];
471
472 if (!empty($formValues['saveTemplate'])) {
473 $messageTemplate['msg_title'] = $formValues['saveTemplateName'];
474 CRM_Core_BAO_MessageTemplate::add($messageTemplate);
475 }
476
477 if (!empty($formValues['template']) && !empty($formValues['updateTemplate'])) {
478 $messageTemplate['id'] = $formValues['template'];
479 unset($messageTemplate['msg_title']);
480 CRM_Core_BAO_MessageTemplate::add($messageTemplate);
481 }
482 }
483 }
484
485 /**
486 * List available tokens for this form.
487 *
488 * @return array
489 */
490 public function listTokens() {
491 return CRM_Core_SelectValues::contactTokens();
492 }
493
494 /**
495 * Get the emails from the added element.
496 *
497 * @param HTML_QuickForm_Element $element
498 *
499 * @return array
500 */
501 protected function getEmails($element): array {
502 $allEmails = explode(',', $element->getValue());
503 $return = [];
504 foreach ($allEmails as $value) {
505 $values = explode('::', $value);
506 $return[] = ['contact_id' => $values[0], 'email' => $values[1]];
507 }
508 return $return;
509 }
510
511 /**
512 * Get the string for the email IDs.
513 *
514 * @param array $emailIDs
515 * Array of email IDs.
516 *
517 * @return string
518 * e.g. "Smith, Bob<bob.smith@example.com>".
519 *
520 * @throws \API_Exception
521 * @throws \Civi\API\Exception\UnauthorizedException
522 */
523 protected function getEmailString(array $emailIDs): string {
524 if (empty($emailIDs)) {
525 return '';
526 }
527 $emails = Email::get()
528 ->addWhere('id', 'IN', $emailIDs)
529 ->setCheckPermissions(FALSE)
530 ->setSelect(['contact_id', 'email', 'contact_id.sort_name', 'contact_id.display_name'])->execute();
531 $emailStrings = [];
532 foreach ($emails as $email) {
533 $this->contactEmails[$email['id']] = $email;
534 $emailStrings[] = '"' . $email['contact_id.sort_name'] . '" <' . $email['email'] . '>';
535 }
536 return implode(',', $emailStrings);
537 }
538
539 /**
540 * Get the url string.
541 *
542 * This is called after the contacts have been retrieved so we don't need to re-retrieve.
543 *
544 * @param array $emailIDs
545 *
546 * @return string
547 * e.g. <a href='{$contactURL}'>Bob Smith</a>'
548 */
549 protected function getEmailUrlString(array $emailIDs): string {
550 $urls = [];
551 foreach ($emailIDs as $email) {
552 $contactURL = CRM_Utils_System::url('civicrm/contact/view', ['reset' => 1, 'cid' => $this->contactEmails[$email]['contact_id']], TRUE);
553 $urls[] = "<a href='{$contactURL}'>" . $this->contactEmails[$email]['contact_id.display_name'] . '</a>';
554 }
555 return implode(', ', $urls);
556 }
557
558 /**
559 * Set the emails that are not to be sent out.
560 *
561 * @param int $contactID
562 * @param array $values
563 */
564 protected function setSuppressedEmail($contactID, $values) {
565 $contactViewUrl = CRM_Utils_System::url('civicrm/contact/view', 'reset=1&cid=' . $contactID);
566 $this->suppressedEmails[$contactID] = "<a href='$contactViewUrl' title='{$values['email']}'>{$values['display_name']}</a>" . ($values['on_hold'] ? '(' . ts('on hold') . ')' : '');
567 }
568
569 /**
570 * Get any attachments.
571 *
572 * @param array $formValues
573 *
574 * @return array
575 */
576 protected function getAttachments(array $formValues): array {
577 $attachments = [];
578 CRM_Core_BAO_File::formatAttachment($formValues,
579 $attachments,
580 NULL, NULL
581 );
582 return $attachments;
583 }
584
585 /**
586 * Get the subject for the message.
587 *
588 * The case handling should possibly be on the case form.....
589 *
590 * @param string $subject
591 *
592 * @return string
593 */
594 protected function getSubject(string $subject):string {
595 // CRM-5916: prepend case id hash to CiviCase-originating emails’ subjects
596 if (isset($this->_caseId) && is_numeric($this->_caseId)) {
597 $hash = substr(sha1(CIVICRM_SITE_KEY . $this->_caseId), 0, 7);
598 $subject = "[case #$hash] $subject";
599 }
600 return $subject;
601 }
602
603 /**
604 * Create any follow up activities.
605 *
606 * @param array $formValues
607 * @param int $activityId
608 *
609 * @return string
610 *
611 * @throws \CRM_Core_Exception
612 */
613 protected function createFollowUpActivities($formValues, $activityId): string {
614 $params = [];
615 $followupStatus = '';
616 $followupActivity = NULL;
617 if (!empty($formValues['followup_activity_type_id'])) {
618 $params['followup_activity_type_id'] = $formValues['followup_activity_type_id'];
619 $params['followup_activity_subject'] = $formValues['followup_activity_subject'];
620 $params['followup_date'] = $formValues['followup_date'];
621 $params['target_contact_id'] = $this->_contactIds;
622 $params['followup_assignee_contact_id'] = array_filter(explode(',', $formValues['followup_assignee_contact_id']));
623 $followupActivity = CRM_Activity_BAO_Activity::createFollowupActivity($activityId, $params);
624 $followupStatus = ts('A followup activity has been scheduled.');
625
626 if (Civi::settings()->get('activity_assignee_notification')) {
627 if ($followupActivity) {
628 $mailToFollowupContacts = [];
629 $assignee = [$followupActivity->id];
630 $assigneeContacts = CRM_Activity_BAO_ActivityAssignment::getAssigneeNames($assignee, TRUE, FALSE);
631 foreach ($assigneeContacts as $values) {
632 $mailToFollowupContacts[$values['email']] = $values;
633 }
634
635 $sentFollowup = CRM_Activity_BAO_Activity::sendToAssignee($followupActivity, $mailToFollowupContacts);
636 if ($sentFollowup) {
637 $followupStatus .= '<br />' . ts('A copy of the follow-up activity has also been sent to follow-up assignee contacts(s).');
638 }
639 }
640 }
641 }
642 return $followupStatus;
643 }
644
645 /**
646 * Form rule.
647 *
648 * @param array $fields
649 * The input form values.
650 *
651 * @return bool|array
652 * true if no errors, else array of errors
653 */
654 public static function saveTemplateFormRule(array $fields) {
655 $errors = [];
656 //Added for CRM-1393
657 if (!empty($fields['saveTemplate']) && empty($fields['saveTemplateName'])) {
658 $errors['saveTemplateName'] = ts('Enter name to save message template');
659 }
660 return empty($errors) ? TRUE : $errors;
661 }
662
663 }