<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aaron Hunter</title>
    <description>The latest articles on DEV Community by Aaron Hunter (@aaronshunter).</description>
    <link>https://dev.to/aaronshunter</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F976687%2F6e370c87-c048-451f-adc5-6e71f436f45b.jpeg</url>
      <title>DEV Community: Aaron Hunter</title>
      <link>https://dev.to/aaronshunter</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aaronshunter"/>
    <language>en</language>
    <item>
      <title>My Kids Couldn't Spell "Because," So I Built an App to Help</title>
      <dc:creator>Aaron Hunter</dc:creator>
      <pubDate>Mon, 14 Sep 2026 21:31:33 +0000</pubDate>
      <link>https://dev.to/aaronshunter/my-kids-couldnt-spell-because-so-i-built-an-app-to-help-3lp</link>
      <guid>https://dev.to/aaronshunter/my-kids-couldnt-spell-because-so-i-built-an-app-to-help-3lp</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fotibk12oo4t5eq7yj47c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fotibk12oo4t5eq7yj47c.png" alt="Bee Hunter Header Image" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I built a serverless handwriting-grading app for my own kids, and it turned into one of the most honest reliability projects I've worked on. This is the deep version... the event-driven internals, the throttling war story, and the reconciliation layer, straight out of the deployed code.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every week my kids bring home a spelling list. And every week the same thing happened... they'd ace the open-book practice, then forget some words and write others incorrectly. Letters flipped. Words running together. "Because" spelled a different way every time they tried.&lt;/p&gt;

&lt;p&gt;I'm an engineer, so I built a thing. It's called Bee Hunter, and I'll be straight with you... it taught me more about the sharp edges of event-driven systems than half the production services I've worked on. My son named it: "Bee" for spelling bee, "Hunter" because that's our last name. He was very proud, and I was too.&lt;/p&gt;

&lt;p&gt;Here's the twist. My users are in elementary school. They don't file bug reports. When something's slow or broken, they do one thing... they quit and hand the phone back to go play Roblox. That one constraint changed how I thought about the whole system, and it's the thread running through everything below: the architecture, the stage-machine bug that became a Bedrock throttling storm, and the reconciliation layer that stitches two AI services together. Every snippet here is from the deployed functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Bee Hunter does
&lt;/h2&gt;

&lt;p&gt;A kid photographs a handwritten spelling test, and a few seconds later the app shows which words are spelled correctly, how legible each one is, and a cropped image of each word so you can see what they actually wrote. It runs serverless on AWS, scales to zero, and is cheap enough that a project for two kids doesn't turn into a bill I resent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;A photo goes in, word-by-word feedback comes out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Photo upload
   -&amp;gt; S3 (image storage)
   -&amp;gt; Textract         (OCR: finds WHERE the words are)
   -&amp;gt; Claude / Bedrock (reads WHAT the words say + scores them)
   -&amp;gt; Reconcile        (fuzzy-match the two together)
   -&amp;gt; Results          (crops + spelling + legibility scores)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pieces: &lt;strong&gt;S3 + CloudFront&lt;/strong&gt; for hosting and image storage, &lt;strong&gt;API Gateway + Lambda&lt;/strong&gt; for the backend (14 small functions, one job each), &lt;strong&gt;Amazon Textract&lt;/strong&gt; for bounding boxes, &lt;strong&gt;Amazon Bedrock (Claude Sonnet 4.5)&lt;/strong&gt; to read and score the handwriting, and &lt;strong&gt;DynamoDB&lt;/strong&gt; for submissions and results.&lt;/p&gt;

&lt;p&gt;The flow: the browser uploads straight to S3 with a presigned URL. That fires an S3 event to &lt;code&gt;beehunter-textract-processor&lt;/code&gt;, which runs Textract and writes a record to &lt;code&gt;beehunter-textract-results&lt;/code&gt; stamped &lt;code&gt;stage: 'textract_complete'&lt;/code&gt;. That write fires a &lt;strong&gt;DynamoDB stream&lt;/strong&gt; to &lt;code&gt;beehunter-claude-analyzer&lt;/code&gt;, the brain of the app: it reads the image, calls Bedrock, reconciles the two AI outputs, scores every word, and writes the final record to &lt;code&gt;beehunter-results&lt;/code&gt;. The frontend polls &lt;code&gt;GET /status/{id}&lt;/code&gt;, which checks results first and falls back to the textract &lt;code&gt;stage&lt;/code&gt; for a progress message.&lt;/p&gt;

&lt;p&gt;Two facts about that analyzer step set up the best bug in the project. First, it's a Lambda writing to DynamoDB tables that &lt;em&gt;also&lt;/em&gt; have streams, and it emits its own &lt;code&gt;claude_starting&lt;/code&gt;/&lt;code&gt;claude_analyzing&lt;/code&gt;/&lt;code&gt;complete&lt;/code&gt; write-backs so the poller can show progress. Every one of those is itself a stream event. Second, &lt;strong&gt;DynamoDB Streams deliver at-least-once, not exactly-once&lt;/strong&gt;, so a record can hit your Lambda more than once and your function has to be idempotent. Hold both thoughts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two AI services that don't talk to each other
&lt;/h2&gt;

&lt;p&gt;I'm using two AI services, and each is good at exactly half the job.&lt;/p&gt;

