Commit 66dea52d21
Unsigned
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 | |||
| 9 | I recently migrated my emails from Proton Mail to Migadu after a failed attempt | ||
| 10 | to get myself into the Proton ecosystem and wanted to detail my process, as it | ||
| 11 | was far more painful than expected. | ||
| 12 | |||
| 13 | To give some context: I had nearly 5000 messages stored, accounting for around | ||
| 14 | 2.5 GB of space. | ||
| 15 | |||
| 16 | Overall, this process would have taken all day had I done it in one sitting, but | ||
| 17 | I decided to break it up and it lasted a couple days before I was able to say that | ||
| 18 | all my messages were stored in my new account. | ||
| 19 | |||
| 20 | * Exporting Messages | ||
| 21 | |||
| 22 | To start, I needed to export my messages from Proton Mail. As I am using macOS, | ||
| 23 | I was able to use the [[https://proton.me/support/proton-mail-export-tool][Proton Mail Export Tool]]. However, the downside is that | ||
| 24 | this dumps every single email on your account into a single folder in the =.eml= | ||
| 25 | format. They also export a JSON file for each message, in the case you're | ||
| 26 | importing back into Proton Mail. | ||
| 27 | |||
| 28 | This means that anything in my Inbox, Sent, Archive, Trash, and user-created | ||
| 29 | folders were all dumped out into a single folder with incomprehensible names. | ||
| 30 | |||
| 31 | Without a clear path to easily figure out how to re-organize my emails into a | ||
| 32 | new account, I was left a bit annoyed at Proton's export process. | ||
| 33 | |||
| 34 | * Importing Messages | ||
| 35 | |||
| 36 | Left with a pile of messages and no way to discern what they were without | ||
| 37 | opening each one, I decided to try and use Thunderbird to import messages into | ||
| 38 | my new Migadu IMAP account. | ||
| 39 | |||
| 40 | This led to a dead end as my two methods failed: | ||
| 41 | |||
| 42 | 1. [[https://addons.thunderbird.net/en-US/thunderbird/addon/importexporttools-ng/][ImportExportTools NG]] does not work with my version of Thunderbird (135). | ||
| 43 | 2. 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 | |||
| 48 | At this point, I decided to take a step back and use [[https://github.com/djcb/mu][mu]], a command-line utility | ||
| 49 | that would index my files and sync back and forth with Migadu for me. | ||
| 50 | |||
| 51 | Using my blog post, [[https://cleberg.net/blog/mu4e.html][Email in Doom Emacs with Mu4e on macOS]], (and skipping the | ||
| 52 | mu4e parts) I was able to set up a minimal directory connected to my Migadu IMAP | ||
| 53 | account. Using my terminal, I simply moved all of my messages into the =mu= | ||
| 54 | directory and synchronized the account, and voila, my messages synchronized | ||
| 55 | successfully to the remote server and my other email clients. | ||
| 56 | |||
| 57 | However, the remaining issue was that I now had all 5000 messages in the Archive | ||
| 58 | folder and needed to figure out how to organize them back into their proper | ||
| 59 | directories. | ||
| 60 | |||
| 61 | * Organizing Messages Into Folders | ||
| 62 | |||
| 63 | As with any problem, I used Python as my hammer to fix the problem. I started by | ||
| 64 | creating the directories required in Thunderbird, fetching them with =mbsync= so | ||
| 65 | that they appeared in my =mu= directory, and using Python to organize my | ||
| 66 | messages into the newly-created sub-folders. | ||
| 67 | |||
| 68 | ** Sent Messages | ||
| 69 | |||
| 70 | I started by organizing my Sent messages. This required checking each file for | ||
| 71 | the =From= header and moving them to the Sent folder. | ||
| 72 | |||
| 73 | #+begin_src shell | ||
| 74 | cd ~/.maildir/migadu/Archive/cur | ||
| 75 | nano _sent.py | ||
| 76 | #+end_src | ||
| 77 | |||
| 78 | #+begin_src python | ||
| 79 | # _sent.py | ||
| 80 | import os | ||
| 81 | import glob | ||
| 82 | import shutil | ||
| 83 | |||
| 84 | # Loop through all files in the current folder | ||
| 85 | for 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 | ||
| 110 | python3 _sent.py | ||
| 111 | #+end_src | ||
| 112 | |||
| 113 | The only downside to my current approach is that it was the quick and dirty | ||
| 114 | option, so I re-ran it while editing the =user@example.com= string for each | ||
| 115 | email I wanted to move. If I had wanted to create a more well-defined solution, | ||
| 116 | I would have created an array of addresses to check for and have the =if= | ||
| 117 | statement check against that array. | ||
| 118 | |||
| 119 | Regardless, I was able to run this with the addresses I wanted to move to the | ||
| 120 | Sent folder and was soon finished. | ||
| 121 | |||
| 122 | ** Archive Sub-Folders | ||
| 123 | |||
| 124 | Next, I needed to move the remaining ~3000 messages from the Archive folder into | ||
| 125 | dated sub-folders, organized as such: | ||
| 126 | |||
| 127 | - Archive/2016 | ||
| 128 | - ... | ||
| 129 | - Archive/2025 | ||
| 130 | |||
| 131 | |||
| 132 | To 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 | ||
| 136 | cd ~/.maildir/migadu/Archive/cur | ||
| 137 | nano _archive.py | ||
| 138 | #+end_src | ||
| 139 | |||
| 140 | This approach requires finding the =X-Pm-Date= header and splitting it by the | ||
| 141 | spaces contained within. Once split into a list, we must select the fourth | ||
| 142 | element, as that contains the year which will match the directory we should move | ||
| 143 | it to. | ||
| 144 | |||
| 145 | For example, the header =X-Pm-Date: Fri, 07 Feb 2025 16:12:08 +0000= will be | ||
| 146 | split into a list as such: | ||
| 147 | |||
| 148 | 0. X-Pm-Date: | ||
| 149 | 1. Fri, | ||
| 150 | 2. 07 | ||
| 151 | 3. Feb | ||
| 152 | 4. 2025 | ||
| 153 | 5. 16:12:08 | ||
| 154 | 6. +0000 | ||
| 155 | |||
| 156 | From this list, we select the fourth element (=2025=) and use that to build the | ||
| 157 | destination path. | ||
| 158 | |||
| 159 | #+begin_src python | ||
| 160 | # _archive.py | ||
| 161 | import os | ||
| 162 | import glob | ||
| 163 | import shutil | ||
| 164 | |||
| 165 | # Loop through all files in the sub-folders under Archive | ||
| 166 | for 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 | ||
| 192 | python3 _archive.py | ||
| 193 | #+end_src | ||
| 194 | |||
| 195 | At this point, we've now moved all Sent messages to the Sent box and organized | ||
| 196 | all messages under the Archive folder into their correct sub-folders. | ||
| 197 | |||
| 198 | If you exported other files, such as files from your Inbox, Trash, etc., you | ||
| 199 | could follow a similar approach and determine the best header or attribute to | ||
| 200 | identify them for further organization. | ||
| 201 | |||
| 202 | ** Synchronize the Results | ||
| 203 | |||
| 204 | Before synchronizing the files in their new locations, I needed to remove the | ||
| 205 | characters at the end of the file name since =mu= appends IDs to the end of file | ||
| 206 | names. | ||
| 207 | |||
| 208 | #+begin_src shell | ||
| 209 | cd ~/.maildir/migadu/Archive | ||
| 210 | nano _sync_prep.py | ||
| 211 | #+end_src | ||
| 212 | |||
| 213 | This script prepares the =Archive= sub-folders for synchronization, but the same | ||
| 214 | concept applies to the Sent folder, except you'd replace =*/cur/*= with =*= if | ||
| 215 | this script were inside the =Sent/cur= directory. | ||
| 216 | |||
| 217 | #+begin_src python | ||
| 218 | import glob | ||
| 219 | import shutil | ||
| 220 | |||
| 221 | # Loop through all files in the sub-folders under Archive | ||
| 222 | for 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 | ||
| 230 | python3 _sync_prep.py | ||
| 231 | #+end_src | ||
| 232 | |||
| 233 | Finally, we can synchronize the results. | ||
| 234 | |||
| 235 | #+begin_src shell | ||
| 236 | mbsync -aV | ||
| 237 | #+end_src | ||
| 238 | |||
| 239 | * Removing Duplicates | ||
| 240 | |||
| 241 | My only remaining issue at the time of writing is identifying and removing | ||
| 242 | duplicate messages. I have toyed with simple Python and command-line solutions | ||
| 243 | to identify duplicate files, but could not get them to effectively define all | ||
| 244 | the duplicates found in any specific directory. | ||
| 245 | |||
| 246 | I've even tried using the [[https://github.com/pkolaczk/fclones][fclones]] utility, to no avail. It seems that something | ||
| 247 | in the Proton export, my manual Thunderbird method attempt, or possible sync | ||
| 248 | issues between Thunderbird -> Migadu <-> mu caused duplicates where content | ||
| 249 | within the message has been modified. | ||
| 250 | |||
| 251 | Although I now seem to be wasting space and in need of a deduplication tool, I | ||
| 252 | have all of my messages migrated to my new service. | ||
theme/templates/index.html +5 −7
| @@ -8,6 +8,11 @@ uid [ultimate] Christian Cleberg <hello@cleberg.net></pre> | |||
| 8 | <section> | 8 | <section> |
| 9 | <h2>Recent Blog Posts</h2> | 9 | <h2>Recent Blog Posts</h2> |
| 10 | 10 | ||
| 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 | |||
| 11 | <div class="post"> | 16 | <div class="post"> |
| 12 | <time datetime="2025-02-11 11:40:00">2025-02-11</time> | 17 | <time datetime="2025-02-11 11:40:00">2025-02-11</time> |
| 13 | <a href="/blog/obscura-vpn.html">A Review of Obscura VPN: A Two-Party | 18 | <a href="/blog/obscura-vpn.html">A Review of Obscura VPN: A Two-Party |
| @@ -21,13 +26,6 @@ uid [ultimate] Christian Cleberg <hello@cleberg.net></pre> | |||
| 21 | > | 26 | > |
| 22 | </div> | 27 | </div> |
| 23 | 28 | ||
| 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 | |||
| 31 | <br /> | 29 | <br /> |
| 32 | <a href="/blog/">All Posts →</a> | 30 | <a href="/blog/">All Posts →</a> |
| 33 | </section> | 31 | </section> |