@hola.org/emailjs-addressparser
Version:
Parse rfc2822 address fields
282 lines (252 loc) • 6.64 kB
JavaScript
/**
* Parses structured e-mail addresses from an address field
*
* Example:
*
* "Name <address@domain>"
*
* will be converted to
*
* [{name: "Name", address: "address@domain"}]
*
* @param {String} str Address field
* @return {Array} An array of address objects
*/
export default function parse (str) {
const tokenizer = new Tokenizer(str)
const tokens = tokenizer.tokenize()
const addresses = []
let address = []
let parsedAddresses = []
tokens.forEach(function (token) {
if (token.type === 'operator' && (token.value === ',' || token.value === ';')) {
if (address.length) {
addresses.push(address)
}
address = []
} else {
address.push(token)
}
})
if (address.length) {
addresses.push(address)
}
addresses.forEach(function (address) {
address = _handleAddress(address)
if (address.length) {
parsedAddresses = parsedAddresses.concat(address)
}
})
return parsedAddresses
};
/**
* Converts tokens for a single address into an address object
*
* @param {Array} tokens Tokens object
* @return {Object} Address object
*/
function _handleAddress (tokens) {
let isGroup = false
let state = 'text'
let address
const addresses = []
const data = {
address: [],
comment: [],
group: [],
text: []
}
// Filter out <addresses>, (comments) and regular text
for (let i = 0, len = tokens.length; i < len; i++) {
const token = tokens[i]
if (token.type === 'operator') {
switch (token.value) {
case '<':
state = 'address'
break
case '(':
state = 'comment'
break
case ':':
state = 'group'
isGroup = true
break
default:
state = 'text'
}
} else {
if (token.value) {
data[state].push(token.value)
}
}
}
// If there is no text but a comment, replace the two
if (!data.text.length && data.comment.length) {
data.text = data.comment
data.comment = []
}
if (isGroup) {
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
data.text = data.text.join(' ')
addresses.push({
name: data.text || (address && address.name),
group: data.group.length ? parse(data.group.join(',')) : []
})
} else {
// If no address was found, try to detect one from regular text
if (!data.address.length && data.text.length) {
for (let i = data.text.length - 1; i >= 0; i--) {
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
data.address = data.text.splice(i, 1)
break
}
}
var _regexHandler = function (address) {
if (!data.address.length) {
data.address = [address.trim()]
return ' '
} else {
return address
}
}
// still no address
if (!data.address.length) {
for (let i = data.text.length - 1; i >= 0; i--) {
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, _regexHandler).trim()
if (data.address.length) {
break
}
}
}
}
// If there's still is no text but a comment exixts, replace the two
if (!data.text.length && data.comment.length) {
data.text = data.comment
data.comment = []
}
// Keep only the first address occurence, push others to regular text
if (data.address.length > 1) {
data.text = data.text.concat(data.address.splice(1))
}
// Join values with spaces
data.text = data.text.join(' ')
data.address = data.address.join(' ')
if (!data.address && isGroup) {
return []
} else {
address = {
address: data.address || data.text || '',
name: data.text || data.address || ''
}
if (address.address === address.name) {
if ((address.address || '').match(/@/)) {
address.name = ''
} else {
address.address = ''
}
}
addresses.push(address)
}
}
return addresses
};
/*
* Operator tokens and which tokens are expected to end the sequence
*/
const OPERATORS = {
'"': '"',
'(': ')',
'<': '>',
',': '',
// Groups are ended by semicolons
':': ';',
// Semicolons are not a legal delimiter per the RFC2822 grammar other
// than for terminating a group, but they are also not valid for any
// other use in this context. Given that some mail clients have
// historically allowed the semicolon as a delimiter equivalent to the
// comma in their UI, it makes sense to treat them the same as a comma
// when used outside of a group.
';': ''
}
/**
* Creates a Tokenizer object for tokenizing address field strings
*
* @constructor
* @param {String} str Address field string
*/
class Tokenizer {
constructor (str) {
this.str = (str || '').toString()
this.operatorCurrent = ''
this.operatorExpecting = ''
this.node = null
this.escaped = false
this.list = []
}
/**
* Tokenizes the original input string
*
* @return {Array} An array of operator|text tokens
*/
tokenize () {
let chr
let list = []
for (var i = 0, len = this.str.length; i < len; i++) {
chr = this.str.charAt(i)
this.checkChar(chr)
}
this.list.forEach(function (node) {
node.value = (node.value || '').toString().trim()
if (node.value) {
list.push(node)
}
})
return list
}
/**
* Checks if a character is an operator or text and acts accordingly
*
* @param {String} chr Character from the address field
*/
checkChar (chr) {
if ((chr in OPERATORS || chr === '\\') && this.escaped) {
this.escaped = false
} else if (this.operatorExpecting && chr === this.operatorExpecting) {
this.node = {
type: 'operator',
value: chr
}
this.list.push(this.node)
this.node = null
this.operatorExpecting = ''
this.escaped = false
return
} else if (!this.operatorExpecting && chr in OPERATORS) {
this.node = {
type: 'operator',
value: chr
}
this.list.push(this.node)
this.node = null
this.operatorExpecting = OPERATORS[chr]
this.escaped = false
return
}
if (!this.escaped && chr === '\\') {
this.escaped = true
return
}
if (!this.node) {
this.node = {
type: 'text',
value: ''
}
this.list.push(this.node)
}
if (this.escaped && chr !== '\\') {
this.node.value += '\\'
}
this.node.value += chr
this.escaped = false
}
}