← Para municipios neerlandeses

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
Mail ☑︎ 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

  1. Install Goooy with the mail stack (mail.enabled=true, deployment.md). Coexistence needs Postfix.
  2. Add the mail domain (gemeente.nl) in Admin → Domains, but leave the MX record alone. Microsoft keeps receiving mail until the cutover.
  3. Add a routing domain, for example goooy.gemeente.nl, as an alias of gemeente.nl, and point its MX at Goooy. During coexistence, Microsoft forwards the mail of people who have moved to name@goooy.gemeente.nl, and Goooy files it under name@gemeente.nl.
  4. 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.
  5. 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:

  1. 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.microsoft host on newer setups).
  2. 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:

  1. App registrations → New registration: name “Goooy migration”, single tenant, no redirect URI.
  2. API permissions → Add → Microsoft Graph → Application permissions: Mail.Read, Calendars.Read, Contacts.Read, Files.Read.All, User.Read.All. Then Grant admin consent.
  3. Certificates & secrets → New client secret with a short lifetime (three months is plenty). Copy the value now; Entra shows it once.
  4. 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

  1. Admin → Migrations → New migration → Microsoft 365. Enter the tenant ID, client ID and secret. The secret is stored encrypted and never shown again.
  2. 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.
  3. 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)

  1. Open the batch and Process now once more. This final pass brings what arrived since the first.

  2. Click Deliver their mail to Goooy on the batch. Goooy now files mail for these people in their Goooy mailbox.

  3. 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 $false

    Microsoft 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.

  4. 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:

  1. Point the MX record of gemeente.nl at Goooy (dns.md). Keep the TTL low the day before.
  2. In Goooy, End coexistence for the domain.
  3. 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.
  4. Remove include:spf.protection.outlook.com from SPF and Microsoft’s DKIM selectors after a week.
  5. 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.