Skip to content

Instantly share code, notes, and snippets.

@benfavre
Last active April 11, 2022 14:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save benfavre/097a59d4e5e4bea31b3a533d0fd2bc10 to your computer and use it in GitHub Desktop.
Save benfavre/097a59d4e5e4bea31b3a533d0fd2bc10 to your computer and use it in GitHub Desktop.
Acelle mail - Fix email sending to outlook with IE conditional comments - Check for // FIX comments
<?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')); // 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) {
$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'); // FIX
} 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()); // FIX
$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;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment