mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-07-10 15:08:28 +03:00
Improved email address parsing and handling
This commit is contained in:
parent
e8c93a1d0c
commit
33653eae81
7 changed files with 165 additions and 238 deletions
|
|
@ -4,7 +4,7 @@ import { MessageSetAction, ComposeType/*, FolderType*/ } from 'Common/EnumsUser'
|
|||
import { doc, createElement, elementById, dropdowns, dropdownVisibility, SettingsGet, leftPanelDisabled } from 'Common/Globals';
|
||||
import { plainToHtml } from 'Common/Html';
|
||||
import { getNotification } from 'Common/Translator';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { EmailCollectionModel } from 'Model/EmailCollection';
|
||||
import { MessageModel } from 'Model/Message';
|
||||
import { MessageUserStore } from 'Stores/User/Message';
|
||||
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
||||
|
|
@ -158,27 +158,13 @@ mailToHelper = mailToUrl => {
|
|||
const
|
||||
email = mailToUrl[0],
|
||||
params = new URLSearchParams(mailToUrl[1]),
|
||||
toEmailModel = value => null != value ? EmailModel.parseEmailLine(value) : null;
|
||||
to = params.get('to'),
|
||||
toEmailModel = value => EmailCollectionModel.fromString(value);
|
||||
|
||||
showMessageComposer([
|
||||
ComposeType.Empty,
|
||||
null,
|
||||
params.get('to')
|
||||
? Object.values(
|
||||
toEmailModel(email + ',' + params.get('to')).reduce((result, value) => {
|
||||
if (value) {
|
||||
if (result[value.email]) {
|
||||
if (!result[value.email].name) {
|
||||
result[value.email] = value;
|
||||
}
|
||||
} else {
|
||||
result[value.email] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, {})
|
||||
)
|
||||
: EmailModel.parseEmailLine(email),
|
||||
toEmailModel(to ? email + ',' + to : email),
|
||||
toEmailModel(params.get('cc')),
|
||||
toEmailModel(params.get('bcc')),
|
||||
params.get('subject'),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,29 @@
|
|||
import { doc, createElement, addEventsListeners } from 'Common/Globals';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { EmailModel, addressparser } from 'Model/Email';
|
||||
|
||||
const contentType = 'snappymail/emailaddress',
|
||||
getAddressKey = li => li?.emailaddress?.key;
|
||||
getAddressKey = li => li?.emailaddress?.key,
|
||||
|
||||
parseEmailLine = line => addressparser(line).map(item =>
|
||||
(item.name || item.address)
|
||||
? new EmailModel(item.address, item.name) : null
|
||||
).filter(v => v),
|
||||
splitEmailLine = line => {
|
||||
const result = [];
|
||||
let exists = false;
|
||||
addressparser(line).forEach(item => {
|
||||
const address = (item.name || item.address)
|
||||
? new EmailModel(item.address, item.name)
|
||||
: null;
|
||||
|
||||
if (address?.email) {
|
||||
exists = true;
|
||||
}
|
||||
|
||||
result.push(address ? address.toLine() : item.name);
|
||||
});
|
||||
return exists ? result : null;
|
||||
};
|
||||
|
||||
let dragAddress, datalist;
|
||||
|
||||
|
|
@ -154,8 +175,8 @@ export class EmailAddressesComponent {
|
|||
if (val) {
|
||||
const self = this,
|
||||
v = val.trim(),
|
||||
hook = (v && [',', ';', '\n'].includes(v.slice(-1))) ? EmailModel.splitEmailLine(val) : null,
|
||||
values = (hook || [val]).map(value => EmailModel.parseEmailLine(value))
|
||||
hook = (v && [',', ';', '\n'].includes(v.slice(-1))) ? splitEmailLine(val) : null,
|
||||
values = (hook || [val]).map(value => parseEmailLine(value))
|
||||
.flat(Infinity)
|
||||
.map(item => (item.toLine ? [item.toLine(), item] : [item, null]));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
|
||||
import { ParseMime } from 'Mime/Parser';
|
||||
import { AttachmentModel } from 'Model/Attachment';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { FileInfo } from 'Common/File';
|
||||
import { BEGIN_PGP_MESSAGE } from 'Stores/User/Pgp';
|
||||
|
||||
|
|
@ -17,16 +16,10 @@ export function MimeToMessage(data, message)
|
|||
let html = struct.getByContentType('text/html');
|
||||
html = html ? html.body : '';
|
||||
|
||||
if (struct.headers.subject) {
|
||||
message.subject(struct.headers.subject.value);
|
||||
}
|
||||
['from','to'].forEach(name => {
|
||||
if (struct.headers[name] && !message[name].length) {
|
||||
let mail = new EmailModel;
|
||||
mail.parse(struct.headers[name].value);
|
||||
message[name].push(mail);
|
||||
}
|
||||
});
|
||||
message.subject(struct.headerValue('subject') || '');
|
||||
|
||||
// EmailCollectionModel
|
||||
['from','to'].forEach(name => message[name].fromString(struct.headerValue(name)));
|
||||
|
||||
struct.forEach(part => {
|
||||
let cd = part.header('content-disposition'),
|
||||
|
|
|
|||
|
|
@ -18,32 +18,83 @@ import { AbstractModel } from 'Knoin/AbstractModel';
|
|||
* @param {String} str Address field
|
||||
* @return {Array} An array of address objects
|
||||
*/
|
||||
function addressparser(str) {
|
||||
var tokenizer = new Tokenizer(str);
|
||||
var tokens = tokenizer.tokenize();
|
||||
var addresses = [];
|
||||
var address = [];
|
||||
var parsedAddresses = [];
|
||||
export function addressparser(str) {
|
||||
str = (str || '').toString();
|
||||
|
||||
tokens.forEach(token => {
|
||||
if (token.type === 'operator' && (token.value === ',' || token.value === ';')) {
|
||||
address.length && addresses.push(address);
|
||||
let
|
||||
endOperator = '',
|
||||
node = {
|
||||
type: 'text',
|
||||
value: ''
|
||||
},
|
||||
escaped = false,
|
||||
address = [],
|
||||
addresses = [];
|
||||
|
||||
const
|
||||
/*
|
||||
* Operator tokens and which tokens are expected to end the sequence
|
||||
*/
|
||||
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.
|
||||
';': ''
|
||||
},
|
||||
pushToken = token => {
|
||||
token.value = (token.value || '').toString().trim();
|
||||
token.value.length && address.push(token);
|
||||
node = {
|
||||
type: 'text',
|
||||
value: ''
|
||||
},
|
||||
escaped = false;
|
||||
},
|
||||
pushAddress = () => {
|
||||
if (address.length) {
|
||||
address = _handleAddress(address);
|
||||
if (address.length) {
|
||||
addresses = addresses.concat(address);
|
||||
}
|
||||
}
|
||||
address = [];
|
||||
};
|
||||
|
||||
[...str].forEach(chr => {
|
||||
if (!escaped && (chr === endOperator || (!endOperator && chr in OPERATORS))) {
|
||||
pushToken(node);
|
||||
if (',' === chr || ';' === chr) {
|
||||
pushAddress();
|
||||
} else {
|
||||
endOperator = endOperator ? '' : OPERATORS[chr];
|
||||
if ('<' === chr) {
|
||||
node.type = 'address';
|
||||
} else if ('(' === chr) {
|
||||
node.type = 'comment';
|
||||
} else if (':' === chr) {
|
||||
node.type = 'group';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
address.push(token);
|
||||
node.value += chr;
|
||||
escaped = !escaped && '\\' === chr;
|
||||
}
|
||||
});
|
||||
pushToken(node);
|
||||
|
||||
address.length && addresses.push(address);
|
||||
pushAddress();
|
||||
|
||||
addresses.forEach(address => {
|
||||
address = _handleAddress(address);
|
||||
if (address.length) {
|
||||
parsedAddresses = parsedAddresses.concat(address);
|
||||
}
|
||||
});
|
||||
|
||||
return parsedAddresses;
|
||||
return addresses;
|
||||
// return addresses.map(item => (item.name || item.address) ? new EmailModel(item.address, item.name) : null).filter(v => v);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -53,37 +104,21 @@ function addressparser(str) {
|
|||
* @return {Object} Address object
|
||||
*/
|
||||
function _handleAddress(tokens) {
|
||||
var isGroup = false;
|
||||
var state = 'text';
|
||||
var address = void 0;
|
||||
var addresses = [];
|
||||
var data = {
|
||||
address: [],
|
||||
comment: [],
|
||||
group: [],
|
||||
text: []
|
||||
};
|
||||
let
|
||||
isGroup = false,
|
||||
address = {},
|
||||
addresses = [],
|
||||
data = {
|
||||
address: [],
|
||||
comment: [],
|
||||
group: [],
|
||||
text: []
|
||||
};
|
||||
|
||||
// Filter out <addresses>, (comments) and regular text
|
||||
tokens.forEach(token => {
|
||||
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);
|
||||
}
|
||||
isGroup = isGroup || 'group' === token.type;
|
||||
data[token.type].push(token.value);
|
||||
});
|
||||
|
||||
// If there is no text but a comment, replace the two
|
||||
|
|
@ -94,10 +129,11 @@ function _handleAddress(tokens) {
|
|||
|
||||
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 ? addressparser(data.group.join(',')) : []
|
||||
address: '',
|
||||
name: data.text.join(' ').trim(),
|
||||
group: addressparser(data.group.join(','))
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
});
|
||||
} else {
|
||||
// If no address was found, try to detect one from regular text
|
||||
|
|
@ -128,7 +164,7 @@ function _handleAddress(tokens) {
|
|||
}
|
||||
}
|
||||
|
||||
// If there's still is no text but a comment exixts, replace the two
|
||||
// If there's still is no text but a comment exists, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
|
|
@ -139,133 +175,40 @@ function _handleAddress(tokens) {
|
|||
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 [];
|
||||
}
|
||||
address = {
|
||||
address: data.address || data.text || '',
|
||||
name: data.text || data.address || ''
|
||||
// Join values with spaces
|
||||
address: data.address.join(' ').trim(),
|
||||
name: data.text.join(' ').trim()
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
};
|
||||
|
||||
if (address.address === address.name) {
|
||||
if ((address.address || '').match(/@/)) {
|
||||
if (address.address.includes('@')) {
|
||||
address.name = '';
|
||||
} else {
|
||||
address.address = '';
|
||||
}
|
||||
}
|
||||
|
||||
// address.address = address.address.replace(/^[<]+(.*)[>]+$/g, '$1');
|
||||
|
||||
addresses.push(address);
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/*
|
||||
* Operator tokens and which tokens are expected to end the sequence
|
||||
*/
|
||||
var 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.
|
||||
';': ''
|
||||
};
|
||||
|
||||
class Tokenizer
|
||||
{
|
||||
constructor(str) {
|
||||
this.str = (str || '').toString();
|
||||
this.operatorCurrent = '';
|
||||
this.operatorExpecting = '';
|
||||
this.node = null;
|
||||
this.escaped = false;
|
||||
this.list = [];
|
||||
}
|
||||
|
||||
tokenize() {
|
||||
var list = [];
|
||||
[...this.str].forEach(c => this.checkChar(c));
|
||||
|
||||
this.list.forEach(node => {
|
||||
node.value = (node.value || '').toString().trim();
|
||||
node.value && list.push(node);
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailModel extends AbstractModel {
|
||||
/**
|
||||
* @param {string=} email = ''
|
||||
* @param {string=} name = ''
|
||||
* @param {string=} dkimStatus = 'none'
|
||||
*/
|
||||
constructor(email = '', name = '', dkimStatus = 'none') {
|
||||
constructor(email, name, dkimStatus = 'none') {
|
||||
super();
|
||||
this.email = email;
|
||||
this.name = name;
|
||||
this.email = email || '';
|
||||
this.name = name || '';
|
||||
this.dkimStatus = dkimStatus;
|
||||
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
|
|
@ -277,7 +220,7 @@ export class EmailModel extends AbstractModel {
|
|||
static reviveFromJson(json) {
|
||||
const email = super.reviveFromJson(json);
|
||||
email?.cleanup();
|
||||
return email;
|
||||
return email?.validate() ? email : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -341,45 +284,4 @@ export class EmailModel extends AbstractModel {
|
|||
}
|
||||
return result || name;
|
||||
}
|
||||
|
||||
static splitEmailLine(line) {
|
||||
const result = [];
|
||||
let exists = false;
|
||||
addressparser(line).forEach(item => {
|
||||
const address = item.address
|
||||
? new EmailModel(item.address.replace(/^[<]+(.*)[>]+$/g, '$1'), item.name || '')
|
||||
: null;
|
||||
|
||||
if (address?.email) {
|
||||
exists = true;
|
||||
}
|
||||
|
||||
result.push(address ? address.toLine() : item.name);
|
||||
});
|
||||
return exists ? result : null;
|
||||
}
|
||||
|
||||
static parseEmailLine(line) {
|
||||
return addressparser(line).map(item =>
|
||||
item.address ? new EmailModel(item.address.replace(/^[<]+(.*)[>]+$/g, '$1'), item.name || '') : null
|
||||
).filter(v => v);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} emailAddress
|
||||
* @returns {boolean}
|
||||
*/
|
||||
parse(emailAddress) {
|
||||
emailAddress = emailAddress.trim();
|
||||
if (emailAddress) {
|
||||
const result = addressparser(emailAddress);
|
||||
if (result.length) {
|
||||
this.name = result[0].name || '';
|
||||
this.email = result[0].address || '';
|
||||
this.cleanup();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { EmailModel, addressparser } from 'Model/Email';
|
||||
import { forEachObjectValue } from 'Common/Utils';
|
||||
|
||||
'use strict';
|
||||
|
||||
|
|
@ -13,6 +14,16 @@ export class EmailCollectionModel extends AbstractCollectionModel
|
|||
return super.reviveFromJson(items, email => EmailModel.reviveFromJson(email));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {EmailCollectionModel}
|
||||
*/
|
||||
static fromString(str) {
|
||||
let list = new this();
|
||||
list.fromString(str);
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean=} friendlyView = false
|
||||
* @param {boolean=} wrapWithLink = false
|
||||
|
|
@ -21,4 +32,23 @@ export class EmailCollectionModel extends AbstractCollectionModel
|
|||
toString(friendlyView, wrapWithLink) {
|
||||
return this.map(email => email.toLine(friendlyView, wrapWithLink)).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
fromString(str) {
|
||||
if (str) {
|
||||
let items = {}, key;
|
||||
addressparser(str).forEach(item => {
|
||||
item = new EmailModel(item.address, item.name);
|
||||
// Make them unique
|
||||
key = item.email || item.name;
|
||||
if (key && (item.name || !items[key])) {
|
||||
items[key] = item;
|
||||
}
|
||||
});
|
||||
forEachObjectValue(items, item => this.push(item));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
|||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { ComposeAttachmentModel } from 'Model/ComposeAttachment';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { EmailModel, addressparser } from 'Model/Email';
|
||||
|
||||
import { decorateKoCommands, showScreenPopup } from 'Knoin/Knoin';
|
||||
import { AbstractViewPopup } from 'Knoin/AbstractViews';
|
||||
|
|
@ -59,12 +59,7 @@ const
|
|||
|
||||
base64_encode = text => btoa(unescape(encodeURIComponent(text))).match(/.{1,76}/g).join('\r\n'),
|
||||
|
||||
email = new EmailModel(),
|
||||
getEmail = value => {
|
||||
email.clear();
|
||||
email.parse(value.trim());
|
||||
return email.email || false;
|
||||
},
|
||||
getEmail = value => addressparser(value)[0]?.address || false,
|
||||
|
||||
/**
|
||||
* @param {Array} aList
|
||||
|
|
|
|||
|
|
@ -551,7 +551,7 @@ class ServiceActions
|
|||
{
|
||||
$this->oHttp->ServerNoCache();
|
||||
$sTo = \trim($_GET['to'] ?? '');
|
||||
if (!empty($sTo) && \preg_match('/^mailto:/i', $sTo)) {
|
||||
if (\preg_match('/^mailto:/i', $sTo)) {
|
||||
\SnappyMail\Cookies::set(
|
||||
Actions::AUTH_MAILTO_TOKEN_KEY,
|
||||
Utils::EncodeKeyValuesQ(array(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue