Skip to content
Letterhop

Documentation

Guides for setting up and running it, taken directly from the product.

Overview

Updated on 2026-09-28

Letterhop is built for managed service providers and IT companies that look after mail for many customers at once. It comes in two parts: Letterhop Relay, an agent that runs on a customer's own network, and the platform where you keep your customers, their sites and their agents.

The agent accepts classic SMTP with a username and password on the customer's LAN and delivers through that same customer's Microsoft 365 tenant over OAuth2, so scan-to-email and application mail keep working after Basic Authentication for SMTP AUTH is switched off.

Every site runs its own agent, and the site is also the unit that gets billed. The bill goes to whoever looks after the sites, whether that is a provider running them for customers or a company running its own. The prices are on the pricing page.

Microsoft switches Basic Auth for SMTP AUTH off by default in existing tenants at the end of December 2026. An administrator can switch it back on afterwards for the time being; the change is a new default, not yet a removal of the feature. In tenants created after that date, this authentication method is unavailable by default.

The obvious case is scan-to-email on a multifunction printer. The durable one is everything else that will never get OAuth-capable firmware: ERP and merchandise systems, monitoring and alerting, NAS boxes, POS systems, alarm panels, time tracking, old line-of-business software.

When you do not need us

Not every device needs an agent, and two of the ways around it come from Microsoft. Where a site has a static public IP address it shares with no other organisation, an inbound connector is enough. Where a device only sends to recipients inside its own customer's tenant, High Volume Email is the shorter route: an SMTP endpoint of its own that the December change does not touch, with no static address and no certificate. Both come with conditions worth knowing before you plan around them, and both are set out in full, alongside the other ways out, in the shutdown overview.

What works today

The mail path is complete and works end to end: SMTP ingress in three transport modes, a durable on-disk spool, a retrying queue with dead-lettering, and delivery through Microsoft Graph including the automatic switch to an upload session for large scans.

The platform side is built too. A relay joins the platform with an enrolment token issued in the portal, or is claimed at the machine with a one-time code when it changes hands (remote management); from then on almost everything the relay's own interface offers can be changed remotely, with the same validation and the same log entry a change at the machine would get (remote management). The platform watches every relay's check-ins and alerts when one goes silent, flaps or stops delivering (alerting). A customer's Microsoft 365 tenant can be connected with a single consent click, with no client secret ever reaching the site (connect Microsoft 365). And an MSP's own tooling can read the state of every site it manages through a read API.

Google Workspace is a later step: the provider exists in the relay's code but does not send mail yet, so it is not part of the first release.

Entra ID setup

Updated on 2026-08-13

The relay authenticates as an application, not as a user. There is no interactive login and no refresh token. You do all of this in your customer's own Microsoft 365 tenant, once per customer tenant.

  1. Register an application. Entra admin centre → App registrations → New registration. Single tenant. No redirect URI is needed.
  2. Create a client secret. Certificates & secrets → New client secret. Copy the value immediately; it is shown once. Note its expiry - delivery stops when it lapses.
  3. Add application permissions under API permissions → Microsoft Graph → Application permissions:
    • Mail.Send
    • Mail.ReadWrite
  4. Grant admin consent. Application permissions do nothing until an administrator consents.

Why Mail.ReadWrite is needed, and how to keep it narrow

Mail.Send alone is not enough. Any attachment large enough to need an upload session is sent by creating a draft first, and creating a draft requires Mail.ReadWrite. In practice that is most scans. The connection test uses the same mechanism.

Mail.ReadWrite granted tenant-wide means read and write access to every mailbox in that customer's tenant, which is not what you want. Restrict the application to the specific mailboxes it may touch:

  • RBAC for Applications (current approach): create a management scope covering only the mailbox that is meant to send and assign the application a role within it.
  • Application Access Policy (older, still supported): create a mail-enabled security group containing only that mailbox and apply a policy restricting the app to it.

Do this before granting consent if you can. An unrestricted Mail.ReadWrite is the single biggest configuration mistake you can make with this product.

