From Microsoft 365 to Goooy
A runbook for a gemeente (or any organization) that runs on Microsoft 365 today.
Switching everyone in one day is rarely realistic. This runbook moves an organization in steps: one team first, then groups, and only at the end the mail domain itself. During the coexistence period both systems run side by side and mail keeps flowing.
| Phase | Typical length | What happens |
|---|---|---|
| 0. Prepare | 2–4 weeks | Decisions, Goooy running, sign-in with Entra ID, everyone provisioned, the migration app registered |
| 1. Pilot | 2–4 weeks | One team moves (mail, calendar, contacts, OneDrive) and works in Goooy |
| 2. Groups | depends on size | The rest in groups of 25–100 people, one or two groups a week |
| 3. Cutover | one evening | The MX record points at Goooy; coexistence ends |
| 4. Clean-up | weeks | Microsoft 365 read-only, then licences and data per the Archiefwet |
What moves, and what doesn’t
| Data | Moves | How |
|---|---|---|
| ☑︎ every folder, read and flag state, received date | Admin → Migrations → Microsoft 365 (Graph) | |
| Calendar | ☑︎ appointments, meetings with organizer and attendees, recurring series | same |
| Contacts | ☑︎ | same |
| OneDrive | ☑︎ files and folders, into Files → OneDrive | same |
| Shared mailboxes | ☑︎ map the shared mailbox’s address to a Goooy shared mailbox | same |
| Exceptions to a recurring series (one moved or cancelled occurrence) | ❌ the series moves; its exceptions don’t | re-plan by hand |
| Inbox rules, categories, delegates, calendar permissions | ❌ | set up again in Goooy |
| Teams chat | ❌ | see Teams |
| SharePoint sites and Teams files | ❌ not by the connector | see SharePoint |
| OneNote, Planner, Forms, Loop | ❌ | export per tool; OneNote notebooks are skipped |
Files larger than MIGRATION_MAX_UPLOAD_MB (20 GiB by default) |
❌ | the desktop sync |
Re-running a batch is safe. Mail is matched by Message-ID, calendar items and contacts by their Microsoft ID, and a file already in the same folder with the same size is skipped. Run a first pass days ahead, then a short final pass at the moment a group moves.
0. Prepare
Decisions
- Who moves when. Pick a pilot team that uses mail, calendar and files every day and is willing to report problems, such as Communicatie or I&A. Then group the rest by department, because people who plan meetings together should move together (see free/busy).
- Privacy and security paperwork. Update the DPIA and the verwerkingsregister (privacy.md), the BIO2 statement of applicability (bio.md) and the processor agreement with whoever runs Goooy.
- The works council (OR) where the organization’s own rules require it.
Goooy
- Install Goooy with the mail stack (
mail.enabled=true, deployment.md). Coexistence needs Postfix. - Add the mail domain (
gemeente.nl) in Admin → Domains, but leave the MX record alone. Microsoft keeps receiving mail until the cutover. - Add a routing domain, for example
goooy.gemeente.nl, as an alias ofgemeente.nl, and point its MX at Goooy. During coexistence, Microsoft forwards the mail of people who have moved toname@goooy.gemeente.nl, and Goooy files it undername@gemeente.nl. - Publish Goooy’s DKIM key next to Microsoft’s (a different selector,
dns.md) and add Goooy’s outbound address to the SPF record next to
include:spf.protection.outlook.com. Leave the DMARC record as it is. - Set Admin → Defaults → Language and region to the Dutch preset (regional.md).
Sign-in and accounts: Entra ID stays the source
- Single sign-on: register Goooy in Entra ID as an OpenID Connect app (sso.md). People sign in to Goooy with their Microsoft account and MFA, as they do now.
- Provisioning: connect Entra ID to Goooy’s SCIM endpoint (scim.md), so that everyone gets a Goooy account on day one. Everyone is then in the Goooy address book, and a later joiner or leaver is handled in Entra as before.
Because everyone exists in Goooy from the start, tell Goooy that their mail is still elsewhere (next section).
Coexistence
In Admin → Migrations → Coexistence with the old system:
- For
gemeente.nl, enter Microsoft’s inbound host for the domain: the MX host Microsoft 365 shows for it (gemeente-nl.mail.protection.outlook.com, or a…mx.microsofthost on newer setups). - Click Everyone still on the old system.
From then on, mail from Goooy to someone who hasn’t moved goes to Microsoft 365
and not into their empty Goooy mailbox. This covers new mail, replies and calendar
invitations. Postfix also sends mail for an address that has no Goooy account at
all to Microsoft (deploy/mail/postfix/pgsql/transport.cf).
In Exchange Online, create an inbound connector from Goooy (partner
organization, identified by Goooy’s outbound IP address or TLS certificate). Mail from Goooy
for @gemeente.nl is then treated as trusted, not as a spoof of the organization’s
own domain.
The migration app registration
The Microsoft 365 source reads mailboxes and OneDrives through Microsoft Graph with one app registration. No user passwords are involved. Exchange Online turned off password sign-in for IMAP in 2022, so the IMAP route no longer works for Microsoft 365.
In the Entra admin center:
- App registrations → New registration: name “Goooy migration”, single tenant, no redirect URI.
- API permissions → Add → Microsoft Graph → Application permissions:
Mail.Read,Calendars.Read,Contacts.Read,Files.Read.All,User.Read.All. Then Grant admin consent. - Certificates & secrets → New client secret with a short lifetime (three months is plenty). Copy the value now; Entra shows it once.
- Note the Directory (tenant) ID and Application (client) ID on the Overview page.
These permissions let the app read every mailbox and OneDrive in the tenant. To
limit mail and calendar access to the people being moved, use RBAC for
Applications in Exchange Online (a management scope on a group) instead of the
Graph permissions for those two. Files.Read.All can’t be scoped that way. Keep
the secret’s lifetime short and delete the app registration after the cutover.
Every batch and every mapping change is in Goooy’s audit log.
If Goooy runs with DATA_RESIDENCY=eu-strict (data-sovereignty.md),
add graph.microsoft.com and login.microsoftonline.com to EU_ALLOWED_HOSTS
for the migration period. The connector refuses to call them otherwise.
1. Pilot
A few days before the move
- Admin → Migrations → New migration → Microsoft 365. Enter the tenant ID, client ID and secret. The secret is stored encrypted and never shown again.
- Load users, fill in the Goooy domain so each address maps to its Goooy account, and switch off everyone who isn’t in the pilot. Use Test on one row to check the app can read that mailbox.
- Choose what to bring (Mail, Calendar, Contacts, OneDrive) and start. The
dashboard shows each person and data type with its progress. Graph slows
down large tenants (throttling); the migration waits (it follows Graph’s
Retry-After, up to two minutes per attempt and eight attempts) and continues on its own. A task that is still throttled after that fails and can be retried.
On the day (after work)
-
Open the batch and Process now once more. This final pass brings what arrived since the first.
-
Click Deliver their mail to Goooy on the batch. Goooy now files mail for these people in their Goooy mailbox.
-
At the same time, in Exchange Online, forward each pilot mailbox to the routing domain without keeping a copy:
Set-Mailbox j.devries@gemeente.nl -ForwardingSmtpAddress j.devries@goooy.gemeente.nl -DeliverToMailboxAndForward $falseMicrosoft 365 blocks automatic forwarding to outside addresses by default. Allow it for these mailboxes with a custom outbound spam filter policy (automatic forwarding: on) scoped to the moved people.
-
The pilot team signs in to Goooy: the web app, Outlook through MAPI/HTTP with a new profile, phones through ActiveSync or the Goooy app (mobile-app.md), and the desktop apps (desktop-app.md).
During the pilot, keep a short list: what is missing, what is slower, what people like. Decide at the end whether to go on, what to change in the instructions, and which settings the next groups need.
2. Groups
The same steps per group: a new batch (or the same batch with more people switched on), a first pass, then on the day the final pass, Deliver their mail to Goooy and the forwards. The Microsoft 365 side is a PowerShell loop over the group:
Get-DistributionGroupMember "Migratie groep 3" | ForEach-Object {
Set-Mailbox $_.PrimarySmtpAddress -ForwardingSmtpAddress ($_.PrimarySmtpAddress -replace '@gemeente.nl$', '@goooy.gemeente.nl') -DeliverToMailboxAndForward $false
}
The coexistence section on the Migrations page counts how many people are still on the old system.
What coexistence does not do
- Free/busy across the two systems. Goooy can’t see calendars in Exchange Online and the reverse. Move people who schedule each other in the same group.
- Delegates across the two systems. A secretary and their manager move together.
- Shared mailboxes move when the last of their users has moved.
3. Cutover
When the last group has moved:
- Point the MX record of
gemeente.nlat Goooy (dns.md). Keep the TTL low the day before. - In Goooy, End coexistence for the domain.
- Remove the forwards in Exchange Online. After the MX change, remote servers deliver to Goooy directly; a few stragglers still reaching Microsoft 365 are forwarded as before for as long as the forwards stay, so remove them after a day or two.
- Remove
include:spf.protection.outlook.comfrom SPF and Microsoft’s DKIM selectors after a week. - Delete the migration app registration in Entra.
4. Clean-up
- Keep Microsoft 365 read-only until you are sure nothing is missing. Remove licences in steps.
- Archiefwet: what didn’t move (Teams chat, SharePoint sites) must stay available for its retention period, or be exported into the archive. Goooy’s archive (MDTO export, destruction lists) takes over from the moment of migration.
- Rollback during the coexistence period is possible per person. In Goooy, mark them as on the old system again, and remove the forward in Exchange Online. Goooy only ever reads from Microsoft 365, so nothing there has changed.
Teams
Teams chat can’t be migrated. Microsoft’s API for reading chat messages across a tenant is a protected, metered API that needs Microsoft’s approval. For the archive, export chats with Microsoft Purview eDiscovery and keep the export under the Archiefwet. Day-to-day conversations start fresh in Goooy Chat and Meet (chat-slack.md, meet.md); pin a message in each new channel pointing to where the old history is kept.
SharePoint and Teams files
The Microsoft 365 source moves each person’s OneDrive, not SharePoint sites (the files tab of a Team is a SharePoint library). For a handful of libraries, sync the library with the OneDrive client on a PC and copy it into a shared folder in Goooy Files, or through the network drive (files-sync.md). For many libraries, a copy tool that speaks both SharePoint and WebDAV, such as rclone, can do it. We haven’t tested that path.
Testing
apps/api/test/smoke-m365.mjs runs the Microsoft 365 source against a mock
tenant: sign-in with the app registration, users across pages, a throttled request, mail
folders (Postvak IN, Verzonden items and a subfolder) with read and flag state,
a meeting with attendees, a weekly series, an all-day item, a contact, and a
OneDrive with a folder and a OneNote notebook. It also checks that a second run adds
nothing and that coexistence routes mail and invitations. The Postfix
maps were checked with postmap -q against the real schema. Against a real
tenant: not yet. The first gemeente pilot is that test, so run it with a
test mailbox first.