mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-08-26 02:29:20 +03:00
Merge branch 'upstream/master' into dev/sync-nextcloud-addressbook
This commit is contained in:
commit
957654c746
1528 changed files with 85567 additions and 32765 deletions
|
|
@ -3,6 +3,8 @@ import ko from 'ko';
|
|||
import { logoutLink } from 'Common/Links';
|
||||
import { i18nToNodes, initOnStartOrLangChange } from 'Common/Translator';
|
||||
|
||||
import { arePopupsVisible } from 'Knoin/Knoin';
|
||||
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
import { initThemes } from 'Stores/Theme';
|
||||
|
||||
|
|
@ -18,6 +20,7 @@ export class AbstractApp {
|
|||
}
|
||||
|
||||
logoutReload(url) {
|
||||
arePopupsVisible(false);
|
||||
url = url || logoutLink();
|
||||
if (location.href !== url) {
|
||||
setTimeout(() => location.href = url, 100);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'External/ko';
|
||||
|
||||
import { Settings, SettingsGet } from 'Common/Globals';
|
||||
import { SettingsGet, SettingsAdmin } from 'Common/Globals';
|
||||
import { initThemes } from 'Stores/Theme';
|
||||
|
||||
import Remote from 'Remote/Admin/Fetch';
|
||||
|
|
@ -11,6 +11,8 @@ import { LoginAdminScreen } from 'Screen/Admin/Login';
|
|||
import { startScreens } from 'Knoin/Knoin';
|
||||
import { AbstractApp } from 'App/Abstract';
|
||||
|
||||
import { AskPopupView } from 'View/Popup/Ask';
|
||||
|
||||
export class AdminApp extends AbstractApp {
|
||||
constructor() {
|
||||
super(Remote);
|
||||
|
|
@ -23,7 +25,8 @@ export class AdminApp extends AbstractApp {
|
|||
}
|
||||
|
||||
start() {
|
||||
if (!Settings.app('adminAllowed')) {
|
||||
// if (!Settings.app('adminAllowed')) {
|
||||
if (!SettingsAdmin('allowed')) {
|
||||
rl.route.root();
|
||||
setTimeout(() => location.href = '/', 1);
|
||||
} else if (SettingsGet('Auth')) {
|
||||
|
|
@ -34,3 +37,16 @@ export class AdminApp extends AbstractApp {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
AskPopupView.credentials = function(sAskDesc, btnText) {
|
||||
return new Promise(resolve => {
|
||||
this.showModal([
|
||||
sAskDesc,
|
||||
view => resolve({username:view.username(), password:view.passphrase()}),
|
||||
() => resolve(null),
|
||||
true,
|
||||
3,
|
||||
btnText
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
124
dev/App/User.js
124
dev/App/User.js
|
|
@ -1,8 +1,7 @@
|
|||
import 'External/User/ko';
|
||||
|
||||
import { SMAudio } from 'Common/Audio';
|
||||
import { isArray, pInt } from 'Common/Utils';
|
||||
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility } from 'Common/UtilsUser';
|
||||
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser';
|
||||
|
||||
import {
|
||||
FolderType,
|
||||
|
|
@ -27,15 +26,15 @@ import {
|
|||
getFolderFromCacheList
|
||||
} from 'Common/Cache';
|
||||
|
||||
import { i18n, reloadTime } from 'Common/Translator';
|
||||
import { i18n, reloadTime, getErrorMessage } from 'Common/Translator';
|
||||
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
import { NotificationUserStore } from 'Stores/User/Notification';
|
||||
import { AccountUserStore } from 'Stores/User/Account';
|
||||
import { ContactUserStore } from 'Stores/User/Contact';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
import { PgpUserStore } from 'Stores/User/Pgp';
|
||||
import { SMimeUserStore } from 'Stores/User/SMime';
|
||||
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
||||
import { ThemeStore, initThemes } from 'Stores/Theme';
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
|
|
@ -43,9 +42,6 @@ import { MessageUserStore } from 'Stores/User/Message';
|
|||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { AccountModel } from 'Model/Account';
|
||||
import { IdentityModel } from 'Model/Identity';
|
||||
|
||||
import { LoginUserScreen } from 'Screen/User/Login';
|
||||
import { MailBoxUserScreen } from 'Screen/User/MailBox';
|
||||
import { SettingsUserScreen } from 'Screen/User/Settings';
|
||||
|
|
@ -89,6 +85,10 @@ export class AppUser extends AbstractApp {
|
|||
|
||||
this.folderList = FolderUserStore.folderList;
|
||||
this.messageList = MessagelistUserStore;
|
||||
|
||||
this.ask = AskPopupView;
|
||||
|
||||
this.loadAccountsAndIdentities = loadAccountsAndIdentities;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -113,7 +113,7 @@ export class AppUser extends AbstractApp {
|
|||
case FolderType.Trash:
|
||||
oMoveFolder = getFolderFromCacheList(FolderUserStore.trashFolder());
|
||||
nSetSystemFoldersNotification = iFolderType;
|
||||
bDelete = bDelete || UNUSED_OPTION_VALUE === FolderUserStore.trashFolder()
|
||||
bDelete = bDelete/* || UNUSED_OPTION_VALUE === FolderUserStore.trashFolder()*/
|
||||
|| sFromFolderFullName === FolderUserStore.spamFolder()
|
||||
|| sFromFolderFullName === FolderUserStore.trashFolder();
|
||||
break;
|
||||
|
|
@ -125,9 +125,7 @@ export class AppUser extends AbstractApp {
|
|||
// no default
|
||||
}
|
||||
|
||||
if (!oMoveFolder && !bDelete) {
|
||||
showScreenPopup(FolderSystemPopupView, [nSetSystemFoldersNotification]);
|
||||
} else if (bDelete) {
|
||||
if (bDelete) {
|
||||
showScreenPopup(AskPopupView, [
|
||||
i18n('POPUPS_ASK/DESC_WANT_DELETE_MESSAGES'),
|
||||
() => {
|
||||
|
|
@ -136,34 +134,11 @@ export class AppUser extends AbstractApp {
|
|||
]);
|
||||
} else if (oMoveFolder) {
|
||||
MessagelistUserStore.moveMessages(sFromFolderFullName, oUids, oMoveFolder.fullName);
|
||||
} else {
|
||||
showScreenPopup(FolderSystemPopupView, [nSetSystemFoldersNotification]);
|
||||
}
|
||||
}
|
||||
|
||||
accountsAndIdentities() {
|
||||
AccountUserStore.loading(true);
|
||||
IdentityUserStore.loading(true);
|
||||
|
||||
Remote.request('AccountsAndIdentities', (iError, oData) => {
|
||||
AccountUserStore.loading(false);
|
||||
IdentityUserStore.loading(false);
|
||||
|
||||
if (!iError) {
|
||||
let items = oData.Result.Accounts;
|
||||
AccountUserStore(isArray(items)
|
||||
? items.map(oValue => new AccountModel(oValue.email, oValue.name))
|
||||
: []
|
||||
);
|
||||
AccountUserStore.unshift(new AccountModel(SettingsGet('mainEmail'), '', false));
|
||||
|
||||
items = oData.Result.Identities;
|
||||
IdentityUserStore(isArray(items)
|
||||
? items.map(identityData => IdentityModel.reviveFromJson(identityData))
|
||||
: []
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} folder
|
||||
* @param {Array=} list = []
|
||||
|
|
@ -173,8 +148,10 @@ export class AppUser extends AbstractApp {
|
|||
}
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('register_protocol_offered');
|
||||
Remote.request('Logout', () => rl.logoutReload(Settings.app('customLogoutLink')));
|
||||
Remote.request('Logout', (iError, data) =>
|
||||
iError ? alert('Logout error: ' + getErrorMessage(iError, data))
|
||||
: rl.logoutReload(Settings.app('customLogoutLink'))
|
||||
);
|
||||
}
|
||||
|
||||
bootstart() {
|
||||
|
|
@ -206,19 +183,17 @@ export class AppUser extends AbstractApp {
|
|||
SettingsUserStore.init();
|
||||
ContactUserStore.init();
|
||||
|
||||
loadFolders(value => {
|
||||
loadFolders((success, error) => {
|
||||
try {
|
||||
if (value) {
|
||||
if (success) {
|
||||
startScreens([
|
||||
MailBoxUserScreen,
|
||||
SettingsUserScreen
|
||||
]);
|
||||
|
||||
setRefreshFoldersInterval(pInt(SettingsGet('CheckMailInterval')));
|
||||
setRefreshFoldersInterval(SettingsGet('CheckMailInterval'));
|
||||
|
||||
ContactUserStore.init();
|
||||
|
||||
this.accountsAndIdentities();
|
||||
loadAccountsAndIdentities();
|
||||
|
||||
setTimeout(() => {
|
||||
const cF = FolderUserStore.currentFolderFullName();
|
||||
|
|
@ -247,27 +222,17 @@ export class AppUser extends AbstractApp {
|
|||
setInterval(reloadTime, 60000);
|
||||
|
||||
PgpUserStore.init();
|
||||
SMimeUserStore.loadCertificates();
|
||||
|
||||
setTimeout(() => mailToHelper(SettingsGet('mailToEmail')), 500);
|
||||
|
||||
if (!localStorage.getItem('register_protocol_offered')) {
|
||||
// When auto-login is active
|
||||
navigator.registerProtocolHandler?.(
|
||||
'mailto',
|
||||
location.protocol + '//' + location.host + location.pathname + '?mailto&to=%s',
|
||||
(SettingsGet('title') || 'SnappyMail')
|
||||
);
|
||||
localStorage.setItem('register_protocol_offered', '1');
|
||||
}
|
||||
|
||||
} else {
|
||||
this.logout();
|
||||
alert('Folders error: ' + getErrorMessage(0, error))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
startScreens([LoginUserScreen]);
|
||||
}
|
||||
|
|
@ -278,3 +243,50 @@ export class AppUser extends AbstractApp {
|
|||
showScreenPopup(ComposePopupView, params);
|
||||
}
|
||||
}
|
||||
|
||||
AskPopupView.password = function(sAskDesc, btnText, ask) {
|
||||
return new Promise(resolve => {
|
||||
this.showModal([
|
||||
sAskDesc,
|
||||
view => resolve({
|
||||
password:view.passphrase(),
|
||||
username:/*ask & 2 ? */view.username(),
|
||||
remember:/*ask & 4 ? */view.remember()
|
||||
}),
|
||||
() => resolve(null),
|
||||
true,
|
||||
ask || 1,
|
||||
btnText
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
AskPopupView.cryptkey = () => new Promise(resolve => {
|
||||
const fn = () => AskPopupView.showModal([
|
||||
i18n('CRYPTO/ASK_CRYPTKEY_PASS'),
|
||||
view => {
|
||||
let pass = view.passphrase();
|
||||
if (pass) {
|
||||
Remote.post('ResealCryptKey', null, {
|
||||
passphrase: pass
|
||||
}).then(response => {
|
||||
resolve(response?.Result);
|
||||
}).catch(e => {
|
||||
if (111 === e.code) {
|
||||
fn();
|
||||
} else {
|
||||
console.error(e);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
() => resolve(null),
|
||||
true,
|
||||
1,
|
||||
i18n('CRYPTO/DECRYPT')
|
||||
]);
|
||||
fn();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export const SMAudio = new class {
|
|||
if ('running' == audioCtx.state && (this.supportedMp3 || this.supportedOgg)) {
|
||||
notificator = notificator || createNewObject();
|
||||
if (notificator) {
|
||||
// SettingsGet('NotificationSound').startsWith('custom@')
|
||||
notificator.src = Links.staticLink('sounds/'
|
||||
+ SettingsGet('NotificationSound')
|
||||
+ (this.supportedMp3 ? '.mp3' : '.ogg'));
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ Notifications = {
|
|||
ConnectionError: 104,
|
||||
DomainNotAllowed: 109,
|
||||
AccountNotAllowed: 110,
|
||||
CryptKeyError: 111,
|
||||
|
||||
ContactsSyncError: 140,
|
||||
|
||||
|
|
@ -95,7 +96,6 @@ Notifications = {
|
|||
JsonParse: 952,
|
||||
// JsonTimeout: 953,
|
||||
|
||||
UnknownNotification: 998,
|
||||
UnknownError: 999,
|
||||
|
||||
// Admin
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ MessageSetAction = {
|
|||
SetSeen: 0,
|
||||
UnsetSeen: 1,
|
||||
SetFlag: 2,
|
||||
UnsetFlag: 3
|
||||
UnsetFlag: 3,
|
||||
SetDeleted: 4,
|
||||
UnsetDeleted: 5
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
/* eslint key-spacing: 0 */
|
||||
/* eslint quote-props: 0 */
|
||||
|
||||
import { arrayLength } from 'Common/Utils';
|
||||
import { arrayLength, pInt } from 'Common/Utils';
|
||||
|
||||
export const RFC822 = 'message/rfc822';
|
||||
|
||||
const
|
||||
cache = {},
|
||||
|
|
@ -12,8 +14,8 @@ const
|
|||
lowerCase = text => text.toLowerCase().trim(),
|
||||
|
||||
exts = {
|
||||
eml: 'message/rfc822',
|
||||
mime: 'message/rfc822',
|
||||
eml: RFC822,
|
||||
mime: RFC822,
|
||||
vcard: 'text/vcard',
|
||||
vcf: 'text/vcard',
|
||||
htm: 'text/html',
|
||||
|
|
@ -28,6 +30,8 @@ const
|
|||
p7c: app+'pkcs7-mime',
|
||||
p7m: app+'pkcs7-mime',
|
||||
p7s: app+'pkcs7-signature',
|
||||
p12: app+'pkcs12',
|
||||
pfx: app+'x-pkcs12',
|
||||
torrent: app+'x-bittorrent',
|
||||
|
||||
// scripts
|
||||
|
|
@ -116,7 +120,8 @@ export const FileType = {
|
|||
Spreadsheet: 'spreadsheet',
|
||||
Presentation: 'presentation',
|
||||
Certificate: 'certificate',
|
||||
Archive: 'archive'
|
||||
Archive: 'archive',
|
||||
Calendar: 'calendar'
|
||||
};
|
||||
|
||||
export const FileInfo = {
|
||||
|
|
@ -133,7 +138,7 @@ export const FileInfo = {
|
|||
getContentType: fileName => {
|
||||
fileName = lowerCase(fileName);
|
||||
if ('winmail.dat' === fileName) {
|
||||
return app + 'ms-tnef';
|
||||
return app + 'vnd.ms-tnef';
|
||||
}
|
||||
let ext = fileName.split('.').pop();
|
||||
if (/^(txt|text|def|list|in|ini|log|sql|cfg|conf)$/.test(ext))
|
||||
|
|
@ -161,7 +166,7 @@ export const FileInfo = {
|
|||
*/
|
||||
getType: (ext, mimeType) => {
|
||||
ext = lowerCase(ext);
|
||||
mimeType = lowerCase(mimeType).replace('csv/plain', 'text/csv');
|
||||
mimeType = lowerCase(mimeType).replace('csv/plain', 'text/csv').replace('x-','');
|
||||
|
||||
let key = ext + mimeType;
|
||||
if (cache[key]) {
|
||||
|
|
@ -170,7 +175,7 @@ export const FileInfo = {
|
|||
|
||||
let result = FileType.Unknown;
|
||||
const mimeTypeParts = mimeType.split('/'),
|
||||
type = mimeTypeParts[1].replace('x-','').replace('-compressed',''),
|
||||
type = mimeTypeParts[1].replace('-compressed',''),
|
||||
match = str => mimeType.includes(str),
|
||||
archive = /^(zip|7z|tar|rar|gzip|bzip|bzip2)$/;
|
||||
|
||||
|
|
@ -187,9 +192,12 @@ export const FileInfo = {
|
|||
case ['php', 'js', 'css', 'xml', 'html'].includes(ext) || 'text/html' == mimeType:
|
||||
result = FileType.Code;
|
||||
break;
|
||||
case 'eml' == ext || ['message/delivery-status', 'message/rfc822'].includes(mimeType):
|
||||
case 'eml' == ext || ['message/delivery-status', RFC822].includes(mimeType):
|
||||
result = FileType.Eml;
|
||||
break;
|
||||
case 'ics' == ext || mimeType == 'text/calendar':
|
||||
result = FileType.Calendar;
|
||||
break;
|
||||
case 'text' == mimeTypeParts[0] || 'txt' == ext || 'log' == ext:
|
||||
result = FileType.Text;
|
||||
break;
|
||||
|
|
@ -199,9 +207,8 @@ export const FileInfo = {
|
|||
case 'pdf' == type || 'pdf' == ext:
|
||||
result = FileType.Pdf;
|
||||
break;
|
||||
case [app+'pgp-signature', app+'pgp-keys'].includes(mimeType)
|
||||
|| ['asc', 'pem', 'ppk'].includes(ext)
|
||||
|| [app+'pkcs7-signature'].includes(mimeType) || 'p7s' == ext:
|
||||
case [app+'pgp-signature', app+'pgp-keys', exts.p7m, exts.p7s, exts.p12, exts.pfx].includes(mimeType)
|
||||
|| ['asc', 'pem', 'ppk', 'p7s', 'p7m', 'p12', 'pfx'].includes(ext):
|
||||
result = FileType.Certificate;
|
||||
break;
|
||||
case match(msOffice+'.wordprocessingml') || match(openDoc+'.text') || match('vnd.ms-word')
|
||||
|
|
@ -240,6 +247,7 @@ export const FileInfo = {
|
|||
case FileType.Certificate:
|
||||
case FileType.Spreadsheet:
|
||||
case FileType.Presentation:
|
||||
case FileType.Calendar:
|
||||
return result + '-' + fileType;
|
||||
}
|
||||
return result;
|
||||
|
|
@ -266,8 +274,8 @@ export const FileInfo = {
|
|||
},
|
||||
|
||||
friendlySize: bytes => {
|
||||
bytes = parseInt(bytes, 10) || 0;
|
||||
let i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
bytes = pInt(bytes);
|
||||
let i = bytes ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0;
|
||||
return (bytes / Math.pow(1024, i)).toFixed(2>i ? 0 : 1) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { isArray, arrayLength } from 'Common/Utils';
|
||||
import {
|
||||
getFolderInboxName,
|
||||
getFolderFromCacheList
|
||||
} from 'Common/Cache';
|
||||
import { RFC822 } from 'Common/File';
|
||||
import { getFolderInboxName, getFolderFromCacheList } from 'Common/Cache';
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
import { SettingsGet } from 'Common/Globals';
|
||||
import { isArray, arrayLength, pInt } from 'Common/Utils';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
||||
|
|
@ -10,13 +10,13 @@ import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
|||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
let refreshInterval,
|
||||
// Default every 5 minutes
|
||||
refreshFoldersInterval = 300000;
|
||||
// Default every 15 minutes
|
||||
refreshFoldersInterval = 900000;
|
||||
|
||||
export const
|
||||
|
||||
setRefreshFoldersInterval = minutes => {
|
||||
refreshFoldersInterval = Math.max(5, minutes) * 60000;
|
||||
refreshFoldersInterval = Math.max(1, pInt(SettingsGet('minRefreshInterval')), pInt(minutes)) * 60000;
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = setInterval(() => {
|
||||
const cF = FolderUserStore.currentFolderFullName(),
|
||||
|
|
@ -29,7 +29,7 @@ setRefreshFoldersInterval = minutes => {
|
|||
|
||||
sortFolders = folders => {
|
||||
try {
|
||||
let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
|
||||
let collator = baseCollator(true);
|
||||
folders.sort((a, b) =>
|
||||
a.isInbox() ? -1 : (b.isInbox() ? 1 : collator.compare(a.fullName, b.fullName))
|
||||
);
|
||||
|
|
@ -50,15 +50,14 @@ folderListOptionsBuilder = (
|
|||
aDisabled,
|
||||
aHeaderLines,
|
||||
fRenameCallback,
|
||||
fDisableCallback,
|
||||
bNoSelectSelectable,
|
||||
aList = FolderUserStore.folderList()
|
||||
fDisableCallback
|
||||
) => {
|
||||
const
|
||||
aResult = [],
|
||||
sDeepPrefix = '\u00A0\u00A0\u00A0',
|
||||
// FolderSystemPopupView should always be true
|
||||
showUnsubscribed = fRenameCallback ? !SettingsUserStore.hideUnsubscribed() : true,
|
||||
isDisabled = fDisableCallback || (item => !item.selectable() || aDisabled.includes(item.fullName)),
|
||||
|
||||
foldersWalk = folders => {
|
||||
folders.forEach(oItem => {
|
||||
|
|
@ -69,10 +68,7 @@ folderListOptionsBuilder = (
|
|||
sDeepPrefix.repeat(oItem.deep) +
|
||||
fRenameCallback(oItem),
|
||||
system: false,
|
||||
disabled: !bNoSelectSelectable && (
|
||||
!oItem.selectable() ||
|
||||
aDisabled.includes(oItem.fullName) ||
|
||||
fDisableCallback(oItem))
|
||||
disabled: isDisabled(oItem)
|
||||
});
|
||||
}
|
||||
foldersWalk(oItem.subFolders());
|
||||
|
|
@ -93,7 +89,7 @@ folderListOptionsBuilder = (
|
|||
})
|
||||
);
|
||||
|
||||
foldersWalk(aList);
|
||||
foldersWalk(FolderUserStore.folderList());
|
||||
|
||||
return aResult;
|
||||
},
|
||||
|
|
@ -198,12 +194,12 @@ folderInformationMultiply = (boot = false) => {
|
|||
dropFilesInFolder = (sFolderFullName, files) => {
|
||||
let count = files.length;
|
||||
for (const file of files) {
|
||||
if ('message/rfc822' === file.type) {
|
||||
if (RFC822 === file.type) {
|
||||
let data = new FormData;
|
||||
data.append('folder', sFolderFullName);
|
||||
data.append('appendFile', file);
|
||||
Remote.request('FolderAppend', (iError, data)=>{
|
||||
iError && console.error(data.ErrorMessage);
|
||||
iError && console.error(data.message);
|
||||
0 == --count
|
||||
&& FolderUserStore.currentFolderFullName() == sFolderFullName
|
||||
&& MessagelistUserStore.reload(true, true);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const
|
|||
|
||||
Settings = rl.settings,
|
||||
SettingsGet = Settings.get,
|
||||
SettingsAdmin = name => (SettingsGet('Admin') || {})[name],
|
||||
SettingsCapa = name => name && !!(SettingsGet('Capa') || {})[name],
|
||||
|
||||
dropdowns = [],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createElement } from 'Common/Globals';
|
||||
import { forEachObjectEntry, pInt } from 'Common/Utils';
|
||||
import { forEachObjectEntry, isArray, pInt } from 'Common/Utils';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
const
|
||||
|
|
@ -207,7 +207,9 @@ export const
|
|||
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
|
||||
|
||||
result = {
|
||||
hasExternals: false
|
||||
hasExternals: false,
|
||||
tracking: false,
|
||||
linkedData: []
|
||||
},
|
||||
|
||||
findAttachmentByCid = cId => oAttachments.findByCid(cId),
|
||||
|
|
@ -269,12 +271,15 @@ export const
|
|||
// Not supported by <template> element
|
||||
// .replace(/<!doctype[^>]*>/gi, '')
|
||||
// .replace(/<\?xml[^>]*\?>/gi, '')
|
||||
.replace(/<(\/?)head(\s[^>]*)?>/gi, '')
|
||||
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
|
||||
// .replace(/<\/?(html|head)[^>]*>/gi, '')
|
||||
// Fix Reddit https://github.com/the-djmaze/snappymail/issues/540
|
||||
.replace(/<span class="preview-text"[\s\S]+?<\/span>/, '')
|
||||
// https://github.com/the-djmaze/snappymail/issues/900
|
||||
.replace(/\u2028/g,' ')
|
||||
// https://github.com/the-djmaze/snappymail/issues/1415
|
||||
.replace(/<br>\s*<\/p>/gi,'</p>')
|
||||
.trim();
|
||||
html = '';
|
||||
|
||||
|
|
@ -284,6 +289,21 @@ export const
|
|||
nodeIterator.referenceNode.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic support for Linked Data (Structured Email)
|
||||
* https://json-ld.org/
|
||||
* https://structured.email/
|
||||
**/
|
||||
tmpl.content.querySelectorAll('script[type="application/ld+json"]').forEach(oElement => {
|
||||
// Could be array of objects or single object
|
||||
try {
|
||||
const data = JSON.parse(oElement.textContent);
|
||||
(isArray(data) ? data : [data]).forEach(entry => result.linkedData.push(entry));
|
||||
} catch (e) {
|
||||
console.error(e, oElement.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
tmpl.content.querySelectorAll(
|
||||
disallowedTags
|
||||
+ (0 < bqLevel ? ',' + (new Array(1 + bqLevel).fill('blockquote').join(' ')) : '')
|
||||
|
|
@ -292,6 +312,18 @@ export const
|
|||
// https://github.com/the-djmaze/snappymail/issues/1125
|
||||
tmpl.content.querySelectorAll('form,button').forEach(oElement => replaceWithChildren(oElement));
|
||||
|
||||
// https://github.com/the-djmaze/snappymail/issues/1641
|
||||
let body = tmpl.content.querySelector('.mail-body');
|
||||
[...tmpl.content.querySelectorAll('.mail-body + .mail-body')]
|
||||
.forEach(oElement => body.append(...oElement.childNodes));
|
||||
/*
|
||||
.forEach(oElement => {
|
||||
let bq = createElement('blockquote');
|
||||
bq.append(...oElement.childNodes);
|
||||
body.replaceWith(bq);
|
||||
});
|
||||
*/
|
||||
|
||||
[...tmpl.content.querySelectorAll('*')].forEach(oElement => {
|
||||
const name = oElement.tagName,
|
||||
oStyle = oElement.style;
|
||||
|
|
@ -381,10 +413,14 @@ export const
|
|||
if ('A' === name) {
|
||||
value = oElement.href;
|
||||
if (!/^([a-z]+):/i.test(value)) {
|
||||
setAttribute('data-x-broken-href', value);
|
||||
setAttribute('data-x-href-broken', value);
|
||||
delAttribute('href');
|
||||
} else {
|
||||
oElement.href = stripTracking(value);
|
||||
if (oElement.href != value) {
|
||||
result.tracking = true;
|
||||
setAttribute('data-x-href-tracking', value);
|
||||
}
|
||||
setAttribute('target', '_blank');
|
||||
// setAttribute('rel', 'external nofollow noopener noreferrer');
|
||||
}
|
||||
|
|
@ -406,9 +442,8 @@ export const
|
|||
*/
|
||||
|
||||
let skipStyle = false;
|
||||
if (hasAttribute('src')) {
|
||||
value = stripTracking(delAttribute('src'));
|
||||
|
||||
value = delAttribute('src');
|
||||
if (value) {
|
||||
if ('IMG' === name) {
|
||||
oElement.loading = 'lazy';
|
||||
let attachment;
|
||||
|
|
@ -445,12 +480,18 @@ export const
|
|||
oStyle.display = 'none';
|
||||
// setAttribute('style', 'display:none');
|
||||
setAttribute('data-x-src-hidden', value);
|
||||
// result.tracking = true;
|
||||
}
|
||||
else if (httpre.test(value))
|
||||
{
|
||||
setAttribute('data-x-src', value);
|
||||
let src = stripTracking(value);
|
||||
if (src != value) {
|
||||
result.tracking = true;
|
||||
setAttribute('data-x-src-tracking', value);
|
||||
}
|
||||
setAttribute('data-x-src', src);
|
||||
result.hasExternals = true;
|
||||
oElement.alt || (oElement.alt = value.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
|
||||
oElement.alt || (oElement.alt = src.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
|
||||
}
|
||||
else if (value.startsWith('data:image/'))
|
||||
{
|
||||
|
|
@ -582,6 +623,8 @@ export const
|
|||
html = html
|
||||
.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gim, (...args) =>
|
||||
1 < args.length ? args[1].toString().replace(/\n/g, '<br>') : '')
|
||||
// Remove line duplication
|
||||
.replace(/<br><\/div>/gi, '</div>')
|
||||
.replace(/\r?\n/g, '')
|
||||
.replace(/\s+/gm, ' ');
|
||||
|
||||
|
|
@ -721,184 +764,7 @@ export const
|
|||
.replace(/\n/g, '<br>');
|
||||
blockquoteSwitcher();
|
||||
return tmpl.innerHTML.trim();
|
||||
},
|
||||
|
||||
WYSIWYGS = ko.observableArray();
|
||||
|
||||
WYSIWYGS.push(['Squire', (owner, container, onReady)=>{
|
||||
let squire = new SquireUI(container);
|
||||
setTimeout(()=>onReady(squire), 1);
|
||||
/*
|
||||
squire.on('blur', () => owner.blurTrigger());
|
||||
squire.on('focus', () => clearTimeout(owner.blurTimer));
|
||||
squire.on('mode', () => {
|
||||
owner.blurTrigger();
|
||||
owner.onModeChange?.(!owner.isPlain());
|
||||
});
|
||||
*/
|
||||
}]);
|
||||
|
||||
rl.registerWYSIWYG = (name, construct) => WYSIWYGS.push([name, construct]);
|
||||
|
||||
export class HtmlEditor {
|
||||
/**
|
||||
* @param {Object} element
|
||||
* @param {Function=} onBlur
|
||||
* @param {Function=} onReady
|
||||
* @param {Function=} onModeChange
|
||||
*/
|
||||
constructor(element, onBlur = null, onReady = null, onModeChange = null) {
|
||||
this.blurTimer = 0;
|
||||
|
||||
this.onBlur = onBlur;
|
||||
this.onModeChange = onModeChange;
|
||||
|
||||
if (element) {
|
||||
onReady = onReady ? [onReady] : [];
|
||||
this.onReady = fn => onReady.push(fn);
|
||||
// TODO: make 'which' user configurable
|
||||
// const which = 'CKEditor4',
|
||||
// wysiwyg = WYSIWYGS.find(item => which == item[0]) || WYSIWYGS.find(item => 'Squire' == item[0]);
|
||||
const wysiwyg = WYSIWYGS.find(item => 'Squire' == item[0]);
|
||||
wysiwyg[1](this, element, editor => {
|
||||
this.editor = editor;
|
||||
editor.on('blur', () => this.blurTrigger());
|
||||
editor.on('focus', () => clearTimeout(this.blurTimer));
|
||||
editor.on('mode', () => {
|
||||
this.blurTrigger();
|
||||
this.onModeChange?.(!this.isPlain());
|
||||
});
|
||||
this.onReady = fn => fn();
|
||||
onReady.forEach(fn => fn());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
blurTrigger() {
|
||||
if (this.onBlur) {
|
||||
clearTimeout(this.blurTimer);
|
||||
this.blurTimer = setTimeout(() => this.onBlur?.(), 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isHtml() {
|
||||
return this.editor ? !this.isPlain() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPlain() {
|
||||
return this.editor ? 'plain' === this.editor.mode : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
clearCachedSignature() {
|
||||
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||
clearCache: true
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} signature
|
||||
* @param {bool} html
|
||||
* @param {bool} insertBefore
|
||||
* @returns {void}
|
||||
*/
|
||||
setSignature(signature, html, insertBefore = false) {
|
||||
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||
isHtml: html,
|
||||
insertBefore: insertBefore,
|
||||
signature: signature
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean=} wrapIsHtml = false
|
||||
* @returns {string}
|
||||
*/
|
||||
getData() {
|
||||
let result = '';
|
||||
if (this.editor) {
|
||||
try {
|
||||
if (this.isPlain() && this.editor.plugins.plain && this.editor.__plain) {
|
||||
result = this.editor.__plain.getRawData();
|
||||
} else {
|
||||
result = this.editor.getData();
|
||||
}
|
||||
} catch (e) {} // eslint-disable-line no-empty
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
getDataWithHtmlMark() {
|
||||
return (this.isHtml() ? ':HTML:' : '') + this.getData();
|
||||
}
|
||||
|
||||
modeWysiwyg() {
|
||||
this.onReady(() => this.editor.setMode('wysiwyg'));
|
||||
}
|
||||
modePlain() {
|
||||
this.onReady(() => this.editor.setMode('plain'));
|
||||
}
|
||||
|
||||
setHtmlOrPlain(text) {
|
||||
text.startsWith(':HTML:')
|
||||
? this.setHtml(text.slice(6))
|
||||
: this.setPlain(text);
|
||||
}
|
||||
|
||||
setData(mode, data) {
|
||||
this.onReady(() => {
|
||||
const editor = this.editor;
|
||||
this.clearCachedSignature();
|
||||
try {
|
||||
editor.setMode(mode);
|
||||
if (this.isPlain() && editor.plugins.plain && editor.__plain) {
|
||||
editor.__plain.setRawData(data);
|
||||
} else {
|
||||
editor.setData(data);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
|
||||
setHtml(html) {
|
||||
this.setData('wysiwyg', html/*.replace(/<p[^>]*><\/p>/gi, '')*/);
|
||||
}
|
||||
|
||||
setPlain(txt) {
|
||||
this.setData('plain', txt);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.onReady(() => this.editor.focus());
|
||||
}
|
||||
|
||||
hasFocus() {
|
||||
try {
|
||||
return !!this.editor?.focusManager.hasFocus;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
blur() {
|
||||
this.onReady(() => this.editor.focusManager.blur(true));
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.onReady(() => this.isPlain() ? this.setPlain('') : this.setHtml(''));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rl.Utils = {
|
||||
htmlToPlain: htmlToPlain,
|
||||
|
|
|
|||
161
dev/Common/HtmlEditor.js
Normal file
161
dev/Common/HtmlEditor.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
export const
|
||||
WYSIWYGS = ko.observableArray();
|
||||
|
||||
WYSIWYGS.push({
|
||||
name: 'Squire',
|
||||
construct: (owner, container, onReady) => onReady(new SquireUI(container))
|
||||
});
|
||||
|
||||
rl.registerWYSIWYG = (name, construct) => WYSIWYGS.push({name, construct});
|
||||
|
||||
export class HtmlEditor {
|
||||
/**
|
||||
* @param {Object} element
|
||||
* @param {Function=} onBlur
|
||||
* @param {Function=} onReady
|
||||
* @param {Function=} onModeChange
|
||||
*/
|
||||
constructor(element, onReady = null, onModeChange = null, onBlur = null) {
|
||||
this.blurTimer = 0;
|
||||
|
||||
this.onBlur = onBlur;
|
||||
this.onModeChange = onModeChange;
|
||||
|
||||
if (element) {
|
||||
onReady = onReady ? [onReady] : [];
|
||||
this.onReady = fn => onReady.push(fn);
|
||||
const which = SettingsUserStore.editorWysiwyg(),
|
||||
wysiwyg = WYSIWYGS.find(item => which == item.name) || WYSIWYGS.find(item => 'Squire' == item.name);
|
||||
wysiwyg.construct(this, element, editor => setTimeout(()=>{
|
||||
this.editor = editor;
|
||||
editor.on('blur', () => this.blurTrigger());
|
||||
editor.on('focus', () => clearTimeout(this.blurTimer));
|
||||
editor.on('mode', () => {
|
||||
this.blurTrigger();
|
||||
this.onModeChange?.(!this.isPlain());
|
||||
});
|
||||
this.onReady = fn => fn();
|
||||
onReady.forEach(fn => fn());
|
||||
},1));
|
||||
}
|
||||
}
|
||||
|
||||
blurTrigger() {
|
||||
if (this.onBlur) {
|
||||
clearTimeout(this.blurTimer);
|
||||
this.blurTimer = setTimeout(() => this.onBlur?.(), 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isHtml() {
|
||||
return this.editor ? !this.isPlain() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPlain() {
|
||||
return this.editor ? 'plain' === this.editor.mode : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
clearCachedSignature() {
|
||||
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||
clearCache: true
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} signature
|
||||
* @param {bool} html
|
||||
* @param {bool} insertBefore
|
||||
* @returns {void}
|
||||
*/
|
||||
setSignature(signature, html, insertBefore = false) {
|
||||
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||
isHtml: html,
|
||||
insertBefore: insertBefore,
|
||||
signature: signature
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean=} wrapIsHtml = false
|
||||
* @returns {string}
|
||||
*/
|
||||
getData() {
|
||||
let result = '';
|
||||
if (this.editor) {
|
||||
try {
|
||||
if (this.isPlain()) {
|
||||
result = this.editor.getPlainData();
|
||||
} else {
|
||||
result = this.editor.getData();
|
||||
}
|
||||
} catch (e) {} // eslint-disable-line no-empty
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
getDataWithHtmlMark() {
|
||||
return (this.isHtml() ? ':HTML:' : '') + this.getData();
|
||||
}
|
||||
|
||||
modeWysiwyg() {
|
||||
this.onReady(() => this.editor.setMode('wysiwyg'));
|
||||
}
|
||||
modePlain() {
|
||||
this.onReady(() => this.editor.setMode('plain'));
|
||||
}
|
||||
|
||||
setHtmlOrPlain(text) {
|
||||
text.startsWith(':HTML:')
|
||||
? this.setHtml(text.slice(6))
|
||||
: this.setPlain(text);
|
||||
}
|
||||
|
||||
setData(mode, data) {
|
||||
this.onReady(() => {
|
||||
const editor = this.editor;
|
||||
this.clearCachedSignature();
|
||||
try {
|
||||
editor.setMode(mode);
|
||||
if (this.isPlain()) {
|
||||
editor.setPlainData(data);
|
||||
} else {
|
||||
editor.setData(data);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
|
||||
setHtml(html) {
|
||||
this.setData('wysiwyg', html/*.replace(/<p[^>]*><\/p>/gi, '')*/);
|
||||
}
|
||||
|
||||
setPlain(txt) {
|
||||
this.setData('plain', txt);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.onReady(() => this.editor.focus());
|
||||
}
|
||||
|
||||
blur() {
|
||||
this.onReady(() => this.editor.blur());
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.onReady(() => this.isPlain() ? this.setPlain('') : this.setHtml(''));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { pInt } from 'Common/Utils';
|
||||
import { doc, Settings } from 'Common/Globals';
|
||||
import { doc, Settings, SettingsAdmin } from 'Common/Globals';
|
||||
|
||||
const
|
||||
BASE = doc.location.pathname.replace(/\/+$/,'') + '/',
|
||||
HASH_PREFIX = '#/',
|
||||
|
||||
adminPath = () => rl.adminArea() && !Settings.app('adminHost'),
|
||||
adminPath = () => rl.adminArea() && !SettingsAdmin('host'),
|
||||
|
||||
prefix = () => BASE + '?' + (adminPath() ? Settings.app('adminPath') : '');
|
||||
prefix = () => BASE + '?' + (adminPath() ? SettingsAdmin('path') : '');
|
||||
|
||||
export const
|
||||
SUB_QUERY_PREFIX = '&q[]=',
|
||||
|
|
@ -38,11 +38,10 @@ export const
|
|||
|
||||
/**
|
||||
* @param {string} download
|
||||
* @param {string=} customSpecSuffix
|
||||
* @returns {string}
|
||||
*/
|
||||
attachmentDownload = (download, customSpecSuffix) =>
|
||||
serverRequestRaw('Download', download, customSpecSuffix),
|
||||
attachmentDownload = (download) =>
|
||||
serverRequestRaw('Download', download),
|
||||
|
||||
proxy = url =>
|
||||
BASE + '?/ProxyExternal/'
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { koComputable } from 'External/ko';
|
|||
oCallbacks:
|
||||
ItemSelect
|
||||
MiddleClick
|
||||
AutoSelect
|
||||
canSelect
|
||||
ItemGetUid
|
||||
UpOrDown
|
||||
*/
|
||||
|
|
@ -21,15 +21,13 @@ export class Selector {
|
|||
* @param {koProperty} koFocusedItem
|
||||
* @param {string} sItemSelector
|
||||
* @param {string} sItemCheckedSelector
|
||||
* @param {string} sItemFocusedSelector
|
||||
*/
|
||||
constructor(
|
||||
koList,
|
||||
koSelectedItem,
|
||||
koFocusedItem,
|
||||
sItemSelector,
|
||||
sItemCheckedSelector,
|
||||
sItemFocusedSelector
|
||||
sItemCheckedSelector
|
||||
) {
|
||||
koFocusedItem = (koFocusedItem || ko.observable(null)).extend({ toggleSubscribeProperty: [this, 'focused'] });
|
||||
koSelectedItem = (koSelectedItem || ko.observable(null)).extend({ toggleSubscribeProperty: [null, 'selected'] });
|
||||
|
|
@ -46,7 +44,7 @@ export class Selector {
|
|||
|
||||
this.sItemSelector = sItemSelector;
|
||||
this.sItemCheckedSelector = sItemCheckedSelector;
|
||||
this.sItemFocusedSelector = sItemFocusedSelector;
|
||||
this.sItemFocusedSelector = sItemSelector + '.focused';
|
||||
|
||||
this.sLastUid = '';
|
||||
this.oCallbacks = {};
|
||||
|
|
@ -74,7 +72,7 @@ export class Selector {
|
|||
|
||||
koSelectedItem.subscribe(item => {
|
||||
if (item) {
|
||||
koList.forEach(subItem => subItem.checked(false));
|
||||
// koList.forEach(subItem => subItem.checked(false));
|
||||
selectedItemUseCallback && itemSelectedThrottle(item);
|
||||
} else {
|
||||
selectedItemUseCallback && itemSelected();
|
||||
|
|
@ -120,7 +118,8 @@ export class Selector {
|
|||
|
||||
if (isArray(aItems)) {
|
||||
let temp,
|
||||
isChecked;
|
||||
isChecked,
|
||||
next = this.iFocusedNextHelper || this.iSelectNextHelper;
|
||||
|
||||
aItems.forEach(item => {
|
||||
const uid = this.getItemUid(item);
|
||||
|
|
@ -145,24 +144,10 @@ export class Selector {
|
|||
|
||||
selectedItemUseCallback = true;
|
||||
|
||||
if (
|
||||
(this.iSelectNextHelper || this.iFocusedNextHelper) &&
|
||||
aItems.length &&
|
||||
!koFocusedItem()
|
||||
) {
|
||||
temp = null;
|
||||
if (this.iFocusedNextHelper) {
|
||||
temp = aItems[-1 === this.iFocusedNextHelper ? aItems.length - 1 : 0];
|
||||
}
|
||||
|
||||
if (!temp && this.iSelectNextHelper) {
|
||||
temp = aItems[-1 === this.iSelectNextHelper ? aItems.length - 1 : 0];
|
||||
}
|
||||
|
||||
if (next && aItems.length && !koFocusedItem()) {
|
||||
temp = aItems[-1 === next ? aItems.length - 1 : 0];
|
||||
if (temp) {
|
||||
if (this.iSelectNextHelper) {
|
||||
koSelectedItem(temp);
|
||||
}
|
||||
this.iSelectNextHelper && koSelectedItem(temp);
|
||||
|
||||
koFocusedItem(temp);
|
||||
|
||||
|
|
@ -200,10 +185,11 @@ export class Selector {
|
|||
|
||||
addEventsListeners(contentScrollable, {
|
||||
click: event => {
|
||||
let el = event.target.closestWithin(this.sItemSelector, contentScrollable);
|
||||
el && this.actionClick(ko.dataFor(el), event);
|
||||
const el = event.target.closestWithin(this.sItemSelector, contentScrollable);
|
||||
let item = el && ko.dataFor(el);
|
||||
el && (this.oCallbacks.click || (()=>1))(event, item) && this.actionClick(item, event);
|
||||
|
||||
const item = getItem(this.sItemCheckedSelector);
|
||||
item = getItem(this.sItemCheckedSelector);
|
||||
if (item) {
|
||||
if (event.shiftKey) {
|
||||
this.actionClick(item, event);
|
||||
|
|
@ -249,7 +235,7 @@ export class Selector {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
autoSelect(bForce) {
|
||||
(bForce || (this.oCallbacks.AutoSelect || (()=>1))())
|
||||
(bForce || (this.oCallbacks.canSelect || (()=>1))())
|
||||
&& this.focusedItem()
|
||||
&& this.selectedItem(this.focusedItem());
|
||||
}
|
||||
|
|
@ -268,10 +254,11 @@ export class Selector {
|
|||
* @param {boolean=} bForceSelect = false
|
||||
*/
|
||||
newSelectPosition(sEventKey, bShiftKey, bForceSelect) {
|
||||
let isArrow = 'ArrowUp' === sEventKey || 'ArrowDown' === sEventKey,
|
||||
result;
|
||||
let result;
|
||||
|
||||
const pageStep = 10,
|
||||
const up = 'ArrowUp' === sEventKey,
|
||||
isArrow = up || 'ArrowDown' === sEventKey,
|
||||
pageStep = 10,
|
||||
list = this.list(),
|
||||
listLen = list.length,
|
||||
focused = this.focusedItem();
|
||||
|
|
@ -283,8 +270,7 @@ export class Selector {
|
|||
} else if (listLen) {
|
||||
if (focused) {
|
||||
if (isArrow) {
|
||||
let i = list.indexOf(focused),
|
||||
up = 'ArrowUp' == sEventKey;
|
||||
let i = list.indexOf(focused);
|
||||
if (bShiftKey) {
|
||||
shiftStart = -1 < shiftStart ? shiftStart : i;
|
||||
shiftStart == i
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const
|
|||
|
||||
getNotificationMessage = code => {
|
||||
let key = getKeyByValue(Notifications, code);
|
||||
return key ? I18N_DATA.NOTIFICATIONS[i18nKey(key).replace('_NOTIFICATION', '_ERROR')] : '';
|
||||
return key ? I18N_DATA.NOTIFICATIONS[key] : '';
|
||||
},
|
||||
|
||||
fromNow = date => relativeTime(Math.round((date.getTime() - Date.now()) / 1000));
|
||||
|
|
@ -45,8 +45,7 @@ export const
|
|||
}
|
||||
}
|
||||
if (Intl.RelativeTimeFormat) {
|
||||
let rtf = new Intl.RelativeTimeFormat(doc.documentElement.lang);
|
||||
return rtf.format(seconds, unit);
|
||||
return (new Intl.RelativeTimeFormat(doc.documentElement.lang)).format(seconds, unit);
|
||||
}
|
||||
// Safari < 14
|
||||
abs = Math.abs(seconds);
|
||||
|
|
@ -62,7 +61,7 @@ export const
|
|||
* @returns {string}
|
||||
*/
|
||||
i18n = (key, valueList, defaulValue) => {
|
||||
let result = null == defaulValue ? key : defaulValue;
|
||||
let result = defaulValue ?? key;
|
||||
let path = key.split('/');
|
||||
if (I18N_DATA[path[0]] && path[1]) {
|
||||
result = I18N_DATA[path[0]][path[1]] || result;
|
||||
|
|
@ -102,8 +101,7 @@ export const
|
|||
|
||||
timestampToString = (timeStampInUTC, formatStr) => {
|
||||
const now = Date.now(),
|
||||
time = 0 < timeStampInUTC ? Math.min(now, timeStampInUTC * 1000) : (0 === timeStampInUTC ? now : 0);
|
||||
|
||||
time = 0 < timeStampInUTC ? timeStampInUTC * 1000 : (0 === timeStampInUTC ? now : 0);
|
||||
if (31536000000 < time) {
|
||||
const m = new Date(time), h = LanguageStore.hourCycle();
|
||||
switch (formatStr) {
|
||||
|
|
@ -144,7 +142,7 @@ export const
|
|||
time = Date.parse(element.dateTime) / 1000;
|
||||
}
|
||||
|
||||
let key = element.dataset.momentFormat;
|
||||
let key = element.dataset.timeFormat;
|
||||
if (key) {
|
||||
element.textContent = timestampToString(time, key);
|
||||
if ('FULL' !== key && 'FROMNOW' !== key) {
|
||||
|
|
@ -186,6 +184,9 @@ export const
|
|||
|| '';
|
||||
},
|
||||
|
||||
getErrorMessage = (code, data) =>
|
||||
getNotification(code) || data?.messageAdditional || data?.message || data,
|
||||
|
||||
/**
|
||||
* @param {*} code
|
||||
* @returns {string}
|
||||
|
|
@ -212,14 +213,13 @@ export const
|
|||
script.remove();
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => reject(new Error('Language '+language+' failed'));
|
||||
script.onerror = () => reject(Error('Language '+language+' failed'));
|
||||
script.src = langLink(language, admin);
|
||||
// script.async = true;
|
||||
doc.head.append(script);
|
||||
}),
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} language
|
||||
* @param {boolean=} isEng = false
|
||||
* @returns {string}
|
||||
|
|
@ -229,6 +229,8 @@ export const
|
|||
'LANGS_NAMES' + (true === isEng ? '_EN' : '') + '/' + language,
|
||||
null,
|
||||
language
|
||||
);
|
||||
),
|
||||
|
||||
baseCollator = numeric => new Intl.Collator(doc.documentElement.lang, {numeric: !!numeric, sensitivity: 'base'});
|
||||
|
||||
init();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const
|
|||
|
||||
pInt = (value, defaultValue = 0) => {
|
||||
value = parseInt(value, 10);
|
||||
return isNaN(value) || !isFinite(value) ? defaultValue : value;
|
||||
return isFinite(value) ? value : defaultValue;
|
||||
},
|
||||
|
||||
defaultOptionsAfterRender = (domItem, item) =>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,57 @@ import { ThemeStore } from 'Stores/Theme';
|
|||
import Remote from 'Remote/User/Fetch';
|
||||
import { attachmentDownload } from 'Common/Links';
|
||||
|
||||
import { AccountModel } from 'Model/Account';
|
||||
import { IdentityModel } from 'Model/Identity';
|
||||
import { AccountUserStore } from 'Stores/User/Account';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
import { isArray } from 'Common/Utils';
|
||||
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
import { IdentityPopupView } from 'View/Popup/Identity';
|
||||
|
||||
export const
|
||||
|
||||
moveAction = ko.observable(false),
|
||||
// 1 = move, 2 = copy
|
||||
moveAction = ko.observable(0),
|
||||
|
||||
dropdownsDetectVisibility = (() =>
|
||||
dropdownVisibility(!!dropdowns.find(item => item.classList.contains('show')))
|
||||
).debounce(50),
|
||||
|
||||
|
||||
editIdentity = Identity => showScreenPopup(IdentityPopupView, [Identity]),
|
||||
|
||||
loadAccountsAndIdentities = () => {
|
||||
AccountUserStore.loading(true);
|
||||
IdentityUserStore.loading(true);
|
||||
|
||||
Remote.request('AccountsAndIdentities', (iError, oData) => {
|
||||
AccountUserStore.loading(false);
|
||||
IdentityUserStore.loading(false);
|
||||
|
||||
if (!iError) {
|
||||
let items = oData.Result.Accounts;
|
||||
AccountUserStore(isArray(items)
|
||||
? items.map(oValue => new AccountModel(oValue.email, oValue.name))
|
||||
: []
|
||||
);
|
||||
AccountUserStore.unshift(new AccountModel(SettingsGet('mainEmail'), '', false));
|
||||
|
||||
items = oData.Result.Identities;
|
||||
IdentityUserStore(isArray(items)
|
||||
? items.map(identityData => IdentityModel.reviveFromJson(identityData))
|
||||
: []
|
||||
);
|
||||
|
||||
// Invoke "Update Identity" pop up right after login
|
||||
// https://github.com/the-djmaze/snappymail/issues/1689
|
||||
const main = IdentityUserStore.main();
|
||||
main && !main.exists() && setTimeout(()=>editIdentity(main), 1000);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} link
|
||||
* @returns {boolean}
|
||||
|
|
@ -89,7 +132,7 @@ computedPaginatorHelper = (koCurrentPage, koPageCount) => {
|
|||
next = 0,
|
||||
limit = 2;
|
||||
|
||||
if (1 < pageCount || (0 < pageCount && pageCount < currentPage)) {
|
||||
if (1 < pageCount) {
|
||||
if (pageCount < currentPage) {
|
||||
fAdd(pageCount);
|
||||
prev = pageCount;
|
||||
|
|
@ -248,7 +291,7 @@ setLayoutResizer = (source, sClientSideKeyName, mode) =>
|
|||
|
||||
viewMessage = (oMessage, popup) => {
|
||||
if (popup) {
|
||||
oMessage.viewPopupMessage();
|
||||
oMessage.popupMessage();
|
||||
} else {
|
||||
MessageUserStore.error('');
|
||||
let id = 'rl-msg-' + oMessage.hash,
|
||||
|
|
@ -260,6 +303,8 @@ viewMessage = (oMessage, popup) => {
|
|||
class:'b-text-part'
|
||||
+ (oMessage.pgpSigned() ? ' openpgp-signed' : '')
|
||||
+ (oMessage.pgpEncrypted() ? ' openpgp-encrypted' : '')
|
||||
+ (oMessage.smimeSigned() ? ' smime-signed' : '')
|
||||
+ (oMessage.smimeEncrypted() ? ' smime-encrypted' : '')
|
||||
});
|
||||
MessageUserStore.purgeCache();
|
||||
}
|
||||
|
|
@ -276,7 +321,7 @@ viewMessage = (oMessage, popup) => {
|
|||
MessageUserStore.loading(false);
|
||||
oMessage.body.hidden = false;
|
||||
|
||||
if (oMessage.isUnseen()) {
|
||||
if (oMessage.isUnseen() && SettingsUserStore.messageReadAuto()) {
|
||||
MessageUserStore.MessageSeenTimer = setTimeout(
|
||||
() => MessagelistUserStore.setAction(oMessage.folder, MessageSetAction.SetSeen, [oMessage]),
|
||||
SettingsUserStore.messageReadDelay() * 1000 // seconds
|
||||
|
|
@ -301,10 +346,14 @@ populateMessageBody = (oMessage, popup) => {
|
|||
} else {
|
||||
let json = oData?.Result;
|
||||
if (json
|
||||
&& oMessage.hash === json.hash
|
||||
// && oMessage.folder === json.folder
|
||||
// && oMessage.uid == json.uid
|
||||
&& oMessage.revivePropertiesFromJson(json)
|
||||
&& ((
|
||||
oMessage.hash && oMessage.hash === json.hash
|
||||
) || (
|
||||
!oMessage.hash
|
||||
&& oMessage.folder === json.folder
|
||||
&& oMessage.uid == json.uid)
|
||||
)
|
||||
&& oMessage.revivePropertiesFromJson(json)
|
||||
) {
|
||||
/*
|
||||
if (bCached) {
|
||||
|
|
@ -321,5 +370,5 @@ populateMessageBody = (oMessage, popup) => {
|
|||
}
|
||||
};
|
||||
|
||||
leftPanelDisabled.subscribe(value => value && moveAction(false));
|
||||
leftPanelDisabled.subscribe(value => value && moveAction(0));
|
||||
moveAction.subscribe(value => value && leftPanelDisabled(false));
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export class CheckboxComponent {
|
|||
: ko.observable(!!params.value);
|
||||
|
||||
this.enable = ko.isObservable(params.enable) ? params.enable
|
||||
: ko.observable(undefined === params.enable || !!params.enable);
|
||||
: ko.observable(params.enable ?? 1);
|
||||
|
||||
this.label = params.label;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { doc, createElement, addEventsListeners } from 'Common/Globals';
|
||||
import { EmailModel, addressparser } from 'Model/Email';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { addressparser } from 'Mime/Address';
|
||||
|
||||
const contentType = 'snappymail/emailaddress',
|
||||
getAddressKey = li => li?.emailaddress?.key,
|
||||
|
|
@ -165,7 +166,9 @@ export class EmailAddressesComponent {
|
|||
|
||||
_parseInput(force) {
|
||||
let val = this.input.value;
|
||||
if ((force || val.includes(',') || val.includes(';')) && this._parseValue(val)) {
|
||||
if ((force || val.includes(',') || val.includes(';')
|
||||
|| (val.charAt(val.length-1)===' ' && this._simpleEmailMatch(val)))
|
||||
&& this._parseValue(val)) {
|
||||
this.input.value = '';
|
||||
}
|
||||
this._resizeInput();
|
||||
|
|
@ -284,6 +287,13 @@ export class EmailAddressesComponent {
|
|||
}
|
||||
}
|
||||
|
||||
_simpleEmailMatch(value) {
|
||||
// A very SIMPLE test to check if the value might be an email
|
||||
const val = value.trim();
|
||||
return /^[^@]*<[^\s@]{1,128}@[^\s@]{1,256}\.[\w]{2,32}>$/g.test(val)
|
||||
|| /^[^\s@]{1,128}@[^\s@]{1,256}\.[\w]{2,32}$/g.test(val);
|
||||
}
|
||||
|
||||
_renderTags() {
|
||||
let self = this;
|
||||
[...self.ul.children].forEach(node => node !== self.inputCont && node.remove());
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export class JCard {
|
|||
if (input) {
|
||||
// read from jCard
|
||||
if (typeof input !== 'object') {
|
||||
throw new Error('error reading vcard')
|
||||
throw Error('error reading vcard')
|
||||
}
|
||||
this.parseFromJCard(input)
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ export class JCard {
|
|||
arg = new VCardProperty(String(arg), value, params, type);
|
||||
}
|
||||
if (!(arg instanceof VCardProperty)) {
|
||||
throw new Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty');
|
||||
throw Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty');
|
||||
}
|
||||
let field = arg.getField();
|
||||
this.props.set(field, [arg]);
|
||||
|
|
@ -101,7 +101,7 @@ export class JCard {
|
|||
arg = new VCardProperty(String(arg), value, params, type);
|
||||
}
|
||||
if (!(arg instanceof VCardProperty)) {
|
||||
throw new Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty');
|
||||
throw Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty');
|
||||
}
|
||||
// VCardProperty arguments
|
||||
let field = arg.getField();
|
||||
|
|
@ -124,15 +124,15 @@ export class JCard {
|
|||
// VCardProperty argument
|
||||
else if (arg instanceof VCardProperty) {
|
||||
let propArray = this.props.get(arg.getField());
|
||||
if (!(propArray === null || propArray === void 0 ? void 0 : propArray.includes(arg)))
|
||||
throw new Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg));
|
||||
if (!propArray?.includes(arg))
|
||||
throw Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg));
|
||||
propArray.splice(propArray.indexOf(arg), 1);
|
||||
if (propArray.length === 0)
|
||||
this.props.delete(arg.getField());
|
||||
}
|
||||
// incorrect arguments
|
||||
else
|
||||
throw new Error('invalid argument of VCard.remove(), expects ' +
|
||||
throw Error('invalid argument of VCard.remove(), expects ' +
|
||||
'string and optional param filter or a VCardProperty');
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +202,7 @@ export class JCard {
|
|||
parseFullName(options) {
|
||||
let n = this.getOne('n');
|
||||
if (n === undefined) {
|
||||
throw new Error('\'fn\' VCardProperty not present in card, cannot parse full name');
|
||||
throw Error('\'fn\' VCardProperty not present in card, cannot parse full name');
|
||||
}
|
||||
let fnString = '';
|
||||
// Position in n -> position in fn
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export class VCardProperty {
|
|||
}
|
||||
// invalid property
|
||||
else {
|
||||
throw new Error('invalid Property constructor');
|
||||
throw Error('invalid Property constructor');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
59
dev/External/SquireUI.js
vendored
59
dev/External/SquireUI.js
vendored
|
|
@ -133,11 +133,11 @@ class SquireUI
|
|||
dir: {
|
||||
dir_ltr: {
|
||||
html: '⁋',
|
||||
cmd: () => squire.bidi('ltr')
|
||||
cmd: () => squire.setTextDirection('ltr')
|
||||
},
|
||||
dir_rtl: {
|
||||
html: '¶',
|
||||
cmd: () => squire.bidi('rtl')
|
||||
cmd: () => squire.setTextDirection('rtl')
|
||||
}
|
||||
},
|
||||
colors: {
|
||||
|
|
@ -237,7 +237,7 @@ class SquireUI
|
|||
cmd: () => {
|
||||
let node = squire.getSelectionClosest('IMG'),
|
||||
src = prompt("Image", node?.src || "https://");
|
||||
src?.length ? squire.insertImage(src) : (node && squire.detach(node));
|
||||
src?.length ? squire.insertImage(src) : node?.remove();
|
||||
},
|
||||
matches: 'IMG'
|
||||
},
|
||||
|
|
@ -270,6 +270,13 @@ class SquireUI
|
|||
btn.classList.toggle('active', 'source' == this.mode);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clear: {
|
||||
removeStyle: {
|
||||
html: '⎚',
|
||||
cmd: () => squire.setStyle()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -318,11 +325,6 @@ class SquireUI
|
|||
wysiwyg.className = 'squire-wysiwyg';
|
||||
wysiwyg.dir = 'auto';
|
||||
this.mode = ''; // 'plain' | 'wysiwyg'
|
||||
this.__plain = {
|
||||
getRawData: () => this.plain.value,
|
||||
setRawData: plain => this.plain.value = plain
|
||||
};
|
||||
|
||||
this.container = container;
|
||||
this.squire = squire;
|
||||
this.plain = plain;
|
||||
|
|
@ -403,9 +405,9 @@ class SquireUI
|
|||
|
||||
let changes = actions.changes;
|
||||
changes.undo.input.disabled = changes.redo.input.disabled = true;
|
||||
squire.addEventListener('undoStateChange', state => {
|
||||
changes.undo.input.disabled = !state.canUndo;
|
||||
changes.redo.input.disabled = !state.canRedo;
|
||||
squire.addEventListener('undoStateChange', e => {
|
||||
changes.undo.input.disabled = !e.detail.canUndo;
|
||||
changes.redo.input.disabled = !e.detail.canRedo;
|
||||
});
|
||||
|
||||
actions.font.fontSize.input.selectedIndex = actions.font.fontSize.defaultValueIndex;
|
||||
|
|
@ -478,21 +480,21 @@ class SquireUI
|
|||
squire.addEventListener('pathChange', e => {
|
||||
|
||||
const squireRoot = squire.getRoot();
|
||||
let elm = e.detail.element;
|
||||
|
||||
forEachObjectValue(actions, entries => {
|
||||
forEachObjectValue(entries, cfg => {
|
||||
// cfg.matches && cfg.input.classList.toggle('active', e.element && e.element.matches(cfg.matches));
|
||||
cfg.matches && cfg.input.classList.toggle('active', e.element && e.element.closestWithin(cfg.matches, squireRoot));
|
||||
// cfg.matches && cfg.input.classList.toggle('active', elm && elm.matches(cfg.matches));
|
||||
cfg.matches && cfg.input.classList.toggle('active', elm && elm.closestWithin(cfg.matches, squireRoot));
|
||||
});
|
||||
});
|
||||
|
||||
if (e.element) {
|
||||
if (elm) {
|
||||
// try to find font-family and/or font-size and set "select" elements' values
|
||||
|
||||
let sizeSelectedIndex = actions.font.fontSize.defaultValueIndex;
|
||||
let familySelectedIndex = defaultFontFamilyIndex;
|
||||
|
||||
let elm = e.element;
|
||||
let familyFound = false;
|
||||
let sizeFound = false;
|
||||
do {
|
||||
|
|
@ -524,21 +526,12 @@ class SquireUI
|
|||
});
|
||||
/*
|
||||
squire.addEventListener('cursor', e => {
|
||||
console.dir({cursor:e.range});
|
||||
console.dir({cursor:e.detail.range});
|
||||
});
|
||||
squire.addEventListener('select', e => {
|
||||
console.dir({select:e.range});
|
||||
console.dir({select:e.detail.range});
|
||||
});
|
||||
*/
|
||||
|
||||
// CKEditor gimmicks used by HtmlEditor
|
||||
this.plugins = {
|
||||
plain: true
|
||||
};
|
||||
this.focusManager = {
|
||||
hasFocus: () => squire._isFocused,
|
||||
blur: () => squire.blur()
|
||||
};
|
||||
}
|
||||
|
||||
doAction(name) {
|
||||
|
|
@ -576,7 +569,6 @@ class SquireUI
|
|||
this.modeSelect.selectedIndex = 'plain' == this.mode ? 1 : 0;
|
||||
}
|
||||
|
||||
// CKeditor gimmicks used by HtmlEditor
|
||||
on(type, fn) {
|
||||
if ('mode' == type) {
|
||||
this.onModeChange = fn;
|
||||
|
|
@ -619,6 +611,7 @@ class SquireUI
|
|||
// Move cursor above signature
|
||||
div.before(br);
|
||||
div.before(br.cloneNode());
|
||||
// squire._docWasChanged();
|
||||
}
|
||||
this._prev_txt_sig = signature;
|
||||
} catch (e) {
|
||||
|
|
@ -642,6 +635,18 @@ class SquireUI
|
|||
squire.setSelection( range );
|
||||
}
|
||||
|
||||
getPlainData() {
|
||||
return this.plain.value;
|
||||
}
|
||||
|
||||
setPlainData(text) {
|
||||
this.plain.value = text;
|
||||
}
|
||||
|
||||
blur() {
|
||||
this.squire.blur();
|
||||
}
|
||||
|
||||
focus() {
|
||||
if ('plain' == this.mode) {
|
||||
this.plain.focus();
|
||||
|
|
|
|||
27
dev/External/User/ko.js
vendored
27
dev/External/User/ko.js
vendored
|
|
@ -1,9 +1,9 @@
|
|||
import 'External/ko';
|
||||
import ko from 'ko';
|
||||
import { HtmlEditor } from 'Common/Html';
|
||||
import { RFC822 } from 'Common/File';
|
||||
import { HtmlEditor } from 'Common/HtmlEditor';
|
||||
import { timeToNode } from 'Common/Translator';
|
||||
import { doc, elementById, addEventsListeners, dropdowns, leftPanelDisabled } from 'Common/Globals';
|
||||
import { dropdownsDetectVisibility } from 'Common/UtilsUser';
|
||||
import { EmailAddressesComponent } from 'Component/EmailAddresses';
|
||||
import { ThemeStore } from 'Stores/Theme';
|
||||
import { dropFilesInFolder } from 'Common/Folders';
|
||||
|
|
@ -44,7 +44,7 @@ const rlContentType = 'snappymail/action',
|
|||
let files = false;
|
||||
// if (e.dataTransfer.types.includes('Files'))
|
||||
for (const item of e.dataTransfer.items) {
|
||||
files |= 'file' === item.kind && 'message/rfc822' === item.type;
|
||||
files |= 'file' === item.kind && RFC822 === item.type;
|
||||
}
|
||||
if (files || dragMessages()) {
|
||||
e.stopPropagation();
|
||||
|
|
@ -91,7 +91,7 @@ Object.assign(ko.bindingHandlers, {
|
|||
};
|
||||
|
||||
if (ko.isObservable(fValue)) {
|
||||
editor = new HtmlEditor(element, fUpdateKoValue, fOnReady, fUpdateKoValue);
|
||||
editor = new HtmlEditor(element, fOnReady, fUpdateKoValue, fUpdateKoValue);
|
||||
|
||||
fValue.__fetchEditorValue = fUpdateKoValue;
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ Object.assign(ko.bindingHandlers, {
|
|||
}
|
||||
},
|
||||
|
||||
moment: {
|
||||
time: {
|
||||
init: ttn,
|
||||
update: ttn
|
||||
},
|
||||
|
|
@ -194,10 +194,6 @@ Object.assign(ko.bindingHandlers, {
|
|||
};
|
||||
addEventsListeners(element, {
|
||||
dragstart: e => {
|
||||
dragData = {
|
||||
action: 'sortable',
|
||||
element: element
|
||||
};
|
||||
setDragAction(e, 'sortable', 'move', element, element);
|
||||
element.style.opacity = 0.25;
|
||||
},
|
||||
|
|
@ -247,18 +243,5 @@ Object.assign(ko.bindingHandlers, {
|
|||
dropdowns.push(element);
|
||||
element.ddBtn = new BSN.Dropdown(element.querySelector('.dropdown-toggle'));
|
||||
}
|
||||
},
|
||||
|
||||
openDropdownTrigger: {
|
||||
update: (element, fValueAccessor) => {
|
||||
if (ko.unwrap(fValueAccessor())) {
|
||||
const el = element.ddBtn;
|
||||
el.open || el.toggle();
|
||||
// el.focus();
|
||||
|
||||
dropdownsDetectVisibility();
|
||||
fValueAccessor()(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
30
dev/External/ko.js
vendored
30
dev/External/ko.js
vendored
|
|
@ -28,6 +28,11 @@ export const
|
|||
|
||||
dispose = disposable => isFunction(disposable?.dispose) && disposable.dispose(),
|
||||
|
||||
onEvent = (element, event, fn) => {
|
||||
element.addEventListener(event, fn);
|
||||
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener(event, fn));
|
||||
},
|
||||
|
||||
onKey = (key, element, fValueAccessor, fAllBindings, model) => {
|
||||
let fn = event => {
|
||||
if (key == event.key) {
|
||||
|
|
@ -36,8 +41,7 @@ export const
|
|||
fValueAccessor().call(model);
|
||||
}
|
||||
};
|
||||
element.addEventListener('keydown', fn);
|
||||
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keydown', fn));
|
||||
onEvent(element, 'keydown', fn);
|
||||
},
|
||||
|
||||
// With this we don't need delegateRunOnDestroy
|
||||
|
|
@ -62,8 +66,7 @@ Object.assign(ko.bindingHandlers, {
|
|||
},
|
||||
update: (element, fValueAccessor) => {
|
||||
let value = ko.unwrap(fValueAccessor());
|
||||
value = isFunction(value) ? value() : value;
|
||||
errorTip(element, value);
|
||||
errorTip(element, isFunction(value) ? value() : value);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -82,6 +85,15 @@ Object.assign(ko.bindingHandlers, {
|
|||
onKey(' ', element, fValueAccessor, fAllBindings, model)
|
||||
},
|
||||
|
||||
toggle: {
|
||||
init: (element, fValueAccessor) => {
|
||||
let observable = fValueAccessor(),
|
||||
fn = () => observable(!observable());
|
||||
onEvent(element, 'click', fn);
|
||||
onEvent(element, 'keydown', event => ' ' == event.key && fn());
|
||||
}
|
||||
},
|
||||
|
||||
i18nUpdate: {
|
||||
update: (element, fValueAccessor) => {
|
||||
ko.unwrap(fValueAccessor());
|
||||
|
|
@ -89,16 +101,12 @@ Object.assign(ko.bindingHandlers, {
|
|||
}
|
||||
},
|
||||
|
||||
title: {
|
||||
update: (element, fValueAccessor) => element.title = ko.unwrap(fValueAccessor())
|
||||
},
|
||||
|
||||
command: {
|
||||
init: (element, fValueAccessor, fAllBindings, viewModel, bindingContext) => {
|
||||
const command = fValueAccessor();
|
||||
|
||||
if (!command || !command.canExecute) {
|
||||
throw new Error('Value should be a command');
|
||||
throw Error('Value should be a command');
|
||||
}
|
||||
|
||||
ko.bindingHandlers['FORM'==element.nodeName ? 'submit' : 'click'].init(
|
||||
|
|
@ -110,10 +118,8 @@ Object.assign(ko.bindingHandlers, {
|
|||
);
|
||||
},
|
||||
update: (element, fValueAccessor) => {
|
||||
const cl = element.classList;
|
||||
|
||||
let disabled = !fValueAccessor().canExecute();
|
||||
cl.toggle('disabled', disabled);
|
||||
element.classList.toggle('disabled', disabled);
|
||||
|
||||
if (element.matches('INPUT,TEXTAREA,BUTTON')) {
|
||||
element.disabled = disabled;
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title></title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(125,128,128,0.3);
|
||||
border-bottom: 1px solid #888;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
header * {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
header time {
|
||||
float: right;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 2px solid rgba(125,128,128,0.5);
|
||||
margin: 0;
|
||||
padding: 0 0 0 10px;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
body > * {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
#attachments > * {
|
||||
border: 1px solid rgba(125,128,128,0.5);
|
||||
padding: 0.25em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
#attachments > *::before {
|
||||
content: '📎 ';
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
|
|
@ -5,9 +5,13 @@ function typeCast(curValue, newValue) {
|
|||
if (null != curValue) {
|
||||
switch (typeof curValue)
|
||||
{
|
||||
case 'boolean': return 0 != newValue && !!newValue;
|
||||
case 'number': return isFinite(newValue) ? parseFloat(newValue) : 0;
|
||||
case 'string': return null != newValue ? '' + newValue : '';
|
||||
case 'boolean':
|
||||
return 0 != newValue && !!newValue;
|
||||
case 'number':
|
||||
newValue = parseFloat(newValue);
|
||||
return isFinite(newValue) ? newValue : 0;
|
||||
case 'string':
|
||||
return null != newValue ? '' + newValue : '';
|
||||
case 'object':
|
||||
if (curValue.constructor.reviveFromJson) {
|
||||
return curValue.constructor.reviveFromJson(newValue);
|
||||
|
|
@ -23,10 +27,10 @@ export class AbstractModel {
|
|||
constructor() {
|
||||
/*
|
||||
if (new.target === AbstractModel) {
|
||||
throw new Error("Can't instantiate AbstractModel!");
|
||||
throw Error("Can't instantiate AbstractModel!");
|
||||
}
|
||||
*/
|
||||
this.disposables = [];
|
||||
Object.defineProperty(this, 'disposables', {value: []});
|
||||
}
|
||||
|
||||
addObservables(observables) {
|
||||
|
|
|
|||
|
|
@ -60,10 +60,9 @@ export class AbstractViewPopup extends AbstractView
|
|||
this.keyScope.scope = name;
|
||||
this.modalVisible = ko.observable(false).extend({ rateLimit: 0 });
|
||||
this.close = () => this.modalVisible(false);
|
||||
this.tryToClose = () => (false === this.onClose()) || this.close();
|
||||
addShortcut('escape,close', '', name, () => {
|
||||
if (this.modalVisible() && false !== this.onClose()) {
|
||||
this.close();
|
||||
}
|
||||
this.modalVisible() && this.tryToClose();
|
||||
return false;
|
||||
// return true; Issue with supported modal close
|
||||
});
|
||||
|
|
@ -116,6 +115,11 @@ export class AbstractViewSettings
|
|||
onHide() {}
|
||||
viewModelDom
|
||||
*/
|
||||
/**
|
||||
* When this[name] does not exists, create as observable with value of SettingsGet(name)
|
||||
* When this[name+'Trigger'] does not exists, create as observable
|
||||
* Subscribe to this[name], and handle saving the setting
|
||||
*/
|
||||
addSetting(name, valueCb)
|
||||
{
|
||||
let prop = name[0].toLowerCase() + name.slice(1),
|
||||
|
|
@ -131,6 +135,7 @@ export class AbstractViewSettings
|
|||
rl.app.Remote.saveSetting(name, value,
|
||||
iError => {
|
||||
this[trigger](iError ? SaveSettingStatus.Failed : SaveSettingStatus.Success);
|
||||
// iError || Settings.set(name, value);
|
||||
setTimeout(() => this[trigger](SaveSettingStatus.Idle), 1000);
|
||||
}
|
||||
);
|
||||
|
|
@ -138,6 +143,10 @@ export class AbstractViewSettings
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreach name if this[name] does not exists, create as observable with value of SettingsGet(name)
|
||||
* Subscribe to this[name], for saving the setting
|
||||
*/
|
||||
addSettings(names)
|
||||
{
|
||||
names.forEach(name => {
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ const
|
|||
screen = screenName => (screenName && SCREENS.get(screenName)) || null,
|
||||
|
||||
/**
|
||||
* Creates the extended AbstractView model
|
||||
* @param {Function} ViewModelClass
|
||||
* @param {Object=} vmScreen
|
||||
* @returns {*}
|
||||
*/
|
||||
buildViewModel = (ViewModelClass, vmScreen) => {
|
||||
if (ViewModelClass && !ViewModelClass.__builded) {
|
||||
let vmDom = null;
|
||||
if (ViewModelClass && !ViewModelClass.__vm) {
|
||||
const
|
||||
vm = new ViewModelClass(vmScreen),
|
||||
id = vm.viewModelTemplateID,
|
||||
|
|
@ -39,16 +39,15 @@ const
|
|||
dialog = ViewTypePopup === vm.viewType,
|
||||
vmPlace = doc.getElementById(position);
|
||||
|
||||
ViewModelClass.__builded = true;
|
||||
ViewModelClass.__vm = vm;
|
||||
|
||||
if (vmPlace) {
|
||||
vmDom = dialog
|
||||
ViewModelClass.__vm = vm;
|
||||
|
||||
let vmDom = dialog
|
||||
? createElement('dialog',{id:'V-'+id})
|
||||
: createElement('div',{id:'V-'+id,hidden:''})
|
||||
vmPlace.append(vmDom);
|
||||
|
||||
vm.viewModelDom = ViewModelClass.__dom = vmDom;
|
||||
vm.viewModelDom = vmDom;
|
||||
|
||||
if (dialog) {
|
||||
// Firefox < 98 / Safari < 15.4 HTMLDialogElement not defined
|
||||
|
|
@ -59,13 +58,11 @@ const
|
|||
vmDom.before(vmDom.backdrop = createElement('div',{class:'dialog-backdrop'}));
|
||||
vmDom.setAttribute('open','');
|
||||
vmDom.open = true;
|
||||
vmDom.returnValue = null;
|
||||
vmDom.backdrop.hidden = false;
|
||||
};
|
||||
vmDom.close = v => {
|
||||
vmDom.close = () => {
|
||||
// if (vmDom.dispatchEvent(new CustomEvent('cancel', {cancelable:true}))) {
|
||||
vmDom.backdrop.hidden = true;
|
||||
vmDom.returnValue = v;
|
||||
vmDom.removeAttribute('open', null);
|
||||
vmDom.open = false;
|
||||
// vmDom.dispatchEvent(new CustomEvent('close'));
|
||||
|
|
@ -77,13 +74,17 @@ const
|
|||
// vmDom.addEventListener('close', () => vm.modalVisible(false));
|
||||
|
||||
// show/hide popup/modal
|
||||
// transitionend is called for each property, so we only listen to `opacity`
|
||||
// as defined in CSS by `dialog:not(.animate)`
|
||||
const endShowHide = e => {
|
||||
if (e.target === vmDom) {
|
||||
if (e.target === vmDom && 'opacity' === e.propertyName) {
|
||||
if (vmDom.classList.contains('animate')) {
|
||||
vm.afterShow?.();
|
||||
fireEvent('rl-vm-visible', vm);
|
||||
} else {
|
||||
vmDom.close();
|
||||
vm.afterHide?.();
|
||||
// fireEvent('rl-vm-hidden', vm);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -139,10 +140,9 @@ const
|
|||
screen.viewModels.forEach(ViewModelClass => {
|
||||
if (
|
||||
ViewModelClass.__vm &&
|
||||
ViewModelClass.__dom &&
|
||||
ViewTypePopup !== ViewModelClass.__vm.viewType
|
||||
) {
|
||||
fn(ViewModelClass.__vm, ViewModelClass.__dom);
|
||||
fn(ViewModelClass.__vm, ViewModelClass.__vm.viewModelDom);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
@ -152,7 +152,7 @@ const
|
|||
forEachViewModel(screenToHide, (vm, dom) => {
|
||||
dom.hidden = true;
|
||||
vm.onHide?.();
|
||||
destroy && vm.viewModelDom.remove();
|
||||
destroy && dom.remove();
|
||||
});
|
||||
ThemeStore.isMobile() && leftPanelDisabled(true);
|
||||
},
|
||||
|
|
@ -164,10 +164,10 @@ const
|
|||
*/
|
||||
screenOnRoute = (screenName, subPart) => {
|
||||
screenName = screenName || defaultScreenName;
|
||||
if (screenName && fireEvent('sm-show-screen', screenName, 1)) {
|
||||
if (screenName && fireEvent('sm-show-screen', screenName + (subPart ? '/' + subPart : ''), 1)) {
|
||||
// Close all popups
|
||||
for (let vm of visiblePopups) {
|
||||
(false === vm.onClose()) || vm.close();
|
||||
vm.tryToClose();
|
||||
}
|
||||
|
||||
let vmScreen = screen(screenName);
|
||||
|
|
@ -229,15 +229,11 @@ export const
|
|||
* @returns {void}
|
||||
*/
|
||||
showScreenPopup = (ViewModelClassToShow, params = []) => {
|
||||
const vm = buildViewModel(ViewModelClassToShow) && ViewModelClassToShow.__dom && ViewModelClassToShow.__vm;
|
||||
|
||||
const vm = buildViewModel(ViewModelClassToShow);
|
||||
if (vm) {
|
||||
params = params || [];
|
||||
|
||||
vm.beforeShow?.(...params);
|
||||
|
||||
vm.modalVisible(true);
|
||||
|
||||
vm.onShow?.(...params);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
197
dev/Mime/Address.js
Normal file
197
dev/Mime/Address.js
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { decodeEncodedWords } from 'Mime/Encoding';
|
||||
|
||||
/**
|
||||
* Parses structured e-mail addresses from an address/mailbox(-list) field
|
||||
* https://datatracker.ietf.org/doc/html/rfc2822#section-3.4
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* "Name <address@domain>"
|
||||
*
|
||||
* will be converted to
|
||||
*
|
||||
* [{name: "Name", email: "address@domain"}]
|
||||
*
|
||||
* @param {String} str Address field
|
||||
* @return {Array} An array of address objects
|
||||
*/
|
||||
export function addressparser(str) {
|
||||
str = (str || '').toString();
|
||||
|
||||
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 = 'email';
|
||||
} else if ('(' === chr) {
|
||||
node.type = 'comment';
|
||||
} else if (':' === chr) {
|
||||
node.type = 'group';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
node.value += chr;
|
||||
escaped = !escaped && '\\' === chr;
|
||||
}
|
||||
});
|
||||
pushToken(node);
|
||||
|
||||
pushAddress();
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
address = {},
|
||||
addresses = [],
|
||||
data = {
|
||||
email: [],
|
||||
comment: [],
|
||||
group: [],
|
||||
text: []
|
||||
};
|
||||
|
||||
tokens.forEach(token => {
|
||||
isGroup = isGroup || 'group' === token.type;
|
||||
data[token.type].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
|
||||
/*
|
||||
addresses.push({
|
||||
email: '',
|
||||
name: data.text.join(' ').trim(),
|
||||
group: addressparser(data.group.join(','))
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
});
|
||||
*/
|
||||
addresses = addresses.concat(addressparser(data.group.join(',')));
|
||||
} else {
|
||||
// If no address was found, try to detect one from regular text
|
||||
if (!data.email.length && data.text.length) {
|
||||
var i = data.text.length;
|
||||
while (i--) {
|
||||
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
||||
data.email = data.text.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// still no address
|
||||
if (!data.email.length) {
|
||||
i = data.text.length;
|
||||
while (i--) {
|
||||
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, address => {
|
||||
if (!data.email.length) {
|
||||
data.email = [address.trim()];
|
||||
return '';
|
||||
}
|
||||
return address.trim();
|
||||
});
|
||||
if (data.email.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's still no text but a comment exists, 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.email.length > 1) {
|
||||
data.text = data.text.concat(data.email.splice(1));
|
||||
}
|
||||
|
||||
address = {
|
||||
// Join values with spaces
|
||||
email: decodeEncodedWords(data.email.join(' ').trim()),
|
||||
name: decodeEncodedWords(data.text.join(' ').trim())
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
};
|
||||
|
||||
if (address.email === address.name) {
|
||||
if (address.email.includes('@')) {
|
||||
address.name = '';
|
||||
} else {
|
||||
address.email = '';
|
||||
}
|
||||
}
|
||||
|
||||
// address.email = address.email.replace(/^[<]+(.*)[>]+$/g, '$1');
|
||||
|
||||
addresses.push(address);
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
35
dev/Mime/Encoding.js
Normal file
35
dev/Mime/Encoding.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
const
|
||||
QPDecodeParams = [/=([0-9A-F]{2})/g, (...args) => String.fromCharCode(parseInt(args[1], 16))];
|
||||
|
||||
export const
|
||||
// https://datatracker.ietf.org/doc/html/rfc2045#section-6.8
|
||||
BDecode = atob,
|
||||
|
||||
// unescape(encodeURIComponent()) makes the UTF-16 DOMString to an UTF-8 string
|
||||
BEncode = data => btoa(unescape(encodeURIComponent(data))),
|
||||
/* // Without deprecated 'unescape':
|
||||
BEncode = data => btoa(encodeURIComponent(data).replace(
|
||||
/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode('0x' + p1)
|
||||
)),
|
||||
*/
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2045#section-6.7
|
||||
QPDecode = data => data.replace(/=\r?\n/g, '').replace(...QPDecodeParams),
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2047#section-4.1
|
||||
// https://datatracker.ietf.org/doc/html/rfc2047#section-4.2
|
||||
// encoded-word = "=?" charset "?" encoding "?" encoded-text "?="
|
||||
decodeEncodedWords = data =>
|
||||
data.replace(/=\?([^?]+)\?(B|Q)\?(.+?)\?=/g, (m, charset, encoding, text) =>
|
||||
decodeText(charset, 'B' == encoding ? BDecode(text) : QPDecode(text))
|
||||
)
|
||||
,
|
||||
|
||||
decodeText = (charset, data) => {
|
||||
try {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings
|
||||
return new TextDecoder(charset).decode(Uint8Array.from(data, c => c.charCodeAt(0)));
|
||||
} catch (e) {
|
||||
console.error({charset:charset,error:e});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,17 +1,5 @@
|
|||
//import { b64Encode } from 'Common/Utils';
|
||||
|
||||
const
|
||||
// RFC2045
|
||||
QPDecodeParams = [/=([0-9A-F]{2})/g, (...args) => String.fromCharCode(parseInt(args[1], 16))],
|
||||
QPDecode = data => data.replace(/=\r?\n/g, '').replace(...QPDecodeParams),
|
||||
decodeText = (charset, data) => {
|
||||
try {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings
|
||||
return new TextDecoder(charset).decode(Uint8Array.from(data, c => c.charCodeAt(0)));
|
||||
} catch (e) {
|
||||
console.error({charset:charset,error:e});
|
||||
}
|
||||
};
|
||||
import { decodeEncodedWords, BDecode, BEncode, QPDecode, decodeText } from 'Mime/Encoding';
|
||||
import { addressparser } from 'Mime/Address';
|
||||
|
||||
export function ParseMime(text)
|
||||
{
|
||||
|
|
@ -27,7 +15,52 @@ export function ParseMime(text)
|
|||
this.bodyEnd = 0;
|
||||
this.boundary = '';
|
||||
this.bodyText = '';
|
||||
this.headers = {};
|
||||
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6
|
||||
// https://datatracker.ietf.org/doc/html/rfc4021
|
||||
this.headers = {
|
||||
// Required
|
||||
date = null,
|
||||
from = [], // mailbox-list
|
||||
// Optional
|
||||
sender = [], // mailbox MUST occur with multi-address
|
||||
'reply-to' = [], // address-list
|
||||
to = [], // address-list
|
||||
cc = [], // address-list
|
||||
bcc = [], // address-list
|
||||
'message-id' = '', // msg-id SHOULD be present
|
||||
'in-reply-to' = '', // 1*msg-id SHOULD occur in some replies
|
||||
references = '', // 1*msg-id SHOULD occur in some replies
|
||||
subject = '', // unstructured
|
||||
// Optional unlimited
|
||||
comments = [], // unstructured
|
||||
keywords = [], // phrase *("," phrase)
|
||||
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6.6
|
||||
'resent-date' = [],
|
||||
'resent-from' = [],
|
||||
'resent-sender' = [],
|
||||
'resent-to' = [],
|
||||
'resent-cc' = [],
|
||||
'resent-bcc' = [],
|
||||
'resent-msg-id' = [],
|
||||
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6.7
|
||||
trace = [],
|
||||
'return-path' = '', // angle-addr
|
||||
received = [],
|
||||
// optional others outside RFC2822
|
||||
'mime-version' = '', // RFC2045
|
||||
'content-transfer-encoding' = '',
|
||||
'content-type' = '',
|
||||
'delivered-to' = [], // RFC9228 addr-spec
|
||||
'authentication-results' = '', // dkim, spf, dmarc
|
||||
'dkim-signature' = '',
|
||||
'x-rspamd-queue-id' = '',
|
||||
'x-rspamd-action' = '',
|
||||
'x-spamd-bar' = '',
|
||||
'x-rspamd-server' = '',
|
||||
'x-spamd-result' = '',
|
||||
'x-remote-address' = '',
|
||||
// etc.
|
||||
};
|
||||
}
|
||||
*/
|
||||
|
||||
|
|
@ -50,26 +83,25 @@ export function ParseMime(text)
|
|||
get body() {
|
||||
let body = this.bodyRaw,
|
||||
charset = this.header('content-type')?.params.charset,
|
||||
encoding = this.headerValue('content-transfer-encoding');
|
||||
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
|
||||
if ('quoted-printable' == encoding) {
|
||||
body = QPDecode(body);
|
||||
} else if ('base64' == encoding) {
|
||||
body = atob(body.replace(/\r?\n/g, ''));
|
||||
body = BDecode(body.replace(/\r?\n/g, ''));
|
||||
}
|
||||
return decodeText(charset, body);
|
||||
}
|
||||
|
||||
get dataUrl() {
|
||||
let body = this.bodyRaw,
|
||||
encoding = this.headerValue('content-transfer-encoding');
|
||||
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
|
||||
if ('base64' == encoding) {
|
||||
body = body.replace(/\r?\n/g, '');
|
||||
} else {
|
||||
if ('quoted-printable' == encoding) {
|
||||
body = QPDecode(body);
|
||||
}
|
||||
body = btoa(body);
|
||||
// body = b64Encode(body);
|
||||
body = BEncode(body);
|
||||
}
|
||||
return 'data:' + this.headerValue('content-type') + ';base64,' + body;
|
||||
}
|
||||
|
|
@ -80,7 +112,7 @@ export function ParseMime(text)
|
|||
}
|
||||
|
||||
getByContentType(type) {
|
||||
if (type == this.headerValue('content-type')) {
|
||||
if (type == this.headerValue('content-type')?.toLowerCase()) {
|
||||
return this;
|
||||
}
|
||||
let i = 0, p = this.parts, part;
|
||||
|
|
@ -92,6 +124,9 @@ export function ParseMime(text)
|
|||
}
|
||||
}
|
||||
|
||||
// mailbox-list or address-list
|
||||
const lists = ['from','reply-to','to','cc','bcc'];
|
||||
|
||||
const ParsePart = (mimePart, start_pos = 0, id = '') =>
|
||||
{
|
||||
let part = new MimePart,
|
||||
|
|
@ -113,11 +148,19 @@ export function ParseMime(text)
|
|||
[...header.matchAll(/;\s*([^;=]+)=\s*"?([^;"]+)"?/g)].forEach(param =>
|
||||
params[param[1].trim().toLowerCase()] = param[2].trim()
|
||||
);
|
||||
// encoded-word = "=?" charset "?" encoding "?" encoded-text "?="
|
||||
match[2] = match[2].trim().replace(/=\?([^?]+)\?(B|Q)\?(.+?)\?=/g, (m, charset, encoding, text) =>
|
||||
decodeText(charset, 'B' == encoding ? atob(text) : QPDecode(text))
|
||||
);
|
||||
headers[match[1].trim().toLowerCase()] = {
|
||||
let field = match[1].trim().toLowerCase();
|
||||
if (lists.includes(field)) {
|
||||
match[2] = addressparser(match[2]);
|
||||
} else if ('keywords' === field) {
|
||||
match[2] = match[2].split(',').forEach(entry => decodeEncodedWords(entry.trim()));
|
||||
match[2] = (headers[field]?.value || []).concat(match[2]);
|
||||
} else {
|
||||
match[2] = decodeEncodedWords(match[2].trim());
|
||||
if ('comments' === field) {
|
||||
match[2] = (headers[field]?.value || []).push(match[2]);
|
||||
}
|
||||
}
|
||||
headers[field] = {
|
||||
value: match[2],
|
||||
params: params
|
||||
};
|
||||
|
|
@ -132,7 +175,7 @@ export function ParseMime(text)
|
|||
let boundary = headers['content-type']?.params.boundary;
|
||||
if (boundary) {
|
||||
part.boundary = boundary;
|
||||
let regex = new RegExp('(?:^|\r?\n)--' + boundary + '(?:--)?(?:\r?\n|$)', 'g'),
|
||||
let regex = new RegExp('(?:^|\r?\n)--' + RegExp.escape(boundary) + '(?:--)?(?:\r?\n|$)', 'g'),
|
||||
body = mimePart.slice(head.length),
|
||||
bodies = body.split(regex),
|
||||
pos = part.bodyStart;
|
||||
|
|
|
|||
|
|
@ -4,23 +4,34 @@ import { AttachmentModel } from 'Model/Attachment';
|
|||
import { FileInfo } from 'Common/File';
|
||||
import { BEGIN_PGP_MESSAGE } from 'Stores/User/Pgp';
|
||||
|
||||
import { EmailModel } from 'Model/Email';
|
||||
|
||||
/**
|
||||
* @param string data
|
||||
* @param MessageModel message
|
||||
*/
|
||||
export function MimeToMessage(data, message)
|
||||
{
|
||||
let signed;
|
||||
const struct = ParseMime(data);
|
||||
if (struct.headers) {
|
||||
let html = struct.getByContentType('text/html'),
|
||||
subject = struct.headerValue('subject');
|
||||
html = html ? html.body : '';
|
||||
|
||||
// Content-Type: ...; protected-headers="v1"
|
||||
subject && message.subject(subject);
|
||||
|
||||
// EmailCollectionModel
|
||||
['from','to'].forEach(name => message[name].fromString(struct.headerValue(name)));
|
||||
['from','to'].forEach(name => {
|
||||
const items = message[name];
|
||||
struct.headerValue(name)?.forEach(item => {
|
||||
item = new EmailModel(item.email, item.name);
|
||||
// Make them unique
|
||||
if (item.email && item.name || !items.find(address => address.email == item.email)) {
|
||||
items.push(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
struct.forEach(part => {
|
||||
let cd = part.header('content-disposition'),
|
||||
|
|
@ -54,12 +65,28 @@ export function MimeToMessage(data, message)
|
|||
} else {
|
||||
message.attachments.push(attachment);
|
||||
}
|
||||
} else if ('multipart/signed' === type.value && 'application/pgp-signature' === type.params.protocol) {
|
||||
signed = {
|
||||
} else if ('multipart/signed' === type.value) {
|
||||
let protocol = type.params.protocol;
|
||||
if ('application/pgp-signature' === protocol) {
|
||||
message.pgpSigned({
|
||||
micAlg: type.micalg,
|
||||
bodyPart: part.parts[0],
|
||||
sigPart: part.parts[1]
|
||||
});
|
||||
} else if ('application/pkcs7-signature' === protocol.replace('x-')) {
|
||||
message.smimeSigned({
|
||||
micAlg: type.micalg,
|
||||
bodyPart: part,
|
||||
sigPart: part.parts[1], // For importing
|
||||
detached: true
|
||||
});
|
||||
}
|
||||
} else if ('application/pkcs7-mime' === type.value /*&& 'signed-data' === type.params['smime-type']=*/) {
|
||||
message.smimeSigned({
|
||||
micAlg: type.micalg,
|
||||
bodyPart: part.parts[0],
|
||||
sigPart: part.parts[1]
|
||||
};
|
||||
bodyPart: part,
|
||||
detached: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -70,10 +97,7 @@ export function MimeToMessage(data, message)
|
|||
message.plain(data);
|
||||
}
|
||||
|
||||
if (!signed && message.plain().includes(BEGIN_PGP_MESSAGE)) {
|
||||
signed = true;
|
||||
if (message.plain().includes(BEGIN_PGP_MESSAGE)) {
|
||||
message.pgpSigned(true);
|
||||
}
|
||||
message.pgpSigned(signed);
|
||||
|
||||
// TODO: Verify instantly?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export class AbstractCollectionModel extends Array
|
|||
constructor() {
|
||||
/*
|
||||
if (new.target === AbstractCollectionModel) {
|
||||
throw new Error("Can't instantiate AbstractCollectionModel!");
|
||||
throw Error("Can't instantiate AbstractCollectionModel!");
|
||||
}
|
||||
*/
|
||||
super();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ export class AccountModel extends AbstractModel {
|
|||
&& setTimeout(()=>this.fetchUnread(), (Math.ceil(Math.random() * 10)) * 3000);
|
||||
}
|
||||
|
||||
label() {
|
||||
return this.name || IDN.toUnicode(this.email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get INBOX unread messages
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -126,7 +126,10 @@ export class AttachmentModel extends AbstractModel {
|
|||
}
|
||||
|
||||
get download() {
|
||||
return b64EncodeJSONSafe({
|
||||
return b64EncodeJSONSafe(this.url ? {
|
||||
fileName: this.fileName,
|
||||
data: this.url.replace(/^.+,/, '')
|
||||
} : {
|
||||
folder: this.folder,
|
||||
uid: this.uid,
|
||||
mimeIndex: this.mimeIndex,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { baseCollator } from 'Common/Translator';
|
||||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||
import { AttachmentModel } from 'Model/Attachment';
|
||||
|
||||
|
|
@ -10,14 +11,24 @@ export class AttachmentCollectionModel extends AbstractCollectionModel
|
|||
* @returns {AttachmentCollectionModel}
|
||||
*/
|
||||
static reviveFromJson(items) {
|
||||
return super.reviveFromJson(items, attachment => AttachmentModel.reviveFromJson(attachment));
|
||||
/*
|
||||
const attachments = super.reviveFromJson(items, attachment => AttachmentModel.reviveFromJson(attachment));
|
||||
let collator = baseCollator(true);
|
||||
attachments.sort((a, b) => {
|
||||
if (a.isInline()) {
|
||||
if (!b.isInline()) {
|
||||
return 1;
|
||||
}
|
||||
} else if (!b.isInline()) {
|
||||
return -1;
|
||||
}
|
||||
return collator.compare(a.fileName, b.fileName);
|
||||
});
|
||||
/*
|
||||
if (attachments) {
|
||||
attachments.InlineCount = attachments.reduce((accumulator, a) => accumulator + (a.isInline ? 1 : 0), 0);
|
||||
}
|
||||
return attachments;
|
||||
*/
|
||||
return attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export class ContactModel extends AbstractModel {
|
|||
focused: false,
|
||||
selected: false,
|
||||
checked: false,
|
||||
sendToAll: true,
|
||||
|
||||
deleted: false,
|
||||
readOnly: false,
|
||||
|
|
@ -134,22 +135,6 @@ export class ContactModel extends AbstractModel {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array|null}
|
||||
*/
|
||||
getNameAndEmailHelper() {
|
||||
let name = (this.givenName() + ' ' + this.surName()).trim(),
|
||||
email = this.email()[0]?.value();
|
||||
/*
|
||||
// this.jCard.getOne('fn')?.notEmpty() ||
|
||||
this.jCard.parseFullName({set:true});
|
||||
// let name = this.jCard.getOne('nickname'),
|
||||
let name = this.jCard.getOne('fn'),
|
||||
email = this.jCard.getOne('email');
|
||||
*/
|
||||
return email ? [email, name] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @static
|
||||
* @param {jCard} json
|
||||
|
|
@ -209,19 +194,15 @@ export class ContactModel extends AbstractModel {
|
|||
return contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
generateUid() {
|
||||
return '' + this.id;
|
||||
}
|
||||
|
||||
addEmail() {
|
||||
// home, work
|
||||
this.email.push({
|
||||
value: ko.observable('')
|
||||
// type: prop.params.type
|
||||
});
|
||||
|
||||
if (this.sendToAllDisplayStatus())
|
||||
document.getElementById('send-to-all').style.display = 'block';
|
||||
}
|
||||
|
||||
addTel() {
|
||||
|
|
@ -318,4 +299,9 @@ export class ContactModel extends AbstractModel {
|
|||
+ (this.checked() ? ' checked' : '')
|
||||
+ (this.focused() ? ' focused' : '');
|
||||
}
|
||||
|
||||
sendToAllDisplayStatus() {
|
||||
return this.email.length > 1
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,202 +4,6 @@ import { AbstractModel } from 'Knoin/AbstractModel';
|
|||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 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 function addressparser(str) {
|
||||
str = (str || '').toString();
|
||||
|
||||
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 = 'email';
|
||||
} else if ('(' === chr) {
|
||||
node.type = 'comment';
|
||||
} else if (':' === chr) {
|
||||
node.type = 'group';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
node.value += chr;
|
||||
escaped = !escaped && '\\' === chr;
|
||||
}
|
||||
});
|
||||
pushToken(node);
|
||||
|
||||
pushAddress();
|
||||
|
||||
return addresses;
|
||||
// return addresses.map(item => (item.name || item.email) ? new EmailModel(item.email, item.name) : null).filter(v => v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
address = {},
|
||||
addresses = [],
|
||||
data = {
|
||||
email: [],
|
||||
comment: [],
|
||||
group: [],
|
||||
text: []
|
||||
};
|
||||
|
||||
tokens.forEach(token => {
|
||||
isGroup = isGroup || 'group' === token.type;
|
||||
data[token.type].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
|
||||
/*
|
||||
addresses.push({
|
||||
email: '',
|
||||
name: data.text.join(' ').trim(),
|
||||
group: addressparser(data.group.join(','))
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
});
|
||||
*/
|
||||
addresses = addresses.concat(addressparser(data.group.join(',')));
|
||||
} else {
|
||||
// If no address was found, try to detect one from regular text
|
||||
if (!data.email.length && data.text.length) {
|
||||
var i = data.text.length;
|
||||
while (i--) {
|
||||
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
||||
data.email = data.text.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// still no address
|
||||
if (!data.email.length) {
|
||||
i = data.text.length;
|
||||
while (i--) {
|
||||
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, address => {
|
||||
if (!data.email.length) {
|
||||
data.email = [address.trim()];
|
||||
return '';
|
||||
}
|
||||
return address.trim();
|
||||
});
|
||||
if (data.email.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's still no text but a comment exists, 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.email.length > 1) {
|
||||
data.text = data.text.concat(data.email.splice(1));
|
||||
}
|
||||
|
||||
address = {
|
||||
// Join values with spaces
|
||||
email: data.email.join(' ').trim(),
|
||||
name: data.text.join(' ').trim()
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
};
|
||||
|
||||
if (address.email === address.name) {
|
||||
if (address.email.includes('@')) {
|
||||
address.name = '';
|
||||
} else {
|
||||
address.email = '';
|
||||
}
|
||||
}
|
||||
|
||||
// address.email = address.email.replace(/^[<]+(.*)[>]+$/g, '$1');
|
||||
|
||||
addresses.push(address);
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
export class EmailModel extends AbstractModel {
|
||||
/**
|
||||
* @param {string=} email = ''
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||
import { EmailModel, addressparser } from 'Model/Email';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { forEachObjectValue } from 'Common/Utils';
|
||||
import { addressparser } from 'Mime/Address';
|
||||
|
||||
'use strict';
|
||||
|
||||
|
|
@ -51,4 +52,24 @@ export class EmailCollectionModel extends AbstractCollectionModel
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {array} [{name: "Name", email: "address@domain"}]
|
||||
*/
|
||||
/*
|
||||
static fromArray(addresses) {
|
||||
let list = new this();
|
||||
list.fromArray(addresses);
|
||||
return list;
|
||||
}
|
||||
fromArray(addresses) {
|
||||
addresses.forEach(item => {
|
||||
item = new EmailModel(item.email, item.name);
|
||||
// Make them unique
|
||||
if (item.email && item.name || !this.find(address => address.email == item.email)) {
|
||||
this.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
|||
import { UNUSED_OPTION_VALUE } from 'Common/Consts';
|
||||
import { isArray, getKeyByValue, forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
|
||||
import { ClientSideKeyNameExpandedFolders, FolderType, FolderMetadataKeys } from 'Common/EnumsUser';
|
||||
import { clearCache, getFolderFromCacheList, setFolder, setFolderInboxName, removeFolderFromCacheList } from 'Common/Cache';
|
||||
import { clearCache, getFolderFromCacheList, setFolder, setFolderInboxName } from 'Common/Cache';
|
||||
import { Settings, SettingsGet, fireEvent } from 'Common/Globals';
|
||||
import { Notifications } from 'Common/Enums';
|
||||
|
||||
import * as Local from 'Storage/Client';
|
||||
|
||||
|
|
@ -15,16 +14,23 @@ import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
|||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
import { sortFolders } from 'Common/Folders';
|
||||
import { i18n, translateTrigger, getNotification } from 'Common/Translator';
|
||||
import { i18n, translateTrigger } from 'Common/Translator';
|
||||
|
||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
|
||||
import { /*koComputable,*/ addObservablesTo } from 'External/ko';
|
||||
|
||||
//import { mailBox } from 'Common/Links';
|
||||
import { mailBox } from 'Common/Links';
|
||||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { FileInfo } from 'Common/File';
|
||||
|
||||
import { FolderPopupView } from 'View/Popup/Folder';
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
|
||||
import { isAllowedKeyword } from 'Stores/User/Folder';
|
||||
|
||||
const
|
||||
// isPosNumeric = value => null != value && /^[0-9]*$/.test(value.toString()),
|
||||
|
||||
|
|
@ -98,7 +104,7 @@ export const
|
|||
// Repeat every 15 minutes?
|
||||
// this.foldersTimeout = setTimeout(loadFolders, 900000);
|
||||
})
|
||||
.catch(() => fCallback && setTimeout(fCallback, 1, false));
|
||||
.catch(e => fCallback && setTimeout(fCallback, 1, false, e));
|
||||
};
|
||||
|
||||
export class FolderCollectionModel extends AbstractCollectionModel
|
||||
|
|
@ -111,6 +117,8 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
|||
this.namespace;
|
||||
this.optimized
|
||||
this.capabilities
|
||||
this.allow; // allow adding
|
||||
// this.exist;
|
||||
}
|
||||
*/
|
||||
|
||||
|
|
@ -252,6 +260,10 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
|||
return result;
|
||||
}
|
||||
|
||||
visible() {
|
||||
return this.filter(folder => folder.visible());
|
||||
}
|
||||
|
||||
storeIt() {
|
||||
FolderUserStore.displaySpecSetting(Settings.app('folderSpecLimit') < this.CountRec);
|
||||
|
||||
|
|
@ -273,7 +285,7 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
|||
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
|
||||
AppUserStore.threadsAllowed(!!this.capabilities.some(capa => capa.startsWith('THREAD=')));
|
||||
|
||||
// FolderUserStore.folderListOptimized(!!this.optimized);
|
||||
// FolderUserStore.optimized(!!this.optimized);
|
||||
FolderUserStore.quotaUsage(this.quotaUsage);
|
||||
FolderUserStore.quotaLimit(this.quotaLimit);
|
||||
FolderUserStore.capabilities(this.capabilities);
|
||||
|
|
@ -294,6 +306,7 @@ export class FolderModel extends AbstractModel {
|
|||
super();
|
||||
|
||||
this.fullName = '';
|
||||
this.parentName = '';
|
||||
this.delimiter = '';
|
||||
this.deep = 0;
|
||||
this.expires = 0;
|
||||
|
|
@ -304,6 +317,7 @@ export class FolderModel extends AbstractModel {
|
|||
this.etag = '';
|
||||
this.id = 0;
|
||||
this.uidNext = 0;
|
||||
this.size = 0;
|
||||
|
||||
addObservablesTo(this, {
|
||||
name: '',
|
||||
|
|
@ -313,12 +327,10 @@ export class FolderModel extends AbstractModel {
|
|||
|
||||
focused: false,
|
||||
selected: false,
|
||||
editing: false,
|
||||
isSubscribed: true,
|
||||
checkable: false, // Check for new messages
|
||||
askDelete: false,
|
||||
|
||||
nameForEdit: '',
|
||||
errorMsg: '',
|
||||
|
||||
totalEmails: 0,
|
||||
|
|
@ -338,7 +350,6 @@ export class FolderModel extends AbstractModel {
|
|||
this.addSubscribables({
|
||||
kolabType: sValue => this.metadata[FolderMetadataKeys.KolabFolderType] = sValue,
|
||||
permanentFlags: aValue => this.tagsAllowed(aValue.includes('\\*')),
|
||||
editing: value => value && this.nameForEdit(this.name()),
|
||||
unreadEmails: unread => FolderType.Inbox === this.type() && fireEvent('mailbox.inbox-unread-count', unread)
|
||||
});
|
||||
|
||||
|
|
@ -360,20 +371,19 @@ export class FolderModel extends AbstractModel {
|
|||
.extend({ notify: 'always' });
|
||||
*/
|
||||
/*
|
||||
https://www.rfc-editor.org/rfc/rfc8621.html#section-2
|
||||
"myRights": {
|
||||
"mayAddItems": true,
|
||||
"mayRename": false,
|
||||
"maySubmit": true,
|
||||
"mayDelete": false,
|
||||
"maySetKeywords": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"maySetSeen": true,
|
||||
"mayReadItems": true
|
||||
},
|
||||
// https://www.rfc-editor.org/rfc/rfc8621.html#section-2
|
||||
this.myRights = {
|
||||
'mayAddItems': true,
|
||||
'mayCreateChild': true,
|
||||
'mayDelete': true,
|
||||
'mayReadItems': true,
|
||||
'mayRemoveItems': true,
|
||||
'mayRename': true,
|
||||
'maySetKeywords': true,
|
||||
'maySetSeen': true,
|
||||
'maySubmit': true
|
||||
};
|
||||
*/
|
||||
|
||||
this.addComputables({
|
||||
|
||||
isInbox: () => FolderType.Inbox === this.type(),
|
||||
|
|
@ -384,6 +394,7 @@ export class FolderModel extends AbstractModel {
|
|||
// isSubscribed: () => this.attributes().includes('\\subscribed'),
|
||||
|
||||
hasVisibleSubfolders: () => !!this.subFolders().find(folder => folder.visible()),
|
||||
visibleSubfolders: () => this.subFolders().visible(),
|
||||
|
||||
hasSubscriptions: () => this.isSubscribed() | !!this.subFolders().find(
|
||||
oFolder => {
|
||||
|
|
@ -392,8 +403,6 @@ export class FolderModel extends AbstractModel {
|
|||
}
|
||||
),
|
||||
|
||||
canBeEdited: () => !this.type() && this.exists/* && this.selectable()*/,
|
||||
|
||||
isSystemFolder: () => this.type()
|
||||
| (FolderUserStore.allowKolab() && !!this.kolabType() & !SettingsUserStore.unhideKolabFolders()),
|
||||
|
||||
|
|
@ -404,6 +413,8 @@ export class FolderModel extends AbstractModel {
|
|||
canBeSubscribed: () => this.selectable()
|
||||
&& !(this.isSystemFolder() | !SettingsUserStore.hideUnsubscribed()),
|
||||
|
||||
optionalTags: () => this.permanentFlags.filter(isAllowedKeyword),
|
||||
|
||||
/**
|
||||
* Folder is visible when:
|
||||
* - hasVisibleSubfolders()
|
||||
|
|
@ -456,62 +467,34 @@ export class FolderModel extends AbstractModel {
|
|||
return '';
|
||||
},
|
||||
|
||||
friendlySize: () => FileInfo.friendlySize(this.size),
|
||||
|
||||
detailedName: () => this.name() + ' ' + this.nameInfo(),
|
||||
|
||||
hasSubscribedUnreadMessagesSubfolders: () =>
|
||||
!!this.subFolders().find(
|
||||
folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
|
||||
)
|
||||
/*
|
||||
!!this.subFolders().filter(
|
||||
folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
|
||||
).length
|
||||
*/
|
||||
// ,href: () => this.canBeSelected() && mailBox(this.fullNameHash)
|
||||
icon: () => {
|
||||
switch (this.type())
|
||||
{
|
||||
case 1: return '📥'; // FolderType.Inbox
|
||||
case 2: return '📧'; // FolderType.Sent icon-paper-plane
|
||||
case 3: return '🗎'; // FolderType.Drafts
|
||||
case 4: return '⚠'; // FolderType.Junk
|
||||
case 5: return '🗑'; // FolderType.Trash
|
||||
case 6: return '🗄'; // FolderType.Archive
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
hasUnreadInSub: () =>
|
||||
this.subFolders().some(
|
||||
folder => folder.unreadEmails() | folder.hasUnreadInSub()
|
||||
),
|
||||
|
||||
href: () => this.canBeSelected() && mailBox(this.fullNameHash)
|
||||
});
|
||||
}
|
||||
|
||||
edit() {
|
||||
this.canBeEdited() && this.editing(true);
|
||||
}
|
||||
|
||||
unedit() {
|
||||
this.editing(false);
|
||||
}
|
||||
|
||||
rename() {
|
||||
const folder = this,
|
||||
nameToEdit = folder.nameForEdit().trim();
|
||||
if (nameToEdit && folder.name() !== nameToEdit) {
|
||||
Remote.abort('Folders').post('FolderRename', FolderUserStore.foldersRenaming, {
|
||||
folder: folder.fullName,
|
||||
newFolderName: nameToEdit,
|
||||
subscribe: folder.isSubscribed() ? 1 : 0
|
||||
})
|
||||
.then(data => {
|
||||
folder.name(nameToEdit/*data.name*/);
|
||||
if (folder.subFolders.length) {
|
||||
Remote.setTrigger(FolderUserStore.foldersLoading, true);
|
||||
// clearTimeout(Remote.foldersTimeout);
|
||||
// Remote.foldersTimeout = setTimeout(loadFolders, 500);
|
||||
setTimeout(loadFolders, 500);
|
||||
// TODO: rename all subfolders with folder.delimiter to prevent reload?
|
||||
} else {
|
||||
removeFolderFromCacheList(folder.fullName);
|
||||
folder.fullName = data.Result.fullName;
|
||||
setFolder(folder);
|
||||
const parent = getFolderFromCacheList(folder.parentName);
|
||||
sortFolders(parent ? parent.subFolders : FolderUserStore.folderList);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
FolderUserStore.folderListError(
|
||||
getNotification(error.code, '', Notifications.CantRenameFolder)
|
||||
+ '.\n' + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
folder.editing(false);
|
||||
showScreenPopup(FolderPopupView, [this]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -543,6 +526,8 @@ export class FolderModel extends AbstractModel {
|
|||
|
||||
folder.isSubscribed(attr('\\subscribed'));
|
||||
folder.exists = !attr('\\nonexistent');
|
||||
folder.subFolders.allow = !attr('\\noinferiors');
|
||||
// folder.subFolders.exist = attr('\\haschildren') || !attr('\\hasnochildren');
|
||||
folder.selectable(folder.exists && !attr('\\noselect'));
|
||||
|
||||
type && 'mail' != type && folder.kolabType(type);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
import { addObservablesTo } from 'External/ko';
|
||||
import { addObservablesTo, addComputablesTo } from 'External/ko';
|
||||
|
||||
export class IdentityModel extends AbstractModel {
|
||||
/**
|
||||
|
|
@ -11,16 +11,32 @@ export class IdentityModel extends AbstractModel {
|
|||
|
||||
addObservablesTo(this, {
|
||||
id: '',
|
||||
label: '',
|
||||
email: '',
|
||||
name: '',
|
||||
|
||||
replyTo: '',
|
||||
bcc: '',
|
||||
sentFolder: '',
|
||||
|
||||
signature: '',
|
||||
signatureInsertBefore: false,
|
||||
|
||||
askDelete: false
|
||||
pgpSign: false,
|
||||
pgpEncrypt: false,
|
||||
|
||||
smimeKey: '',
|
||||
smimeCertificate: '',
|
||||
|
||||
askDelete: false,
|
||||
|
||||
exists: false
|
||||
});
|
||||
|
||||
addComputablesTo(this, {
|
||||
smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----'),
|
||||
smimeKeyValid: () => /^-----BEGIN (ENCRYPTED |RSA )?PRIVATE KEY-----/.test(this.smimeKey()),
|
||||
smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate())
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -29,8 +45,8 @@ export class IdentityModel extends AbstractModel {
|
|||
*/
|
||||
formattedName() {
|
||||
const name = this.name(),
|
||||
email = this.email();
|
||||
|
||||
return name ? name + ' <' + email + '>' : email;
|
||||
email = this.email(),
|
||||
label = this.label();
|
||||
return (name ? `${name} ` : '') + `<${email}>` + (label ? ` (${label})` : '');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,27 +2,85 @@ import ko from 'ko';
|
|||
|
||||
import { i18n } from 'Common/Translator';
|
||||
|
||||
import { doc, SettingsGet } from 'Common/Globals';
|
||||
import { doc, elementById, SettingsGet } from 'Common/Globals';
|
||||
import { encodeHtml, plainToHtml, htmlToPlain, cleanHtml } from 'Common/Html';
|
||||
import { forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
|
||||
import { serverRequestRaw, proxy } from 'Common/Links';
|
||||
import { addObservablesTo, addComputablesTo } from 'External/ko';
|
||||
|
||||
import { FolderUserStore, isAllowedKeyword } from 'Stores/User/Folder';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
import { FileInfo } from 'Common/File';
|
||||
import { FileInfo, RFC822 } from 'Common/File';
|
||||
import { AttachmentCollectionModel } from 'Model/AttachmentCollection';
|
||||
import { EmailCollectionModel } from 'Model/EmailCollection';
|
||||
import { MimeHeaderCollectionModel } from 'Model/MimeHeaderCollection';
|
||||
//import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
|
||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
|
||||
import PreviewHTML from 'Html/PreviewMessage.html';
|
||||
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { MimeToMessage } from 'Mime/Utils';
|
||||
|
||||
const
|
||||
PreviewHTML = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title></title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(125,128,128,0.3);
|
||||
border-bottom: 1px solid #888;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
header * {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
header time {
|
||||
float: right;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 2px solid rgba(125,128,128,0.5);
|
||||
margin: 0;
|
||||
padding: 0 0 0 10px;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
body > * {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
#attachments > * {
|
||||
border: 1px solid rgba(125,128,128,0.5);
|
||||
padding: 0.25em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
#attachments > *::before {
|
||||
content: '📎 ';
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`,
|
||||
|
||||
msgHtml = msg => cleanHtml(msg.html(), msg.attachments(), '#rl-msg-' + msg.hash),
|
||||
|
||||
toggleTag = (message, keyword) => {
|
||||
|
|
@ -55,43 +113,51 @@ export class MessageModel extends AbstractModel {
|
|||
constructor() {
|
||||
super();
|
||||
|
||||
this.folder = '';
|
||||
this.uid = 0;
|
||||
this.hash = '';
|
||||
this.from = new EmailCollectionModel;
|
||||
this.to = new EmailCollectionModel;
|
||||
this.cc = new EmailCollectionModel;
|
||||
this.bcc = new EmailCollectionModel;
|
||||
this.sender = new EmailCollectionModel;
|
||||
this.replyTo = new EmailCollectionModel;
|
||||
this.deliveredTo = new EmailCollectionModel;
|
||||
this.body = null;
|
||||
this.draftInfo = [];
|
||||
this.dkim = [];
|
||||
this.spf = [];
|
||||
this.dmarc = [];
|
||||
this.messageId = '';
|
||||
this.inReplyTo = '';
|
||||
this.references = '';
|
||||
this.autocrypt = {};
|
||||
Object.assign(this, {
|
||||
folder: '',
|
||||
uid: 0,
|
||||
hash: '',
|
||||
from: new EmailCollectionModel,
|
||||
to: new EmailCollectionModel,
|
||||
cc: new EmailCollectionModel,
|
||||
bcc: new EmailCollectionModel,
|
||||
sender: new EmailCollectionModel,
|
||||
replyTo: new EmailCollectionModel,
|
||||
deliveredTo: new EmailCollectionModel,
|
||||
body: null,
|
||||
draftInfo: [],
|
||||
dkim: [],
|
||||
spf: [],
|
||||
dmarc: [],
|
||||
messageId: '',
|
||||
inReplyTo: '',
|
||||
references: '',
|
||||
// autocrypt: ko.observableArray(),
|
||||
hasVirus: null, // or boolean when scanned
|
||||
priority: 3, // Normal
|
||||
senderEmailsString: '',
|
||||
senderClearEmailsString: '',
|
||||
isSpam: false,
|
||||
spamScore: 0,
|
||||
spamResult: '',
|
||||
size: 0,
|
||||
readReceipt: '',
|
||||
preview: null,
|
||||
|
||||
attachments: ko.observableArray(new AttachmentCollectionModel),
|
||||
threads: ko.observableArray(),
|
||||
threadUnseen: ko.observableArray(),
|
||||
unsubsribeLinks: ko.observableArray(),
|
||||
flags: ko.observableArray(),
|
||||
headers: ko.observableArray(new MimeHeaderCollectionModel)
|
||||
});
|
||||
|
||||
addObservablesTo(this, {
|
||||
subject: '',
|
||||
plain: '',
|
||||
html: '',
|
||||
size: 0,
|
||||
spamScore: 0,
|
||||
spamResult: '',
|
||||
isSpam: false,
|
||||
hasVirus: null, // or boolean when scanned
|
||||
dateTimestamp: 0,
|
||||
internalTimestamp: 0,
|
||||
priority: 3, // Normal
|
||||
|
||||
senderEmailsString: '',
|
||||
senderClearEmailsString: '',
|
||||
|
||||
deleted: false,
|
||||
dateTimestampSource: 0,
|
||||
|
||||
// Also used by Selector
|
||||
focused: false,
|
||||
|
|
@ -101,26 +167,29 @@ export class MessageModel extends AbstractModel {
|
|||
isHtml: false,
|
||||
hasImages: false,
|
||||
hasExternals: false,
|
||||
|
||||
pgpSigned: null,
|
||||
pgpVerified: null,
|
||||
hasTracking: false,
|
||||
|
||||
encrypted: false,
|
||||
|
||||
pgpSigned: null,
|
||||
pgpEncrypted: null,
|
||||
pgpDecrypted: false,
|
||||
|
||||
readReceipt: '',
|
||||
smimeSigned: null,
|
||||
smimeEncrypted: null,
|
||||
smimeDecrypted: false,
|
||||
|
||||
// rfc8621
|
||||
id: '',
|
||||
// threadId: ''
|
||||
});
|
||||
|
||||
this.attachments = ko.observableArray(new AttachmentCollectionModel);
|
||||
this.threads = ko.observableArray();
|
||||
this.threadUnseen = ko.observableArray();
|
||||
this.unsubsribeLinks = ko.observableArray();
|
||||
this.flags = ko.observableArray();
|
||||
/**
|
||||
* Basic support for Linked Data (Structured Email)
|
||||
* https://json-ld.org/
|
||||
* https://structured.email/
|
||||
**/
|
||||
linkedData: []
|
||||
});
|
||||
|
||||
addComputablesTo(this, {
|
||||
attachmentIconClass: () =>
|
||||
|
|
@ -128,24 +197,28 @@ export class MessageModel extends AbstractModel {
|
|||
threadsLen: () => rl.app.messageList.threadUid() ? 0 : this.threads().length,
|
||||
threadUnseenLen: () => rl.app.messageList.threadUid() ? 0 : this.threadUnseen().length,
|
||||
|
||||
threadsLenText: () => {
|
||||
const unseenLen = this.threadUnseenLen();
|
||||
return this.threadsLen() + (unseenLen > 0 ? '/' + unseenLen : '');
|
||||
},
|
||||
|
||||
isUnseen: () => !this.flags().includes('\\seen'),
|
||||
isFlagged: () => this.flags().includes('\\flagged'),
|
||||
isDeleted: () => this.flags().includes('\\deleted'),
|
||||
// isJunk: () => this.flags().includes('$junk') && !this.flags().includes('$nonjunk'),
|
||||
// isPhishing: () => this.flags().includes('$phishing'),
|
||||
|
||||
tagOptions: () => {
|
||||
const tagOptions = [];
|
||||
FolderUserStore.currentFolder().permanentFlags.forEach(value => {
|
||||
if (isAllowedKeyword(value)) {
|
||||
let lower = value.toLowerCase();
|
||||
tagOptions.push({
|
||||
css: 'msgflag-' + lower,
|
||||
value: value,
|
||||
checked: this.flags().includes(lower),
|
||||
label: i18n('MESSAGE_TAGS/'+lower, 0, value),
|
||||
toggle: (/*obj*/) => toggleTag(this, value)
|
||||
});
|
||||
}
|
||||
FolderUserStore.currentFolder().optionalTags().forEach(value => {
|
||||
let lower = value.toLowerCase();
|
||||
tagOptions.push({
|
||||
css: 'msgflag-' + lower,
|
||||
value: value,
|
||||
checked: this.flags().includes(lower),
|
||||
label: i18n('MESSAGE_TAGS/'+lower, 0, value),
|
||||
toggle: (/*obj*/) => toggleTag(this, value)
|
||||
});
|
||||
});
|
||||
return tagOptions
|
||||
},
|
||||
|
|
@ -174,14 +247,18 @@ export class MessageModel extends AbstractModel {
|
|||
return options;
|
||||
}
|
||||
});
|
||||
|
||||
this.smimeSigned.subscribe(value =>
|
||||
value?.body && MimeToMessage(value.body, this)
|
||||
);
|
||||
}
|
||||
|
||||
get requestHash() {
|
||||
return b64EncodeJSONSafe({
|
||||
folder: this.folder,
|
||||
uid: this.uid,
|
||||
mimeType: 'message/rfc822',
|
||||
fileName: (this.subject() || 'message-' + this.hash) + '.eml',
|
||||
mimeType: RFC822,
|
||||
fileName: (this.subject() || 'message') + '-' + this.hash + '.eml',
|
||||
accountHash: SettingsGet('accountHash')
|
||||
});
|
||||
}
|
||||
|
|
@ -191,23 +268,23 @@ export class MessageModel extends AbstractModel {
|
|||
}
|
||||
|
||||
spamStatus() {
|
||||
let spam = this.spamResult();
|
||||
return spam ? i18n(this.isSpam() ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
|
||||
let spam = this.spamResult;
|
||||
return spam ? i18n(this.isSpam ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
friendlySize() {
|
||||
return FileInfo.friendlySize(this.size());
|
||||
return FileInfo.friendlySize(this.size);
|
||||
}
|
||||
|
||||
computeSenderEmail() {
|
||||
const list = this[
|
||||
[FolderUserStore.sentFolder(), FolderUserStore.draftsFolder()].includes(this.folder) ? 'to' : 'from'
|
||||
];
|
||||
this.senderEmailsString(list.toString(true));
|
||||
this.senderClearEmailsString(list.map(email => email?.email).filter(email => email).join(', '));
|
||||
this.senderEmailsString = list.toString(true);
|
||||
this.senderClearEmailsString = list.map(email => email?.email).filter(email => email).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -218,8 +295,63 @@ export class MessageModel extends AbstractModel {
|
|||
if (super.revivePropertiesFromJson(json)) {
|
||||
// this.foundCIDs = isArray(json.FoundCIDs) ? json.FoundCIDs : [];
|
||||
// this.attachments(AttachmentCollectionModel.reviveFromJson(json.attachments, this.foundCIDs));
|
||||
// this.headers(MimeHeaderCollectionModel.reviveFromJson(json.headers));
|
||||
|
||||
this.computeSenderEmail();
|
||||
|
||||
let value, headers = this.headers();
|
||||
/* // These could be by Envelope or MIME
|
||||
this.messageId = headers.valueByName('Message-Id');
|
||||
this.subject(headers.valueByName('Subject'));
|
||||
this.sender = EmailCollectionModel.fromString(headers.valueByName('Sender'));
|
||||
this.from = EmailCollectionModel.fromArray(headers.valueByName('From'));
|
||||
this.replyTo = EmailCollectionModel.fromArray(headers.valueByName('Reply-To'));
|
||||
this.to = EmailCollectionModel.fromArray(headers.valueByName('To'));
|
||||
this.cc = EmailCollectionModel.fromArray(headers.valueByName('Cc'));
|
||||
this.bcc = EmailCollectionModel.fromArray(headers.valueByName('Bcc'));
|
||||
this.inReplyTo = headers.valueByName('In-Reply-To');
|
||||
|
||||
this.deliveredTo = EmailCollectionModel.fromString(headers.valueByName('Delivered-To'));
|
||||
*/
|
||||
// Priority
|
||||
value = headers.valueByName('X-MSMail-Priority')
|
||||
|| headers.valueByName('Importance')
|
||||
|| headers.valueByName('X-Priority');
|
||||
if (value) {
|
||||
if (/[h12]/.test(value[0])) {
|
||||
this.priority = 1;
|
||||
} else if (/[l45]/.test(value[0])) {
|
||||
this.priority = 5;
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe links
|
||||
if (value = headers.valueByName('List-Unsubscribe')) {
|
||||
this.unsubsribeLinks(value.split(',').map(
|
||||
link => link.replace(/^[ <>]+|[ <>]+$/g, '')
|
||||
));
|
||||
}
|
||||
|
||||
if (headers.valueByName('X-Virus')) {
|
||||
this.hasVirus = true;
|
||||
}
|
||||
if (value = headers.valueByName('X-Virus-Status')) {
|
||||
if (value.includes('infected')) {
|
||||
this.hasVirus = true;
|
||||
} else if (value.includes('clean')) {
|
||||
this.hasVirus = false;
|
||||
}
|
||||
}
|
||||
/*
|
||||
if (value = headers.valueByName('X-Virus-Scanned')) {
|
||||
this.virusScanned(value);
|
||||
}
|
||||
|
||||
// https://autocrypt.org/level1.html#the-autocrypt-header
|
||||
headers.valuesByName('Autocrypt').forEach(value => {
|
||||
this.autocrypt.push(new MimeHeaderAutocryptModel(value));
|
||||
});
|
||||
*/
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -230,12 +362,11 @@ export class MessageModel extends AbstractModel {
|
|||
lineAsCss(flags=1) {
|
||||
let classes = [];
|
||||
forEachObjectEntry({
|
||||
deleted: this.deleted(),
|
||||
selected: this.selected(),
|
||||
checked: this.checked(),
|
||||
unseen: this.isUnseen(),
|
||||
focused: this.focused(),
|
||||
priorityHigh: this.priority() === 1,
|
||||
priorityHigh: this.priority === 1,
|
||||
withAttachments: !!this.attachments().length,
|
||||
// hasChildrenMessage: 1 < this.threadsLen()
|
||||
}, (key, value) => value && classes.push(key));
|
||||
|
|
@ -301,8 +432,10 @@ export class MessageModel extends AbstractModel {
|
|||
let result = msgHtml(this);
|
||||
this.hasExternals(result.hasExternals);
|
||||
this.hasImages(!!result.hasExternals);
|
||||
this.hasTracking(!!result.tracking);
|
||||
this.linkedData(result.linkedData);
|
||||
body.innerHTML = result.html;
|
||||
if (!this.isSpam() && FolderUserStore.spamFolder() != this.folder) {
|
||||
if (!this.isSpam && FolderUserStore.spamFolder() != this.folder) {
|
||||
if ('always' === SettingsUserStore.viewImages()) {
|
||||
this.showExternalImages();
|
||||
}
|
||||
|
|
@ -316,7 +449,7 @@ export class MessageModel extends AbstractModel {
|
|||
? this.plain()
|
||||
.replace(/-----BEGIN PGP (SIGNED MESSAGE-----(\r?\n[^\r\n]+)+|SIGNATURE-----[\s\S]*)/sg, '')
|
||||
.trim()
|
||||
: htmlToPlain(body.innerHTML)
|
||||
: htmlToPlain(body.innerHTML || msgHtml(this).html)
|
||||
)
|
||||
);
|
||||
this.hasImages(false);
|
||||
|
|
@ -326,6 +459,7 @@ export class MessageModel extends AbstractModel {
|
|||
this.isHtml(html);
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
viewHtml() {
|
||||
|
|
@ -336,14 +470,22 @@ export class MessageModel extends AbstractModel {
|
|||
return this.viewBody(false);
|
||||
}
|
||||
|
||||
viewPopupMessage(print) {
|
||||
swapColors() {
|
||||
const cl = this.body?.classList;
|
||||
cl && cl.toggle('swapColors');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean=} print = false
|
||||
*/
|
||||
popupMessage(print) {
|
||||
const
|
||||
timeStampInUTC = this.dateTimestamp() || 0,
|
||||
ccLine = this.cc.toString(),
|
||||
bccLine = this.bcc.toString(),
|
||||
m = 0 < timeStampInUTC ? new Date(timeStampInUTC * 1000) : null,
|
||||
win = open('', 'sm-msg-'+this.requestHash
|
||||
/*,newWindow ? 'innerWidth=' + elementById('V-MailMessageView').clientWidth : ''*/
|
||||
,SettingsUserStore.messageNewWindow() ? 'innerWidth=' + elementById('V-MailMessageView').clientWidth : ''
|
||||
),
|
||||
sdoc = win.document,
|
||||
subject = encodeHtml(this.subject()),
|
||||
|
|
@ -364,25 +506,11 @@ export class MessageModel extends AbstractModel {
|
|||
.replace('</body>', `<div id="attachments">${attachments}</div></body>`)
|
||||
);
|
||||
sdoc.close();
|
||||
print && setTimeout(() => win.print(), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean=} print = false
|
||||
*/
|
||||
popupMessage() {
|
||||
this.viewPopupMessage();
|
||||
(true === print) && setTimeout(() => win.print(), 100);
|
||||
}
|
||||
|
||||
printMessage() {
|
||||
this.viewPopupMessage(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
generateUid() {
|
||||
return this.folder + '/' + this.uid;
|
||||
this.popupMessage(true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -435,7 +563,7 @@ export class MessageModel extends AbstractModel {
|
|||
hasImages = true;
|
||||
},
|
||||
attr = 'data-x-src',
|
||||
src, useProxy = !!SettingsGet('useLocalProxyForExternalImages');
|
||||
src, useProxy = !!SettingsGet('proxyExternalImages');
|
||||
body.querySelectorAll('img[' + attr + ']').forEach(node => {
|
||||
src = node.getAttribute(attr);
|
||||
if (isValid(src)) {
|
||||
|
|
|
|||
|
|
@ -30,12 +30,13 @@ export class MessageCollectionModel extends AbstractCollectionModel
|
|||
let msg = MessageUserStore.message();
|
||||
return super.reviveFromJson(object, message => {
|
||||
// If message is currently viewed, use that.
|
||||
// Maybe then use msg.revivePropertiesFromJson(message) ?
|
||||
message = (msg && msg.hash === message.hash) ? msg : MessageModel.reviveFromJson(message);
|
||||
if (message) {
|
||||
message.deleted(false);
|
||||
return message;
|
||||
if (msg && msg.hash === message.hash) {
|
||||
msg.revivePropertiesFromJson(message);
|
||||
message = msg;
|
||||
} else {
|
||||
message = MessageModel.reviveFromJson(message);
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
dev/Model/MimeHeader.js
Normal file
13
dev/Model/MimeHeader.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import ko from 'ko';
|
||||
|
||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
|
||||
export class MimeHeaderModel extends AbstractModel
|
||||
{
|
||||
constructor() {
|
||||
super();
|
||||
this.name = '';
|
||||
this.value = '';
|
||||
this.parameters = ko.observableArray();
|
||||
}
|
||||
}
|
||||
34
dev/Model/MimeHeaderAutocrypt.js
Normal file
34
dev/Model/MimeHeaderAutocrypt.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
|
||||
export class MimeHeaderAutocryptModel/* extends AbstractModel*/
|
||||
{
|
||||
constructor(value) {
|
||||
// super();
|
||||
this.addr = '';
|
||||
this.prefer_encrypt = 'nopreference', // nopreference or mutual
|
||||
this.keydata = '';
|
||||
|
||||
if (value) {
|
||||
value.split(';').forEach(entry => {
|
||||
entry = entry.match(/^([^=]+)=(.*)$/);
|
||||
const trim = str => (str || '').trim().replace(/^["']|["']+$/g, '');
|
||||
this[trim(entry[1]).replace('-', '_')] = trim(entry[2]);
|
||||
});
|
||||
this.keydata = this.keydata.replace(/\s+/g, '\n');
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
let result = `addr=${this.addr}; `;
|
||||
if ('mutual' === this.prefer_encrypt) {
|
||||
result += 'prefer-encrypt=mutual; ';
|
||||
}
|
||||
return result + 'keydata=' + this.keydata.replace(/\n/g, '\n ');
|
||||
}
|
||||
|
||||
pem() {
|
||||
return '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n'
|
||||
+ this.keydata
|
||||
+ '\n-----END PGP PUBLIC KEY BLOCK-----';
|
||||
}
|
||||
}
|
||||
38
dev/Model/MimeHeaderCollection.js
Normal file
38
dev/Model/MimeHeaderCollection.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||
import { MimeHeaderModel } from 'Model/MimeHeader';
|
||||
|
||||
'use strict';
|
||||
|
||||
export class MimeHeaderCollectionModel extends AbstractCollectionModel
|
||||
{
|
||||
/**
|
||||
* @param {?Array} json
|
||||
* @returns {MimeHeaderCollectionModel}
|
||||
*/
|
||||
static reviveFromJson(items) {
|
||||
return super.reviveFromJson(items, header => MimeHeaderModel.reviveFromJson(header));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {?MimeHeader}
|
||||
*/
|
||||
getByName(name)
|
||||
{
|
||||
name = name.toLowerCase();
|
||||
return this.find(header => header.name.toLowerCase() === name);
|
||||
}
|
||||
|
||||
valueByName(name)
|
||||
{
|
||||
const header = this.getByName(name);
|
||||
return header ? header.value : '';
|
||||
}
|
||||
|
||||
valuesByName(name)
|
||||
{
|
||||
name = name.toLowerCase();
|
||||
return this.filter(header => header.name.toLowerCase() === name).map(header => header.value);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -8,18 +8,17 @@ let iJsonErrorCount = 0;
|
|||
const getURL = (add = '') => serverRequest('Json') + pString(add),
|
||||
|
||||
checkResponseError = data => {
|
||||
const err = data ? data.ErrorCode : null;
|
||||
const err = data ? data.code : null;
|
||||
if (Notifications.InvalidToken === err) {
|
||||
console.error(getNotification(err));
|
||||
console.error(getNotification(err) + ` (${data.messageAdditional})`);
|
||||
// alert(getNotification(err));
|
||||
rl.logoutReload();
|
||||
setTimeout(rl.logoutReload, 5000);
|
||||
} else if ([
|
||||
Notifications.AuthError,
|
||||
Notifications.ConnectionError,
|
||||
Notifications.DomainNotAllowed,
|
||||
Notifications.AccountNotAllowed,
|
||||
Notifications.MailServerError,
|
||||
Notifications.UnknownNotification,
|
||||
Notifications.UnknownError
|
||||
].includes(err)
|
||||
) {
|
||||
|
|
@ -132,8 +131,8 @@ export class AbstractFetchRemote
|
|||
|
||||
fetchJSON(sAction, getURL(sGetAdd),
|
||||
sGetAdd ? null : (params || {}),
|
||||
undefined === iTimeout ? 30000 : pInt(iTimeout),
|
||||
data => {
|
||||
pInt(iTimeout ?? 30000),
|
||||
async data => {
|
||||
let iError = 0;
|
||||
if (data) {
|
||||
/*
|
||||
|
|
@ -145,10 +144,14 @@ export class AbstractFetchRemote
|
|||
iJsonErrorCount = 0;
|
||||
} else {
|
||||
checkResponseError(data);
|
||||
iError = data.ErrorCode || Notifications.UnknownError
|
||||
iError = data.code || Notifications.UnknownError
|
||||
}
|
||||
}
|
||||
|
||||
if (111 === iError && rl.app.ask && await rl.app.ask.cryptkey()) {
|
||||
return this.request(sAction, fCallback, params, iTimeout, sGetAdd);
|
||||
}
|
||||
|
||||
fCallback && fCallback(
|
||||
iError,
|
||||
data,
|
||||
|
|
@ -170,13 +173,6 @@ export class AbstractFetchRemote
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?Function} fCallback
|
||||
*/
|
||||
getPublicKey(fCallback) {
|
||||
this.request('GetPublicKey', fCallback);
|
||||
}
|
||||
|
||||
setTrigger(trigger, value) {
|
||||
if (trigger) {
|
||||
value = !!value;
|
||||
|
|
@ -193,12 +189,16 @@ export class AbstractFetchRemote
|
|||
post(action, fTrigger, params, timeOut) {
|
||||
this.setTrigger(fTrigger, true);
|
||||
return fetchJSON(action, getURL(), params || {}, pInt(timeOut, 30000),
|
||||
data => {
|
||||
async data => {
|
||||
abort(action, 0, 1);
|
||||
|
||||
if (!data) {
|
||||
return Promise.reject(new FetchError(Notifications.JsonParse));
|
||||
}
|
||||
|
||||
if (111 === data?.code && rl.app.ask && await rl.app.ask.cryptkey()) {
|
||||
return this.post(action, fTrigger, params, timeOut);
|
||||
}
|
||||
/*
|
||||
let isCached = false, type = '';
|
||||
if (data?.epoch) {
|
||||
|
|
@ -222,8 +222,8 @@ export class AbstractFetchRemote
|
|||
if (!data.Result || action !== data.Action) {
|
||||
checkResponseError(data);
|
||||
return Promise.reject(new FetchError(
|
||||
data ? data.ErrorCode : 0,
|
||||
data ? (data.ErrorMessageAdditional || data.ErrorMessage) : ''
|
||||
data ? data.code : 0,
|
||||
data ? (data.messageAdditional || data.message) : ''
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,16 +64,6 @@ class RemoteUserFetch extends AbstractFetchRemote {
|
|||
[key]: value
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
folderMove(sPrevFolderFullName, sNewFolderFullName, bSubscribe) {
|
||||
return this.post('FolderMove', FolderUserStore.foldersRenaming, {
|
||||
folder: sPrevFolderFullName,
|
||||
newFolder: sNewFolderFullName,
|
||||
subscribe: bSubscribe ? 1 : 0
|
||||
});
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
export default new RemoteUserFetch();
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export class AbstractSettingsScreen extends AbstractScreen {
|
|||
|
||||
if (RoutedSettingsViewModel) {
|
||||
// const vmPlace = elementById('V-SettingsPane') || elementById('V-AdminPane);
|
||||
const vmPlace = this.viewModels[1].__dom,
|
||||
const vmPlace = this.viewModels[1].__vm.viewModelDom,
|
||||
SettingsViewModelClass = RoutedSettingsViewModel.vmc;
|
||||
if (SettingsViewModelClass.__vm) {
|
||||
settingsScreen = SettingsViewModelClass.__vm;
|
||||
|
|
@ -46,7 +46,6 @@ export class AbstractSettingsScreen extends AbstractScreen {
|
|||
settingsScreen.viewModelDom = viewModelDom;
|
||||
settingsScreen.viewModelTemplateID = RoutedSettingsViewModel.template;
|
||||
|
||||
SettingsViewModelClass.__dom = viewModelDom;
|
||||
SettingsViewModelClass.__vm = settingsScreen;
|
||||
|
||||
fireEvent('rl-view-model.create', settingsScreen);
|
||||
|
|
@ -118,7 +117,7 @@ export class AbstractSettingsScreen extends AbstractScreen {
|
|||
rules = {
|
||||
subname: /^(.*)$/,
|
||||
normalize_: (rquest, vals) => {
|
||||
vals.subname = null == vals.subname ? defaultRoute : pString(vals.subname);
|
||||
vals.subname = pString(vals.subname ?? defaultRoute);
|
||||
return [vals.subname];
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export class MailBoxUserScreen extends AbstractScreen {
|
|||
* @returns {void}
|
||||
*/
|
||||
onRoute(folderHash, page, search, messageUid) {
|
||||
// Only works when FolderUserStore.folderList() is loaded
|
||||
const folder = getFolderFromHashMap(folderHash.replace(/~([\d]+)$/, ''));
|
||||
if (folder) {
|
||||
FolderUserStore.currentFolder(folder);
|
||||
|
|
@ -108,7 +109,7 @@ export class MailBoxUserScreen extends AbstractScreen {
|
|||
*/
|
||||
onBuild() {
|
||||
doc.addEventListener('click', event =>
|
||||
event.target.closest('#rl-right') && moveAction(false)
|
||||
event.target.closest('#rl-right') && moveAction(0)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ export class AdminSettingsAbout /*extends AbstractViewSettings*/ {
|
|||
});
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
Remote.request('AdminClearCache');
|
||||
}
|
||||
|
||||
updateCoreData() {
|
||||
if (!this.coreUpdating()) {
|
||||
this.coreUpdating(true);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,40 @@ import ko from 'ko';
|
|||
|
||||
import Remote from 'Remote/Admin/Fetch';
|
||||
import { forEachObjectEntry } from 'Common/Utils';
|
||||
import { SettingsAdmin } from 'Common/Globals';
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
import { ThemeStore } from 'Stores/Theme';
|
||||
|
||||
export class AdminSettingsConfig /*extends AbstractViewSettings*/ {
|
||||
|
||||
constructor() {
|
||||
this.config = ko.observableArray();
|
||||
this.search = ko.observableArray();
|
||||
this.saved = ko.observable(false).extend({ falseTimeout: 5000 });
|
||||
|
||||
this.search.subscribe(value => {
|
||||
const v = value.toLowerCase(),
|
||||
qsa = (node, selector, fn) => node.querySelectorAll(selector).forEach(fn),
|
||||
match = node => node.textContent.toLowerCase().includes(v);
|
||||
if (v.length) {
|
||||
qsa(this.viewModelDom, 'tbody', tbody => {
|
||||
let show = match(tbody.querySelector('th'));
|
||||
if (show) {
|
||||
qsa(tbody, '[hidden]', n => n.hidden = false);
|
||||
} else {
|
||||
qsa(tbody, 'tbody td:first-child', td => {
|
||||
let hide = !match(td);
|
||||
show = show || !hide;
|
||||
// td.closest('tr').hidden = hide;
|
||||
td.parentNode.hidden = hide;
|
||||
});
|
||||
}
|
||||
tbody.hidden = !show;
|
||||
});
|
||||
} else {
|
||||
qsa(this.viewModelDom, 'table [hidden]', n => n.hidden = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beforeShow() {
|
||||
|
|
@ -28,13 +56,19 @@ export class AdminSettingsConfig /*extends AbstractViewSettings*/ {
|
|||
items: []
|
||||
};
|
||||
forEachObjectEntry(items, (skey, item) => {
|
||||
if ('language' === skey) {
|
||||
item[2] = ('webmail' === key) ? LanguageStore.languages : SettingsAdmin('languages');
|
||||
} else if ('theme' === skey) {
|
||||
item[2] = ThemeStore.themes;
|
||||
}
|
||||
'admin_password' === skey ||
|
||||
section.items.push({
|
||||
key: `config[${key}][${skey}]`,
|
||||
name: skey,
|
||||
value: item[0],
|
||||
type: getInputType(item[0], skey.includes('password')),
|
||||
comment: item[1]
|
||||
comment: item[1],
|
||||
options: item[2]
|
||||
});
|
||||
});
|
||||
cfg.push(section);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export class AdminSettingsContacts extends AbstractViewSettings {
|
|||
this.addSetting('contactsMySQLSSLVerify');
|
||||
this.addSetting('contactsMySQLSSLCiphers');
|
||||
|
||||
this.addSetting('contactsSQLiteGlobal');
|
||||
|
||||
addObservablesTo(this, {
|
||||
testing: false,
|
||||
testContactsSuccess: false,
|
||||
|
|
@ -102,7 +104,8 @@ export class AdminSettingsContacts extends AbstractViewSettings {
|
|||
PdoPassword: this.contactsPdoPassword(),
|
||||
MySQLSSLCA: this.contactsMySQLSSLCA(),
|
||||
MySQLSSLVerify: this.contactsMySQLSSLVerify(),
|
||||
MySQLSSLCiphers: this.contactsMySQLSSLCiphers()
|
||||
MySQLSSLCiphers: this.contactsMySQLSSLCiphers(),
|
||||
SQLiteGlobal: this.contactsSQLiteGlobal()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import ko from 'ko';
|
||||
|
||||
import {
|
||||
isArray
|
||||
} from 'Common/Utils';
|
||||
|
||||
import { addObservablesTo, addSubscribablesTo, addComputablesTo } from 'External/ko';
|
||||
|
||||
import { SaveSettingStatus } from 'Common/Enums';
|
||||
import { Settings, SettingsGet, SettingsCapa } from 'Common/Globals';
|
||||
import { SettingsAdmin, SettingsGet, SettingsCapa } from 'Common/Globals';
|
||||
import { translatorReload, convertLangName } from 'Common/Translator';
|
||||
|
||||
import { AbstractViewSettings } from 'Knoin/AbstractViews';
|
||||
|
|
@ -24,11 +20,7 @@ export class AdminSettingsGeneral extends AbstractViewSettings {
|
|||
super();
|
||||
|
||||
this.language = LanguageStore.language;
|
||||
this.languages = LanguageStore.languages;
|
||||
|
||||
const aLanguagesAdmin = Settings.app('languagesAdmin');
|
||||
this.languagesAdmin = ko.observableArray(isArray(aLanguagesAdmin) ? aLanguagesAdmin : []);
|
||||
this.languageAdmin = ko.observable(SettingsGet('languageAdmin'));
|
||||
this.languageAdmin = ko.observable(SettingsAdmin('language'));
|
||||
|
||||
this.theme = ThemeStore.theme;
|
||||
this.themes = ThemeStore.themes;
|
||||
|
|
@ -107,14 +99,18 @@ export class AdminSettingsGeneral extends AbstractViewSettings {
|
|||
}
|
||||
|
||||
selectLanguage() {
|
||||
showScreenPopup(LanguagesPopupView, [this.language, this.languages(), LanguageStore.userLanguage()]);
|
||||
showScreenPopup(LanguagesPopupView, [
|
||||
this.language,
|
||||
LanguageStore.languages,
|
||||
LanguageStore.userLanguage()
|
||||
]);
|
||||
}
|
||||
|
||||
selectLanguageAdmin() {
|
||||
showScreenPopup(LanguagesPopupView, [
|
||||
this.languageAdmin,
|
||||
this.languagesAdmin(),
|
||||
SettingsGet('languageUsers')
|
||||
SettingsAdmin('languages'),
|
||||
SettingsAdmin('clientLanguage')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export class AdminSettingsPackages extends AbstractViewSettings {
|
|||
if (iError) {
|
||||
this.packagesError(
|
||||
getNotification(install ? Notifications.CantInstallPackage : Notifications.CantDeletePackage)
|
||||
+ (data.ErrorMessage ? ':\n' + data.ErrorMessage : '')
|
||||
+ (data.message ? ':\n' + data.message : '')
|
||||
);
|
||||
} else if (data.Result.Reload) {
|
||||
location.reload();
|
||||
|
|
@ -113,8 +113,8 @@ export class AdminSettingsPackages extends AbstractViewSettings {
|
|||
if (iError) {
|
||||
plugin.enabled(disable);
|
||||
this.packagesError(
|
||||
(Notifications.UnsupportedPluginPackage === iError && data?.ErrorMessage)
|
||||
? data.ErrorMessage
|
||||
(Notifications.UnsupportedPluginPackage === iError && data?.message)
|
||||
? data.message
|
||||
: getNotification(iError)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
constructor() {
|
||||
super();
|
||||
|
||||
this.addSettings(['useLocalProxyForExternalImages']);
|
||||
this.addSettings(['proxyExternalImages', 'autoVerifySignatures']);
|
||||
|
||||
this.weakPassword = rl.app.weakPassword;
|
||||
|
||||
|
|
@ -28,6 +28,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
|
||||
viewQRCode: '',
|
||||
|
||||
capaGnuPG: SettingsCapa('GnuPG'),
|
||||
capaOpenPGP: SettingsCapa('OpenPGP')
|
||||
});
|
||||
|
||||
|
|
@ -65,7 +66,8 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
|
||||
adminPasswordNew2: reset,
|
||||
|
||||
capaOpenPGP: value => Remote.saveSetting('CapaOpenPGP', value)
|
||||
capaGnuPG: value => Remote.saveSetting('capaGnuPG', value),
|
||||
capaOpenPGP: value => Remote.saveSetting('capaOpenPGP', value)
|
||||
});
|
||||
|
||||
this.adminTOTP(SettingsGet('adminTOTP'));
|
||||
|
|
@ -75,6 +77,16 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
});
|
||||
}
|
||||
|
||||
generateTOTP() {
|
||||
let CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
|
||||
length = 16,
|
||||
secret = '';
|
||||
while (0 < length--) {
|
||||
secret += CHARS[Math.floor(Math.random() * 32)];
|
||||
}
|
||||
this.adminTOTP(secret);
|
||||
}
|
||||
|
||||
saveAdminUserCommand() {
|
||||
if (!this.adminLogin().trim()) {
|
||||
this.adminLoginError(true);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import ko from 'ko';
|
|||
|
||||
//import { koComputable } from 'External/ko';
|
||||
import { SettingsCapa, SettingsGet } from 'Common/Globals';
|
||||
import { loadAccountsAndIdentities, editIdentity } from 'Common/UtilsUser';
|
||||
|
||||
import { AccountUserStore } from 'Stores/User/Account';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
|
|
@ -11,7 +12,6 @@ import Remote from 'Remote/User/Fetch';
|
|||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
|
||||
import { AccountPopupView } from 'View/Popup/Account';
|
||||
import { IdentityPopupView } from 'View/Popup/Identity';
|
||||
|
||||
export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
|
||||
constructor() {
|
||||
|
|
@ -43,11 +43,11 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
|
|||
}
|
||||
|
||||
addNewIdentity() {
|
||||
showScreenPopup(IdentityPopupView);
|
||||
editIdentity();
|
||||
}
|
||||
|
||||
editIdentity(identity) {
|
||||
showScreenPopup(IdentityPopupView, [identity]);
|
||||
editIdentity(identity);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,7 +64,7 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
|
|||
rl.route.root();
|
||||
setTimeout(() => location.reload(), 1);
|
||||
} else {
|
||||
rl.app.accountsAndIdentities();
|
||||
loadAccountsAndIdentities();
|
||||
}
|
||||
}, {
|
||||
emailToDelete: accountToRemove.email
|
||||
|
|
@ -88,7 +88,7 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
|
|||
|
||||
accountsAndIdentitiesAfterMove() {
|
||||
Remote.request('AccountsAndIdentitiesSortOrder', null, {
|
||||
Accounts: AccountUserStore.getEmailAddresses().filter(v => v != SettingsGet('mainEmail')),
|
||||
Accounts: AccountUserStore.filter(item => item.isAdditional()).map(item => item.email),
|
||||
Identities: IdentityUserStore.map(item => (item ? item.id() : ""))
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import Remote from 'Remote/User/Fetch';
|
|||
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
|
||||
//import { FolderPopupView } from 'View/Popup/Folder';
|
||||
import { FolderCreatePopupView } from 'View/Popup/FolderCreate';
|
||||
import { FolderSystemPopupView } from 'View/Popup/FolderSystem';
|
||||
|
||||
|
|
@ -41,8 +42,8 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
|
|||
|
||||
this.displaySpecSetting = FolderUserStore.displaySpecSetting;
|
||||
this.folderList = FolderUserStore.folderList;
|
||||
this.folderListOptimized = FolderUserStore.folderListOptimized;
|
||||
this.folderListError = FolderUserStore.folderListError;
|
||||
this.folderListOptimized = FolderUserStore.optimized;
|
||||
this.folderListError = FolderUserStore.error;
|
||||
this.hideUnsubscribed = SettingsUserStore.hideUnsubscribed;
|
||||
this.unhideKolabFolders = SettingsUserStore.unhideKolabFolders;
|
||||
|
||||
|
|
@ -55,7 +56,7 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
|
|||
}
|
||||
|
||||
onShow() {
|
||||
FolderUserStore.folderListError('');
|
||||
FolderUserStore.error('');
|
||||
}
|
||||
/*
|
||||
onBuild(oDom) {
|
||||
|
|
@ -75,7 +76,7 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
|
|||
&& folderToRemove.askDelete()
|
||||
) {
|
||||
if (0 < folderToRemove.totalEmails()) {
|
||||
// FolderUserStore.folderListError(getNotification(Notifications.CantDeleteNonEmptyFolder));
|
||||
// FolderUserStore.error(getNotification(Notifications.CantDeleteNonEmptyFolder));
|
||||
folderToRemove.errorMsg(getNotification(Notifications.CantDeleteNonEmptyFolder));
|
||||
} else {
|
||||
folderForDeletion(null);
|
||||
|
|
@ -96,7 +97,7 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
|
|||
}
|
||||
},
|
||||
error => {
|
||||
FolderUserStore.folderListError(
|
||||
FolderUserStore.error(
|
||||
getNotification(error.code, '', Notifications.CantDeleteFolder)
|
||||
+ '.\n' + error.message
|
||||
);
|
||||
|
|
@ -108,7 +109,7 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
|
|||
}
|
||||
|
||||
hideError() {
|
||||
this.folderListError('');
|
||||
FolderUserStore.error('');
|
||||
}
|
||||
|
||||
toggleFolderKolabType(folder, event) {
|
||||
|
|
|
|||
|
|
@ -5,15 +5,17 @@ import { SaveSettingStatus } from 'Common/Enums';
|
|||
import { LayoutSideView, LayoutBottomView } from 'Common/EnumsUser';
|
||||
import { setRefreshFoldersInterval } from 'Common/Folders';
|
||||
import { Settings, SettingsGet } from 'Common/Globals';
|
||||
import { isArray } from 'Common/Utils';
|
||||
import { WYSIWYGS } from 'Common/HtmlEditor';
|
||||
import { addSubscribablesTo, addComputablesTo } from 'External/ko';
|
||||
import { i18n, translateTrigger, translatorReload, convertLangName } from 'Common/Translator';
|
||||
import { editIdentity } from 'Common/UtilsUser';
|
||||
|
||||
import { AbstractViewSettings } from 'Knoin/AbstractViews';
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
|
||||
import { AppUserStore } from 'Stores/User/App';
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
import { NotificationUserStore } from 'Stores/User/Notification';
|
||||
|
|
@ -21,13 +23,14 @@ import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
|||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { IdentityPopupView } from 'View/Popup/Identity';
|
||||
import { LanguagesPopupView } from 'View/Popup/Languages';
|
||||
|
||||
export class UserSettingsGeneral extends AbstractViewSettings {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.mailto = ko.observable(!!navigator.registerProtocolHandler);
|
||||
|
||||
this.language = LanguageStore.language;
|
||||
this.languages = LanguageStore.languages;
|
||||
this.hourCycle = LanguageStore.hourCycle;
|
||||
|
|
@ -36,17 +39,30 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
this.notificationSound = ko.observable(SettingsGet('NotificationSound'));
|
||||
this.notificationSounds = ko.observableArray(SettingsGet('newMailSounds'));
|
||||
|
||||
this.minRefreshInterval = SettingsGet('minRefreshInterval');
|
||||
|
||||
this.desktopNotifications = NotificationUserStore.enabled;
|
||||
this.isDesktopNotificationAllowed = NotificationUserStore.allowed;
|
||||
|
||||
this.threadsAllowed = AppUserStore.threadsAllowed;
|
||||
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
|
||||
this.threadAlgorithms = ko.observableArray();
|
||||
FolderUserStore.capabilities.forEach(capa =>
|
||||
capa.startsWith('THREAD=') && this.threadAlgorithms.push(capa.slice(7))
|
||||
);
|
||||
this.threadAlgorithms.sort((a, b) => a.length - b.length);
|
||||
this.threadAlgorithm = SettingsUserStore.threadAlgorithm;
|
||||
|
||||
['layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
|
||||
'editorDefaultType', 'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
||||
['useThreads', 'threadAlgorithm',
|
||||
// These use addSetting()
|
||||
'layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
|
||||
'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel',
|
||||
// These are in addSettings()
|
||||
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
||||
'viewHTML', 'viewImages', 'viewImagesWhitelist', 'removeColors', 'allowStyles', 'allowDraftAutosave',
|
||||
'hideDeleted', 'listInlineAttachments', 'simpleAttachmentsList', 'collapseBlockquotes', 'maxBlockquotesLevel',
|
||||
'useCheckboxesInList', 'listGrouped', 'useThreads', 'replySameFolder', 'msgDefaultAction', 'allowSpellcheck',
|
||||
'showNextMessage'
|
||||
'hideDeleted', 'listInlineAttachments', 'simpleAttachmentsList', 'collapseBlockquotes',
|
||||
'useCheckboxesInList', 'listGrouped', 'replySameFolder', 'allowSpellcheck',
|
||||
'messageReadAuto', 'showNextMessage', 'messageNewWindow'
|
||||
].forEach(name => this[name] = SettingsUserStore[name]);
|
||||
|
||||
this.allowLanguagesOnSettings = !!SettingsGet('allowLanguagesOnSettings');
|
||||
|
|
@ -55,16 +71,13 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
|
||||
this.identities = IdentityUserStore;
|
||||
|
||||
this.wysiwygs = WYSIWYGS;
|
||||
|
||||
addComputablesTo(this, {
|
||||
languageFullName: () => convertLangName(this.language()),
|
||||
|
||||
identityMain: () => {
|
||||
const list = this.identities();
|
||||
return isArray(list) ? list.find(item => item && !item.id()) : null;
|
||||
},
|
||||
|
||||
identityMainDesc: () => {
|
||||
const identity = this.identityMain();
|
||||
const identity = IdentityUserStore.main();
|
||||
return identity ? identity.formattedName() : '---';
|
||||
},
|
||||
|
||||
|
|
@ -76,6 +89,8 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
];
|
||||
},
|
||||
|
||||
hasWysiwygs: () => 1 < WYSIWYGS().length,
|
||||
|
||||
msgDefaultActions: () => {
|
||||
translateTrigger();
|
||||
return [
|
||||
|
|
@ -95,6 +110,7 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
});
|
||||
|
||||
this.addSetting('EditorDefaultType');
|
||||
this.addSetting('editorWysiwyg');
|
||||
this.addSetting('MsgDefaultAction');
|
||||
this.addSetting('MessageReadDelay');
|
||||
this.addSetting('MessagesPerPage');
|
||||
|
|
@ -102,10 +118,13 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
this.addSetting('Layout');
|
||||
this.addSetting('MaxBlockquotesLevel');
|
||||
|
||||
this.addSettings(['ViewHTML', 'ViewImages', 'ViewImagesWhitelist', 'HideDeleted', 'RemoveColors', 'AllowStyles',
|
||||
'ListInlineAttachments', 'simpleAttachmentsList', 'UseCheckboxesInList', 'listGrouped', 'ReplySameFolder',
|
||||
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt', 'allowSpellcheck',
|
||||
'DesktopNotifications', 'SoundNotification', 'CollapseBlockquotes', 'AllowDraftAutosave', 'showNextMessage']);
|
||||
this.addSettings([
|
||||
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
||||
'ViewHTML', 'ViewImages', 'ViewImagesWhitelist', 'RemoveColors', 'AllowStyles', 'AllowDraftAutosave',
|
||||
'HideDeleted', 'ListInlineAttachments', 'simpleAttachmentsList', 'CollapseBlockquotes',
|
||||
'UseCheckboxesInList', 'listGrouped', 'ReplySameFolder', 'allowSpellcheck',
|
||||
'messageReadAuto', 'showNextMessage', 'messageNewWindow',
|
||||
'DesktopNotifications', 'SoundNotification']);
|
||||
|
||||
const fReloadLanguageHelper = (saveSettingsStep) => () => {
|
||||
this.languageTrigger(saveSettingsStep);
|
||||
|
|
@ -133,6 +152,11 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
Remote.saveSetting('UseThreads', value);
|
||||
},
|
||||
|
||||
threadAlgorithm: value => {
|
||||
MessagelistUserStore([]);
|
||||
Remote.saveSetting('threadAlgorithm', value);
|
||||
},
|
||||
|
||||
checkMailInterval: () => {
|
||||
setRefreshFoldersInterval(SettingsUserStore.checkMailInterval());
|
||||
}
|
||||
|
|
@ -140,8 +164,7 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
}
|
||||
|
||||
editMainIdentity() {
|
||||
const identity = this.identityMain();
|
||||
identity && showScreenPopup(IdentityPopupView, [identity]);
|
||||
editIdentity(IdentityUserStore.main());
|
||||
}
|
||||
|
||||
testSoundNotification() {
|
||||
|
|
@ -155,4 +178,15 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
selectLanguage() {
|
||||
showScreenPopup(LanguagesPopupView, [this.language, this.languages(), LanguageStore.userLanguage()]);
|
||||
}
|
||||
|
||||
registerMailto() {
|
||||
console.log(`mailto = ${location.protocol}//${location.host}${location.pathname}?mailto`);
|
||||
navigator.registerProtocolHandler(
|
||||
'mailto',
|
||||
`${location.protocol}//${location.host}${location.pathname}?mailto&to=%s`,
|
||||
(SettingsGet('title') || 'SnappyMail')
|
||||
);
|
||||
alert(i18n('GLOBAL/DONE'));
|
||||
this.mailto(0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import { showScreenPopup } from 'Knoin/Knoin';
|
|||
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
|
||||
import { OpenPgpGeneratePopupView } from 'View/Popup/OpenPgpGenerate';
|
||||
|
||||
//import Remote from 'Remote/User/Fetch';
|
||||
import { SMimeUserStore } from 'Stores/User/SMime';
|
||||
import { SMimeImportPopupView } from 'View/Popup/SMimeImport';
|
||||
|
||||
export class UserSettingsSecurity extends AbstractViewSettings {
|
||||
constructor() {
|
||||
|
|
@ -25,9 +26,9 @@ export class UserSettingsSecurity extends AbstractViewSettings {
|
|||
this.autoLogoutOptions = koComputable(() => {
|
||||
translateTrigger();
|
||||
return [
|
||||
{ id: 0, name: i18n('SETTINGS_SECURITY/AUTOLOGIN_NEVER_OPTION_NAME') },
|
||||
{ id: 0, name: i18n('SETTINGS_SECURITY/NEVER') },
|
||||
{ id: 5, name: relativeTime(300) },
|
||||
{ id: 10, name: relativeTime(600) },
|
||||
{ id: 15, name: relativeTime(900) },
|
||||
{ id: 30, name: relativeTime(1800) },
|
||||
{ id: 60, name: relativeTime(3600) },
|
||||
{ id: 120, name: relativeTime(7200) },
|
||||
|
|
@ -37,12 +38,17 @@ export class UserSettingsSecurity extends AbstractViewSettings {
|
|||
});
|
||||
this.addSetting('AutoLogout');
|
||||
|
||||
this.keyPassForget = SettingsUserStore.keyPassForget;
|
||||
this.addSetting('keyPassForget');
|
||||
|
||||
this.gnupgPublicKeys = GnuPGUserStore.publicKeys;
|
||||
this.gnupgPrivateKeys = GnuPGUserStore.privateKeys;
|
||||
|
||||
this.openpgpkeysPublic = OpenPGPUserStore.publicKeys;
|
||||
this.openpgpkeysPrivate = OpenPGPUserStore.privateKeys;
|
||||
|
||||
this.smimeCertificates = SMimeUserStore;
|
||||
|
||||
this.canOpenPGP = SettingsCapa('OpenPGP');
|
||||
this.canGnuPG = GnuPGUserStore.isSupported();
|
||||
this.canMailvelope = !!window.mailvelope;
|
||||
|
|
@ -56,6 +62,14 @@ export class UserSettingsSecurity extends AbstractViewSettings {
|
|||
showScreenPopup(OpenPgpGeneratePopupView);
|
||||
}
|
||||
|
||||
importToOpenPGP() {
|
||||
OpenPGPUserStore.loadBackupKeys();
|
||||
}
|
||||
|
||||
importToSMime() {
|
||||
showScreenPopup(SMimeImportPopupView);
|
||||
}
|
||||
|
||||
onBuild() {
|
||||
/**
|
||||
* Create an iframe to display the Mailvelope keyring settings.
|
||||
|
|
|
|||
|
|
@ -99,8 +99,8 @@ export class UserSettingsThemes /*extends AbstractViewSettings*/ {
|
|||
themeBackground.hash(data?.Result?.hash || '');
|
||||
if (!themeBackground.name() || !themeBackground.hash()) {
|
||||
let errorMsg = '';
|
||||
if (data.ErrorCode) {
|
||||
switch (data.ErrorCode) {
|
||||
if (data.code) {
|
||||
switch (data.code) {
|
||||
case UploadErrorCode.FileIsTooBig:
|
||||
errorMsg = i18n('SETTINGS_THEMES/ERROR_FILE_IS_TOO_BIG');
|
||||
break;
|
||||
|
|
@ -111,7 +111,7 @@ export class UserSettingsThemes /*extends AbstractViewSettings*/ {
|
|||
}
|
||||
}
|
||||
|
||||
themeBackground.error(errorMsg || data.ErrorMessage || i18n('SETTINGS_THEMES/ERROR_UNKNOWN'));
|
||||
themeBackground.error(errorMsg || data.message || i18n('SETTINGS_THEMES/ERROR_UNKNOWN'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ import {
|
|||
*/
|
||||
export class ConditionalCommand extends ControlCommand
|
||||
{
|
||||
constructor()
|
||||
constructor(identifier)
|
||||
{
|
||||
super();
|
||||
super(identifier);
|
||||
this.test = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ export class AddressTest extends TestCommand
|
|||
this.header_list = new GrammarStringList;
|
||||
this.key_list = new GrammarStringList;
|
||||
// rfc5260#section-6
|
||||
// this.index = new GrammarNumber;
|
||||
// this.last = false;
|
||||
this.index = new GrammarNumber;
|
||||
this.last = false;
|
||||
// rfc5703#section-6
|
||||
// this.mime
|
||||
// this.anychild
|
||||
|
|
@ -67,7 +67,7 @@ export class AddressTest extends TestCommand
|
|||
}
|
||||
}
|
||||
return result
|
||||
// + (this.last ? ' :last' : (this.index.value ? ' :index ' + this.index : ''))
|
||||
+ (this.last ? ' :last' : (this.index.value ? ' :index ' + this.index : ''))
|
||||
+ (this.comparator ? ' :comparator ' + this.comparator : '')
|
||||
+ ' ' + this.address_part
|
||||
+ ' ' + this.match_type
|
||||
|
|
@ -234,8 +234,8 @@ export class HeaderTest extends TestCommand
|
|||
this.header_names = new GrammarStringList;
|
||||
this.key_list = new GrammarStringList;
|
||||
// rfc5260#section-6
|
||||
// this.index = new GrammarNumber;
|
||||
// this.last = false;
|
||||
this.index = new GrammarNumber;
|
||||
this.last = false;
|
||||
// rfc5703#section-6
|
||||
this.mime = false;
|
||||
this.anychild = false;
|
||||
|
|
@ -278,7 +278,7 @@ export class HeaderTest extends TestCommand
|
|||
}
|
||||
}
|
||||
return result
|
||||
// + (this.last ? ' :last' : (this.index.value ? ' :index ' + this.index : ''))
|
||||
+ (this.last ? ' :last' : (this.index.value ? ' :index ' + this.index : ''))
|
||||
+ (this.comparator ? ' :comparator ' + this.comparator : '')
|
||||
+ ' ' + this.match_type
|
||||
+ ' ' + this.header_names
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ class FlagCommand extends ActionCommand
|
|||
|
||||
toString()
|
||||
{
|
||||
return this.identifier + ' ' + this._variablename + ' ' + this.list_of_flags + ';';
|
||||
let name = this._variablename;
|
||||
return this.identifier + (name.length ? ' ' + this.variablename : '') + ' ' + this.list_of_flags + ';';
|
||||
}
|
||||
|
||||
get variablename()
|
||||
|
|
|
|||
|
|
@ -8,22 +8,17 @@ import {
|
|||
GrammarString
|
||||
} from 'Sieve/Grammar';
|
||||
|
||||
/**
|
||||
* https://tools.ietf.org/html/rfc5429#section-2.1
|
||||
*/
|
||||
export class ErejectCommand extends ActionCommand
|
||||
class rfc5429Command extends ActionCommand
|
||||
{
|
||||
constructor()
|
||||
constructor(identifier)
|
||||
{
|
||||
super();
|
||||
super(identifier);
|
||||
this._reason = new GrammarQuotedString;
|
||||
}
|
||||
|
||||
get require() { return 'ereject'; }
|
||||
|
||||
toString()
|
||||
{
|
||||
return 'ereject ' + this._reason + ';';
|
||||
return this.require + ' ' + this._reason + ';';
|
||||
}
|
||||
|
||||
get reason()
|
||||
|
|
@ -44,38 +39,20 @@ export class ErejectCommand extends ActionCommand
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* https://tools.ietf.org/html/rfc5429#section-2.1
|
||||
*/
|
||||
export class ErejectCommand extends rfc5429Command
|
||||
{
|
||||
constructor() { super('ereject'); }
|
||||
get require() { return 'ereject'; }
|
||||
}
|
||||
|
||||
/**
|
||||
* https://tools.ietf.org/html/rfc5429#section-2.2
|
||||
*/
|
||||
export class RejectCommand extends ActionCommand
|
||||
export class RejectCommand extends rfc5429Command
|
||||
{
|
||||
constructor()
|
||||
{
|
||||
super();
|
||||
this._reason = new GrammarQuotedString;
|
||||
}
|
||||
|
||||
constructor() { super('reject'); }
|
||||
get require() { return 'reject'; }
|
||||
|
||||
toString()
|
||||
{
|
||||
return 'reject ' + this._reason + ';';
|
||||
}
|
||||
|
||||
get reason()
|
||||
{
|
||||
return this._reason.value;
|
||||
}
|
||||
|
||||
set reason(value)
|
||||
{
|
||||
this._reason.value = value;
|
||||
}
|
||||
|
||||
pushArguments(args)
|
||||
{
|
||||
if (args[0] instanceof GrammarString) {
|
||||
this._reason = args[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ export class GrammarTestList extends Array
|
|||
// return '(\r\n\t' + arrayToString(this, ',\r\n\t') + '\r\n)';
|
||||
return '(' + this.join(', ') + ')';
|
||||
}
|
||||
return this.length ? this[0] : '';
|
||||
return this.length ? this[0].toString() : '';
|
||||
}
|
||||
|
||||
push(value)
|
||||
|
|
@ -254,7 +254,7 @@ export class GrammarStringList extends Array
|
|||
if (1 < this.length) {
|
||||
return '[' + this.join(',') + ']';
|
||||
}
|
||||
return this.length ? this[0] : '';
|
||||
return this.length ? this[0].toString() : '';
|
||||
}
|
||||
|
||||
push(value)
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ export class AbstractModel {
|
|||
constructor() {
|
||||
/*
|
||||
if (new.target === AbstractModel) {
|
||||
throw new Error("Can't instantiate AbstractModel!");
|
||||
throw Error("Can't instantiate AbstractModel!");
|
||||
}
|
||||
*/
|
||||
this.disposables = [];
|
||||
Object.defineProperty(this, 'disposables', {value: []});
|
||||
}
|
||||
|
||||
addObservables(observables) {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ export class FilterModel extends AbstractModel {
|
|||
this.addObservables({
|
||||
enabled: true,
|
||||
askDelete: false,
|
||||
canBeDeleted: true,
|
||||
|
||||
name: '',
|
||||
nameError: false,
|
||||
|
|
@ -181,27 +180,6 @@ export class FilterModel extends AbstractModel {
|
|||
return true;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
// '@Object': 'Object/Filter',
|
||||
ID: this.id,
|
||||
Enabled: this.enabled() ? 1 : 0,
|
||||
Name: this.name,
|
||||
Conditions: this.conditions,
|
||||
ConditionsType: this.conditionsType,
|
||||
|
||||
ActionType: this.actionType(),
|
||||
ActionValue: this.actionValue,
|
||||
ActionValueSecond: this.actionValueSecond,
|
||||
ActionValueThird: this.actionValueThird,
|
||||
ActionValueFourth: this.actionValueFourth,
|
||||
|
||||
Keep: this.keep() ? 1 : 0,
|
||||
Stop: this.stop() ? 1 : 0,
|
||||
MarkAsRead: this.markAsRead() ? 1 : 0
|
||||
};
|
||||
}
|
||||
|
||||
addCondition() {
|
||||
this.conditions.push(new FilterConditionModel());
|
||||
}
|
||||
|
|
@ -210,6 +188,24 @@ export class FilterModel extends AbstractModel {
|
|||
this.conditions.remove(oConditionToDelete);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
ID: this.id,
|
||||
Enabled: this.enabled(),
|
||||
Name: this.name(),
|
||||
Conditions: this.conditions(),
|
||||
ConditionsType: this.conditionsType(),
|
||||
ActionType: this.actionType(),
|
||||
ActionValue: this.actionValue(),
|
||||
ActionValueSecond: this.actionValueSecond(),
|
||||
ActionValueThird: this.actionValueThird(),
|
||||
ActionValueFourth: this.actionValueFourth(),
|
||||
Keep: this.keep(),
|
||||
Stop: this.stop(),
|
||||
MarkAsRead: this.markAsRead()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @static
|
||||
* @param {FetchJsonFilter} json
|
||||
|
|
@ -222,7 +218,10 @@ export class FilterModel extends AbstractModel {
|
|||
if (filter) {
|
||||
filter.id = '' + (filter.id || '');
|
||||
filter.conditions(
|
||||
(json.Conditions || []).map(aData => FilterConditionModel.reviveFromJson(aData)).filter(v => v)
|
||||
(json.Conditions || json.conditions || []).map(condition => {
|
||||
condition['@Object'] = 'Object/FilterCondition';
|
||||
return FilterConditionModel.reviveFromJson(condition)
|
||||
}).filter(v => v)
|
||||
);
|
||||
}
|
||||
return filter;
|
||||
|
|
|
|||
|
|
@ -79,18 +79,17 @@ export class FilterConditionModel extends AbstractModel {
|
|||
return true;
|
||||
}
|
||||
|
||||
// static reviveFromJson(json) {}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
// '@Object': 'Object/FilterCondition',
|
||||
Field: this.field,
|
||||
Type: this.type,
|
||||
Value: this.value,
|
||||
ValueSecond: this.valueSecond
|
||||
Field: this.field(),
|
||||
Type: this.type(),
|
||||
Value: this.value(),
|
||||
ValueSecond: this.valueSecond()
|
||||
};
|
||||
}
|
||||
|
||||
// static reviveFromJson(json) {}
|
||||
|
||||
cloneSelf() {
|
||||
const filterCond = new FilterConditionModel();
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ function filtersToSieveScript(filters)
|
|||
''
|
||||
];
|
||||
|
||||
const quote = string => '"' + string.trim().replace(/(\\|")/g, '\\$1') + '"';
|
||||
const StripSpaces = string => string.replace(/\s+/, ' ').trim();
|
||||
const quote = string => '"' + string.replace(/(\\|")/g, '\\$1') + '"';
|
||||
const StripSpaces = string => string.replace(/\s+/, ' ');
|
||||
|
||||
// conditionToSieveScript
|
||||
const conditionToString = (condition, require) =>
|
||||
|
|
@ -26,8 +26,8 @@ function filtersToSieveScript(filters)
|
|||
let result = '',
|
||||
type = condition.type(),
|
||||
field = condition.field(),
|
||||
value = condition.value().trim(),
|
||||
valueSecond = condition.valueSecond().trim();
|
||||
value = condition.value(),
|
||||
valueSecond = condition.valueSecond();
|
||||
|
||||
if (value.length && ('Header' !== field || valueSecond.length)) {
|
||||
switch (type)
|
||||
|
|
@ -85,7 +85,7 @@ function filtersToSieveScript(filters)
|
|||
}
|
||||
|
||||
if (('From' === field || 'Recipient' === field) && value.includes(',')) {
|
||||
result += ' [' + value.split(',').map(value => quote(value)).join(', ').trim() + ']';
|
||||
result += ' [' + value.split(',').map(value => quote(value)).join(', ') + ']';
|
||||
} else if ('Size' === field) {
|
||||
result += ' ' + value;
|
||||
} else {
|
||||
|
|
@ -130,7 +130,7 @@ function filtersToSieveScript(filters)
|
|||
result.push(sTab + 'addflag "\\\\Seen";');
|
||||
}
|
||||
|
||||
let value = filter.actionValue().trim();
|
||||
let value = filter.actionValue();
|
||||
value = value.length ? quote(value) : 0;
|
||||
switch (filter.actionType())
|
||||
{
|
||||
|
|
@ -146,21 +146,21 @@ function filtersToSieveScript(filters)
|
|||
let days = 1,
|
||||
subject = '',
|
||||
addresses = '',
|
||||
paramValue = filter.actionValueSecond().trim();
|
||||
paramValue = filter.actionValueSecond();
|
||||
|
||||
if (paramValue.length) {
|
||||
subject = ':subject ' + quote(StripSpaces(paramValue)) + ' ';
|
||||
}
|
||||
|
||||
paramValue = ('' + (filter.actionValueThird() || '')).trim();
|
||||
paramValue = ('' + (filter.actionValueThird() || ''));
|
||||
if (paramValue.length) {
|
||||
days = Math.max(1, parseInt(paramValue, 10));
|
||||
}
|
||||
|
||||
paramValue = ('' + (filter.actionValueFourth() || '')).trim()
|
||||
paramValue = ('' + (filter.actionValueFourth() || ''))
|
||||
if (paramValue.length) {
|
||||
paramValue = paramValue.split(',').map(email =>
|
||||
email.trim().length ? quote(email) : ''
|
||||
email.length ? quote(email) : ''
|
||||
).filter(email => email.length);
|
||||
if (paramValue.length) {
|
||||
addresses = ':addresses [' + paramValue.join(', ') + '] ';
|
||||
|
|
@ -228,7 +228,7 @@ function filtersToSieveScript(filters)
|
|||
}
|
||||
|
||||
// fileStringToCollection
|
||||
function sieveScriptToFilters(script)
|
||||
function rainloopScriptToFilters(script)
|
||||
{
|
||||
let regex = /BEGIN:HEADER([\s\S]+?)END:HEADER/gm,
|
||||
filters = [],
|
||||
|
|
@ -239,7 +239,6 @@ function sieveScriptToFilters(script)
|
|||
json = decodeURIComponent(escape(atob(json[1].replace(/\s+/g, ''))));
|
||||
if (json && json.length && (json = JSON.parse(json))) {
|
||||
json['@Object'] = 'Object/Filter';
|
||||
json.Conditions.forEach(condition => condition['@Object'] = 'Object/FilterCondition');
|
||||
filter = FilterModel.reviveFromJson(json);
|
||||
filter && filters.push(filter);
|
||||
}
|
||||
|
|
@ -261,7 +260,6 @@ export class SieveScriptModel extends AbstractModel
|
|||
exists: false,
|
||||
nameError: false,
|
||||
askDelete: false,
|
||||
canBeDeleted: true,
|
||||
hasChanges: false
|
||||
});
|
||||
|
||||
|
|
@ -280,13 +278,8 @@ export class SieveScriptModel extends AbstractModel
|
|||
// this.body(filtersToSieveScript(this.filters));
|
||||
}
|
||||
|
||||
rawToFilters() {
|
||||
return sieveScriptToFilters(this.body());
|
||||
// this.filters(sieveScriptToFilters(this.body()));
|
||||
}
|
||||
|
||||
verify() {
|
||||
this.nameError(!this.name().trim());
|
||||
this.nameError(!this.name());
|
||||
return !this.nameError();
|
||||
}
|
||||
|
||||
|
|
@ -315,9 +308,8 @@ export class SieveScriptModel extends AbstractModel
|
|||
const script = super.reviveFromJson(json);
|
||||
if (script) {
|
||||
if (script.allowFilters()) {
|
||||
script.filters(sieveScriptToFilters(script.body()));
|
||||
script.filters(rainloopScriptToFilters(script.body()));
|
||||
}
|
||||
script.canBeDeleted(SIEVE_FILE_NAME !== json.name);
|
||||
script.exists(true);
|
||||
script.hasChanges(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ const
|
|||
sDeepPrefix = '\u00A0\u00A0\u00A0',
|
||||
showUnsubscribed = true/*!SettingsUserStore.hideUnsubscribed()*/,
|
||||
|
||||
disabled = rl.settings.get('sieveAllowFileintoInbox') ? '' : 'INBOX',
|
||||
|
||||
foldersWalk = folders => {
|
||||
folders.forEach(oItem => {
|
||||
if (showUnsubscribed || oItem.hasSubscriptions() || !oItem.exists) {
|
||||
|
|
@ -39,7 +37,7 @@ const
|
|||
id: oItem.fullName,
|
||||
name: sDeepPrefix.repeat(oItem.deep) + oItem.detailedName(),
|
||||
system: false,
|
||||
disabled: !oItem.selectable() || disabled == oItem.fullName
|
||||
disabled: !oItem.selectable()
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -120,12 +118,14 @@ export class FilterPopupView extends rl.pluginPopupView {
|
|||
id: FilterAction.MoveTo,
|
||||
name: i18nFilter('ACTION_MOVE_TO')
|
||||
});
|
||||
this.actionTypeOptions.push({
|
||||
id: FilterAction.Forward,
|
||||
name: i18nFilter('ACTION_FORWARD_TO')
|
||||
});
|
||||
}
|
||||
|
||||
// redirect command
|
||||
this.actionTypeOptions.push({
|
||||
id: FilterAction.Forward,
|
||||
name: i18nFilter('ACTION_FORWARD_TO')
|
||||
});
|
||||
|
||||
if (capa.includes('reject')) {
|
||||
this.actionTypeOptions.push({ id: FilterAction.Reject, name: i18nFilter('ACTION_REJECT') });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export class SieveScriptPopupView extends rl.pluginPopupView {
|
|||
|
||||
if (iError) {
|
||||
self.saveError(true);
|
||||
self.errorText(data?.ErrorMessageAdditional || getNotification(iError));
|
||||
self.errorText(data?.messageAdditional || getNotification(iError));
|
||||
} else {
|
||||
script.exists() || scripts.push(script);
|
||||
script.exists(true);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ try {
|
|||
data = data ? decodeURIComponent(data[2]) : null;
|
||||
data = data ? JSON.parse(data) : {};
|
||||
win[sName] = {
|
||||
getItem: key => data[key] == null ? null : data[key],
|
||||
getItem: key => data[key] ?? null,
|
||||
setItem: (key, value) => {
|
||||
data[key] = ''+value; // forces the value to a string
|
||||
document.cookie = sName+'='+encodeURIComponent(JSON.stringify(data))
|
||||
|
|
|
|||
|
|
@ -1 +1,21 @@
|
|||
export const Passphrases = new Map();
|
||||
import { AskPopupView } from 'View/Popup/Ask';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
export const Passphrases = new WeakMap();
|
||||
|
||||
Passphrases.ask = async (key, sAskDesc, btnText) =>
|
||||
Passphrases.has(key)
|
||||
? {password:Passphrases.handle(key)/*, remember:false*/}
|
||||
: await AskPopupView.password(sAskDesc, btnText, 5);
|
||||
|
||||
const timeouts = {};
|
||||
// get/set accessor to control deletion after N minutes of inactivity
|
||||
Passphrases.handle = (key, pass) => {
|
||||
const timeout = SettingsUserStore.keyPassForget();
|
||||
if (timeout && !timeouts[key]) {
|
||||
timeouts[key] = (()=>Passphrases.delete(key)).debounce(timeout * 1000);
|
||||
}
|
||||
pass && Passphrases.set(key, pass);
|
||||
timeout && timeouts[key]();
|
||||
return Passphrases.get(key);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ DomainAdminStore.fetch = () => {
|
|||
if (!iError) {
|
||||
DomainAdminStore(
|
||||
data.Result.map(item => {
|
||||
item.name = IDN.toUnicode(item.name);
|
||||
item.disabled = ko.observable(item.disabled);
|
||||
item.askDelete = ko.observable(false);
|
||||
return item;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export const LanguageStore = {
|
|||
const aLanguages = Settings.app('languages');
|
||||
this.languages(isArray(aLanguages) ? aLanguages : []);
|
||||
this.language(SettingsGet('language'));
|
||||
this.userLanguage(SettingsGet('userLanguage'));
|
||||
this.userLanguage(SettingsGet('clientLanguage'));
|
||||
this.hourCycle(SettingsGet('hourCycle'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import { addObservablesTo, koArrayWithDestroy } from 'External/ko';
|
|||
|
||||
export const AccountUserStore = koArrayWithDestroy();
|
||||
|
||||
AccountUserStore.loading = ko.observable(false).extend({ debounce: 100 });
|
||||
|
||||
AccountUserStore.getEmailAddresses = () => AccountUserStore.map(item => item.email);
|
||||
|
||||
addObservablesTo(AccountUserStore, {
|
||||
email: '',
|
||||
signature: ''
|
||||
loading: false
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ ContactUserStore.sync = fResultFunc => {
|
|||
line = JSON.parse(line);
|
||||
if ('ContactsSync' === line.Action) {
|
||||
ContactUserStore.syncing(false);
|
||||
fResultFunc?.(line.ErrorCode, line);
|
||||
fResultFunc?.(line.code, line);
|
||||
}
|
||||
} catch (e) {
|
||||
ContactUserStore.syncing(false);
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ FolderUserStore = new class {
|
|||
*/
|
||||
displaySpecSetting: false,
|
||||
|
||||
// sortMode: '',
|
||||
sortMode: '',
|
||||
|
||||
quotaLimit: 0,
|
||||
quotaUsage: 0,
|
||||
|
|
@ -70,8 +70,8 @@ FolderUserStore = new class {
|
|||
trashFolder: '',
|
||||
archiveFolder: '',
|
||||
|
||||
folderListOptimized: false,
|
||||
folderListError: '',
|
||||
optimized: false,
|
||||
error: '',
|
||||
|
||||
foldersLoading: false,
|
||||
foldersCreating: false,
|
||||
|
|
@ -81,8 +81,6 @@ FolderUserStore = new class {
|
|||
foldersInboxUnreadCount: 0
|
||||
});
|
||||
|
||||
self.sortMode = ko.observable('');
|
||||
|
||||
self.namespace = '';
|
||||
|
||||
self.folderList = ko.observableArray(/*new FolderCollectionModel*/);
|
||||
|
|
|
|||
|
|
@ -9,25 +9,17 @@ import Remote from 'Remote/User/Fetch';
|
|||
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey';
|
||||
import { AskPopupView } from 'View/Popup/Ask';
|
||||
|
||||
import { Passphrases } from 'Storage/Passphrases';
|
||||
|
||||
const
|
||||
askPassphrase = async (privateKey, btnTxt = 'LABEL_SIGN') => {
|
||||
const key = privateKey.id,
|
||||
pass = Passphrases.has(key)
|
||||
? {password:Passphrases.get(key), remember:false}
|
||||
: await AskPopupView.password('GnuPG key<br>' + key + ' ' + privateKey.emails[0], 'OPENPGP/'+btnTxt);
|
||||
pass && pass.remember && Passphrases.set(key, pass.password);
|
||||
return pass.password;
|
||||
},
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
|
||||
const
|
||||
findGnuPGKey = (keys, query/*, sign*/) =>
|
||||
keys.find(key =>
|
||||
// key[sign ? 'can_sign' : 'can_decrypt']
|
||||
(key.can_sign || key.can_decrypt)
|
||||
&& (key.emails.includes(query) || key.subkeys.find(key => query == key.keyid || query == key.fingerprint))
|
||||
&& (key.for(query) || key.subkeys.find(key => query == key.keyid || query == key.fingerprint))
|
||||
);
|
||||
|
||||
export const GnuPGUserStore = new class {
|
||||
|
|
@ -45,7 +37,8 @@ export const GnuPGUserStore = new class {
|
|||
this.keyring = null;
|
||||
this.publicKeys([]);
|
||||
this.privateKeys([]);
|
||||
Remote.request('GnupgGetKeys',
|
||||
SettingsCapa('GnuPG')
|
||||
&& Remote.request('GnupgGetKeys',
|
||||
(iError, oData) => {
|
||||
if (oData?.Result) {
|
||||
this.keyring = oData.Result;
|
||||
|
|
@ -55,6 +48,7 @@ export const GnuPGUserStore = new class {
|
|||
key.fingerprint = key.subkeys[0].fingerprint;
|
||||
key.uids.forEach(uid => uid.email && aEmails.push(uid.email));
|
||||
key.emails = aEmails;
|
||||
key.for = email => aEmails.includes(IDN.toASCII(email));
|
||||
key.askDelete = ko.observable(false);
|
||||
key.openForDeletion = ko.observable(null).askDeleteHelper();
|
||||
key.remove = () => {
|
||||
|
|
@ -63,7 +57,7 @@ export const GnuPGUserStore = new class {
|
|||
(iError, oData) => {
|
||||
if (oData) {
|
||||
if (iError) {
|
||||
alert(oData.ErrorMessage);
|
||||
alert(oData.message);
|
||||
} else if (oData.Result) {
|
||||
isPrivate
|
||||
? this.privateKeys.remove(key)
|
||||
|
|
@ -77,33 +71,49 @@ export const GnuPGUserStore = new class {
|
|||
);
|
||||
}
|
||||
};
|
||||
key.view = () => {
|
||||
const fetch = pass => Remote.request('GnupgExportKey',
|
||||
(iError, oData) => {
|
||||
if (oData?.Result) {
|
||||
key.armor = oData.Result;
|
||||
showScreenPopup(OpenPgpKeyPopupView, [key]);
|
||||
} else {
|
||||
Passphrases.delete(key.id);
|
||||
}
|
||||
}, {
|
||||
keyId: key.id,
|
||||
isPrivate: isPrivate,
|
||||
passphrase: pass
|
||||
}
|
||||
if (isPrivate) {
|
||||
key.password = async btnTxt => {
|
||||
const pass = await Passphrases.ask(key,
|
||||
'GnuPG key<br>' + key.id + ' ' + key.emails[0],
|
||||
btnTxt
|
||||
);
|
||||
if (isPrivate) {
|
||||
askPassphrase(key, 'POPUP_VIEW_TITLE').then(passphrase => {
|
||||
(null !== passphrase) && fetch(passphrase);
|
||||
});
|
||||
pass && pass.remember && Passphrases.handle(key, pass.password);
|
||||
return pass?.password;
|
||||
};
|
||||
}
|
||||
key.fetch = async callback => {
|
||||
if (key.armor) {
|
||||
callback && callback();
|
||||
} else {
|
||||
fetch('');
|
||||
let pass = isPrivate ? await key.password('OPENPGP/POPUP_VIEW_TITLE') : '';
|
||||
if (null != pass) try {
|
||||
const result = await Remote.post('GnupgExportKey', null, {
|
||||
keyId: key.id,
|
||||
isPrivate: isPrivate,
|
||||
passphrase: pass
|
||||
});
|
||||
if (result?.Result) {
|
||||
key.armor = result.Result;
|
||||
callback && callback();
|
||||
} else {
|
||||
Passphrases.delete(key);
|
||||
}
|
||||
} catch (e) {
|
||||
Passphrases.delete(key);
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
return key.armor;
|
||||
};
|
||||
key.view = () => key.fetch(() => showScreenPopup(OpenPgpKeyPopupView, [key]));
|
||||
return key;
|
||||
};
|
||||
this.publicKeys(oData.Result.public.map(key => initKey(key, 0)));
|
||||
this.privateKeys(oData.Result.private.map(key => initKey(key, 1)));
|
||||
},
|
||||
collator = baseCollator(),
|
||||
sort = keys => keys.sort(
|
||||
(a, b) => collator.compare(a.emails[0], b.emails[0]) || collator.compare(a.id, b.id)
|
||||
);
|
||||
this.publicKeys(sort(oData.Result.public.map(key => initKey(key, 0))));
|
||||
this.privateKeys(sort(oData.Result.private.map(key => initKey(key, 1))));
|
||||
console.log('gnupg ready');
|
||||
}
|
||||
}
|
||||
|
|
@ -117,19 +127,6 @@ export const GnuPGUserStore = new class {
|
|||
return SettingsCapa('GnuPG');
|
||||
}
|
||||
|
||||
importKey(key, callback) {
|
||||
Remote.request('GnupgImportKey',
|
||||
(iError, oData) => {
|
||||
if (oData?.Result/* && (oData.Result.imported || oData.Result.secretimported)*/) {
|
||||
this.loadKeyrings();
|
||||
}
|
||||
callback?.(iError, oData);
|
||||
}, {
|
||||
key: key
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
keyPair.privateKey
|
||||
keyPair.publicKey
|
||||
|
|
@ -155,7 +152,7 @@ export const GnuPGUserStore = new class {
|
|||
const count = recipients.length,
|
||||
length = count ? recipients.filter(email =>
|
||||
// (key.can_verify || key.can_encrypt) &&
|
||||
this.publicKeys.find(key => key.emails.includes(email))
|
||||
this.publicKeys.find(key => key.for(email))
|
||||
).length : 0;
|
||||
return length && length === count;
|
||||
}
|
||||
|
|
@ -163,13 +160,13 @@ export const GnuPGUserStore = new class {
|
|||
getPublicKeyFingerprints(recipients) {
|
||||
const fingerprints = [];
|
||||
recipients.forEach(email => {
|
||||
fingerprints.push(this.publicKeys.find(key => key.emails.includes(email)).fingerprint);
|
||||
fingerprints.push(this.publicKeys.find(key => key.for(email)).fingerprint);
|
||||
});
|
||||
return fingerprints;
|
||||
}
|
||||
|
||||
getPrivateKeyFor(query, sign) {
|
||||
return findGnuPGKey(this.privateKeys, query, sign);
|
||||
getPrivateKeyFor(query/*, sign*/) {
|
||||
return findGnuPGKey(this.privateKeys, query/*, sign*/);
|
||||
}
|
||||
|
||||
async decrypt(message) {
|
||||
|
|
@ -191,22 +188,27 @@ export const GnuPGUserStore = new class {
|
|||
uid: message.uid,
|
||||
partId: pgpInfo.partId,
|
||||
keyId: key.id,
|
||||
passphrase: await askPassphrase(key, 'BUTTON_DECRYPT'),
|
||||
passphrase: await key.password('CRYPTO/DECRYPT'),
|
||||
data: '' // message.plain() optional
|
||||
}
|
||||
if (null !== params.passphrase) {
|
||||
const result = await Remote.post('GnupgDecrypt', null, params);
|
||||
if (result?.Result && false !== result.Result.data) {
|
||||
return result.Result;
|
||||
if (null != params.passphrase) {
|
||||
try {
|
||||
const response = await Remote.post('GnupgDecrypt', null, params);
|
||||
if (response?.Result?.data) {
|
||||
return response.Result;
|
||||
}
|
||||
throw response;
|
||||
} catch (e) {
|
||||
Passphrases.delete(key);
|
||||
throw e;
|
||||
}
|
||||
Passphrases.delete(key.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async verify(message) {
|
||||
let data = message.pgpSigned(); // { bodyPartId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
|
||||
let data = message.pgpSigned(); // { partId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
|
||||
if (data) {
|
||||
data = { ...data }; // clone
|
||||
// const sender = message.from[0].email;
|
||||
|
|
@ -217,18 +219,19 @@ export const GnuPGUserStore = new class {
|
|||
data.bodyPart = data.bodyPart.raw;
|
||||
data.sigPart = data.sigPart.body;
|
||||
}
|
||||
let response = await Remote.post('MessagePgpVerify', null, data);
|
||||
let response = await Remote.post('PgpVerifyMessage', null, data);
|
||||
if (response?.Result) {
|
||||
return {
|
||||
fingerprint: response.Result.fingerprint,
|
||||
success: 0 == response.Result.status // GOODSIG
|
||||
success: 0 == response.Result.status, // GOODSIG
|
||||
error: response.Result.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sign(privateKey) {
|
||||
return await askPassphrase(privateKey);
|
||||
return await privateKey.password('CRYPTO/SIGN');
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { koArrayWithDestroy } from 'External/ko';
|
||||
import { koArrayWithDestroy, koComputable } from 'External/ko';
|
||||
import { isArray } from 'Common/Utils';
|
||||
|
||||
export const IdentityUserStore = koArrayWithDestroy();
|
||||
|
||||
IdentityUserStore.loading = ko.observable(false).extend({ debounce: 100 });
|
||||
|
||||
/** Returns main (login) identity */
|
||||
IdentityUserStore.main = koComputable(() => {
|
||||
const list = IdentityUserStore();
|
||||
return isArray(list) ? list.find(item => item && !item.id()) : null;
|
||||
});
|
||||
|
|
|
|||
124
dev/Stores/User/Mailvelope.js
Normal file
124
dev/Stores/User/Mailvelope.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { SettingsGet } from 'Common/Globals';
|
||||
|
||||
// https://mailvelope.github.io/mailvelope/Keyring.html
|
||||
let mailvelopeKeyring = null;
|
||||
|
||||
export const MailvelopeUserStore = {
|
||||
keyring: null,
|
||||
|
||||
loadKeyring(identifier) {
|
||||
identifier = identifier || SettingsGet('Email');
|
||||
if (window.mailvelope) {
|
||||
const fn = keyring => {
|
||||
mailvelopeKeyring = keyring;
|
||||
console.log('mailvelope ready');
|
||||
};
|
||||
mailvelope.getKeyring().then(fn, err => {
|
||||
if (identifier) {
|
||||
// attempt to create a new keyring for this app/user
|
||||
mailvelope.createKeyring(identifier).then(fn, err => console.error(err));
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
addEventListener('mailvelope-disconnect', event => {
|
||||
alert('Mailvelope is updated to version ' + event.detail.version + '. Reload page');
|
||||
}, false);
|
||||
} else {
|
||||
addEventListener('mailvelope', () => this.loadKeyring(identifier));
|
||||
}
|
||||
},
|
||||
|
||||
async hasPublicKeyForEmails(recipients) {
|
||||
const
|
||||
mailvelope = mailvelopeKeyring && await mailvelopeKeyring.validKeyForAddress(recipients)
|
||||
/*.then(LookupResult => Object.entries(LookupResult))*/,
|
||||
entries = mailvelope && Object.entries(mailvelope);
|
||||
return entries && entries.filter(value => value[1]).length === recipients.length;
|
||||
},
|
||||
|
||||
async getPrivateKeyFor(email/*, sign*/) {
|
||||
if (mailvelopeKeyring && await mailvelopeKeyring.hasPrivateKey({email:email})) {
|
||||
return ['mailvelope', email];
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
async decrypt(message) {
|
||||
// Try Mailvelope (does not support inline images)
|
||||
if (mailvelopeKeyring) {
|
||||
const sender = message.from[0].email,
|
||||
armoredText = message.plain();
|
||||
try {
|
||||
let emails = [...message.from,...message.to,...message.cc].validUnique(),
|
||||
i = emails.length;
|
||||
while (i--) {
|
||||
if (await this.getPrivateKeyFor(emails[i].email)) {
|
||||
/**
|
||||
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createEncryptedFormContainer
|
||||
* Creates an iframe to display an encrypted form
|
||||
*/
|
||||
// mailvelope.createEncryptedFormContainer('#mailvelope-form');
|
||||
/**
|
||||
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createDisplayContainer
|
||||
* Creates an iframe to display the decrypted content of the encrypted mail.
|
||||
*/
|
||||
const body = message.body;
|
||||
body.textContent = '';
|
||||
let result = await mailvelope.createDisplayContainer(
|
||||
'#'+body.id,
|
||||
armoredText,
|
||||
mailvelopeKeyring,
|
||||
{
|
||||
senderAddress: sender
|
||||
// emails[i].email
|
||||
}
|
||||
);
|
||||
if (result) {
|
||||
if (result.error?.message) {
|
||||
if ('PWD_DIALOG_CANCEL' !== result.error.code) {
|
||||
alert(result.error.code + ': ' + result.error.message);
|
||||
}
|
||||
} else {
|
||||
body.classList.add('mailvelope');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns headers that should be added to an outgoing email.
|
||||
* So far this is only the autocrypt header.
|
||||
*/
|
||||
/*
|
||||
mailvelopeKeyring.additionalHeadersForOutgoingEmail(from: 'abc@web.de')
|
||||
.then(function(additional) {
|
||||
console.log('additionalHeadersForOutgoingEmail', additional);
|
||||
// logs: {autocrypt: "addr=abc@web.de; prefer-encrypt=mutual; keydata=..."}
|
||||
});
|
||||
|
||||
mailvelopeKeyring.addSyncHandler(syncHandlerObj)
|
||||
mailvelopeKeyring.createKeyBackupContainer(selector, options)
|
||||
mailvelopeKeyring.createKeyGenContainer(selector, {
|
||||
// userIds: [],
|
||||
keySize: 4096
|
||||
})
|
||||
|
||||
mailvelopeKeyring.exportOwnPublicKey(emailAddr).then(<AsciiArmored, Error>)
|
||||
mailvelopeKeyring.importPublicKey(armored)
|
||||
|
||||
// https://mailvelope.github.io/mailvelope/global.html#SyncHandlerObject
|
||||
mailvelopeKeyring.addSyncHandler({
|
||||
uploadSync
|
||||
downloadSync
|
||||
backup
|
||||
restore
|
||||
});
|
||||
*/
|
||||
};
|
||||
|
|
@ -32,8 +32,11 @@ import { SettingsGet } from 'Common/Globals';
|
|||
import { SUB_QUERY_PREFIX } from 'Common/Links';
|
||||
import { AppUserStore } from 'Stores/User/App';
|
||||
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
|
||||
const
|
||||
isChecked = item => item.checked(),
|
||||
isDeleted = item => item.isDeleted(),
|
||||
replaceHash = hash => {
|
||||
rl.route.off();
|
||||
hasher.replaceHash(hash);
|
||||
|
|
@ -105,15 +108,15 @@ addComputablesTo(MessagelistUserStore, {
|
|||
listCheckedOrSelected: () => {
|
||||
const
|
||||
selectedMessage = MessagelistUserStore.selectedMessage(),
|
||||
focusedMessage = MessagelistUserStore.focusedMessage(),
|
||||
checked = MessagelistUserStore.filter(item => isChecked(item));
|
||||
return checked.length ? checked : (selectedMessage || focusedMessage ? [selectedMessage || focusedMessage] : []);
|
||||
return checked.length ? checked : (selectedMessage ? [selectedMessage] : []);
|
||||
},
|
||||
|
||||
listCheckedOrSelectedUidsWithSubMails: () => {
|
||||
let result = new Set;
|
||||
MessagelistUserStore.listCheckedOrSelected().forEach(message => {
|
||||
result.add(message.uid);
|
||||
result.folder = message.folder;
|
||||
if (1 < message.threadsLen()) {
|
||||
message.threads().forEach(result.add, result);
|
||||
}
|
||||
|
|
@ -134,11 +137,18 @@ MessagelistUserStore.hasChecked = koComputable(
|
|||
|
||||
MessagelistUserStore.hasCheckedOrSelected = koComputable(() =>
|
||||
!!MessagelistUserStore.selectedMessage()
|
||||
| !!MessagelistUserStore.focusedMessage()
|
||||
// Issue: not all are observed?
|
||||
| !!MessagelistUserStore.find(isChecked)
|
||||
).extend({ rateLimit: 50 });
|
||||
|
||||
MessagelistUserStore.hasCheckedOrSelectedAndDeleted = koComputable(
|
||||
() => !!MessagelistUserStore.listCheckedOrSelected().find(isDeleted)
|
||||
).extend({ rateLimit: 50 });
|
||||
|
||||
MessagelistUserStore.hasCheckedOrSelectedAndUndeleted = koComputable(
|
||||
() => !!MessagelistUserStore.listCheckedOrSelected().find(item => !item?.isDeleted())
|
||||
).extend({ rateLimit: 50 });
|
||||
|
||||
MessagelistUserStore.notifyNewMessages = (folder, newMessages) => {
|
||||
if (getFolderInboxName() === folder && arrayLength(newMessages)) {
|
||||
|
||||
|
|
@ -165,7 +175,7 @@ MessagelistUserStore.notifyNewMessages = (folder, newMessages) => {
|
|||
}
|
||||
}
|
||||
|
||||
MessagelistUserStore.canAutoSelect = () =>
|
||||
MessagelistUserStore.canSelect = () =>
|
||||
!disableAutoSelect()
|
||||
&& SettingsUserStore.usePreviewPane();
|
||||
// && !SettingsUserStore.showNextMessage();
|
||||
|
|
@ -253,17 +263,14 @@ MessagelistUserStore.reload = (bDropPagePosition = false, bDropCurrentFolderCach
|
|||
|
||||
let flags = folderInfo.permanentFlags || [];
|
||||
if (flags.includes('\\*')) {
|
||||
/** Add Thunderbird labels */
|
||||
let i = 6;
|
||||
while (--i) {
|
||||
flags.includes('$label'+i) || flags.push('$label'+i);
|
||||
}
|
||||
/** TODO: add others by default? */
|
||||
}
|
||||
flags.sort((a, b) => {
|
||||
a = a.toUpperCase();
|
||||
b = b.toUpperCase();
|
||||
return (a < b) ? -1 : ((a > b) ? 1 : 0);
|
||||
});
|
||||
folder.permanentFlags(flags);
|
||||
folder.permanentFlags(flags.sort(baseCollator().compare));
|
||||
|
||||
MessagelistUserStore.notifyNewMessages(folder.fullName, collection.newMessages);
|
||||
}
|
||||
|
|
@ -313,6 +320,7 @@ MessagelistUserStore.reload = (bDropPagePosition = false, bDropCurrentFolderCach
|
|||
|
||||
if (AppUserStore.threadsAllowed() && SettingsUserStore.useThreads()) {
|
||||
params.useThreads = 1;
|
||||
params.threadAlgorithm = SettingsUserStore.threadAlgorithm();
|
||||
params.threadUid = MessagelistUserStore.threadUid();
|
||||
} else {
|
||||
params.threadUid = 0;
|
||||
|
|
@ -344,13 +352,23 @@ MessagelistUserStore.setAction = (sFolderFullName, iSetAction, messages) => {
|
|||
length;
|
||||
|
||||
if (iSetAction == MessageSetAction.SetSeen) {
|
||||
messages.forEach(oMessage =>
|
||||
oMessage.isUnseen() && rootUids.push(oMessage.uid) && oMessage.flags.push('\\seen')
|
||||
);
|
||||
messages.forEach(oMessage => {
|
||||
if (oMessage.isUnseen() && rootUids.push(oMessage.uid)) {
|
||||
oMessage.flags.push('\\seen');
|
||||
if (oMessage.threads().length > 0 && oMessage.threadUnseen().includes(oMessage.uid)) {
|
||||
oMessage.threadUnseen.remove(oMessage.uid);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (iSetAction == MessageSetAction.UnsetSeen) {
|
||||
messages.forEach(oMessage =>
|
||||
!oMessage.isUnseen() && rootUids.push(oMessage.uid) && oMessage.flags.remove('\\seen')
|
||||
);
|
||||
messages.forEach(oMessage => {
|
||||
if (!oMessage.isUnseen() && rootUids.push(oMessage.uid)) {
|
||||
oMessage.flags.remove('\\seen');
|
||||
if (oMessage.threads().length > 0 && !oMessage.threadUnseen().includes(oMessage.uid)) {
|
||||
oMessage.threadUnseen.push(oMessage.uid);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (iSetAction == MessageSetAction.SetFlag) {
|
||||
messages.forEach(oMessage =>
|
||||
!oMessage.isFlagged() && rootUids.push(oMessage.uid) && oMessage.flags.push('\\flagged')
|
||||
|
|
@ -359,6 +377,14 @@ MessagelistUserStore.setAction = (sFolderFullName, iSetAction, messages) => {
|
|||
messages.forEach(oMessage =>
|
||||
oMessage.isFlagged() && rootUids.push(oMessage.uid) && oMessage.flags.remove('\\flagged')
|
||||
);
|
||||
} else if (iSetAction == MessageSetAction.SetDeleted) {
|
||||
messages.forEach(oMessage =>
|
||||
!oMessage.isDeleted() && rootUids.push(oMessage.uid) && oMessage.flags.push('\\deleted')
|
||||
);
|
||||
} else if (iSetAction == MessageSetAction.UnsetDeleted) {
|
||||
messages.forEach(oMessage =>
|
||||
oMessage.isDeleted() && rootUids.push(oMessage.uid) && oMessage.flags.remove('\\deleted')
|
||||
);
|
||||
}
|
||||
rootUids = rootUids.validUnique();
|
||||
length = rootUids.length;
|
||||
|
|
@ -388,6 +414,15 @@ MessagelistUserStore.setAction = (sFolderFullName, iSetAction, messages) => {
|
|||
setAction: iSetAction == MessageSetAction.SetFlag ? 1 : 0
|
||||
});
|
||||
break;
|
||||
|
||||
case MessageSetAction.SetDeleted:
|
||||
case MessageSetAction.UnsetDeleted:
|
||||
Remote.request('MessageSetDeleted', null, {
|
||||
folder: sFolderFullName,
|
||||
uids: rootUids.join(','),
|
||||
setAction: iSetAction == MessageSetAction.SetDeleted ? 1 : 0
|
||||
});
|
||||
break;
|
||||
// no default
|
||||
}
|
||||
}
|
||||
|
|
@ -433,23 +468,6 @@ MessagelistUserStore.moveMessages = (
|
|||
if (page > MessagelistUserStore.pageCount()) {
|
||||
setPage = MessagelistUserStore.pageCount();
|
||||
}
|
||||
if (MessagelistUserStore.threadUid()
|
||||
&& MessagelistUserStore.length
|
||||
&& MessagelistUserStore.find(item => item?.deleted() && item.uid == MessagelistUserStore.threadUid())
|
||||
) {
|
||||
const message = MessagelistUserStore.find(item => item && !item.deleted());
|
||||
if (!message) {
|
||||
if (1 < page) {
|
||||
setPage = page - 1;
|
||||
} else {
|
||||
MessagelistUserStore.threadUid(0);
|
||||
setPage = MessagelistUserStore.pageBeforeThread();
|
||||
}
|
||||
} else if (MessagelistUserStore.threadUid() != message.uid) {
|
||||
MessagelistUserStore.threadUid(message.uid);
|
||||
setPage = page;
|
||||
}
|
||||
}
|
||||
if (setPage) {
|
||||
MessagelistUserStore.page(setPage);
|
||||
replaceHash(
|
||||
|
|
@ -507,7 +525,6 @@ MessagelistUserStore.moveMessages = (
|
|||
currentMessage = null;
|
||||
MessageUserStore.message(null);
|
||||
}
|
||||
item.deleted(true);
|
||||
MessagelistUserStore.remove(item);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,38 +10,48 @@ import Remote from 'Remote/User/Fetch';
|
|||
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey';
|
||||
import { AskPopupView } from 'View/Popup/Ask';
|
||||
|
||||
import { Passphrases } from 'Storage/Passphrases';
|
||||
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
|
||||
const
|
||||
loaded = () => !!window.openpgp,
|
||||
|
||||
findOpenPGPKey = (keys, query/*, sign*/) =>
|
||||
keys.find(key =>
|
||||
key.emails.includes(query) || query == key.id || query == key.fingerprint
|
||||
key.for(query) || query == key.id || query == key.fingerprint
|
||||
),
|
||||
|
||||
decryptKey = async (privateKey, btnTxt = 'LABEL_SIGN') => {
|
||||
decryptKey = async (privateKey, btnTxt = 'SIGN') => {
|
||||
if (privateKey.key.isDecrypted()) {
|
||||
return privateKey.key;
|
||||
}
|
||||
const key = privateKey.id,
|
||||
pass = Passphrases.has(key)
|
||||
? {password:Passphrases.get(key), remember:false}
|
||||
: await AskPopupView.password(
|
||||
'OpenPGP.js key<br>' + key + ' ' + privateKey.emails[0],
|
||||
'OPENPGP/'+btnTxt
|
||||
);
|
||||
pass = await Passphrases.ask(privateKey,
|
||||
'OpenPGP.js key<br>' + key + ' ' + privateKey.emails[0],
|
||||
'CRYPTO/'+btnTxt
|
||||
);
|
||||
if (pass) {
|
||||
const passphrase = pass.password,
|
||||
result = await openpgp.decryptKey({
|
||||
privateKey: privateKey.key,
|
||||
passphrase
|
||||
});
|
||||
result && pass.remember && Passphrases.set(key, passphrase);
|
||||
result && pass.remember && Passphrases.handle(privateKey, passphrase);
|
||||
return result;
|
||||
}
|
||||
},
|
||||
|
||||
collator = baseCollator(),
|
||||
sort = keys => keys.sort(
|
||||
// (a, b) => collator.compare(a.emails[0], b.emails[0]) || collator.compare(a.fingerprint, b.fingerprint)
|
||||
(a, b) => collator.compare(a.emails[0], b.emails[0]) || collator.compare(a.id, b.id)
|
||||
),
|
||||
dedup = keys => sort((keys || [])
|
||||
.filter((v, i, a) => a.findIndex(entry => entry.fingerprint == v.fingerprint) === i)
|
||||
),
|
||||
|
||||
/**
|
||||
* OpenPGP.js v5 removed the localStorage (keyring)
|
||||
* This should be compatible with the old OpenPGP.js v2
|
||||
|
|
@ -53,9 +63,11 @@ const
|
|||
let keys = [], key,
|
||||
armoredKeys = JSON.parse(storage.getItem(itemname)),
|
||||
i = arrayLength(armoredKeys);
|
||||
while (i--) {
|
||||
while (i--) try {
|
||||
key = await openpgp.readKey({armoredKey:armoredKeys[i]});
|
||||
key.err || keys.push(new OpenPgpKeyModel(armoredKeys[i], key));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return keys;
|
||||
},
|
||||
|
|
@ -71,15 +83,11 @@ const
|
|||
class OpenPgpKeyModel {
|
||||
constructor(armor, key) {
|
||||
this.key = key;
|
||||
const aEmails = [];
|
||||
if (key.users) {
|
||||
key.users.forEach(user => user.userID.email && aEmails.push(user.userID.email));
|
||||
}
|
||||
this.id = key.getKeyID().toHex().toUpperCase();
|
||||
this.fingerprint = key.getFingerprint();
|
||||
this.can_encrypt = !!key.getEncryptionKey();
|
||||
this.can_sign = !!key.getSigningKey();
|
||||
this.emails = aEmails;
|
||||
this.emails = key.users.map(user => IDN.toASCII(user.userID.email)).filter(email => email);
|
||||
this.armor = armor;
|
||||
this.askDelete = ko.observable(false);
|
||||
this.openForDeletion = ko.observable(null).askDeleteHelper();
|
||||
|
|
@ -87,6 +95,18 @@ class OpenPgpKeyModel {
|
|||
// key.getPrimaryUser()
|
||||
}
|
||||
|
||||
/*
|
||||
get id() { return this.key.getKeyID().toHex().toUpperCase(); }
|
||||
get fingerprint() { return this.key.getFingerprint(); }
|
||||
get can_encrypt() { return !!this.key.getEncryptionKey(); }
|
||||
get can_sign() { return !!this.key.getSigningKey(); }
|
||||
get emails() { return this.key.users.map(user => IDN.toASCII(user.userID.email)).filter(email => email); }
|
||||
get armor() { return this.key.armor(); }
|
||||
*/
|
||||
for(email) {
|
||||
return this.emails.includes(IDN.toASCII(email));
|
||||
}
|
||||
|
||||
view() {
|
||||
showScreenPopup(OpenPgpKeyPopupView, [this]);
|
||||
}
|
||||
|
|
@ -116,37 +136,62 @@ export const OpenPGPUserStore = new class {
|
|||
}
|
||||
|
||||
loadKeyrings() {
|
||||
if (window.openpgp) {
|
||||
loadOpenPgpKeys(publicKeysItem).then(keys => {
|
||||
this.publicKeys(keys || []);
|
||||
if (loaded()) {
|
||||
loadOpenPgpKeys(publicKeysItem)
|
||||
.then(keys => {
|
||||
this.publicKeys(dedup(keys));
|
||||
console.log('openpgp.js public keys loaded');
|
||||
});
|
||||
loadOpenPgpKeys(privateKeysItem).then(keys => {
|
||||
this.privateKeys(keys || [])
|
||||
console.log('openpgp.js private keys loaded');
|
||||
})
|
||||
.finally(() => {
|
||||
loadOpenPgpKeys(privateKeysItem)
|
||||
.then(keys => {
|
||||
this.privateKeys(dedup(keys));
|
||||
console.log('openpgp.js private keys loaded');
|
||||
})
|
||||
.finally(() => {
|
||||
/*SettingsGet('loadBackupKeys') && */this.loadBackupKeys();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadBackupKeys() {
|
||||
Remote.request('GetPGPKeys',
|
||||
(iError, oData) => !iError && oData.Result && this.importKeys(oData.Result)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSupported() {
|
||||
return !!window.openpgp;
|
||||
return loaded();
|
||||
}
|
||||
|
||||
importKey(armoredKey) {
|
||||
window.openpgp && openpgp.readKey({armoredKey:armoredKey}).then(key => {
|
||||
if (!key.err) {
|
||||
if (key.isPrivate()) {
|
||||
this.privateKeys.push(new OpenPgpKeyModel(armoredKey, key));
|
||||
storeOpenPgpKeys(this.privateKeys, privateKeysItem);
|
||||
} else {
|
||||
this.publicKeys.push(new OpenPgpKeyModel(armoredKey, key));
|
||||
storeOpenPgpKeys(this.publicKeys, publicKeysItem);
|
||||
this.importKeys([armoredKey]);
|
||||
}
|
||||
|
||||
async importKeys(keys) {
|
||||
if (loaded()) {
|
||||
const privateKeys = this.privateKeys(),
|
||||
publicKeys = this.publicKeys();
|
||||
for (const armoredKey of keys) try {
|
||||
let key = await openpgp.readKey({armoredKey:armoredKey});
|
||||
if (!key.err) {
|
||||
key = new OpenPgpKeyModel(armoredKey, key);
|
||||
const keys = key.key.isPrivate() ? privateKeys : publicKeys;
|
||||
keys.find(entry => entry.fingerprint == key.fingerprint)
|
||||
|| keys.push(key);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e, armoredKey);
|
||||
}
|
||||
});
|
||||
this.privateKeys(sort(privateKeys));
|
||||
this.publicKeys(sort(publicKeys));
|
||||
storeOpenPgpKeys(privateKeys, privateKeysItem);
|
||||
storeOpenPgpKeys(publicKeys, publicKeysItem);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -155,7 +200,7 @@ export const OpenPGPUserStore = new class {
|
|||
keyPair.revocationCertificate
|
||||
*/
|
||||
storeKeyPair(keyPair) {
|
||||
if (window.openpgp) {
|
||||
if (loaded()) {
|
||||
openpgp.readKey({armoredKey:keyPair.publicKey}).then(key => {
|
||||
this.publicKeys.push(new OpenPgpKeyModel(keyPair.publicKey, key));
|
||||
storeOpenPgpKeys(this.publicKeys, publicKeysItem);
|
||||
|
|
@ -173,7 +218,7 @@ export const OpenPGPUserStore = new class {
|
|||
hasPublicKeyForEmails(recipients) {
|
||||
const count = recipients.length,
|
||||
length = count ? recipients.filter(email =>
|
||||
this.publicKeys().find(key => key.emails.includes(email))
|
||||
this.publicKeys().find(key => key.for(email))
|
||||
).length : 0;
|
||||
return length && length === count;
|
||||
}
|
||||
|
|
@ -201,7 +246,7 @@ export const OpenPGPUserStore = new class {
|
|||
}
|
||||
}
|
||||
if (privateKey) try {
|
||||
const decryptedKey = await decryptKey(privateKey, 'BUTTON_DECRYPT');
|
||||
const decryptedKey = await decryptKey(privateKey, 'DECRYPT');
|
||||
if (decryptedKey) {
|
||||
const publicKey = findOpenPGPKey(this.publicKeys, sender/*, sign*/);
|
||||
return await openpgp.decrypt({
|
||||
|
|
@ -222,15 +267,15 @@ export const OpenPGPUserStore = new class {
|
|||
* https://docs.openpgpjs.org/#sign-and-verify-cleartext-messages
|
||||
*/
|
||||
async verify(message) {
|
||||
const data = message.pgpSigned(), // { bodyPartId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
|
||||
publicKey = this.publicKeys().find(key => key.emails.includes(message.from[0].email));
|
||||
const data = message.pgpSigned(), // { partId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
|
||||
publicKey = this.publicKeys().find(key => key.for(message.from[0].email));
|
||||
if (data && publicKey) {
|
||||
data.folder = message.folder;
|
||||
data.uid = message.uid;
|
||||
data.tryGnuPG = 0;
|
||||
let response;
|
||||
if (data.sigPartId) {
|
||||
response = await Remote.post('MessagePgpVerify', null, data);
|
||||
response = await Remote.post('PgpVerifyMessage', null, data);
|
||||
} else if (data.bodyPart) {
|
||||
// MimePart
|
||||
response = { Result: { text: data.bodyPart.raw, signature: data.sigPart.body } };
|
||||
|
|
@ -282,7 +327,7 @@ export const OpenPGPUserStore = new class {
|
|||
*/
|
||||
async encrypt(text, recipients, signPrivateKey) {
|
||||
const count = recipients.length;
|
||||
recipients = recipients.map(email => this.publicKeys().find(key => key.emails.includes(email))).filter(key => key);
|
||||
recipients = recipients.map(email => this.publicKeys().find(key => key.for(email))).filter(key => key);
|
||||
if (count === recipients.length) {
|
||||
if (signPrivateKey) {
|
||||
signPrivateKey = await decryptKey(signPrivateKey);
|
||||
|
|
|
|||
|
|
@ -8,14 +8,16 @@ import { SettingsCapa, SettingsGet } from 'Common/Globals';
|
|||
|
||||
import { GnuPGUserStore } from 'Stores/User/GnuPG';
|
||||
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
|
||||
import { MailvelopeUserStore } from 'Stores/User/Mailvelope';
|
||||
|
||||
// https://mailvelope.github.io/mailvelope/Keyring.html
|
||||
let mailvelopeKeyring = null;
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
export const
|
||||
BEGIN_PGP_MESSAGE = '-----BEGIN PGP MESSAGE-----',
|
||||
// BEGIN_PGP_SIGNATURE = '-----BEGIN PGP SIGNATURE-----',
|
||||
// BEGIN_PGP_SIGNED = '-----BEGIN PGP SIGNED MESSAGE-----',
|
||||
// BEGIN_PGP_PUBLIC_KEY = '-----BEGIN PGP PUBLIC KEY BLOCK-----',
|
||||
// END_PGP_PUBLIC_KEY = '-----END PGP PUBLIC KEY BLOCK-----',
|
||||
|
||||
PgpUserStore = new class {
|
||||
init() {
|
||||
|
|
@ -33,32 +35,9 @@ export const
|
|||
}
|
||||
|
||||
loadKeyrings(identifier) {
|
||||
identifier = identifier || SettingsGet('Email');
|
||||
if (window.mailvelope) {
|
||||
const fn = keyring => {
|
||||
mailvelopeKeyring = keyring;
|
||||
console.log('mailvelope ready');
|
||||
};
|
||||
mailvelope.getKeyring().then(fn, err => {
|
||||
if (identifier) {
|
||||
// attempt to create a new keyring for this app/user
|
||||
mailvelope.createKeyring(identifier).then(fn, err => console.error(err));
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
addEventListener('mailvelope-disconnect', event => {
|
||||
alert('Mailvelope is updated to version ' + event.detail.version + '. Reload page');
|
||||
}, false);
|
||||
} else {
|
||||
addEventListener('mailvelope', () => this.loadKeyrings(identifier));
|
||||
}
|
||||
|
||||
MailvelopeUserStore.loadKeyring(identifier);
|
||||
OpenPGPUserStore.loadKeyrings();
|
||||
|
||||
if (SettingsCapa('GnuPG')) {
|
||||
GnuPGUserStore.loadKeyrings();
|
||||
}
|
||||
GnuPGUserStore.loadKeyrings();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -75,21 +54,28 @@ export const
|
|||
return 0 === text.trim().indexOf(BEGIN_PGP_MESSAGE);
|
||||
}
|
||||
|
||||
async mailvelopeHasPublicKeyForEmails(recipients) {
|
||||
const
|
||||
mailvelope = mailvelopeKeyring && await mailvelopeKeyring.validKeyForAddress(recipients)
|
||||
/*.then(LookupResult => Object.entries(LookupResult))*/,
|
||||
entries = mailvelope && Object.entries(mailvelope);
|
||||
return entries && entries.filter(value => value[1]).length === recipients.length;
|
||||
importKey(key, gnuPG, backup) {
|
||||
if (gnuPG || backup) {
|
||||
Remote.request('PgpImportKey',
|
||||
(iError, oData) => {
|
||||
if (gnuPG && oData?.Result/* && (oData.Result.imported || oData.Result.secretimported)*/) {
|
||||
GnuPGUserStore.loadKeyrings();
|
||||
}
|
||||
iError && alert(oData.message);
|
||||
}, {
|
||||
key, gnuPG, backup
|
||||
}
|
||||
);
|
||||
}
|
||||
OpenPGPUserStore.importKey(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if verifying/encrypting a message is possible with given email addresses.
|
||||
* Returns the first library that can.
|
||||
*/
|
||||
async hasPublicKeyForEmails(recipients) {
|
||||
const count = recipients.length;
|
||||
if (count) {
|
||||
hasPublicKeyForEmails(recipients) {
|
||||
if (recipients.length) {
|
||||
if (GnuPGUserStore.hasPublicKeyForEmails(recipients)) {
|
||||
return 'gnupg';
|
||||
}
|
||||
|
|
@ -100,40 +86,15 @@ export const
|
|||
return false;
|
||||
}
|
||||
|
||||
async getMailvelopePrivateKeyFor(email/*, sign*/) {
|
||||
if (mailvelopeKeyring && await mailvelopeKeyring.hasPrivateKey({email:email})) {
|
||||
return ['mailvelope', email];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if signing a message is possible with given email address.
|
||||
* Returns the first library that can.
|
||||
*/
|
||||
async getKeyForSigning(email) {
|
||||
let key = OpenPGPUserStore.getPrivateKeyFor(email, 1);
|
||||
if (key) {
|
||||
return ['openpgp', key];
|
||||
}
|
||||
|
||||
key = GnuPGUserStore.getPrivateKeyFor(email, 1);
|
||||
if (key) {
|
||||
return ['gnupg', key];
|
||||
}
|
||||
|
||||
// return await this.getMailvelopePrivateKeyFor(email, 1);
|
||||
}
|
||||
|
||||
async decrypt(message) {
|
||||
const sender = message.from[0].email,
|
||||
armoredText = message.plain();
|
||||
const armoredText = message.plain();
|
||||
if (!this.isEncrypted(armoredText)) {
|
||||
throw Error('Not armored text');
|
||||
}
|
||||
|
||||
// Try OpenPGP.js
|
||||
if (OpenPGPUserStore.isSupported()) {
|
||||
const sender = message.from[0].email;
|
||||
let result = await OpenPGPUserStore.decrypt(armoredText, sender);
|
||||
if (result) {
|
||||
return result;
|
||||
|
|
@ -141,68 +102,20 @@ export const
|
|||
}
|
||||
|
||||
// Try Mailvelope (does not support inline images)
|
||||
if (mailvelopeKeyring) {
|
||||
try {
|
||||
let emails = [...message.from,...message.to,...message.cc].validUnique(),
|
||||
i = emails.length;
|
||||
while (i--) {
|
||||
if (await this.getMailvelopePrivateKeyFor(emails[i].email)) {
|
||||
/**
|
||||
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createEncryptedFormContainer
|
||||
* Creates an iframe to display an encrypted form
|
||||
*/
|
||||
// mailvelope.createEncryptedFormContainer('#mailvelope-form');
|
||||
/**
|
||||
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createDisplayContainer
|
||||
* Creates an iframe to display the decrypted content of the encrypted mail.
|
||||
*/
|
||||
const body = message.body;
|
||||
body.textContent = '';
|
||||
let result = await mailvelope.createDisplayContainer(
|
||||
'#'+body.id,
|
||||
armoredText,
|
||||
mailvelopeKeyring,
|
||||
{
|
||||
senderAddress: sender
|
||||
// emails[i].email
|
||||
}
|
||||
);
|
||||
if (result) {
|
||||
if (result.error?.message) {
|
||||
if ('PWD_DIALOG_CANCEL' !== result.error.code) {
|
||||
alert(result.error.code + ': ' + result.error.message);
|
||||
}
|
||||
} else {
|
||||
body.classList.add('mailvelope');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Now try GnuPG
|
||||
return GnuPGUserStore.decrypt(message);
|
||||
return (await MailvelopeUserStore.decrypt(message))
|
||||
// Or try GnuPG
|
||||
|| GnuPGUserStore.decrypt(message);
|
||||
}
|
||||
|
||||
async verify(message) {
|
||||
const signed = message.pgpSigned();
|
||||
const signed = message.pgpSigned(),
|
||||
sender = message.from[0].email;
|
||||
if (signed) {
|
||||
const sender = message.from[0].email,
|
||||
gnupg = GnuPGUserStore.hasPublicKeyForEmails([sender]),
|
||||
openpgp = OpenPGPUserStore.hasPublicKeyForEmails([sender]);
|
||||
// Detached signature use GnuPG first, else we must download whole message
|
||||
if (gnupg && signed.sigPartId) {
|
||||
return GnuPGUserStore.verify(message);
|
||||
}
|
||||
if (openpgp) {
|
||||
// OpenPGP only when inline, else we must download the whole message
|
||||
if (!signed.sigPartId && OpenPGPUserStore.hasPublicKeyForEmails([sender])) {
|
||||
return OpenPGPUserStore.verify(message);
|
||||
}
|
||||
if (gnupg) {
|
||||
if (GnuPGUserStore.hasPublicKeyForEmails([sender])) {
|
||||
return GnuPGUserStore.verify(message);
|
||||
}
|
||||
// Mailvelope can't
|
||||
|
|
@ -210,29 +123,23 @@ export const
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns headers that should be added to an outgoing email.
|
||||
* So far this is only the autocrypt header.
|
||||
*/
|
||||
/*
|
||||
mailvelopeKeyring.additionalHeadersForOutgoingEmail(headers)
|
||||
mailvelopeKeyring.addSyncHandler(syncHandlerObj)
|
||||
mailvelopeKeyring.createKeyBackupContainer(selector, options)
|
||||
mailvelopeKeyring.createKeyGenContainer(selector, {
|
||||
// userIds: [],
|
||||
keySize: 4096
|
||||
})
|
||||
|
||||
mailvelopeKeyring.exportOwnPublicKey(emailAddr).then(<AsciiArmored, Error>)
|
||||
mailvelopeKeyring.importPublicKey(armored)
|
||||
|
||||
// https://mailvelope.github.io/mailvelope/global.html#SyncHandlerObject
|
||||
mailvelopeKeyring.addSyncHandler({
|
||||
uploadSync
|
||||
downloadSync
|
||||
backup
|
||||
restore
|
||||
});
|
||||
*/
|
||||
|
||||
getPublicKeyOfEmails(recipients) {
|
||||
if (recipients.length) {
|
||||
let result = {};
|
||||
recipients.forEach(email => {
|
||||
OpenPGPUserStore.publicKeys().forEach(key => {
|
||||
if (key.for(email)) {
|
||||
result[email] = key.armor;
|
||||
}
|
||||
});
|
||||
GnuPGUserStore.publicKeys.map(async key => {
|
||||
if (!result[email] && key.for(email)) {
|
||||
result[email] = await key.fetch();
|
||||
}
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
21
dev/Stores/User/SMime.js
Normal file
21
dev/Stores/User/SMime.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { addObservablesTo, koArrayWithDestroy } from 'External/ko';
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
export const SMimeUserStore = koArrayWithDestroy();
|
||||
|
||||
addObservablesTo(SMimeUserStore, {
|
||||
loading: false
|
||||
});
|
||||
|
||||
SMimeUserStore.loadCertificates = () => {
|
||||
SMimeUserStore([]);
|
||||
SMimeUserStore.loading(true);
|
||||
Remote.request('SMimeGetCertificates', (iError, oData) => {
|
||||
SMimeUserStore.loading(false);
|
||||
const collator = baseCollator();
|
||||
iError || SMimeUserStore(oData.Result.sort(
|
||||
(a, b) => collator.compare(a.emailAddress, b.emailAddress) || (b.validTo_time_t - a.validTo_time_t)
|
||||
));
|
||||
});
|
||||
};
|
||||
|
|
@ -29,12 +29,16 @@ export const SettingsUserStore = new class {
|
|||
showNextMessage: 0,
|
||||
allowDraftAutosave: 1,
|
||||
useThreads: 0,
|
||||
threadAlgorithm: '',
|
||||
replySameFolder: 0,
|
||||
hideUnsubscribed: 0,
|
||||
hideDeleted: 1,
|
||||
unhideKolabFolders: 0,
|
||||
autoLogout: 0,
|
||||
keyPassForget: 15,
|
||||
showUnreadCount: 0,
|
||||
messageNewWindow: 0,
|
||||
messageReadAuto: 0,
|
||||
|
||||
requestReadReceipt: 0,
|
||||
requestDsn: 0,
|
||||
|
|
@ -45,6 +49,7 @@ export const SettingsUserStore = new class {
|
|||
|
||||
layout: 1,
|
||||
editorDefaultType: 'Html',
|
||||
editorWysiwyg: 'Squire',
|
||||
msgDefaultAction: 1
|
||||
});
|
||||
|
||||
|
|
@ -76,41 +81,83 @@ export const SettingsUserStore = new class {
|
|||
|
||||
init() {
|
||||
const self = this;
|
||||
self.editorDefaultType(SettingsGet('EditorDefaultType'));
|
||||
|
||||
[
|
||||
'EditorDefaultType',
|
||||
'editorWysiwyg',
|
||||
'messageNewWindow',
|
||||
'messageReadAuto',
|
||||
'MsgDefaultAction',
|
||||
'ViewHTML',
|
||||
'ViewImages',
|
||||
'ViewImagesWhitelist',
|
||||
'RemoveColors',
|
||||
'AllowStyles',
|
||||
'CollapseBlockquotes',
|
||||
'MaxBlockquotesLevel',
|
||||
'ListInlineAttachments',
|
||||
'simpleAttachmentsList',
|
||||
'UseCheckboxesInList',
|
||||
'listGrouped',
|
||||
'showNextMessage',
|
||||
'AllowDraftAutosave',
|
||||
'useThreads',
|
||||
'threadAlgorithm',
|
||||
'ReplySameFolder',
|
||||
'HideUnsubscribed',
|
||||
'HideDeleted',
|
||||
'ShowUnreadCount',
|
||||
'UnhideKolabFolders',
|
||||
'requestReadReceipt',
|
||||
'requestDsn',
|
||||
'requireTLS',
|
||||
'pgpSign',
|
||||
'pgpEncrypt',
|
||||
'allowSpellcheck'
|
||||
/*
|
||||
'MessagesPerPage',
|
||||
'MessageReadDelay',
|
||||
'SoundNotification',
|
||||
'NotificationSound',
|
||||
'DesktopNotifications',
|
||||
'Layout',
|
||||
'AutoLogout',
|
||||
'ContactsAutosave',
|
||||
'contactsAllowed',
|
||||
'CheckMailInterval',
|
||||
'SentFolder',
|
||||
'DraftsFolder',
|
||||
'JunkFolder',
|
||||
'TrashFolder',
|
||||
'ArchiveFolder',
|
||||
'hourCycle',
|
||||
'Resizer4Width',
|
||||
'Resizer5Width',
|
||||
'Resizer5Height',
|
||||
'fontSansSerif',
|
||||
'fontSerif',
|
||||
'fontMono',
|
||||
'userBackgroundName',
|
||||
'userBackgroundHash',
|
||||
'autoVerifySignatures',
|
||||
'allowLanguagesOnSettings',
|
||||
'attachmentLimit',
|
||||
'Theme',
|
||||
'language',
|
||||
'clientLanguage',
|
||||
'StaticLibsJs',
|
||||
*/
|
||||
].forEach(name => {
|
||||
let value = SettingsGet(name);
|
||||
name = name[0].toLowerCase() + name.slice(1);
|
||||
self[name](value);
|
||||
});
|
||||
|
||||
self.layout(pInt(SettingsGet('Layout')));
|
||||
self.messagesPerPage(pInt(SettingsGet('MessagesPerPage')));
|
||||
self.checkMailInterval(pInt(SettingsGet('CheckMailInterval')));
|
||||
self.messageReadDelay(pInt(SettingsGet('MessageReadDelay')));
|
||||
self.autoLogout(pInt(SettingsGet('AutoLogout')));
|
||||
self.msgDefaultAction(SettingsGet('MsgDefaultAction'));
|
||||
|
||||
self.viewHTML(SettingsGet('ViewHTML'));
|
||||
self.viewImages(SettingsGet('ViewImages'));
|
||||
self.viewImagesWhitelist(SettingsGet('ViewImagesWhitelist'));
|
||||
self.removeColors(SettingsGet('RemoveColors'));
|
||||
self.allowStyles(SettingsGet('AllowStyles'));
|
||||
self.collapseBlockquotes(SettingsGet('CollapseBlockquotes'));
|
||||
self.maxBlockquotesLevel(SettingsGet('MaxBlockquotesLevel'));
|
||||
self.listInlineAttachments(SettingsGet('ListInlineAttachments'));
|
||||
self.simpleAttachmentsList(SettingsGet('simpleAttachmentsList'));
|
||||
self.useCheckboxesInList(SettingsGet('UseCheckboxesInList'));
|
||||
self.listGrouped(SettingsGet('listGrouped'));
|
||||
self.showNextMessage(SettingsGet('showNextMessage'));
|
||||
self.allowDraftAutosave(SettingsGet('AllowDraftAutosave'));
|
||||
self.useThreads(SettingsGet('UseThreads'));
|
||||
self.replySameFolder(SettingsGet('ReplySameFolder'));
|
||||
|
||||
self.hideUnsubscribed(SettingsGet('HideUnsubscribed'));
|
||||
self.hideDeleted(SettingsGet('HideDeleted'));
|
||||
self.showUnreadCount(SettingsGet('ShowUnreadCount'));
|
||||
self.unhideKolabFolders(SettingsGet('UnhideKolabFolders'));
|
||||
|
||||
self.requestReadReceipt(SettingsGet('requestReadReceipt'));
|
||||
self.requestDsn(SettingsGet('requestDsn'));
|
||||
self.requireTLS(SettingsGet('requireTLS'));
|
||||
self.pgpSign(SettingsGet('pgpSign'));
|
||||
self.pgpEncrypt(SettingsGet('pgpEncrypt'));
|
||||
self.allowSpellcheck(SettingsGet('allowSpellcheck'));
|
||||
self.keyPassForget(pInt(SettingsGet('keyPassForget')));
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ body#rl-app {
|
|||
#rl-loading, #rl-loading-error, #rl-BadBrowser, #rl-NoCookie {
|
||||
color: var(--loading-color, #000);
|
||||
text-shadow: var(--loading-text-shadow);
|
||||
font-size: 30px;
|
||||
font-size: 214%;
|
||||
line-height: 130%;
|
||||
position: fixed;
|
||||
text-align: center;
|
||||
|
|
|
|||
|
|
@ -7,16 +7,12 @@
|
|||
max-width: 810px;
|
||||
|
||||
.domain-desc {
|
||||
color: #666;
|
||||
line-height: 20px;
|
||||
background-color: #f9f9f9;
|
||||
padding: 8px;
|
||||
border: 1px solid #eee;
|
||||
border: 1px solid;
|
||||
border-radius: var(--input-border-radius, 3px);
|
||||
margin: -5px 0;
|
||||
i {
|
||||
font-style: normal;
|
||||
color: red;
|
||||
strong {
|
||||
color: var(--error-clr, #b94a48);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
color: var(--settings-menu-color, #333);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-size: 128%;
|
||||
line-height: 30px;
|
||||
padding: 4px 10px;
|
||||
text-decoration: none;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
font-weight: bold;
|
||||
}
|
||||
.package-desc {
|
||||
font-size: 12px;
|
||||
font-size: 85%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
#V-PopupsAsk {
|
||||
|
||||
.modal-body {
|
||||
font-size: 18px;
|
||||
font-size: 128%;
|
||||
padding: 3em 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
.descWrapper {
|
||||
color: var(--loading-color, #000);
|
||||
font-size: 30px;
|
||||
font-size: 214%;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
text-shadow: var(--loading-text-shadow);
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
}
|
||||
|
||||
input {
|
||||
font-size: 18px;
|
||||
font-size: 128%;
|
||||
height: 40px;
|
||||
padding-right: 30px;
|
||||
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
|
||||
.controls > .fontastic:first-child {
|
||||
position: absolute;
|
||||
font-size: 17px;
|
||||
font-size: 121%;
|
||||
line-height: 38px;
|
||||
left: 6px;
|
||||
}
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
.btn {
|
||||
|
||||
text-transform: uppercase;
|
||||
font-size: 13px;
|
||||
font-size: 90%;
|
||||
|
||||
&:hover, &:active {
|
||||
border-color: #fff;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
.attachmentList {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.attachmentItem {
|
||||
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
|
|
@ -9,8 +13,7 @@
|
|||
line-height: 20px;
|
||||
margin: 5px;
|
||||
min-height: 48px;
|
||||
max-width: 200px;
|
||||
min-width: 60px;
|
||||
width: 16em;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
|
|
@ -52,7 +55,7 @@
|
|||
}
|
||||
|
||||
.iconBG {
|
||||
font-size: 18px;
|
||||
font-size: 128%;
|
||||
font-weight: bold;
|
||||
line-height: 55px;
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
|
|
@ -78,13 +81,13 @@
|
|||
}
|
||||
|
||||
.attachmentSize {
|
||||
font-size: 12px;
|
||||
font-size: 85%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.iconMain, .iconPreview {
|
||||
padding: 6px 0 0;
|
||||
font-size: 36px;
|
||||
font-size: 257%;
|
||||
color: var(--main-color);
|
||||
filter: invert(33%);
|
||||
mix-blend-mode: difference;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
#V-PopupsCompose {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-width: 1000px;
|
||||
max-width: min(99vw, 1600px);
|
||||
resize: horizontal;
|
||||
width: 99%;
|
||||
|
||||
.modal-body {
|
||||
|
|
@ -52,7 +53,7 @@
|
|||
.no-attachments-desc {
|
||||
padding-top: 50px;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-size: 171%;
|
||||
opacity: 0.5;
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
|
@ -65,7 +66,7 @@
|
|||
.close, .minimize-custom {
|
||||
border-color: #eee;
|
||||
float: none;
|
||||
font-size: 24px;
|
||||
font-size: 171%;
|
||||
opacity: 1;
|
||||
line-height: 28px;
|
||||
margin-left: .7em;
|
||||
|
|
@ -145,7 +146,7 @@
|
|||
line-height: 119px;
|
||||
text-align: center;
|
||||
background-color: var(--dialog-bg-clr, #fff);
|
||||
font-size: 24px;
|
||||
font-size: 171%;
|
||||
|
||||
border-radius: 10px;
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
color: #999;
|
||||
text-align: center;
|
||||
padding: 60px 10px;
|
||||
font-size: 24px;
|
||||
font-size: 171%;
|
||||
line-height: 30px;
|
||||
}
|
||||
}
|
||||
|
|
@ -96,7 +96,7 @@
|
|||
}
|
||||
|
||||
.nameParent {
|
||||
font-size: 16px;
|
||||
font-size: 114%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
@ -145,18 +145,18 @@
|
|||
margin: 0;
|
||||
overflow: auto;
|
||||
|
||||
span {
|
||||
:not(.e-checkbox) > span {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
display: none;
|
||||
padding: 5px 7px;
|
||||
font-size: 18px;
|
||||
font-size: 128%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b-contact-view-desc {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-size: 171%;
|
||||
line-height: 30px;
|
||||
padding-top: 120px;
|
||||
color: #999;
|
||||
|
|
@ -166,7 +166,7 @@
|
|||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
input:not([type='checkbox']), textarea {
|
||||
width: 70vw;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
}
|
||||
|
||||
.emailaddresses li a {
|
||||
font-size: 12px;
|
||||
font-size: 85%;
|
||||
color: #999;
|
||||
padding: 1px;
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,15 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
/* https://github.com/the-djmaze/snappymail/issues/1427 */
|
||||
#V-MailFolderList .e-checkbox {
|
||||
margin: 0 4px;
|
||||
width: calc(100% - 8px);
|
||||
}
|
||||
#V-MailFolderList .unreadOnly li a:not(.unread-sub):not([data-unread]) {
|
||||
display:none;
|
||||
}
|
||||
|
||||
.b-folders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -56,6 +65,7 @@
|
|||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
min-width: 100px;
|
||||
color: var(--folders-color, #333);
|
||||
}
|
||||
|
||||
hr {
|
||||
|
|
@ -68,22 +78,18 @@
|
|||
white-space: nowrap;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 34px;
|
||||
background-color: transparent;
|
||||
vertical-align: middle;
|
||||
color: var(--folders-disabled-color, #666);
|
||||
border-left: 3px solid transparent;
|
||||
|
||||
color: var(--folders-color, #333);
|
||||
display: block;
|
||||
line-height: 34px;
|
||||
padding: 0 2em 0 @folderItemPadding;
|
||||
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
z-index: 1;
|
||||
|
||||
&.selectable {
|
||||
|
||||
color: var(--folders-color, #333);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
|
|
@ -106,6 +112,7 @@
|
|||
}
|
||||
}
|
||||
&:not(.selectable) {
|
||||
color: var(--folders-disabled-color, #666);
|
||||
cursor: default;
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
@ -142,7 +149,7 @@
|
|||
background-color: var(--unread-count-bg-color, #999);
|
||||
border-radius: 1em;
|
||||
color: var(--unread-count-color, #fff);
|
||||
font-size: 11px;
|
||||
font-size: 80%;
|
||||
line-height: 1.5em;
|
||||
margin-top: 7px;
|
||||
min-width: 1.7em;
|
||||
|
|
@ -185,33 +192,13 @@
|
|||
li li li li li a {
|
||||
text-indent: 4em;
|
||||
}
|
||||
|
||||
/**/
|
||||
&.single-root-inbox .b-folders-user > li > a {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&.single-root-inbox {
|
||||
li li a {
|
||||
text-indent: 0;
|
||||
}
|
||||
li li li a {
|
||||
text-indent: 1em;
|
||||
}
|
||||
li li li li a {
|
||||
text-indent: 2em;
|
||||
}
|
||||
li li li li li a {
|
||||
text-indent: 3em;
|
||||
}
|
||||
}
|
||||
/**/
|
||||
}
|
||||
|
||||
#rl-left .buttonCompose {
|
||||
max-height: 2em;
|
||||
max-width: fit-content;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: calc(100% - 48px);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@
|
|||
|
||||
max-width: 770px;
|
||||
|
||||
.tab-content {
|
||||
grid-column-end: 5;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.e-signature-place {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: var(--input-border-radius, 3px);
|
||||
margin-bottom: 1.43em;
|
||||
}
|
||||
|
||||
.textEmail {
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ html.rl-left-panel-disabled {
|
|||
a {
|
||||
opacity: 0.5;
|
||||
text-decoration: none;
|
||||
font-size: 22px;
|
||||
font-size: 157%;
|
||||
padding: 0 3px;
|
||||
cursor: pointer;
|
||||
|
||||
|
|
@ -147,7 +147,7 @@ html.rl-left-panel-disabled {
|
|||
|
||||
&.current {
|
||||
opacity: 1;
|
||||
font-size: 25px;
|
||||
font-size: 179%;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +173,7 @@ html.rl-left-panel-disabled {
|
|||
top: 0;
|
||||
bottom: 0;
|
||||
width: 50vw;
|
||||
z-index: 3;
|
||||
z-index: 99;
|
||||
}
|
||||
.rl-left-panel-disabled #rl-left {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
.groupLabel {
|
||||
background:rgba(128,128,128,0.2);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-size: 85%;
|
||||
line-height: 2;
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
display: inline-block;
|
||||
margin-top: 5px;
|
||||
margin-left: 5px;
|
||||
font-size: 18px;
|
||||
font-size: 128%;
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
}
|
||||
|
||||
.btn.buttonMoreSearch {
|
||||
font-size: 11px;
|
||||
font-size: 80%;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
|
@ -123,7 +123,7 @@
|
|||
color: #999;
|
||||
text-align: center;
|
||||
padding: 60px 10px;
|
||||
font-size: 24px;
|
||||
font-size: 171%;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
|
|
@ -147,13 +147,13 @@
|
|||
}
|
||||
|
||||
.listSearchDesc {
|
||||
font-size: 16px;
|
||||
font-size: 114%;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.listThreadUidDesc {
|
||||
font-size: 16px;
|
||||
font-size: 114%;
|
||||
padding: 7px 20px 6px 20px;
|
||||
background-color: rgba(128,128,128,0.5);
|
||||
border-bottom: 1px solid #888;
|
||||
|
|
@ -185,7 +185,7 @@
|
|||
border-bottom: 1px solid rgba(153, 153, 153, 0.2);
|
||||
border-left: 6px solid transparent;
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
font-size: 90%;
|
||||
line-height: 2;
|
||||
position: relative;
|
||||
/*
|
||||
|
|
@ -207,12 +207,8 @@
|
|||
font-weight: bolder;
|
||||
}
|
||||
|
||||
&.deleted {
|
||||
opacity: .3;
|
||||
}
|
||||
|
||||
.messageCheckbox {
|
||||
font-size: 16px;
|
||||
font-size: 114%;
|
||||
padding: 5px .5em 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
@ -223,7 +219,7 @@
|
|||
time, .sizeParent {
|
||||
margin: 0 5px 0 0;
|
||||
opacity: 0.7;
|
||||
font-size: 11px;
|
||||
font-size: 80%;
|
||||
white-space: nowrap;
|
||||
min-width: 3.5em;
|
||||
text-align: var(--right, right);
|
||||
|
|
@ -272,7 +268,7 @@
|
|||
.threads-len {
|
||||
border-radius: var(--border-radius, 5px);
|
||||
border: 1px solid #ccc;
|
||||
font-size: 11px;
|
||||
font-size: 80%;
|
||||
padding: 0 4px;
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
|
|
@ -327,6 +323,7 @@
|
|||
.attachmentParent,
|
||||
.flagParent {
|
||||
margin: 0 10px 0 5px;
|
||||
min-width: 1em;
|
||||
}
|
||||
.flagParent::after {
|
||||
content: '☆'; /*⚐*/
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue