Skip to content

feat: added possibility to use Sendgrid, AWS SES, and SMTP for mails#3977

Open
thorsten wants to merge 1 commit intomainfrom
feat/mail-improvements
Open

feat: added possibility to use Sendgrid, AWS SES, and SMTP for mails#3977
thorsten wants to merge 1 commit intomainfrom
feat/mail-improvements

Conversation

@thorsten
Copy link
Owner

@thorsten thorsten commented Feb 13, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • Support for multiple email providers: SendGrid, AWS SES, Mailgun, and SMTP
    • Email delivery now queued by default and processed asynchronously by background worker
  • Documentation

    • Enhanced mail delivery configuration and provider setup documentation
    • Added multisite hosting installation guidance
    • Documented background job processing for local development

@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

📝 Walkthrough

Walkthrough

This PR adds support for multiple mail providers (SMTP, SendGrid, SES, Mailgun) to phpMyFAQ. The Mail class is refactored to support asynchronous delivery via queue with provider-based dispatch, introducing a MailProviderInterface with concrete provider implementations. Configuration, migrations, and documentation are updated accordingly.

Changes

Cohort / File(s) Summary
Mail Provider Infrastructure
phpmyfaq/src/phpMyFAQ/Mail/MailProviderInterface.php, phpmyfaq/src/phpMyFAQ/Mail/Provider/SendGridProvider.php, phpmyfaq/src/phpMyFAQ/Mail/Provider/SesProvider.php
Introduces MailProviderInterface contract and two concrete provider implementations (SendGrid, SES) for HTTP/AWS-based mail dispatch with recipient parsing and header extraction.
Mail Delivery Refactoring
phpmyfaq/src/phpMyFAQ/Mail.php, phpmyfaq/src/phpMyFAQ/Configuration/MailSettings.php, phpmyfaq/src/phpMyFAQ/Configuration.php
Refactors Mail::send() to accept forceSynchronousDelivery parameter, adds sendPreparedEnvelope() for provider dispatch, and introduces getProvider() logic with provider validation against allowed list (smtp, sendgrid, ses, mailgun).
Configuration & Setup
phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php, phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php
Adds default configuration keys for new mail providers (mail.sendgridApiKey, mail.sesAccessKeyId/SecretAccessKey/Region, mail.mailgunApiKey/Domain) in seeder and migration.
Queue Integration
phpmyfaq/src/phpMyFAQ/Queue/Handler/SendMailHandler.php
Updates SendMailHandler to support prepared envelope path (recipients, headers, body) and calls Mail::sendPreparedEnvelope(), with fallback to forceSynchronous mode for existing path.
Documentation
docs/administration.md, docs/development.md
Documents mail provider configuration (smtp default, SendGrid, SES, Mailgun), queue delivery behavior, worker execution commands, and multisite directory structure guidance.
Tests
tests/phpMyFAQ/Configuration/MailSettingsTest.php, tests/phpMyFAQ/Mail/Provider/SendGridProviderTest.php, tests/phpMyFAQ/Mail/Provider/SesProviderTest.php, tests/phpMyFAQ/MailTest.php
Adds unit tests for MailSettings provider fallback logic, SendGrid send/payload/error handling, SES credential/header validation, and Mail queue enqueue behavior.
Test Refactoring
tests/phpMyFAQ/Scheduler/TaskSchedulerTest.php
Minor updates: Session mock replaced with stub, logger assertion formatting adjusted for clarity.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Mail
    participant Queue
    participant SendMailHandler
    participant Provider
    
    Client->>Mail: send(forceSynchronousDelivery=false)
    alt Queue Available & Not Forced Sync
        Mail->>Queue: enqueueForDelivery(recipients, headers, body)
        Queue-->>Mail: queued
        Mail-->>Client: returns 1
        
        Note over Queue,Provider: Background Processing
        Queue->>SendMailHandler: invoke(message with envelope)
        SendMailHandler->>Mail: sendPreparedEnvelope(recipients, headers, body)
    else Forced Sync or No Queue
        Mail->>Mail: createProvider(mail.provider)
        Mail->>Provider: send(recipients, headers, body)
        Provider-->>Mail: recipient count
        Mail-->>Client: returns count
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PR #3971: Implements the DB-backed queue infrastructure (DatabaseTransport, SendMailMessage, message-bus, worker) that this PR depends on for asynchronous mail delivery.

