Skip to content

Instantly share code, notes, and snippets.

@benfavre
Last active April 11, 2022 14:24
Show Gist options
  • Save benfavre/097a59d4e5e4bea31b3a533d0fd2bc10 to your computer and use it in GitHub Desktop.
Save benfavre/097a59d4e5e4bea31b3a533d0fd2bc10 to your computer and use it in GitHub Desktop.

Revisions

  1. benfavre revised this gist Apr 11, 2022. 1 changed file with 6 additions and 6 deletions.
    12 changes: 6 additions & 6 deletions web-app-Library-Traits-HasTemplate.php
    Original file line number Diff line number Diff line change
    @@ -289,10 +289,10 @@ public function prepareEmail($subscriber, $server = null, $fromCache = false, $e
    }

    $message->setReplyTo($this->reply_to);
    // $message->setEncoder(new \Swift_Mime_ContentEncoder_PlainContentEncoder('8bit'));
    $factory = new \Swift_CharacterReaderFactory_SimpleCharacterReaderFactory();
    $stream = new \Swift_CharacterStream_ArrayCharacterStream($factory, 'utf-8');
    $message->setEncoder(new \Swift_Mime_ContentEncoder_NullContentEncoder('quoted-printable'));
    // $message->setEncoder(new \Swift_Mime_ContentEncoder_PlainContentEncoder('8bit')); // FIX
    $factory = new \Swift_CharacterReaderFactory_SimpleCharacterReaderFactory(); // FIX
    $stream = new \Swift_CharacterStream_ArrayCharacterStream($factory, 'utf-8'); // FIX
    $message->setEncoder(new \Swift_Mime_ContentEncoder_NullContentEncoder('quoted-printable')); // FIX


    if (is_null($this->type) || $this->type == self::TYPE_REGULAR) {
    @@ -308,7 +308,7 @@ public function prepareEmail($subscriber, $server = null, $fromCache = false, $e

    // IMPORTANT: add plain part first, then html part
    $message->addPart($plain, 'text/plain');
    $message->addPart(quoted_printable_encode($html), 'text/html');
    $message->addPart(quoted_printable_encode($html), 'text/html'); // FIX
    } else {
    // Get plain content is for PLAIN campaign only
    $plain = $this->getPlainContent($subscriber, $msgId, $server);
    @@ -479,7 +479,7 @@ public function getBaseHtmlContent($fromCache = false, $expiresInSeconds = 600)
    $pipeline = new PipelineBuilder();
    $pipeline->add(new AddDoctype());
    $pipeline->add(new RemoveTitleTag());
    // $pipeline->add(new ReplaceBareLineFeed());
    // $pipeline->add(new ReplaceBareLineFeed()); // FIX
    $pipeline->add(new AppendHtml($this->getHtmlFooter()));
    $pipeline->add(new ParseRss());
    $pipeline->add(new MakeInlineCss($this->template->findCssFiles()));
  2. benfavre revised this gist Apr 11, 2022. No changes.
  3. benfavre created this gist Apr 11, 2022.
    587 changes: 587 additions & 0 deletions web-app-Library-Traits-HasTemplate.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,587 @@
    <?php

    namespace Acelle\Library\Traits;

    use Acelle\Model\Template;
    use Exception;
    use Acelle\Library\ExtendedSwiftMessage;
    use Acelle\Model\Setting;
    use Acelle\Library\StringHelper;
    use League\Pipeline\PipelineBuilder;
    use Acelle\Library\HtmlHandler\ParseRss;
    use Acelle\Library\HtmlHandler\ReplaceBareLineFeed;
    use Acelle\Library\HtmlHandler\AppendHtml;
    use Acelle\Library\HtmlHandler\TransformTag;
    use Acelle\Library\HtmlHandler\InjectTrackingPixel;
    use Acelle\Library\HtmlHandler\MakeInlineCss;
    use Acelle\Library\HtmlHandler\TransformUrl;
    use Acelle\Library\HtmlHandler\TransformWidgets;
    use Acelle\Library\HtmlHandler\AddDoctype;
    use Acelle\Library\HtmlHandler\RemoveTitleTag;
    use Acelle\Library\Lockable;
    use File;
    use Cache;
    use Soundasleep\Html2Text;

    trait HasTemplate
    {
    /**
    * Campaign has one template.
    */
    public function template()
    {
    return $this->belongsTo('Acelle\Model\Template');
    }

    /**
    * Get template.
    */
    public function setTemplate($template, $name=null)
    {
    $campaignTemplate = $template->copy([
    'name' => $name ? $name : trans('messages.campaign.template_name', ['name' => $this->name]),
    'customer_id' => $this->customer_id,
    ]);

    // remove exist template
    if ($this->template) {
    $this->template->deleteAndCleanup();
    }

    $this->template_id = $campaignTemplate->id;
    $this->save();
    $this->refresh();
    if (\Schema::hasColumn($this->getTable(), 'plain')) {
    $this->updatePlainFromHtml();
    }
    if (method_exists($this, 'updateLinks')) {
    $this->updateLinks();
    }
    }

    /**
    * Upload a template.
    */
    public function uploadTemplate($request)
    {
    $template = Template::uploadTemplate($request);
    $this->setTemplate($template);
    }

    /**
    * Check if email has template.
    */
    public function hasTemplate()
    {
    return $this->template()->exists();
    }

    /**
    * Get thumb.
    */
    public function getThumbUrl()
    {
    if ($this->template) {
    return $this->template->getThumbUrl();
    } else {
    return url('images/placeholder.jpg');
    }
    }

    /**
    * Remove email template.
    */
    public function removeTemplate()
    {
    $this->template->deleteAndCleanup();
    }

    /**
    * Update campaign plain text.
    */
    public function updatePlainFromHtml()
    {
    if (!$this->plain) {
    $this->plain = preg_replace('/\s+/', ' ', preg_replace('/\r\n/', ' ', strip_tags($this->getTemplateContent())));
    $this->save();
    }
    }

    /**
    * Set template content.
    */
    public function setTemplateContent($content, $callback = null)
    {
    if (!$this->template) {
    throw new Exception('Cannot set content: campaign/email does not have template!');
    }

    $template = $this->template;
    $template->content = $content;
    $template->save();
    if (!is_null($callback)) {
    $callback($this);
    }
    }

    /**
    * Get template content.
    */
    public function getTemplateContent()
    {
    if (!$this->template) {
    throw new Exception('Cannot get content: campaign/email does not have template!');
    }

    return $this->template->content;
    }

    /**
    * Build Email Custom Headers.
    *
    * @return Hash list of custom headers
    */
    public function getCustomHeaders($subscriber, $server)
    {
    $msgId = StringHelper::generateMessageId(StringHelper::getDomainFromEmail($this->from_email));

    if ($this->isStdClassSubscriber($subscriber)) {
    $unsubscribeUrl = null;
    } else {
    $unsubscribeUrl = $subscriber->generateUnsubscribeUrl($msgId);
    if ($this->trackingDomain) {
    $unsubscribeUrl = $this->trackingDomain->buildTrackingUrl($unsubscribeUrl);
    }
    }

    $headers = array(
    'X-Acelle-Campaign-Id' => $this->uid,
    'X-Acelle-Subscriber-Id' => $subscriber->uid,
    'X-Acelle-Customer-Id' => $this->customer->uid,
    'X-Acelle-Message-Id' => $msgId,
    'X-Acelle-Sending-Server-Id' => $server->uid,
    'Precedence' => 'bulk',
    );

    if ($unsubscribeUrl) {
    $headers['List-Unsubscribe'] = "<{$unsubscribeUrl}>";
    } else {
    $sampleUnsubscribeUrl = route('campaign_message', ['message' => StringHelper::base64UrlEncode(trans('messages.email.test_link_note')) ]);
    $headers['List-Unsubscribe'] = "<{$sampleUnsubscribeUrl}>";
    }

    return $headers;
    }

    /**
    * Check if the given variable is a subscriber object (for actually sending a email)
    * Or a stdClass subscriber (for sending test email).
    *
    * @param object $object
    */
    public function isStdClassSubscriber($object)
    {
    return get_class($object) == 'stdClass';
    }

    function indentContent($content, $tab="\t"){
    // add marker linefeeds to aid the pretty-tokeniser (adds a linefeed between all tag-end boundaries)
    $content = preg_replace('/(>)(<)(\/*)/', "$1\n$2$3", $content);

    // now indent the tags
    $token = strtok($content, "\n");
    $result = ''; // holds formatted version as it is built
    $pad = 0; // initial indent
    $matches = array(); // returns from preg_matches()

    // scan each line and adjust indent based on opening/closing tags
    while ($token !== false && strlen($token)>0)
    {
    $token = trim($token);
    // test for the various tag states

    // 1. open and closing tags on same line - no change
    if (preg_match('/.+<\/\w[^>]*>$/', $token, $matches)) $indent=0;
    // 2. closing tag - outdent now
    elseif (preg_match('/^<\/\w/', $token, $matches))
    {
    $pad--;
    if($indent>0) $indent=0;
    if($nextTagNegative){
    $pad--;$nextTagNegative=false;
    }
    }
    // 3. opening tag - don't pad this one, only subsequent tags (only if it isn't a void tag)
    elseif (preg_match('/^<\w[^>]*[^\/]>.*$/', $token, $matches))
    {
    $voidTag = false;
    foreach ($matches as $m)
    {
    // Void elements according to http://www.htmlandcsswebdesign.com/articles/voidel.php
    if (preg_match('/^<(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)/im', $m))
    {
    $voidTag = true;
    break;
    }
    }

    if (!$voidTag) $indent=1;$nextTagNegative=true;
    }
    // 4. no indentation needed
    else $indent = 0;

    // pad the line with the required number of leading spaces
    $line = str_pad($token, strlen($token)+$pad, $tab, STR_PAD_LEFT);
    $result .= $line."\n"; // add to the cumulative result, with linefeed
    $token = strtok("\n"); // get the next token
    $pad += $indent; // update the pad size for subsequent lines
    }

    return $result;
    }

    /**
    * Prepare the email content using Swift Mailer.
    *
    * @input object subscriber
    * @input object sending server
    *
    * @return MIME text message
    */
    public function prepareEmail($subscriber, $server = null, $fromCache = false, $expiresInSeconds = 600)
    {
    // build the message
    $customHeaders = $this->getCustomHeaders($subscriber, $this);
    $msgId = $customHeaders['X-Acelle-Message-Id'];

    $message = new ExtendedSwiftMessage();
    $message->setId($msgId);

    if (is_null($this->type) || $this->type == self::TYPE_REGULAR) {
    $message->setContentType('text/html; charset=utf-8');
    } else {
    $message->setContentType('text/plain; charset=utf-8');
    }

    foreach ($customHeaders as $key => $value) {
    $message->getHeaders()->addTextHeader($key, $value);
    }

    // @TODO for AWS, setting returnPath requires verified domain or email address
    if (!is_null($server) && $server->allowCustomReturnPath()) {
    $returnPath = $server->getVerp($subscriber->email);
    if ($returnPath) {
    $message->setReturnPath($returnPath);
    }
    }
    $message->setSubject('[test] ' . $this->getSubject($subscriber, $msgId));
    $message->setFrom(array($this->from_email => $this->from_name));
    $message->setTo($subscriber->email);

    if (!empty(Setting::get('campaign.bcc'))) {
    $addresses = array_filter(preg_split('/\s*,\s*/', Setting::get('campaign.bcc')));
    $message->setBcc($addresses);
    }

    if (!empty(Setting::get('campaign.cc'))) {
    $addresses = array_filter(preg_split('/\s*,\s*/', Setting::get('campaign.cc')));
    $message->setCc($addresses);
    }

    $message->setReplyTo($this->reply_to);
    // $message->setEncoder(new \Swift_Mime_ContentEncoder_PlainContentEncoder('8bit'));
    $factory = new \Swift_CharacterReaderFactory_SimpleCharacterReaderFactory();
    $stream = new \Swift_CharacterStream_ArrayCharacterStream($factory, 'utf-8');
    $message->setEncoder(new \Swift_Mime_ContentEncoder_NullContentEncoder('quoted-printable'));


    if (is_null($this->type) || $this->type == self::TYPE_REGULAR) {
    $html = $this->getHtmlContent($subscriber, $msgId, $server, $fromCache, $expiresInSeconds);


    $options = array(
    'ignore_errors' => true,
    // other options go here
    );

    $plain = Html2Text::convert($html, $options);

    // IMPORTANT: add plain part first, then html part
    $message->addPart($plain, 'text/plain');
    $message->addPart(quoted_printable_encode($html), 'text/html');
    } else {
    // Get plain content is for PLAIN campaign only
    $plain = $this->getPlainContent($subscriber, $msgId, $server);
    $message->addPart($plain, 'text/plain');
    }

    if ($this->sign_dkim) {
    $message = $this->sign($message);
    }

    if ($this->attachments) {
    // Email model
    foreach ($this->attachments as $file) {
    $attachment = \Swift_Attachment::fromPath($file->file);
    $message->attach($attachment);
    // This is used by certain delivery services like ElasticEmail
    $message->extAttachments[] = [ 'path' => $file->file, 'type' => $attachment->getContentType()];
    }
    } else {
    // Campaign model
    //@todo attach function used for any attachment of Campaign
    $path_campaign = $this->getAttachmentPath();
    if (is_dir($path_campaign)) {
    $files = File::allFiles($path_campaign);
    foreach ($files as $file) {
    $attachment = \Swift_Attachment::fromPath((string) $file);
    $message->attach($attachment);
    // This is used by certain delivery services like ElasticEmail
    $message->extAttachments[] = [ 'path' => (string) $file, 'type' => $attachment->getContentType()];
    }
    }
    }

    return array($message, $msgId);
    }

    /**
    * Get tagged Subject.
    *
    * @return string
    */
    public function getSubject($subscriber, $msgId)
    {
    $pipeline = new PipelineBuilder();
    $pipeline->add(new ReplaceBareLineFeed());
    $pipeline->add(new TransformTag($this, $subscriber, $msgId));
    return $pipeline->build()->process($this->subject);
    }

    /**
    * Check if email footer enabled.
    *
    * @return string
    * @deprecated this is a very poorly designed function with dependencies session!
    * @todo so, we are adding if/else to facilitate testing only
    */
    public function footerEnabled()
    {
    if (is_null($this->customer)) {
    return;
    }

    return ($this->customer->getCurrentSubscription()->plan->getOption('email_footer_enabled') == 'yes') ? true : false;
    }

    /**
    * Get HTML footer.
    *
    * @return string
    * @deprecated this is a very poorly designed function with dependencies session!
    * @todo so, we are adding if/else to facilitate testing only
    */
    public function getHtmlFooter()
    {
    if (is_null($this->customer)) {
    return;
    }

    return $this->customer->getCurrentSubscription()->plan->getOption('html_footer');
    }

    /**
    * Find sending domain from email.
    *
    * @return mixed
    */
    public function findSendingDomain($email)
    {
    $domainName = substr(strrchr($email, '@'), 1);

    if ($domainName == false) {
    return;
    }

    $domain = $this->customer->sendingDomains()->where('name', $domainName)->first();

    return $domain;
    }

    /**
    * Sign the message with DKIM.
    *
    * @return mixed
    */
    public function sign($message)
    {
    $sendingDomain = $this->findSendingDomain($this->from_email);

    if (is_null($sendingDomain)) {
    return $message;
    }

    $privateKey = $sendingDomain->dkim_private;
    $domainName = $sendingDomain->name;
    $selector = $sendingDomain->getDkimSelectorParts()[0];
    $signer = new \Swift_Signers_DKIMSigner($privateKey, $domainName, $selector);
    $signer->ignoreHeader('Return-Path');
    $message->attachSigner($signer);

    return $message;
    }

    public function getCachedHtmlId()
    {
    return "{$this->uid}-html";
    }

    public function clearCache()
    {
    Cache::forget($this->getCachedHtmlId());
    }

    /**
    * Build Email HTML content.
    *
    * @return string
    */
    public function getHtmlContent($subscriber = null, $msgId = null, $server = null, $fromCache = false, $expiresInSeconds = 600)
    {
    $baseHtml = $this->getBaseHtmlContent($fromCache, $expiresInSeconds);

    // Bind subscriber/message/server information to email content
    $pipeline = new PipelineBuilder();
    $pipeline->add(new TransformTag($this, $subscriber, $msgId, $server));
    $pipeline->add(new InjectTrackingPixel($this, $msgId));
    $pipeline->add(new TransformUrl($this->template, $msgId, $this->trackingDomain));

    // Actually push HTML to pipeline for processing
    $html = $pipeline->build()->process($baseHtml);

    // Return subscriber's bound html
    return $html;
    }

    // Return the HTML content which has been processed through base handlers (pipeline)
    // Which is not associated with any subscriber/message/server
    public function getBaseHtmlContent($fromCache = false, $expiresInSeconds = 600)
    {
    if (!$this->template) {
    throw new Exception('No template available');
    }

    $cacheId = $this->getCachedHtmlId();
    $updateCacheFlag = $fromCache && !Cache::has($cacheId);
    $html = null;

    if (!$fromCache || $updateCacheFlag) {
    $pipeline = new PipelineBuilder();
    $pipeline->add(new AddDoctype());
    $pipeline->add(new RemoveTitleTag());
    // $pipeline->add(new ReplaceBareLineFeed());
    $pipeline->add(new AppendHtml($this->getHtmlFooter()));
    $pipeline->add(new ParseRss());
    $pipeline->add(new MakeInlineCss($this->template->findCssFiles()));
    $pipeline->add(new TransformWidgets());
    // // $pipeline->add(new TransformTag($this, $subscriber, $msgId, $server));
    // // $pipeline->add(new InjectTrackingPixel($this, $msgId));
    // // $pipeline->add(new TransformUrl($this->template, $msgId, $this->trackingDomain));
    // // $html = $this->wooTransform($html);
    $html = $pipeline->build()->process($this->getTemplateContent());
    }

    if ($updateCacheFlag) {
    $lockfile = storage_path('locks/campaign-cache-'.$this->uid);
    $lock = new Lockable($lockfile);

    $lock->getExclusiveLock(function ($f) use ($cacheId, $html, $expiresInSeconds) {
    Cache::put($cacheId, $html, $expiresInSeconds);
    }, $timeoutSeconds = 3, $timeoutCallback = function () {
    // echo "Quit me mememem";
    // just quit, do not throw exception
    });
    }

    // It is important to return $html in priority here, as cache update may not work!
    return $html ?: Cache::get($cacheId);
    }

    /**
    * Build Email HTML content.
    * Notice: this method is used for PLAIN CAMPAIGN only. To extract plain content from HTML, use Html2Text instead
    *
    * @return string
    */
    public function getPlainContent($subscriber, $msgId, $server = null)
    {
    $plain = $this->plain.$this->getPlainTextFooter();
    $pipeline = new PipelineBuilder();
    $pipeline->add(new ReplaceBareLineFeed());
    $pipeline->add(new TransformTag($this, $subscriber, $msgId, $server));
    $plain = $pipeline->build()->process($plain);

    return $plain;
    }

    /**
    * Get PLAIN TEXT footer.
    *
    * @return string
    * @deprecated this is a very poorly designed function with dependencies session!
    * @todo so, we are adding if/else to facilitate testing only
    */
    public function getPlainTextFooter()
    {
    if (is_null($this->customer)) {
    return;
    }

    return $this->customer->getCurrentSubscription()->plan->getOption('plain_text_footer');
    }

    /**
    * Create a stdClass subscriber (for sending a campaign test email)
    * The campaign sending functions take a subscriber object as input
    * However, a test email address is not yet a subscriber object, so we have to build a fake stdClass object
    * which can be used as a real subscriber.
    *
    * @param array $subscriber
    */
    public function createStdClassSubscriber($subscriber)
    {
    // default attributes that are required
    $jsonObj = [
    'uid' => uniqid(),
    ];

    // append the customer specified attributes and build a stdClass object
    $stdObj = json_decode(json_encode(array_merge($jsonObj, $subscriber)));

    return $stdObj;
    }

    public function makeTrackingPixel($msgId)
    {
    if (!is_null($msgId)) {
    $url = route('openTrackingUrl', ['message_id' => StringHelper::base64UrlEncode($msgId)], true);
    if ($this->trackingDomain) {
    $url = $this->trackingDomain->buildTrackingUrl($url);
    }
    } else {
    $url = $this->makeSampleLink();
    }

    return '<img alt="" src="'.$url.'" width="0" height="0" alt="" style="visibility:hidden" />' . PHP_EOL;
    }

    public function makeSampleLink()
    {
    $sampleLink = route('campaign_message', [ 'message' => StringHelper::base64UrlEncode(trans('messages.email.test_link_note')) ]);
    if ($this->trackingDomain) {
    $sampleLink = $this->trackingDomain->buildTrackingUrl($sampleLink);
    }

    return $sampleLink;
    }
    }