&lt;p&gt;Textract nails &lt;em&gt;where&lt;/em&gt; the words are. Pixel-perfect boxes, as fractions of the image. Here's a real spelling test one of my kids handed me:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F35r4c7rovoe38um2ce8b.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F35r4c7rovoe38um2ce8b.jpg" alt="handwritten spelling test: river, meter, paper, permit, tiger, hermit, number, over, later, ruler, then himself, person, swerve, mercy, germ" width="800" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One of my kids' actual spelling tests. "Ruler" gave everybody trouble.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And here's what Textract made of it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Word&lt;/th&gt;
&lt;th&gt;X&lt;/th&gt;
&lt;th&gt;Y&lt;/th&gt;
&lt;th&gt;W&lt;/th&gt;
&lt;th&gt;H&lt;/th&gt;
&lt;th&gt;Confidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;river&lt;/td&gt;
&lt;td&gt;862&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;471&lt;/td&gt;
&lt;td&gt;187&lt;/td&gt;
&lt;td&gt;99.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;meter&lt;/td&gt;
&lt;td&gt;850&lt;/td&gt;
&lt;td&gt;581&lt;/td&gt;
&lt;td&gt;559&lt;/td&gt;
&lt;td&gt;207&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paper&lt;/td&gt;
&lt;td&gt;859&lt;/td&gt;
&lt;td&gt;778&lt;/td&gt;
&lt;td&gt;475&lt;/td&gt;
&lt;td&gt;264&lt;/td&gt;
&lt;td&gt;99.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tiger&lt;/td&gt;
&lt;td&gt;859&lt;/td&gt;
&lt;td&gt;1255&lt;/td&gt;
&lt;td&gt;450&lt;/td&gt;
&lt;td&gt;212&lt;/td&gt;
&lt;td&gt;83.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;number&lt;/td&gt;
&lt;td&gt;890&lt;/td&gt;
&lt;td&gt;1580&lt;/td&gt;
&lt;td&gt;681&lt;/td&gt;
&lt;td&gt;173&lt;/td&gt;
&lt;td&gt;99.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ruler&lt;/td&gt;
&lt;td&gt;1008&lt;/td&gt;
&lt;td&gt;2057&lt;/td&gt;
&lt;td&gt;475&lt;/td&gt;
&lt;td&gt;179&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;34.4%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;swerve&lt;/td&gt;
&lt;td&gt;811&lt;/td&gt;
&lt;td&gt;2840&lt;/td&gt;
&lt;td&gt;613&lt;/td&gt;
&lt;td&gt;181&lt;/td&gt;
&lt;td&gt;84.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;germ&lt;/td&gt;
&lt;td&gt;873&lt;/td&gt;
&lt;td&gt;3187&lt;/td&gt;
&lt;td&gt;399&lt;/td&gt;
&lt;td&gt;174&lt;/td&gt;
&lt;td&gt;99.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the coordinates. Dead-on, every time. Now look at "ruler": &lt;strong&gt;34.4% confidence&lt;/strong&gt;, because Textract actually read it as "Fuler." There's a particular kind of humbling in watching the app you built squint at your own kid's homework and guess wrong. It can't reliably read a kid's handwriting, and the messier the word, the worse it gets. The long ones a kid mangles a different way every time... "because" coming out &lt;code&gt;becuase&lt;/code&gt; one week and &lt;code&gt;becuse&lt;/code&gt; the next... are exactly where Textract's confidence craters.&lt;/p&gt;

&lt;p&gt;Claude is the opposite. It reads the handwriting and understands intent. It knows the kid meant "ruler." But ask it for pixel coordinates and it tells on itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A couple notes: 
coordinates are eyeballed from the visual layout, not from an OCR bounding-box pass, so treat them as approximate anchor points. 
The two words I'd flag as less certain are "hermit" and "ruler" (the r is rough).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Approximate anchor points" won't crop an image. So I need both. Claude's read for accurate feedback, Textract's boxes for the crop. The catch is they disagree about the same word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Textract sees: "Fuler"  (34.4% confidence, but exact coordinates)
Claude sees:   "ruler"  (reads it right, no usable coordinates)
Target word:   "ruler"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"Fuler" == "ruler"&lt;/code&gt; is &lt;code&gt;False&lt;/code&gt;. Naive exact-match drops the word, so you get no crop and no score. I need a reconciliation layer that connects Claude's read to Textract's coordinates even when the strings disagree.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reconciliation is a three-tier cascade
&lt;/h3&gt;

&lt;p&gt;For each word Claude reports, the analyzer tries three strategies in order and stops at the first hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1: exact match.&lt;/strong&gt; Claude's word matches a Textract word case-insensitively, and that box isn't claimed yet. Cheap and unambiguous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2: hardcoded special cases.&lt;/strong&gt; Scars from watching real submissions. When one specific misread showed up often enough, I stopped being clever and just hardcoded it. Two are literally in the deployed code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# "geam" should match "feam" (common g/f OCR error)
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;written_word&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;geam&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;feam_match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;tw&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;textract_words&lt;/span&gt;
                       &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;feam&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
                       &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;tw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;used_textract_words&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="c1"&gt;# "him self" should match "him" + "self" and combine their boxes
&lt;/span&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;written_word&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;him self&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;min_left&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;him_bbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;self_bbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;max_right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;him_bbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;him_bbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;self_bbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;self_bbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;"him self"&lt;/code&gt; case is the interesting one. Claude reads two Textract tokens as one intended word, so I merge two boxes into one crop with min/max corners. Special-case tiers accumulate, and each one is a signal your general strategy has a blind spot. I keep these because they're cheap and they document real failures. A third or fourth would be pressure to go fix Tier 3 instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 3: fuzzy match.&lt;/strong&gt; Fall back to &lt;code&gt;difflib.SequenceMatcher(...).ratio()&lt;/code&gt; between Claude's word and each unclaimed Textract word, keeping the best above a threshold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;FUZZY_MATCH_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;  &lt;span class="c1"&gt;# lowered from 0.6 for better word matching
&lt;/span&gt;&lt;span class="n"&gt;similarity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;difflib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SequenceMatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claude_word&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                                     &lt;span class="n"&gt;tw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;best_similarity&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;FUZZY_MATCH_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;best_match&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best_similarity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;0.5, lowered from 0.6&lt;/code&gt; comment is where the real work hides. &lt;code&gt;SequenceMatcher.ratio()&lt;/code&gt; is brutally sensitive to length, so on the three- and four-letter words first graders get, a single bad character drops them under a 0.6 threshold and they vanish. Lowering to 0.5 buys them back; drop much lower and "the" starts matching "he" and stealing the wrong box. The &lt;code&gt;used_words&lt;/code&gt; set threads through all three tiers so two of Claude's words can't claim the same box.&lt;/p&gt;

&lt;h3&gt;
  
  
  The subtle part: I grade Textract, not Claude
&lt;/h3&gt;

&lt;p&gt;The most subtle decision in the app. When I compute the spelling score, I don't score Claude's &lt;em&gt;corrected&lt;/em&gt; reading. I score &lt;strong&gt;Textract's raw detection&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Use TEXTRACT detection (actual handwriting), not Claude's correction
&lt;/span&gt;&lt;span class="n"&gt;actual_written_word&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;textract_match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;letter_accuracy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate_letter_accuracy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;actual_written_word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# what the OCR literally saw
&lt;/span&gt;    &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;target_word&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;# what it was supposed to be
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude is &lt;em&gt;helpful&lt;/em&gt;, and helpful is wrong here. A kid writes &lt;code&gt;becuase&lt;/code&gt; and Claude wants to hand you back "because." But the whole point of a spelling app is to catch that the kid wrote it wrong. If I scored Claude's cleaned-up read, every kid gets 100%. So Claude &lt;em&gt;finds and labels&lt;/em&gt; the word, and Textract's dumb, literal, character-for-character read &lt;em&gt;grades&lt;/em&gt; it. And &lt;code&gt;calculate_letter_accuracy&lt;/code&gt; is a real Levenshtein aligner, not a ratio: it classifies every character as a match, substitution, insertion, or deletion, which is what powers "you swapped the 'a' and 'u' in 'because'" instead of just "wrong."&lt;/p&gt;