Quick start

Updated on 2026-09-28

A site is added in three steps: create the customer and the site in the platform, install the agent at the customer and enrol it with the enrolment token the portal issues for that site (letterhop enrol -token <token>), then point the device at it. Once enrolled, almost everything below can also be done from the portal instead of the command line - see remote management. This page covers the command-line path.

The tenant side comes before any of it: an app registration in the customer's Microsoft 365 tenant, with admin consent, restricted to the mailbox this site is meant to send from. That is covered step by step in Entra ID setup.

The agent stores everything under %ProgramData%\Letterhop\Relay\ on Windows and /var/lib/letterhop/ elsewhere. Override with LETTERHOP_DATA_DIR.

# 1. Store the credentials of the app registration in the customer's tenant. The
#    secret is read from stdin so it does not appear in your shell history or in
#    the process list.
letterhop add-credential -name "Contoso tenant" -tenant <tenant-id> -client-id <client-id>

# 2. Create a device login. The sender address is forced onto every message this
#    login submits, whatever the device claims.
letterhop add-account -username printer-2og -password <device-password> \
    -from scan@example.com -credential <credential-id>

# 3. Verify the tenant side before you touch a printer.
letterhop test-credential <credential-id>

# 4. Run it.
letterhop serve

test-credential reports the sender address Graph would actually stamp on a message. If that differs from the address you configured - which happens when you use an alias rather than the mailbox's primary address - it says so. Check it before wiring devices, not afterwards.

letterhop status prints instance identity, account and credential counts, and queue state. letterhop list-credentials shows ids and last-check results.

Repeat this per site. Every site gets an agent of its own, holding the credentials from its own customer's tenant.

Doing this from the portal instead

Once a relay is enrolled, the accounts, credentials and connections these commands create can also be created, changed and viewed from the portal, without a session on the machine at all - what reaches the relay this way and what stays local either way is in remote management.

Configuring a device

Updated on 2026-08-13

Point the device at the relay's LAN address and one of these ports:

PortTransportDefault
587STARTTLSenabled - use this
465implicit TLSenabled - for devices that only do SMTPS
25no encryptiondisabled - opt in only on a trusted LAN

Use the local username and password from add-account as the SMTP credentials. AUTH LOGIN and AUTH PLAIN are both offered; most older devices need LOGIN.

The relay generates a self-signed certificate on first start covering its hostname, its LAN addresses and loopback. Devices that verify certificates need it imported, or you can supply your own.

Whatever sender address the device is configured with is ignored. The account's address is used instead. That is deliberate: it means a misconfigured or compromised device cannot send as anyone else in the customer's domain.

Message handling

Updated on 2026-08-09

The relay answers 250 OK only once the message is on disk with an fsync and its queue row is committed. From that moment the device may delete its copy, because delivery is now the relay's responsibility.

Delivery then runs in the background with an escalating retry schedule. A message that keeps failing for 24 hours is dead-lettered rather than retried forever, and its body is kept for 30 days so it can be re-sent once the cause is fixed. Failures are recorded with the plain-text reason, not just a status code.

Attachments at or above roughly 3 MB go through an upload session automatically. The device never sees the difference.

Security notes

  • Device passwords and provider secrets are protected at rest. Secrets are encrypted with AES-256-GCM; on Windows the key is wrapped with DPAPI at machine scope and the data directory carries an ACL granting only SYSTEM, administrators and the account that created it.
  • This does not protect against a local administrator. Anything the relay can decrypt, code running as administrator on the same machine can decrypt. That is inherent to a service that starts without someone typing a passphrase. What it does protect is a stolen disk, a copied backup, or an exfiltrated database file.
  • Repeated failed logins from one address are throttled. Devices with a stored password never trip it; a technician typing a password by hand a few times might, and will need to wait a few minutes.
  • Unencrypted SMTP on port 25 is off by default and authentication is refused on any listener that is not encrypted, unless you explicitly enable the plain listener.

Alerting and the test mail

Updated on 2026-09-28

