Jake Vanderwerf
2026-08-25 3d25ad7de4fdbb3eba2cde2a62422e6be5d79a0a
inc/integrations/services/PostMark.php
@@ -1,7 +1,13 @@
<?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;
}
@@ -14,123 +20,69 @@
 */
class PostMark extends Integrations
{
   protected ?string $server_token = null;
   protected string $message_stream = 'outbound';
   protected string $from_email;
   protected string $from_name;
   protected bool $track_open;
   protected bool $track_links;
   protected ?string $lastMessageId = null;
   use Admin, Instance, Requests;
   protected static array $instances = [];
   public static function getInstance(?int $userID = null):self
   {
      $key = is_null($userID) ? 'base' : $userID;
      if (!array_key_exists($key, self::$instances)) {
         self::$instances[$key] = new static($userID);
      }
      return self::$instances[$key];
   }
   /**
    * Constructor
    */
   protected function __construct(?int $userID = null)
   protected function __construct()
   {
      $this->service_name = 'postmark';
      $this->title = 'PostMark';
      $this->icon = 'envelope-simple';
      $this->cacheName = ($userID)? 'gmb_'.$userID : 'postmark';
      // PostMark API configuration
      $this->apiBase = 'https://api.postmarkapp.com';
      $this->apiVersion = 'v1';
      $this->baseUrl = 'https://api.postmarkapp.com';
      // Define available endpoints
      $this->apiEndpoints = [
         'email',
         'email/batch',
         'email/withTemplate',
         'bounces',
         'deliverystats',
         'messages/outbound',
         'messages/outbound/[^/]+',
         'messages/outbound/opens',
         'stats/outbound',
         'stats/outbound/opens',
         'stats/outbound/bounces',
         'templates',
         'templates/[^/]+',
         'server'
      ];
      $this->defineFields();
      $this->defineInstructions();
      // Required credentials
      $this->fields = [
         'server_token' => [
            'label' => 'Server API Token',
            'type' => 'text',
            'subtype'=> 'password',
            'placeholder' => 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
            'hint' => 'Your PostMark Server API Token'
         ],
         'message_stream' => [
            'label' => 'Message Stream ID',
            'type' => 'text',
            'placeholder' => 'outbound',
            'default' => 'outbound',
            'description' => 'Message stream to use (default: outbound)'
         ],
         'from_email' => [
            'label' => 'From Email',
            'type' => 'email',
            'placeholder' => 'noreply@yourdomain.com',
            'description' => 'Default sender email address (must be verified in PostMark)'
         ],
         'from_name' => [
            'label' => 'From Name',
            'type' => 'text',
            'placeholder' => get_bloginfo('name'),
            'default' => get_bloginfo('name'),
            'description' => 'Default sender name'
         ],
         'track_opens' => [
            'label' => 'Track Opens',
            'type' => 'select',
            'options' => [
               'false' => 'Disabled',
               'true' => 'Enabled'
            ],
            'default' => 'false',
            'description' => 'Track when emails are opened'
         ],
         'track_links' => [
            'label' => 'Track Links',
            'type' => 'select',
            'options' => [
      parent::__construct();
      $this->defineActions();
   }
      protected function defineFields():void
      {
         $token = $this->addField('service_token', 'Server API Token', 'text', 'manage_options');
            $token->setSubType('password');
         $stream = $this->addField('message_stream', 'Message Stream ID', 'text', 'manage_options');
            $stream->setDefault('outbound');
            $stream->setHint('Message stream to use (default: outbound)');
         $from = $this->addField('from_email', 'From Email', 'email', 'manage_options');
            $from->setPlaceholder('noreply@yourdomain.com');
            $from->setDefault(get_option('admin_email'));
         $name = $this->addField('from_name', 'From Name', 'text', 'manage_options');
            $name->setDefault(get_bloginfo('name'));
            $name->setHint('Default sender name');
         $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');
         $links = $this->addField('track_links', 'Track Links', 'select', 'manage_options');
            $links->setOptions([
               'None' => 'Disabled',
               'HtmlOnly' => 'HTML Only',
               'HtmlAndText' => 'HTML and Text',
            ],
            'default' => 'None',
            'description' => 'Track link clicks in emails'
         ]
      ];
      parent::__construct();
      // Hook into WordPress mail system if integration is healthy
      if ($this->is_healthy && $this->isSetUp()) {
         $this->initializeMailHooks();
            ]);
            $links->setDefault('None');
            $links->setHint('Track link clicks in emails');
      }
   }
      protected function defineInstructions():void
      {
   /**
    * Initialize WordPress mail hooks
    */
   protected function initializeMailHooks(): void
      }
      protected function defineActions():void
      {
      }
   protected function initialize(): void
   {
      // Replace wp_mail with PostMark
      add_filter('wp_mail', [$this, 'sendViaPostMark'], 10, 1);
      add_filter('wp_mail', [$this, 'wp_mail'], 10, 1);
      // Optional: Hook for logging sent emails
      add_action('postmark_email_sent', [$this, 'logSentEmail'], 10, 2);
@@ -138,289 +90,239 @@
   }
   /**
    * Send email via PostMark API
    * Hooks into wp_mail filter
    *
    * @param array $args Email arguments from wp_mail
    * @return array Modified args (or original if sending via PostMark)
    * Fired on the wp_mail filter
    * @param array $args Email arguments from wp_mail, including 'to', 'subject', 'message', 'headers', 'attachments'
    * @return array
    */
   public function sendViaPostMark(array $args): array
   {
      // Extract email components
      $to = $args['to'];
      $subject = $args['subject'];
      $message = $args['message'];
      $headers = $args['headers'] ?? '';
      $attachments = $args['attachments'] ?? [];
      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'] ?? [];
      // Parse headers
      $parsed_headers = $this->parseMailHeaders($headers);
         $headers = $this->parseMailHeaders($headers);
      // Determine content type
      $is_html = $parsed_headers['content-type'] === 'text/html' ||
         strpos($message, '<html') !== false ||
         strpos($message, '<body') !== false;
         $is_html = $headers['content-type'] === 'text/html' || str_contains($message, '<html') || str_contains($message, '<body');
      // Build PostMark payload
      $payload = $this->buildEmailPayload(
         $to,
         $subject,
         $message,
         $is_html,
         $parsed_headers,
         $attachments
      );
         $response = $this->sendEmail(
            $to,
            $subject,
            $message,
            $is_html,
            $headers,
            $attachments
         );
      // Send via PostMark
      $result = $this->sendEmail($payload);
      if ($result === true) {
         error_log('================================ Email sent! ================================');
         // Prevent default wp_mail from sending
         add_filter('pre_wp_mail', '__return_true');
         do_action('postmark_email_sent', $args, $payload);
      } else {
         error_log('=-======================[POSTMARK]Something went wrong... ================================');
         // Log failure but allow fallback to default mail
         do_action('postmark_email_failed', $args, $result);
         // Optionally fall back to default wp_mail
         if ($this->shouldFallback()) {
            return $args;
         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;
      }
      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'    => [],
            ];
   /**
    * Build email payload for PostMark API
    */
   protected function buildEmailPayload(
      $to,
      string $subject,
      string $message,
      bool $is_html,
      array $headers,
      array $attachments
   ): array {
      $credentials = $this->getCredentials();
            if (empty($headers)) {
               return $parsed;
            }
      // Handle multiple recipients
      $to_addresses = is_array($to) ? implode(',', $to) : $to;
            if (is_string($headers)) {
               $headers = explode("\n", $headers);
            }
      // Build base payload
      $payload = [
         'From' => sprintf(
            '%s <%s>',
            $headers['from-name'] ?? $credentials['from_name'],
            $headers['from'] ?? $credentials['from_email']
         ),
         'To' => $to_addresses,
         'Subject' => $subject,
         'TrackOpens' => $credentials['track_opens'] === 'true',
         'TrackLinks' => $credentials['track_links'],
         'MessageStream' => $credentials['message_stream'] ?? 'outbound'
      ];
            foreach ($headers as $line) {
               $this->parseHeaderLine($line, $parsed);
            }
      // Set content based on type
      if ($is_html) {
         $payload['HtmlBody'] = $message;
         // Generate text version from HTML
         $payload['TextBody'] = $this->generateTextFromHtml($message);
      } else {
         $payload['TextBody'] = $message;
      }
            return $parsed;
         }
            protected function parseHeaderLine(string $line, array &$parsed):void
            {
               if (empty($line)) {
                  return;
               }
               $parts = explode(':', $line, 2);
               if (count($parts) !== 2) {
                  return;
               }
      // Add CC/BCC if present
      if (!empty($headers['cc'])) {
         $payload['Cc'] = is_array($headers['cc']) ?
            implode(',', $headers['cc']) : $headers['cc'];
      }
               $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;
      if (!empty($headers['bcc'])) {
         $payload['Bcc'] = is_array($headers['bcc']) ?
            implode(',', $headers['bcc']) : $headers['bcc'];
      }
                  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;
      // Add Reply-To if present
      if (!empty($headers['reply-to'])) {
         $payload['ReplyTo'] = $headers['reply-to'];
      }
                  case 'cc':
                  case 'bcc':
                     $parsed[$name][] = $value;
                     break;
      // Handle attachments
      if (!empty($attachments)) {
         $payload['Attachments'] = $this->processAttachments($attachments);
      }
                  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;
                     }
               }
            }
      // Add custom headers if needed
      if (!empty($headers['x-headers'])) {
         $payload['Headers'] = $headers['x-headers'];
      }
      // Add tags for tracking (optional)
      $payload['Tag'] = $this->determineEmailTag($subject, $headers);
      return $payload;
   }
   /**
    * Send email via PostMark API
    */
   protected function sendEmail(array $payload): bool|WP_Error
   protected function getRequestHeaders(): array
   {
      if (!$this->isSetUp()) {
      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;
      }
      try {
         $response = $this->postRequest('email', $payload);
         error_log('================================ POSTMARK RESPONSE: ================================'.print_r($response, true));
         if (is_wp_error($response)) {
            return $response;
         }
         // Store message ID for tracking
         if (!empty($response['MessageID'])) {
            $this->lastMessageId = $response['MessageID'];
            // Cache for potential status checking
            $this->cache->set(
               'postmark_message_' . $response['MessageID'],
               $payload,
               3600 // 1 hour
            );
         }
         return true;
      } catch (\Exception $e) {
         $this->logError('[POSTMARK]', ['method' => 'sendEmail', 'error' => $e->getMessage()]);
         return new WP_Error(
            'postmark_send_failed',
            $e->getMessage()
         );
      }
   }
   /**
    * Parse mail headers from various formats
    */
   protected function parseMailHeaders($headers): array
   /**********************************************************
    * UTILITY
   **********************************************************/
   protected function getServerToken():string|false
   {
      $parsed = [
         'content-type' => 'text/plain',
         'from' => null,
         'from-name' => null,
         'reply-to' => null,
         'cc' => [],
         'bcc' => [],
         'x-headers' => []
      ];
      if (empty($headers)) {
         return $parsed;
      }
      // Handle array format
      if (is_array($headers)) {
         foreach ($headers as $header) {
            $this->parseHeaderLine($header, $parsed);
         }
      } else {
         // Handle string format (multiple lines)
         $lines = explode("\n", $headers);
         foreach ($lines as $line) {
            $this->parseHeaderLine($line, $parsed);
         }
      }
      return $parsed;
      return $this->loadCredentials()['server_token']??false;
   }
   /**
    * Parse individual header line
    */
   protected function parseHeaderLine(string $line, array &$parsed): void
   protected function getMessageStream():string
   {
      if (empty($line)) {
         return;
      }
      // Split header name and value
      $parts = explode(':', $line, 2);
      if (count($parts) !== 2) {
         return;
      }
      $name = strtolower(trim($parts[0]));
      $value = trim($parts[1]);
      switch ($name) {
         case 'content-type':
            if (strpos($value, 'text/html') !== false) {
               $parsed['content-type'] = 'text/html';
            }
            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 'reply-to':
            $parsed['reply-to'] = $value;
            break;
         case 'cc':
            $parsed['cc'][] = $value;
            break;
         case 'bcc':
            $parsed['bcc'][] = $value;
            break;
         default:
            // Store custom headers
            if (strpos($name, 'x-') === 0) {
               $parsed['x-headers'][] = [
                  'Name' => $parts[0], // Original case
                  'Value' => $value
               ];
            }
      }
      return $this->loadCredentials()['message_stream']??'outbound';
   }
   /**
    * Process attachments for PostMark
    */
   protected function processAttachments(array $attachments): array
   protected function getFromEmail():string
   {
      $processed = [];
      foreach ($attachments as $file) {
         if (!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;
      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';
   }
   /**
    * Generate plain text from HTML content
    */
   protected function generateTextFromHtml(string $html): string
   /****************************************************************************
    * HELPERS
   ****************************************************************************/
   protected function textFromHtml(string $html):string
   {
      // Remove HTML tags
      $text = strip_tags($html);
@@ -434,228 +336,301 @@
   }
   /**
    * Determine email tag based on content
    * @param array $attachments Either an array of file paths, or attachment IDs
    * @return array
    */
   protected function determineEmailTag(string $subject, array $headers): string
   protected function processAttachments(array $attachments):array
   {
      // Password reset emails
      if (stripos($subject, 'password') !== false && stripos($subject, 'reset') !== false) {
      $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';
      }
      // New user registration
      if (stripos($subject, 'welcome') !== false || stripos($subject, 'registration') !== false) {
      if (str_contains($tmp, 'welcome') || str_contains($tmp, 'registr') || str_contains($tmp, 'register')) {
         return 'registration';
      }
      // Account notifications
      if (stripos($subject, 'account') !== false) {
      if (str_contains($tmp, 'account')) {
         return 'account';
      }
      // Order/transaction emails
      if (stripos($subject, 'order') !== false || stripos($subject, 'receipt') !== false) {
      if (str_contains($tmp, 'order') || str_contains($tmp, 'receipt')) {
         return 'transaction';
      }
      // Default
      return 'general';
   }
   /**
    * Check if should fallback to default mail
    */
   protected function shouldFallback(): bool
   /****************************************************************************
    * MAILERS
   ****************************************************************************/
   public function sendEmail(string $to, string $subject, string $message, bool $is_html = true, array $headers = [], array $attachments = []):array
   {
      // Don't fallback if we've had too many errors
      if ($this->error_stats['consecutive_errors'] >= 3) {
         return false;
      if (!$this->isSetUp()) {
         return $this->response(false, 'Not set up');
      }
      // Check if fallback is enabled in settings
      $credentials = $this->getCredentials();
      return ($credentials['enable_fallback'] ?? 'true') === 'true';
      $payload = $this->buildPayload($to, $subject, $message, $is_html, $headers, $attachments);
      return $this->postRequest(
         sprintf('%s/email', $this->apiBase),
         $payload
      );
   }
   /**
    * Override makeRequest to add PostMark headers
    */
   protected function getRequestHeaders(): array {
      $credentials = $this->getCredentials();
      // Add PostMark specific headers
      return [
         'Accept' => 'application/json',
         'Content-Type' => 'application/json',
         'X-Postmark-Server-Token' => $credentials['server_token'] ?? ''
      ];
   }
   /**
    * Get email statistics
    */
   public function getEmailStats(int $days = 30): array
   {
      $cache_key = 'postmark_stats_' . $days;
      $cached = $this->cache->get($cache_key);
      if ($cached !== false) {
         return $cached;
      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;
      try {
         $stats = $this->getRequest('stats/outbound', [
            'fromdate' => date('Y-m-d', strtotime("-{$days} days")),
            'todate' => date('Y-m-d')
         ]);
         $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_wp_error($stats)) {
            $this->cache->set($cache_key, $stats, 3600); // Cache for 1 hour
            return $stats;
         if ($is_html) {
            $payload['HtmlBody'] = $message;
            $payload['TextBody'] = $this->textFromHtml($message);
         } else {
            $payload['TextBody'] = $message;
         }
      } catch (\Exception $e) {
         $this->logError('stats/outbound', ['error' => $e->getMessage()]);
         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;
      }
      return [];
   }
   /**
    * Get bounce information
    * 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 getBounces(int $count = 25, int $offset = 0): array
   public function sendMessageToMany(array $userIDs, string $subject, string $message, bool $is_html = true, array $headers = [], array $attachments = []):array
   {
      try {
         return $this->getRequest('bounces', [
            'count' => $count,
            'offset' => $offset
         ]);
      } catch (\Exception $e) {
         $this->logError('[POSTMARK]', ['method' => 'getBounces', 'error' => $e->getMessage()]);
         return [];
      }
   }
   /**
    * Log sent emails for debugging/tracking
    */
   public function logSentEmail(array $args, array $payload): void
   {
      if (!defined('WP_DEBUG') || !WP_DEBUG) {
         return;
      if (!$this->isSetUp()) {
         return $this->response(false, 'Not set up');
      }
      $this->logDebug('Email sent via PostMark', [
         'to' => $args['to'],
         'subject' => $args['subject'],
         'message_id' => $this->lastMessageId ?? 'unknown',
         'tag' => $payload['Tag'] ?? 'none'
      ]);
   }
      $tags = [];
      $pattern = '/\{\{(.*?)\}\}/';
   /**
    * Log failed email attempts
    */
   public function logFailedEmail(array $args, $error): void
   {
      $error_message = is_wp_error($error) ? $error->get_error_message() : 'Unknown error';
      if (str_contains($subject, '{{')){
         preg_match_all($pattern, $subject, $tags);
      }
      if (str_contains($message, '{{')) {
         preg_match_all($pattern, $message, $tags);
      }
      $this->logError('Failed to send email via PostMark', [
         'to' => $args['to'],
         'subject' => $args['subject'],
         'error' => $error_message
      ]);
   }
      $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);
      }
   /**
    * Test email configuration
    */
   public function sendTestEmail(string $to_email): bool|WP_Error
   {
      $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' => $this->getCredentials()['from_email'] . ' <' . $this->getCredentials()['from_email'] . '>',
         'To' => $to_email,
         'Subject' => 'PostMark Test Email - ' . get_bloginfo('name'),
         'TextBody' => 'This is a test email from your PostMark integration.',
         'HtmlBody' => '<p>This is a <strong>test email</strong> from your PostMark integration.</p>',
         'Tag' => 'test',
         'TrackOpens' => false,
         'TrackLinks' => 'None'
         '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'
      ];
      return $this->sendEmail($payload);
      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);
      }
   /**
    * Render additional options in dashboard
    * @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 renderAdditionalOptions(): void
   public function sendMany(array $emails):array
   {
      $stats = $this->getEmailStats(7);
      ?>
      <div class="postmark-dashboard">
         <h3>Email Statistics (Last 7 Days)</h3>
         <?php if (!empty($stats)): ?>
            <div class="stats-grid">
               <div class="stat-item">
                  <span class="stat-value"><?= number_format($stats['Sent'] ?? 0) ?></span>
                  <span class="stat-label">Emails Sent</span>
               </div>
               <div class="stat-item">
                  <span class="stat-value"><?= number_format($stats['Bounced'] ?? 0) ?></span>
                  <span class="stat-label">Bounced</span>
               </div>
               <div class="stat-item">
                  <span class="stat-value"><?= $stats['Opens'] ?? 0 ?>%</span>
                  <span class="stat-label">Open Rate</span>
               </div>
            </div>
         <?php else: ?>
            <p>No statistics available. Check your API configuration.</p>
         <?php endif; ?>
         <div class="test-email-section">
            <h4>Send Test Email</h4>
            <form id="postmark-test-email" method="post">
               <input type="email"
                     name="test_email"
                     placeholder="test@example.com"
                     value="<?= esc_attr(wp_get_current_user()->user_email) ?>"
                     required>
               <button type="submit" class="button">Send Test</button>
            </form>
         </div>
         <?php if ($bounces = $this->getBounces(5)): ?>
            <div class="recent-bounces">
               <h4>Recent Bounces</h4>
               <ul>
                  <?php foreach ($bounces as $bounce): ?>
                     <li>
                        <?= esc_html($bounce['Email']) ?> -
                        <?= esc_html($bounce['Type']) ?>
                        <small>(<?= esc_html($bounce['BouncedAt']) ?>)</small>
                     </li>
                  <?php endforeach; ?>
               </ul>
            </div>
         <?php endif; ?>
      </div>
      <?php
   }
   protected function initialize(): void
   {
      if (empty($this->credentials)) {
         $this->loadCredentials();
      if (!$this->isSetUp()) {
         return $this->response(false, 'Not set up');
      }
      $this->server_token = (array_key_exists('server_token', $this->credentials)) ? $this->credentials['server_token'] : null;
      $this->from_email = (array_key_exists('from_email', $this->credentials)) ? $this->credentials['from_email'] : get_bloginfo('admin_email');
      $this->from_name = (array_key_exists('from_name', $this->credentials)) ? $this->credentials['from_name'] : get_bloginfo('name');
      $this->message_stream = (array_key_exists('message_stream', $this->credentials)) ? $this->credentials['message_stream'] : 'outbound';
      $this->track_open = (array_key_exists('track_open', $this->credentials)) ? $this->credentials['track_open'] : false;
      $this->track_links = (array_key_exists('track_links', $this->credentials)) ? $this->credentials['track_links'] : false;
      $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);
      }
}