Microsoft Exchange Mail Tool
Read Exchange Online mailboxes, folders and attachments, and send mail as a mailbox. Built for a monitored or shared mailbox that a job processes on a schedule.
Tool ID
msgraph-mail
Credential Required
Yes — the shared msgraph credential, owned by the msgraph connector. Configure the tenant
once and all nine tools use it; rotating the client secret is one edit rather than nine.
It is a server-managed OAuth credential: InTouch mints and refreshes the access token itself and
the connector never sees the secret. For unattended work set grant_type to client_credentials
(the app-only grant) — an app-only token belongs to the application, so a 06:00 schedule does not
run as a person, see only what that person can see, and stop working the day they leave.
Credential Properties
| Property | Type | Default | Description |
|---|---|---|---|
client_id |
string | — | Required. The Entra app registration's Application (client) ID. |
client_secret |
string | — | Required for app-only. The secret's Value, not its Secret ID. |
token_url |
string | — | Required. https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token |
grant_type |
string | (blank) | client_credentials for unattended app-only use. Blank means the user-delegated refresh-token flow. |
scope |
string | — | https://graph.microsoft.com/.default — resolves to whatever application permissions an admin has consented to. |
access_token |
string | — | Machine-managed. Leave empty; the server mints and persists it. |
refresh_token |
string | — | Delegated credentials only. Not used by the app-only grant. |
expires_at |
string | 0 |
Machine-managed expiry, epoch seconds. |
Entra setup
- https://entra.microsoft.com → App registrations → your app → API permissions.
- Add a permission → Microsoft Graph → Application permissions — not Delegated. Picking Delegated is the most common failure: it consents cleanly and then 403s at runtime.
- Tick the permissions listed under Permissions below, then Grant admin consent. The Status column must read Granted.
- Permissions live in the token, not the portal. A token minted before the grant carries the old
claims for about an hour — blank
access_tokento force a fresh mint.
Permissions
Mail.Read to read, Mail.Send to send.
Operations
1. list_messages — List messages
Messages in a mailbox. Always bound this with a $filter on receivedDateTime. Calls GET /users/{id}/messages.
Properties:
| Property | Type | Default | Description |
|---|---|---|---|
operation |
string | — | Required. list_messages |
id |
string | — | Required. The MAILBOX: a user id or userPrincipalName (e.g. [email protected]). Required by every operation — there is no 'me' on an app-only credential. |
query |
string | — | Optional. OData query params as a JSON object string. Filter server-side — {"$filter": "receivedDateTime ge 2026-08-01T00:00:00Z", "$select": "subject,from,receivedDateTime", "$top": 50} — because a mailbox is unbounded and draining it page by page is slow and rate-limited. |
Published Outputs:
- result — the JSON response body
- status — HTTP status code
2. get_message — Get one message
One message by id, including its body. Calls GET /users/{id}/messages/{messageId}.
Properties:
| Property | Type | Default | Description |
|---|---|---|---|
operation |
string | — | Required. get_message |
id |
string | — | Required. The MAILBOX: a user id or userPrincipalName (e.g. [email protected]). Required by every operation — there is no 'me' on an app-only credential. |
messageId |
string | — | Required. Message id. Required by get_message and list_message_attachments. |
query |
string | — | Optional. OData query params as a JSON object string. Filter server-side — {"$filter": "receivedDateTime ge 2026-08-01T00:00:00Z", "$select": "subject,from,receivedDateTime", "$top": 50} — because a mailbox is unbounded and draining it page by page is slow and rate-limited. |
Published Outputs:
- result — the JSON response body
- status — HTTP status code
3. list_message_attachments — List attachments
The attachments on one message. Calls GET /users/{id}/messages/{messageId}/attachments.
Properties:
| Property | Type | Default | Description |
|---|---|---|---|
operation |
string | — | Required. list_message_attachments |
id |
string | — | Required. The MAILBOX: a user id or userPrincipalName (e.g. [email protected]). Required by every operation — there is no 'me' on an app-only credential. |
messageId |
string | — | Required. Message id. Required by get_message and list_message_attachments. |
query |
string | — | Optional. OData query params as a JSON object string. Filter server-side — {"$filter": "receivedDateTime ge 2026-08-01T00:00:00Z", "$select": "subject,from,receivedDateTime", "$top": 50} — because a mailbox is unbounded and draining it page by page is slow and rate-limited. |
Published Outputs:
- result — the JSON response body
- status — HTTP status code
4. list_mail_folders — List mail folders
The mailbox's folder tree. Calls GET /users/{id}/mailFolders.
Properties:
| Property | Type | Default | Description |
|---|---|---|---|
operation |
string | — | Required. list_mail_folders |
id |
string | — | Required. The MAILBOX: a user id or userPrincipalName (e.g. [email protected]). Required by every operation — there is no 'me' on an app-only credential. |
query |
string | — | Optional. OData query params as a JSON object string. Filter server-side — {"$filter": "receivedDateTime ge 2026-08-01T00:00:00Z", "$select": "subject,from,receivedDateTime", "$top": 50} — because a mailbox is unbounded and draining it page by page is slow and rate-limited. |
Published Outputs:
- result — the JSON response body
- status — HTTP status code
5. list_folder_messages — List messages in a folder
Messages in one folder. folderId accepts well-known names such as inbox, sentitems, drafts, archive. Calls GET /users/{id}/mailFolders/{folderId}/messages.
Properties:
| Property | Type | Default | Description |
|---|---|---|---|
operation |
string | — | Required. list_folder_messages |
id |
string | — | Required. The MAILBOX: a user id or userPrincipalName (e.g. [email protected]). Required by every operation — there is no 'me' on an app-only credential. |
folderId |
string | — | Required. Mail folder id, or a well-known name such as inbox, sentitems, drafts, archive. Required by list_folder_messages. |
query |
string | — | Optional. OData query params as a JSON object string. Filter server-side — {"$filter": "receivedDateTime ge 2026-08-01T00:00:00Z", "$select": "subject,from,receivedDateTime", "$top": 50} — because a mailbox is unbounded and draining it page by page is slow and rate-limited. |
Published Outputs:
- result — the JSON response body
- status — HTTP status code
6. send_mail — Send mail
Send as the named mailbox. Returns HTTP 202 with no body. Calls POST /users/{id}/sendMail.
Properties:
| Property | Type | Default | Description |
|---|---|---|---|
operation |
string | — | Required. send_mail |
id |
string | — | Required. The MAILBOX: a user id or userPrincipalName (e.g. [email protected]). Required by every operation — there is no 'me' on an app-only credential. |
body |
string | — | Required. Message to send, as a JSON object string — {"message": {"subject": "Nightly load", "body": {"contentType": "Text", "content": "finished at 06:12"}, "toRecipients": [{"emailAddress": {"address": "[email protected]"}}]}, "saveToSentItems": true}. Only used by send_mail. |
query |
string | — | Optional. OData query params as a JSON object string. Filter server-side — {"$filter": "receivedDateTime ge 2026-08-01T00:00:00Z", "$select": "subject,from,receivedDateTime", "$top": 50} — because a mailbox is unbounded and draining it page by page is slow and rate-limited. |
Published Outputs:
- result — the JSON response body
- status — HTTP status code
Paging
Graph answers a collection with 100 rows and an @odata.nextLink, so a naive listing returns a
fraction of a large tenant with HTTP 200 and no sign anything is missing. This connector follows
the continuation link until the collection is exhausted. If it stops at the page cap it says so —
the result carries complete: false with a warning, and the step returns WARNING rather than
SUCCESS. A page cap must never look like the end of the data.
Notes
The scope is the whole tenant until someone narrows it. Application Mail.Read does not mean
one mailbox — it means every mailbox in the organisation, including the executives'. Nothing in
Entra narrows it. The control is an Exchange application access policy:
Connect-ExchangeOnline
New-DistributionGroup -Name "InTouch-Mailboxes" -Type Security
New-ApplicationAccessPolicy -AppId <your-app-id> `
-PolicyScopeGroupId InTouch-Mailboxes@<yourdomain> -AccessRight RestrictAccess `
-Description "InTouch automation — restricted to the InTouch-Mailboxes group"
Test-ApplicationAccessPolicy -Identity someone@<yourdomain> -AppId <your-app-id>
Grant the consent and skip the policy and you have handed a job server the entire organisation's mail. It works perfectly, which is the problem. Allow up to an hour for the policy to propagate.
Addressed by mailbox, never by "me". /me/messages is delegated-only and answers 400 on an
app-only token, so every route here names the mailbox explicitly.
Send returns 202 and no body. That means Exchange accepted it for delivery, not that it was delivered; a later bounce is invisible here.
Filter server-side. A mailbox is unbounded. {"$filter": "receivedDateTime ge 2026-08-01T00:00:00Z", "$select": "subject,from,receivedDateTime", "$top": 50}
is the difference between a fast job and a throttled one.
For ordinary outbound notification from a job, prefer InTouch's own messaging over this connector.
Self-Contained JAR
The jar bundles all runtime dependencies; only intouch-tool-api is provided by the server.
Publishes
The keys a later task may reference as {{taskName.key}}. Referencing anything not listed here resolves to nothing at run time.
completeoperationresultstatuswarning