Every commissioned relay checks in with the platform every 60 seconds and reports its state: the length of its queue, the age of the oldest waiting message, the number of consecutive delivery failures, and the latest error code. The platform evaluates this for every customer once a minute and decides from it whether an alert is due.

When an alert fires

Three independent triggers, with defaults you can override in the portal per tenant and, on top of that, per customer:

  • Silence. The relay has not checked in for at least the configured time (default 15 minutes, adjustable between 5 minutes and 24 hours). A relay that has never checked in at all - right after commissioning, say - does not count as silent; otherwise every new installation would be greeted with an outage alert on day one.
  • Flapping. Several silence alerts (default 3, adjustable between 2 and 10) have piled up inside a window (default 2 hours, adjustable between 30 minutes and 24 hours). This catches the line that only ever drops out briefly, which the end customer experiences as "the scanner sometimes just does not work" without any single outage ever crossing the silence threshold on its own.
  • Delivery failing. The relay reports at least as many consecutive delivery failures as configured (default 3, adjustable between 1 and 20) while it still has something queued. Both conditions together, because a failure count alone would already trip on a single bad address, and a full queue alone would already trip on an ordinary load spike.

An alert goes out exactly twice: once when the condition starts, and once when it clears - never repeated while it stays open. If you turn alerting off for a customer while something is currently open, it stays open rather than being counted as resolved: turning it off means "I am not watching right now", not "everything is fine again".

The relay also writes the same events, each with a fixed number, to the Windows Event Log, so an RMM tool can build its own rules on them: among others, configuration locked/unlocked, delivery failing/recovered, and test mail sent/failed.

Who receives it

Without a setting of your own, alerts go to the administrators of your own tenant on the platform - not to the end customer. You can set up to 20 addresses of your own instead, for the whole tenant or, overriding that, for one customer. Anyone with access to that customer on the platform can see the thresholds and recipients, even without permission to change them; changing them is limited to whoever is specifically authorised to.

The end-customer contact

Separate from the alerts to your own tenant, the relay keeps its own contact address for the end customer, stored locally on the device. It hears nothing about a delivery problem - that is what the alert above is for - only when this relay loses contact with the platform for long enough that its configuration is about to lock, or already has. The warning goes out through the customer's own outgoing mail, over the account you name for it when you set it up: daily from two weeks before the lock, once on the day it locks, and weekly after that for as long as it stays locked. With no contact address configured, this warning simply does not go out; delivery itself is unaffected.

What a test mail proves

A test mail sends straight through the configured mailbox, without going through the queue - a success proves the account can genuinely send right now, not only that the setup was saved. A failure happens for the same reason real mail would fail: a wrong secret, a missing permission, a rejected mailbox.

There are two ways to send one, with different amounts of freedom:

  • At the machine itself, through the relay's own interface: a free recipient, subject, cc and bcc, and on failure the provider's own message verbatim.
  • From the portal, as a remote command: only the recipient is free to choose (and, if the relay has more than one active account, which account is meant); subject and body are fixed and name the site, the customer and the time, never free text - otherwise the button would be a mail client able to send anything at all out of a customer's own mailbox. A failure here is never reported in the provider's exact wording, only as a fixed, human-written sentence; when the provider itself answered, that sentence names one of six classes (authentication, permission, throttled, network, mailbox, other). The portal's test mail is capped at 10 per hour per site.

When delivery keeps failing

An alert changes nothing about the relay's own behaviour: a message keeps being retried on an escalating schedule until it either arrives or has failed for 24 hours. What happens after that, and how long the content is kept for a resend, is in message handling. The alert is the notification of it - it neither prevents nor speeds up the dead-lettering.

Remote management from the portal

Updated on 2026-09-28

