<?php
/**
* Perforce Swarm
*
* @copyright 2012 Perforce Software. All rights reserved.
* @license Please see LICENSE.txt in top-level folder of this distribution.
* @version <release>/<patch>
*/
namespace Mail;
use Activity\Model\Activity;
use P4\Spec\Change;
use P4\Spec\Exception\NotFoundException;
use Users\Model\User;
use Zend\Mail\Message;
use Zend\Mime\Message as MimeMessage;
use Zend\Mime\Part as MimePart;
use Zend\Mvc\MvcEvent;
use Zend\Stdlib\StringUtils;
use Zend\Validator\EmailAddress;
use Zend\View\Model\ViewModel;
use Zend\View\Resolver\TemplatePathStack;
class Module
{
/**
* Connect to queue events to send email notifications
*
* @param Event $event the bootstrap event
* @return void
*/
public function onBootstrap(MvcEvent $event)
{
$application = $event->getApplication();
$services = $application->getServiceManager();
$manager = $services->get('queue');
$events = $manager->getEventManager();
// send email notifications for task events that prepare mail data.
// we use a very low priority so that others can influence the message.
$events->attach(
'*',
function ($event) use ($application, $services) {
$mail = $event->getParam('mail');
$activity = $event->getParam('activity');
if (!is_array($mail) || !$activity instanceof Activity) {
return;
}
// ignore 'quiet' events.
$data = (array) $event->getParam('data') + array('quiet' => null);
$quiet = $event->getParam('quiet', $data['quiet']);
if ($quiet === true || in_array('mail', (array) $quiet)) {
return;
}
// normalize and validate message configuration
$mail += array(
'to' => null,
'toUsers' => null,
'subject' => null,
'cropSubject' => false,
'fromAddress' => null,
'fromName' => null,
'fromUser' => null,
'messageId' => null,
'inReplyTo' => null,
'htmlTemplate' => null,
'textTemplate' => null,
);
// detect bad templates, clear them (to avoid later errors) and log it
$invalidTemplates = array();
foreach (array('htmlTemplate', 'textTemplate') as $templateKey) {
if ($mail[$templateKey] && !is_readable($mail[$templateKey])) {
$invalidTemplates[] = $mail[$templateKey];
$mail[$templateKey] = null;
}
}
if (count($invalidTemplates)) {
$services->get('logger')->err(
'Invalid mail template(s) specified: ' . implode(', ', $invalidTemplates)
);
}
// if we don't have any valid templates, we can't send email
if (!$mail['htmlTemplate'] && !$mail['textTemplate']) {
$services->get('logger')->err("Cannot send mail. No valid templates specified.");
return;
}
// normalize mail configuration, start by ensuring all of the keys are at least present
$configs = $services->get('config') + array('mail' => array());
$config = $configs['mail'] +
array(
'sender' => null,
'recipients' => null,
'subject_prefix' => null,
'use_bcc' => null,
'use_replyto' => true
);
// if we are configured not to email events involving restricted changes
// and this event has a change attached, dig into the associated change.
// if the associated change ends up being restricted, bail.
if ((!isset($configs['security']['email_restricted_changes'])
|| !$configs['security']['email_restricted_changes'])
&& $activity->get('change')
) {
// try and re-use the event's change if it has a matching id otherwise do a fetch
$changeId = $activity->get('change');
$change = $event->getParam('change');
if (!$change instanceof Change || $change->getId() != $changeId) {
try {
$change = Change::fetch($changeId, $services->get('p4_admin'));
} catch (NotFoundException $e) {
// if we cannot fetch the change, we have to assume
// it's restricted and bail out of sending email
return;
}
}
// if the change is restricted, don't email just bail
if ($change->getType() == Change::RESTRICTED_CHANGE) {
return;
}
}
// if sender has no value use the default
$config['sender'] = $config['sender'] ?: 'notifications@' . $configs['environment']['hostname'];
// if subject prefix was specified or is an empty string, use it.
// for unspecified or null subject prefixes we use the default.
$config['subject_prefix'] = $config['subject_prefix'] || $config['subject_prefix'] === ''
? $config['subject_prefix'] : '[Swarm]';
// as a convenience, listeners may specify to/from as usernames
// and we will resolve these into the appropriate email addresses.
$to = (array) $mail['to'];
$users = array_unique(array_merge((array) $mail['toUsers'], (array) $mail['fromUser']));
if (count($users)) {
$p4Admin = $services->get('p4_admin');
$users = User::fetchAll(array(User::FETCH_BY_NAME => $users), $p4Admin);
}
if (is_array($mail['toUsers'])) {
foreach ($mail['toUsers'] as $toUser) {
if (isset($users[$toUser])) {
$to[] = $users[$toUser]->getEmail();
}
}
}
if (isset($users[$mail['fromUser']])) {
$fromUser = $users[$mail['fromUser']];
$mail['fromAddress'] = $fromUser->getEmail() ?: $mail['fromAddress'];
$mail['fromName'] = $fromUser->getFullName() ?: $mail['fromName'];
}
// remove any duplicate or empty recipient addresses
$to = array_unique(array_filter($to, 'strlen'));
// filter out invalid addresses from the list of recipients
$validator = new EmailAddress;
$to = array_filter($to, array($validator, 'isValid'));
// if we don't have any recipients, nothing more to do
if (!$to && !$config['recipients']) {
return;
}
// if explicit recipients have been configured (e.g. for testing),
// log the computed list of recipients for debug purposes.
if ($config['recipients']) {
$services->get('logger')->debug('Mail recipients: ' . implode(', ', $to));
}
// prepare view for rendering message template
// customize view resolver to only look for the specific
// templates we've been given (note we cloned view, so it's ok)
$renderer = clone $services->get('ViewManager')->getRenderer();
$resolver = new TemplatePathStack;
$resolver->addPaths(array(dirname($mail['htmlTemplate']), dirname($mail['textTemplate'])));
$renderer->setResolver($resolver);
$viewModel = new ViewModel(
array(
'services' => $services,
'event' => $event,
'activity' => $activity
)
);
// message has up to two parts (html and plain-text)
$parts = array();
if ($mail['textTemplate']) {
$viewModel->setTemplate(basename($mail['textTemplate']));
$text = new MimePart($renderer->render($viewModel));
$text->type = 'text/plain; charset=UTF-8';
$parts[] = $text;
}
if ($mail['htmlTemplate']) {
$viewModel->setTemplate(basename($mail['htmlTemplate']));
$html = new MimePart($renderer->render($viewModel));
$html->type = 'text/html; charset=UTF-8';
$parts[] = $html;
}
// prepare subject by applying prefix, collapsing whitespace,
// trimming whitespace or dashes and optionally cropping
$subject = $config['subject_prefix'] . ' ' . $mail['subject'];
if ($mail['cropSubject']) {
$utility = StringUtils::getWrapper();
$length = strlen($subject);
$subject = $utility->substr($subject, 0, (int) $mail['cropSubject']);
$subject = trim($subject, "- \t\n\r\0\x0B");
$subject .= strlen($subject) < $length ? '...' : '';
}
$subject = preg_replace('/\s+/', " ", $subject);
$subject = trim($subject, "- \t\n\r\0\x0B");
// prepare thread-index header for outlook/exchange
// - thread-index is 6-bytes of FILETIME followed by a 16-byte GUID
// - time can vary between messages in a thread, but the GUID can't
// - current time in FILETIME format is the number of 100 nanosecond
// intervals since the win32 epoch (January 1, 1601 UTC)
// - GUID is inReplyTo header md5'd and packed into 16 bytes
// - the time and GUID are then combined and base-64 encoded
$fileTime = (time() + 11644473600) * 10000000;
$fileTime = pack('Nn', $fileTime >> 32, $fileTime >> 16);
$guid = pack('H*', md5($mail['inReplyTo']));
$threadIndex = base64_encode($fileTime . $guid);
// build the mail message
$body = new MimeMessage();
$body->setParts($parts);
$message = new Message();
$recipients = $config['recipients'] ?: $to;
if ($config['use_bcc']) {
$message->setTo($config['sender'], 'Unspecified Recipients');
$message->addBcc($recipients);
} else {
$message->addTo($recipients);
}
$message->setSubject($subject);
$message->setFrom($config['sender'], $mail['fromName']);
if ($config['use_replyto']) {
$message->addReplyTo($mail['fromAddress'] ?: $config['sender'], $mail['fromName']);
} else {
$message->addReplyTo('noreply@' . $configs['environment']['hostname'], 'No Reply');
}
$message->setBody($body);
$message->setEncoding('UTF-8');
$message->getHeaders()->addHeaders(
array_filter(
array(
'Message-ID' => $mail['messageId'],
'In-Reply-To' => $mail['inReplyTo'],
'References' => $mail['inReplyTo'],
'Thread-Index' => $threadIndex,
'Thread-Topic' => $subject,
'X-Swarm-Host' => $configs['environment']['hostname'],
'X-Swarm-Version' => VERSION,
)
)
);
// set alternative multi-part if we have both html and text templates
// so that the client knows to show one or the other, not both
if ($mail['htmlTemplate'] && $mail['textTemplate']) {
$message->getHeaders()->get('content-type')->setType('multipart/alternative');
}
try {
$mailer = $services->get('mailer');
$mailer->send($message);
// if we have the option, disconnect to avoid timeouts
// unit tests don't have this method so we have to gate the call
if (method_exists($mailer, 'disconnect')) {
$mailer->disconnect();
}
} catch (\Exception $e) {
$services->get('logger')->err($e);
}
},
-200
);
}
public function getConfig()
{
return include __DIR__ . '/config/module.config.php';
}
public function getAutoloaderConfig()
{
return array(
'Zend\Loader\StandardAutoloader' => array(
'namespaces' => array(
__NAMESPACE__ => __DIR__ . '/src/' . __NAMESPACE__,
),
),
);
}
}