rfc822-json
Version:
Parses an RFC-822 message stream (standard email) into JSON and returns a readable stream.
230 lines (205 loc) • 9.69 kB
JavaScript
var Stream = require('stream');
var util = require('util');
// Creates a reusable tokenizer which inherits from
// the Stream module.
function HeaderTokenizer() {
// Call the Stream.Transform constructor
Stream.Transform.call(this);
// Initialize state
this.state = this.states.FIELD_NAME;
this.currentToken = {type: 'field_name', value: ""};
// If someone attaches a new event listener for the 'done' event after
// we are already in the DONE state, then just re-fire the event.
this.on('newListener', function(name, listener) {
if (name === 'done' && this.state === this.states.DONE) {
listener();
}
});
}
// Set up the inheritance
util.inherits(HeaderTokenizer, Stream.Transform);
// Helper object to hold the regular expressions for matching various
// character types.
HeaderTokenizer.prototype.charTypes = {
colon: /\:/,
newline: /\n/,
whitespace: /\s/,
carraigeReturn: /\r/,
printableAscii: /[\x21-\x7e]/
};
HeaderTokenizer.prototype.states = {
// While processing a field name the tokenizer is in this state.
FIELD_NAME: 'FIELD_NAME',
// While processing printable characters in the field body the tokenizer
// is in this state.
FIELD_BODY: 'FIELD_BODY',
// While processing whitespace characters in the field body the
// tokenizer is in this state. All consecutive whitespace characters are
// collapsed into a single space.
WHITESPACE: 'WHITESPACE',
// While processing a <CR> in the field body the tokenizer is in this
// state.
CARRAIGE_RETURN: 'CARRAIGE_RETURN',
// While processing stuff after a <CR><LF> the tokenizer is in this
// state. Note that header field bodies are allowed to "fold," meaning
// that they can carry onto multiple lines as long as the "folded over"
// portion is indented with at least one linear white space character.
CRLF: 'CRLF',
// While processing stuff after a <CR><LF><CR> the tokenizer is in this
// state. Significant because the tokenizer expects a <LF> and will emit
// an error if it receives anything else.
CRLF_CR: 'CRLF_CR',
// After consuming an empty line, the tokenizer emits a "done" event and
// transitions to this trap state. TODO: deprecate the "done" event
// since the message parser will probably never even send the header
// tokenier the require CRLFCRLF characters required to get to this done
// state.
DONE: 'DONE'
};
// Implements the Stream.Transform._transform method.
HeaderTokenizer.prototype._transform = function(chunk, encoding, callback) {
var str = chunk.toString('binary');
this.tokenize(str);
callback();
};
HeaderTokenizer.prototype._flush = function(callback) {
// Assert: we are done consuming the readable stream. Emit the current
// token. If we have already entered the DONE state then do not emit
// anything. We do this check because there are two cases when we want
// to emit the 'done' event:
// 1. After encountering <CR><LF><CR><LF> sequence in an email message.
// When this happens, the HeaderTokenizer immediately enters the DONE
// state.
// 2. After encountering the end of a stream in an email message which
// contained no message body (and thus potentially no <CR><LF><CR><LF>
// sequence).
if (this.state !== this.states.DONE) {
this.state = this.states.DONE;
this.currentToken.value = this.currentToken.value.trim();
this.emit('token', this.currentToken);
this.emit('done');
callback();
}
};
// The tokenize function simply takes in a string and feeds each character
// to the HeaderTokenizer#receiveChar function.
HeaderTokenizer.prototype.tokenize = function(str) {
for (var i = 0; i < str.length; i++) {
this.receiveChar(str[i]);
}
};
// This function is the heart of the HeaderTokenizer class. It consumes
// the characters of an email message sequentially, emitting
// "token" events when it has finished tokenizing header field names and
// header field bodies. The "token" events have a
// header token object of the form `{type:'field_body', value:'blah'}` as
// the argument for their listener functions.
HeaderTokenizer.prototype.receiveChar = function(ch) {
var states = this.states; // Cache the states
switch(this.state) {
case this.states.FIELD_NAME:
if (this.charTypes.colon.test(ch)) {
// Colon delimits fieldName:fieldBody. Emit the current
// field name token and set up the new field body token
this.emit('token', this.currentToken);
this.currentToken = {type: 'field_body', value: ""};
this.state = states.FIELD_BODY;
} else if (this.charTypes.printableAscii.test(ch)) {
// Append to the current field name
this.currentToken.value += ch;
} else {
// Whitespace, or other non-printable ascii characters are
// not allowed in the field names
this.emit('error', new Error('Unexpected character "' + ch + '" while parsing field name.'));
}
break;
case this.states.FIELD_BODY:
if (this.charTypes.printableAscii.test(ch)) {
this.currentToken.value += ch;
} else if (this.charTypes.carraigeReturn.test(ch)) {
this.state = states.CARRAIGE_RETURN;
} else if (this.charTypes.whitespace.test(ch)) {
this.currentToken.value += " ";
this.state = states.WHITESPACE;
} else {
this.emit('error', new Error('Unexpected character "' + ch + '" while parsing field body.'));
}
break;
case this.states.WHITESPACE:
if (this.charTypes.printableAscii.test(ch)) {
this.currentToken.value += ch;
this.state = states.FIELD_BODY;
} else if (this.charTypes.carraigeReturn.test(ch)) {
this.state = states.CARRAIGE_RETURN;
} else if (this.charTypes.whitespace.test(ch)) {
// Do nothing; just eat more whitespace
} else {
this.emit('error', new Error('Unexpected character "' + ch + '" found while parsing field body.'));
}
break;
case this.states.CARRAIGE_RETURN:
if (this.charTypes.newline.test(ch)) {
this.state = states.CRLF;
} else if (this.charTypes.whitespace.test(ch)) {
this.currentToken.value += " ";
this.state = states.WHITESPACE;
} else if (this.charTypes.printableAscii.test(ch)) {
// Push a space to account for the CR we read previously
this.currentToken.value += " ";
this.currentToken.value += ch;
this.state = states.FIELD_BODY;
} else {
this.emit('error', new Error('Unexpected character "' + ch + '" found while parsing field body.'));
}
break;
case this.states.CRLF:
if (this.charTypes.printableAscii.test(ch)) {
// A printable character on the very next line means we just
// finished the field body of one header and are now parsing
// the field name of a new header. Trim any whitespace from
// the beginning/end of the field body and emit.
this.currentToken.value = this.currentToken.value.trim();
this.emit('token', this.currentToken);
this.state = states.FIELD_NAME;
this.currentToken = {type: 'field_name', value: ch};
} else if (this.charTypes.carraigeReturn.test(ch)) {
this.state = states.CRLF_CR;
} else if (this.charTypes.whitespace.test(ch)) {
// This is just field body folding
this.currentToken.value += " ";
this.state = states.WHITESPACE;
} else {
this.emit('error', new Error('Unexpected character "' + ch + '" found while parsing field body.'));
}
break;
case this.states.CRLF_CR:
if (this.charTypes.newline.test(ch)) {
// We have already seen <CR><LF><CR>. Another <LF> means
// this is the empty line separating header fields from the
// message body. Emit the final field body token and a done
// event.
this.currentToken.value = this.currentToken.value.trim();
this.emit('token', this.currentToken);
this.emit('done');
this.state = states.DONE;
} else if (this.charTypes.whitespace.test(ch)) {
this.currentToken.value += " ";
this.state = states.WHITESPACE;
} else if (this.charTypes.printableAscii.test(ch)) {
// This is just field body folding
this.currentToken.value += " ";
this.currentToken.value += ch;
this.state = states.FIELD_BODY;
} else {
this.emit('error', new Error('Unexpected character "' + ch + '" found while parsing field body.'));
}
break;
case this.states.DONE:
// Trap state
break;
default:
// Should never get here, but in case we do...
this.emit('error', new Error('Unknown state "' + this.state + '"'));
}
};
module.exports = HeaderTokenizer;