The platform keeps a continuously current copy of every relay's configuration. A change you make in the portal is sent to that one relay as a command and only carried out there - with the same validation, the same lock while the licence has lapsed, and the same entry in the log that a change at the machine itself would get. A new password or secret is already encrypted in the browser with that relay's own public key; the platform only forwards the encrypted package and cannot read it itself. Over a connection the relay keeps open at all times, a command usually reaches it within seconds; when that connection is down, the relay picks it up at its next check-in at the latest, at most 60 seconds later. Viewing the configuration in the portal is open to anyone with access to that customer; changing it is limited to whoever is specifically authorised to.

What the portal reaches, and what stays at the machine

From the portal you reach almost everything the relay's own interface offers: creating accounts, setting their password, turning them active or inactive or deleting them, delivery rules, connections, listeners, the certificate, the end-customer contact, users, and viewing and retrying the queue. Microsoft 365 connections can be set up entirely remotely this way: either with an app registration of its own, like any other connection, or by consent, where a customer approves it themselves once - the latter is its own topic, see connect Microsoft 365. Gmail is not yet supported, at the machine or from the portal - see the overview.

One thing stays at the machine:

  • An account cannot be edited after it is created, either way, except for its password, whether it is active, and its delivery rules. For anything else - the mailbox or the from address, for instance - there is no edit path at all, locally or in the portal; the only option is to delete the account and create it again.

A relay that has just been delivered, or is changing hands between providers, is claimed at the device itself with the one-time code from setup - the portal cannot take possession of a not-yet-claimed relay remotely.

Named users and their two roles

The relay keeps its own named logins to its own interface, separate from access to the portal itself. Every user has one of two roles:

  • admin may change everything this page describes, and can also create users, change their role, reset their password and delete them.
  • reader sees most of what an admin sees, including the queue, but not the Users page and not the configuration download. The only thing a reader can change is their own password; any other write from this login is refused.

The last remaining administrator can neither be deleted nor demoted to reader; a relay should never end up with no administrator at all. The one exception is claiming a relay with a one-time code: that replaces every existing user, so a previous provider's access is not carried over by default. A user created from the portal is marked as such, so it stays traceable at the machine that the access came from there.

The live queue view

Both at the machine and in the portal you can see what the relay has accepted and not yet delivered. What the portal shows is deliberately narrower than what the machine shows: an id, how old the entry is, how many times it has been tried, when the next attempt is due, its state, one of six failure classes, and whether trying again would even make sense - never who the message is for, who it is from, its subject, or its size. The portal shows at most 50 entries at a time, with the total count alongside; a larger queue is shown in parts. At the machine itself, the same view additionally shows the recipient and subject, because whoever is signed in there is already on the customer's own network. Retrying an item - one at a time or all at once - is logged with the name of the user who triggered it, either way.

Exporting and importing a configuration

An export is a JSON file with the relay's entire setup: accounts, connections, listeners, certificates, the contact, and users. You can download it at the machine, or build it in the portal from the copy already held there, kept continuously current - no separate command to the relay is needed for it. At the machine, the download is limited to the admin role; in the portal, anyone who already has access to that customer's configuration can see it.

What an export never contains: an account's password, a connection's secret (client secret, SMTP password or private key), a certificate's private key or PEM body, or a user's password. For each of those, only whether one is set is recorded, never what it is.

You can start an import from either side too: upload the file at the machine, or upload it in the portal, from where it is sent to the relay as a command - useful exactly when a relay is meant to be set up remotely, without anyone on site. An import is meant only for a relay that currently has no account and no connection at all - a fresh setup or a hardware replacement, never a merge with what is already there; a relay with existing accounts refuses it. Accounts come back deactivated and without their password, connections without their secret; both need to be set once before anything can send again. Users whose name already exists on the relay - typically the one running the import - are skipped and left with their real password untouched, so nobody locks themselves out through their own import. The file is capped at 500 accounts and 500 users, and everything is written in one step: completely, or not at all.

Connect Microsoft 365

Updated on 2026-09-28

