---
title: "Architecture"
manual: "Microsoft Exchange 365 Mailer"
version: "main"
permalink: "https://docs.typo3.org/permalink/oliverkroener/ok-exchange365-mailer:architecture@main"
source: "Development/Architecture.rst"
rendered: "2026-10-04T20:03:13+00:00"
---

# Architecture {#architecture}

The extension is small — two PHP classes — but the way configuration reaches the
transport is subtle, and there are two parallel frontend configuration paths. This
page documents both.

## Components {#architecture-components}

| Class | Responsibility |
| --- | --- |
| `\Mail\Transport\Exchange365Transport` | Symfony `AbstractTransport` implementation. Resolves configuration, authenticates, converts the message and calls Microsoft Graph. Keeps one Graph client per credential set — see [Graph client, timeouts and retries](https://docs.typo3.org/permalink/oliverkroener/ok-exchange365-mailer:architecture-graph-client@main). |
| `\Lowlevel\EventListener\ModifyBlindedConfigurationOptionsEventListener` | Blinds the tenant ID, client ID and client secret in the backend **Configuration** module, so the values render as `ab******yz`. Covers both `$GLOBALS['TYPO3_CONF_VARS']['MAIL']` (provider `confVars`) and the same settings in a site's configuration (provider `sitesYamlConfiguration`, nested or dotted keys). |

Message conversion itself lives in
[`oliverkroener/ok-typo3-helper`](https://packagist.org/packages/oliverkroener/ok-typo3-helper), whose
`MSGraphMailApiService::convertToGraphMessage()` turns the Symfony
`SentMessage` into a Graph message. Bugs in attachment, inline-image or
recipient handling usually belong in that package rather than here.

## How the transport is selected {#architecture-transport-selection}

There is **no DSN factory**. TYPO3 activates this transport when
`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['transport']` is the
**fully-qualified class name**:

```text
OliverKroener\OkExchange365\Mail\Transport\Exchange365Transport
```

TYPO3's mailer then instantiates the class directly, passing the whole
`$GLOBALS['TYPO3_CONF_VARS']['MAIL']` array as the `$mailSettings`
constructor argument.

> [!IMPORTANT]
> Because TYPO3 constructs the class itself, it must **not** be handled by the
> Symfony DI container. It is therefore excluded from the autoloading resource in
> `Configuration/Services.yaml`. Do not add it back to autowiring.

The `__toString()` return value `exchange365api` is only a display name; it
plays no part in transport selection.

## Configuration resolution {#architecture-configuration-resolution}

`getConfiguration()` builds the effective configuration from two sources:

1.  **Mail settings are the baseline.** The
    `$GLOBALS['TYPO3_CONF_VARS']['MAIL']['transport_exchange365_*']` values,
    normalised into short keys (`tenantId`, `clientId`, …) by
    `getMailSettingsConfiguration()`. This is the only source available in
    backend, CLI and scheduler contexts.
1.  **Frontend TypoScript overlays it per key.** The values below
    `plugin.tx_okexchange365mailer.settings.exchange365`, read only when
    `$GLOBALS['TYPO3_REQUEST']` reports `applicationType === 1`. Site-set
    settings arrive through this same path, because site settings are flattened
    into TypoScript constants.

Two rules govern the overlay, and both are deliberate:

**Classes/Mail/Transport/Exchange365Transport.php**

```php
if ($key === 'saveToSentItems' || ($value !== '' && $value !== null)) {
    $conf[$key] = $value;
}
```

-   An **empty** TypoScript value means *"not configured here"* and must not shadow
    a value that is set in the mail settings. This is why the code uses an explicit
    guard rather than a plain `??` or an array merge.
-   `saveToSentItems` is **exempt** from that guard. A site setting of
    `false` flattens to an empty constant, and there it genuinely means *false*.

`getTypoScriptConfiguration()` reads the `frontend.typoscript` request
attribute unconditionally. On TYPO3 12.4.0 — the one supported version that predates
that attribute — it is absent, the method returns `null`, and the mail-settings
baseline stands.

`validateConfiguration()` hard-requires `tenantId`, `clientId` and
`clientSecret`. Every other setting has a fallback.

## Sender resolution {#architecture-sender-resolution}

`graphSenderUserId` — the mailbox in the `/users/{id}/sendMail` path — is
deliberately decoupled from the message `From` header, so that *Send As* and
*Send On Behalf* work. It resolves in this order:

```text
conf.graphSenderUserId
  → $graphMessage['from']
    → conf.fromEmail
      → MAIL.defaultMailFromAddress
        → RuntimeException
```

`resolveGraphSenderUserId()` walks these candidates and returns the first one
that is a non-empty scalar. An empty string counts as "unset" at **every** step —
including an empty message `From` — because
`getMailSettingsConfiguration()` returns empty strings, not nulls, for unset
values.

> [!NOTE]
> The sender **display name** comes from the mailbox in Exchange Online, not from
> TYPO3. `defaultMailFromName` has no effect on what recipients see.
> See [Configuring the Sender Display Name](https://docs.typo3.org/permalink/oliverkroener/ok-exchange365-mailer:sender-display-name@main).

## Two mutually exclusive frontend paths {#architecture-frontend-paths}

| Path | TYPO3 | Files |
| --- | --- | --- |
| Site set `oliverkroener/ok-exchange365-mailer` (preferred, since 4.3.0) | 13 / 14 | `Configuration/Sets/Exchange365Mailer/config.yaml`, `settings.definitions.yaml`, `setup.typoscript` |
| Static template *\[kroener.DIGITAL\] Exchange 365 Mailer* | 12 | `Configuration/TypoScript/constants.typoscript`, `setup.typoscript`, registered in `Configuration/TCA/Overrides/sys_template.php` |

The set's `setup.typoscript` is a one-line `@import` of the static template's
`setup.typoscript`. The site-setting keys are named identically to the
TypoScript constants, so the mapping file is reused verbatim.

> [!WARNING]
> Use the set **or** the static template, never both. Site settings are applied to
> constants *before* template records, so a lingering static template overwrites the
> set's values with its own empty defaults.

When adding or renaming a setting, change all four places:
`constants.typoscript`, `setup.typoscript`,
`settings.definitions.yaml`, and `getMailSettingsConfiguration()`.

## Graph client, timeouts and retries {#architecture-graph-client}

`getGraphServiceClient()` keeps **one** `GraphServiceClient` per
credential set (keyed by a hash of tenant ID, client ID and client secret). The
client holds its OAuth token in memory, so a request that sends several mails — a
form with a receiver and a confirmation mail, a scheduler run — authenticates once.
Different credentials, for example a frontend site with its own app registration,
get their own client.

`createGraphServiceClient()` builds the client the same way the SDK does by
default, with two deliberate differences:

-   **The Graph call** uses a 10 s connect and 30 s total timeout instead of the
    SDK's 30 s / 100 s, so a stalled connection cannot hold a frontend request for
    minutes.
-   **The OAuth token request** gets the same timeouts through an injected HTTP
    client. The OAuth library the SDK uses otherwise sends it with **no timeout at
    all** — a stalled token request would block a CLI or scheduler run forever.

Throttling (429) and 503/504 responses are retried by the SDK's own middleware,
honouring `Retry-After`. A request that **stalled after connecting is not
retried**: Graph may already have accepted the message, and a retry could send it
twice.

`createGraphServiceClient()` is `protected` and is the one seam the unit
tests use to count or replace client creation.

## Error handling {#architecture-errors}

`doSend()` catches every `\Throwable`, logs at `error` level, and
rethrows a Symfony `TransportException` with the original as `previous`.
`TransportException` extends `\RuntimeException`, so existing
`catch (\RuntimeException)` blocks keep working. Graph's original message is
appended to the exception text — it is the only diagnostic an integrator gets, so
keep it when editing. The client secret and the token never appear in it.
