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(); $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', ]); $links->setDefault('None'); $links->setHint('Track link clicks in emails'); } 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, '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 " 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); $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), }; } /********************************************************** * 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); $response = $this->postRequest( sprintf('%s/email', $this->apiBase), $payload ); } protected function handleEmailResponse(WP_Error|array $response, string $method):array { } protected 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']; } 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; } }