commit 31441753ede3647b3851929f80303a4758397998 Author: kaj dijkstra Date: Tue Dec 30 20:28:13 2025 +0100 First commit diff --git a/data.json b/data.json new file mode 100644 index 0000000..fcb82a9 --- /dev/null +++ b/data.json @@ -0,0 +1,8 @@ +{ + "user": "kajdijkstra@protonmail.com", + "password": "WGMBLtu2peVYIsQAT282_g", + "host": "127.0.0.1", + "port": 1143, + "tls": false, + "tlsOptions": { "rejectUnauthorized": false } +} diff --git a/email-server.js b/email-server.js new file mode 100644 index 0000000..82f60f1 --- /dev/null +++ b/email-server.js @@ -0,0 +1,549 @@ +import express from "express"; + +import Imap from "imap"; + +import { simpleParser } from "mailparser"; + +import { promisify } from "util"; + +import { once } from "events"; + +import data from "./data.json" with { type: "json" }; + + +class ProtonMailIMAPReader { + + constructor ( config ) { + + this.imap = new Imap( config ); + + this.imap.once( "error", this.onImapError.bind( this ) ); + + this.imap.once( "end", this.onImapEnd.bind( this ) ); + + this.openBoxAsync = promisify( this.imap.openBox.bind( this.imap ) ); + + this.getBoxesAsync = promisify( this.imap.getBoxes.bind( this.imap ) ); + + } + + + onImapError ( err ) { + + console.error( "IMAP Error:", err ); + + } + + + onImapEnd ( ) { + + console.log( "Connection ended" ); + + } + + + connect ( ) { + + this.imap.connect( ); + + return once( this.imap, "ready" ); + + } + + + openBox ( boxName ) { + + return this.openBoxAsync( boxName, false ); + + } + + + async fetchEmails ( folder = "INBOX" ) { + + const box = await this.openBox( folder ); + + if ( !box || box.messages.total === 0 ) { + + return new Array( ); + + } + + let range; + + if ( box.messages.total > 9 ) { + + range = ( box.messages.total - 9 ) + ":" + box.messages.total; + + } else { + + range = "1:" + box.messages.total; + + } + + const fetch = this.imap.seq.fetch( range, { bodies: "", struct: true } ); + + const messageBuffers = { }; + + fetch.on( "message", this.handleMessage.bind( this, messageBuffers ) ); + + await once( fetch, "end" ); + + const emails = await this.parseMessages( messageBuffers ); + + emails.sort( this.compareEmailsBySeqno.bind( this ) ); + + return emails; + + } + + + async parseMessages ( messageBuffers ) { + + const emails = new Array( ); + + const seqnos = Object.keys( messageBuffers ); + + for ( let i = 0 ; i < seqnos.length ; i ++ ) { + + const seqno = seqnos[ i ]; + + const parsed = await simpleParser( messageBuffers[ seqno ] ); + + const email = this.prepareEmailObject( parseInt( seqno ), parsed ); + + emails.push( email ); + + } + + return emails; + + } + + + compareEmailsBySeqno ( a, b ) { + + if ( a.seqno < b.seqno ) { + + return -1; + + } + + if ( a.seqno > b.seqno ) { + + return 1; + + } + + return 0; + + } + + + handleMessage ( buffers, msg, seqno ) { + + const bufferChunks = new Array( ); + + msg.on( "body", this.onMessageBody.bind( this, bufferChunks ) ); + + msg.once( "end", this.onMessageEnd.bind( this, buffers, seqno, bufferChunks ) ); + + } + + + onMessageBody ( bufferChunks, stream ) { + + stream.on( "data", this.onStreamData.bind( this, bufferChunks ) ); + + } + + + onStreamData ( bufferChunks, chunk ) { + + bufferChunks.push( chunk ); + + } + + + onMessageEnd ( buffers, seqno, bufferChunks ) { + + buffers[ seqno ] = Buffer.concat( bufferChunks ); + + } + + + async fetchEmailBySeqno ( seqno, folder = "INBOX" ) { + + await this.openBox( folder ); + + const fetch = this.imap.seq.fetch( String( seqno ), { bodies: "", struct: true } ); + + const messageBuffers = { }; + + fetch.on( "message", this.handleMessage.bind( this, messageBuffers ) ); + + await once( fetch, "end" ); + + if ( !messageBuffers[ seqno ] ) { + + return null; + + } + + const parsed = await simpleParser( messageBuffers[ seqno ] ); + + return this.prepareEmailObject( parseInt( seqno ), parsed ); + + } + + + prepareEmailObject ( seqno, parsed ) { + + const email = { + + seqno, + + subject: parsed.subject ?? "(no subject)", + + from: parsed.from?.text ?? "(unknown sender)", + + date: parsed.date, + + text: parsed.text ?? "" + + }; + + return email; + + } + + + async markAsRead ( seqno, folder = "INBOX" ) { + + await this.openBox( folder ); + + return this.addFlagsAsync( seqno, "\\Seen" ); + + } + + + async removeMessage ( seqno, folder = "INBOX" ) { + + await this.openBox( folder ); + + const success = await this.addFlagsAsync( seqno, "\\Deleted" ); + + if ( !success ) { + + return false; + + } + + return this.expungeAsync( seqno ); + + } + + + addFlagsAsync ( seqno, flag ) { + + return new Promise( ( resolve ) => { + + this.imap.seq.addFlags( seqno, flag, this.onAddFlagsDone.bind( this, resolve ) ); + + } ); + + } + + + onAddFlagsDone ( resolve, err ) { + + if ( err ) { + + console.error( "Error adding flags:", err ); + + } + + resolve( !err ); + + } + + + expungeAsync ( seqno ) { + + return new Promise( ( resolve ) => { + + this.imap.seq.expunge( seqno, this.onExpungeDone.bind( this, resolve ) ); + + } ); + + } + + + onExpungeDone ( resolve, err ) { + + if ( err ) { + + console.error( "Error expunging message:", err ); + + } + + resolve( !err ); + + } + + + async listFolders ( ) { + + const boxes = await this.getBoxesAsync( ); + + const flat = new Array( ); + + this.flattenBoxes( boxes, "", flat ); + + return flat; + + } + + + flattenBoxes ( boxes, prefix, flat ) { + + for ( const name in boxes ) { + + const path = prefix ? prefix + "/" + name : name; + + flat.push( path ); + + if ( boxes[ name ].children ) { + + this.flattenBoxes( boxes[ name ].children, path, flat ); + + } + + } + + } + + + end ( ) { + + this.imap.end( ); + + } + +} + + +class ProtonMailServer { + + constructor ( ) { + + this.app = express( ); + + this.app.get( "/emails", this.getEmails.bind( this ) ); + + this.app.use( "/emails/folder/", this.getFolderEmails.bind( this ) ); + + this.app.get( "/message/:index", this.getEmailByIndex.bind( this ) ); + + this.app.get( "/flag/read/:index", this.markEmailAsRead.bind( this ) ); + + this.app.get( "/removeMessage/:message_id", this.removeMessageById.bind( this ) ); + + this.app.get( "/folders", this.getFolders.bind( this ) ); + + } + + + formatJsonResponse ( res, data ) { + + const jsonString = JSON.stringify( data, null, "\t" ); + + res.setHeader( "Content-Type", "application/json" ); + + res.send( jsonString ); + + } + + + async getEmails ( req, res ) { + + const reader = new ProtonMailIMAPReader( data ); + + await reader.connect( ); + + const emails = await reader.fetchEmails( ); + + reader.end( ); + + const filtered = emails.map( this.filterEmailSummary ); + + this.formatJsonResponse( res, { + + type: "emails", data: filtered, message: "Emails have been fetched." + + } ); + + } + + + filterEmailSummary ( email ) { + + const summary = { + + seqno: email.seqno, subject: email.subject, from: email.from, date: email.date + + }; + + return summary; + + } + + +async getFolderEmails ( req, res ) { + + let folderPath = decodeURIComponent( req.path.replace( "/emails/folder/", "" ) ); + + if ( folderPath.startsWith( "/" ) ) { + + folderPath = folderPath.slice( 1 ); + + } + + const reader = new ProtonMailIMAPReader( data ); + + await reader.connect( ); + + let emails; + + try { + + emails = await reader.fetchEmails( folderPath ); + + } catch ( err ) { + + reader.end( ); + + res.status( 500 ).json( { error: "Failed to fetch emails for folder: " + folderPath } ); + + return; + + } + + reader.end( ); + + const filtered = emails.map( this.filterEmailSummary ); + + this.formatJsonResponse( res, { + + type: "emails", folder: folderPath, data: filtered, message: "Emails have been fetched." + + } ); + +} + + + + async getEmailByIndex ( req, res ) { + + const seqno = req.params.index; + + const reader = new ProtonMailIMAPReader( data ); + + await reader.connect( ); + + const email = await reader.fetchEmailBySeqno( seqno ); + + reader.end( ); + + if ( email === null ) { + + res.status( 404 ).json( { error: "Email not found" } ); + + } else { + + this.formatJsonResponse( res, { + + type: "email", data: email, message: "Email fetched successfully." + + } ); + + } + + } + + + async markEmailAsRead ( req, res ) { + + const seqno = req.params.index; + + const reader = new ProtonMailIMAPReader( data ); + + await reader.connect( ); + + await reader.markAsRead( seqno ); + + reader.end( ); + + this.formatJsonResponse( res, { message: "Email marked as read." } ); + + } + + + async removeMessageById ( req, res ) { + + const seqno = req.params.message_id; + + const reader = new ProtonMailIMAPReader( data ); + + await reader.connect( ); + + const success = await reader.removeMessage( seqno ); + + reader.end( ); + + if ( success ) { + + this.formatJsonResponse( res, { message: "Email removed successfully." } ); + + } else { + + this.formatJsonResponse( res, { error: "Failed to remove email." } ); + + } + + } + + + async getFolders ( req, res ) { + + const reader = new ProtonMailIMAPReader( data ); + + await reader.connect( ); + + const flatFolders = await reader.listFolders( ); + + reader.end( ); + + this.formatJsonResponse( res, { folders: flatFolders } ); + + } + + + start ( port = 3000 ) { + + this.app.listen( port, this.onServerStarted.bind( this, port ) ); + + } + + + onServerStarted ( port ) { + + console.log( "ProtonMail IMAP server running on port " + port ); + + } + +} + + +const server = new ProtonMailServer( ); + +server.start( 3000 ); diff --git a/ngrok-stable-linux-amd64.zip b/ngrok-stable-linux-amd64.zip new file mode 100644 index 0000000..83d15db Binary files /dev/null and b/ngrok-stable-linux-amd64.zip differ diff --git a/ngrok-v3-stable-linux-amd64.tgz b/ngrok-v3-stable-linux-amd64.tgz new file mode 100644 index 0000000..2b641db Binary files /dev/null and b/ngrok-v3-stable-linux-amd64.tgz differ