docs: add azure-mailer package design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d8cab94bcb
commit
a922450e50
231
docs/superpowers/specs/2026-05-26-azure-mailer-design.md
Normal file
231
docs/superpowers/specs/2026-05-26-azure-mailer-design.md
Normal file
@ -0,0 +1,231 @@
|
||||
# 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
|
||||
Loading…
x
Reference in New Issue
Block a user