Poem

🐰 With providers galore—SendGrid, SES, and SMTP too,
I queue up the mail for async delivery through and through,
No more blocking sends! Each message takes flight,
The rabbit's refactor makes email delivery right! 📬✨

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.54% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main objective of the pull request: adding support for multiple mail providers (SendGrid, AWS SES, and SMTP).
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/mail-improvements

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@docs/development.md`:
- Around line 266-283: There are two problems: the new "### 9.6.4 Background
jobs during development" heading duplicates the later "### 9.6.4 How to get the
phpMyFAQ source code?" heading and an orphaned line "$ git config core.autocrlf"
sits between them; fix by renumbering the later heading and all subsequent 9.6.x
headings (change "### 9.6.4 How to get the phpMyFAQ source code?" → "### 9.6.5"
and bump 9.6.5→9.6.6, etc.), and move the orphaned line "$ git config
core.autocrlf" back into the Git configuration section (the original 9.6.3) or
under its appropriate heading so it is no longer standalone.

In `@phpmyfaq/src/phpMyFAQ/Configuration/MailSettings.php`:
- Around line 26-27: Remove 'mailgun' from the ALLOWED_PROVIDERS constant in
MailSettings.php so it no longer advertises an unimplemented provider;
specifically edit the private const array ALLOWED_PROVIDERS (leaving
DEFAULT_PROVIDER unchanged) to exclude 'mailgun' and keep the existing explicit
check/error in sendPreparedEnvelope() so callers still get the current "Mailgun
provider is not implemented yet." message if used elsewhere.

In `@phpmyfaq/src/phpMyFAQ/Mail.php`:
- Around line 432-434: In send(), when the enqueueForDelivery($recipients,
$this->headers, $this->body) path succeeds, do not unconditionally return 1;
instead compute and return the actual recipient count (e.g., sum of counts of
$this->to, $this->cc and $this->bcc) or return a clear sentinel (e.g., -1) to
indicate queued delivery; update the return expression in the branch that
currently returns 1 inside the send() method (and keep the
$forceSynchronousDelivery check and enqueueForDelivery call as-is) so callers
receive a meaningful value.
- Around line 439-454: Update the PHPDoc for the headers parameter to reflect
that header values can be string or int (not only string): change the `@param` on
sendPreparedEnvelope to array<string, string|int> (matching the class-level doc
and the value set by createHeaders for 'X-Priority'), and make the same change
for the `@param` annotation on sendViaSmtpAgent so both methods accurately
describe header value types.
🧹 Nitpick comments (7)
tests/phpMyFAQ/Mail/Provider/SendGridProviderTest.php (1)

1-61: Tests look good; consider adding a failure-response test.

The two tests cover the key paths (missing API key and successful send). You may want to add a test for non-2xx responses from SendGrid to verify the exception message includes the status code and response body, matching the SendGridProvider::send error branch at lines 86–93 of SendGridProvider.php.

tests/phpMyFAQ/Mail/Provider/SesProviderTest.php (1)

1-42: Consider adding a success-path test with a mocked SesClient.

Unlike SendGridProviderTest, there is no test that exercises the happy path (valid credentials, successful sendEmail call). The constructor already accepts an optional ?SesClient $sesClient for exactly this purpose — you could pass a mocked SesClient to verify sendEmail is called with the expected arguments and that the method returns the correct recipient count.

phpmyfaq/src/phpMyFAQ/Mail.php (1)

794-804: createProvider duplicates provider knowledge with sendPreparedEnvelope.

Both sendPreparedEnvelope (line 447) and createProvider (line 799) maintain independent match expressions over provider names. If a new provider is added, both must be updated. Consider consolidating: sendPreparedEnvelope could delegate to createProvider for all non-SMTP providers by having createProvider handle the 'mailgun' case (throw not-implemented) and the default case.

Sketch
     public function sendPreparedEnvelope(string $recipients, array $headers, string $body): int
     {
         $provider = $this->configuration->getMailProvider();
 
-        return match ($provider) {
-            'smtp' => $this->sendViaSmtpAgent($recipients, $headers, $body),
-            'sendgrid' => $this->createProvider('sendgrid')->send($recipients, $headers, $body),
-            'ses' => $this->createProvider('ses')->send($recipients, $headers, $body),
-            'mailgun' => throw new Exception('Mailgun provider is not implemented yet.'),
-            default => $this->sendViaSmtpAgent($recipients, $headers, $body),
-        };
+        if ($provider === 'smtp' || !in_array($provider, ['sendgrid', 'ses', 'mailgun'], true)) {
+            return $this->sendViaSmtpAgent($recipients, $headers, $body);
+        }
+
+        return $this->createProvider($provider)->send($recipients, $headers, $body);
     }
 
     private function createProvider(string $provider): MailProviderInterface
     {
         return match ($provider) {
             'sendgrid' => new SendGridProvider($this->configuration),
             'ses' => new SesProvider($this->configuration),
+            'mailgun' => throw new Exception('Mailgun provider is not implemented yet.'),
             default => throw new Exception('Unsupported mail provider: ' . $provider),
         };
     }
phpmyfaq/src/phpMyFAQ/Mail/Provider/SesProvider.php (2)

55-83: Both Text and Html body content are identical.

Lines 67–74 set both the plaintext and HTML parts of the SES message to the same $body value. If the body contains HTML markup, plaintext email clients will render raw tags; if it's plaintext, the HTML part won't be properly formatted. The same pattern exists in SendGridProvider (lines 73–74).

Consider stripping HTML tags for the Text part (e.g., via strip_tags($body)) to provide a proper plaintext fallback.

Proposed fix
                 'Body' => [
                     'Text' => [
-                        'Data' => $body,
+                        'Data' => strip_tags($body),
                         'Charset' => 'UTF-8',
                     ],
                     'Html' => [

101-108: extractEmailAddress does not validate the extracted value is an email.

If the input is <malformed>, this returns malformed without any validation. The Mail::validateEmail utility exists in the codebase and could be reused here to ensure only valid emails pass through. The same applies to SendGridProvider::extractEmailAddress.

phpmyfaq/src/phpMyFAQ/Mail/Provider/SendGridProvider.php (2)

98-121: parseRecipients and extractEmailAddress are duplicated across providers.

These two methods are identical in both SendGridProvider and SesProvider. Consider extracting them into a shared trait (e.g., ParsesRecipientsTrait) or a small utility class to keep DRY.

Trait sketch
// phpmyfaq/src/phpMyFAQ/Mail/Provider/ParsesRecipientsTrait.php
trait ParsesRecipientsTrait
{
    /** `@return` array<int, string> */
    private function parseRecipients(string $recipients): array
    {
        $addresses = [];
        foreach (explode(',', $recipients) as $recipient) {
            $address = $this->extractEmailAddress($recipient);
            if ($address !== '') {
                $addresses[] = $address;
            }
        }

        return array_values(array_unique($addresses));
    }

    private function extractEmailAddress(string $rawAddress): string
    {
        if (preg_match('/<([^>]+)>/', $rawAddress, $matches) === 1) {
            return trim($matches[1]);
        }

        return trim($rawAddress);
    }
}

72-74: Same Text/Html body duplication as noted in SesProvider.

Both content types use the raw $body verbatim. Consider strip_tags($body) for the text/plain entry so plaintext clients get a readable fallback.

Proposed fix
             'content' => [
-                ['type' => 'text/plain', 'value' => $body],
+                ['type' => 'text/plain', 'value' => strip_tags($body)],
                 ['type' => 'text/html', 'value' => $body],
             ],

Comment on lines +266 to 283

### 9.6.4 Background jobs during development

phpMyFAQ uses the internal queue for asynchronous tasks such as mail delivery.
Run the worker locally while testing features that send email:

```bash
php bin/worker.php
```

Optionally limit processing to a fixed number of jobs:

```bash
php bin/worker.php 10
```
$ git config core.autocrlf # if you're on Windows

### 9.6.4 How to get the phpMyFAQ source code?
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Duplicate section number and orphaned line.

Two issues:

  1. Duplicate heading: The new section at line 267 and the existing section at line 283 are both numbered ### 9.6.4. The subsequent sections (9.6.5–9.6.9) need to be renumbered as well.
  2. Orphaned content: Line 281 ($ git config core.autocrlf) was previously part of the Git configuration section (9.6.3) but now sits between the new 9.6.4 and the old 9.6.4 without a heading, making it look detached.
Proposed fix
     $ git config --global user.email you@example.com
+    $ git config core.autocrlf # if you're on Windows

-### 9.6.4 Background jobs during development
+### 9.6.4 Background jobs during development

 phpMyFAQ uses the internal queue for asynchronous tasks such as mail delivery.
 Run the worker locally while testing features that send email:

 ```bash
 php bin/worker.php

