Merge branch 'upstream/master' into dev/sync-nextcloud-addressbook

This commit is contained in:
Fahim Salam Chowdhury 2024-08-23 16:27:28 +06:00
commit 957654c746
1528 changed files with 85567 additions and 32765 deletions

View file

@ -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);

View file

@ -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
]);
});
};

View file

@ -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();
});

View file

@ -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'));

View file

@ -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

View file

@ -71,7 +71,9 @@ MessageSetAction = {
SetSeen: 0,
UnsetSeen: 1,
SetFlag: 2,
UnsetFlag: 3
UnsetFlag: 3,
SetDeleted: 4,
UnsetDeleted: 5
},
/**

View file

@ -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];
}

View file

@ -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);

View file

@ -15,6 +15,7 @@ export const
Settings = rl.settings,
SettingsGet = Settings.get,
SettingsAdmin = name => (SettingsGet('Admin') || {})[name],
SettingsCapa = name => name && !!(SettingsGet('Capa') || {})[name],
dropdowns = [],

View file

@ -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
View 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(''));
}
}

View file

@ -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/'

View file

@ -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

View file

@ -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();

View file

@ -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) =>

View file

@ -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));

View file

@ -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;
}

View file

@ -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());

View file

@ -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

View file

@ -55,7 +55,7 @@ export class VCardProperty {
}
// invalid property
else {
throw new Error('invalid Property constructor');
throw Error('invalid Property constructor');
}
}

View file

@ -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();

View file

@ -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
View file

@ -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;

View file

@ -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>

View file

@ -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) {

View file

@ -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 => {

View file

@ -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
View 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
View 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});
}
};

View file

@ -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;

View file

@ -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?
}

View file

@ -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();

View file

@ -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
*/

View file

@ -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,

View file

@ -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;
}
/**

View file

@ -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
}
}

View file

@ -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 = ''

View file

@ -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);
}
});
}
*/
}

View file

@ -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);

View file

@ -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})` : '');
}
}

View file

@ -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)) {

View file

@ -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
View 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();
}
}

View 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-----';
}
}

View 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);
}
}

View file

@ -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) : ''
));
}

View file

@ -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();

View file

@ -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];
}
};

View file

@ -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)
);
}

View file

@ -78,6 +78,10 @@ export class AdminSettingsAbout /*extends AbstractViewSettings*/ {
});
}
clearCache() {
Remote.request('AdminClearCache');
}
updateCoreData() {
if (!this.coreUpdating()) {
this.coreUpdating(true);

View file

@ -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);

View file

@ -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()
}
);
}

View file

@ -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')
]);
}
}

View file

@ -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)
);
}

View file

@ -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);

View file

@ -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() : ""))
});
}

View file

@ -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) {

View file

@ -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);
}
}

View file

@ -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.

View file

@ -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'));
}
});
}

View file

@ -18,9 +18,9 @@ import {
*/
export class ConditionalCommand extends ControlCommand
{
constructor()
constructor(identifier)
{
super();
super(identifier);
this.test = null;
}

View file

@ -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

View file

@ -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()

View file

@ -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];
}
}
}

View file

@ -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)

View file

@ -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) {

View file

@ -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;

View file

@ -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();

View file

@ -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);
}

View file

@ -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') });
}

View file

@ -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);

View file

@ -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))

View file

@ -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);
};

View file

@ -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;

View file

@ -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'));
}
}

View file

@ -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
});

View file

@ -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);

View file

@ -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*/);

View file

@ -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');
}
};

View file

@ -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;
});

View 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
});
*/
};

View file

@ -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);
});
}

View file

@ -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);

View file

@ -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
View 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)
));
});
};

View file

@ -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')));
}
};

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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;

View file

@ -26,7 +26,7 @@
font-weight: bold;
}
.package-desc {
font-size: 12px;
font-size: 85%;
opacity: 0.7;
}

View file

@ -1,7 +1,7 @@
#V-PopupsAsk {
.modal-body {
font-size: 18px;
font-size: 128%;
padding: 3em 15px;
text-align: center;
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

@ -48,7 +48,7 @@
}
.emailaddresses li a {
font-size: 12px;
font-size: 85%;
color: #999;
padding: 1px;
position: absolute;

View file

@ -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);
}

View file

@ -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 {

View file

@ -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;

View file

@ -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