From 8a81b34ed044639b754ecb0d106b9fc5fd0505c0 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 25 Apr 2020 11:25:47 -0600 Subject: [PATCH] WIP: Lots of progress with QWK reader/writer --- core/message.js | 11 +- core/message_area.js | 1 + core/qwk_mail_packet.js | 554 +++++++++++++++++++++++++++++++++++----- 3 files changed, 492 insertions(+), 74 deletions(-) diff --git a/core/message.js b/core/message.js index 68f51ee4..3649a6f6 100644 --- a/core/message.js +++ b/core/message.js @@ -49,7 +49,8 @@ const SYSTEM_META_NAMES = { const ADDRESS_FLAVOR = { Local : 'local', // local / non-remote addressing FTN : 'ftn', // FTN style - Email : 'email', + Email : 'email', // From email + QWK : 'qwk', // QWK packet }; const STATE_FLAGS0 = { @@ -94,14 +95,6 @@ const QWKPropertyNames = { InReplyToNum : 'qwk_in_reply_to_num', // note that we prefer the 'InReplyToMsgId' kludge if available }; -const QWKKludgeNames = { - Via : 'via', - MessageId : 'msg_id', - InReplyToMsgId : 'in_reply_to_msg_id', - SyncTZ : 'synchronet_timezone', - ReplyTo : 'reply_to', -}; - // :TODO: this is a ugly hack due to bad variable names - clean it up & just _.camelCase(k)! const MESSAGE_ROW_MAP = { reply_to_message_id : 'replyToMsgId', diff --git a/core/message_area.js b/core/message_area.js index 579f1c9b..ff01f1e1 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -29,6 +29,7 @@ exports.getDefaultMessageAreaTagByConfTag = getDefaultMessageAreaTagByConfTag; exports.getSuitableMessageConfAndAreaTags = getSuitableMessageConfAndAreaTags; exports.getMessageConferenceByTag = getMessageConferenceByTag; exports.getMessageAreaByTag = getMessageAreaByTag; +exports.getMessageConfTagByAreaTag = getMessageConfTagByAreaTag; exports.changeMessageConference = changeMessageConference; exports.changeMessageArea = changeMessageArea; exports.hasMessageConfAndAreaRead = hasMessageConfAndAreaRead; diff --git a/core/qwk_mail_packet.js b/core/qwk_mail_packet.js index f670e494..5dc3275f 100644 --- a/core/qwk_mail_packet.js +++ b/core/qwk_mail_packet.js @@ -1,12 +1,14 @@ - - const ArchiveUtil = require('./archive_util'); const { Errors } = require('./enig_error'); const Message = require('./message'); const { splitTextAtTerms } = require('./string_util'); +const { getMessageConfTagByAreaTag } = require('./message_area'); +const StatLog = require('./stat_log'); +const Config = require('./config').get; +const SysProps = require('./system_property'); const { EventEmitter } = require('events'); -const temptmp = require('temptmp').createTrackedSession('qwk_packet'); +const temptmp = require('temptmp'); const async = require('async'); const fs = require('graceful-fs'); const paths = require('path'); @@ -16,27 +18,64 @@ const moment = require('moment'); const _ = require('lodash'); const IniConfigParser = require('ini-config-parser'); -const SMBTZToUTCOffset = (smbTZ) => { - // convert a Synchronet smblib TZ to a UTC offset - // see https://github.com/kvadevack/synchronet/blob/master/src/smblib/smbdefs.h - return { - // US Standard - '40F0' : '-04:00', // Atlantic - '412C' : '-05:00', // Eastern - '4168' : '-06:00', // Central - '41A4' : '-07:00', // Mountain - '41E0' : '-08:00', // Pacific - '421C' : '-09:00', // Yukon - '4258' : '-10:00', // Hawaii/Alaska - '4294' : '-11:00', // Bering +const enigmaVersion = require('../package.json').version; - // US Daylight - // :TODO: FINISH ME! +// Synchronet smblib TZ to a UTC offset +// see https://github.com/kvadevack/synchronet/blob/master/src/smblib/smbdefs.h +const SMBTZToUTCOffset = { + // US Standard + '40F0' : '-04:00', // Atlantic + '412C' : '-05:00', // Eastern + '4168' : '-06:00', // Central + '41A4' : '-07:00', // Mountain + '41E0' : '-08:00', // Pacific + '421C' : '-09:00', // Yukon + '4258' : '-10:00', // Hawaii/Alaska + '4294' : '-11:00', // Bering - }[smbTZ]; + // US Daylight + 'C0F0' : '-03:00', // Atlantic + 'C12C' : '-04:00', // Eastern + 'C168' : '-05:00', // Central + 'C1A4' : '-06:00', // Mountain + 'C1E0' : '-07:00', // Pacific + 'C21C' : '-08:00', // Yukon + 'C258' : '-09:00', // Hawaii/Alaska + 'C294' : '-10:00', // Bering + + // "Non-Standard" + '2294' : '-11:00', // Midway + '21E0' : '-08:00', // Vancouver + '21A4' : '-07:00', // Edmonton + '2168' : '-06:00', // Winnipeg + '212C' : '-05:00', // Bogota + '20F0' : '-04:00', // Caracas + '20B4' : '-03:00', // Rio de Janeiro + '2078' : '-02:00', // Fernando de Noronha + '203C' : '-01:00', // Azores + '1000' : '+00:00', // London + '103C' : '+01:00', // Berlin + '1078' : '+02:00', // Athens + '10B4' : '+03:00', // Moscow + '10F0' : '+04:00', // Dubai + '110E' : '+04:30', // Kabul + '112C' : '+05:00', // Karachi + '114A' : '+05:30', // Bombay + '1159' : '+05:45', // Kathmandu + '1168' : '+06:00', // Dhaka + '11A4' : '+07:00', // Bangkok + '11E0' : '+08:00', // Hong Kong + '121C' : '+09:00', // Tokyo + '1258' : '+10:00', // Sydney + '1294' : '+11:00', // Noumea + '12D0' : '+12:00', // Wellington }; -const QWKMessageBlockSize = 128; +const UTCOffsetToSMBTZ = _.invert(SMBTZToUTCOffset); + +const QWKMessageBlockSize = 128; +const QWKHeaderTimestampFormat = 'MM-DD-YYHH:mm'; +const QWKLF = 0xe3; // See the following: // - http://fileformats.archiveteam.org/wiki/QWK @@ -95,14 +134,29 @@ const MessageHeaderParser = new Parser() .uint16('relNum') .uint8('netTag'); +const replaceCharInBuffer = (buffer, search, replace) => { + let i = 0; + search = Buffer.from([search]); + while (i < buffer.length) { + i = buffer.indexOf(search, i); + if (-1 === i) { + break; + } + buffer[i] = replace; + ++i; + } +} class QWKPacketReader extends EventEmitter { - constructor(packetPath, mode=QWKPacketReader.Modes.Guess, options = { keepTearAndOrigin : true } ) { + constructor( + packetPath, + { mode = QWKPacketReader.Modes.Guess, keepTearAndOrigin = true } = { mode : QWKPacketReader.Modes.Guess, keepTearAndOrigin : true }) + { super(); this.packetPath = packetPath; - this.mode = mode; - this.options = options; + this.options = { mode, keepTearAndOrigin }; + this.temptmp = temptmp.createTrackedSession('qwkpacketreader'); } static get Modes() { @@ -137,7 +191,7 @@ class QWKPacketReader extends EventEmitter { }, // create a temporary location to do processing (archiveType, callback) => { - temptmp.mkdir( { prefix : 'enigqwkpacket-'}, (err, tempDir) => { + this.temptmp.mkdir( { prefix : 'enigqwkreader-'}, (err, tempDir) => { if (err) { return callback(err); } @@ -172,20 +226,20 @@ class QWKPacketReader extends EventEmitter { switch (key) { case 'MESSAGES.DAT' : // QWK - if (this.mode === QWKPacketReader.Modes.Guess) { - this.mode = QWKPacketReader.Modes.QWK; + if (this.options.mode === QWKPacketReader.Modes.Guess) { + this.options.mode = QWKPacketReader.Modes.QWK; } - if (this.mode === QWKPacketReader.Modes.QWK) { + if (this.options.mode === QWKPacketReader.Modes.QWK) { out.messages = { filename }; } break; case 'ID.MSG' : - if (this.mode === QWKPacketReader.Modes.Guess) { - this.mode = Modes.REP; + if (this.options.mode === QWKPacketReader.Modes.Guess) { + this.options.mode = Modes.REP; } - if (this.mode === QWKPacketReader.Modes.REP) { + if (this.options.mode === QWKPacketReader.Modes.REP) { out.messages = { filename }; } break; @@ -258,12 +312,9 @@ class QWKPacketReader extends EventEmitter { (callback) => { return this.processPacketFiles(callback); }, - (tempDir, callback) => { - return callback(null); - } ], err => { - temptmp.cleanup(); + this.temptmp.cleanup(); if (err) { return this.emit('error', err); @@ -386,14 +437,19 @@ class QWKPacketReader extends EventEmitter { const path = paths.join(this.packetInfo.tempDir, this.packetInfo.headers.filename); fs.readFile(path, { encoding : 'utf8' }, (err, iniData) => { if (err) { - this.emit('warning', Errors.Invalid(`HEADERS.DAT file appears to be invalid (${err.message})`)); + this.emit('warning', Errors.Invalid(`Problem reading HEADERS.DAT: ${err.message}`)); return cb(null); // non-fatal } try { - this.packetInfo.headers.ini = IniConfigParser.parse(iniData); + const parserOptions = { + lineComment : false, // no line comments; consume full lines + nativeType : false, // just keep everything as strings + dotKey : false, // 'a.b.c = value' stays 'a.b.c = value' + }; + this.packetInfo.headers.ini = IniConfigParser.parse(iniData, parserOptions); } catch (e) { - this.emit('warning', Errors.Invalid(`HEADERS.DAT file appears to be invalid (${e.message})`)); + this.emit('warning', Errors.Invalid(`HEADERS.DAT file appears to be invalid: ${e.message}`)); } return cb(null); @@ -401,7 +457,6 @@ class QWKPacketReader extends EventEmitter { } readMessages(cb) { - // :TODO: update to use proper encoding: if headers.dat specifies UTF-8, use that, else CP437 if (!this.packetInfo.messages) { return cb(Errors.DoesNotExist('No messages file found within QWK packet')); } @@ -420,7 +475,6 @@ class QWKPacketReader extends EventEmitter { const FTNPropertyMapping = { 'X-FTN-AREA' : Message.FtnPropertyNames.FtnArea, 'X-FTN-SEEN-BY' : Message.FtnPropertyNames.FtnSeenBy, - 'X-FTN-FLAGS' : Message.FtnPropertyNames }; const FTNKludgeMapping = { @@ -452,6 +506,10 @@ class QWKPacketReader extends EventEmitter { Reply : '@REPLY:', TZ : '@TZ:', // https://github.com/kvadevack/synchronet/blob/master/src/smblib/smbdefs.h ReplyTo : '@REPLYTO:', + + // :TODO: Look into other non-standards + // https://github.com/wmcbrine/MultiMail/blob/master/mmail/qwk.cc + // title, @subject, etc. }; let blockCount = 0; @@ -480,7 +538,7 @@ class QWKPacketReader extends EventEmitter { if (0 === blockCount) { // first 128 bytes is a space padded ID const id = buffer.toString('ascii').trim(); - this.emit('generator', id); + this.emit('creator', id); state = 'header'; } else { switch (state) { @@ -493,7 +551,7 @@ class QWKPacketReader extends EventEmitter { header[field] = iconv.decode(header[field], encodingToSpec).trim(); }); - header.timestamp = moment(header.timestamp, 'MM-DD-YYHH:mm'); + header.timestamp = moment(header.timestamp, QWKHeaderTimestampFormat); currMessage = { header, @@ -523,7 +581,7 @@ class QWKPacketReader extends EventEmitter { case 'message' : if (!currMessage.body) { - currMessage.body = buffer; + currMessage.body = Buffer.from(buffer); } else { currMessage.body = Buffer.concat([currMessage.body, buffer]); } @@ -534,16 +592,7 @@ class QWKPacketReader extends EventEmitter { // First, replace QWK style line feeds (0xe3) unless the message is UTF-8. // If the message is UTF-8, we assume it's using standard line feeds. if (encoding !== 'utf8') { - let i = 0; - const QWKLF = Buffer.from([0xe3]); - while (i < currMessage.body.length) { - i = currMessage.body.indexOf(QWKLF, i); - if (-1 === i) { - break; - } - currMessage.body[i] = 0x0a; - ++i; - } + replaceCharInBuffer(currMessage.body, QWKLF, 0x0a); } // @@ -583,15 +632,15 @@ class QWKPacketReader extends EventEmitter { } else if (line.startsWith(Kludges.Subject)) { currMessage.subject = line.substring(Kludges.Subject.length).trim(); } else if (line.startsWith(Kludges.Via)) { - qwkKludge.via = line; + qwkKludge['@VIA'] = line; } else if (line.startsWith(Kludges.MsgID)) { - qwkKludge.msg_id = line.substring(Kludges.MsgID.length).trim(); + qwkKludge['@MSGID'] = line.substring(Kludges.MsgID.length).trim(); } else if (line.startsWith(Kludges.Reply)) { - qwkKludge.in_reply_to_msg_id = line.substring(Kludges.Reply.length).trim(); + qwkKludge['@REPLY'] = line.substring(Kludges.Reply.length).trim(); } else if (line.startsWith(Kludges.TZ)) { - qwkKludge.synchronet_timezone = line.substring(Kludges.TZ.length).trim(); + qwkKludge['@TZ'] = line.substring(Kludges.TZ.length).trim(); } else if (line.startsWith(Kludges.ReplyTo)) { - qwkKludge.reply_to = line.substring(Kludges.ReplyTo.length).trim(); + qwkKludge['@REPLYTO'] = line.substring(Kludges.ReplyTo.length).trim(); } else { bodyState = 'body'; // past this point and up to any tear/origin/etc., is the real message body bodyLines.push(line); @@ -620,12 +669,12 @@ class QWKPacketReader extends EventEmitter { const ext = currMessage.headersExtension; // to and subject can be overridden yet again if entries are present - currMessage.toName = ext.To || currMessage.toName + currMessage.toName = ext.To || currMessage.toName; currMessage.subject = ext.Subject || currMessage.subject; - currMessage.from = ext.Sender || currMessage.from; // why not From? Who the fuck knows. + currMessage.from = ext.Sender || currMessage.fromName; // why not From? Who the fuck knows. // possibly override message ID kludge - qwkKludge.msg_id = ext['Message-ID'] || qwkKludge.msg_id; + qwkKludge['@MSGID'] = ext['Message-ID'] || qwkKludge['@MSGID']; // WhenWritten contains a ISO-8601-ish timestamp and a Synchronet/SMB style TZ offset: // 20180101174837-0600 4168 @@ -640,7 +689,7 @@ class QWKPacketReader extends EventEmitter { } if (ext.Tags) { - currMessage.hashTags = ext.Tags.split(' '); + currMessage.hashTags = (ext.Tags).toString().split(' '); } // FTN style properties/kludges represented as X-FTN-XXXX @@ -668,6 +717,9 @@ class QWKPacketReader extends EventEmitter { hashTags : currMessage.hashTags, }); + // Indicate this message was imported from a QWK packet + message.meta.System[Message.SystemMetaNames.ExternalFlavor] = Message.AddressFlavor.QWK; + if (!_.isEmpty(qwkKludge)) { message.meta.QwkKludge = qwkKludge; } @@ -692,8 +744,8 @@ class QWKPacketReader extends EventEmitter { } // Update the timestamp if we have a valid TZ - if (useTZKludge && _.has(message, 'meta.QwkKludge.synchronet_timezone')) { - const tzOffset = SMBTZToUTCOffset(message.meta.QwkKludge.synchronet_timezone); + if (useTZKludge && qwkKludge['@TZ']) { + const tzOffset = SMBTZToUTCOffset[qwkKludge['@TZ']]; if (tzOffset) { message.modTimestamp.utcOffset(tzOffset); } @@ -704,7 +756,7 @@ class QWKPacketReader extends EventEmitter { qwk_in_reply_to_num : currMessage.header.replyToNum, }; - if (this.mode === QWKPacketReader.Modes.QWK) { + if (this.options.mode === QWKPacketReader.Modes.QWK) { message.meta.QwkProperty.qwk_msg_num = currMessage.header.num; message.meta.QwkProperty.qwk_conf_num = currMessage.header.confNum; } else { @@ -735,7 +787,379 @@ class QWKPacketReader extends EventEmitter { } }; +class QWKPacketWriter extends EventEmitter { + constructor( + { + enableQWKE = true, + enableHeadersExtension = true, + enableAtKludges = true, + encoding = 'cp437', + systemDomain = 'enigma-bbs', + bbsID = '', + toUser = '', + } = QWKPacketWriter.DefaultOptions) + { + super(); + + this.options = { + enableQWKE, + enableHeadersExtension, + enableAtKludges, + systemDomain, + bbsID, + toUser, + encoding : encoding.toLowerCase(), + }; + + this.temptmp = temptmp.createTrackedSession('qwkpacketwriter'); + } + + static get DefaultOptions() { + return { + enableQWKE : true, + enableHeadersExtension : true, + enableAtKludges : true, + encoding : 'cp437', + systemDomain : 'enigma-bbs', + bbsID : '', + toUser : '', + }; + } + + init() { + async.series( + [ + (callback) => { + return StatLog.init(callback); + }, + (callback) => { + this.temptmp.mkdir( { prefix : 'enigqwkwriter-'}, (err, workDir) => { + this.workDir = workDir; + return callback(err); + }); + }, + (callback) => { + this.messagesStream = fs.createWriteStream(paths.join(this.workDir, 'messages.dat')); + + if (this.options.enableHeadersExtension) { + this.headersDatStream = fs.createWriteStream(paths.join(this.workDir, 'headers.dat')); + } + + // First block is a space padded ID + const id = `Created with ENiGMA 1/2 BBS v${enigmaVersion} Copyright (c) 2015-2020 Bryan Ashby`; + this.messagesStream.write(id.padEnd(QWKMessageBlockSize, ' '), 'ascii'); + this.currentMessageOffset = QWKMessageBlockSize; + + this.totalMessages = 0; + this.areaTagsSeen = new Set(); + + return callback(null); + }, + ], + err => { + if (err) { + return this.emit('error', err); + } + + this.emit('ready'); + } + ) + } + + makeMessageIdentifier(message) { + return `<${message.messageId}.${message.messageUuid}@${this.options.systemDomain}>`; + } + + appendMessage(message) { + // + // Each message has to: + // - Append to MESSAGES.DAT + // - Append to HEADERS.DAT if enabled + // + // If this is a personal (ie: non-network) packet: + // - Produce PERSONAL.NDX + // - Produce 000.NDX with pointers to the users personal "inbox" mail + // - Produce ####.NDX with pointers to the public/conference mail + // - Produce TOREADER.EXT if QWKE support is enabled + // + + let fullMessageBody = ''; + + // Start of body is kludges if enabled + if (this.options.enableQWKE) { + if (message.toUserName.length > 25) { + fullMessageBody += `To: ${message.toUserName}\n`; + } + if (message.fromUserName.length > 25) { + fullMessageBody += `From: ${message.fromUserName}\n`; + } + if (message.subject.length > 25) { + fullMessageBody += `Subject: ${message.subject}\n`; + } + } + + if (this.options.enableAtKludges) { + // Add in original kludges (perhaps in a different order) if + // they were originally imported + if (Message.AddressFlavor.QWK == message.meta.System[Message.SystemMetaNames.ExternalFlavor]) { + if (message.meta.QwkKludge) { + for (let [kludge, value] of Object.entries(message.meta.QwkKludge)) { + fullMessageBody += `${kludge}: ${value}\n`; + }; + } + } else { + fullMessageBody += `@MSGID: ${this.makeMessageIdentifier(message)}\n`; + } + } + + // The actual message contents + fullMessageBody += message.message; + + // :TODO: sanitize line feeds -> \n ???? + + // splitTextAtTerms(message.message).forEach(line => { + // appendBodyLine(line); + // }); + + const encodedMessage = iconv.encode(fullMessageBody, this.options.encoding); + + // + // QWK spec wants line feeds as 0xe3 for some reason, so we'll have + // to replace the \n's. If we're going against the spec and using UTF-8 + // we can just leave them be. + // + if ('utf8' !== this.options.encoding) { + replaceCharInBuffer(encodedMessage, 0x0a, QWKLF); + } + + // Messages must comprise of multiples of 128 bit blocks with the last + // block padded by spaces or nulls (we use nulls) + const fullBlocks = Math.trunc(encodedMessage.length / QWKMessageBlockSize); + const remainBytes = QWKMessageBlockSize - (encodedMessage.length % QWKMessageBlockSize); + + // The first block is always a header + this._writeMessageHeader( + message, + fullBlocks + 1 + (remainBytes ? 1 : 0), + ); + + this.messagesStream.write(encodedMessage); + + + if (remainBytes) { + this.messagesStream.write(Buffer.alloc(remainBytes, 0x00)); + } + + if (this.options.enableHeadersExtension) { + this._appendHeadersExtensionData(message); + } + + this.currentMessageOffset += fullBlocks * QWKMessageBlockSize; + + if (remainBytes) + { + this.currentMessageOffset += QWKMessageBlockSize; + } + + this.totalMessages += 1; + this.areaTagsSeen.add(message.areaTag); + } + + appendNewFile() { + + } + + finish(packetPath) { + async.series( + [ + (callback) => { + this.messagesStream.on('close', () => { + return callback(null); + }); + this.messagesStream.end(); + }, + (callback) => { + if (!this.headersDatStream) { + return callback(null); + } + this.headersDatStream.on('close', () => { + return callback(null); + }); + this.headersDatStream.end(); + }, + (callback) => { + return this._createControlData(callback); + } + ], + err => { + this.temptmp.cleanup(); + + if (err) { + return this.emit('error', err); + } + + this.emit('finished'); + } + ) + } + + _writeMessageHeader(message, totalBlocks) { + const asciiNum = (n, l) => { + if (isNaN(n)) { + return ''; + } + return n.toString().substr(0, l); + }; + + const status = 'FIXME'; + const totalBlocksStr = asciiNum(totalBlocks, 6);//totalBlocks.toString().padEnd(6, ' '); + const messageStatus = 255; // :TODO: ever anything different? + const confNumber = 1004; // :TODO: areaTag -> conf mapping + const netTag = ' '; // :TODO: + + if (totalBlocksStr.length > 6) { + return this.emit('warning', Errors.General('Message too large for packet'), message); + } + + const header = Buffer.alloc(QWKMessageBlockSize, ' '); + header.write(status[0], 0, 1, 'ascii'); + header.write(asciiNum(message.messageId), 1, 'ascii'); // :TODO: It seems Sync puts the relative, as in # of messages we've called appendMessage()?! + header.write(message.modTimestamp.format(QWKHeaderTimestampFormat), 8, 13, 'ascii'); + header.write(message.toUserName.substr(0, 25), 21, 'ascii'); + header.write(message.fromUserName.substr(0, 25), 46, 'ascii'); + header.write(message.subject.substr(0, 25), 71, 'ascii'); + header.write(' '.repeat(12), 96, 'ascii'); // we don't use the password field + header.write(asciiNum(message.replyToMsgId), 108, 'ascii'); + header.write(asciiNum(totalBlocks, 6), 116, 'ascii'); + header.writeUInt8(messageStatus, 122); + header.writeUInt16LE(confNumber, 123); + header.writeUInt16LE(0, 125); // :TODO: Check if others actually populate this + header.write(netTag[0], 127, 1, 'ascii'); + + this.messagesStream.write(header); + } + + _createControlData(cb) { + const controlStream = fs.createWriteStream(paths.join(this.workDir, 'control.dat')); + controlStream.setDefaultEncoding('ascii'); + + controlStream.on('close', () => { + return cb(null); + }); + + controlStream.on('error', err => { + return cb(err); + }); + + const controlData = [ + Config().general.boardName, + 'Earth', + 'XXX-XXX-XXX', + `${StatLog.getSystemStat(SysProps.SysOpUsername)}, Sysop`, + `0000,${this.options.bbsID}`, + moment().format('MM-DD-YYYY,HH:mm:ss'), + this.options.toUser, + '', // name of Qmail menu + '0', // uh, OK + this.totalMessages.toString(), + // this next line is total conferences - 1: + // We have areaTag <> conference mapping, so the number should work out + (this.areaTagsSeen.size - 1).toString(), + + // :TODO: append all areaTag->conf number/IDs and names (13 chars max) + '0', 'First Conf', + 'HELLO', + 'BBSNEWS', + 'GOODBYE', + ]; + + controlData.forEach(line => { + controlStream.write(`${line}\r\n`); + }); + + controlStream.end(); + } + + _makeSynchronetTimestamp(ts) { + const syncTimestamp = ts.format('YYYYMMDDHHmmssZZ'); + const syncTZ = UTCOffsetToSMBTZ[ts.format('Z')] || '0000'; // :TODO: what if we don't have a map? + return `${syncTimestamp} ${syncTZ}`; + } + + _appendHeadersExtensionData(message) { + const messageData = { + // Synchronet style + Utf8 : ('utf8' === this.options.encoding ? 'true' : 'false'), + 'Message-ID' : this.makeMessageIdentifier(message), + + WhenWritten : this._makeSynchronetTimestamp(message.modTimestamp), + // WhenImported : '', // :TODO: only if we have a imported time from another external system? + ExportedFrom : `${this.options.systemID} ${message.areaTag} ${message.messageId}`, + Sender : message.fromUserName, + + // :TODO: if exporting for QWK-Net style/etc. + //SenderNetAddr + + SenderIpAddr : '127.0.0.1', // no sir, that's private. + SenderHostName : this.options.systemDomain, + // :TODO: if exported: + //SenderProtocol + Organization : 'BBS', + + //'Reply-To' : :TODO: "address to direct replies".... ?! + Subject : message.subject, + To : message.toUserName, + //ToNetAddr : :TODO: net addr to?! + + // :TODO: Only set if not imported: + Tags : message.hashTags.join(' '), + + // :TODO: Needs tested with Sync/etc.; Sync wants Conference *numbers* + Conference : getMessageConfTagByAreaTag(message.areaTag), + + // ENiGMA Headers + MessageUUID : message.uuid, + ModTimestamp : message.modTimestamp.format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + AreaTag : message.areaTag, + }; + + const externalFlavor = message.meta.System[Message.SystemMetaNames.ExternalFlavor]; + if (externalFlavor === Message.AddressFlavor.FTN) { + // Add FTN properties if it came from such an origin + if (message.meta.FtnProperty) { + const ftnProp = message.meta.FtnProperty; + messageData['X-FTN-AREA'] = ftnProp[Message.FtnPropertyNames.FtnArea]; + messageData['X-FTN-SEEN-BY'] = fntProp[Message.FtnPropertyNames.FtnSeenBy]; + } + + if (message.meta.FtnKludge) { + const ftnKludge = message.meta.FtnKludge; + messageData['X-FTN-PATH'] = ftnKludge.PATH; + messageData['X-FTN-MSGID'] = ftnKludge.MSGID; + messageData['X-FTN-REPLY'] = fntKludge.REPLY; + messageData['X-FTN-PID'] = fntKludge.PID; + messageData['X-FTN-FLAGS'] = ftnKludge.FLAGS; + messageData['X-FTN-TID'] = fntKludge.TID; + messageData['X-FTN-CHRS'] = fntKludge.CHRS; + } + } else { + messageData.WhenExported = this._makeSynchronetTimestamp(moment()); + messageData.Editor = `ENiGMA 1/2 BBS FSE v${enigmaVersion}`; + } + + this.headersDatStream.write(iconv.encode(`[${this.currentMessageOffset.toString(16)}]\r\n`, this.options.encoding)); + + for (let [name, value] of Object.entries(messageData)) { + if (value) { + this.headersDatStream.write(iconv.encode(`${name}: ${value}\r\n`, this.options.encoding)); + } + } + + this.headersDatStream.write('\r\n'); + } +} + module.exports = { QWKPacketReader, -// QWKPacketWriter, + QWKPacketWriter, }