Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions includes/Mail/class-branded-email-template.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

/**
* Branded Email Template - renders HTML email templates with configurable
* branding (logo, accent color, footer text).
*/

namespace Escalated\Mail;

use Escalated\Models\Setting;

class Branded_Email_Template
{
/**
* Render a branded HTML email.
*
* @param string $subject The email subject.
* @param string $body The email body content (HTML).
* @param array $options Optional overrides for branding settings.
* @return string The complete branded HTML email.
*/
public static function render(string $subject, string $body, array $options = []): string
{
$logo_url = $options['logo_url'] ?? Setting::get('email_logo_url', '');
$accent_color = $options['accent_color'] ?? Setting::get('email_accent_color', '#3B82F6');
$footer_text = $options['footer_text'] ?? Setting::get('email_footer_text', '');
$company_name = $options['company_name'] ?? Setting::get('email_company_name', get_bloginfo('name'));

// Sanitize the accent color.
if (! preg_match('/^#[0-9A-Fa-f]{6}$/', $accent_color)) {
$accent_color = '#3B82F6';
}

$logo_html = '';
if (! empty($logo_url)) {
$logo_html = sprintf(
'<img src="%s" alt="%s" style="max-height:50px;max-width:200px;display:block;margin:0 auto 16px auto;" />',
esc_url($logo_url),
esc_attr($company_name)
);
}

$footer_html = '';
if (! empty($footer_text)) {
$footer_html = sprintf(
'<p style="margin:0;font-size:12px;color:#9CA3AF;">%s</p>',
esc_html($footer_text)
);
}

return sprintf(
'<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%1$s</title>
</head>
<body style="margin:0;padding:0;background-color:#F3F4F6;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,sans-serif;">
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background-color:#F3F4F6;">
<tr>
<td align="center" style="padding:24px 16px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%%;">
<!-- Header -->
<tr>
<td style="background-color:%2$s;padding:24px;text-align:center;border-radius:8px 8px 0 0;">
%3$s
<h1 style="margin:0;font-size:18px;font-weight:600;color:#FFFFFF;">%4$s</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="background-color:#FFFFFF;padding:24px;border-left:1px solid #E5E7EB;border-right:1px solid #E5E7EB;">
%5$s
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color:#F9FAFB;padding:16px 24px;text-align:center;border:1px solid #E5E7EB;border-top:none;border-radius:0 0 8px 8px;">
%6$s
<p style="margin:4px 0 0;font-size:11px;color:#D1D5DB;">Powered by Escalated</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>',
esc_html($subject),
esc_attr($accent_color),
$logo_html,
esc_html($company_name),
$body,
$footer_html
);
}

/**
* Get the current branding settings.
*
* @return array Associative array of branding settings.
*/
public static function get_settings(): array
{
return [
'logo_url' => Setting::get('email_logo_url', ''),
'accent_color' => Setting::get('email_accent_color', '#3B82F6'),
'footer_text' => Setting::get('email_footer_text', ''),
'company_name' => Setting::get('email_company_name', get_bloginfo('name')),
];
}

/**
* Update branding settings.
*
* @param array $settings Settings to update.
*/
public static function update_settings(array $settings): void
{
$allowed = ['email_logo_url', 'email_accent_color', 'email_footer_text', 'email_company_name'];

foreach ($allowed as $key) {
if (isset($settings[$key])) {
Setting::set($key, sanitize_text_field($settings[$key]));
}
}
}
}
152 changes: 152 additions & 0 deletions includes/Mail/class-email-threading.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php

/**
* Email Threading - adds In-Reply-To, References, and Message-ID headers
* to outbound WordPress emails for proper email threading support.
*/

namespace Escalated\Mail;

class Email_Threading
{
/**
* The current ticket context for email headers.
*/
private static ?object $current_ticket = null;

/**
* The current reply context for email headers.
*/
private static ?object $current_reply = null;

/**
* Register WordPress hooks for email threading.
*/
public function register(): void
{
add_filter('wp_mail', [$this, 'add_threading_headers'], 10, 1);
add_action('escalated_reply_created', [$this, 'set_reply_context'], 5, 2);
add_action('escalated_ticket_created', [$this, 'set_ticket_context'], 5, 1);
}

/**
* Set the ticket context for the next outbound email.
*
* @param object $ticket The ticket object.
*/
public function set_ticket_context(object $ticket): void
{
self::$current_ticket = $ticket;
self::$current_reply = null;
}

/**
* Set the reply context for the next outbound email.
*
* @param object $reply The reply object.
* @param object $ticket The parent ticket object.
*/
public function set_reply_context(object $reply, object $ticket): void
{
self::$current_reply = $reply;
self::$current_ticket = $ticket;
}

/**
* Add threading headers to outbound WordPress emails.
*
* @param array $args The wp_mail arguments.
* @return array Modified wp_mail arguments.
*/
public function add_threading_headers(array $args): array
{
if (! self::$current_ticket) {
return $args;
}

$ticket = self::$current_ticket;
$reply = self::$current_reply;
$domain = self::get_email_domain();

// Generate Message-ID for this email.
$message_id = self::generate_message_id($ticket, $reply, $domain);

// Build threading headers.
$headers = is_array($args['headers']) ? $args['headers'] : [];
if (is_string($args['headers'])) {
$headers = explode("\n", $args['headers']);
$headers = array_filter(array_map('trim', $headers));
}

$headers[] = sprintf('Message-ID: <%s>', $message_id);

// For replies, reference the original ticket's message ID.
if ($reply) {
$original_message_id = self::generate_ticket_message_id($ticket, $domain);
$headers[] = sprintf('In-Reply-To: <%s>', $original_message_id);
$headers[] = sprintf('References: <%s>', $original_message_id);
}

$args['headers'] = $headers;

// Reset context after use.
self::$current_ticket = null;
self::$current_reply = null;

return $args;
}

/**
* Generate a Message-ID for a ticket.
*
* @param object $ticket The ticket object.
* @param string $domain The email domain.
*/
public static function generate_ticket_message_id(object $ticket, string $domain): string
{
return sprintf('ticket-%s@%s', $ticket->reference, $domain);
}

/**
* Generate a Message-ID for a specific email.
*
* @param object $ticket The ticket object.
* @param object|null $reply The reply object, if any.
* @param string $domain The email domain.
*/
public static function generate_message_id(object $ticket, ?object $reply, string $domain): string
{
if ($reply) {
return sprintf('reply-%d-ticket-%s@%s', $reply->id, $ticket->reference, $domain);
}

return self::generate_ticket_message_id($ticket, $domain);
}

/**
* Get the email domain for Message-ID generation.
*/
public static function get_email_domain(): string
{
$site_url = wp_parse_url(site_url(), PHP_URL_HOST);

return $site_url ?: 'localhost';
}

/**
* Get the current ticket context (for testing).
*/
public static function get_current_ticket(): ?object
{
return self::$current_ticket;
}

/**
* Reset the context (useful for testing).
*/
public static function reset_context(): void
{
self::$current_ticket = null;
self::$current_reply = null;
}
}
1 change: 1 addition & 0 deletions includes/class-escalated.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public function boot(): void
(new Frontend\Ajax_Handler)->register();
(new Api\Api_Bootstrap)->register();
(new Mail\Inbound_Controller)->register();
(new Mail\Email_Threading)->register();
(new Cron\Sla_Check)->register();
(new Cron\Escalation_Check)->register();
(new Cron\Automation_Check)->register();
Expand Down
Loading
Loading