<?php
|
|
namespace JVBase\integrations\services;
|
use JVBase\integrations\Admin;
|
use JVBase\Instance;
|
use JVBase\integrations\Integrations;
|
use JVBase\integrations\Requests;
|
use JVBase\meta\Meta;
|
use WP_Error;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* PostMark Email Service Integration
|
*
|
* Provides integration with PostMark for transactional email delivery
|
* Hooks into WordPress's wp_mail() function for seamless integration
|
*/
|
class PostMark extends Integrations
|
{
|
use Admin, Instance, Requests;
|
|
protected function __construct()
|
{
|
$this->service_name = 'postmark';
|
$this->title = 'PostMark';
|
$this->icon = 'envelope-simple';
|
$this->apiVersion = 'v1';
|
$this->baseUrl = 'https://api.postmarkapp.com';
|
|
$this->defineFields();
|
$this->defineInstructions();
|
|
parent::__construct();
|
}
|
|
protected function defineFields():void
|
{
|
$token = $this->addField('service_token', 'Server API Token', 'text', 'manage_options');
|
$token->setSubType('password');
|
$token->makeSetupField();
|
$this->updateField('service_token', $token);
|
|
$stream = $this->addField('message_stream', 'Message Stream ID', 'text', 'manage_options');
|
$stream->setDefault('outbound');
|
$stream->setHint('Message stream to use (default: outbound)');
|
$stream->makeAdditionalField();
|
$this->updateField('message_stream', $stream);
|
|
$from = $this->addField('from_email', 'From Email', 'email', 'manage_options');
|
$from->setPlaceholder('noreply@yourdomain.com');
|
$from->setDefault(get_option('admin_email'));
|
$from->makeAdditionalField();
|
$this->updateField('from_email', $from);
|
|
$name = $this->addField('from_name', 'From Name', 'text', 'manage_options');
|
$name->setDefault(get_bloginfo('name'));
|
$name->setHint('Default sender name');
|
$name->makeAdditionalField();
|
$this->updateField('from_name', $from);
|
|
$opens = $this->addField('track_opens', 'Track Opens', 'select', 'manage_options');
|
$opens->setOptions([
|
'false' => 'Disabled',
|
'true' => 'Enabled',
|
]);
|
$opens->setDefault('false');
|
$opens->setHint('Track when emails are opened');
|
$opens->makeAdvancedField();
|
$this->updateField('track_opens', $opens);
|
|
$links = $this->addField('track_links', 'Track Links', 'select', 'manage_options');
|
$links->setOptions([
|
'None' => 'Disabled',
|
'HtmlOnly' => 'HTML Only',
|
'HtmlAndText' => 'HTML and Text',
|
]);
|
$links->setDefault('None');
|
$links->setHint('Track link clicks in emails');
|
$links->makeAdvancedField();
|
$this->updateField('track_links', $links);
|
}
|
protected function defineInstructions():void
|
{
|
|
}
|
protected function defineActions():void
|
{
|
|
}
|
protected function initialize(): void
|
{
|
// Replace wp_mail with PostMark
|
add_filter('wp_mail', [$this, 'wp_mail'], 10, 1);
|
|
// Optional: Hook for logging sent emails
|
add_action('postmark_email_sent', [$this, 'logSentEmail'], 10, 2);
|
add_action('postmark_email_failed', [$this, 'logFailedEmail'], 10, 2);
|
}
|
|
/**
|
* Fired on the wp_mail filter
|
* @param array $args Email arguments from wp_mail, including 'to', 'subject', 'message', 'headers', 'attachments'
|
* @return array
|
*/
|
public function wp_mail(array $args):array
|
{
|
// Extract email components
|
$to = $args['to'];
|
$subject = $args['subject'];
|
$message = $args['message'];
|
$headers = $args['headers']??'';
|
$attachments = $args['attachments'] ?? [];
|
|
$headers = $this->parseMailHeaders($headers);
|
|
$is_html = $headers['content-type'] === 'text/html' || str_contains($message, '<html') || str_contains($message, '<body');
|
|
$response = $this->sendEmail(
|
$to,
|
$subject,
|
$message,
|
$is_html,
|
$headers,
|
$attachments
|
);
|
|
if ($response['success']) {
|
error_log('================================ Email sent! ================================');
|
// Prevent default wp_mail from sending
|
add_filter('pre_wp_mail', '__return_true');
|
do_action('postmark_email_sent', $args, $response);
|
} else {
|
error_log('=-======================[POSTMARK]Something went wrong... ================================');
|
// Log failure but allow fallback to default mail
|
do_action('postmark_email_failed', $args, $response);
|
}
|
return $args;
|
}
|
|
protected function parseMailHeaders(string|array $headers):array
|
{
|
$parsed = [
|
'content-type' => 'text/plain',
|
'from' => null,
|
'from-name' => null,
|
'reply-top' => null,
|
'cc' => [],
|
'bcc' => [],
|
'x-headers' => [],
|
];
|
|
if (empty($headers)) {
|
return $parsed;
|
}
|
|
if (is_string($headers)) {
|
$headers = explode("\n", $headers);
|
}
|
|
foreach ($headers as $line) {
|
$this->parseHeaderLine($line, $parsed);
|
}
|
|
return $parsed;
|
}
|
protected function parseHeaderLine(string $line, array &$parsed):void
|
{
|
if (empty($line)) {
|
return;
|
}
|
$parts = explode(':', $line, 2);
|
if (count($parts) !== 2) {
|
return;
|
}
|
|
$name = strtolower(trim($parts[0]));
|
$value = trim($parts[1]);
|
switch ($name) {
|
case 'content-type':
|
if (str_contains($value, 'text/html')) {
|
$parsed['content-type'] = $value;
|
}
|
break;
|
|
case 'from':
|
// Parse "Name <email>" format
|
if (preg_match('/^(.+?)\s*<(.+)>$/', $value, $matches)) {
|
$parsed['from-name'] = trim($matches[1], '"\'');
|
$parsed['from'] = $matches[2];
|
} else {
|
$parsed['from'] = $value;
|
}
|
break;
|
|
case 'cc':
|
case 'bcc':
|
$parsed[$name][] = $value;
|
break;
|
|
default:
|
//TODO: Verify we need this. This feels extra clunky
|
//Why not just $parsed['x-headers'][$name] = $value?
|
if (str_contains($name, 'x-')) {
|
$parsed['x-headers'][] = [
|
'Name' => $name,
|
'Value' => $value
|
];
|
} else {
|
$parsed[$name] = $value;
|
}
|
}
|
}
|
|
protected function getRequestHeaders(): array
|
{
|
return [
|
'Accept' => 'application/json',
|
'Content-Type' => 'application/json',
|
'X-Postmark-Server-Token' => $this->getServerToken()
|
];
|
}
|
|
protected function handleResponse(WP_Error|array $response, string $method, string $endpoint): array
|
{
|
if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') {
|
return $this->response(false, 'Rate limit exceeded');
|
}
|
$endpoint = str_replace($this->apiBase, '', $endpoint);
|
$checkErrors = $this->checkErrors($response);
|
if (!empty($checkErrors)) {
|
return $checkErrors;
|
}
|
|
return match ($endpoint) {
|
'/email' => $this->handleEmailResponse($response, $method),
|
'/email/bulk' => $this->handleBulkEmailResponse($response, $method),
|
'/email/batch' => $this->handleBatchEmailResponse($response, $method),
|
default => $this->response(false, 'Unhandled Response for '. $endpoint . ' endpoint, with method: '. $method),
|
};
|
}
|
|
protected function checkErrors(WP_Error|array $response):array|false
|
{
|
$error = $response['ErrorCode'] ?? false;
|
if ($error) {
|
switch ($error) {
|
case '0':
|
//Do nothing, no error found
|
return false;
|
//Bad or missing API Token
|
case '10':
|
//Multiple errors
|
case '11':
|
//Resource Not found
|
case '12':
|
//Invalid Pagination Key
|
case '13':
|
//Maintenance; PostMark API down for a sec
|
case '100':
|
//Maybe schedule a retry?
|
//Invalid Email Request
|
case '300':
|
//Sender Signature not found
|
case '400':
|
//Sender signature not confirmed
|
case '401':
|
//Invalid JSON
|
case '402':
|
//Invalid request field(s) - {FIELD_NAME}
|
case '403':
|
//Not allowed to send (no more credits in account)
|
case '405':
|
//Inactive recipient. Recipient hard bounces, or filed spam complaint
|
case '406':
|
//JSON required
|
case '409':
|
//Too many requests (500 max)
|
case '410':
|
//Maybe schedule a retry?
|
//Forbidden Attachment type
|
case '411':
|
//Account is pending
|
case '412':
|
//Account may not exist
|
case '413':
|
//Some errors in bulk request
|
case '422':
|
//Invalid query string parameter
|
case '500':
|
default:
|
$this->logError(
|
'handleResponse',
|
'Something went wrong: '.$response['Message']??'',
|
$response
|
);
|
return $this->response(false, 'Something went wrong: '.$response['Message']??'');
|
}
|
}
|
return false;
|
}
|
|
/**********************************************************
|
* UTILITY
|
**********************************************************/
|
protected function getServerToken():string|false
|
{
|
return $this->loadCredentials()['server_token']??false;
|
}
|
protected function getMessageStream():string
|
{
|
return $this->loadCredentials()['message_stream']??'outbound';
|
}
|
protected function getFromEmail():string
|
{
|
return $this->loadCredentials()['from_email']??get_option('admin_email');
|
}
|
protected function getFromName():string
|
{
|
return $this->loadCredentials()['from_name']??get_bloginfo('name');
|
}
|
protected function getTrackOpens():string
|
{
|
return $this->loadCredentials()['track_opens']??'false';
|
}
|
protected function getTrackLinks():string
|
{
|
return $this->loadCredentials()['track_links']??'None';
|
}
|
|
/****************************************************************************
|
* HELPERS
|
****************************************************************************/
|
protected function textFromHtml(string $html):string
|
{
|
// Remove HTML tags
|
$text = strip_tags($html);
|
|
// Convert entities
|
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
// Clean up whitespace
|
$text = preg_replace('/\s+/', ' ', $text);
|
return trim($text);
|
}
|
|
/**
|
* @param array $attachments Either an array of file paths, or attachment IDs
|
* @return array
|
*/
|
protected function processAttachments(array $attachments):array
|
{
|
$processed = [];
|
foreach ($attachments as $file) {
|
if (is_int($file)) {
|
$file = get_attached_file($file);
|
}
|
if (!$file || !file_exists($file)) {
|
continue;
|
}
|
$processed[] = [
|
'Name' => basename($file),
|
'Content' => base64_encode(file_get_contents($file)),
|
'ContentType' => mime_content_type($file) ?? 'application/octet-stream'
|
];
|
}
|
|
return $processed;
|
}
|
|
protected function determineEmailTag(string $subject, array $headers):string
|
{
|
if (array_key_exists('email-tag', $headers)) {
|
return $headers['email-tag'];
|
}
|
$tmp = strtolower($subject);
|
if (str_contains($tmp, 'password')) {
|
return 'password-reset';
|
}
|
|
if (str_contains($tmp, 'welcome') || str_contains($tmp, 'registr') || str_contains($tmp, 'register')) {
|
return 'registration';
|
}
|
|
if (str_contains($tmp, 'account')) {
|
return 'account';
|
}
|
|
if (str_contains($tmp, 'order') || str_contains($tmp, 'receipt')) {
|
return 'transaction';
|
}
|
|
return 'general';
|
}
|
|
/****************************************************************************
|
* MAILERS
|
****************************************************************************/
|
public function sendEmail(string $to, string $subject, string $message, bool $is_html = true, array $headers = [], array $attachments = []):array
|
{
|
if (!$this->isSetUp()) {
|
return $this->response(false, 'Not set up');
|
}
|
|
$payload = $this->buildPayload($to, $subject, $message, $is_html, $headers, $attachments);
|
|
return $this->postRequest(
|
sprintf('%s/email', $this->apiBase),
|
$payload
|
);
|
}
|
protected function handleEmailResponse(WP_Error|array $response, string $method):array
|
{
|
return $this->response(true, 'Successfully sent request to PostMark', $response);
|
}
|
public function buildPayload(string|array $to, string $subject, string $message, bool $is_html = true, array $headers = [], array $attachments = []):array
|
{
|
$to = is_array($to) ? implode(',',$to) : $to;
|
|
$payload = [
|
'From' => sprintf(
|
'%s <%s>',
|
$headers['from-name'] ?? $this->getFromName(),
|
$headers['from'] ?? $this->getFromEmail()
|
),
|
'To' => $to,
|
'Subject' => sanitize_text_field($subject),
|
'TrackOpens' => $this->getTrackOpens() === 'true',
|
'TrackLinks' => $this->getTrackLinks(),
|
'MessageStream' => $this->getMessageStream()
|
];
|
|
if ($is_html) {
|
$payload['HtmlBody'] = $message;
|
$payload['TextBody'] = $this->textFromHtml($message);
|
} else {
|
$payload['TextBody'] = $message;
|
}
|
|
if (!empty($headers['cc'])) {
|
$payload['Cc'] = is_array($headers['cc']) ?
|
implode(',',$headers['cc']) :
|
$headers['cc'];
|
}
|
|
if (!empty($headers['bcc'])) {
|
$payload['Bcc'] = is_array($headers['bcc']) ?
|
implode(',',$headers['bcc']) :
|
$headers['bcc'];
|
}
|
|
if (!empty($headers['reply-to'])) {
|
$payload['ReplyTo'] = $headers['reply-to'];
|
} else {
|
$payload['ReplyTo'] = $this->getFromEmail();
|
}
|
|
if (!empty($attachments)) {
|
$payload['Attachments'] = $this->processAttachments($attachments);
|
}
|
|
if (!empty($headers['x-headers'])) {
|
$payload['Headers'] = $headers['x-headers'];
|
}
|
|
$payload['Tag'] = $this->determineEmailTag($subject, $headers);
|
|
return $payload;
|
}
|
|
/**
|
* Sends the same message to multiple emails. Can use templates
|
* @param array<int> $userIDs
|
* @param string $subject
|
* @param string $message
|
* @param bool $is_html
|
* @param array $headers
|
* @param array $attachments
|
* @return array
|
*/
|
public function sendMessageToMany(array $userIDs, string $subject, string $message, bool $is_html = true, array $headers = [], array $attachments = []):array
|
{
|
if (!$this->isSetUp()) {
|
return $this->response(false, 'Not set up');
|
}
|
|
$tags = [];
|
$pattern = '/\{\{(.*?)\}\}/';
|
|
if (str_contains($subject, '{{')){
|
preg_match_all($pattern, $subject, $tags);
|
}
|
if (str_contains($message, '{{')) {
|
preg_match_all($pattern, $message, $tags);
|
}
|
|
$tags = array_unique($tags);
|
foreach ($tags as $tag) {
|
$t = implode(
|
array_map(function($word) {
|
return ucfirst($word);
|
}, explode('_', $tag)
|
));
|
str_replace('{{'.$tag.'}}', '{{'.$t.'}}', $subject);
|
str_replace('{{'.$tag.'}}', '{{'.$t.'}}', $message);
|
}
|
|
|
$to = array_map(function ($userID) use ($tags, $subject, $message) {
|
$meta = Meta::forUser($userID);
|
$email = $meta->get('user_email');
|
$out = [];
|
if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
$out['To'] = $email;
|
}
|
|
if (empty($out)) {
|
$this->logError('sendMessageToMany', 'User does not have an email address',['user' => $userID]);
|
return [];
|
}
|
|
$tmp = [];
|
foreach ($tags as $tag) {
|
$v = $meta->get($tag);
|
|
if (!empty($v)) {
|
$tag = implode(
|
array_map(function($word) {
|
return ucfirst($word);
|
}, explode('_', $tag)
|
));
|
$tmp[$tag] = $v;
|
}
|
}
|
if (!empty($tmp)) {
|
$out['TemplateModel'] = $tmp;
|
}
|
return $out;
|
}, $userIDs);
|
$to = array_filter($to, function($result) {
|
return array_key_exists('To', $result) && !empty($result['To']);
|
});
|
|
if (empty($to)) {
|
$this->logError('sendMessageToMany', 'No users set',['users' => $userIDs]);
|
}
|
|
//Onward!
|
$payload = [
|
'From' => sprintf(
|
'%s <%s>',
|
$headers['from-name'] ?? $this->getFromName(),
|
$headers['from'] ?? $this->getFromEmail()
|
),
|
'Subject' => sanitize_text_field($subject),
|
'TrackOpens' => $this->getTrackOpens() === 'true',
|
'TrackLinks' => $this->getTrackLinks(),
|
'MessageStream' => 'broadcast'
|
];
|
|
if ($is_html) {
|
$payload['HtmlBody'] = $message;
|
$payload['TextBody'] = $this->textFromHtml($message);
|
} else {
|
$payload['TextBody'] = $message;
|
}
|
|
if (!empty($headers['reply-to'])) {
|
$payload['ReplyTo'] = $headers['reply-to'];
|
} else {
|
$payload['ReplyTo'] = $this->getFromEmail();
|
}
|
if (!empty($attachments)) {
|
$payload['Attachments'] = $this->processAttachments($attachments);
|
}
|
if (!empty($headers['x-headers'])) {
|
$payload['Headers'] = $headers['x-headers'];
|
}
|
$payload['Tag'] = $this->determineEmailTag($subject, $headers);
|
|
$payload['Messages'] = $to;
|
|
return $this->postRequest(
|
sprintf('%s/email/bulk', $this->apiBase),
|
$payload
|
);
|
}
|
protected function handleBulkEmailResponse(WP_Error|array $response, string $method):array
|
{
|
return $this->response($response['Status']??'' === 'Accepted', 'Successfully sent request to Postmark', $response);
|
}
|
|
/**
|
* @param array $emails An array of results from buildPayload, ready to batch send to Postmark. Limit of 500 at a time
|
* @return array
|
*/
|
public function sendMany(array $emails):array
|
{
|
if (!$this->isSetUp()) {
|
return $this->response(false, 'Not set up');
|
}
|
$emails = array_filter($emails, function ($email) {
|
if (!array_key_exists('To', $email) || !filter_var($email['To'], FILTER_VALIDATE_EMAIL)) {
|
$this->logError('sendMany', 'Email does not have a To email address', $email);
|
return false;
|
}
|
//This shouldn't happen, but if it does, we'll just remove it for now.
|
if (!array_key_exists('From', $email)) {
|
$this->logError('sendMany', 'Email does not have a From email address', $email);
|
return false;
|
}
|
|
return true;
|
});
|
|
if (empty($emails)) {
|
$this->logError('sendMany', 'No emails have been sent', $emails);
|
return $this->response(false, 'No emails have been sent', $emails);
|
}
|
|
return $this->postRequest(
|
sprintf(
|
'%s/email/batch',
|
$this->apiBase
|
),
|
$emails
|
);
|
}
|
protected function handleBatchEmailResponse(WP_Error|array $response, string $method):array
|
{
|
$main = $response[0];
|
$checkErrors = $this->checkErrors($main);
|
if (!empty($checkErrors)) {
|
return $checkErrors;
|
}
|
if (count($response) > 1) {
|
$errors = $response;
|
unset($errors[0]);
|
foreach ($errors as $error) {
|
$this->checkErrors($error);
|
}
|
}
|
return $this->response($main['Message']??'' === 'OK', 'Successfully sent request to Postmark', $response);
|
}
|
}
|