I spent years supporting WorkMail and SES at AWS, and even became a Subject Matter Expert in both services. Here's how I moved my own mail off it, start to finish... and got sentimental doing it.
Level: 200 (intermediate). Assumes you're comfortable with the AWS CLI, IAM roles, S3, and KMS.
Amazon WorkMail is winding down... AWS has announced end of support for March 31, 2027. If you're running a mailbox or two on WorkMail, now is a good time to think about where that mail is going to live next. In my case, I'm moving my domain's mail over to Google Workspace, and I wanted to bring years of old email along for the ride.
I'll be straight with you up front, though... this one's personal, and writing a guide to leave WorkMail behind is genuinely bittersweet. I'll get into why at the end... but first, let's do the work.
Here's the important part... WorkMail gives you a clean, supported way to get your mail out: the StartMailboxExportJob API. It drops every message into an S3 bucket as a KMS-encrypted .zip of standard .eml files. From there, getting those messages into Gmail is just a matter of speaking IMAP.
In this post, we're going to walk through the whole path... exporting the mailbox, wiring up the IAM and KMS pieces the export needs, downloading and inspecting the archive, uploading everything into Gmail with a small Python script, bringing the calendar over, tearing WorkMail down when you're done, and finally locking the domain down with SPF, DKIM, and DMARC so your new Gmail-hosted mail actually lands. Along the way I'll call out the gotchas that cost me time, so they don't cost you any.
The shape of the solution
Before we touch a command, let's set the mental model. There are two halves to this migration:
-
Get the mail out of WorkMail.
StartMailboxExportJobwrites an encrypted.zipto S3. This needs a KMS key and an IAM role the WorkMail export service can assume. -
Get the mail into Gmail. Gmail speaks IMAP, and IMAP has an
APPENDcommand that uploads a raw message into a mailbox. We loop over the exported.emlfiles and append each one.
That's it. No third-party migration tool, no paid service. Just AWS APIs on one side and IMAP on the other.
Part 1: Exporting the mailbox
What the export actually contains
The export writes email messages and calendar items to a .zip, organized into folders that mirror your mailbox (Inbox, Sent Items, Deleted Items, Junk E-mail, Calendar). Messages come out as .eml (standard MIME), and calendar entries come out as .ics.
One thing that surprised me... contacts and tasks are not included. If you need those, export them separately from the WorkMail web app. Good to know before you assume the .zip is everything.
The prerequisites the export needs
The export job can't just write to S3 on its own. It needs three things in place, and all of them have to live in the same AWS Region as your WorkMail organization:
- A symmetric KMS key to encrypt the output.
- An IAM role that the export service (
export.workmail.amazonaws.com) can assume. - An S3 bucket to receive the
.zip.
Let's build them. First, the KMS key:
aws kms create-key \
--description "WorkMail mailbox export encryption key" \
--key-spec SYMMETRIC_DEFAULT \
--key-usage ENCRYPT_DECRYPT
Grab the key ARN from the output... you'll need it in a couple of places.
Now the IAM role. This is the part people trip on, so let's be precise. The role needs a trust policy that lets the WorkMail export service assume it, scoped to your account and organization:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "export.workmail.amazonaws.com" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "aws:SourceAccount": "111122223333" },
"ArnLike": {
"aws:SourceArn": "arn:aws:workmail:us-east-1:111122223333:organization/m-EXAMPLEORGID"
}
}
}
]
}
Those aws:SourceAccount and aws:SourceArn conditions matter... they're what stop a confused-deputy situation where some other org could trick your role into running. Keep them.
And the permissions policy that lets the role write to your bucket and use the KMS key:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:AbortMultipartUpload", "s3:PutObject", "s3:GetBucketPolicyStatus"],
"Resource": [
"arn:aws:s3:::amzn-s3-demo-bucket",
"arn:aws:s3:::amzn-s3-demo-bucket/*"
]
},
{
"Effect": "Allow",
"Action": ["kms:Decrypt", "kms:GenerateDataKey"],
"Resource": ["arn:aws:kms:us-east-1:111122223333:key/EXAMPLE-KEY-ID"],
"Condition": {
"StringEquals": { "kms:ViaService": "s3.us-east-1.amazonaws.com" },
"StringLike": {
"kms:EncryptionContext:aws:s3:arn": "arn:aws:s3:::amzn-s3-demo-bucket/mail-export*"
}
}
}
]
}
Create the role and attach the policy:
aws iam create-role \
--role-name WorkmailMailboxExportRole \
--assume-role-policy-document file://trust-policy.json
aws iam put-role-policy \
--role-name WorkmailMailboxExportRole \
--policy-name MailboxExport \
--policy-document file://permissions-policy.json
Kicking off the export
You'll need your organization ID and the entity (user) ID for the mailbox. Both come from the WorkMail API:
aws workmail list-organizations
aws workmail list-users --organization-id m-EXAMPLEORGID
Now start the job:
aws workmail start-mailbox-export-job \
--organization-id m-EXAMPLEORGID \
--entity-id EXAMPLE-ENTITY-ID \
--kms-key-arn arn:aws:kms:us-east-1:111122223333:key/EXAMPLE-KEY-ID \
--role-arn arn:aws:iam::111122223333:role/WorkmailMailboxExportRole \
--s3-bucket-name amzn-s3-demo-bucket \
--s3-prefix mail-export \
--description "Mailbox export for Gmail migration"
You'll get back a JobId. Poll it until the state flips to COMPLETED:
aws workmail describe-mailbox-export-job \
--organization-id m-EXAMPLEORGID \
--job-id EXAMPLE-JOB-ID
A mid-size mailbox (a few thousand messages) finishes in a handful of minutes. When it's done, you'll have one .zip sitting at s3://amzn-s3-demo-bucket/mail-export/....
Heads up if your bucket fronts a public website. The export
.zipis KMS-encrypted, so even a public bucket doesn't expose readable mail... a random visitor just gets ciphertext. Still, download it over authenticated S3 rather than the public URL, and delete it once you're done. No reason to leave a copy of your entire mailbox lying around.
Part 2: Download and inspect
Pull the archive down (S3 decrypts on the fly because your identity has access to the KMS key) and unzip it:
aws s3 cp "s3://amzn-s3-demo-bucket/mail-export/EXAMPLE.zip" ./mailbox-export.zip
unzip -q mailbox-export.zip -d mailbox-export
Take a minute to look at what you got. Counting files per folder tells you where the value actually is:
cd mailbox-export
for d in */; do printf "%-16s %s files\n" "$d" "$(find "$d" -type f | wc -l)"; done
In my export, the Inbox and Sent Items were the mail I cared about... Deleted Items and Junk E-mail were thousands of messages of noise I had no interest in re-importing. Knowing that up front saved me from uploading ~4,800 junk messages into a fresh mailbox.
Part 3: Uploading into Gmail over IMAP
This is where it gets fun. Gmail exposes IMAP at imap.gmail.com:993, and Python's standard library ships imaplib, so we don't need any dependencies.
A few design decisions that make the difference between "it technically worked" and "it worked well":
-
Preserve the original date. If you don't set the IMAP internal date, every message shows up dated today, which wrecks sorting. We parse each message's
Date:header and pass it through. -
Mark messages as read. Uploading 3,900 emails as unread is a great way to make your new inbox unusable. We append with the
\Seenflag. - Batch the work. Gmail throttles after a burst of appends, and huge operations are easier to reason about in chunks. Split the Inbox into folders of ~500 and upload one at a time.
- Never hardcode credentials. The script reads your Gmail address and an app password from environment variables. It never stores or prints them.
Here's the core of the uploader:
import email, email.utils, imaplib, os, sys, time
from pathlib import Path
def internaldate_for(msg):
date_hdr = msg.get("Date")
if date_hdr:
parsed = email.utils.parsedate_tz(date_hdr)
if parsed:
return imaplib.Time2Internaldate(email.utils.mktime_tz(parsed))
return None # server stamps "now" if the header is missing
def upload(directory, label):
imap = imaplib.IMAP4_SSL("imap.gmail.com", 993)
imap.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"])
if not label.startswith("[Gmail]"):
imap.create(f'"{label}"') # harmless if it already exists
for f in sorted(Path(directory).glob("*.eml")):
raw = f.read_bytes()
msg = email.message_from_bytes(raw)
imap.append(f'"{label}"', r"(\Seen)", internaldate_for(msg), raw)
time.sleep(0.05) # be polite to Gmail's rate limits
imap.logout()
A quick word on app passwords... Gmail won't let a script log in with your normal password once 2-Step Verification is on. Instead you generate a 16-character app password (Google Account → Security → App passwords), hand it to the script through an environment variable, and revoke it the moment you're done. That keeps the credential out of your code, out of your shell history if you're careful, and easy to kill.
Run it per batch, and point Sent mail at Gmail's real Sent folder:
export GMAIL_ADDRESS="you@yourdomain.com"
export GMAIL_APP_PASSWORD="xxxxxxxxxxxxxxxx"
for b in 01 02 03 04 05 06 07 08; do
python3 import_to_gmail.py --dir "mailbox-export/Inbox-batches/batch-$b" --label "WorkMail Import"
done
python3 import_to_gmail.py --dir "mailbox-export/Sent Items" --label "[Gmail]/Sent Mail"
The gotchas that cost me time
A few things I learned the hard way, so you don't have to:
Gmail has a 25 MB per-message limit. IMAP APPEND rejects anything bigger with [TOOBIG] Message too large. In my migration exactly one email failed... a 39 MB message stuffed with photos. There's no IMAP workaround; you handle those few manually from the export. The fix is to log the failures with their subject and date as you go, so you have a short punch list at the end instead of a mystery.
A custom label is not the Inbox. This one's subtle. When you APPEND a message to a label like "WorkMail Import", Gmail applies that label... and only that label. The Inbox in Gmail is itself just a label (\Inbox), and your append never applied it. So all your mail imports perfectly and then appears to be "missing" from the Inbox. It's not missing... it just isn't tagged Inbox.
The fix is elegant once you know it. In Gmail, copying a message to INBOX adds the Inbox label without duplicating the message. So a one-shot IMAP COPY from your label into INBOX lights everything up:
imap.select('"WorkMail Import"')
_, data = imap.uid("SEARCH", None, "ALL")
uids = data[0].split()
for i in range(0, len(uids), 200):
chunk = b",".join(uids[i:i+200]).decode()
imap.uid("COPY", chunk, "INBOX") # adds \Inbox label, no dupes
Everything must share a Region. WorkMail org, KMS key, and S3 bucket all have to be in the same AWS Region, or the export simply won't run. Check this first.
Watch the throttling. My first batch of 500 flew through in about six minutes. Later batches slowed as Gmail throttled the connection. It never failed... it just paced itself. Batching plus a small sleep between appends keeps you on the right side of Gmail's limits.
Part 4: Bringing the calendar over
Remember those .ics files in the Calendar/ folder? Google Calendar can import them, but its web UI takes one file at a time, and I had a lot to import. So first, merge them into a single file.
The naive move... cat *.ics > combined.ics... produces a file with separate BEGIN:VCALENDAR ... END:VCALENDAR blocks, and some importers choke on that. The correct shape is one VCALENDAR wrapper containing all the event components. Pull the components out of each file and drop them into a single calendar:
def extract_components(lines):
"""Keep only component blocks (VEVENT, VTIMEZONE, ...); drop the outer
VCALENDAR wrapper and calendar-level scalar props so we can re-wrap once."""
out, depth = [], 0
for ln in lines:
s = ln.strip()
if s in ("BEGIN:VCALENDAR", "END:VCALENDAR"):
continue
if s.startswith("BEGIN:"): depth += 1; out.append(ln); continue
if s.startswith("END:"): out.append(ln); depth -= 1; continue
if depth >= 1: out.append(ln) # inside a component
return out
Wrap the collected components with a single header (BEGIN:VCALENDAR / VERSION:2.0 / PRODID:... / CALSCALE:GREGORIAN) and footer, write it out with CRLF line endings per RFC 5545, and you've got one importable file.
The "you do not have sufficient access on the target calendar" trap
Here's the one that had me scratching my head. I imported the merged file into my own calendar and got:
Imported zero events. Could not upload your events because you do not have sufficient access on the target calendar.
On my own calendar. Cute.
It's a misleading error. It has nothing to do with permissions. These events were meeting invites... Calendly bookings, Google-generated invitations... and each one carried its original UID ending in @google.com, with an ORGANIZER that was someone else. Google recognizes that UID as an event owned by another account in its namespace, and refuses to let you import a copy... hence "insufficient access."
The fix is to give each event a fresh, unique UID so Google treats them as brand-new events you own:
import uuid
# for each line in the .ics:
if line.startswith("UID:") or line.startswith("UID;"):
line = f"UID:{uuid.uuid4()}@yourdomain.com"
Rewrite the UIDs, re-import, and you'll get the satisfying "Imported 7 out of 7 events." Tip: import into a dedicated calendar (make a "WorkMail" one first) so if anything looks off, you just delete that calendar instead of untangling events from your main one.
And remember... the export never included contacts. Export those from the WorkMail web app as a vCard and import them into Google Contacts separately.
Part 5: Decommissioning WorkMail
Once you've confirmed everything is safely in Gmail, you can tear WorkMail down to stop the per-mailbox billing. Order matters here.
Repoint your MX records first. Before you delete anything, make sure your domain's MX points at Google, not WorkMail, so incoming mail flows to the right place. Verify it against your authoritative nameserver (more on why in Part 6):
dig +short MX yourdomain.com # want: 1 smtp.google.com.
Only once that's confirmed should you delete the organization. This is irreversible... it permanently destroys the mailbox and its directory:
aws workmail delete-organization \
--organization-id m-EXAMPLEORGID \
--delete-directory \
--force-delete # needed if the org still has enabled users
Then clean up the export scaffolding you built in Part 1, plus the archive itself:
# Schedule the KMS key for deletion (7-30 day window, cancellable)
aws kms schedule-key-deletion --key-id EXAMPLE-KEY-ID --pending-window-in-days 7
# Remove the IAM role (delete its inline policy first)
aws iam delete-role-policy --role-name WorkmailMailboxExportRole --policy-name MailboxExport
aws iam delete-role --role-name WorkmailMailboxExportRole
One more thing on the S3 archive: if your bucket has versioning enabled, a plain aws s3 rm just drops a delete marker... the actual object version (your entire mailbox) sticks around and stays recoverable. To truly purge it, delete the versions:
aws s3api list-object-versions --bucket amzn-s3-demo-bucket --prefix mail-export
# then delete each returned VersionId with:
aws s3api delete-object --bucket amzn-s3-demo-bucket --key <key> --version-id <VersionId>
Don't forget SES receiving, if you set it up
Here's one that's easy to leave dangling. If at some point you configured SES to receive mail for your domain (a receipt rule set with an S3 action that drops inbound messages into a bucket), that's a completely separate pile of resources from WorkMail... and it keeps quietly storing mail as long as your MX points at SES. Once you've moved MX to Google, it's dead weight. In my case it had accumulated over 11,000 raw messages in an S3 prefix I'd forgotten about.
Find it by looking at your active rule set:
aws ses describe-active-receipt-rule-set # shows the S3Action bucket + prefix
To tear it down, deactivate the rule set before deleting it (SES won't let you delete the active one), then purge the S3 prefix:
aws ses set-active-receipt-rule-set # deactivate all
aws ses delete-receipt-rule-set --rule-set-name your-rule-set-name
aws s3 rm "s3://your-bucket/SES/" --recursive --only-show-errors # scope to the prefix!
Two cautions here:
-
Scope the S3 delete to the prefix, not the bucket. That bucket may hold unrelated things... mine also had a website folder and a video file at the root. Delete
s3://bucket/SES/, nevers3://bucket. - Be careful with SES identities. A verified domain identity governs sending, not just receiving. If anything still sends transactional mail as your domain through SES, keep the domain identity. Only delete identities you're sure are unused (a stale per-address identity is usually safe):
aws ses delete-identity --identity old-unused@yourdomain.com
Part 6: Locking down email authentication (SPF, DKIM, DMARC)
Migrating the mail is only half the job. If you don't set up email authentication for your domain on Google Workspace, your outbound mail is going to land in spam folders... or get rejected outright. You want all three: SPF, DKIM, and DMARC.
A debugging tip that will save you an hour: when you add a DNS record and it "isn't showing up," don't guess about propagation. Query your domain's authoritative nameserver directly. If the record is there, it's live; if it's not, your edit didn't land (wrong hosted zone, wrong record name, not saved). This cuts through all the caching noise:
NS=$(dig +short NS yourdomain.com | head -1)
dig @"$NS" +short TXT yourdomain.com # SPF lives on the apex
dig @"$NS" +short TXT _dmarc.yourdomain.com # DMARC lives here
dig @"$NS" +short TXT google._domainkey.yourdomain.com # DKIM
SPF goes on the apex as a TXT record:
"v=spf1 include:_spf.google.com ~all"
Watch out here if you're on Route 53: you can't have two separate TXT record sets with the same name. If your apex already has, say, a google-site-verification TXT, the SPF string goes in as an additional quoted value in that same record set... not a new record.
DKIM is generated in the Google Admin console (Apps → Google Workspace → Gmail → Authenticate email). It hands you a TXT record with the host name google._domainkey and a v=DKIM1; k=rsa; p=... value. Add it as its own separate record, then... and this is the step people miss... go back to the Admin console and click Start authentication. The DNS record alone doesn't turn it on. (If you already see a "Stop authentication" button, it's active.)
DMARC is the one everybody gets wrong, so pay attention: it does not go on your apex. It goes on its own record named _dmarc.yourdomain.com. That's a different DNS name, so it's a separate record and won't conflict with anything... the "only one TXT per name" rule doesn't apply across different names. Mail servers only ever look up DMARC at _dmarc.<domain>, so a v=DMARC1 string sitting on your apex does absolutely nothing.
Also know this: DMARC is domain-wide, not per-address. One record covers every mailbox on the domain. The mailto: in it isn't a "protected" address... it's just where aggregate reports get sent. Start in monitor-only mode:
_dmarc.yourdomain.com TXT "v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com"
Leave it at p=none for a week or two while you watch the reports, then tighten to p=quarantine and eventually p=reject once you're confident legit mail is passing. DMARC only enforces once SPF and/or DKIM are aligned, so get those two live and signing first.
Wrapping up
When the dust settled, I had ONE message that wasn't migrated. It ended up being that oversized photo email, which I grabbed by hand. Sent mail landed in Sent, old mail landed in the Inbox (labeled correctly), all calendar events came across, WorkMail was fully torn down, and the domain was authenticating with SPF, DKIM, and DMARC.
The core pattern generalizes well beyond WorkMail. Export to a portable format (.eml/MIME), then speak the destination's native protocol (IMAP). Any mailbox that can export standard messages and any provider that offers IMAP can be bridged with a script like this. The calendar half is the same idea in miniature... portable .ics, imported natively.
A few closing lessons worth internalizing:
- Verify against the authoritative nameserver, not a public resolver, whenever DNS "isn't working." It turns a guessing game into a yes/no answer.
-
DMARC on
_dmarc, DKIM ongoogle._domainkey, SPF on the apex. Wrong record name is the number-one reason email auth silently does nothing. -
Clean up what you created. Revoke the app password, delete the export
.zip(including old versions if the bucket is versioned), schedule the KMS key for deletion, and remove the export IAM role. And repoint MX before you delete the WorkMail org, never after. -
Hunt down orphaned receiving infrastructure. WorkMail wasn't necessarily the only thing catching your mail... an old SES receipt rule set quietly archiving to S3 will keep costing you storage long after you've moved on. Check
describe-active-receipt-rule-setand clean it up too.
Happy migrating.
One last thing... a personal goodbye
I'm going to step out of tutorial mode for this part, because writing a guide for migrating off WorkMail is genuinely bittersweet for me.
Before I ever built things on AWS, I worked at AWS, as a Cloud Support Engineer. WorkMail became my thing. I went deep enough to become an SME on it... and on SES right alongside it. I spent years in the trenches with some of our largest enterprise customers, untangling mail flow, receipt rules, DKIM, deliverability... the exact stuff this post is about. None of this is abstract for me. It's muscle memory from a lot of customer cases and a lot of late nights.
In 2016, I got to fly out to The Hague, near Amsterdam, and spend a week working side by side with the WorkMail service team. Sitting in the same room as the people who actually built the service, learning how they thought about it... that's one of the highlights of my entire AWS career. I'll never forget that week.
So yeah... watching WorkMail sunset stings. This was never just another service to me. It was customers I got to help, hard problems I got to solve, and a team I got to learn from. It's a chapter of my career I'm genuinely proud of. If you found your way here because you're migrating off too, I hope this guide makes it painless... and I hope you'll forgive me for getting a little sentimental about the thing we're packing up.
Thank you, WorkMail. And thank you to the team behind it. It was an honor. 🧡

Top comments (0)