&lt;h2&gt;
  
  
  The war story: a stage bug became a throttling storm
&lt;/h2&gt;

&lt;p&gt;The pipeline is event-driven. Textract writes &lt;code&gt;stage: 'textract_complete'&lt;/code&gt;, the stream triggers the analyzer. Simple. Except the analyzer's guard checked for the wrong stage value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BROKEN: Textract writes stage='textract_complete', but the guard
# waits for 'storing_results', which never arrives on the trigger event.
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;processing_status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;textract_complete&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;storing_results&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# run Claude analysis
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intended trigger never cleanly matched. Meanwhile the analyzer's own status write-backs (&lt;code&gt;claude_starting&lt;/code&gt;, &lt;code&gt;claude_analyzing&lt;/code&gt;) and user retries kept firing &lt;em&gt;new&lt;/em&gt; stream events into the same function. Not one clean run, but a swarm of overlapping invocations all reaching for Bedrock at once.&lt;/p&gt;

&lt;p&gt;That's when Bedrock started returning &lt;strong&gt;&lt;code&gt;ServiceUnavailableException&lt;/code&gt; / "Too many connections."&lt;/strong&gt; Users got 60-second timeouts, and it would have gotten exponentially worse with more traffic. A one-word string mismatch in an &lt;code&gt;if&lt;/code&gt; statement was a latent denial-of-service against my own model endpoint.&lt;/p&gt;

&lt;p&gt;The fix has two halves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix the stage machine so there's exactly one trigger.&lt;/strong&gt; Match the value Textract actually writes, and set the terminal stage to something that isn't a trigger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED: match the stage Textract really writes
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;processing_status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;textract_complete&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;textract_complete&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# run Claude analysis
&lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# our own write-backs (claude_starting/analyzing/complete) land here
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Event ignored - not ready&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final write sets &lt;code&gt;stage: 'complete'&lt;/code&gt;, which no longer matches the trigger. The self-loop is closed structurally, and my own progress writes fall through to the harmless &lt;code&gt;else&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make Bedrock calls survive a throttle.&lt;/strong&gt; Even loop-free, a burst of real uploads can make Bedrock push back, so add exponential backoff with jitter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="n"&gt;base_delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="c1"&gt;# 1s -&amp;gt; 2s -&amp;gt; 4s -&amp;gt; 8s, plus 0-2s random jitter
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;claude_response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bedrock_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke_model&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ServiceUnavailableException&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;throttled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_delay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The jitter is the part people skip. Without it, ten throttled invocations back off for exactly &lt;code&gt;2**attempt&lt;/code&gt; seconds and retry in lockstep, restampeding the endpoint. &lt;code&gt;random.uniform(0, 2)&lt;/code&gt; smears them across a window so they don't re-collide.&lt;/p&gt;

&lt;p&gt;The lesson worth keeping: the stage typo didn't just cause wrong behavior, it &lt;em&gt;manufactured concurrency&lt;/em&gt;, and the concurrency is what took Bedrock down.&lt;/p&gt;

&lt;p&gt;There's a UX angle here too. Claude's analysis takes ~25 seconds, and a seven-year-old's patience budget is about 20, so latency is a reliability metric, not just a performance one. I learned that number the hard way, watching my kid take the photo, wait, sigh, and set the phone down to go do literally anything else before the score came back. If your feedback loop is slower than your user's attention span, the system has failed that user. I couldn't make Claude faster, so I made the wait survivable with those same &lt;code&gt;update_processing_status()&lt;/code&gt; write-backs and a &lt;code&gt;GET /status/{id}&lt;/code&gt; poll that turns them into live progress. That's the real SLO for this app: not "99.9% under 500ms," but "results appear before the kid walks away." The analyzer already logs a per-step timing breakdown, so the honest next move is to emit those as CloudWatch metrics and alarm on &lt;strong&gt;p90&lt;/strong&gt; end-to-end, not the average that hides the one kid who waited 40 seconds behind the nine who waited 10.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd add before letting strangers in
&lt;/h2&gt;

&lt;p&gt;The fix above is what's deployed, and it's right for two kids. But a 300-level reader will see what's missing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A conditional write for real idempotency.&lt;/strong&gt; The stage guard stops the loop, but at-least-once delivery means the same event can still arrive twice. A &lt;code&gt;ConditionExpression&lt;/code&gt; (&lt;code&gt;attribute_not_exists(id)&lt;/code&gt;) on the results write makes a duplicate a no-op at the database, so I never pay for a second Bedrock call on a dupe. Today I rely on the stage flag, which is code-level, not storage-level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dead-letter queue on the stream mapping.&lt;/strong&gt; A poison record (corrupt image, unparseable response) makes a stream Lambda retry the whole batch until records expire, up to 24 hours, blocking the shard and re-billing Bedrock every spin. &lt;code&gt;BisectBatchOnFunctionError&lt;/code&gt;, a &lt;code&gt;MaximumRetryAttempts&lt;/code&gt; cap, and an &lt;code&gt;OnFailure&lt;/code&gt; destination would isolate it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure-as-code for the stream mappings, retry config, and DLQ.&lt;/strong&gt; Right now I redeploy with &lt;code&gt;aws lambda update-function-code&lt;/code&gt; and a prayer. There's literally a &lt;code&gt;.lambda.backup-*&lt;/code&gt; folder from the day I fixed the loop. Fine for a personal app, but those retry settings are exactly what you can't afford to configure by hand and forget.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A logic bug and a capacity failure can be the same incident.&lt;/strong&gt; A one-word stage mismatch manufactured concurrency, and the concurrency is what threw &lt;code&gt;ServiceUnavailableException&lt;/code&gt;. Guard the trigger edge precisely, and assume every downstream call can be throttled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backoff without jitter just reschedules the stampede.&lt;/strong&gt; &lt;code&gt;random.uniform(0, 2)&lt;/code&gt; on top of exponential delay is what keeps retries from re-colliding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the "wrong" service on purpose.&lt;/strong&gt; Claude to read, Textract to grade, so the helpful model can't auto-correct away the mistakes the app exists to catch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The best engineering I've done on this app didn't happen at a desk. It happened at the kitchen table, with my kids handing me the phone and quitting the second it got slow. The most honest QA team I've ever had can't spell "because"... but they'll tell you when your app is too slow.&lt;/p&gt;

