Skip to content

Add SES V2 API support, tenant isolation and custom SMTP headers - #348

Open
dlackovic2 wants to merge 4 commits into
WPManageNinja:masterfrom
dlackovic2:feature/add-ses-tenant-support
Open

dlackovic2 wants to merge 4 commits into
WPManageNinja:masterfrom
dlackovic2:feature/add-ses-tenant-support

Conversation

@dlackovic2

Copy link
Copy Markdown

Add SES V2 API Support, Tenant Mode, Config Sets & Custom SMTP Headers

Summary

This PR adds full Amazon SES V2 API support to FluentSMTP, including tenant isolation, configuration set validation, error handling, and updated UI fields.
SMTP provider now supports custom headers (e.g. X-SES-TENANT) for tenant routing.


Key Changes

  • New SES V2 client (SimpleEmailServiceV2)
  • Replaced SES V1 usage in handler, validator, helper
  • Added Tenant Mode + validation for configuration sets and tenants
  • Updated admin UI (Amazon SES + SMTP providers)
  • Improved AWS error handling + validation messages
  • Updated sending flow to use /v2/email/outbound-emails
  • SMTP provider now supports custom headers

Testing

SES V2

  1. Add AWS keys + region
  2. Send test email
  3. Should return valid MessageId

Tenant Mode

  1. Enable tenant mode

  2. Enter configuration set name + tenant name

  3. Validation should:

    • fail if resources don’t exist
    • fail if permissions missing
    • succeed otherwise
  4. Send test email → tenant should apply

SMTP Custom Headers

  1. Add custom headers in SMTP settings
  2. Send email
  3. Raw email should contain all headers

Copilot AI review requested due to automatic review settings December 5, 2025 02:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds comprehensive AWS SES V2 API support to FluentSMTP, replacing the legacy V1 API implementation. It introduces tenant isolation capabilities for multi-tenant environments and adds custom SMTP header support for enhanced routing capabilities.

Key Changes:

  • Complete migration from SES V1 to V2 API with new SimpleEmailServiceV2 client class
  • Tenant isolation feature with configuration set and tenant name validation
  • Custom SMTP headers support for advanced email routing scenarios

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
app/Services/Mailer/Providers/AmazonSes/SimpleEmailServiceV2.php New SES V2 API client with AWS Signature V4 authentication, tenant support, and configuration set handling
app/Services/Mailer/Providers/AmazonSes/Handler.php Updated to use V2 API for sending emails, listing identities, and retrieving account statistics
app/Services/Mailer/Providers/AmazonSes/ValidatorTrait.php Enhanced validation with tenant/configuration set checks and improved AWS error message parsing
app/Services/Mailer/Providers/Smtp/Handler.php Added custom header parsing and injection for SMTP emails
resources/admin/Modules/Settings/Partials/Providers/AmazonSes.vue Added UI controls for tenant mode configuration with conditional field visibility
resources/admin/Modules/Settings/Partials/Providers/Smtp.vue Added custom headers textarea input with help text
app/Functions/helpers.php Updated helper function to instantiate V2 client instead of V1

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/Services/Mailer/Providers/AmazonSes/SimpleEmailServiceV2.php
Comment thread app/Services/Mailer/Providers/AmazonSes/Handler.php
Comment on lines +208 to +219

