Commit 66dea52d21

66dea52d215b771c1d0004ab4e2f96714942ffc6

parent: b6da76df9f

Unsigned

cmc <hello@cleberg.net> · 2025-02-25 02:29 UTC

publish email-migration.org

Layout: unified · split

content/blog/2025-02-24-email-migration.org added +252
@@ -0,0 +1,252 @@
1#+date: <2025-02-25 Mon 19:20:05>
2#+title: A Painful Email Migration
3#+description: Some notes on my painful experience migrating emails out of the Proton Mail platform.
4#+filetags: :email:
5#+slug: email-migration
6
7* The Setup
8
9I recently migrated my emails from Proton Mail to Migadu after a failed attempt
10to get myself into the Proton ecosystem and wanted to detail my process, as it
11was far more painful than expected.
12
13To give some context: I had nearly 5000 messages stored, accounting for around
142.5 GB of space.
15
16Overall, this process would have taken all day had I done it in one sitting, but
17I decided to break it up and it lasted a couple days before I was able to say that
18all my messages were stored in my new account.
19
20* Exporting Messages
21
22To start, I needed to export my messages from Proton Mail. As I am using macOS,
23I was able to use the [[https://proton.me/support/proton-mail-export-tool][Proton Mail Export Tool]]. However, the downside is that
24this dumps every single email on your account into a single folder in the =.eml=
25format. They also export a JSON file for each message, in the case you're
26importing back into Proton Mail.
27
28This means that anything in my Inbox, Sent, Archive, Trash, and user-created
29folders were all dumped out into a single folder with incomprehensible names.
30
31Without a clear path to easily figure out how to re-organize my emails into a
32new account, I was left a bit annoyed at Proton's export process.
33
34* Importing Messages
35
36Left with a pile of messages and no way to discern what they were without
37opening each one, I decided to try and use Thunderbird to import messages into
38my new Migadu IMAP account.
39
40This led to a dead end as my two methods failed:
41
421. [[https://addons.thunderbird.net/en-US/thunderbird/addon/importexporttools-ng/][ImportExportTools NG]] does not work with my version of Thunderbird (135).
432. Manually dragging the =.eml= files onto a folder in Thunderbird worked for
44 small batches of files, but seemed to lock up if I tried to import more than
45 a few hundred at a time. It also seemed a bit buggy, as I ended up with many
46 duplicate, and sometimes triplicate, messages.
47
48At this point, I decided to take a step back and use [[https://github.com/djcb/mu][mu]], a command-line utility
49that would index my files and sync back and forth with Migadu for me.
50
51Using my blog post, [[https://cleberg.net/blog/mu4e.html][Email in Doom Emacs with Mu4e on macOS]], (and skipping the
52mu4e parts) I was able to set up a minimal directory connected to my Migadu IMAP
53account. Using my terminal, I simply moved all of my messages into the =mu=
54directory and synchronized the account, and voila, my messages synchronized
55successfully to the remote server and my other email clients.
56
57However, the remaining issue was that I now had all 5000 messages in the Archive
58folder and needed to figure out how to organize them back into their proper
59directories.
60
61* Organizing Messages Into Folders
62
63As with any problem, I used Python as my hammer to fix the problem. I started by
64creating the directories required in Thunderbird, fetching them with =mbsync= so
65that they appeared in my =mu= directory, and using Python to organize my
66messages into the newly-created sub-folders.
67
68** Sent Messages
69
70I started by organizing my Sent messages. This required checking each file for
71the =From= header and moving them to the Sent folder.
72
73#+begin_src shell
74cd ~/.maildir/migadu/Archive/cur
75nano _sent.py
76#+end_src
77
78#+begin_src python
79# _sent.py
80import os
81import glob
82import shutil
83
84# Loop through all files in the current folder
85for file in glob.glob("*.eml"):
86 # Create boolean to check if we should move the file
87 move = False
88
89 # Open the current file
90 f = open(file, 'r')
91
92 # For each line in file, find the From header
93 for line in f:
94 if line.startswith("From:"):
95 # If we find ourself, mark the message for move
96 if "user@example.com" in line:
97 move = True
98
99 # Close the file
100 f.close()
101
102 # Move the file, if marked for move
103 if move == True:
104 filepath = os.path.join("/Users/YOUR_USERNAME/.maildir/migadu/Archive/cur/", file)
105 new_filepath = os.path.join("/Users/YOUR_USERNAME/.maildir/migadu/Sent/cur/", file)
106 shutil.move(filepath, new_filepath)
107#+end_src
108
109#+begin_src python
110python3 _sent.py
111#+end_src
112
113The only downside to my current approach is that it was the quick and dirty
114option, so I re-ran it while editing the =user@example.com= string for each
115email I wanted to move. If I had wanted to create a more well-defined solution,
116I would have created an array of addresses to check for and have the =if=
117statement check against that array.
118
119Regardless, I was able to run this with the addresses I wanted to move to the
120Sent folder and was soon finished.
121
122** Archive Sub-Folders
123
124Next, I needed to move the remaining ~3000 messages from the Archive folder into
125dated sub-folders, organized as such:
126
127- Archive/2016
128- ...
129- Archive/2025
130
131
132To do this, I followed a similar approach as the method above but check for the
133=Date= header instead of the =From= header.
134
135#+begin_src shell
136cd ~/.maildir/migadu/Archive/cur
137nano _archive.py
138#+end_src
139
140This approach requires finding the =X-Pm-Date= header and splitting it by the
141spaces contained within. Once split into a list, we must select the fourth
142element, as that contains the year which will match the directory we should move
143it to.
144
145For example, the header =X-Pm-Date: Fri, 07 Feb 2025 16:12:08 +0000= will be
146split into a list as such:
147
1480. X-Pm-Date:
1491. Fri,
1502. 07
1513. Feb
1524. 2025
1535. 16:12:08
1546. +0000
155
156From this list, we select the fourth element (=2025=) and use that to build the
157destination path.
158
159#+begin_src python
160# _archive.py
161import os
162import glob
163import shutil
164
165# Loop through all files in the sub-folders under Archive
166for file in glob.glob("*.eml"):
167 # Create boolean to check if we should move the file
168 move = False
169
170 # Open the current file
171 f = open(file, 'r')
172
173 # For each line in file, find the X-Pm-Date header
174 for line in f:
175 if line.startswith("X-Pm-Date"):
176 # Split the line into a list by spaces;
177 # Then select the item that contains the year
178 year = line.split(" ")[4]
179 move = True
180
181 # Close the file
182 f.close()
183
184 # Move the file, if marked for move
185 if move == True:
186 filepath = os.path.join("/Users/YOUR_USERNAME/.maildir/migadu/Archive/cur/", file)
187 new_filepath = os.path.join(f"/Users/YOUR_USERNAME/.maildir/migadu/Archive/{year}/cur/", file)
188 shutil.move(filepath, new_filepath)
189#+end_src
190
191#+begin_src python
192python3 _archive.py
193#+end_src
194
195At this point, we've now moved all Sent messages to the Sent box and organized
196all messages under the Archive folder into their correct sub-folders.
197
198If you exported other files, such as files from your Inbox, Trash, etc., you
199could follow a similar approach and determine the best header or attribute to
200identify them for further organization.
201
202** Synchronize the Results
203
204Before synchronizing the files in their new locations, I needed to remove the
205characters at the end of the file name since =mu= appends IDs to the end of file
206names.
207
208#+begin_src shell
209cd ~/.maildir/migadu/Archive
210nano _sync_prep.py
211#+end_src
212
213This script prepares the =Archive= sub-folders for synchronization, but the same
214concept applies to the Sent folder, except you'd replace =*/cur/*= with =*= if
215this script were inside the =Sent/cur= directory.
216
217#+begin_src python
218import glob
219import shutil
220
221# Loop through all files in the sub-folders under Archive
222for file in glob.glob("*/cur/*"):
223 # Remove the characters at the end of the file name created by =mu=
224 new_file = file.split(",U=",1)[0]
225 # Move the file to the new file name
226 shutil.move(file, new_file)
227#+end_src
228
229#+begin_src shell
230python3 _sync_prep.py
231#+end_src
232
233Finally, we can synchronize the results.
234
235#+begin_src shell
236mbsync -aV
237#+end_src
238
239* Removing Duplicates
240
241My only remaining issue at the time of writing is identifying and removing
242duplicate messages. I have toyed with simple Python and command-line solutions
243to identify duplicate files, but could not get them to effectively define all
244the duplicates found in any specific directory.
245
246I've even tried using the [[https://github.com/pkolaczk/fclones][fclones]] utility, to no avail. It seems that something
247in the Proton export, my manual Thunderbird method attempt, or possible sync
248issues between Thunderbird -> Migadu <-> mu caused duplicates where content
249within the message has been modified.
250
251Although I now seem to be wasting space and in need of a deduplication tool, I
252have all of my messages migrated to my new service.
theme/templates/index.html +5 −7
@@ -8,6 +8,11 @@ uid [ultimate] Christian Cleberg &lt;hello@cleberg.net&gt;</pre>
88<section>
99 <h2>Recent Blog Posts</h2>
1010
11 <div class="post">
12 <time datetime="2025-02-24 19:20:05">2025-02-24</time>
13 <a href="/blog/email-migration.html" >A Painful Email Migration</a>
14 </div>
15
1116 <div class="post">
1217 <time datetime="2025-02-11 11:40:00">2025-02-11</time>
1318 <a href="/blog/obscura-vpn.html">A Review of Obscura VPN: A Two-Party
@@ -21,13 +26,6 @@ uid [ultimate] Christian Cleberg &lt;hello@cleberg.net&gt;</pre>
2126 >
2227 </div>
2328
24 <div class="post">
25 <time datetime="2024-12-29 17:45:00">2024-12-29</time>
26 <a href="/blog/self-hosting-the-lounge.html"
27 >Self-Hosting The Lounge: An IRC Web Client</a
28 >
29 </div>
30
3129 <br />
3230 <a href="/blog/">All Posts →</a>
3331</section>