Commit 0cd298da authored by Aegir user's avatar Aegir user
Browse files
parents 4fea4f31 6e5fb4aa
# Change Log
## v0.2-alpha1
* Override core's `Mailing.preview` API to support rendering via
Flexmailer events.
* (BC Break) In the class `DefaultComposer`, change the signature for
`createMessageTemplates()` and `applyClickTracking()` to provide full
access to the event context (`$e`).
This diff is collapsed.
# org.civicrm.flexmailer
The FlexMailer is an email delivery system for CiviCRM v4.7+ which supports
batching and events, such as `WalkBatchesEvent`, `ComposeBatchEvent` and
`SendBatchEvent`.
FlexMailer includes default listeners for these events. The listeners behave
in basically the same way as CiviMail's traditional BAO-based delivery
system (respecting `mailerJobSize`, `mailThrottleTime`, `mailing_backend`,
`hook_civicrm_alterMailParams`, etal). However, this arrangement allows you
change behaviors in more fine-grained ways.
> NOTE: Some examples have not been tested well. This is an early revision of the doc+code!
## Installation
To download the latest alpha or beta version:
```
cv dl --dev flexmailer
```
To download the latest code from git:
```
git clone https://github.com/civicrm/org.civicrm.flexmailer.git $(cv path -x ./org.civicrm.flexmailer)
```
## Unit Tests
The headless unit tests are based on `phpunit` and `cv`. Simply run:
```
phpunit4
```
## Events: Inspection
To see what event listeners are configured, run:
```
cv debug:event-dispatcher /flexmail/
```
## Events: `ComposeBatchEvent`
The [`ComposeBatchEvent`](src/Event/ComposeBatchEvent.php) (`EVENT_COMPOSE`) builds the email messages. Each message
is represented as a [`FlexMailerTask`](src/FlexMailerTask.php) with a list of [`MailParams`](src/MailParams.php).
Some listeners are "under the hood" -- they define less visible parts of the message, e.g.
* `BasicHeaders` defines `Message-Id`, `Precedence`, `From`, `Reply-To`, and others.
* `BounceTracker` defines various headers for bounce-tracking.
* `OpenTracker` appends an HTML tracking code to any HTML messages.
The heavy-lifting of composing the message content is also handled by a listener, such as
[`DefaultComposer`](src/Listener/DefaultComposer.php). `DefaultComposer` replicates
traditional CiviMail functionality:
* Reads email content from `$mailing->body_text` and `$mailing->body_html`.
* Interprets tokens like `{contact.display_name}` and `{mailing.viewUrl}`.
* Loads data in batches.
* Post-processes the message with Smarty (if `CIVICRM_SMARTY` is enabled).
The traditional CiviMail semantics have some problems -- e.g. the Smarty post-processing is incompatible with Smarty's
template cache, and it is difficult to securely post-process the message with Smarty. However, changing the behavior
would break existing templates.
A major goal of FlexMailer is to facilitate a migration toward different template semantics. For example, an
extension might (naively) implement support for Mustache templates using:
```php
function mustache_civicrm_container($container) {
$container->addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__));
$container->findDefinition('dispatcher')->addMethodCall('addListener',
array(\Civi\FlexMailer\FlexMailer::EVENT_COMPOSE, '_mustache_compose_batch')
);
}
function _mustache_compose_batch(\Civi\FlexMailer\Event\ComposeBatchEvent $event) {
if ($event->getMailing()->template_type !== 'mustache') return;
$m = new Mustache_Engine();
foreach ($event->getTasks() as $task) {
if ($task->hasContent()) continue;
$contact = civicrm_api3('Contact', 'getsingle', array(
'id' => $task->getContactId(),
));
$task->setMailParam('text', $m->render($event->getMailing()->body_text, $contact));
$task->setMailParam('html', $m->render($event->getMailing()->body_html, $contact));
}
}
```
This implementation is naive in a few ways -- it performs separate SQL queries for each recipient; it doesn't optimize
the template compilation; it has a very limited range of tokens; and it doesn't handle click-through tracking. For
more ideas about these issues, review [`DefaultComposer`](src/Listener/DefaultComposer.php).
> FIXME: Core's `TokenProcessor` is useful for batch-loading token data.
> However, you currently have to use `addMessage()` and `render()` to kick it
> off -- but those are based on CiviMail template notation. We should provide
> another function that doesn't depend on the template notation -- so that
> other templates can leverage our token library.
> **Tip**: When you register a listener for `EVENT_COMPOSE`, note the weight.
> The default weight puts your listener in the middle of pipeline -- right
> before the `DefaultComposer`. However, you might want to position
> relative to other places -- e.g. `WEIGHT_PREPARE`, `WEIGHT_MAIN`,
> `WEIGHT_ALTER`, or `WEIGHT_END`.
## Events: `SendBatchEvent`
The [`SendBatchEvent`](src/Event/SendBatchEvent.php) (`EVENT_SEND`) takes a
batch of recipients and messages, and it delivers the messages. For
example, suppose you wanted to replace the built-in delivery mechanism with
a batch-oriented web-service:
```php
function example_civicrm_container($container) {
$container->addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__));
$container->findDefinition('dispatcher')->addMethodCall('addListener',
array(\Civi\FlexMailer\FlexMailer::EVENT_SEND, '_example_send_batch')
);
}
function _example_send_batch(\Civi\FlexMailer\Event\SendBatchEvent $event) {
$event->stopPropagation(); // Disable standard delivery
$context = stream_context_create(array(
'http' => array(
'method' => 'POST',
'header' => 'Content-type: application/vnd.php.serialize',
'content' => serialize($event->getTasks()),
),
));
return file_get_contents('https://example.org/batch-delivery', false, $context);
}
```
## Events: `WalkBatchesEvent`
The [`WalkBatchesEvent`](src/Event/WalkBatchesEvent.php) (`EVENT_WALK`)
examines the recipient list and pulls out a subset for whom you want to send
email. This is useful if you need strategies for chunking-out deliveries.
The basic formula for defining your own batch logic is:
```php
function example_civicrm_container($container) {
$container->addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__));
$container->findDefinition('dispatcher')->addMethodCall('addListener',
array(\Civi\FlexMailer\FlexMailer::EVENT_WALK, '_example_walk_batches')
);
}
function _example_walk_batches(\Civi\FlexMailer\Event\WalkBatchesEvent $event) {
$event->stopPropagation(); // Disable standard delivery
while (...) {
$tasks = array();
$task[] = new FlexMailerTask(...);
$task[] = new FlexMailerTask(...);
$task[] = new FlexMailerTask(...);
$event->visit($tasks);
}
}
```
## FAQ: How do you register a listener?
The examples above use `hook_civicrm_container` to manipulate the `dispatcher`;
and, specifically, they use `addListener()` to register a single global
function. However, you can also add multievent "subscribers" via
`addSubscriber()`, and you can register object-oriented services via
`addListenerService()` or `addSubscriberService()`.
For more discussion, see http://symfony.com/doc/current/components/event_dispatcher/introduction.html
## FAQ: Why use Symfony `EventDispatcher` instead of `CRM_Utils_Hook`?
There are several advantages of Symfony's event system -- for example, it
supports type-hinting, better in-source documentation, better
object-oriented modeling, and better refactoring. However, that's not why
FlexMailer uses it. Firstly, Symfony allows you to have multiple listeners
in the same module. Secondly, when you have multiple parties influencing an
event, the EventDispatcher allows you to both (a) set priorities among them
and (b) allow one listener to veto other listeners.
Some of these characteristics are also available in CMS event systems, but
not consistently so. The challenge of `CRM_Utils_Hook` is that it must
support the lowest-common denominator of functionality.
<?php
// AUTO-GENERATED FILE -- Civix may overwrite any changes made to this file
/**
* (Delegated) Implements hook_civicrm_config().
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_config
*/
function _flexmailer_civix_civicrm_config(&$config = NULL) {
static $configured = FALSE;
if ($configured) {
return;
}
$configured = TRUE;
$template =& CRM_Core_Smarty::singleton();
$extRoot = dirname(__FILE__) . DIRECTORY_SEPARATOR;
$extDir = $extRoot . 'templates';
if ( is_array( $template->template_dir ) ) {
array_unshift( $template->template_dir, $extDir );
}
else {
$template->template_dir = array( $extDir, $template->template_dir );
}
$include_path = $extRoot . PATH_SEPARATOR . get_include_path( );
set_include_path($include_path);
}
/**
* (Delegated) Implements hook_civicrm_xmlMenu().
*
* @param $files array(string)
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_xmlMenu
*/
function _flexmailer_civix_civicrm_xmlMenu(&$files) {
foreach (_flexmailer_civix_glob(__DIR__ . '/xml/Menu/*.xml') as $file) {
$files[] = $file;
}
}
/**
* Implements hook_civicrm_install().
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_install
*/
function _flexmailer_civix_civicrm_install() {
_flexmailer_civix_civicrm_config();
if ($upgrader = _flexmailer_civix_upgrader()) {
$upgrader->onInstall();
}
}
/**
* Implements hook_civicrm_postInstall().
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_postInstall
*/
function _flexmailer_civix_civicrm_postInstall() {
_flexmailer_civix_civicrm_config();
if ($upgrader = _flexmailer_civix_upgrader()) {
if (is_callable(array($upgrader, 'onPostInstall'))) {
$upgrader->onPostInstall();
}
}
}
/**
* Implements hook_civicrm_uninstall().
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_uninstall
*/
function _flexmailer_civix_civicrm_uninstall() {
_flexmailer_civix_civicrm_config();
if ($upgrader = _flexmailer_civix_upgrader()) {
$upgrader->onUninstall();
}
}
/**
* (Delegated) Implements hook_civicrm_enable().
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_enable
*/
function _flexmailer_civix_civicrm_enable() {
_flexmailer_civix_civicrm_config();
if ($upgrader = _flexmailer_civix_upgrader()) {
if (is_callable(array($upgrader, 'onEnable'))) {
$upgrader->onEnable();
}
}
}
/**
* (Delegated) Implements hook_civicrm_disable().
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_disable
* @return mixed
*/
function _flexmailer_civix_civicrm_disable() {
_flexmailer_civix_civicrm_config();
if ($upgrader = _flexmailer_civix_upgrader()) {
if (is_callable(array($upgrader, 'onDisable'))) {
$upgrader->onDisable();
}
}
}
/**
* (Delegated) Implements hook_civicrm_upgrade().
*
* @param $op string, the type of operation being performed; 'check' or 'enqueue'
* @param $queue CRM_Queue_Queue, (for 'enqueue') the modifiable list of pending up upgrade tasks
*
* @return mixed based on op. for 'check', returns array(boolean) (TRUE if upgrades are pending)
* for 'enqueue', returns void
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_upgrade
*/
function _flexmailer_civix_civicrm_upgrade($op, CRM_Queue_Queue $queue = NULL) {
if ($upgrader = _flexmailer_civix_upgrader()) {
return $upgrader->onUpgrade($op, $queue);
}
}
/**
* @return CRM_Flexmailer_Upgrader
*/
function _flexmailer_civix_upgrader() {
if (!file_exists(__DIR__.'/CRM/Flexmailer/Upgrader.php')) {
return NULL;
}
else {
return CRM_Flexmailer_Upgrader_Base::instance();
}
}
/**
* Search directory tree for files which match a glob pattern
*
* Note: Dot-directories (like "..", ".git", or ".svn") will be ignored.
* Note: In Civi 4.3+, delegate to CRM_Utils_File::findFiles()
*
* @param $dir string, base dir
* @param $pattern string, glob pattern, eg "*.txt"
* @return array(string)
*/
function _flexmailer_civix_find_files($dir, $pattern) {
if (is_callable(array('CRM_Utils_File', 'findFiles'))) {
return CRM_Utils_File::findFiles($dir, $pattern);
}
$todos = array($dir);
$result = array();
while (!empty($todos)) {
$subdir = array_shift($todos);
foreach (_flexmailer_civix_glob("$subdir/$pattern") as $match) {
if (!is_dir($match)) {
$result[] = $match;
}
}
if ($dh = opendir($subdir)) {
while (FALSE !== ($entry = readdir($dh))) {
$path = $subdir . DIRECTORY_SEPARATOR . $entry;
if ($entry{0} == '.') {
} elseif (is_dir($path)) {
$todos[] = $path;
}
}
closedir($dh);
}
}
return $result;
}
/**
* (Delegated) Implements hook_civicrm_managed().
*
* Find any *.mgd.php files, merge their content, and return.
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_managed
*/
function _flexmailer_civix_civicrm_managed(&$entities) {
$mgdFiles = _flexmailer_civix_find_files(__DIR__, '*.mgd.php');
foreach ($mgdFiles as $file) {
$es = include $file;
foreach ($es as $e) {
if (empty($e['module'])) {
$e['module'] = 'org.civicrm.flexmailer';
}
$entities[] = $e;
}
}
}
/**
* (Delegated) Implements hook_civicrm_caseTypes().
*
* Find any and return any files matching "xml/case/*.xml"
*
* Note: This hook only runs in CiviCRM 4.4+.
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_caseTypes
*/
function _flexmailer_civix_civicrm_caseTypes(&$caseTypes) {
if (!is_dir(__DIR__ . '/xml/case')) {
return;
}
foreach (_flexmailer_civix_glob(__DIR__ . '/xml/case/*.xml') as $file) {
$name = preg_replace('/\.xml$/', '', basename($file));
if ($name != CRM_Case_XMLProcessor::mungeCaseType($name)) {
$errorMessage = sprintf("Case-type file name is malformed (%s vs %s)", $name, CRM_Case_XMLProcessor::mungeCaseType($name));
CRM_Core_Error::fatal($errorMessage);
// throw new CRM_Core_Exception($errorMessage);
}
$caseTypes[$name] = array(
'module' => 'org.civicrm.flexmailer',
'name' => $name,
'file' => $file,
);
}
}
/**
* (Delegated) Implements hook_civicrm_angularModules().
*
* Find any and return any files matching "ang/*.ang.php"
*
* Note: This hook only runs in CiviCRM 4.5+.
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules
*/
function _flexmailer_civix_civicrm_angularModules(&$angularModules) {
if (!is_dir(__DIR__ . '/ang')) {
return;
}
$files = _flexmailer_civix_glob(__DIR__ . '/ang/*.ang.php');
foreach ($files as $file) {
$name = preg_replace(':\.ang\.php$:', '', basename($file));
$module = include $file;
if (empty($module['ext'])) {
$module['ext'] = 'org.civicrm.flexmailer';
}
$angularModules[$name] = $module;
}
}
/**
* Glob wrapper which is guaranteed to return an array.
*
* The documentation for glob() says, "On some systems it is impossible to
* distinguish between empty match and an error." Anecdotally, the return
* result for an empty match is sometimes array() and sometimes FALSE.
* This wrapper provides consistency.
*
* @link http://php.net/glob
* @param string $pattern
* @return array, possibly empty
*/
function _flexmailer_civix_glob($pattern) {
$result = glob($pattern);
return is_array($result) ? $result : array();
}
/**
* Inserts a navigation menu item at a given place in the hierarchy.
*
* @param array $menu - menu hierarchy
* @param string $path - path where insertion should happen (ie. Administer/System Settings)
* @param array $item - menu you need to insert (parent/child attributes will be filled for you)
*/
function _flexmailer_civix_insert_navigation_menu(&$menu, $path, $item) {
// If we are done going down the path, insert menu
if (empty($path)) {
$menu[] = array(
'attributes' => array_merge(array(
'label' => CRM_Utils_Array::value('name', $item),
'active' => 1,
), $item),
);
return TRUE;
}
else {
// Find an recurse into the next level down
$found = false;
$path = explode('/', $path);
$first = array_shift($path);
foreach ($menu as $key => &$entry) {
if ($entry['attributes']['name'] == $first) {
if (!$entry['child']) $entry['child'] = array();
$found = _flexmailer_civix_insert_navigation_menu($entry['child'], implode('/', $path), $item, $key);
}
}
return $found;
}
}
/**
* (Delegated) Implements hook_civicrm_navigationMenu().
*/
function _flexmailer_civix_navigationMenu(&$nodes) {
if (!is_callable(array('CRM_Core_BAO_Navigation', 'fixNavigationMenu'))) {
_flexmailer_civix_fixNavigationMenu($nodes);
}
}
/**
* Given a navigation menu, generate navIDs for any items which are
* missing them.
*/
function _flexmailer_civix_fixNavigationMenu(&$nodes) {
$maxNavID = 1;
array_walk_recursive($nodes, function($item, $key) use (&$maxNavID) {
if ($key === 'navID') {
$maxNavID = max($maxNavID, $item);
}
});
_flexmailer_civix_fixNavigationMenuItems($nodes, $maxNavID, NULL);
}
function _flexmailer_civix_fixNavigationMenuItems(&$nodes, &$maxNavID, $parentID) {
$origKeys = array_keys($nodes);
foreach ($origKeys as $origKey) {
if (!isset($nodes[$origKey]['attributes']['parentID']) && $parentID !== NULL) {
$nodes[$origKey]['attributes']['parentID'] = $parentID;
}
// If no navID, then assign navID and fix key.
if (!isset($nodes[$origKey]['attributes']['navID'])) {
$newKey = ++$maxNavID;
$nodes[$origKey]['attributes']['navID'] = $newKey;
$nodes[$newKey] = $nodes[$origKey];
unset($nodes[$origKey]);
$origKey = $newKey;
}
if (isset($nodes[$origKey]['child']) && is_array($nodes[$origKey]['child'])) {
_flexmailer_civix_fixNavigationMenuItems($nodes[$origKey]['child'], $maxNavID, $nodes[$origKey]['attributes']['navID']);
}
}
}
/**
* (Delegated) Implements hook_civicrm_alterSettingsFolders().
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_alterSettingsFolders
*/
function _flexmailer_civix_civicrm_alterSettingsFolders(&$metaDataFolders = NULL) {
static $configured = FALSE;
if ($configured) {
return;
}
$configured = TRUE;
$settingsDir = __DIR__ . DIRECTORY_SEPARATOR . 'settings';
if(is_dir($settingsDir) && !in_array($settingsDir, $metaDataFolders)) {
$metaDataFolders[] = $settingsDir;
}
}
<?php
/**
* Civi v4.6 does not provide all the API's we would need to define
* FlexMailer in an extension, but you can patch core to simulate them.
* These define()s tell core to enable any such hacks (if available).
*/
define('CIVICRM_FLEXMAILER_HACK_DELIVER', '\Civi\FlexMailer\FlexMailer::createAndRun');
//define('CIVICRM_FLEXMAILER_HACK_SERVICES', '\Civi\FlexMailer\Services::registerServices');
//define('CIVICRM_FLEXMAILER_HACK_LISTENERS', '\Civi\FlexMailer\Services::registerListeners');
require_once 'flexmailer.civix.php';
/**
* Define an autoloader for FlexMailer.
*
* FlexMailer uses the namespace 'Civi\FlexMailer', but the
* autoloader in Civi v4.6 doesn't support this, so we provide
* our own autoloader.
*
* TODO: Whenever v4.6 dies, remove this file and define the
* autoloader in info.xml
*
* @link http://www.php-fig.org/psr/psr-4/examples/
*/
function _flexmailer_autoload($class) {
$prefix = 'Civi\\FlexMailer\\';
$base_dir = __DIR__ . '/src/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relative_class = substr($class, $len);
$file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
if (file_exists($file)) {
require $file;
}
}
spl_autoload_register('_flexmailer