For a connection under Entra ID setup someone registers an application of their own in the customer's tenant. This page describes the second path: a technician holding relay.configure starts a Microsoft 365 connection for a site in the portal and sends the customer's administrator a link. The site does not need a relay online for this - the relay can still be offline at this point. The administrator opens the link, signs in at Microsoft and consents once. The application being consented to is neither the customer's own nor the systems house's, but a single application Letterhop itself operates, one per path - the same one for every customer who takes that path, distinguished only by which tenant consented. From then on the platform itself holds the credential of that application, and hands the relay short-lived access tokens on an ongoing basis. Neither a client secret nor a password ever reaches the site.

Two paths, one choice per connection

The technician picks between two paths in the portal before the link is even created:

  • One click, tenant-wide. The application is registered in Entra with the Mail.Send and Mail.ReadWrite application permissions. The administrator consents once, and from then on the relay can send as any mailbox in that tenant, and read, write and delete mail in any of them. There is no waiting period.
  • Per mailbox, through Exchange. A second, separate application that declares no application permission at all in Entra. The consent itself carries almost no rights; the actual authorisation comes afterwards, when the administrator configures it themselves in Exchange Online, against a named group of mailboxes, through RBAC for Applications (New-ServicePrincipal, New-ManagementScope, New-ManagementRoleAssignment). Microsoft provides neither a user interface for this nor a way to trigger it from the portal. This path has not yet been proved to work on a real Microsoft 365 tenant; if it turns out not to, the application will declare Mail.Send and Mail.ReadWrite after all, authorised through the legacy Application Access Policy instead of RBAC.

One application cannot offer both at once. A permission granted in Entra and an authorisation granted in Exchange are unioned by Microsoft, not intersected: the moment an application holds tenant-wide Mail.ReadWrite, that effectively overrides any narrower authorisation in Exchange. That is why these are two separate applications with two separate links, not one application with an option.

On the consent screen the administrator sees what the respective application has declared in Entra:

  • For the tenant-wide application that is Mail.Send and Mail.ReadWrite, both as an application permission - meaning no person has to be signed in. Mail.Send allows sending as any mailbox in the tenant. Mail.ReadWrite additionally allows reading, writing and deleting mail in any mailbox; it is needed because both the connection test and every attachment above the upload threshold create a draft internally, and creating a draft requires Mail.ReadWrite, not Mail.Send alone. Letterhop requests nothing beyond these two permissions - no access to calendars, contacts, files or anything outside mail. Neither permission grants access to the directory: nothing here ever asks for User.Read.All, and this connection cannot be used to list the tenant's mailboxes.
  • For the per-mailbox application the screen shows none of that, because the application declares no application permission at all. What it may do is decided entirely by the authorisation the administrator grants afterwards in Exchange.

Because the tenant-wide permission applies to the whole tenant literally, it is what this documentation elsewhere calls the largest configuration mistake, when granted without further restriction. For a single, named address the second path is therefore the narrower choice, even though it takes more effort.

The publisher is not yet verified. Both applications currently run without Microsoft publisher verification. The consent screen therefore shows them as an application from an unverified publisher, and some tenants block such applications by policy on their own. Should the consent fail for that reason, Microsoft reports it on the same redirect, and the portal shows the technician Microsoft's own message verbatim.

A mailbox is typed and proven, not picked from a list

There is no step that lists a tenant's mailboxes. Such a list would need User.Read.All on every customer's consent screen and would not even show what the application may actually send as, because on this path that is decided by Exchange rather than by the directory. Instead, someone types the address this site is meant to send through, and the relay proves it with the same test the customer's own app registration uses: it creates a draft in that mailbox, reads back the address Graph actually resolves it to, and deletes the draft again. Three outcomes are possible: the address is allowed, with the address Graph resolved; it is not allowed, with Microsoft's own error; or it cannot be judged yet, see below.

The authorisation in Exchange can take up to two hours

On the Exchange path, Exchange Online caches a newly granted authorisation for up to two hours before it takes effect everywhere. A connection test run right after the PowerShell authorisation can therefore be refused even though the setup is correct. The relay tells this apart from a genuine refusal: when a 403 comes back for a connection authorised through Exchange, it reports "not yet in effect" instead of a final failure, and names Test-ServicePrincipalAuthorization, which bypasses the same cache and answers immediately which of the two it is. The tenant-wide connection has no such waiting period, and a 403 there is a genuine refusal.