// Check if it's a domain (no @ sign) or email address
if (strpos($identityName, '@') === false) {
// It's a domain
if ($sendingEnabled) {
$domains[] = $identityName;
}
} else {
// It's an email address
if ($sendingEnabled) {
$addresses[] = $identityName;
}

Copilot AI Dec 5, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic to determine if an identity is a domain or email address by checking for '@' sign may not be fully reliable. AWS SES identities could include other types of resources. Additionally, the SendingEnabled field might not exist in all identity responses from the V2 API.

Consider checking the IdentityType field if available in the API response to reliably distinguish between email addresses and domains, rather than relying on the presence of '@' character.

Suggested change
// Check if it's a domain (no @ sign) or email address
if (strpos($identityName, '@') === false) {
// It's a domain
if ($sendingEnabled) {
$domains[] = $identityName;
}
} else {
// It's an email address
if ($sendingEnabled) {
$addresses[] = $identityName;
}
$identityType = isset($identity['IdentityType']) ? $identity['IdentityType'] : null;
// Use IdentityType to distinguish between domain and email address
if ($identityType === 'DOMAIN' || $identityType === 'MANAGED_DOMAIN') {
if ($sendingEnabled) {
$domains[] = $identityName;
}
} elseif ($identityType === 'EMAIL_ADDRESS') {
if ($sendingEnabled) {
$addresses[] = $identityName;
}
} else {
// Fallback: if IdentityType is missing, use previous logic
if (strpos($identityName, '@') === false) {
if ($sendingEnabled) {
$domains[] = $identityName;
}
} else {
if ($sendingEnabled) {
$addresses[] = $identityName;
}
}

Copilot uses AI. Check for mistakes.
if (isset($this->response['MessageId'])) {
$this->response = [
'MessageId' => $this->response['MessageId']
];

Copilot AI Dec 5, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the SES V2 API returns a successful response without a MessageId field, the $this->response will be the full API response array, which may not match the expected format downstream. This could cause unexpected behavior if the response structure doesn't match what handleResponse() expects.

Consider adding an else clause to handle the case where MessageId is not present:

if (isset($this->response['MessageId'])) {
    $this->response = [
        'MessageId' => $this->response['MessageId']
    ];
} else {
    // Handle unexpected response format
    $this->response = new \WP_Error(422, __('Invalid response from SES API', 'fluent-smtp'), []);
}
Suggested change
];
];
} else {
$this->response = new \WP_Error(422, __('Invalid response from SES API', 'fluent-smtp'), []);

Copilot uses AI. Check for mistakes.
Comment on lines +412 to +413
// Only include verified identities
if (isset($identity['SendingEnabled']) && $identity['SendingEnabled']) {

Copilot AI Dec 5, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The listVerifiedEmailAddresses() method includes both email addresses and domains in the Identities array. This differs from the SES V1 API which had separate methods for listing verified email addresses and domains.

If existing code expects listVerifiedEmailAddresses() to return only email addresses (not domains), this could cause unexpected behavior. Consider filtering to only include identities that contain '@' character to match the V1 API behavior:

if (isset($identity['SendingEnabled']) && $identity['SendingEnabled'] && strpos($identity['IdentityName'], '@') !== false) {
    $verifiedEmails[] = $identity['IdentityName'];
}
Suggested change
// Only include verified identities
if (isset($identity['SendingEnabled']) && $identity['SendingEnabled']) {
// Only include verified email addresses (not domains)
if (
isset($identity['SendingEnabled']) && $identity['SendingEnabled'] &&
strpos($identity['IdentityName'], '@') !== false
) {

Copilot uses AI. Check for mistakes.
if ($colonPos !== false) {
$headerName = trim(substr($headerLine, 0, $colonPos));
$headerValue = trim(substr($headerLine, $colonPos + 1));
if (!empty($headerName) && !empty($headerValue)) {

Copilot AI Dec 5, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom headers from user input are added to SMTP messages without validation or sanitization. This could allow header injection attacks where malicious users could inject newline characters (\r\n) in header values to inject additional headers or manipulate the email structure.

Consider validating that:

  1. Header names contain only valid characters (RFC 5322: printable ASCII, no colons or whitespace)
  2. Header values do not contain newline characters (\r or \n)
  3. Consider using a whitelist of allowed header names if possible

Example mitigation:

// Validate header name (RFC 5322)
if (!preg_match('/^[!-9;-~]+$/', $headerName)) {
    continue; // Skip invalid header names
}
// Validate header value (no newlines)
if (preg_match('/[\r\n]/', $headerValue)) {
    continue; // Skip values with newlines
}
Suggested change
if (!empty($headerName) && !empty($headerValue)) {
if (!empty($headerName) && !empty($headerValue)) {
// Validate header name (RFC 5322: printable ASCII, no colons or whitespace)
if (!preg_match('/^[!-9;-~]+$/', $headerName)) {
continue; // Skip invalid header names
}
// Validate header value (no newlines)
if (preg_match('/[\r\n]/', $headerValue)) {
continue; // Skip values with newlines
}

Copilot uses AI. Check for mistakes.
Comment thread app/Functions/helpers.php Outdated
Comment thread app/Services/Mailer/Providers/AmazonSes/Handler.php Outdated
Comment thread app/Services/Mailer/Providers/AmazonSes/Handler.php Outdated
- Refactor SES V2 initialization by removing unnecessary parameter since we can use default in construcotr for SSL verfyPeer
- enhance header validation to prevent injection attacks

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +134 to +135
// Only allow alphanumeric, hyphen, and underscore in header names
if (!empty($headerName) && !empty($headerValue) && preg_match('/^[A-Za-z0-9\-_]+$/', $headerName)) {

Copilot AI Dec 5, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern /^[A-Za-z0-9\-_]+$/ doesn't allow dots (.) in header names, but dots are valid characters in RFC 822 header names. For example, X-SES.Tenant would be rejected even though it's a valid header name according to RFC standards. Consider updating the pattern to /^[A-Za-z0-9\-_.]+$/ to allow dots.

Suggested change
// Only allow alphanumeric, hyphen, and underscore in header names
if (!empty($headerName) && !empty($headerValue) && preg_match('/^[A-Za-z0-9\-_]+$/', $headerName)) {
// Only allow alphanumeric, hyphen, underscore, and dot in header names
if (!empty($headerName) && !empty($headerValue) && preg_match('/^[A-Za-z0-9\-_.]+$/', $headerName)) {

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +90
} catch (ValidationException $e) {
throw $e;

Copilot AI Dec 5, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch blocks that re-throw ValidationException immediately after catching it are redundant. Since ValidationException extends from the base \Exception, it will already be caught by the subsequent catch (\Exception $e) block. Consider removing these redundant catch blocks to simplify the code.

Copilot uses AI. Check for mistakes.
Comment on lines +105 to +106
} catch (ValidationException $e) {
throw $e;

Copilot AI Dec 5, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch blocks that re-throw ValidationException immediately after catching it are redundant. Since ValidationException extends from the base \Exception, it will already be caught by the subsequent catch (\Exception $e) block. Consider removing these redundant catch blocks to simplify the code.

Copilot uses AI. Check for mistakes.
Comment on lines +370 to +371
if ($httpCode >= 200 && $httpCode < 300) {
$response = ['success' => true, 'httpCode' => $httpCode];

Copilot AI Dec 5, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the response body is empty and the HTTP code is in the 2xx range, the code creates a success response with ['success' => true, 'httpCode' => $httpCode]. However, for operations like sendEmail(), the caller expects a MessageId in the response. This could lead to issues where a 2xx response without proper JSON would be treated as successful even though it's missing required data. Consider validating that the response contains expected fields for critical operations or logging a warning when receiving an empty successful response.

Copilot uses AI. Check for mistakes.
@dlackovic2

Copy link
Copy Markdown
Author

Comment just to see if this will make PR reappear since it is not appearing in PRs at all

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants