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 

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.
\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 , 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 

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

OliverKroener\OkExchange365\Mail\Transport\Exchange365Transport
Copied!

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

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

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.
  2. 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
if ($key === 'saveToSentItems' || ($value !== '' && $value !== null)) {
    $conf[$key] = $value;
}
Copied!
  • 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 

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:

conf.graphSenderUserId
  → $graphMessage['from']
    → conf.fromEmail
      → MAIL.defaultMailFromAddress
        → RuntimeException
Copied!

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.

Two mutually exclusive 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.

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

Graph client, timeouts and retries 

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 

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.