Configure, use, and troubleshoot the innoge/laravel-msgraph-mail package, which sends Laravel mail through the Microsoft Graph API instead of SMTP. Use when setting up the microsoft-graph mailer, registering the Azure app for it, configuring certificate authentication, or debugging Graph mail sending errors (401/403/404, AADSTS errors, timeouts).
php artisan boost:add-skill innoge/laravel-msgraph-mail
Save this content to: .claude/skills/msgraph-mail/SKILL.md
# Microsoft Graph Mail
## When to use this skill
Use this skill when working with the `innoge/laravel-msgraph-mail` package (3.x):
- Configuring Laravel to send mail through Microsoft 365 / Microsoft Graph (replacing deprecated Office 365 SMTP).
- Creating or fixing the required Azure app registration, including certificate authentication.
- Debugging failed sends through the `microsoft-graph` mailer.
## Configuration
The package registers a `microsoft-graph` mail transport via package auto-discovery. There is no publishable package config — all configuration lives in `config/mail.php` under `mailers`:
```php
'mailers' => [
'microsoft-graph' => [
'transport' => 'microsoft-graph',
'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'),
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
'from' => [ // optional: omit the key entirely to use the global mail.from
'address' => env('MAIL_FROM_ADDRESS'),
'name' => env('MAIL_FROM_NAME'),
],
'save_to_sent_items' => env('MAIL_SAVE_TO_SENT_ITEMS', false), // optional
],
// ...
],
```
Then activate it with `MAIL_MAILER=microsoft-graph` plus the `MICROSOFT_GRAPH_*` env vars.
`tenant_id` and `client_id` are always required. For authentication provide EITHER `client_secret` OR a `client_certificate` block (certificate wins when both are present):
```php
'client_certificate' => [
'certificate' => env('MICROSOFT_GRAPH_CERTIFICATE'), // PEM content or file path
'private_key' => env('MICROSOFT_GRAPH_PRIVATE_KEY'), // PEM content or file path
'passphrase' => env('MICROSOFT_GRAPH_KEY_PASSPHRASE'), // optional
],
```
Certificate auth signs a JWT client assertion (requires ext-openssl); the certificate must be uploaded to the app registration under Certificates & secrets → Certificates. It avoids expiring client secrets.
The from address (mailer-level or global `mail.from`) must be the primary SMTP address of a real mailbox (licensed user or shared mailbox) in the tenant — Graph sends *as* that mailbox.
## Azure prerequisite
The package uses the OAuth2 client-credentials flow, so it needs an Entra ID (Azure AD) app registration with the Microsoft Graph **`Mail.Send` application permission** (not delegated) and **admin consent** granted. Sending mails larger than ~3 MB additionally requires **`Mail.ReadWrite`**.
Read `references/azure-app-registration.md` when the user needs to create the app registration, obtain the tenant/client ID or credentials, or fix permission/consent problems.
## How it works and behavior notes
- Sending is a `POST https://graph.microsoft.com/v1.0/users/{from-address}/sendMail`. Overriding the sender per mailable with `->from('[email protected]')` changes the URL target — that address must also be a real mailbox the app may send as.
- Mails work with standard `Mail::`, Mailables, and Notifications; no package-specific API.
- **Large mails** (total payload over ~3 MB): automatically sent via a draft message + chunked attachment upload sessions. Requires the `Mail.ReadWrite` application permission — otherwise a `MissingMailReadWritePermissionException` exception is thrown. `save_to_sent_items` is honored: when disabled, the sent message is deleted from Sent Items afterwards; failed sends clean up their draft.
- **`save_to_sent_items`** is read from the mailer's own config entry (works with mailers under any key). Per-mailable override: `$mailable->withSymfonyMessage(fn ($m) => $m->getHeaders()->add(new \Symfony\Component\Mailer\Header\MetadataHeader('save-to-sent-items', 'true')))`.
- The OAuth token is cached per tenant + client for its `expires_in` lifetime minus 60s. **Tokens do not pick up credential or permission changes until refreshed** — run `php artisan cache:clear` after rotating secrets/certificates or granting new Graph permissions.
- Attachment names use the real filename with extension where the framework exposes one; inline-image content ids are separate and stable.
- Only custom headers whose name starts with `X-` are forwarded to Graph (Symfony metadata/tag headers excluded); other custom headers are silently dropped. Recipient display names are dropped (only addresses are sent — Microsoft substitutes tenant-known names).
- The Graph API can be transiently slow or unreachable; queue mail with retries (`$tries`, backoff) rather than sending synchronously in requests.
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `Configuration key tenant_id/client_id/client_secret/from.address for microsoft-graph mailer is missing` | Mailer entry missing from `config/mail.php` (common in apps upgraded from older Laravel versions), empty env vars, or stale config cache | Add the full mailer block above, set the env vars, run `php artisan config:clear` |
| `AADSTS7000215: Invalid client secret` | Wrong or expired client secret, or the secret's **ID** was copied instead of its **Value** | Create a new secret, copy the Value, update env; check secret expiry — or switch to certificate auth |
| `AADSTS700016: Application ... was not found` | Wrong `client_id` or app registered in a different tenant | Verify Application (client) ID and Directory (tenant) ID on the app's Overview page |
| `AADSTS90002: Tenant ... not found` | Wrong `tenant_id` | Use the Directory (tenant) ID GUID |
| `AADSTS700027: Client assertion contains an invalid signature` | Certificate not uploaded to the app registration, or wrong certificate/private key pair | Upload the certificate under Certificates & secrets → Certificates; verify thumbprints match |
| `403 ErrorAccessDenied` | `Mail.Send` application permission missing, admin consent not granted, or an Exchange ApplicationAccessPolicy blocks this mailbox | Add `Mail.Send` as an **application** permission and grant admin consent; check access policies |
| `MissingMailReadWritePermissionException` exception | Mail exceeds ~3 MB and the app lacks `Mail.ReadWrite` | Grant `Mail.ReadWrite` application permission + admin consent, then `php artisan cache:clear` (cached tokens lack new permissions) — or reduce attachment size |
| Permission was granted but errors persist | The cached token was issued before the grant and does not contain the new role | `php artisan cache:clear` |
| `404 ErrorInvalidUser: The requested user '...' is invalid` | The from address is not an existing mailbox, or is an alias instead of the primary SMTP address | Use the mailbox's primary SMTP address; verify the mailbox exists in the tenant |
| Auth keeps failing after rotating the client secret | Old token still cached | `php artisan cache:clear` |
| `ErrorMessageSizeExceeded` | Attachments exceed Graph's hard 150 MB upload-session limit (mails up to ~150 MB are handled automatically) | Send download links instead of attachments |
| `cURL error 28` / connection timeouts | Transient Graph API or DNS issues | Queue mails with retries and backoff; don't raise timeouts expecting a fix |
| Mail arrives but is not in Sent Items | `save_to_sent_items` is false (default) | Enable it in the mailer config or per mailable via the metadata header |
For live debugging, use Boost's MCP tools where available: `last-error` / `read-log-entries` to see the actual Graph error response, and `search-docs` for Laravel mail documentation.
## Version note
Package 1.x behaves differently in three relevant ways: `save_to_sent_items` only works on the mailer key literally named `microsoft-graph`, large mails fail with `ErrorMessageSizeExceeded` (~4 MB cap, no upload sessions), and there is no certificate auth. See `UPGRADE.md` in the package for the full 1.x → 2.0 migration guide.
Use this skill when working with the innoge/laravel-msgraph-mail package (3.x):
microsoft-graph mailer.The package registers a microsoft-graph mail transport via package auto-discovery. There is no publishable package config — all configuration lives in config/mail.php under mailers:
'mailers' => [
'microsoft-graph' => [
'transport' => 'microsoft-graph',
'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'),
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
'from' => [ // optional: omit the key entirely to use the global mail.from
'address' => env('MAIL_FROM_ADDRESS'),
'name' => env('MAIL_FROM_NAME'),
],
'save_to_sent_items' => env('MAIL_SAVE_TO_SENT_ITEMS', false), // optional
],
// ...
],
Then activate it with MAIL_MAILER=microsoft-graph plus the MICROSOFT_GRAPH_* env vars.
tenant_id and client_id are always required. For authentication provide EITHER client_secret OR a client_certificate block (certificate wins when both are present):
'client_certificate' => [
'certificate' => env('MICROSOFT_GRAPH_CERTIFICATE'), // PEM content or file path
'private_key' => env('MICROSOFT_GRAPH_PRIVATE_KEY'), // PEM content or file path
'passphrase' => env('MICROSOFT_GRAPH_KEY_PASSPHRASE'), // optional
],
Certificate auth signs a JWT client assertion (requires ext-openssl); the certificate must be uploaded to the app registration under Certificates & secrets → Certificates. It avoids expiring client secrets.
The from address (mailer-level or global mail.from) must be the primary SMTP address of a real mailbox (licensed user or shared mailbox) in the tenant — Graph sends as that mailbox.
The package uses the OAuth2 client-credentials flow, so it needs an Entra ID (Azure AD) app registration with the Microsoft Graph Mail.Send application permission (not delegated) and admin consent granted. Sending mails larger than ~3 MB additionally requires Mail.ReadWrite.
Read references/azure-app-registration.md when the user needs to create the app registration, obtain the tenant/client ID or credentials, or fix permission/consent problems.
POST https://graph.microsoft.com/v1.0/users/{from-address}/sendMail. Overriding the sender per mailable with ->from('[email protected]') changes the URL target — that address must also be a real mailbox the app may send as.Mail::, Mailables, and Notifications; no package-specific API.Mail.ReadWrite application permission — otherwise a MissingMailReadWritePermissionException exception is thrown. save_to_sent_items is honored: when disabled, the sent message is deleted from Sent Items afterwards; failed sends clean up their draft.save_to_sent_items is read from the mailer's own config entry (works with mailers under any key). Per-mailable override: $mailable->withSymfonyMessage(fn ($m) => $m->getHeaders()->add(new \Symfony\Component\Mailer\Header\MetadataHeader('save-to-sent-items', 'true'))).expires_in lifetime minus 60s. Tokens do not pick up credential or permission changes until refreshed — run php artisan cache:clear after rotating secrets/certificates or granting new Graph permissions.X- are forwarded to Graph (Symfony metadata/tag headers excluded); other custom headers are silently dropped. Recipient display names are dropped (only addresses are sent — Microsoft substitutes tenant-known names).$tries, backoff) rather than sending synchronously in requests.| Symptom | Cause | Fix |
|---|---|---|
Configuration key tenant_id/client_id/client_secret/from.address for microsoft-graph mailer is missing |
Mailer entry missing from config/mail.php (common in apps upgraded from older Laravel versions), empty env vars, or stale config cache |
Add the full mailer block above, set the env vars, run php artisan config:clear |
AADSTS7000215: Invalid client secret |
Wrong or expired client secret, or the secret's ID was copied instead of its Value | Create a new secret, copy the Value, update env; check secret expiry — or switch to certificate auth |
AADSTS700016: Application ... was not found |
Wrong client_id or app registered in a different tenant |
Verify Application (client) ID and Directory (tenant) ID on the app's Overview page |
AADSTS90002: Tenant ... not found |
Wrong tenant_id |
Use the Directory (tenant) ID GUID |
AADSTS700027: Client assertion contains an invalid signature |
Certificate not uploaded to the app registration, or wrong certificate/private key pair | Upload the certificate under Certificates & secrets → Certificates; verify thumbprints match |
403 ErrorAccessDenied |
Mail.Send application permission missing, admin consent not granted, or an Exchange ApplicationAccessPolicy blocks this mailbox |
Add Mail.Send as an application permission and grant admin consent; check access policies |
MissingMailReadWritePermissionException exception |
Mail exceeds ~3 MB and the app lacks Mail.ReadWrite |
Grant Mail.ReadWrite application permission + admin consent, then php artisan cache:clear (cached tokens lack new permissions) — or reduce attachment size |
| Permission was granted but errors persist | The cached token was issued before the grant and does not contain the new role | php artisan cache:clear |
404 ErrorInvalidUser: The requested user '...' is invalid |
The from address is not an existing mailbox, or is an alias instead of the primary SMTP address | Use the mailbox's primary SMTP address; verify the mailbox exists in the tenant |
| Auth keeps failing after rotating the client secret | Old token still cached | php artisan cache:clear |
ErrorMessageSizeExceeded |
Attachments exceed Graph's hard 150 MB upload-session limit (mails up to ~150 MB are handled automatically) | Send download links instead of attachments |
cURL error 28 / connection timeouts |
Transient Graph API or DNS issues | Queue mails with retries and backoff; don't raise timeouts expecting a fix |
| Mail arrives but is not in Sent Items | save_to_sent_items is false (default) |
Enable it in the mailer config or per mailable via the metadata header |
For live debugging, use Boost's MCP tools where available: last-error / read-log-entries to see the actual Graph error response, and search-docs for Laravel mail documentation.
Package 1.x behaves differently in three relevant ways: save_to_sent_items only works on the mailer key literally named microsoft-graph, large mails fail with ErrorMessageSizeExceeded (~4 MB cap, no upload sessions), and there is no certificate auth. See UPGRADE.md in the package for the full 1.x → 2.0 migration guide.
How can I help you explore Laravel packages today?