&lt;p&gt;Happy building.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Bee Hunter runs on AWS: S3, CloudFront, API Gateway, Lambda, DynamoDB, Amazon Textract, and Amazon Bedrock (Claude Sonnet 4.5).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>serverless</category>
      <category>ai</category>
      <category>aws</category>
      <category>sre</category>
    </item>
    <item>
      <title>Saying Goodbye to Amazon WorkMail: How I Migrated My Mailbox to Gmail</title>
      <dc:creator>Aaron Hunter</dc:creator>
      <pubDate>Wed, 02 Sep 2026 15:53:33 +0000</pubDate>
      <link>https://dev.to/aaronshunter/saying-goodbye-to-amazon-workmail-how-i-migrated-my-mailbox-to-gmail-ogj</link>
      <guid>https://dev.to/aaronshunter/saying-goodbye-to-amazon-workmail-how-i-migrated-my-mailbox-to-gmail-ogj</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6hojhhc1r2uqgyunsijp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6hojhhc1r2uqgyunsijp.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Level: 200 (intermediate). Assumes you're comfortable with the AWS CLI, IAM roles, S3, and KMS.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Here's the important part... WorkMail gives you a clean, supported way to get your mail out: the &lt;code&gt;StartMailboxExportJob&lt;/code&gt; API. It drops every message into an S3 bucket as a KMS-encrypted &lt;code&gt;.zip&lt;/code&gt; of standard &lt;code&gt;.eml&lt;/code&gt; files. From there, getting those messages into Gmail is just a matter of speaking IMAP.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the solution
&lt;/h2&gt;

&lt;p&gt;Before we touch a command, let's set the mental model. There are two halves to this migration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Get the mail out of WorkMail.&lt;/strong&gt; &lt;code&gt;StartMailboxExportJob&lt;/code&gt; writes an encrypted &lt;code&gt;.zip&lt;/code&gt; to S3. This needs a KMS key and an IAM role the WorkMail export service can assume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get the mail into Gmail.&lt;/strong&gt; Gmail speaks IMAP, and IMAP has an &lt;code&gt;APPEND&lt;/code&gt; command that uploads a raw message into a mailbox. We loop over the exported &lt;code&gt;.eml&lt;/code&gt; files and append each one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. No third-party migration tool, no paid service. Just AWS APIs on one side and IMAP on the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: Exporting the mailbox
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What the export actually contains
&lt;/h3&gt;

&lt;p&gt;The export writes &lt;strong&gt;email messages and calendar items&lt;/strong&gt; to a &lt;code&gt;.zip&lt;/code&gt;, organized into folders that mirror your mailbox (Inbox, Sent Items, Deleted Items, Junk E-mail, Calendar). Messages come out as &lt;code&gt;.eml&lt;/code&gt; (standard MIME), and calendar entries come out as &lt;code&gt;.ics&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One thing that surprised me... &lt;strong&gt;contacts and tasks are not included.&lt;/strong&gt; If you need those, export them separately from the WorkMail web app. Good to know before you assume the &lt;code&gt;.zip&lt;/code&gt; is everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  The prerequisites the export needs
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;same AWS Region&lt;/strong&gt; as your WorkMail organization:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A symmetric &lt;strong&gt;KMS key&lt;/strong&gt; to encrypt the output.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;IAM role&lt;/strong&gt; that the export service (&lt;code&gt;export.workmail.amazonaws.com&lt;/code&gt;) can assume.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;S3 bucket&lt;/strong&gt; to receive the &lt;code&gt;.zip&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's build them. First, the KMS key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws kms create-key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"WorkMail mailbox export encryption key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key-spec&lt;/span&gt; SYMMETRIC_DEFAULT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key-usage&lt;/span&gt; ENCRYPT_DECRYPT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grab the key ARN from the output... you'll need it in a couple of places.&lt;/p&gt;

&lt;p&gt;Now the IAM role. This is the part people trip on, so let's be precise. The role needs a &lt;strong&gt;trust policy&lt;/strong&gt; that lets the WorkMail export service assume it, scoped to your account and organization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"export.workmail.amazonaws.com"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aws:SourceAccount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"111122223333"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ArnLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"aws:SourceArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:workmail:us-east-1:111122223333:organization/m-EXAMPLEORGID"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those &lt;code&gt;aws:SourceAccount&lt;/code&gt; and &lt;code&gt;aws:SourceArn&lt;/code&gt; conditions matter... they're what stop a confused-deputy situation where some other org could trick your role into running. Keep them.&lt;/p&gt;

