import * as fs from 'fs' ; import * as glob from 'glob' ; import * as path from 'path' ; import * as ProgressBar from 'progress' ; import * as util from 'util' ; import { Splitter , Joiner , Rewriter } from 'mailsplit' ; import { Transform , TransformCallback , pipeline , Writable } from 'stream' ; import * as Debug from 'debug' ; const debug = Debug ( 'converter' ) ; export async function processEmlxs ( inputDir , outputDir , ignoreErrors ? ) { const files = await util . promisify ( glob ) ( '**/*.emlx' , { cwd : inputDir } ) ; const bar = new ProgressBar ( 'Converting [:bar] :percent :etas :file' , { total : files . length , width : 40 } ) ; for ( const file of files ) { bar . tick ( { file } ) ; try { const resultPath = path . join ( outputDir , `${ stripExtension ( path . basename ( file ) ) }.eml` ) ; const writeStream = fs . createWriteStream ( resultPath ) ; const messages = await processEmlx ( path . join ( inputDir , file ) , writeStream , ignoreErrors ) ; messages . forEach ( message => bar . interrupt ( `${ file }: ${ message }` ) ) ; } catch ( e ) { bar . interrupt ( `Encountered error when processing ${ file } -- run with '--ignoreErrors' argument to avoid aborting the conversion.` ) ; bar . terminate ( ) ; throw e ; } } } const appleContentLengthHeader = 'X-Apple-Content-Length' ; param emlxFile param resultStream param ignoreErrors returns export async function processEmlx ( emlxFile , resultStream , ignoreErrors = false ) { const messages = [ ] ; const rewriter = new Rewriter ( ( node ) => node . headers . hasHeader ( appleContentLengthHeader ) ) ; rewriter . on ( 'node' , ( data ) => { data . node . headers . remove ( appleContentLengthHeader ) ; data . decoder . on ( 'data' , ( ) => { } ) ; data . decoder . on ( 'end' , ( ) => { integrateAttachment ( emlxFile , data ) . catch ( err => { if ( ignoreErrors ) { messages . push ( err . message ) ; } else { rewriter . emit ( 'error' , err ) ; } } ) ; } ) ; } ) ; await util . promisify ( pipeline ) ( fs . createReadStream ( emlxFile ) , new SkipEmlxTransform ( ) , new Splitter ( ) , rewriter , new Joiner ( ) , resultStream ) ; return messages ; } async function integrateAttachment ( emlxFile , data ) { const attachmentDirectoryPath = path . join ( path . dirname ( emlxFile ) , '..' , 'Attachments' , stripExtension ( path . basename ( emlxFile ) ) , data . node . partNr . join ( '.' ) ) ; const fileNames = [ data . node . filename , await getFilenameFromFileSystem ( attachmentDirectoryPath ) ] . filter ( ( f ) : f is string => ! ! f ) ; let processedAttachment = false ; for ( const fileName of fileNames ) { const filePath = path . join ( attachmentDirectoryPath , fileName ) ; try { await new Promise < void > ( ( resolve , reject ) => { const stream = fs . createReadStream ( filePath ) ; stream . on ( 'error' , error => reject ( error ) ) ; stream . on ( 'close' , ( ) => resolve ( ) ) ; stream . pipe ( data . encoder ) ; } ) ; processedAttachment = true ; break ; } catch ( e ) { } } if ( ! processedAttachment ) { data . encoder . end ( ) ; let message = 'Could not get attachment file' ; if ( fileNames . length > 0 ) { message += ` (tried ${ fileNames . join ( ', ' ) })` ; } throw new Error ( message ) ; } } param pathToDirectory returns async function getFilenameFromFileSystem ( pathToDirectory ) { try { const files = ( await fs . promises . readdir ( pathToDirectory ) ) . filter ( file => ! file . startsWith ( '.DS_Store' ) ) ; if ( files . length !== 1 ) { const filenames = files . length > 0 ? `(${ files . join ( ', ' ) })` : '' ; debug ( `Couldn’t determine attachment; expected '${ pathToDirectory }' ` + `to contain one file, but there were: ${ files . length } ${ filenames }` ) ; return null ; } else { return files [ 0 ] ; } } catch ( e ) { debug ( `Couldn’t read attachments in '${ pathToDirectory }'` ) ; return null ; } } function stripExtension ( fileName ) { return fileName . replace ( /\..*/ , '' ) ; } export class SkipEmlxTransform extends Transform { private bytesToRead : number | undefined = undefined ; private bytesRead = 0 ; _transform ( chunk , _encoding , callback ) { let offset ; let length ; if ( ! this . bytesToRead ) { const payloadLengthMatch = /^(\d+)\s+/ . exec ( chunk . toString ( 'utf8' ) ) ; if ( ! payloadLengthMatch ) { return callback ( new Error ( 'Invalid structure; content did not start with payload length' ) ) ; } this . bytesToRead = parseInt ( payloadLengthMatch [ 1 ] , 10 ) ; offset = payloadLengthMatch [ 0 ] . length ; length = Math . min ( this . bytesToRead + offset , chunk . length ) ; } else { offset = 0 ; length = Math . min ( this . bytesToRead - this . bytesRead , chunk . length ) ; } let slicedChunk = chunk . slice ( offset , length ) ; this . bytesRead += slicedChunk . length ; if ( this . bytesRead === this . bytesToRead ) { const temp = slicedChunk . toString ( 'utf8' ) ; if ( temp . endsWith ( '-' ) && ! temp . endsWith ( '--' ) ) { const nextChars = chunk . slice ( length , length + 5 ) . toString ( 'utf8' ) ; if ( nextChars === '<?xml' ) { slicedChunk = Buffer . concat ( [ slicedChunk , Buffer . from ( '-' ) ] ) ; } } } callback ( undefined , slicedChunk ) ; } } export function processCli ( ) { const args = process . argv . slice ( 2 ) ; if ( args . length < 2 ) { console . log ( `${ path . basename ( process . argv [ 1 ] ) } input_directory output_directory [--ignoreErrors]` ) ; process . exit ( 1 ) ; } let ignoreErrors = false ; if ( args . length > 2 ) { ignoreErrors = args [ 2 ] === '--ignoreErrors' ; } processEmlxs ( args [ 0 ] , args [ 1 ] , ignoreErrors ) . catch ( err => console . error ( err ) ) ; }