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

7.3 KiB

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

{
    "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

'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

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:

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:
$this->app->make(MailManager::class)->extend('azure', function (array $config) {
    return new AzureTransport(
        new GraphClient(new TokenManager($config), $config),
        $config
    );
});
  1. Registers config publishingvendor:publish --tag=azure-mailer-config

  2. 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

{
    "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