&lt;p&gt;And the &lt;strong&gt;permissions policy&lt;/strong&gt; that lets the role write to your bucket and use the KMS key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"s3:AbortMultipartUpload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:GetBucketPolicyStatus"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::amzn-s3-demo-bucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::amzn-s3-demo-bucket/*"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"kms:Decrypt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kms:GenerateDataKey"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:kms:us-east-1:111122223333:key/EXAMPLE-KEY-ID"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kms:ViaService"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3.us-east-1.amazonaws.com"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"kms:EncryptionContext:aws:s3:arn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::amzn-s3-demo-bucket/mail-export*"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the role and attach the policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam create-role &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-name&lt;/span&gt; WorkmailMailboxExportRole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assume-role-policy-document&lt;/span&gt; file://trust-policy.json

aws iam put-role-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-name&lt;/span&gt; WorkmailMailboxExportRole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-name&lt;/span&gt; MailboxExport &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-document&lt;/span&gt; file://permissions-policy.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Kicking off the export
&lt;/h3&gt;

&lt;p&gt;You'll need your organization ID and the entity (user) ID for the mailbox. Both come from the WorkMail API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws workmail list-organizations
aws workmail list-users &lt;span class="nt"&gt;--organization-id&lt;/span&gt; m-EXAMPLEORGID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now start the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws workmail start-mailbox-export-job &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--organization-id&lt;/span&gt; m-EXAMPLEORGID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--entity-id&lt;/span&gt; EXAMPLE-ENTITY-ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kms-key-arn&lt;/span&gt; arn:aws:kms:us-east-1:111122223333:key/EXAMPLE-KEY-ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-arn&lt;/span&gt; arn:aws:iam::111122223333:role/WorkmailMailboxExportRole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--s3-bucket-name&lt;/span&gt; amzn-s3-demo-bucket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--s3-prefix&lt;/span&gt; mail-export &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"Mailbox export for Gmail migration"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll get back a &lt;code&gt;JobId&lt;/code&gt;. Poll it until the state flips to &lt;code&gt;COMPLETED&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws workmail describe-mailbox-export-job &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--organization-id&lt;/span&gt; m-EXAMPLEORGID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--job-id&lt;/span&gt; EXAMPLE-JOB-ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A mid-size mailbox (a few thousand messages) finishes in a handful of minutes. When it's done, you'll have one &lt;code&gt;.zip&lt;/code&gt; sitting at &lt;code&gt;s3://amzn-s3-demo-bucket/mail-export/...&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Heads up if your bucket fronts a public website.&lt;/strong&gt; The export &lt;code&gt;.zip&lt;/code&gt; is 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Part 2: Download and inspect
&lt;/h2&gt;

&lt;p&gt;Pull the archive down (S3 decrypts on the fly because your identity has access to the KMS key) and unzip it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"s3://amzn-s3-demo-bucket/mail-export/EXAMPLE.zip"&lt;/span&gt; ./mailbox-export.zip
unzip &lt;span class="nt"&gt;-q&lt;/span&gt; mailbox-export.zip &lt;span class="nt"&gt;-d&lt;/span&gt; mailbox-export
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take a minute to look at what you got. Counting files per folder tells you where the value actually is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;mailbox-export
&lt;span class="k"&gt;for &lt;/span&gt;d &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%-16s %s files&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: Uploading into Gmail over IMAP
&lt;/h2&gt;

&lt;p&gt;This is where it gets fun. Gmail exposes IMAP at &lt;code&gt;imap.gmail.com:993&lt;/code&gt;, and Python's standard library ships &lt;code&gt;imaplib&lt;/code&gt;, so we don't need any dependencies.&lt;/p&gt;

&lt;p&gt;A few design decisions that make the difference between "it technically worked" and "it worked &lt;em&gt;well&lt;/em&gt;":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Preserve the original date.&lt;/strong&gt; If you don't set the IMAP internal date, every message shows up dated &lt;em&gt;today&lt;/em&gt;, which wrecks sorting. We parse each message's &lt;code&gt;Date:&lt;/code&gt; header and pass it through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mark messages as read.&lt;/strong&gt; Uploading 3,900 emails as &lt;em&gt;unread&lt;/em&gt; is a great way to make your new inbox unusable. We append with the &lt;code&gt;\Seen&lt;/code&gt; flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch the work.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never hardcode credentials.&lt;/strong&gt; The script reads your Gmail address and an &lt;strong&gt;app password&lt;/strong&gt; from environment variables. It never stores or prints them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the core of the uploader:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;imaplib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;internaldate_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;date_hdr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;date_hdr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parsedate_tz&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_hdr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;imaplib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Time2Internaldate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mktime_tz&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# server stamps "now" if the header is missing
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;imap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;imaplib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IMAP4_SSL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;imap.gmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;993&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;imap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GMAIL_ADDRESS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GMAIL_APP_PASSWORD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[Gmail]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;imap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# harmless if it already exists
&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.eml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message_from_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;imap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(\Seen)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;internaldate_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# be polite to Gmail's rate limits
&lt;/span&gt;
    &lt;span class="n"&gt;imap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick word on &lt;strong&gt;app passwords&lt;/strong&gt;... 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.&lt;/p&gt;

&lt;p&gt;Run it per batch, and point Sent mail at Gmail's real Sent folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GMAIL_ADDRESS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"you@yourdomain.com"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GMAIL_APP_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"xxxxxxxxxxxxxxxx"&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;b &lt;span class="k"&gt;in &lt;/span&gt;01 02 03 04 05 06 07 08&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;python3 import_to_gmail.py &lt;span class="nt"&gt;--dir&lt;/span&gt; &lt;span class="s2"&gt;"mailbox-export/Inbox-batches/batch-&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--label&lt;/span&gt; &lt;span class="s2"&gt;"WorkMail Import"&lt;/span&gt;
&lt;span class="k"&gt;done

&lt;/span&gt;python3 import_to_gmail.py &lt;span class="nt"&gt;--dir&lt;/span&gt; &lt;span class="s2"&gt;"mailbox-export/Sent Items"&lt;/span&gt; &lt;span class="nt"&gt;--label&lt;/span&gt; &lt;span class="s2"&gt;"[Gmail]/Sent Mail"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The gotchas that cost me time
&lt;/h2&gt;

&lt;p&gt;A few things I learned the hard way, so you don't have to:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gmail has a 25 MB per-message limit.&lt;/strong&gt; IMAP &lt;code&gt;APPEND&lt;/code&gt; rejects anything bigger with &lt;code&gt;[TOOBIG] Message too large&lt;/code&gt;. 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 &lt;em&gt;log the failures with their subject and date&lt;/em&gt; as you go, so you have a short punch list at the end instead of a mystery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A custom label is not the Inbox.&lt;/strong&gt; This one's subtle. When you &lt;code&gt;APPEND&lt;/code&gt; a message to a label like "WorkMail Import", Gmail applies &lt;em&gt;that&lt;/em&gt; label... and only that label. The Inbox in Gmail is itself just a label (&lt;code&gt;\Inbox&lt;/code&gt;), 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.&lt;/p&gt;

&lt;p&gt;The fix is elegant once you know it. In Gmail, &lt;strong&gt;copying a message to INBOX adds the Inbox label without duplicating the message.&lt;/strong&gt; So a one-shot &lt;code&gt;IMAP COPY&lt;/code&gt; from your label into &lt;code&gt;INBOX&lt;/code&gt; lights everything up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;imap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;WorkMail Import&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;imap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SEARCH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;uids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uids&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;imap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COPY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INBOX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# adds \Inbox label, no dupes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Everything must share a Region.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch the throttling.&lt;/strong&gt; 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 &lt;code&gt;sleep&lt;/code&gt; between appends keeps you on the right side of Gmail's limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: Bringing the calendar over
&lt;/h2&gt;

&lt;p&gt;Remember those &lt;code&gt;.ics&lt;/code&gt; files in the &lt;code&gt;Calendar/&lt;/code&gt; folder? Google Calendar can import them, but its web UI takes &lt;strong&gt;one file at a time&lt;/strong&gt;, and I had a lot to import. So first, merge them into a single file.&lt;/p&gt;

&lt;p&gt;The naive move... &lt;code&gt;cat *.ics &amp;gt; combined.ics&lt;/code&gt;... produces a file with separate &lt;code&gt;BEGIN:VCALENDAR ... END:VCALENDAR&lt;/code&gt; blocks, and some importers choke on that. The correct shape is &lt;strong&gt;one&lt;/strong&gt; &lt;code&gt;VCALENDAR&lt;/code&gt; wrapper containing all the event components. Pull the components out of each file and drop them into a single calendar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_components&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Keep only component blocks (VEVENT, VTIMEZONE, ...); drop the outer
    VCALENDAR wrapper and calendar-level scalar props so we can re-wrap once.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BEGIN:VCALENDAR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;END:VCALENDAR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BEGIN:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;END:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# inside a component
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap the collected components with a single header (&lt;code&gt;BEGIN:VCALENDAR / VERSION:2.0 / PRODID:... / CALSCALE:GREGORIAN&lt;/code&gt;) and footer, write it out with CRLF line endings per RFC 5545, and you've got one importable file.&lt;/p&gt;

&lt;h3&gt;
  
  
  The "you do not have sufficient access on the target calendar" trap
&lt;/h3&gt;

&lt;p&gt;Here's the one that had me scratching my head. I imported the merged file into &lt;strong&gt;my own&lt;/strong&gt; calendar and got:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Imported zero events. Could not upload your events because you do not have sufficient access on the target calendar.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On my own calendar. Cute.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;original UID ending in &lt;code&gt;@google.com&lt;/code&gt;&lt;/strong&gt;, with an &lt;code&gt;ORGANIZER&lt;/code&gt; that was someone else. Google recognizes that UID as an event owned by another account in &lt;em&gt;its&lt;/em&gt; namespace, and refuses to let you import a copy... hence "insufficient access."&lt;/p&gt;

&lt;p&gt;The fix is to give each event a fresh, unique UID so Google treats them as brand-new events you own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="c1"&gt;# for each line in the .ics:
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UID:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UID;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UID:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;@yourdomain.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rewrite the UIDs, re-import, and you'll get the satisfying "Imported 7 out of 7 events." Tip: import into a &lt;em&gt;dedicated&lt;/em&gt; calendar (make a "WorkMail" one first) so if anything looks off, you just delete that calendar instead of untangling events from your main one.&lt;/p&gt;

&lt;p&gt;And remember... the export never included &lt;strong&gt;contacts&lt;/strong&gt;. Export those from the WorkMail web app as a vCard and import them into Google Contacts separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 5: Decommissioning WorkMail
&lt;/h2&gt;

&lt;p&gt;Once you've confirmed everything is safely in Gmail, you can tear WorkMail down to stop the per-mailbox billing. Order matters here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repoint your MX records first.&lt;/strong&gt; 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):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short MX yourdomain.com     &lt;span class="c"&gt;# want: 1 smtp.google.com.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only once that's confirmed should you delete the organization. This is &lt;strong&gt;irreversible&lt;/strong&gt;... it permanently destroys the mailbox and its directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws workmail delete-organization &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--organization-id&lt;/span&gt; m-EXAMPLEORGID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--delete-directory&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--force-delete&lt;/span&gt;        &lt;span class="c"&gt;# needed if the org still has enabled users&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then clean up the export scaffolding you built in Part 1, plus the archive itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Schedule the KMS key for deletion (7-30 day window, cancellable)&lt;/span&gt;
aws kms schedule-key-deletion &lt;span class="nt"&gt;--key-id&lt;/span&gt; EXAMPLE-KEY-ID &lt;span class="nt"&gt;--pending-window-in-days&lt;/span&gt; 7

&lt;span class="c"&gt;# Remove the IAM role (delete its inline policy first)&lt;/span&gt;
aws iam delete-role-policy &lt;span class="nt"&gt;--role-name&lt;/span&gt; WorkmailMailboxExportRole &lt;span class="nt"&gt;--policy-name&lt;/span&gt; MailboxExport
aws iam delete-role &lt;span class="nt"&gt;--role-name&lt;/span&gt; WorkmailMailboxExportRole
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more thing on the S3 archive: if your bucket has &lt;strong&gt;versioning enabled&lt;/strong&gt;, a plain &lt;code&gt;aws s3 rm&lt;/code&gt; just drops a delete marker... the actual object version (your entire mailbox) sticks around and stays recoverable. To truly purge it, delete the versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3api list-object-versions &lt;span class="nt"&gt;--bucket&lt;/span&gt; amzn-s3-demo-bucket &lt;span class="nt"&gt;--prefix&lt;/span&gt; mail-export
&lt;span class="c"&gt;# then delete each returned VersionId with:&lt;/span&gt;
aws s3api delete-object &lt;span class="nt"&gt;--bucket&lt;/span&gt; amzn-s3-demo-bucket &lt;span class="nt"&gt;--key&lt;/span&gt; &amp;lt;key&amp;gt; &lt;span class="nt"&gt;--version-id&lt;/span&gt; &amp;lt;VersionId&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Don't forget SES receiving, if you set it up
&lt;/h3&gt;

&lt;p&gt;Here's one that's easy to leave dangling. If at some point you configured &lt;strong&gt;SES to receive mail&lt;/strong&gt; 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 &lt;strong&gt;over 11,000 raw messages&lt;/strong&gt; in an S3 prefix I'd forgotten about.&lt;/p&gt;

&lt;p&gt;Find it by looking at your active rule set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ses describe-active-receipt-rule-set    &lt;span class="c"&gt;# shows the S3Action bucket + prefix&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ses set-active-receipt-rule-set                                   &lt;span class="c"&gt;# deactivate all&lt;/span&gt;
aws ses delete-receipt-rule-set &lt;span class="nt"&gt;--rule-set-name&lt;/span&gt; your-rule-set-name
aws s3 &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="s2"&gt;"s3://your-bucket/SES/"&lt;/span&gt; &lt;span class="nt"&gt;--recursive&lt;/span&gt; &lt;span class="nt"&gt;--only-show-errors&lt;/span&gt;      &lt;span class="c"&gt;# scope to the prefix!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two cautions here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scope the S3 delete to the prefix, not the bucket.&lt;/strong&gt; That bucket may hold unrelated things... mine also had a website folder and a video file at the root. Delete &lt;code&gt;s3://bucket/SES/&lt;/code&gt;, never &lt;code&gt;s3://bucket&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be careful with SES identities.&lt;/strong&gt; A verified &lt;strong&gt;domain identity governs &lt;em&gt;sending&lt;/em&gt;, not just receiving.&lt;/strong&gt; 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):
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  aws ses delete-identity &lt;span class="nt"&gt;--identity&lt;/span&gt; old-unused@yourdomain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Part 6: Locking down email authentication (SPF, DKIM, DMARC)
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A debugging tip that will save you an hour:&lt;/strong&gt; when you add a DNS record and it "isn't showing up," don't guess about propagation. Query your domain's &lt;strong&gt;authoritative nameserver directly&lt;/strong&gt;. 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dig +short NS yourdomain.com | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
dig @&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +short TXT yourdomain.com          &lt;span class="c"&gt;# SPF lives on the apex&lt;/span&gt;
dig @&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +short TXT _dmarc.yourdomain.com   &lt;span class="c"&gt;# DMARC lives here&lt;/span&gt;
dig @&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +short TXT google._domainkey.yourdomain.com  &lt;span class="c"&gt;# DKIM&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;SPF&lt;/strong&gt; goes on the apex as a TXT record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"v=spf1 include:_spf.google.com ~all"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch out here if you're on Route 53: you can't have two separate TXT record &lt;em&gt;sets&lt;/em&gt; with the same name. If your apex already has, say, a &lt;code&gt;google-site-verification&lt;/code&gt; TXT, the SPF string goes in as an &lt;strong&gt;additional quoted value in that same record set&lt;/strong&gt;... not a new record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DKIM&lt;/strong&gt; is generated in the Google Admin console (Apps → Google Workspace → Gmail → Authenticate email). It hands you a TXT record with the host name &lt;code&gt;google._domainkey&lt;/code&gt; and a &lt;code&gt;v=DKIM1; k=rsa; p=...&lt;/code&gt; value. Add it as its &lt;strong&gt;own separate record&lt;/strong&gt;, then... and this is the step people miss... go back to the Admin console and click &lt;strong&gt;Start authentication&lt;/strong&gt;. The DNS record alone doesn't turn it on. (If you already see a "Stop authentication" button, it's active.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DMARC&lt;/strong&gt; is the one everybody gets wrong, so pay attention: it does &lt;strong&gt;not&lt;/strong&gt; go on your apex. It goes on its own record named &lt;code&gt;_dmarc.yourdomain.com&lt;/code&gt;. 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 &lt;code&gt;_dmarc.&amp;lt;domain&amp;gt;&lt;/code&gt;, so a &lt;code&gt;v=DMARC1&lt;/code&gt; string sitting on your apex does absolutely nothing.&lt;/p&gt;

&lt;p&gt;Also know this: &lt;strong&gt;DMARC is domain-wide, not per-address.&lt;/strong&gt; One record covers every mailbox on the domain. The &lt;code&gt;mailto:&lt;/code&gt; in it isn't a "protected" address... it's just where aggregate reports get sent. Start in monitor-only mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;_dmarc.yourdomain.com   TXT   "v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave it at &lt;code&gt;p=none&lt;/code&gt; for a week or two while you watch the reports, then tighten to &lt;code&gt;p=quarantine&lt;/code&gt; and eventually &lt;code&gt;p=reject&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The core pattern generalizes well beyond WorkMail. &lt;strong&gt;Export to a portable format (&lt;code&gt;.eml&lt;/code&gt;/MIME), then speak the destination's native protocol (IMAP).&lt;/strong&gt; 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 &lt;code&gt;.ics&lt;/code&gt;, imported natively.&lt;/p&gt;

&lt;p&gt;A few closing lessons worth internalizing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verify against the authoritative nameserver&lt;/strong&gt;, not a public resolver, whenever DNS "isn't working." It turns a guessing game into a yes/no answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DMARC on &lt;code&gt;_dmarc&lt;/code&gt;, DKIM on &lt;code&gt;google._domainkey&lt;/code&gt;, SPF on the apex.&lt;/strong&gt; Wrong record name is the number-one reason email auth silently does nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean up what you created.&lt;/strong&gt; Revoke the app password, delete the export &lt;code&gt;.zip&lt;/code&gt; (including old versions if the bucket is versioned), schedule the KMS key for deletion, and remove the export IAM role. And repoint MX &lt;em&gt;before&lt;/em&gt; you delete the WorkMail org, never after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hunt down orphaned receiving infrastructure.&lt;/strong&gt; 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 &lt;code&gt;describe-active-receipt-rule-set&lt;/code&gt; and clean it up too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy migrating.&lt;/p&gt;

&lt;h2&gt;
  
  
  One last thing... a personal goodbye
&lt;/h2&gt;

&lt;p&gt;I'm going to step out of tutorial mode for this part, because writing a guide for migrating &lt;em&gt;off&lt;/em&gt; WorkMail is genuinely bittersweet for me.&lt;/p&gt;

&lt;p&gt;Before I ever built things &lt;em&gt;on&lt;/em&gt; AWS, I worked &lt;em&gt;at&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;built&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Thank you, WorkMail. And thank you to the team behind it. It was an honor. 🧡&lt;/p&gt;

</description>
      <category>aws</category>
      <category>workmail</category>
      <category>ses</category>
    </item>
    <item>
      <title>How I Built a Serverless CFP Agent on AWS</title>
      <dc:creator>Aaron Hunter</dc:creator>
      <pubDate>Thu, 30 Jul 2026 18:52:16 +0000</pubDate>
      <link>https://dev.to/aaronshunter/how-i-built-a-serverless-cfp-agent-on-aws-54j5</link>
      <guid>https://dev.to/aaronshunter/how-i-built-a-serverless-cfp-agent-on-aws-54j5</guid>
      <description>&lt;p&gt;If you speak at conferences, you know the Call for Papers (CFP) hunt is a grind. Deadlines are scattered across a dozen sites, half of them are already closed, and you can never remember which ones you've already seen or applied to. I was doing this by hand every week... and I was still missing good ones.&lt;/p&gt;

&lt;p&gt;So I built an agent to do it for me. It runs entirely serverless on AWS, wakes up on a schedule, pulls CFPs from multiple sources, ranks them against my speaker profile with a model on Amazon Bedrock, and drops the good ones in Slack. This post walks through how it's put together and, more importantly, why I picked the AWS pieces I did.&lt;/p&gt;

&lt;h2&gt;
  
  
  WHAT I BUILT
&lt;/h2&gt;

&lt;p&gt;The whole thing is a pipeline that runs on its own every morning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fetch open CFPs from a few conference aggregators&lt;/li&gt;
&lt;li&gt;Normalize them into one consistent shape&lt;/li&gt;
&lt;li&gt;Filter them against rules I define (topics, countries, delivery type, deadlines)&lt;/li&gt;
&lt;li&gt;Rank the survivors against my speaker profile using a model on Bedrock&lt;/li&gt;
&lt;li&gt;Store the results as JSON and notify me in Slack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no server to babysit. When it's not running, it costs me basically nothing. That's the part I want to focus on... because the AWS choices are what make an "agent that runs every day" something you can build on a personal budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE PROBLEM UP CLOSE
&lt;/h2&gt;

&lt;p&gt;Here's what the manual version looked like. Open five or six tabs, scroll through conference listings, cross-reference against a note somewhere of what I'd already applied to, do the math on whether the deadline had even passed, and then repeat the whole thing next week because new ones show up constantly.&lt;/p&gt;

&lt;p&gt;The worst part was memory. A CFP I looked at last Tuesday looked brand new again the following Monday. I had no durable sense of "this is new since I last checked" or "I already said no to this one." That's exactly the kind of stateful, repetitive, run-it-on-a-schedule chore that a little bit of AWS glue is perfect for.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE AWS ARCHITECTURE
&lt;/h2&gt;

&lt;p&gt;Here's the shape of it. Every box is serverless or managed, so there's nothing running when the job isn't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EventBridge Scheduler (cron)
        │
        ▼
   Lambda "cfp-agent"
        │
  ┌─────┼───────────────┐
  ▼     ▼               ▼
 feeds  DynamoDB     Bedrock
(fetch) (rules,      (rank +
        profile,      recommend)
        seen log)
        │
   ┌────┴────┐
   ▼         ▼
 S3 json    Slack
(dashboard) (digest)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let me walk through the important pieces.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lambda is the whole engine
&lt;/h3&gt;

&lt;p&gt;The entire pipeline is one Lambda function. Fetch, normalize, filter, rank, write... it all happens inside a single handler. There's no always-on box, no container sitting idle overnight. It spins up, does the work in a minute or two, and goes back to sleep.&lt;/p&gt;

&lt;p&gt;The handler is really just an orchestration of the steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cfps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_all_sources&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# pull the feeds
&lt;/span&gt;    &lt;span class="n"&gt;cfps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# one consistent schema
&lt;/span&gt;    &lt;span class="n"&gt;cfps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tag_against_rules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# topics, countries, deadlines
&lt;/span&gt;    &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rank_with_bedrock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# score vs my profile
&lt;/span&gt;    &lt;span class="nf"&gt;write_to_s3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# dashboard reads this
&lt;/span&gt;    &lt;span class="nf"&gt;notify_slack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;# ping me on the good ones
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the thing I love about Lambda for a personal agent... the unit of deployment is just the function. I'm not paying for uptime, I'm paying for the ninety seconds a day it actually runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  DynamoDB is the agent's memory
&lt;/h3&gt;

&lt;p&gt;This was the fix for my biggest pain point. The model doesn't remember anything between runs, so I give it memory in DynamoDB. One small table holds three things: my filter rules, my speaker profile, and a "seen" log of every CFP id I've already surfaced.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dynamodb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nc"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cfp-agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# the agent's "memory": rules, profile, and what it's already seen
&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rules&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Item&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;profile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Item&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;seen&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Item&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# after the run, remember what we surfaced so tomorrow knows what's new
&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;discovered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;updated_seen&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seen log is what lets the agent tell me "here's what's new since yesterday" instead of showing me the same list forever. DynamoDB on pay-per-request pricing is a great fit here... a handful of tiny reads and writes a day rounds down to nothing, and I never think about capacity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bedrock does the ranking
&lt;/h3&gt;

&lt;p&gt;Deterministic rules do the cheap first cut... drop anything closed, wrong country, wrong topic. But "is this conference actually a good fit for me?" is a judgment call, not a keyword match. That's where a model on Amazon Bedrock comes in.&lt;/p&gt;

&lt;p&gt;I feed it my profile (bio, expertise, goals, past talks) and each surviving CFP, and it hands back a relevance score, a recommendation of apply / consider / skip, and a short reason. Because the model is stateless, that profile from DynamoDB is what makes the recommendations actually about me.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;You match conference CFPs to a speaker.

SPEAKER PROFILE:
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;

Score each CFP 0-100 for fit. Return apply / consider / skip
plus a one-line reason.

CFPS:
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cfp_batch&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bedrock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modelId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing I decided early: no silent fallback. If Bedrock can't be reached, the run fails loudly and writes an error the dashboard shows as a banner. I'd rather know the ranking is broken than get a quietly degraded list of keyword-matched guesses that looks fine but isn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  EventBridge Scheduler makes it an agent, not a script
&lt;/h3&gt;

&lt;p&gt;The thing that turns this from "a script I run when I remember" into "an agent that just handles it" is EventBridge Scheduler. A cron expression kicks the Lambda off every morning. It's timezone-aware, so it fires at 6am my time without me doing daylight-saving math.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cron&lt;span class="o"&gt;(&lt;/span&gt;0 6 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; ? &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# 6:00am, every day&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. That single line is the difference between a tool I have to remember to use and one that quietly does its job before I wake up.&lt;/p&gt;

&lt;h3&gt;
  
  
  S3 + CloudFront for the dashboard
&lt;/h3&gt;

&lt;p&gt;The Lambda writes the results as a JSON file to S3, and a static HTML dashboard reads it through CloudFront. No API, no database query from the browser... just a JSON file on a CDN. The dashboard is where I actually browse, search, hide ones I'm not into, and star the ones I want to apply to. Cheap, fast, and nothing to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  DOES IT WORK?
&lt;/h2&gt;

&lt;p&gt;Yeah. It went from me manually combing sites every week to opening one dashboard where the good ones are already ranked and the new ones are flagged. On a recent run it pulled well over a thousand CFPs across sources, cut them down to a few hundred that passed my rules, and Bedrock flagged a few dozen worth actually applying to. That's the whole point... I look at the dozen, not the thousand.&lt;/p&gt;

&lt;p&gt;And because it remembers what it's shown me, I finally have that "new since last time" signal I was missing. No more re-reading the same listings and wondering if I'd already seen them.&lt;/p&gt;

&lt;h2&gt;
  
  
  THINGS TO CONSIDER
&lt;/h2&gt;

&lt;p&gt;Feed data can be wrong. Community-maintained feeds sometimes have stale or flat-out incorrect deadlines. I added a separate verify step that actually renders the CFP page to double-check the date, but it's a reminder that your agent is only as good as its inputs... build in a way to correct them.&lt;/p&gt;

&lt;p&gt;Bedrock costs scale with volume. Ranking a few hundred CFPs a day is cheap, but it's not free. Batching the calls and doing the cheap rule-based filtering first keeps the model spend down. Don't send the model everything... send it what already survived the free checks.&lt;/p&gt;

&lt;p&gt;Serverless state needs a home. The moment your agent needs to remember anything between runs, you need somewhere durable to put it. DynamoDB was the low-friction answer for me, but the lesson is to plan for state early rather than bolting it on later.&lt;/p&gt;

&lt;h2&gt;
  
  
  GET STARTED
&lt;/h2&gt;

&lt;p&gt;If you've got a repetitive, run-it-on-a-schedule chore that also needs a little judgment, this shape is worth stealing. Lambda for the work, DynamoDB for the memory, Bedrock for the judgment calls, EventBridge to run it on its own, and S3 for the output. Every piece scales to zero when it's idle, so an agent that runs every single day can cost next to nothing.&lt;/p&gt;

&lt;p&gt;I built this because I was tired of missing CFPs... now I just check a dashboard. If you speak at conferences, or want to, that alone is worth the few hours it takes to set this up.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
    </item>
  </channel>
</rss>