Optionally limit processing to a fixed number of jobs:

php bin/worker.php 10
  • $ git config core.autocrlf # if you're on Windows

-### 9.6.4 How to get the phpMyFAQ source code?
+### 9.6.5 How to get the phpMyFAQ source code?


And renumber all subsequent sections (9.6.5 → 9.6.6, etc.).
</details>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.20.0)</summary>

[warning] 272-272: Code block style
Expected: indented; Actual: fenced

(MD046, code-block-style)

---

[warning] 278-278: Code block style
Expected: indented; Actual: fenced

(MD046, code-block-style)

---

[warning] 281-281: Dollar signs used before commands without showing output

(MD014, commands-show-output)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

In @docs/development.md around lines 266 - 283, There are two problems: the new
"### 9.6.4 Background jobs during development" heading duplicates the later "###
9.6.4 How to get the phpMyFAQ source code?" heading and an orphaned line "$ git
config core.autocrlf" sits between them; fix by renumbering the later heading
and all subsequent 9.6.x headings (change "### 9.6.4 How to get the phpMyFAQ
source code?" → "### 9.6.5" and bump 9.6.5→9.6.6, etc.), and move the orphaned
line "$ git config core.autocrlf" back into the Git configuration section (the
original 9.6.3) or under its appropriate heading so it is no longer standalone.


</details>

<!-- fingerprinting:phantom:poseidon:phoenix -->

<!-- This is an auto-generated comment by CodeRabbit -->

Comment on lines +26 to +27
private const string DEFAULT_PROVIDER = 'smtp';
private const array ALLOWED_PROVIDERS = ['smtp', 'sendgrid', 'ses', 'mailgun'];
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine the createProvider method in Mail.php
fd -t f "Mail.php" phpmyfaq/src/phpMyFAQ/ | head -5

Repository: thorsten/phpMyFAQ

Length of output: 91


🏁 Script executed:

# Check the structure of the codebase
ls -la phpmyfaq/src/phpMyFAQ/ | head -20

Repository: thorsten/phpMyFAQ

Length of output: 1221


🏁 Script executed:

# Read the Mail.php file to examine createProvider method
wc -l phpmyfaq/src/phpMyFAQ/Mail.php

Repository: thorsten/phpMyFAQ

Length of output: 95


🏁 Script executed:

# Read MailSettings.php to confirm ALLOWED_PROVIDERS
cat -n phpmyfaq/src/phpMyFAQ/Configuration/MailSettings.php

Repository: thorsten/phpMyFAQ

Length of output: 2012


🏁 Script executed:

# Search for createProvider method in Mail.php
rg -n "createProvider" phpmyfaq/src/phpMyFAQ/Mail.php

Repository: thorsten/phpMyFAQ

Length of output: 333


🏁 Script executed:

# Search for provider handling logic
rg -n "match\|switch" phpmyfaq/src/phpMyFAQ/Mail.php -A 20 | head -50

Repository: thorsten/phpMyFAQ

Length of output: 43


🏁 Script executed:

# Read the createProvider method
sed -n '797,836p' phpmyfaq/src/phpMyFAQ/Mail.php | cat -n

Repository: thorsten/phpMyFAQ

Length of output: 1728


🏁 Script executed:

# Also check the broader send method to understand the flow (around line 449)
sed -n '430,470p' phpmyfaq/src/phpMyFAQ/Mail.php | cat -n

Repository: thorsten/phpMyFAQ

Length of output: 1995


Remove mailgun from ALLOWED_PROVIDERS until implementation is complete.

mailgun is listed in ALLOWED_PROVIDERS in MailSettings.php but is not implemented. While it is explicitly caught in sendPreparedEnvelope() with a clear error message ('Mailgun provider is not implemented yet.'), including it in the allowed providers list creates a false expectation of availability. Remove it from the array until the provider is fully implemented.

🤖 Prompt for AI Agents
In `@phpmyfaq/src/phpMyFAQ/Configuration/MailSettings.php` around lines 26 - 27,
Remove 'mailgun' from the ALLOWED_PROVIDERS constant in MailSettings.php so it
no longer advertises an unimplemented provider; specifically edit the private
const array ALLOWED_PROVIDERS (leaving DEFAULT_PROVIDER unchanged) to exclude
'mailgun' and keep the existing explicit check/error in sendPreparedEnvelope()
so callers still get the current "Mailgun provider is not implemented yet."
message if used elsewhere.

Comment on lines +432 to 434
if (!$forceSynchronousDelivery && $this->enqueueForDelivery($recipients, $this->headers, $this->body)) {
return 1;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Enqueue path always returns 1 regardless of actual recipient count.

When the mail is enqueued successfully, send() returns 1, but the synchronous paths return the actual number of recipients. Callers relying on the return value to know how many recipients were addressed will get an inaccurate result for queued mails.

Consider returning the actual recipient count (e.g., count($this->to) + count($this->cc) + count($this->bcc)) or a sentinel value that clearly indicates the mail was queued rather than sent.

🤖 Prompt for AI Agents
In `@phpmyfaq/src/phpMyFAQ/Mail.php` around lines 432 - 434, In send(), when the
enqueueForDelivery($recipients, $this->headers, $this->body) path succeeds, do
not unconditionally return 1; instead compute and return the actual recipient
count (e.g., sum of counts of $this->to, $this->cc and $this->bcc) or return a
clear sentinel (e.g., -1) to indicate queued delivery; update the return
expression in the branch that currently returns 1 inside the send() method (and
keep the $forceSynchronousDelivery check and enqueueForDelivery call as-is) so
callers receive a meaningful value.

Comment on lines +439 to 454
/**
* @param array<string, string> $headers
* @throws Exception|TransportExceptionInterface
*/
public function sendPreparedEnvelope(string $recipients, array $headers, string $body): int
{
$provider = $this->configuration->getMailProvider();

return match ($provider) {
'smtp' => $this->sendViaSmtpAgent($recipients, $headers, $body),
'sendgrid' => $this->createProvider('sendgrid')->send($recipients, $headers, $body),
'ses' => $this->createProvider('ses')->send($recipients, $headers, $body),
'mailgun' => throw new Exception('Mailgun provider is not implemented yet.'),
default => $this->sendViaSmtpAgent($recipients, $headers, $body),
};
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

PHPDoc type for $headers doesn't match actual header values.

The @param array<string, string> annotation declares all header values as strings, but createHeaders() stores an int value at $this->headers['X-Priority'] (line 552). The class-level doc (line 92) already acknowledges array<string|int>. The same mismatch applies to the sendViaSmtpAgent PHPDoc at line 720.

Proposed fix
     /**
-     * `@param` array<string, string> $headers
+     * `@param` array<string, string|int> $headers
      * `@throws` Exception|TransportExceptionInterface
      */
     public function sendPreparedEnvelope(string $recipients, array $headers, string $body): int

And similarly for sendViaSmtpAgent:

     /**
-     * `@param` array<string, string> $headers
+     * `@param` array<string, string|int> $headers
      * `@throws` Exception|TransportExceptionInterface
      */
     private function sendViaSmtpAgent(string $recipients, array $headers, string $body): int
🤖 Prompt for AI Agents
In `@phpmyfaq/src/phpMyFAQ/Mail.php` around lines 439 - 454, Update the PHPDoc for
the headers parameter to reflect that header values can be string or int (not
only string): change the `@param` on sendPreparedEnvelope to array<string,
string|int> (matching the class-level doc and the value set by createHeaders for
'X-Priority'), and make the same change for the `@param` annotation on
sendViaSmtpAgent so both methods accurately describe header value types.

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.

1 participant