From a3266bb87f6e86f48756496912dde803cd7565ab Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 16 Jun 2026 13:49:14 -0600 Subject: [PATCH 1/2] feat: add OCI Email Delivery integration --- inc/helpers/class-oci-signer.php | 174 ++++++ .../class-integration-registry.php | 2 + .../oci-email/class-oci-email-integration.php | 251 +++++++++ .../class-oci-email-transactional-email.php | 507 ++++++++++++++++++ 4 files changed, 934 insertions(+) create mode 100644 inc/helpers/class-oci-signer.php create mode 100644 inc/integrations/providers/oci-email/class-oci-email-integration.php create mode 100644 inc/integrations/providers/oci-email/class-oci-email-transactional-email.php diff --git a/inc/helpers/class-oci-signer.php b/inc/helpers/class-oci-signer.php new file mode 100644 index 000000000..a4488d99d --- /dev/null +++ b/inc/helpers/class-oci-signer.php @@ -0,0 +1,174 @@ +tenancy_ocid = $tenancy_ocid; + $this->user_ocid = $user_ocid; + $this->fingerprint = $fingerprint; + $this->private_key = $this->normalize_private_key($private_key); + } + + /** + * Sign an OCI HTTP request. + * + * @since 2.5.0 + * + * @param string $method HTTP method. + * @param string $url Full request URL. + * @param string $payload Request body. + * @return array + */ + public function sign(string $method, string $url, string $payload = ''): array { + + $parsed = wp_parse_url($url); + $path = $parsed['path'] ?? '/'; + $query = isset($parsed['query']) && '' !== $parsed['query'] ? '?' . $parsed['query'] : ''; + $host = $parsed['host'] ?? ''; + $date = gmdate('D, d M Y H:i:s \G\M\T'); + $method = strtolower($method); + + $headers = [ + 'date' => $date, + 'host' => $host, + '(request-target)' => $method . ' ' . $path . $query, + ]; + + $signed_header_names = ['date', '(request-target)', 'host']; + + if ('' !== $payload) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- OCI requires a base64-encoded SHA-256 digest header. + $headers['x-content-sha256'] = base64_encode(hash('sha256', $payload, true)); + $headers['content-type'] = 'application/json'; + $headers['content-length'] = (string) strlen($payload); + + $signed_header_names = array_merge($signed_header_names, ['x-content-sha256', 'content-type', 'content-length']); + } + + $signing_string = $this->build_signing_string($headers, $signed_header_names); + $signature = ''; + $private_key = openssl_pkey_get_private($this->private_key); + + if (false === $private_key || ! openssl_sign($signing_string, $signature, $private_key, OPENSSL_ALGO_SHA256)) { + return []; + } + + $key_id = $this->tenancy_ocid . '/' . $this->user_ocid . '/' . $this->fingerprint; + $authorization = sprintf( + 'Signature version="1",keyId="%s",algorithm="rsa-sha256",headers="%s",signature="%s"', + $key_id, + implode(' ', $signed_header_names), + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- OCI requires the RSA signature to be base64 encoded. + base64_encode($signature) + ); + + $response_headers = [ + 'Authorization' => $authorization, + 'Date' => $date, + ]; + + if ('' !== $payload) { + $response_headers['x-content-sha256'] = $headers['x-content-sha256']; + $response_headers['Content-Type'] = $headers['content-type']; + $response_headers['Content-Length'] = $headers['content-length']; + } + + return $response_headers; + } + + /** + * Build the newline-delimited OCI signing string. + * + * @since 2.5.0 + * + * @param array $headers Header values keyed by lowercase header name. + * @param array $signed_header_names Ordered signed header names. + * @return string + */ + private function build_signing_string(array $headers, array $signed_header_names): string { + + $lines = []; + + foreach ($signed_header_names as $name) { + $lines[] = $name . ': ' . $headers[ $name ]; + } + + return implode("\n", $lines); + } + + /** + * Normalize private keys copied through constants or option forms. + * + * @since 2.5.0 + * + * @param string $private_key Private key value. + * @return string + */ + private function normalize_private_key(string $private_key): string { + + return str_replace('\\n', "\n", trim($private_key)); + } +} diff --git a/inc/integrations/class-integration-registry.php b/inc/integrations/class-integration-registry.php index 54dd4a16b..b7c3d50f8 100644 --- a/inc/integrations/class-integration-registry.php +++ b/inc/integrations/class-integration-registry.php @@ -135,6 +135,7 @@ private function register_core_integrations(): void { $this->register(new Providers\BunnyNet\BunnyNet_Integration()); $this->register(new Providers\LaravelForge\LaravelForge_Integration()); $this->register(new Providers\Amazon_SES\Amazon_SES_Integration()); + $this->register(new Providers\OCI_Email\OCI_Email_Integration()); $this->register(new Providers\CyberPanel\CyberPanel_Integration()); $this->register(new Providers\Hostinger\Hostinger_Integration()); } @@ -186,6 +187,7 @@ private function register_core_capabilities(): void { $this->add_capability('bunnynet', new Providers\BunnyNet\BunnyNet_Domain_Mapping()); $this->add_capability('laravel-forge', new Providers\LaravelForge\LaravelForge_Domain_Mapping()); $this->add_capability('amazon-ses', new Providers\Amazon_SES\Amazon_SES_Transactional_Email()); + $this->add_capability('oracle-oci', new Providers\OCI_Email\OCI_Email_Transactional_Email()); $this->add_capability('cyberpanel', new Providers\CyberPanel\CyberPanel_Domain_Mapping()); $this->add_capability('hostinger', new Providers\Hostinger\Hostinger_Domain_Mapping()); } diff --git a/inc/integrations/providers/oci-email/class-oci-email-integration.php b/inc/integrations/providers/oci-email/class-oci-email-integration.php new file mode 100644 index 000000000..b43959f95 --- /dev/null +++ b/inc/integrations/providers/oci-email/class-oci-email-integration.php @@ -0,0 +1,251 @@ +set_logo(function_exists('wu_get_asset') ? wu_get_asset('oracle-oci.svg', 'img/hosts') : ''); + $this->set_tutorial_link('https://ultimatemultisite.com/docs/user-guide/host-integrations/oracle-oci-email-delivery'); + $this->set_constants(['WU_OCI_TENANCY_OCID', 'WU_OCI_USER_OCID', 'WU_OCI_KEY_FINGERPRINT', 'WU_OCI_PRIVATE_KEY', 'WU_OCI_COMPARTMENT_OCID']); + $this->set_optional_constants(['WU_OCI_REGION', 'WU_OCI_EMAIL_SPF_INCLUDE', 'WU_OCI_EMAIL_DOMAIN_ENDPOINT']); + } + + /** + * {@inheritdoc} + */ + public function get_description(): string { + + return __('Oracle Cloud Infrastructure Email Delivery sends high-volume transactional email through OCI. Use it to create Email Delivery domains, request DKIM, and publish the returned SPF/DKIM/DMARC records for each mapped domain.', 'ultimate-multisite'); + } + + /** + * Returns the OCI region to use for Email Delivery. + * + * @since 2.5.0 + * @return string + */ + public function get_region(): string { + + $region = $this->get_credential('WU_OCI_REGION'); + + return $region ?: 'eu-zurich-1'; + } + + /** + * Returns the OCI API base URL for the configured region. + * + * @since 2.5.0 + * @return string + */ + public function get_api_base(): string { + + $endpoint = $this->get_credential('WU_OCI_EMAIL_DOMAIN_ENDPOINT'); + + if ($endpoint) { + return trailingslashit($endpoint); + } + + return sprintf(self::API_BASE, $this->get_region()); + } + + /** + * Returns the SPF include host for OCI Email Delivery. + * + * @since 2.5.0 + * @return string + */ + public function get_spf_include(): string { + + $spf_include = $this->get_credential('WU_OCI_EMAIL_SPF_INCLUDE'); + + return $spf_include ?: 'rp.oracleemaildelivery.com'; + } + + /** + * Returns the configured OCI compartment OCID. + * + * @since 2.5.0 + * @return string + */ + public function get_compartment_ocid(): string { + + return $this->get_credential('WU_OCI_COMPARTMENT_OCID'); + } + + /** + * Returns a configured OCI signer instance. + * + * @since 2.5.0 + * @return OCI_Signer + */ + public function get_signer(): OCI_Signer { + + return new OCI_Signer( + $this->get_credential('WU_OCI_TENANCY_OCID'), + $this->get_credential('WU_OCI_USER_OCID'), + $this->get_credential('WU_OCI_KEY_FINGERPRINT'), + $this->get_credential('WU_OCI_PRIVATE_KEY') + ); + } + + /** + * Makes an authenticated request to the OCI Email Delivery API. + * + * @since 2.5.0 + * + * @param string $endpoint Relative endpoint path. + * @param string $method HTTP method. Defaults to GET. + * @param array $data Request body data (JSON encoded for non-GET requests). + * @return array|\WP_Error Decoded response array or WP_Error on failure. + */ + public function oci_api_call(string $endpoint, string $method = 'GET', array $data = []) { + + $url = $this->get_api_base() . ltrim($endpoint, '/'); + $payload = ('GET' === $method || empty($data)) ? '' : wp_json_encode($data); + $headers = $this->get_signer()->sign($method, $url, $payload ?: ''); + + if (empty($headers)) { + return new \WP_Error('oracle-oci-signing-error', __('Unable to sign OCI API request. Check the configured private key.', 'ultimate-multisite')); + } + + $request_args = [ + 'method' => $method, + 'headers' => $headers, + ]; + + if ($payload) { + $request_args['body'] = $payload; + } + + $response = wp_remote_request($url, $request_args); + + if (is_wp_error($response)) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + $decoded = json_decode($body, true); + + if ($status_code >= 200 && $status_code < 300) { + return $decoded ?: []; + } + + $error_message = isset($decoded['message']) ? $decoded['message'] : wp_remote_retrieve_response_message($response); + + return new \WP_Error( + 'oracle-oci-error', + sprintf( + /* translators: 1: HTTP status code, 2: error message */ + __('OCI Email Delivery API error (HTTP %1$d): %2$s', 'ultimate-multisite'), + $status_code, + $error_message + ) + ); + } + + /** + * Tests the connection to the OCI Email Delivery API. + * + * @since 2.5.0 + * @return true|\WP_Error + */ + public function test_connection() { + + $result = $this->oci_api_call('emailDomains?compartmentId=' . rawurlencode($this->get_compartment_ocid()) . '&limit=1'); + + if (is_wp_error($result)) { + return $result; + } + + return true; + } + + /** + * Returns the credential form fields for the setup wizard. + * + * @since 2.5.0 + * @return array + */ + public function get_fields(): array { + + return [ + 'WU_OCI_TENANCY_OCID' => [ + 'title' => __('OCI Tenancy OCID', 'ultimate-multisite'), + 'placeholder' => __('e.g. ocid1.tenancy.oc1..aaaa...', 'ultimate-multisite'), + ], + 'WU_OCI_USER_OCID' => [ + 'title' => __('OCI User OCID', 'ultimate-multisite'), + 'placeholder' => __('e.g. ocid1.user.oc1..aaaa...', 'ultimate-multisite'), + ], + 'WU_OCI_KEY_FINGERPRINT' => [ + 'title' => __('OCI API Key Fingerprint', 'ultimate-multisite'), + 'placeholder' => __('e.g. 12:34:56:...', 'ultimate-multisite'), + ], + 'WU_OCI_PRIVATE_KEY' => [ + 'title' => __('OCI API Private Key', 'ultimate-multisite'), + 'type' => 'textarea', + 'desc' => __('Paste the PEM private key for the OCI API key. Newline escape sequences are supported.', 'ultimate-multisite'), + 'html_attr' => [ + 'autocomplete' => 'new-password', + ], + ], + 'WU_OCI_COMPARTMENT_OCID' => [ + 'title' => __('OCI Compartment OCID', 'ultimate-multisite'), + 'placeholder' => __('e.g. ocid1.compartment.oc1..aaaa...', 'ultimate-multisite'), + ], + 'WU_OCI_REGION' => [ + 'title' => __('OCI Region', 'ultimate-multisite'), + 'placeholder' => __('e.g. eu-zurich-1', 'ultimate-multisite'), + 'desc' => __('Optional. The OCI region for Email Delivery. Defaults to eu-zurich-1.', 'ultimate-multisite'), + ], + 'WU_OCI_EMAIL_SPF_INCLUDE' => [ + 'title' => __('OCI SPF Include Host', 'ultimate-multisite'), + 'placeholder' => __('rp.oracleemaildelivery.com', 'ultimate-multisite'), + 'desc' => __('Optional. Override only if Oracle changes the SPF include host for your tenancy or region.', 'ultimate-multisite'), + ], + 'WU_OCI_EMAIL_DOMAIN_ENDPOINT' => [ + 'title' => __('OCI Email API Endpoint Override', 'ultimate-multisite'), + 'desc' => __('Optional. Advanced use only; overrides the generated Email Delivery API endpoint.', 'ultimate-multisite'), + ], + ]; + } +} diff --git a/inc/integrations/providers/oci-email/class-oci-email-transactional-email.php b/inc/integrations/providers/oci-email/class-oci-email-transactional-email.php new file mode 100644 index 000000000..41afffbfd --- /dev/null +++ b/inc/integrations/providers/oci-email/class-oci-email-transactional-email.php @@ -0,0 +1,507 @@ + [ + 'domain_verify' => __('Create or locate OCI Email Delivery domains when a new domain is added to the network', 'ultimate-multisite'), + 'dkim_records' => __('Request DKIM for verified domains and return the DNS records required by OCI', 'ultimate-multisite'), + 'authentication_dns' => __('Return SPF and DMARC records alongside OCI DKIM records so DNS automation can publish a complete authentication set', 'ultimate-multisite'), + ], + 'will_not' => [ + 'mailbox_provision' => __('Provision mailboxes or IMAP/POP3 accounts (use the Email Selling capability for that)', 'ultimate-multisite'), + 'dns_auto_create' => __('Automatically create DNS records unless a supported DNS provider capability is configured to consume the returned records', 'ultimate-multisite'), + 'smtp_credentials' => __('Create OCI SMTP credentials; those remain managed in OCI IAM for the sending user', 'ultimate-multisite'), + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function register_hooks(): void { + + add_action('wu_domain_added', [$this, 'on_domain_added'], 10, 2); + add_action('wu_domain_removed', [$this, 'on_domain_removed'], 10, 2); + add_action('wu_settings_transactional_email', [$this, 'register_transactional_email_settings']); + } + + /** + * Registers a status field in the Transactional Email Delivery settings section. + * + * @since 2.5.0 + * @return void + */ + public function register_transactional_email_settings(): void { + + $region = $this->get_oci()->get_region(); + + wu_register_settings_field( + 'emails', + 'oracle_oci_active_region', + [ + 'title' => __('Oracle OCI Email Delivery Active Region', 'ultimate-multisite'), + 'desc' => sprintf( + /* translators: %s is the OCI region identifier, e.g. eu-zurich-1. */ + __('Outbound email domain verification is currently managed through Oracle OCI Email Delivery in the %s region.', 'ultimate-multisite'), + esc_html($region) + ), + 'type' => 'note', + ] + ); + } + + /** + * Gets the parent OCI_Email_Integration for API calls. + * + * @since 2.5.0 + * @return OCI_Email_Integration + */ + private function get_oci(): OCI_Email_Integration { + + /** @var OCI_Email_Integration */ + return $this->get_integration(); + } + + /** + * Handles the wu_domain_added action. + * + * @since 2.5.0 + * + * @param string $domain The domain name that was added. + * @param int $site_id The site ID the domain was added to. + * @return void + */ + public function on_domain_added(string $domain, int $site_id): void { + + $result = $this->verify_domain($domain); + + if ( ! $result['success']) { + wu_log_add( + 'integration-oracle-oci', + sprintf( + 'Failed to initiate OCI Email Delivery domain verification for "%s". Reason: %s', + $domain, + $result['message'] ?? __('Unknown error', 'ultimate-multisite') + ), + LogLevel::ERROR + ); + + return; + } + + wu_log_add('integration-oracle-oci', sprintf('Initiated OCI Email Delivery domain verification for "%s".', $domain)); + + /** + * Fires after OCI domain verification has been initiated. + * + * @since 2.5.0 + * + * @param string $domain The domain name. + * @param int $site_id The site ID. + * @param array $dns_records The DNS records that must be added to complete verification. + */ + do_action('wu_domain_verified', $domain, $site_id, $result['dns_records'] ?? []); + } + + /** + * Handles the wu_domain_removed action. + * + * @since 2.5.0 + * + * @param string $domain The domain name that was removed. + * @param int $site_id The site ID the domain was removed from. + * @return void + */ + public function on_domain_removed(string $domain, int $site_id): void { + + $should_delete = apply_filters('wu_oci_delete_email_domain_on_domain_removed', false, $domain, $site_id); + + if ( ! $should_delete) { + return; + } + + $domain_record = $this->get_email_domain($domain); + + if (is_wp_error($domain_record)) { + wu_log_add('integration-oracle-oci', sprintf('Failed to locate OCI email domain for "%s". Reason: %s', $domain, $domain_record->get_error_message()), LogLevel::ERROR); + + return; + } + + $domain_id = $domain_record['id'] ?? ''; + + if ( ! $domain_id) { + return; + } + + $result = $this->get_oci()->oci_api_call('emailDomains/' . rawurlencode($domain_id), 'DELETE'); + + if (is_wp_error($result)) { + wu_log_add('integration-oracle-oci', sprintf('Failed to delete OCI email domain for "%s". Reason: %s', $domain, $result->get_error_message()), LogLevel::ERROR); + + return; + } + + wu_log_add('integration-oracle-oci', sprintf('Deleted OCI email domain for "%s".', $domain)); + } + + /** + * {@inheritdoc} + * + * @param string $domain The domain name. + */ + public function verify_domain(string $domain): array { + + $email_domain = $this->get_or_create_email_domain($domain); + + if (is_wp_error($email_domain)) { + return [ + 'success' => false, + 'message' => $email_domain->get_error_message(), + ]; + } + + $dkim = $this->get_or_create_dkim($email_domain, $domain); + + if (is_wp_error($dkim)) { + return [ + 'success' => false, + 'message' => $dkim->get_error_message(), + ]; + } + + return [ + 'success' => true, + 'dns_records' => $this->build_dns_records($domain, $email_domain, $dkim), + ]; + } + + /** + * {@inheritdoc} + * + * @param string $domain The domain name. + */ + public function get_domain_verification_status(string $domain): array { + + $result = $this->get_email_domain($domain); + + if (is_wp_error($result)) { + return [ + 'success' => false, + 'status' => 'unknown', + 'message' => $result->get_error_message(), + ]; + } + + $status = $result['lifecycleState'] ?? $result['lifecycle_state'] ?? $result['status'] ?? 'unknown'; + + return [ + 'success' => true, + 'status' => strtolower((string) $status), + ]; + } + + /** + * {@inheritdoc} + * + * @param string $domain The domain name. + */ + public function get_domain_dns_records(string $domain): array { + + $email_domain = $this->get_email_domain($domain); + + if (is_wp_error($email_domain)) { + return [ + 'success' => false, + 'message' => $email_domain->get_error_message(), + ]; + } + + $dkim = $this->get_or_create_dkim($email_domain, $domain); + + if (is_wp_error($dkim)) { + return [ + 'success' => false, + 'message' => $dkim->get_error_message(), + ]; + } + + return [ + 'success' => true, + 'dns_records' => $this->build_dns_records($domain, $email_domain, $dkim), + ]; + } + + /** + * {@inheritdoc} + * + * @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. + * @param array $headers Optional headers. + */ + public function send_email(string $from, string $to, string $subject, string $body, array $headers = []): array { + + return [ + 'success' => false, + 'message' => __('OCI Email Delivery sending is handled by the configured SMTP transport. This integration manages OCI domain, SPF, DKIM, and DMARC setup.', 'ultimate-multisite'), + ]; + } + + /** + * {@inheritdoc} + * + * @param string $domain The domain name. + * @param string $period The metrics period. + */ + public function get_sending_statistics(string $domain, string $period = '24h'): array { + + return [ + 'success' => true, + 'sent' => 0, + 'delivered' => 0, + 'bounced' => 0, + 'complaints' => 0, + 'message' => __('OCI Email Delivery delivery metrics are available in OCI Monitoring, not through this transactional email capability.', 'ultimate-multisite'), + ]; + } + + /** + * {@inheritdoc} + * + * @param string $domain The domain name. + * @param int $max_per_day Maximum emails allowed per day. + */ + public function set_sending_quota(string $domain, int $max_per_day): array { + + return [ + 'success' => true, + 'message' => __('Sending quota management is handled at the OCI tenancy/account level via Oracle Cloud Console.', 'ultimate-multisite'), + ]; + } + + /** + * {@inheritdoc} + */ + public function test_connection() { + + return $this->get_oci()->test_connection(); + } + + /** + * Create or return an existing OCI email domain. + * + * @since 2.5.0 + * + * @param string $domain Domain name. + * @return array|\WP_Error + */ + private function get_or_create_email_domain(string $domain) { + + $existing = $this->get_email_domain($domain); + + if ( ! is_wp_error($existing) && ! empty($existing)) { + return $existing; + } + + $body = [ + 'compartmentId' => $this->get_oci()->get_compartment_ocid(), + 'name' => $domain, + ]; + + $body = apply_filters('wu_oci_create_email_domain_body', $body, $domain); + + return $this->get_oci()->oci_api_call('emailDomains', 'POST', $body); + } + + /** + * Locate an OCI email domain by name. + * + * @since 2.5.0 + * + * @param string $domain Domain name. + * @return array|\WP_Error + */ + private function get_email_domain(string $domain) { + + $result = $this->get_oci()->oci_api_call( + 'emailDomains?compartmentId=' . rawurlencode($this->get_oci()->get_compartment_ocid()) . '&name=' . rawurlencode($domain) + ); + + if (is_wp_error($result)) { + return $result; + } + + $items = $result['items'] ?? $result; + + if (isset($items['name'])) { + return $items; + } + + if (is_array($items)) { + foreach ($items as $item) { + if (isset($item['name']) && strtolower($domain) === strtolower($item['name'])) { + return $item; + } + } + } + + return new \WP_Error( + 'oracle-oci-domain-not-found', + sprintf( + /* translators: %s is a domain name. */ + __('OCI email domain "%s" was not found.', 'ultimate-multisite'), + $domain + ) + ); + } + + /** + * Create or return an existing DKIM record for an OCI email domain. + * + * @since 2.5.0 + * + * @param array $email_domain OCI email domain object. + * @param string $domain Domain name. + * @return array|\WP_Error + */ + private function get_or_create_dkim(array $email_domain, string $domain) { + + $domain_id = $email_domain['id'] ?? ''; + + if ( ! $domain_id) { + return new \WP_Error('oracle-oci-domain-id-missing', __('OCI email domain response did not include an ID.', 'ultimate-multisite')); + } + + $existing = $this->get_oci()->oci_api_call('emailDomains/' . rawurlencode($domain_id) . '/dkims'); + + if ( ! is_wp_error($existing)) { + $items = $existing['items'] ?? $existing; + + if (isset($items['id'])) { + return $items; + } + + if (is_array($items) && ! empty($items)) { + return reset($items); + } + } + + $selector = sanitize_key(apply_filters('wu_oci_dkim_selector', 'ultimate-multisite', $domain, $email_domain)); + $body = apply_filters('wu_oci_create_dkim_body', ['name' => $selector], $domain, $email_domain); + + return $this->get_oci()->oci_api_call('emailDomains/' . rawurlencode($domain_id) . '/dkims', 'POST', $body); + } + + /** + * Build DNS records from OCI Email Delivery responses. + * + * @since 2.5.0 + * + * @param string $domain Domain name. + * @param array $email_domain OCI email domain response. + * @param array $dkim OCI DKIM response. + * @return array + */ + private function build_dns_records(string $domain, array $email_domain, array $dkim): array { + + $records = [ + [ + 'type' => 'TXT', + 'name' => $domain, + 'value' => 'v=spf1 include:' . $this->get_oci()->get_spf_include() . ' ~all', + ], + [ + 'type' => 'TXT', + 'name' => '_dmarc.' . $domain, + 'value' => 'v=DMARC1; p=none', + ], + ]; + + $dkim_record = $this->extract_dkim_record($domain, $dkim); + + if ($dkim_record) { + $records[] = $dkim_record; + } + + return apply_filters('wu_oci_email_dns_records', $records, $domain, $email_domain, $dkim); + } + + /** + * Extract a DKIM DNS record from flexible OCI response shapes. + * + * @since 2.5.0 + * + * @param string $domain Domain name. + * @param array $dkim OCI DKIM response. + * @return array{type: string, name: string, value: string}|null + */ + private function extract_dkim_record(string $domain, array $dkim): ?array { + + $name = $dkim['dnsSubdomainName'] ?? $dkim['dns_subdomain_name'] ?? $dkim['dnsName'] ?? ''; + $value = $dkim['dnsTxtRecordValue'] ?? $dkim['dns_txt_record_value'] ?? $dkim['txtRecordValue'] ?? $dkim['cnameRecordValue'] ?? ''; + $type = ! empty($dkim['cnameRecordValue']) ? 'CNAME' : 'TXT'; + + if ( ! $name) { + $selector = $dkim['name'] ?? $dkim['selector'] ?? 'ultimate-multisite'; + $name = $selector . '._domainkey.' . $domain; + } + + if ( ! $value) { + return null; + } + + return [ + 'type' => $type, + 'name' => $name, + 'value' => $value, + ]; + } +} From 95164a4833e0c98b5fc0b282e4856c234f637574 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 16 Jun 2026 13:53:17 -0600 Subject: [PATCH 2/2] feat: add email provider config guidance --- ...-hosting-integration-wizard-admin-page.php | 14 ++++++---- .../class-amazon-ses-integration.php | 20 ++++++++++++++ .../oci-email/class-oci-email-integration.php | 21 +++++++++++++++ .../host-integrations/configuration.php | 26 +++++++++++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/inc/admin-pages/class-hosting-integration-wizard-admin-page.php b/inc/admin-pages/class-hosting-integration-wizard-admin-page.php index 80d39eb5b..a888d067e 100644 --- a/inc/admin-pages/class-hosting-integration-wizard-admin-page.php +++ b/inc/admin-pages/class-hosting-integration-wizard-admin-page.php @@ -241,7 +241,10 @@ public function section_instructions(): void { */ public function section_configuration(): void { - $fields = $this->integration->get_fields(); + $fields = $this->integration->get_fields(); + $configuration_instructions = method_exists($this->integration, 'get_configuration_instructions') + ? call_user_func([$this->integration, 'get_configuration_instructions']) + : []; // Aggregate fields from capability modules if this is a new Integration if ($this->integration instanceof \WP_Ultimo\Integrations\Integration) { @@ -292,10 +295,11 @@ public function section_configuration(): void { wu_get_template( 'wizards/host-integrations/configuration', [ - 'screen' => get_current_screen(), - 'page' => $this, - 'integration' => $this->integration, - 'form' => $form, + 'screen' => get_current_screen(), + 'page' => $this, + 'integration' => $this->integration, + 'form' => $form, + 'configuration_instructions' => $configuration_instructions, ] ); } diff --git a/inc/integrations/providers/amazon-ses/class-amazon-ses-integration.php b/inc/integrations/providers/amazon-ses/class-amazon-ses-integration.php index 59d9f1bfa..aa102cea1 100644 --- a/inc/integrations/providers/amazon-ses/class-amazon-ses-integration.php +++ b/inc/integrations/providers/amazon-ses/class-amazon-ses-integration.php @@ -205,4 +205,24 @@ public function get_fields(): array { ], ]; } + + /** + * Returns provider-specific guidance for the configuration step. + * + * @since 2.5.0 + * @return array{title: string, description: string, steps: array} + */ + public function get_configuration_instructions(): array { + + return [ + 'title' => __('Amazon SES configuration checklist', 'ultimate-multisite'), + 'description' => __('Use IAM credentials that can manage SES identities and send mail in the selected region. After saving, Ultimate Multisite can request domain verification and return the required DNS records.', 'ultimate-multisite'), + 'steps' => [ + __('Create or choose an IAM access key with SES permissions such as ses:GetAccount, ses:CreateEmailIdentity, ses:GetEmailIdentity, ses:DeleteEmailIdentity, and ses:SendEmail.', 'ultimate-multisite'), + __('Enter the AWS Access Key ID and Secret Access Key. Prefer a restricted IAM user dedicated to Ultimate Multisite instead of a root or broad administrator key.', 'ultimate-multisite'), + __('Set the AWS region where SES is out of sandbox and where you want identities created, for example us-east-1. The region must match your approved SES sending region.', 'ultimate-multisite'), + __('After testing succeeds, add the SPF, DKIM, and DMARC records returned for each domain to DNS. Mail should only be considered fully configured when recipient headers show spf=pass, dkim=pass, and dmarc=pass.', 'ultimate-multisite'), + ], + ]; + } } diff --git a/inc/integrations/providers/oci-email/class-oci-email-integration.php b/inc/integrations/providers/oci-email/class-oci-email-integration.php index b43959f95..8d781c46e 100644 --- a/inc/integrations/providers/oci-email/class-oci-email-integration.php +++ b/inc/integrations/providers/oci-email/class-oci-email-integration.php @@ -248,4 +248,25 @@ public function get_fields(): array { ], ]; } + + /** + * Returns provider-specific guidance for the configuration step. + * + * @since 2.5.0 + * @return array{title: string, description: string, steps: array} + */ + public function get_configuration_instructions(): array { + + return [ + 'title' => __('Oracle OCI Email Delivery configuration checklist', 'ultimate-multisite'), + 'description' => __('Use OCI API key credentials for an IAM user that can manage Email Delivery domains and DKIM records in the selected compartment. SMTP credentials are still managed separately in OCI IAM and used by your server mail transport.', 'ultimate-multisite'), + 'steps' => [ + __('Create or choose an OCI IAM user with a policy that allows managing Email Delivery domains and DKIM in the target compartment.', 'ultimate-multisite'), + __('Generate an OCI API signing key for that user, then enter the Tenancy OCID, User OCID, API key fingerprint, private key, and Compartment OCID here.', 'ultimate-multisite'), + __('Set the OCI region to the Email Delivery region you use for SMTP, for example eu-zurich-1. The API region and SMTP region should match.', 'ultimate-multisite'), + __('Leave the SPF include host as rp.oracleemaildelivery.com unless Oracle provides a different include host for your tenancy.', 'ultimate-multisite'), + __('After testing succeeds, publish the returned SPF, DKIM, and DMARC DNS records. For existing SPF records, merge the OCI include into the single existing SPF TXT record instead of creating a second SPF record.', 'ultimate-multisite'), + ], + ]; + } } diff --git a/views/wizards/host-integrations/configuration.php b/views/wizards/host-integrations/configuration.php index ea2715d4f..048627640 100644 --- a/views/wizards/host-integrations/configuration.php +++ b/views/wizards/host-integrations/configuration.php @@ -9,6 +9,8 @@ /** @var \WP_Ultimo\Admin_Pages\Wizard_Admin_Page $page */ $back_url = $page->get_prev_section_link(); /** @var \WP_Ultimo\UI\Form $form */ +/** @var array $configuration_instructions */ +$configuration_instructions = $configuration_instructions ?? []; ?>

@@ -18,6 +20,30 @@

+ +
+ +

+ +

+ + + +

+ +

+ + + +
    + +
  1. + +
+ +
+ +