Who holds the application credential

For both paths only the platform holds the credential of the respective application, never the relay. The relay stores only the customer's tenant id and which of the two paths was taken for this connection, no secret. From its own credential the platform mints a short-lived access token for exactly that tenant on demand and hands it down over the same standing connection the relay also sends its status reports on; a token itself never reaches the relay's own database and never appears in its log. This is a processing fact that should be known before consenting: for this kind of connection the platform is no longer merely a courier for encrypted packages - it issues, itself, the authorisation that mail is sent as the customer's tenant with.

The relay fetches a fresh token once fewer than 30 minutes remain on the one it holds; as long as the platform is reachable, the relay therefore always keeps a buffer of at least 30 minutes. An outage of the platform shorter than that stays invisible at the site. A longer outage pauses delivery for this kind of connection only: mail collects in the relay's queue and is delivered once tokens arrive again. Nothing is lost and nothing is bounced - connections with an own app registration or with SMTP are unaffected by this and keep delivering throughout, even while the platform is completely down.

If consent is withdrawn at the customer, the platform notices the next time it tries to fetch a token. One thing to note: withdrawing consent alone in Entra's enterprise applications view does not remove an authorisation already granted in Exchange. Whoever wants access truly and fully removed has to delete the enterprise application itself - that takes the matching Exchange authorisation with it automatically.

Read API

Updated on 2026-09-28

One endpoint: GET /api/v1/msp/platform-health. It answers the state of the platform itself, not the state of any customer or site - the database pool, open waits for requests currently in flight, and the command connection. Meant for a systems house's own monitoring, polling the platform regularly, not for a dashboard an end customer sees.

Access

The call needs an API key as a bearer token in the Authorization header. Unlike every other key-protected endpoint on this platform, platform-health does not scope to the key's tenant at all: a valid key from any tenant is enough, because the answer says nothing about any one customer to begin with - it is about the platform as a whole. A key belonging to a locked or terminated tenant is still refused (403), an unknown or wrong one with 401, and a key is capped at 60 calls a minute.

An administrator of your tenant creates a key in the portal under Integrations - not a technician holding individual grants, but someone with full administrative access, for the same reason a webhook target lives there too: both are credentials. The full key is shown exactly once, right when it is created; after that the portal shows only its name, a prefix, and its creation and last-used dates, never the key itself again.

What the response carries

{
  "status": "ok",
  "live_waits": 3,
  "pool": {
    "acquired_conns": 4,
    "idle_conns": 6,
    "empty_acquire_count": 0
  },
  "hub_reconnects": 12
}
  • status is always "ok" whenever the request is answered at all - the endpoint knows no other value.
  • live_waits is the number of requests the platform is currently holding open because a relay is waiting on the standing connection for an instruction.
  • pool is three numbers from the database connection pool: connections in use, connections idle, and how often a call had to wait for a free connection because the pool was empty. This field is absent entirely when the database binding in use does not expose these counters.
  • hub_reconnects counts how many times the command connection has had to reconnect in total. This field is absent when that connection is not configured.

No field names a tenant, a customer or a site - that is deliberate, not an omission: for the state of individual sites this API has its own tenant-scoped endpoints (/api/v1/msp/sites, /api/v1/msp/incidents), which need the same key but return only that key's own tenant's data.

Why /readyz shows none of it

/readyz is unauthenticated and reachable from the internet - meant for a container orchestrator that only needs to know whether this instance can serve requests right now, not for a systems house. It checks the database connection with a two-second timeout and answers only with {"status":"ok"}, {"status":"database unavailable"} or {"status":"no database configured"} - never with the numbers above. Those did briefly ride on /readyz; they moved behind the key check on platform-health because capacity and load numbers for a whole fleet of relays are not something an unauthenticated call from the internet should see.

/healthz, alongside it, checks even less: it answers as long as the process is running at all, without touching the database.