MiknasTrading/docs/superpowers/specs/2026-05-26-azure-mailer-design.md
Ghassan Yusuf a922450e50 docs: add azure-mailer package design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:43:55 +03:00

232 lines
7.3 KiB
Markdown

# azure-mailer — Design Spec
**Package:** `promoseven/azure-mailer`
**Date:** 2026-05-26
**Status:** Approved
---
## Overview
A Laravel package that provides a custom Symfony Mailer transport backed by the Microsoft Graph API. Enables Laravel applications to send email via Microsoft 365 / Exchange Online using Azure AD Client Credentials — a drop-in replacement for SMTP that requires zero changes to existing `Mail::to()->send()` calls.
---
## Goals
- Send email via `POST /v1.0/users/{from}/sendMail` on the Microsoft Graph API
- Authenticate using Azure AD OAuth2 Client Credentials flow
- Cache access tokens via Laravel Cache to avoid redundant Azure AD requests
- Support HTML/plain-text bodies, CC, BCC, Reply-To, and file attachments
- Integrate as a first-class Laravel mail transport driver (configured in `config/mail.php`)
- Auto-discovered via Laravel package discovery — zero manual registration
## Non-Goals (v1)
- Delegated (user OAuth) authentication
- Certificate-based auth
- Calendar invites or other Graph API mail features
- Standalone facade / direct API access
- Multi-from-address support
---
## Package Structure
```
promoseven/azure-mailer/
├── src/
│ ├── AzureMailerServiceProvider.php
│ ├── Transport/
│ │ └── AzureTransport.php
│ ├── Graph/
│ │ ├── TokenManager.php
│ │ └── GraphClient.php
│ └── Exceptions/
│ ├── AuthenticationException.php
│ └── GraphApiException.php
├── config/
│ └── azure-mailer.php
├── tests/
├── composer.json
└── README.md
```
---
## Architecture
### Layered design — three focused classes
| Class | Responsibility |
|-------|---------------|
| `AzureTransport` | Symfony `AbstractTransport` adapter — extracts data from `Email` object, assembles payload, delegates to `GraphClient` |
| `GraphClient` | HTTP calls to Graph API — calls `TokenManager` for Bearer token, POSTs payload, handles Graph error responses |
| `TokenManager` | OAuth2 Client Credentials token fetch + Laravel Cache — single source of truth for the access token |
Dependencies flow one way: `AzureTransport → GraphClient → TokenManager`. No circular dependencies.
---
## Authentication
**Flow:** OAuth2 Client Credentials
1. `TokenManager::getToken()` checks `Cache::get("azure_mailer_token_{client_id}")`
2. On cache miss: POST to `https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token`
- `grant_type=client_credentials`
- `scope=https://graph.microsoft.com/.default`
- `client_id`, `client_secret`
3. Cache the returned `access_token` with TTL = `expires_in - 60` seconds
4. On Azure AD error: throw `AuthenticationException` with error description from response
**401 retry logic in `GraphClient`:**
- On first 401 from Graph API: `Cache::forget(...)`, call `TokenManager::getToken()` to force fresh fetch, retry the request once
- Log a warning via `Log::warning('azure-mailer: 401 received, retrying with fresh token')`
- On second 401: throw `GraphApiException` — do not retry again
**HTTP client:** Laravel `Http` facade (no extra Guzzle dependency).
---
## Graph API Payload
**Endpoint:** `POST https://graph.microsoft.com/v1.0/users/{from_address}/sendMail`
```json
{
"message": {
"subject": "string",
"body": {
"contentType": "HTML",
"content": "string"
},
"toRecipients": [{ "emailAddress": { "address": "string", "name": "string" } }],
"ccRecipients": [{ "emailAddress": { "address": "string", "name": "string" } }],
"bccRecipients": [{ "emailAddress": { "address": "string", "name": "string" } }],
"replyTo": [{ "emailAddress": { "address": "string", "name": "string" } }],
"attachments": [
{
"@odata.type": "#microsoft.graph.fileAttachment",
"name": "filename.pdf",
"contentType": "application/pdf",
"contentBytes": "<base64-encoded bytes>"
}
]
},
"saveToSentItems": false
}
```
**Body content type:** HTML if the `Email` object has an HTML part; falls back to `Text`.
**Attachments:** Each `DataPart` in the Symfony `Email` object is base64-encoded and mapped to a `fileAttachment` entry.
**`saveToSentItems`:** Defaults to `false`. Configurable via published config to avoid cluttering the sending mailbox.
---
## Configuration
### Consumer's `config/mail.php`
```php
'azure' => [
'transport' => 'azure',
'tenant_id' => env('AZURE_TENANT_ID'),
'client_id' => env('AZURE_CLIENT_ID'),
'client_secret' => env('AZURE_CLIENT_SECRET'),
'from_address' => env('AZURE_MAIL_FROM_ADDRESS'),
],
```
### Environment variables
```env
AZURE_TENANT_ID=
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_MAIL_FROM_ADDRESS=
```
### Published config (`config/azure-mailer.php`)
Exposes advanced overrides published via `php artisan vendor:publish --tag=azure-mailer-config`:
```php
return [
'save_to_sent_items' => false,
'timeout' => 30,
'graph_api_version' => 'v1.0',
];
```
---
## Service Provider
`AzureMailerServiceProvider` does three things:
1. **Extends Laravel's `MailManager`** with the `azure` transport factory:
```php
$this->app->make(MailManager::class)->extend('azure', function (array $config) {
return new AzureTransport(
new GraphClient(new TokenManager($config), $config),
$config
);
});
```
2. **Registers config publishing**`vendor:publish --tag=azure-mailer-config`
3. **Auto-discovered** via `extra.laravel.providers` in `composer.json` — no manual registration needed
---
## Error Handling
| Scenario | Behaviour |
|----------|-----------|
| Azure AD token fetch fails | Throw `AuthenticationException` with error description |
| Graph API returns 401 | Invalidate cache, retry once with fresh token, log warning |
| Graph API returns 401 on retry | Throw `GraphApiException` |
| Graph API returns other 4xx/5xx | Throw `GraphApiException` with `error.code` + `error.message` from response body |
| Network timeout | Laravel `Http` facade throws `ConnectionException` — let it propagate |
---
## Dependencies
```json
{
"require": {
"php": "^8.2",
"illuminate/support": "^11.0|^12.0",
"illuminate/http": "^11.0|^12.0",
"illuminate/mail": "^11.0|^12.0",
"symfony/mailer": "^7.0"
}
}
```
No explicit Guzzle dependency — Laravel's `Http` facade is used throughout.
---
## Azure AD App Registration Requirements
The consuming application must grant the registered Azure AD app the following **application permission** (not delegated):
- `Mail.Send` — allows sending mail as any user in the tenant
This is set in Azure Portal → App Registrations → API Permissions → Microsoft Graph → Application permissions.
---
## Testing Strategy
- `TokenManager` — mock `Http` facade, assert correct POST body, assert caching behaviour, assert cache invalidation on retry
- `GraphClient` — mock `Http` facade, assert Authorization header, assert payload structure, assert 401 retry logic
- `AzureTransport` — use a real Symfony `Email` object, assert `GraphClient::send()` is called with correct payload shape
- Integration test — mock the full HTTP stack end-to-end through `Mail::fake()` + transport swap