1: <?php
2:
3: /*****************************************************************************************
4: * X2Engine Open Source Edition is a customer relationship management program developed by
5: * X2Engine, Inc. Copyright (C) 2011-2016 X2Engine Inc.
6: *
7: * This program is free software; you can redistribute it and/or modify it under
8: * the terms of the GNU Affero General Public License version 3 as published by the
9: * Free Software Foundation with the addition of the following permission added
10: * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK
11: * IN WHICH THE COPYRIGHT IS OWNED BY X2ENGINE, X2ENGINE DISCLAIMS THE WARRANTY
12: * OF NON INFRINGEMENT OF THIRD PARTY RIGHTS.
13: *
14: * This program is distributed in the hope that it will be useful, but WITHOUT
15: * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16: * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
17: * details.
18: *
19: * You should have received a copy of the GNU Affero General Public License along with
20: * this program; if not, see http://www.gnu.org/licenses or write to the Free
21: * Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
22: * 02110-1301 USA.
23: *
24: * You can contact X2Engine, Inc. P.O. Box 66752, Scotts Valley,
25: * California 95067, USA. or at email address [email protected].
26: *
27: * The interactive user interfaces in modified source and object code versions
28: * of this program must display Appropriate Legal Notices, as required under
29: * Section 5 of the GNU Affero General Public License version 3.
30: *
31: * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
32: * these Appropriate Legal Notices must retain the display of the "Powered by
33: * X2Engine" logo. If the display of the logo is not reasonably feasible for
34: * technical reasons, the Appropriate Legal Notices must display the words
35: * "Powered by X2Engine".
36: *****************************************************************************************/
37:
38: Yii::import('application.modules.docs.models.*');
39: Yii::import('application.modules.actions.models.*');
40: Yii::import('application.modules.contacts.models.*');
41: Yii::import('application.modules.quotes.models.*');
42:
43: /**
44: * InlineEmail class. InlineEmail is the data structure for taking in and
45: * processing data for outbound email, specifically from the inline email widget.
46: *
47: * It is used by the InlineEmailForm widget and site/inlineEmail, and is
48: * designed around this principle: that the email is being sent in some context
49: * that is dictated by a "target model". Special cases for behavior of the class
50: * have been built this way, i.e. when the target model is a {@link Quote}, the
51: * insertable attributes should include those of both associated contact and
52: * account as well as the quote, and when the email is sent, the action history
53: * record that gets created should appropriately describe the event happened,
54: * i.e. by saying that "Quote #X was issued by email" rather than merely "user X
55: * has sent contact Y an email."
56: *
57: * The following describes the scenarios of this model:
58: * - "custom" is used when a modified email has been submitted for processing or
59: * sending
60: * - "template" is used when the form has been submitted to re-create the email
61: * based on a template.
62: * - Blank/empty string is for when there's a new and blank email (i.e. initial
63: * rendering of the inline email widget {@link InlineEmailForm})
64: *
65: * @property string $actionHeader (read-only) A mock-up of the email's header
66: * fields to be inserted into the email actions' bodies, for display purposes.
67: * @property array $insertableAttributes (read-only) Attributes for the inline
68: * email editor that can be inserted into the message.
69: * @property array $recipientContacts (read-only) an array of contact records
70: * identified by recipient email address.
71: * @property array $recipients (read-only) an array of all recipients of the email.
72: * @property string $signature Signature of the user sending the email, if any
73: * @property X2Model $targetModel The model associated with this email, i.e.
74: * Contacts or Quote
75: * @property Docs $templateModel (read-only) template, if any, to use.
76: * @property string $trackingImage (read-only) Markup for the tracking image to
77: * be placed in the email
78: * @property string $uniqueId A unique ID used for the tracking record and
79: * tracking image URL
80: * @package application.models
81: */
82: class InlineEmail extends CFormModel {
83: // Enclosure comments:
84:
85: const SIGNATURETAG = 'Signature'; // for signature
86: const TRACKTAG = 'OpenedEmail'; // for the tracking image
87: const AHTAG = 'ActionHeader'; // for the inline action header
88: const UIDREGEX = '/uid.([0-9a-f]{32})/';
89:
90: /**
91: * @var string Email address of the addressees
92: */
93: public $to;
94:
95: /**
96: * @var string CC email address(es), if applicable
97: */
98: public $cc;
99:
100: /**
101: * @var string BCC email address(es), if applicable
102: */
103: public $bcc;
104:
105: /**
106: * @var string Email subject
107: */
108: public $subject;
109:
110: /**
111: * @var string Email body/content
112: */
113: public $message;
114:
115: /**
116: * @var strng Email Send Time
117: */
118: public $emailSendTime = '';
119:
120: /**
121: * @var int Email Send Time in unix timestamp format
122: */
123: public $emailSendTimeParsed = 0;
124:
125: /**
126: * @var integer Template ID
127: */
128: public $template = 0;
129:
130: /**
131: * Stores the name of the model associated with the email i.e. Contacts or Quote.
132: * @var string
133: */
134: public $modelName;
135:
136: /**
137: * @var integer
138: */
139: public $modelId;
140:
141: /**
142: *
143: * @var bool Asssociate emails with the linked Contact (true) or the record itself (false)
144: */
145: public $contactFlag = true;
146:
147: /**
148: * @var array
149: */
150: public $mailingList = array();
151: public $attachments = array();
152: public $emailBody = '';
153: public $preview = false;
154: public $stageEmail = false;
155:
156: /**
157: * @var bool $requireSubjectOnCustom Allows subject requirement to be bypassed.
158: * TODO: remove this once scenario code is refactored
159: */
160: public $requireSubjectOnCustom = true;
161:
162:
163:
164:
165:
166: private $_recipientContacts;
167:
168: /**
169: * Stores value of {@link actionHeader}
170: * @var string
171: */
172: private $_actionHeader;
173:
174: /**
175: * Stores value of {@link insertableAttributes}
176: * @var array
177: */
178: private $_insertableAttributes;
179:
180: /**
181: * Stores value of {@link recipients}
182: * @var array
183: */
184: private $_recipients;
185:
186: /**
187: * Stores value of {@link signature}
188: * @var string
189: */
190: private $_signature;
191:
192: /**
193: * Stores value of {@link targetModel}
194: * @var X2Model
195: */
196: private $_targetModel;
197:
198: /**
199: * Stores value of {@link templateModel}
200: */
201: private $_templateModel;
202:
203: /**
204: * Stores value of {@link trackingImage}
205: * @var string
206: */
207: private $_trackingImage;
208:
209: /**
210: * Stores value of {@link uniqueId}
211: * @var type
212: */
213: private $_uniqueId;
214:
215: /**
216: * Declares the validation rules. The rules state that username and password
217: * are required, and password needs to be authenticated.
218: * @return array
219: */
220: public function rules(){
221: $rules = array(
222: array('to', 'required', 'on' => 'custom'),
223: // array('modelName,modelId', 'required', 'on' => 'template'),
224: array('message', 'required', 'on' => 'custom'),
225: array('to,cc,bcc', 'parseMailingList'),
226: array('emailSendTime', 'date', 'allowEmpty' => true, 'timestampAttribute' => 'emailSendTimeParsed'),
227: array('to, cc, credId, bcc, message, template, modelId, modelName, subject', 'safe'),
228:
229: );
230: if ($this->requireSubjectOnCustom) {
231: $rules[] = array('subject', 'required', 'on' => 'custom');
232: }
233: return $rules;
234: }
235:
236: public function relations () {
237: return array(
238: 'credentials' => array(self::BELONGS_TO, 'Credentials', array ('credId' => 'id')),
239: );
240: }
241:
242: /**
243: * Declares attribute labels.
244: * @return array
245: */
246: public function attributeLabels(){
247: return array(
248: 'from' => Yii::t('app', 'From:'),
249: 'to' => Yii::t('app', 'To:'),
250: 'cc' => Yii::t('app', 'CC:'),
251: 'bcc' => Yii::t('app', 'BCC:'),
252: 'subject' => Yii::t('app', 'Subject:'),
253: 'message' => Yii::t('app', 'Message:'),
254: 'template' => Yii::t('app', 'Template:'),
255: 'modelName' => Yii::t('app', 'Model Name'),
256: 'modelId' => Yii::t('app', 'Model ID'),
257: 'credId' => Yii::t('app','Send As:'),
258:
259: );
260: }
261:
262: public function behaviors() {
263: return array(
264: 'emailDelivery' => array('class' => 'application.components.EmailDeliveryBehavior')
265: );
266: }
267:
268: /**
269: * Creates a pattern for finding or inserting content into the email body.
270: *
271: * @param string $name The name of the pattern to use. There should be a
272: * constant defined that is the name in upper case followed by "TAG" that
273: * specifies the name to use in comments that demarcate the inserted content.
274: * @param string $inside The content to be inserted between comments.
275: * @param bool $re Whether to return the pattern as a regular expression
276: * @param string $reFlags PCRE flags to use in the expression, if $re is enabled.
277: */
278: public static function insertedPattern($name, $inside, $re = 0, $reFlags = ''){
279: $tn = constant('self::'.strtoupper($name.'tag'));
280: $tag = "<!--Begin$tn-->~inside~<!--End$tn-->";
281: if($re)
282: $tag = '/'.preg_quote($tag)."/$reFlags";
283: return str_replace('~inside~', $inside, $tag);
284: }
285:
286: /**
287: * Magic getter for {@link actionHeader}
288: *
289: * Composes an informative header for the action record.
290: *
291: * @return type
292: */
293: public function getActionHeader(){
294: if(!isset($this->_actionHeader)){
295:
296: $recipientContacts = $this->recipientContacts;
297:
298: // Add email headers to the top of the action description's body
299: // so that the resulting recorded action has all the info of the
300: // original email.
301: $fromString = $this->from['address'];
302: if(!empty($this->from['name']))
303: $fromString = '"'.$this->from['name'].'" <'.$fromString.'>';
304:
305: $header = CHtml::tag('strong', array(), Yii::t('app', 'Subject: ')).CHtml::encode($this->subject).'<br />';
306: $header .= CHtml::tag('strong', array(), Yii::t('app', 'From: ')).CHtml::encode($fromString).'<br />';
307: // Put in recipient lists, and if any correspond to contacts, make links
308: // to them in place of their names.
309: foreach(array('to', 'cc', 'bcc') as $recList){
310: if(!empty($this->mailingList[$recList])){
311: $header .= CHtml::tag('strong', array(), ucfirst($recList).': ');
312: foreach($this->mailingList[$recList] as $target){
313: if($recipientContacts[$target[1]] != null){
314: $header .= $recipientContacts[$target[1]]->link;
315: }else{
316: $header .= CHtml::encode("\"{$target[0]}\"");
317: }
318: $header .= CHtml::encode(" <{$target[1]}>,");
319: }
320: $header = rtrim($header, ', ').'<br />';
321: }
322: }
323:
324: // Include special quote information if it's a quote being issued or emailed to a random contact
325: if($this->modelName == 'Quote'){
326: $header .= '<br /><hr />';
327: $header .= CHtml::tag('strong', array(), Yii::t('quotes', $this->targetModel->type == 'invoice' ? 'Invoice' : 'Quote')).':';
328: $header .= ' '.$this->targetModel->link.($this->targetModel->status ? ' ('.$this->targetModel->status.'), ' : ' ').Yii::t('app', 'Created').' '.$this->targetModel->renderAttribute('createDate').';';
329: $header .= ' '.Yii::t('app', 'Updated').' '.$this->targetModel->renderAttribute('lastUpdated').' by '.$this->userProfile->fullName.'; ';
330: $header .= ' '.Yii::t('quotes', 'Expires').' '.$this->targetModel->renderAttribute('expirationDate');
331: $header .= '<br />';
332: }
333:
334: // Attachments info
335: if(!empty($this->attachments)){
336: $header .= '<br /><hr />';
337: $header .= CHtml::tag('strong', array(), Yii::t('media', 'Attachments:'))."<br />";
338: $i = 0;
339: foreach($this->attachments as $attachment){
340: if ($i++) $header .= '<br />';
341:
342: if ($attachment['type'] === 'temp') {
343: // attempt to convert temporary file to media record
344:
345: if ($this->modelId && $this->modelName) {
346: $associationId = $this->modelId;
347: $associationType = X2Model::getAssociationType ($this->modelName);
348: } elseif ($contact = reset($recipientContacts)) {
349:
350: $associationId = $contact->id;
351: $associationType = 'contacts';
352: }
353: if (isset ($associationId) &&
354: ($media = $attachment['model']->convertToMedia (array (
355: 'associationType' => $associationType,
356: 'associationId' => $associationId,
357: )))) {
358:
359: $attachment['type'] = 'media';
360: $attachment['id'] = $media->id;
361: }
362: }
363:
364: if ($attachment['type'] === 'media' &&
365: ($media = Media::model ()->findByPk ($attachment['id']))) {
366:
367: $header .= $media->getLink ().' | '.$media->getDownloadLink ();
368: } else {
369: $header .= CHtml::tag(
370: 'span', array('class' => 'email-attachment-text'),
371: $attachment['filename']).'<br />';
372: }
373: }
374: }
375:
376: $this->_actionHeader = $header.'<br /><hr />';
377: }
378: return $this->_actionHeader;
379: }
380:
381: /**
382: * Magic getter for {@link insertableAttributes}.
383: *
384: * Herein is defined how the insertable attributes are put together for each
385: * different model class.
386: * @return array
387: */
388: public function getInsertableAttributes(){
389: if(!isset($this->_insertableAttributes)){
390: $ia = array(); // Insertable attributes
391: if($this->targetModel !== false){
392: // Assemble the arrays to be used in putting together insertable attributes.
393: //
394: // What the labels will look like in the insertable attributes
395: // dropdown. {attr} replaced with attribute name, {model}
396: // replaced with model.
397: $labelFormat = '{attr}';
398: // The headers for each model/section, indexed by model class.
399: $headers = array();
400: // The active record objects corresponding to each model class.
401: $models = array($this->modelName => $this->targetModel);
402: switch($this->modelName){
403: case 'Quote':
404: // There will be many more models whose attributes we want
405: // to insert, so prefix each one with the model name to
406: // distinguish the current section:
407: $labelFormat = '{model}: {attr}';
408: $headers = array(
409: 'Accounts' => 'Account Attributes',
410: 'Quote' => 'Quote Attributes',
411: 'Contacts' => 'Contact Attributes',
412: );
413: $models = array_merge($models, array(
414: 'Accounts' => $this->targetModel->getLinkedModel('accountName'),
415: 'Contacts' => $this->targetModel->contact,
416: ));
417: break;
418: case 'Contacts':
419: $headers = array(
420: 'Contacts' => 'Contact Attributes',
421: );
422: break;
423: case 'Accounts':
424: $labelFormat = '{model}: {attr}';
425: $headers = array_merge($headers, array(
426: 'Accounts' => 'Account Attributes'
427: ));
428: break;
429: case 'Opportunity':
430: $labelFormat = '{model}: {attr}';
431: $headers = array(
432: 'Opportunity' => 'Opportunity Attributes',
433: );
434: // Grab the first associated contact and use it (since that
435: // covers the most common use case of one contact, one opportunity)
436: $contactIds = explode(' ', $this->targetModel->associatedContacts);
437: if(!empty($contactIds[0])){
438: $contact = Contacts::model()->findByPk($contactIds[0]);
439: if(!empty($contact)){
440: $headers['Contacts'] = 'Contact Attributes';
441: $models['Contacts'] = $contact;
442: }
443: }
444: // Obtain the account info as well, if available:
445: if(!empty($this->targetModel->accountName)){
446: $account = Accounts::model()->findAllByPk($this->targetModel->accountName);
447: if(!empty($account)){
448: $headers['Accounts'] = 'Account Attributes';
449: $models['Accounts'] = $account;
450: }
451: }
452: break;
453: case 'Services':
454: $labelFormat = '{model}: {attr}';
455: $headers = array(
456: 'Cases' => 'Case Attributes',
457: 'Contacts' => 'Contact Attributes',
458: );
459: $models = array(
460: 'Cases' => $this->targetModel,
461: 'Contacts' => Contacts::model()->findByPk($this->targetModel->contactId),
462: );
463: break;
464: }
465:
466: $headers = array_map(function($e){
467: return Yii::t('app', $e);
468: }, $headers);
469:
470: foreach($headers as $modelName => $title){
471: $model = $models[$modelName];
472: if($model instanceof CActiveRecord){
473: $ia[$title] = array();
474: $friendlyName = Yii::t('app', rtrim($modelName, 's'));
475: foreach($model->attributeLabels() as $fieldName => $label){
476: $attr = trim($model->renderAttribute($fieldName, false));
477: $fullLabel = strtr($labelFormat, array(
478: '{model}' => $friendlyName,
479: '{attr}' => $label
480: ));
481: if($attr !== '' && $attr != ' ')
482: $ia[$title][$fullLabel] = $attr;
483: }
484: }
485: }
486: }
487: $this->_insertableAttributes = $ia;
488: }
489: return $this->_insertableAttributes;
490: }
491:
492: /**
493: * Magic getter for {@link recipientContacts}
494: */
495: public function getRecipientContacts(){
496: if(!isset($this->_recipientContacts)){
497: $contacts = array();
498: foreach($this->recipients as $target){
499: $contacts[$target[1]] = Contacts::model()->findByEmail($target[1]);
500: }
501: $this->_recipientContacts = $contacts;
502: }
503: return $this->_recipientContacts;
504: }
505:
506: /**
507: * Magic getter for {@link recipients}
508: * @return array
509: */
510: public function getRecipients(){
511: if(empty($this->_recipients)){
512: $this->_recipients = array();
513: foreach(array('to', 'cc', 'bcc') as $recList){
514: if(!empty($this->mailingList[$recList])){
515: foreach($this->mailingList[$recList] as $target){
516: $this->_recipients[] = $target;
517: }
518: }
519: }
520: }
521: return $this->_recipients;
522: }
523:
524: /**
525: * @return bool false if any one of the recipient contacts has their doNotEmail field set to
526: * true, true otherwise
527: */
528: public function checkDoNotEmailFields () {
529: $allRecipientContacts = array();
530: foreach($this->recipients as $target){
531: foreach (
532: Contacts::model()->findAllByAttributes(
533: array('email' => $target[1]),
534: 'visibility!=:private OR assignedTo!="Anyone"',
535: array (
536: ':private' => X2PermissionsBehavior::VISIBILITY_PRIVATE
537: )
538: ) as $contact) {
539:
540: $allRecipientContacts[] = $contact;
541: }
542: }
543: if (array_reduce (
544: $allRecipientContacts,
545: function ($carry, $item) {
546: return $carry || $item->doNotEmail;
547: }, false)) {
548:
549: return false;
550: }
551: return true;
552: }
553:
554:
555: /**
556: * Magic getter for {@link signature}
557: *
558: * Retrieves the email signature from the preexisting body, or from the
559: * user's profile if none can be found.
560: *
561: * @return string
562: */
563: public function getSignature(){
564: if(!isset($this->_signature)){
565: $profile = $this->getUserProfile();
566: if(!empty($profile))
567: $this->_signature = $this->getUserProfile()->getSignature(true);
568: else
569: $this->_signature = null;
570: }
571: return $this->_signature;
572: }
573:
574: /**
575: * Magic getter for {@link targetModel}
576: */
577: public function getTargetModel(){
578: if(!isset($this->_targetModel)){
579: if(!empty ($this->modelId) && !empty ($this->modelName)){
580: $this->_targetModel = X2Model::model($this->modelName)->findByPk($this->modelId);
581: if($this->_targetModel === null)
582: $this->_targetModel = false;
583: } else{
584: $this->_targetModel = false;
585: }
586: // if(!(bool) $this->_targetModel)
587: // throw new Exception('InlineEmail used on a target model name and primary key that matched no existing record.');
588: }
589: return $this->_targetModel;
590: }
591:
592: public function setTargetModel(X2Model $model){
593: $this->_targetModel = $model;
594: }
595:
596: /**
597: * Magic getter for {@link templateModel}
598: * @return type
599: */
600: public function getTemplateModel($id = null){
601: $newTemp = !empty($id);
602: if($newTemp){
603: $this->template = $id;
604: $this->_templateModel = null;
605: }else{
606: $id = $this->template;
607: }
608: if(empty($this->_templateModel)){
609: $this->_templateModel = Docs::model()->findByPk($id);
610: }
611: return $this->_templateModel;
612: }
613:
614: /**
615: * Magic getter for {@link trackingImage}
616: * @return type
617: */
618: public function getTrackingImage(){
619: if(!isset($this->_uniqueId, $this->_trackingImage)){
620: $this->_trackingImage = null;
621: $trackUrl = null;
622: if(!Yii::app()->params->noSession){
623: $trackUrl = Yii::app()->createExternalUrl('/actions/actions/emailOpened', array('uid' => $this->uniqueId, 'type' => 'open'));
624: }else{
625: // This might be a console application! In that case, there's
626: // no controller application component available.
627: $url = rtrim(Yii::app()->absoluteBaseUrl,'/');
628:
629: if(!empty($url))
630: $trackUrl = "$url/index.php/actions/emailOpened?uid={$this->uniqueId}&type=open";
631: else
632: $trackUrl = null;
633: }
634: if($trackUrl != null)
635: $this->_trackingImage = '<img src="'.$trackUrl.'"/>';
636: }
637: return $this->_trackingImage;
638: }
639:
640: /**
641: * Magic setter for {@link uniqueId}
642: */
643: public function getUniqueId(){
644: if(empty($this->_uniqueId))
645: $this->_uniqueId = md5(uniqid(rand(), true));
646: return $this->_uniqueId;
647: }
648:
649: /**
650: * Magic setter for {@link uniqueId}
651: * @param string $value
652: */
653: public function setUniqueId($value){
654: $this->_uniqueId = $value;
655: }
656:
657: /**
658: * Validation function for lists of email addresses.
659: *
660: * @param string $attribute
661: * @param array $params
662: */
663: public function parseMailingList($attribute, $params = array()){
664: // First, convert the mailing list into an array of addresses.
665: // Use EmailDeliveryBehavior's recipient header parsing method,
666: // addressHeaderToArray, to do the heavy lifting.
667: try {
668: $this->mailingList[$attribute] = self::addressHeaderToArray($this->$attribute);
669: } catch (CException $e) {
670: $this->addError($attribute, $e->getMessage());
671: }
672: }
673:
674: /**
675: * Inserts a signature into the body, if none can be found.
676: * @param array $wrap Wrap the signature in tags (index 0 opens, index 1 closes)
677: */
678: public function insertSignature($wrap = array('<br /><br />', '')){
679: if(preg_match(self::insertedPattern('signature', '(.*)', 1, 'um'), $this->message, $matches)){
680: $this->_signature = $matches[1];
681: }else{
682: $sig = self::insertedPattern('signature', $this->signature);
683: if(count($wrap) >= 2){
684: $sig = $wrap[0].$sig.$wrap[1];
685: }
686: if(strpos($this->message, '{signature}')){
687: $this->message = str_replace('{signature}', $sig, $this->message);
688: }else if($this->scenario != 'custom'){
689: $this->insertInBody($sig);
690: }
691: }
692: $this->insertInBody("<div> </div>");
693: }
694:
695: /**
696: * Search for an existing tracking image and insert a new one if none are present.
697: *
698: * Parses the tracking image and unique ID out of the body if there are any.
699: *
700: * The email will be tracked, but only if one and only one of the recipients
701: * corresponds to a contact in X2Engine (remember, the user can switch the
702: * recipient list at the last minute by modifying the "To:" field).
703: *
704: * Otherwise, there's absolutely no way of telling with any certainty who
705: * exactly opened the email (all recipients will be sent the same email,
706: * so any one of them could be the one who opens the email and accesses the
707: * email tracking image). Thus, in such cases, it is pointless to create an
708: * event/action that says "so-and so has opened an email" because who opened
709: * the email is ambiguous and practically unknowable, and thus impractical
710: * to create an email tracking record.
711: *
712: * @param bool $replace reset the image markup and unique ID, and replace
713: * the existing tracking image.
714: */
715: public function insertTrackingImage($replace = false){
716: $recipientContacts = $this->recipientContacts;
717: if(count($recipientContacts) == 1){
718:
719: // Note, if there is more than one contact in the recipient list, it is
720: // impossible to distinguish who opened the email, because both will be
721: // sent the same email. Thus it will be disabled for this use case until
722: // we have time to re-write this class a bit so that it supports sending
723: // distinct email bodies for each different recipient (so that each can
724: // have its own different tracking image)
725: $theContact = reset($recipientContacts);
726: if(!empty($theContact)){ // The one person who was sent an email is an existing contact
727: $insertNew = true;
728: $pattern = self::insertedPattern('track', '(<img.*\/>)', 1, 'u');
729: if(preg_match($pattern, $this->message, $matchImg)){
730: if($replace){
731: // Reset unique ID and insert a new tracking image with a new unique ID
732: $this->_trackingImage = null;
733: $this->_uniqueId = null;
734: $this->message = replace_string(
735: $matchImg[0], self::insertedPattern('track', $this->trackingImage),
736: $this->message);
737: }else{
738: $this->_trackingImage = $matchImg[1];
739: if(preg_match(self::UIDREGEX, $this->_trackingImage, $matchId)){
740: $this->_uniqueId = $matchId[1];
741: $insertNew = false;
742: }
743: }
744: }
745: if($insertNew){
746: $this->insertInBody(self::insertedPattern('track', $this->trackingImage));
747: }
748: }
749: }
750: }
751:
752: public static function extractTrackingUid($body) {
753: $pattern = self::insertedPattern('track', '(<img.*\/>)', 1, 'u');
754: if (preg_match ($pattern, $body, $matchImg)) {
755: if (preg_match (self::UIDREGEX, $matchImg[1], $matchId)) {
756: return $matchId[1];
757: }
758: }
759: }
760:
761: /**
762: * Inserts something near the end of the body in the HTML email.
763: *
764: * @param string $content The markup/text to be inserted.
765: * @param bool $beginning True to insert at the beginning, false to insert at the end.
766: * @param bool $return True to modify {@link message}; false to return the modified body instead.
767: */
768: public function insertInBody($content, $beginning = 0, $return = 0){
769: if($beginning)
770: $newBody = preg_replace('/(?:<body[^>]*>)/','$0{content}',$this->message);
771: else
772: $newBody = str_replace('</body>', '{content}</body>', $this->message);
773: $newBody = str_replace('{content}',$content,$newBody);
774: if($return)
775: return $newBody;
776: else
777: $this->message = $newBody;
778: }
779:
780: /**
781: * Generate a blank HTML document.
782: *
783: * @param string $content Optional content to start with.
784: */
785: public static function emptyBody($content = null){
786: return "<html><head></head><body>$content</body></html>";
787: }
788:
789: /**
790: * Prepare the email body for sending or customization by the user.
791: */
792: public function prepareBody($postReplace = 0){
793: if(!$this->validate()){
794: return false;
795: }
796: // Replace the existing body, if any, with a template, i.e. for initial
797: // set-up or an automated email.
798: if($this->scenario === 'template' ){
799: // Get the template and associated model
800:
801: if(!empty($this->templateModel)){
802: if ($this->templateModel->emailTo !== null) {
803: $this->to = Docs::replaceVariables(
804: $this->templateModel->emailTo, $this->targetModel, array (), false, false);
805: }
806: // Replace variables in the subject and body of the email
807: $this->subject = Docs::replaceVariables($this->templateModel->subject, $this->targetModel);
808: // if(!empty($this->targetModel)) {
809: $this->message = Docs::replaceVariables($this->templateModel->text, $this->targetModel, array('{signature}' => self::insertedPattern('signature', $this->signature)));
810: // } else {
811: // $this->insertInBody('<span style="color:red">'.Yii::t('app','Error: attempted using a template, but the referenced model was not found.').'</span>');
812: // }
813: }else{
814: // No template?
815: $this->message = self::emptyBody();
816: $this->insertSignature();
817: }
818: }else if($postReplace){
819: $this->subject = Docs::replaceVariables($this->subject, $this->targetModel);
820: $this->message = Docs::replaceVariables($this->message, $this->targetModel);
821: }
822:
823: return true;
824: }
825:
826: /**
827: * Performs a send (or stage, or some other action).
828: *
829: * The tracking image is inserted at the very last moment before sending, so
830: * that there is no chance of the user altering the body and deleting it
831: * accidentally.
832: *
833: * @return array
834: */
835: public function send($createEvent = true){
836: $this->insertTrackingImage();
837: $this->status = $this->deliver();
838: if($this->status['code'] == '200') {
839: $this->recordEmailSent($createEvent); // Save all the actions and events
840: $this->clearTemporaryFiles ($this->attachments);
841: }
842: return $this->status;
843: }
844:
845: /**
846: * Save the tracking record for this email, but only if an image was inserted.
847: *
848: * @param integer $actionId ID of the email-type action corresponding to the record.
849: */
850: public function trackEmail($actionId){
851: if(isset($this->_uniqueId)){
852: $track = new TrackEmail;
853: $track->actionId = $actionId;
854: $track->uniqueId = $this->uniqueId;
855: $track->save();
856: }
857: }
858:
859: /**
860: * Make records of the email in every shape and form.
861: *
862: * This method is to be called only once the email has been sent.
863: *
864: * The basic principle behind what all is happening here: emails are getting
865: * sent to people. Since the "To:" field in the inline email form is not
866: * read-only, the emails could be sent to completely different people. Thus,
867: * creating action and event records must be based exclusively on who the
868: * email is addressed to and not the model from whose view the inline email
869: * form (if that's how this model is being used) is submitted.
870: */
871: public function recordEmailSent($makeEvent = true){
872:
873:
874: // The email record, with action header for display purposes:
875: $emailRecordBody = $this->insertInBody(self::insertedPattern('ah', $this->actionHeader), 1, 1);
876: $now = time();
877: $recipientContacts = array_filter($this->recipientContacts);
878:
879: if(!empty($this->targetModel)){
880: $model = $this->targetModel;
881: if((bool) $model){
882: if($model->hasAttribute('lastActivity')){
883: $model->lastActivity = $now;
884: $model->save();
885: }
886: }
887:
888: $action = new Actions;
889: // These attributes will be the same regardless of the type of
890: // email being sent:
891: $action->completedBy = $this->userProfile->username;
892: $action->createDate = $now;
893: $action->dueDate = $now;
894: $action->subject = $this->subject;
895: $action->completeDate = $now;
896: $action->complete = 'Yes';
897: $action->actionDescription = $emailRecordBody;
898:
899:
900: // These attributes are context-sensitive and subject to change:
901: $action->associationId = $model->id;
902: $action->associationType = $model->module;
903: $action->type = 'email';
904: $action->visibility = isset($model->visibility) ? $model->visibility : 1;
905: $action->assignedTo = $this->userProfile->username;
906: if($this->modelName == 'Quote'){
907: // Is an email being sent to the primary
908: // contact on the quote? If so, the user is "issuing" the quote or
909: // invoice, and it should have a special type.
910: if(!empty($this->targetModel->contact)){
911: if(array_key_exists($this->targetModel->contact->email, $recipientContacts)){
912: $action->associationType = lcfirst(get_class($model));
913: $action->associationId = $model->id;
914: $action->type .= '_'.($model->type == 'invoice' ? 'invoice' : 'quote');
915: $action->visibility = 1;
916: $action->assignedTo = $model->assignedTo;
917: }
918: }
919: }
920:
921: if($makeEvent && $action->save()){
922: $this->trackEmail($action->id);
923: // Create a corresponding event record. Note that special cases
924: // may have to be written in the method Events->getText to
925: // accommodate special association types apart from contacts,
926: // in addition to special-case-handling here.
927: if($makeEvent){
928: $event = new Events;
929: $event->type = 'email_sent';
930: $event->subtype = 'email';
931: $event->associationType = $model->myModelName;
932: $event->associationId = $model->id;
933: $event->user = $this->userProfile->username;
934:
935: if($this->modelName == 'Quote'){
936: // Special "quote issued" or "invoice issued" event:
937: $event->subtype = 'quote';
938: if($this->targetModel->type == 'invoice')
939: $event->subtype = 'invoice';
940: $event->associationType = $this->modelName;
941: $event->associationId = $this->modelId;
942: }
943: $event->save();
944: }
945: }
946: }
947:
948: // Create action history events and event feed events for all contacts that were in the
949: // recipient list:
950: if($this->contactFlag){
951: foreach($recipientContacts as $email => $contact){
952: $contact->lastActivity = $now;
953: $contact->update(array('lastActivity'));
954:
955: $skip = false;
956: $skipEvent = false;
957: if($this->targetModel && get_class ($this->targetModel) === 'Contacts'){
958: $skip = $this->targetModel->id == $contact->id;
959: }else if($this->modelName == 'Quote'){
960: // An event has already been made for issuing the quote and
961: // so another event would be redundant.
962: $skipEvent = $this->targetModel->associatedContacts == $contact->nameId;
963: }
964: if($skip)
965: // Only save the action history item/event if this hasn't
966: // already been done.
967: continue;
968:
969: // These attributes will be the same regardless of the type of
970: // email being sent:
971: $action = new Actions;
972: $action->completedBy = $this->userProfile->username;
973: $action->createDate = $now;
974: $action->dueDate = $now;
975: $action->completeDate = $now;
976: $action->complete = 'Yes';
977:
978: // These attributes are context-sensitive and subject to change:
979: $action->associationId = $contact->id;
980: $action->associationType = 'contacts';
981: $action->type = 'email';
982: $action->visibility = isset($contact->visibility) ? $contact->visibility : 1;
983: $action->assignedTo = $this->userProfile->username;
984:
985: // Set the action's text to the modified email body
986: $action->actionDescription = $emailRecordBody;
987: // We don't really care about changelog events for emails; they're
988: // set in stone anyways.
989: $action->disableBehavior('changelog');
990:
991: if($action->save()){
992: // Now create an event for it:
993: if($makeEvent && !$skipEvent){
994: $event = new Events;
995: $event->type = 'email_sent';
996: $event->subtype = 'email';
997: $event->associationType = $contact->myModelName;
998: $event->associationId = $contact->id;
999: $event->user = $this->userProfile->username;
1000: $event->save();
1001: }
1002: }
1003: } // Loop over contacts
1004: } // Conditional statement: do all this only if the flag to perform action history creation for all contacts has been set
1005: // At this stage, if email tracking is to take place, "$action" should
1006: // refer to the action history item of the one and only recipient contact,
1007: // because there has been only one element in the recipient array to loop
1008: // over. If the target model is a contact, and the one recipient is the
1009: // contact itself, the action will be as declared before the above loop,
1010: // and it will thus still be properly associated with that contact.
1011: }
1012:
1013: public function deliver() {
1014: return $this->asa('emailDelivery')->deliverEmail($this->mailingList,$this->subject,$this->message,$this->attachments);
1015: }
1016:
1017: /**
1018: * Insert a "Do Not Email" link into the body of the email message. The link contains the
1019: * contact's trackingKey in it's get parameters. When clicked, the contact's doNotEmail field
1020: * will be set to 1.
1021: * @param Contacts $contact
1022: */
1023: public function appendDoNotEmailLink (Contacts $contact) {
1024: // Insert unsubscribe link placeholder in the email body if there is
1025: // none already:
1026: if(!preg_match('/\{doNotEmailLink\}/', $this->message)){
1027: $doNotEmailLinkText = "<br/>\n-----------------------<br/>\n"
1028: .Yii::t('app',
1029: 'To stop receiving emails from this sender, click here').
1030: ": {doNotEmailLink}";
1031: // Insert
1032: if(strpos($this->message,'</body>')!==false) {
1033: $this->message = str_replace(
1034: '</body>',$doNotEmailLinkText.'</body>',$this->message);
1035: } else {
1036: $this->message .= $doNotEmailLinkText;
1037: }
1038: }
1039:
1040: // Insert do not email link(s):
1041: $doNotEmailUrl = Yii::app()->createExternalUrl(
1042: '/marketing/marketing/doNotEmailLinkClick', array(
1043: 'x2_key' => $contact->trackingKey,
1044: ));
1045: if (Yii::app()->settings->doNotEmailLinkText !== null) {
1046: $linkText = Yii::app()->settings->doNotEmailLinkText;
1047: } else {
1048: $linkText = Admin::getDoNotEmailLinkDefaultText ();
1049: }
1050: $this->message = preg_replace(
1051: '/\{doNotEmailLink\}/', '<a href="'.$doNotEmailUrl.'">'.
1052: $linkText.'</a>', $this->message);
1053: }
1054:
1055:
1056: }
1057: