├── .babelrc ├── .flowconfig ├── .gitignore ├── .npmignore ├── README.md ├── index.js ├── package.json └── src ├── ConversationFetcher.js ├── Exporter.js └── main.js /.babelrc: -------------------------------------------------------------------------------- 1 | { "presets": ["latest", "stage-0"], "plugins": ["transform-flow-strip-types"] } 2 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [options] 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /src/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Export your ProtonMail e-mails 2 | 3 | > **Disclaimer: this repository is not maintained anymore! Now [ProtonMail Bridge](https://protonmail.com/bridge/) is is easiest way to export all your e-mails using IMAP.** 4 | 5 | [ProtonMail](https://protonmail.com/) does not provide (yet) a way to export your e-mails, like an IMAP access or any export option. That would be very nice, for several usages: 6 | 7 | - back up your e-mails, in case of a massive nuclear explosion near ProtonMail's servers; 8 | - move your e-mails to another mail provider if you're not satisfied with ProtonMail; 9 | - etc. 10 | 11 | Additionaly, I strongly believe that every service on the Internet, no matter how great it is, and especially if you pay for it, should be easy to leave for another. 12 | 13 | It's still possible, but you'll be forced to use undocumented ProtonMail API, proceeding by retro-engineering. This small program will make the process very easier for you, although not fully automated. 14 | 15 | Of course, I hope that very soon this program won't be necessary anymore because [ProtonMail will provide such an option](https://protonmail.com/support/knowledge-base/export-import-emails/) :) 16 | 17 | ## Features 18 | 19 | Implemented: 20 | 21 | - Export your e-mails (decrypted) to local [EML](https://en.wikipedia.org/wiki/Email#Filename_extensions) files you can then import in another mail client. 22 | - Fetches your e-mails from *Inbox*, *Sent* and *Archives* folder. 23 | 24 | Not implemented yet: 25 | 26 | - Export attached files. 27 | 28 | ## Installation 29 | 30 | You'll need to have *Node.js* installed on your system, with its package manager *npm*. 31 | 32 | To install the program run the following command: `npm install -g protonmail-export` 33 | 34 | ## How to download your ProtonMail private key? 35 | 36 | ProtonMail stores an encrypted version of your private key on its servers. From the settings pane of your account you can download your public key; unfortunately you cannot download your private key. The good news: you can very easilly find it using the development tools of your browser. Here's how: 37 | 38 | 1. Open the ProtonMail app and log out completely. You should now see the login screen. 39 | 2. Open the dev tools of your browser, and the _Network_ tab to see all network calls. 40 | 3. Enter your username and password and click _Login_ button. 41 | 4. In the network calls, find the one to “/api/auth”. 42 | 5. In this network call, open the *Response* tab to see raw data returned from the server, find the line beginning with `"KeySalt":` and copy the value without quotes to a file. 43 | 6. Find a post call to “/api/users”, there will be a section with addresses, find sections starting with `"PrivateKey":`, and copy the rest of the line, from `"-----BEGIN PGP PRIVATE KEY` to the last `"`, without the trailing comma. 44 | 7. Open the *Console* tab of the dev tools, type `console.log()` then press enter. 45 | 8. Copy the result of the command, and put it into a text file, that's it you have your private key! 46 | 9. Repeat points 6-8 for all addresses to get all your private keys 47 | 48 | Note that the private key is encrypted with a passphrase that is generated from key salt and your ProtonMail's account's password. So the private key you have now is not sufficient to decrypt your mail if someone steals it; however try to keep it somewhere secure ;). If you want to use the key elsewhere, you can use [pmpkpe](https://github.com/kantium/pmpkpe) to get the passphrase and import into your gpg keychain. 49 | 50 | ## How to export your e-mails? 51 | 52 | First you'll need several elements: 53 | 54 | * Your ProtonMail's account private key (see the appendice below), let's put it in a file named *private-key.txt* for instance. 55 | * The passphrase used to encrypt this private key (i.e. the second password you enter while signing in). 56 | * Some technical information about a session opened on ProtonMail. 57 | 58 | Let's get the information mentionned in the last point. 59 | 60 | _Note: these instructions are for Chrome/Chromium browser, but this shouldn't be very different for other browsers._ 61 | 62 | 1. First open a new session with your ProtonMail's account, and make sure your browser development tools are open. If they weren't open on page load, just open them and reload the page. 63 | 2. In the *Network* tab of the development tools, locate the call to */api/users* URL, and more specifically the *Request Headers* section to this call. 64 | 3. Copy-paste somewhere the value of these two headers: *Cookie* (begins with ”AUTH-”) and *x-pm-session* (32 alphanumeric characters). 65 | 66 | Once you have all this elements, you can finally export your mails by running the command: 67 | 68 | ```shell 69 | protonmail-export -i "" -c "" -p 70 | ``` 71 | 72 | For instance this might look like this: *(note that the output directory must already exist)* 73 | 74 | ```shell 75 | protonmail-export -i "95bc88ea1e94e25357e12a433e9b5ee5" -c "AUTH-95bc88(...); NOTICE-ae3cce(...)=true" -p ~/private-key.txt ~/protonmail-messages 76 | ``` 77 | 78 | You'll be asked for your passphrase to decrypt your private key. Then you'll get in the output directory one file for each of your emails. It's possible with most of mail clients to read and import these file to an existing mailbox. 79 | 80 | ## Contribute 81 | 82 | If you want to contribute please don't hesitate to make pull-requests :) 83 | 84 | ## Credits 85 | 86 | This program uses the fantastic [OpenPGP.js](https://openpgpjs.org/) library to decrypt e-mails, which is maintained by ProtonMail. 87 | 88 | ## License 89 | 90 | This program is provided under [GPL-v3.0](https://www.gnu.org/licenses/gpl.html). 91 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('babel-polyfill'); 4 | require('./dist/main.js'); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "protonmail-export", 3 | "version": "1.1.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "build": "flow && rm -Rf dist/* && babel src -d dist" 7 | }, 8 | "bin": "./index.js", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/scastiel/protonmail-export.git" 12 | }, 13 | "keywords": ["protonmail", "mail", "export", "openpgp"], 14 | "author": "Sébastien Castiel ", 15 | "license": "GPL-3.0", 16 | "dependencies": { 17 | "babel-core": "^6.18.2", 18 | "commander": "^2.9.0", 19 | "fs-promise": "^0.5.0", 20 | "node-fetch": "^1.6.3", 21 | "openpgp": "^2.3.5", 22 | "password-prompt": "^1.0.2", 23 | "request": "^2.78.0", 24 | "babel-polyfill": "^6.16.0" 25 | }, 26 | "devDependencies": { 27 | "babel-cli": "^6.18.0", 28 | "babel-plugin-transform-flow-strip-types": "^6.18.0", 29 | "babel-preset-latest": "^6.16.0", 30 | "babel-preset-stage-0": "^6.16.0", 31 | "flow": "^0.2.3" 32 | }, 33 | "description": "Export your ProtonMail e-mails to local files." 34 | } 35 | -------------------------------------------------------------------------------- /src/ConversationFetcher.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import fetch from 'node-fetch'; 4 | import * as openpgp from 'openpgp'; 5 | 6 | export default class ConversationsFetcher { 7 | _headers: any; 8 | _privateKey: string; 9 | _logger: (msg: string) => any; 10 | _counts: any | null; 11 | static BASE_URL: string = 'https://mail.protonmail.com/api'; 12 | constructor(cookie: any, sessionId: string, privateKey: string, logger: (msg: string) => any) { 13 | this._headers = { 14 | cookie, 15 | 'x-pm-apiversion': '1', 16 | 'x-pm-appversion': 'Web_3.11.7', 17 | 'x-pm-session': sessionId 18 | } 19 | this._privateKey = privateKey; 20 | this._logger = logger; 21 | this._counts = null; 22 | } 23 | async getConversationsInPageWithLabel(page: number, label: number): Promise { 24 | const url = `${ConversationsFetcher.BASE_URL}/conversations?Label=${label}&Limit=100&Page=${page}`; 25 | const result = await fetch(url, { headers: this._headers }); 26 | const convList = await result.json(); 27 | return convList.Conversations; 28 | } 29 | async getConversationsCountForLabel(label: number): Promise { 30 | if (!this._counts) { 31 | const url = `${ConversationsFetcher.BASE_URL}/conversations/count`; 32 | const result = await fetch(url, { headers: this._headers }); 33 | this._counts = (await result.json()).Counts.reduce((acc, c) => ({ ... acc, [c.LabelID]: c.Total }), {}); 34 | } 35 | return this._counts[label]; 36 | } 37 | async getConversationsWithLabel(label: number): Promise> { 38 | const conversations: Array = []; 39 | const conversationsCount = await this.getConversationsCountForLabel(label); 40 | this._logger(`${conversationsCount} conversations to fetch for label ${label}.`); 41 | for (let page = 0; page < (conversationsCount / 100) + 1; page++) { 42 | this._logger(`Fetching conversations in page ${page}...`); 43 | conversations.push(... await this.getConversationsInPageWithLabel(page, label)); 44 | } 45 | return conversations; 46 | } 47 | async getConversations(): Promise> { 48 | const conversations: Array = []; 49 | const labels = [0, 2, 6]; 50 | for (let label of labels) { 51 | this._logger(`Fetching conversations for label ${label}...`); 52 | conversations.push(... await this.getConversationsWithLabel(label)); 53 | } 54 | return conversations; 55 | } 56 | async populateConversation(conversation: any): Promise { 57 | this._logger(`Populating conversation ${conversation.ID}...`); 58 | const url = `${ConversationsFetcher.BASE_URL}/conversations/${conversation.ID}`; 59 | const result = await fetch(url, { headers: this._headers }); 60 | const newConversation = await result.json(); 61 | Object.assign(conversation, newConversation.Conversation, { Messages: newConversation.Messages }); 62 | return conversation; 63 | } 64 | async getMessagesFromConversation(conversation: any): Promise<[any]> { 65 | for (let message of conversation.Messages) { 66 | if (!message.Body) { 67 | await this.populateMessage(message); 68 | } 69 | if (!message.BodyDecrypted) { 70 | await this.decryptMessageBody(message); 71 | } 72 | } 73 | return conversation.Messages; 74 | } 75 | async populateMessage(message: any): Promise { 76 | const url = `${ConversationsFetcher.BASE_URL}/messages/${message.ID}`; 77 | const result = await fetch(url, { headers: this._headers }); 78 | const newMessage = await result.json(); 79 | Object.assign(message, newMessage.Message); 80 | } 81 | async decryptMessageBody(message: any): Promise { 82 | try { 83 | const messageBody = await openpgp.decrypt({ 84 | message: openpgp.message.readArmored(message.Body), 85 | privateKey: this._privateKey 86 | }); 87 | message.BodyDecrypted = messageBody.data; 88 | } catch (err) {} 89 | return message; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Exporter.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import fs from 'fs-promise'; 4 | import ConversationFetcher from './ConversationFetcher'; 5 | 6 | export default class Exporter { 7 | _logger: (msg: string) => any; 8 | conversationFetcher: ConversationFetcher; 9 | constructor(conversationFetcher: ConversationFetcher, logger: (msg: string) => any) { 10 | this.conversationFetcher = conversationFetcher; 11 | this._logger = logger; 12 | } 13 | async exportEmails(outputDirectory: string) { 14 | const conversations = await this.conversationFetcher.getConversations(); 15 | for (let conversation of conversations) { 16 | this._logger(`Exporting conversation ${conversation.ID}...`); 17 | await this.conversationFetcher.populateConversation(conversation); 18 | const messages = await this.conversationFetcher.getMessagesFromConversation(conversation); 19 | for (let message of messages) { 20 | const messageString = this._getFullDecryptedMessageAsString(message) 21 | await fs.writeFile(outputDirectory + '/' + message.ID + '.eml', messageString); 22 | } 23 | } 24 | } 25 | _getHeaderFromMessage(message: any): string { 26 | const header = message.Header.replace(/\r?\n\t/gm, ''); 27 | if (header.match(/Content-type:/i)) { 28 | return header.replace(/Content-Type: (.*)/i, `Content-type: ${message.MIMEType}; charset=UTF-8`); 29 | } else { 30 | return header.replace(/(\n+)$/m, `\nContent-type: ${message.MIMEType}; charset=UTF-8$1`); 31 | } 32 | } 33 | _getFullDecryptedMessageAsString(message: any): string { 34 | const header = this._getHeaderFromMessage(message); 35 | return `${header}\n${message.BodyDecrypted}`; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import path from 'path'; 4 | import fs from 'fs-promise'; 5 | import * as openpgp from 'openpgp'; 6 | import program from 'commander'; 7 | import prompt from 'password-prompt'; 8 | import ConversationsFetcher from './ConversationFetcher'; 9 | import Exporter from './Exporter'; 10 | 11 | async function getPrivateKey(privateKeyFile: string): Promise { 12 | const privateKeyText = await fs.readFile(privateKeyFile, 'utf-8'); 13 | return openpgp.key.readArmored(privateKeyText).keys[0]; 14 | } 15 | 16 | async function decryptPrivateKey(privateKey: openpgp.key): Promise { 17 | let ok = false; 18 | let firstTry = true; 19 | do { 20 | if (!firstTry) { 21 | console.log('Wrong passphrase.'); 22 | } 23 | const passphrase = await prompt('Private key passphrase: ', { method: 'hide' }); 24 | ok = privateKey.decrypt(passphrase); 25 | firstTry = false; 26 | } while (!ok); 27 | } 28 | 29 | async function getParams(): 30 | Promise<{ cookie: string, sessionId: string, privateKey: openpgp.Key, outputDirectory: string }> { 31 | 32 | let outputDirectory = ''; 33 | program 34 | .description('Export your ProtonMail e-mails.') 35 | .usage('[options] ') 36 | .option('-c, --cookie ', 'specify the cookie header to send to the API (required)') 37 | .option('-i, --session-id ', 'specify a valid session ID (required)') 38 | .option('-p, --private-key-file ', 'specify the path to a text file containing the private key (required)') 39 | .action(_outputDirectory => outputDirectory = _outputDirectory) 40 | .parse(process.argv); 41 | 42 | const errors = []; 43 | 44 | if (!outputDirectory) { 45 | errors.push('Output directory is required.'); 46 | } else if (!await fs.exists(outputDirectory)) { 47 | errors.push(`Directory not found: ${outputDirectory}`); 48 | } 49 | 50 | if (!program.cookie) { 51 | errors.push('Cookie parameter is required.'); 52 | } 53 | 54 | if (!program.sessionId) { 55 | errors.push('Session ID parameter is required.'); 56 | } 57 | 58 | let privateKey: openpgp.key; 59 | if (!program.privateKeyFile) { 60 | errors.push('Private key file parameter is required.'); 61 | } else if (!await fs.exists(program.privateKeyFile)) { 62 | errors.push(`Private key file not found: ${program.privateKeyFile}`); 63 | } else if (!(privateKey = await getPrivateKey(program.privateKeyFile))) { 64 | errors.push(`Invalid private key file: ${program.privateKeyFile}`); 65 | } 66 | 67 | if (errors.length > 0) { 68 | console.error(`Errors:\n${errors.map(e => ` - ${e}`).join('\n')}`); 69 | process.exit(1); 70 | } 71 | 72 | await decryptPrivateKey(privateKey); 73 | 74 | return { cookie: program.cookie, sessionId: program.sessionId, privateKey, outputDirectory }; 75 | } 76 | 77 | async function main(): Promise { 78 | const { cookie, sessionId, privateKey, outputDirectory } = await getParams(); 79 | const logger = (msg: string) => console.log(msg); 80 | const conversationFetcher = new ConversationsFetcher(cookie, sessionId, privateKey, logger); 81 | const exporter = new Exporter(conversationFetcher, logger); 82 | return await exporter.exportEmails(outputDirectory); 83 | } 84 | 85 | main().then(() => {}).catch(err => console.error(err.stack)); 86 | --------------------------------------------------------------------------------