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
24 changes: 24 additions & 0 deletions inc/class-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -1605,6 +1605,30 @@ public function default_sections(): void {

do_action('wu_settings_emails');

$this->add_field(
'emails',
'transactional_email_header',
[
'title' => __('Transactional Email Delivery', 'ultimate-multisite'),
'desc' => __('Configure how WordPress sends transactional email (password resets, notifications, admin alerts) on a per-site basis. When a provider is configured, outbound email is routed through the provider using each site\'s own domain as the from-address.', 'ultimate-multisite'),
'type' => 'header',
'show_as_submenu' => true,
]
);

$this->add_field(
'emails',
'transactional_email_provider_note',
[
'type' => 'note',
'desc' => sprintf(
/* translators: %s is a link to the Integrations settings page. */
__('To configure a transactional email provider (e.g. Amazon SES), go to the <a href="%s">Integrations settings</a> and set up the provider there.', 'ultimate-multisite'),
esc_url(wu_network_admin_url('wp-ultimo-settings', ['tab' => 'integrations']))
),
]
);

/*
* Domain Mapping
* This section holds the Domain Mapping settings of the Ultimate Multisite Plugin.
Expand Down
221 changes: 221 additions & 0 deletions inc/helpers/class-aws-signer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<?php
/**
* AWS SigV4 Request Signer.
*
* Utility class for signing AWS API requests using Signature Version 4.
* Extracted from the WorkMail integration for reuse across AWS services
* (SES, WorkMail, etc.).
*
* @package WP_Ultimo
* @subpackage Helpers
* @since 2.5.0
*/

namespace WP_Ultimo\Helpers;

// Exit if accessed directly
defined('ABSPATH') || exit;

/**
* AWS Signature Version 4 request signer.
*
* @since 2.5.0
*/
class AWS_Signer {

/**
* AWS access key ID.
*
* @since 2.5.0
* @var string
*/
private string $access_key;

/**
* AWS secret access key.
*
* @since 2.5.0
* @var string
*/
private string $secret_key;

/**
* AWS region.
*
* @since 2.5.0
* @var string
*/
private string $region;

/**
* AWS service name (e.g. 'ses', 'email').
*
* @since 2.5.0
* @var string
*/
private string $service;

/**
* Constructor.
*
* @since 2.5.0
*
* @param string $access_key AWS access key ID.
* @param string $secret_key AWS secret access key.
* @param string $region AWS region (e.g. 'us-east-1').
* @param string $service AWS service name (e.g. 'ses').
*/
public function __construct(string $access_key, string $secret_key, string $region, string $service) {

$this->access_key = $access_key;
$this->secret_key = $secret_key;
$this->region = $region;
$this->service = $service;
}

/**
* Sign an HTTP request using AWS Signature Version 4.
*
* Returns the Authorization header and x-amz-date header values needed
* to authenticate the request with AWS.
*
* @since 2.5.0
*
* @param string $method HTTP method (GET, POST, etc.).
* @param string $url Full request URL.
* @param string $payload Request body (empty string for GET requests).
* @return array{
* Authorization: string,
* x-amz-date: string,
* x-amz-content-sha256: string
* }
*/
public function sign(string $method, string $url, string $payload = ''): array {

$parsed = wp_parse_url($url);
$uri = $parsed['path'] ?? '/';
$query = $parsed['query'] ?? '';
$host = $parsed['host'] ?? '';
$amz_date = gmdate('Ymd\THis\Z');
$date_stamp = gmdate('Ymd');
$payload_hash = hash('sha256', $payload);

$canonical_headers = "host:{$host}\n" .
"x-amz-content-sha256:{$payload_hash}\n" .
"x-amz-date:{$amz_date}\n";

$signed_headers = 'host;x-amz-content-sha256;x-amz-date';

$canonical_request = implode(
"\n",
[
strtoupper($method),
$this->uri_encode_path($uri),
$this->canonical_query_string($query),
$canonical_headers,
$signed_headers,
$payload_hash,
]
);

$credential_scope = implode(
'/',
[
$date_stamp,
$this->region,
$this->service,
'aws4_request',
]
);

$string_to_sign = implode(
"\n",
[
'AWS4-HMAC-SHA256',
$amz_date,
$credential_scope,
hash('sha256', $canonical_request),
]
);

$signing_key = $this->get_signing_key($date_stamp);
$signature = hash_hmac('sha256', $string_to_sign, $signing_key);
$authorization = sprintf(
'AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s',
$this->access_key,
$credential_scope,
$signed_headers,
$signature
);

return [
'Authorization' => $authorization,
'x-amz-date' => $amz_date,
'x-amz-content-sha256' => $payload_hash,
];
}

/**
* Derive the signing key for the given date.
*
* @since 2.5.0
*
* @param string $date_stamp Date in Ymd format.
* @return string Binary signing key.
*/
private function get_signing_key(string $date_stamp): string {

$k_date = hash_hmac('sha256', $date_stamp, 'AWS4' . $this->secret_key, true);
$k_region = hash_hmac('sha256', $this->region, $k_date, true);
$k_service = hash_hmac('sha256', $this->service, $k_region, true);

return hash_hmac('sha256', 'aws4_request', $k_service, true);
}

/**
* URI-encode a path component per AWS requirements.
*
* @since 2.5.0
*
* @param string $path The URI path.
* @return string Encoded path.
*/
private function uri_encode_path(string $path): string {

$segments = explode('/', $path);
$encoded = array_map(
static function (string $segment): string {
return rawurlencode(rawurldecode($segment));
},
$segments
);

return implode('/', $encoded);
}

/**
* Build a canonical query string sorted by key.
*
* @since 2.5.0
*
* @param string $query Raw query string.
* @return string Canonical query string.
*/
private function canonical_query_string(string $query): string {

if ('' === $query) {
return '';
}

parse_str($query, $params);
ksort($params);

$parts = [];

foreach ($params as $key => $value) {
$parts[] = rawurlencode((string) $key) . '=' . rawurlencode((string) $value);
}

return implode('&', $parts);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php
/**
* Transactional Email Capability Interface.
*
* Defines the contract for transactional email delivery providers.
* This is distinct from Email_Selling_Capability (mailbox provisioning) —
* transactional email is about delivery routing, not mailbox management.
*
* @package WP_Ultimo
* @subpackage Integrations/Capabilities
* @since 2.5.0
*/

namespace WP_Ultimo\Integrations\Capabilities;

// Exit if accessed directly
defined('ABSPATH') || exit;

/**
* Interface for transactional email delivery capability modules.
*
* Implementing this interface allows an integration to intercept wp_mail()
* calls and route them through a configured transactional email provider,
* using the correct from-address for the current site's domain.
*
* @since 2.5.0
*/
interface Transactional_Email_Capability {

/**
* Initiate domain verification with the provider.
*
* Triggers the provider to begin the domain verification process and
* returns the DNS records that must be added to complete verification.
*
* @since 2.5.0
*
* @param string $domain The domain name to verify.
* @return array{
* success: bool,
* message?: string,
* dns_records?: array
* }
*/
public function verify_domain(string $domain): array;

/**
* Get the current verification status for a domain.
*
* @since 2.5.0
*
* @param string $domain The domain name to check.
* @return array{
* success: bool,
* status: string,
* message?: string
* }
*/
public function get_domain_verification_status(string $domain): array;

/**
* Get the DNS records required for domain verification.
*
* Returns the SPF, DKIM, and DMARC records that must be added to the
* domain's DNS configuration to complete verification.
*
* @since 2.5.0
*
* @param string $domain The domain name.
* @return array{
* success: bool,
* dns_records?: array<array{type: string, name: string, value: string}>,
* message?: string
* }
*/
public function get_domain_dns_records(string $domain): array;

/**
* Send a transactional email through the provider.
*
* @since 2.5.0
*
* @param string $from The sender email address.
* @param string $to The recipient email address.
* @param string $subject The email subject.
* @param string $body The email body (HTML or plain text).
* @param array $headers Optional additional headers.
* @return array{
* success: bool,
* message_id?: string,
* message?: string
* }
*/
public function send_email(string $from, string $to, string $subject, string $body, array $headers = []): array;

/**
* Get sending statistics for a domain.
*
* @since 2.5.0
*
* @param string $domain The domain name.
* @param string $period The time period for statistics (e.g. '24h', '7d', '30d').
* @return array{
* success: bool,
* sent?: int,
* delivered?: int,
* bounced?: int,
* complaints?: int,
* message?: string
* }
*/
public function get_sending_statistics(string $domain, string $period = '24h'): array;

/**
* Set the daily sending quota for a domain.
*
* @since 2.5.0
*
* @param string $domain The domain name.
* @param int $max_per_day Maximum emails allowed per day.
* @return array{
* success: bool,
* message?: string
* }
*/
public function set_sending_quota(string $domain, int $max_per_day): array;

/**
* Test the connection to the transactional email provider.
*
* @since 2.5.0
*
* @return true|\WP_Error True on success, WP_Error on failure.
*/
public function test_connection();
}
Loading
Loading