| | |
| | | <?php |
| | | |
| | | namespace JVBase\integrations\services; |
| | | use JVBase\integrations\Admin; |
| | | use JVBase\integrations\Instance; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\integrations\Requests; |
| | | use WP_Error; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | |
| | | */ |
| | | 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' => [ |
| | | 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' |
| | | ], |
| | | 'default' => 'false', |
| | | 'description' => 'Track when emails are opened' |
| | | ], |
| | | 'track_links' => [ |
| | | 'label' => 'Track Links', |
| | | 'type' => 'select', |
| | | 'options' => [ |
| | | '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); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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 |
| | | public function wp_mail(array $args):array |
| | | { |
| | | // Extract email components |
| | | $to = $args['to']; |
| | |
| | | $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( |
| | | $response = $this->sendEmail( |
| | | $to, |
| | | $subject, |
| | | $message, |
| | | $is_html, |
| | | $parsed_headers, |
| | | $headers, |
| | | $attachments |
| | | ); |
| | | |
| | | // Send via PostMark |
| | | $result = $this->sendEmail($payload); |
| | | |
| | | if ($result === true) { |
| | | 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, $payload); |
| | | 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, $result); |
| | | |
| | | // Optionally fall back to default wp_mail |
| | | if ($this->shouldFallback()) { |
| | | return $args; |
| | | do_action('postmark_email_failed', $args, $response); |
| | | } |
| | | } |
| | | |
| | | return $args; |
| | | } |
| | | |
| | | /** |
| | | * 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(); |
| | | |
| | | // Handle multiple recipients |
| | | $to_addresses = is_array($to) ? implode(',', $to) : $to; |
| | | |
| | | // 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' |
| | | ]; |
| | | |
| | | // 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; |
| | | } |
| | | |
| | | // Add CC/BCC if present |
| | | 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']; |
| | | } |
| | | |
| | | // Add Reply-To if present |
| | | if (!empty($headers['reply-to'])) { |
| | | $payload['ReplyTo'] = $headers['reply-to']; |
| | | } |
| | | |
| | | // Handle attachments |
| | | if (!empty($attachments)) { |
| | | $payload['Attachments'] = $this->processAttachments($attachments); |
| | | } |
| | | |
| | | // 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 |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | 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 |
| | | protected function parseMailHeaders(string|array $headers):array |
| | | { |
| | | $parsed = [ |
| | | 'content-type' => 'text/plain', |
| | | 'from' => null, |
| | | 'from-name' => null, |
| | | 'reply-to' => null, |
| | | 'reply-top' => null, |
| | | 'cc' => [], |
| | | 'bcc' => [], |
| | | 'x-headers' => [] |
| | | 'x-headers' => [], |
| | | ]; |
| | | |
| | | if (empty($headers)) { |
| | | return $parsed; |
| | | } |
| | | |
| | | // Handle array format |
| | | if (is_array($headers)) { |
| | | foreach ($headers as $header) { |
| | | $this->parseHeaderLine($header, $parsed); |
| | | if (is_string($headers)) { |
| | | $headers = explode("\n", $headers); |
| | | } |
| | | } else { |
| | | // Handle string format (multiple lines) |
| | | $lines = explode("\n", $headers); |
| | | foreach ($lines as $line) { |
| | | |
| | | foreach ($headers as $line) { |
| | | $this->parseHeaderLine($line, $parsed); |
| | | } |
| | | } |
| | | |
| | | return $parsed; |
| | | } |
| | | |
| | | /** |
| | | * Parse individual header line |
| | | */ |
| | | protected function parseHeaderLine(string $line, array &$parsed): void |
| | | { |
| | | 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'; |
| | | if (str_contains($value, 'text/html')) { |
| | | $parsed['content-type'] = $value; |
| | | } |
| | | break; |
| | | |
| | |
| | | } |
| | | break; |
| | | |
| | | case 'reply-to': |
| | | $parsed['reply-to'] = $value; |
| | | break; |
| | | |
| | | case 'cc': |
| | | $parsed['cc'][] = $value; |
| | | break; |
| | | |
| | | case 'bcc': |
| | | $parsed['bcc'][] = $value; |
| | | $parsed[$name][] = $value; |
| | | break; |
| | | |
| | | default: |
| | | // Store custom headers |
| | | if (strpos($name, 'x-') === 0) { |
| | | //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' => $parts[0], // Original case |
| | | 'Name' => $name, |
| | | 'Value' => $value |
| | | ]; |
| | | } else { |
| | | $parsed[$name] = $value; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Process attachments for PostMark |
| | | */ |
| | | protected function processAttachments(array $attachments): array |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | $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 [ |
| | | 'Accept' => 'application/json', |
| | | 'Content-Type' => 'application/json', |
| | | 'X-Postmark-Server-Token' => $this->getServerToken() |
| | | ]; |
| | | } |
| | | |
| | | return $processed; |
| | | 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); |
| | | $error = $response['ErrorCode'] ?? false; |
| | | if ($error) { |
| | | switch ($error) { |
| | | case '0': |
| | | //Do nothing, no error found |
| | | break; |
| | | //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': |
| | | //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': |
| | | //Forbidden Attachment type |
| | | case '411': |
| | | //Account is pending |
| | | case '412': |
| | | //Account may not exist |
| | | case '413': |
| | | //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 match ($endpoint) { |
| | | '/email' => $this->handleEmailResponse($response, $method), |
| | | '/email/bulk' => $this->handleBulkEmailResponse($response, $method), |
| | | default => $this->response(false, 'Unhandled Response for '. $endpoint . ' endpoint, with method: '. $method), |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Generate plain text from HTML content |
| | | */ |
| | | protected function generateTextFromHtml(string $html): string |
| | | /********************************************************** |
| | | * 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); |
| | |
| | | } |
| | | |
| | | /** |
| | | * Determine email tag based on content |
| | | * @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 |
| | | { |
| | | // Password reset emails |
| | | if (stripos($subject, 'password') !== false && stripos($subject, 'reset') !== false) { |
| | | 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); |
| | | |
| | | $response = $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 |
| | | protected function handleEmailResponse(WP_Error|array $response, string $method):array |
| | | { |
| | | $cache_key = 'postmark_stats_' . $days; |
| | | $cached = $this->cache->get($cache_key); |
| | | |
| | | if ($cached !== false) { |
| | | return $cached; |
| | | } |
| | | |
| | | try { |
| | | $stats = $this->getRequest('stats/outbound', [ |
| | | 'fromdate' => date('Y-m-d', strtotime("-{$days} days")), |
| | | 'todate' => date('Y-m-d') |
| | | ]); |
| | | |
| | | if (!is_wp_error($stats)) { |
| | | $this->cache->set($cache_key, $stats, 3600); // Cache for 1 hour |
| | | return $stats; |
| | | } |
| | | } catch (\Exception $e) { |
| | | $this->logError('stats/outbound', ['error' => $e->getMessage()]); |
| | | } |
| | | |
| | | return []; |
| | | } |
| | | |
| | | /** |
| | | * Get bounce information |
| | | */ |
| | | public function getBounces(int $count = 25, int $offset = 0): array |
| | | protected function buildPayload(string|array $to, 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 []; |
| | | } |
| | | } |
| | | $to = is_array($to) ? implode(',',$to) : $to; |
| | | |
| | | /** |
| | | * Log sent emails for debugging/tracking |
| | | */ |
| | | public function logSentEmail(array $args, array $payload): void |
| | | { |
| | | if (!defined('WP_DEBUG') || !WP_DEBUG) { |
| | | return; |
| | | } |
| | | |
| | | $this->logDebug('Email sent via PostMark', [ |
| | | 'to' => $args['to'], |
| | | 'subject' => $args['subject'], |
| | | 'message_id' => $this->lastMessageId ?? 'unknown', |
| | | 'tag' => $payload['Tag'] ?? 'none' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Log failed email attempts |
| | | */ |
| | | public function logFailedEmail(array $args, $error): void |
| | | { |
| | | $error_message = is_wp_error($error) ? $error->get_error_message() : 'Unknown error'; |
| | | |
| | | $this->logError('Failed to send email via PostMark', [ |
| | | 'to' => $args['to'], |
| | | 'subject' => $args['subject'], |
| | | 'error' => $error_message |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Test email configuration |
| | | */ |
| | | public function sendTestEmail(string $to_email): bool|WP_Error |
| | | { |
| | | $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() |
| | | ), |
| | | 'To' => $to, |
| | | 'Subject' => sanitize_text_field($subject), |
| | | 'TrackOpens' => $this->getTrackOpens() === 'true', |
| | | 'TrackLinks' => $this->getTrackLinks(), |
| | | 'MessageStream' => $this->getMessageStream() |
| | | ]; |
| | | |
| | | return $this->sendEmail($payload); |
| | | if ($is_html) { |
| | | $payload['HtmlBody'] = $message; |
| | | $payload['TextBody'] = $this->textFromHtml($message); |
| | | } else { |
| | | $payload['TextBody'] = $message; |
| | | } |
| | | |
| | | /** |
| | | * Render additional options in dashboard |
| | | */ |
| | | public function renderAdditionalOptions(): void |
| | | { |
| | | $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 |
| | | if (!empty($headers['cc'])) { |
| | | $payload['Cc'] = is_array($headers['cc']) ? |
| | | implode(',',$headers['cc']) : |
| | | $headers['cc']; |
| | | } |
| | | |
| | | protected function initialize(): void |
| | | { |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | if (!empty($headers['bcc'])) { |
| | | $payload['Bcc'] = is_array($headers['bcc']) ? |
| | | implode(',',$headers['bcc']) : |
| | | $headers['bcc']; |
| | | } |
| | | $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; |
| | | |
| | | if (!empty($headers['reply-to'])) { |
| | | $payload['ReplyTo'] = $headers['reply-to']; |
| | | } |
| | | |
| | | 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; |
| | | } |
| | | } |