From a922450e5069585dec1696ef26cb474a356fcc58 Mon Sep 17 00:00:00 2001 From: Ghassan Yusuf Date: Tue, 26 May 2026 10:43:55 +0300 Subject: [PATCH] docs: add azure-mailer package design spec Co-Authored-By: Claude Sonnet 4.6 --- .../specs/2026-05-26-azure-mailer-design.md | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-azure-mailer-design.md diff --git a/docs/superpowers/specs/2026-05-26-azure-mailer-design.md b/docs/superpowers/specs/2026-05-26-azure-mailer-design.md new file mode 100644 index 0000000..a2f6be5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-azure-mailer-design.md @@ -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": "" + } + ] + }, + "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