Merge branch 'the-djmaze:master' into nc-theme

This commit is contained in:
Rene Hampölz 2023-07-27 11:22:16 +02:00 committed by GitHub
commit 47ed699525
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
881 changed files with 48156 additions and 36724 deletions

View file

@ -53,26 +53,44 @@ pdo_password = ""
suggestions_limit = 30
[security]
; Enable CSRF protection (http://en.wikipedia.org/wiki/Cross-site_request_forgery)
csrf_protection = On
custom_server_signature = "SnappyMail"
x_xss_protection_header = "1; mode=block"
openpgp = Off
; Access settings
allow_admin_panel = On
; Login and password for web admin panel
admin_login = "admin"
admin_password = ""
admin_totp = ""
; Access settings
allow_admin_panel = On
hide_x_mailer_header = On
admin_panel_host = ""
admin_panel_key = "admin"
force_https = Off
hide_x_mailer_header = On
; For example to allow all images use "img-src https:". More info at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#directives
content_security_policy = ""
; Report CSP errors to PHP and/or SnappyMail Log
csp_report = Off
; A valid cipher method from https://php.net/openssl_get_cipher_methods
encrypt_cipher = "aes-256-cbc-hmac-sha1"
; Strict, Lax or None
cookie_samesite = "Strict"
; Additional allowed Sec-Fetch combinations separated by ";".
; For example:
; * Allow iframe on same domain in any mode: dest=iframe,site=same-origin
; * Allow navigate to iframe on same domain: mode=navigate,dest=iframe,site=same-origin
; * Allow navigate to iframe on (sub)domain: mode=navigate,dest=iframe,site=same-site
; * Allow navigate to iframe from any domain: mode=navigate,dest=iframe,site=cross-site
;
; Default is "site=same-origin;site=none"
secfetch_allow = ""
[admin_panel]
allow_update = Off
@ -93,20 +111,35 @@ cafile = ""
capath = ""
; Location of client certificate file (pem format with private key) on local filesystem
client_cert = ""
local_cert = ""
; This can help mitigate the CRIME attack vector.
disable_compression = On
[capa]
quota = On
; Allow clear folder and delete messages without moving to trash
dangerous_actions = On
message_actions = On
; Allow download attachments as Zip (and optionally others)
attachments_actions = On
[login]
; If someone logs in without "@domain.tld", this value will be used
; When this value is HTTP_HOST, the $_SERVER["HTTP_HOST"] value is used.
; When this value is SERVER_NAME, the $_SERVER["SERVER_NAME"] value is used.
; When this value is gethostname, the gethostname() value is used.
;
default_domain = ""
; Allow language selection on webmail login screen
allow_languages_on_login = On
; Detect language from browser header `Accept-Language`
determine_user_language = On
; Like default_domain but then HTTP_HOST/SERVER_NAME without www.
determine_user_domain = Off
login_lowercase = On
@ -123,7 +156,7 @@ sign_me_auto = "DefaultOff"
; Enable plugin support
enable = Off
; List of enabled plugins
; Comma-separated list of enabled plugins
enabled_list = ""
[defaults]
@ -145,6 +178,9 @@ mail_reply_same_folder = Off
; Enable logging
enable = Off
; Path where log files will be stored
path = ""
; Log messages of set RFC 5424 section 6.2.1 Severity level and higher (0 = highest, 7 = lowest).
; 0 = Emergency
; 1 = Alert
@ -201,6 +237,8 @@ auth_syslog = On
[debug]
; Special option required for development purposes
enable = Off
javascript = Off
css = Off
[cache]
; The section controls caching of the entire application.
@ -208,6 +246,9 @@ enable = Off
; Enables caching in the system
enable = On
; Path where cache files will be stored
path = ""
; Additional caching key. If changed, cache is purged
index = "v1"
@ -225,55 +266,41 @@ http_expires = 3600
; Caching message UIDs when searching and sorting (threading)
server_uids = On
system_data = On
[imap]
use_sort = On
use_force_selection = Off
use_thread = On
use_move = Off
use_expunge_all_on_delete = Off
body_text_limit = 555000
message_list_fast_simple_search = On
message_list_count_limit_trigger = 0
message_list_date_filter = 0
message_list_permanent_filter = ""
message_all_headers = Off
large_thread_limit = 50
folder_list_limit = 200
show_login_alert = On
use_list_status = On
timeout = 300
disable_metadata = Off
fetch_new_messages = On
[labs]
cache_system_data = On
; Display message RFC 2822 date and time header, instead of the arrival internal date.
date_from_headers = On
autocreate_system_folders = Off
allow_message_append = Off
login_fault_delay = 1
; When login fails, wait N seconds before responding
login_fault_delay = 5
log_ajax_response_write_limit = 300
allow_html_editor_biti_buttons = Off
allow_ctrl_enter_on_compose = On
try_to_detect_hidden_images = Off
use_app_debug_js = Off
use_app_debug_css = Off
smtp_show_server_errors = Off
smtp_timeout = 60
sieve_auth_plain_initial = On
sieve_allow_fileinto_inbox = Off
sieve_timeout = 10
sasl_allow_plain = On
sasl_allow_scram_sha = Off
sasl_allow_cram_md5 = Off
; PHP mail() remove To and Subject headers
mail_func_clear_headers = On
; PHP mail() set -f emailaddress
mail_func_additional_parameters = Off
folders_spec_limit = 50
curl_proxy = ""
curl_proxy_auth = ""
force_https = Off
custom_login_link = ""
custom_logout_link = ""
allow_external_login = Off
http_client_ip_check_proxy = Off
fast_cache_memcache_host = "127.0.0.1"
fast_cache_memcache_port = 11211
@ -283,12 +310,11 @@ use_local_proxy_for_external_images = On
image_exif_auto_rotate = Off
cookie_default_path = ""
cookie_default_secure = Off
check_new_messages = On
replace_env_in_configuration = ""
boundary_prefix = ""
dev_email = ""
dev_password = ""
[version]
current = "2.20.0"
saved = "Fri, 30 Sep 2022 12:34:56 +0000"
current = "2.28.4"
saved = "Sun, 18 Dec 2022 22:10:48 +0000"

View file

@ -28,6 +28,7 @@ If applicable, add screenshots to help explain your problem.
- IMAP daemon: [e.g. courier, dovecot]
- PHP version:
- SnappyMail Version:
- Mode: [e.g. standalone, nextcloud, cyberpanel, docker]
**[Debug/logging information](https://github.com/the-djmaze/snappymail/wiki/FAQ#how-do-i-enable-logging)**
Place them here (few lines) or as attachments (many lines)

1
.gitignore vendored
View file

@ -17,6 +17,7 @@
/data
/MULTIPLY
/include.php
/issues
.idea/
.env
/test

View file

@ -26,7 +26,6 @@
# Header set Strict-Transport-Security "max-age=31536000"
Header set imagetoolbar "no"
# Header set X-Content-Type-Options "nosniff"
# Header set X-Frame-Options "DENY"
# Header set X-XSS-Protection "1; mode=block"
Header set Service-Worker-Allowed "/"

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,7 @@
**Editing HTML Template Files**
1. Edit data/\_data_/\_default_/configs/application.ini
2. Set 'cache_system_data' to Off
2. Set `[cache] system_data` to Off
**Release**

View file

@ -34,14 +34,14 @@ And don't forget to read the [RainLoop documentation](https://www.rainloop.net/d
**GNU AFFERO GENERAL PUBLIC LICENSE Version 3 (AGPL)**.
http://www.gnu.org/licenses/agpl-3.0.html
Copyright (c) 2020 - 2022 SnappyMail
Copyright (c) 2020 - 2023 SnappyMail
Copyright (c) 2013 - 2022 RainLoop
## Modifications
This fork of RainLoop has the following changes:
* Privacy/GDPR friendly (no: Social, Gravatar, Facebook, Google, Twitter, DropBox, OwnCloud, X-Mailer)
* Privacy/GDPR friendly (no: Social, Gravatar, Facebook, Google, Twitter, DropBox, X-Mailer)
* Admin uses password_hash/password_verify
* Auth failed attempts written to syslog
* Added Fail2ban instructions
@ -57,10 +57,11 @@ This fork of RainLoop has the following changes:
* Removed background video support
* Removed Sentry (Application Monitoring and Error Tracking Software)
* Removed Spyc yaml
* Removed OwnCloud
* Replaced gulp-uglify with gulp-terser
* CRLF => LF line endings
* Embed boot.js and boot.css into index.html
* Ongoing removal of old JavaScript code (things are native these days)
* Removal of old JavaScript code (things are native these days)
* Added modified [Squire](https://github.com/neilj/Squire) HTML editor as replacement for CKEditor
* Updated [Sabre/VObject](https://github.com/sabre-io/vobject)
* Split Admin specific JavaScript code from User code
@ -105,7 +106,6 @@ Supported are:
The result is faster and smaller download code (good for mobile networks).
* Added dev/prototype.js for some additional features
* boot.js without webpack overhead
* Modified Jua.js to be without jQuery
* Replaced Autolinker with simple https/email detection
* Replaced momentToNode with proper HTML5 `<time>`
@ -118,6 +118,7 @@ The result is faster and smaller download code (good for mobile networks).
* Replaced simplestatemanager with CSS @media
* Replaced inputosaurus with own code
* Replaced keymaster with own shortcuts handler
* Replaced OpenPGP.js v2 with OpenPGP.js v5
* Removed ifvisible.js
* Removed pikaday
* Removed underscore
@ -140,28 +141,28 @@ RainLoop 1.17 vs SnappyMail
|js/* |RainLoop |Snappy |
|--------------- |--------: |--------: |
|admin.js |2.170.153 | 81.545 |
|app.js |4.207.787 | 406.032 |
|boot.js | 868.735 | 2.050 |
|libs.js | 658.812 | 192.965 |
|sieve.js | 0 | 86.121 |
|admin.js |2.170.153 | 80.102 |
|app.js |4.207.787 | 407.874 |
|boot.js | 868.735 | 4.142 |
|libs.js | 658.812 | 187.076 |
|sieve.js | 0 | 85.141 |
|polyfills.js | 334.608 | 0 |
|serviceworker.js | 0 | 285 |
|TOTAL |8.240.095 | 768.998 |
|TOTAL |8.240.095 | 764.620 |
|js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|--------------- |--------: |--------: |------: |------: |--------: |--------: |
|admin.min.js | 256.831 | 40.255 | 73.606 | 13.435 | 60.877 | 12.047 |
|app.min.js | 515.367 | 188.046 |139.456 | 62.537 |110.485 | 53.693 |
|boot.min.js | 84.659 | 1.252 | 26.998 | 778 | 23.643 | 628 |
|libs.min.js | 584.772 | 92.055 |180.901 | 34.113 |155.182 | 30.641 |
|sieve.min.js | 0 | 41.926 | 0 | 10.484 | 0 | 9.451 |
|admin.min.js | 256.831 | 39.350 | 73.606 | 13.163 | 60.877 | 11.805 |
|app.min.js | 515.367 | 186.311 |139.456 | 62.929 |110.485 | 54.076 |
|boot.min.js | 84.659 | 2.084 | 26.998 | 1.202 | 23.643 | 1.003 |
|libs.min.js | 584.772 | 90.808 |180.901 | 33.754 |155.182 | 30.224 |
|sieve.min.js | 0 | 41.399 | 0 | 10.394 | 0 | 9.356 |
|polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 |
|TOTAL user |1.217.635 | 281.353 |358.761 | 97.428 |299.485 | 84.962 |
|TOTAL user+sieve |1.217.635 | 323.279 |358.761 |107.912 |299.485 | 94.413 |
|TOTAL admin | 959.099 | 133.562 |292.911 | 48.326 |249.877 | 43.316 |
|TOTAL user |1.217.635 | 279.203 |358.761 | 97.885 |299.485 | 85.303 |
|TOTAL user+sieve |1.217.635 | 320.602 |358.761 |108.279 |299.485 | 94.659 |
|TOTAL admin | 959.099 | 132.242 |292.911 | 48.119 |249.877 | 43.032 |
For a user its around 70% smaller and faster than traditional RainLoop.
For a user it is around 72% smaller and faster than traditional RainLoop.
### CSS changes
@ -169,6 +170,7 @@ For a user its around 70% smaller and faster than traditional RainLoop.
* Themes work in mobile mode
* Bugfix invalid/conflicting css rules
* Use flexbox
* Use border-box
* Split app.css to have separate admin.css
* Remove oldschool 'float'
* Remove unused css
@ -187,12 +189,12 @@ For a user its around 70% smaller and faster than traditional RainLoop.
|css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli |
|------------ |-------: |------: |------: |------: |--------: |
|app.css | 340.331 | 82.870 | 46.946 | 17.215 | 14.727 |
|app.min.css | 274.947 | 66.646 | 39.647 | 15.260 | 13.322 |
|app.css | 340.331 | 84.390 | 46.946 | 17.605 | 15.084 |
|app.min.css | 274.947 | 67.774 | 39.647 | 15.487 | 13.527 |
|boot.css | | 1.326 | | 664 | 545 |
|boot.min.css | | 1.071 | | 590 | 474 |
|admin.css | | 30.506 | | 6.907 | 5.998 |
|admin.min.css | | 24.677 | | 6.267 | 5.498 |
|admin.css | | 30.482 | | 6.988 | 6.092 |
|admin.min.css | | 24.607 | | 6.315 | 5.579 |
### PGP
RainLoop uses the old OpenPGP.js v2
@ -206,7 +208,7 @@ See https://github.com/the-djmaze/openpgpjs for development
|OpenPGP |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|--------------- |--------: |--------: |------: |-------: |--------: |--------: |
|openpgp.min.js | 330.742 | 539.257 |102.388 | 166.998 | 84.241 | 137.186 |
|openpgp.min.js | 330.742 | 541.176 |102.388 | 168.266 | 84.241 | 138.278 |
|openpgp.worker | 1.499 | | 824 | | 695 | |
@ -216,7 +218,6 @@ The [Squire](https://github.com/neilj/Squire) implementation is not 100% compati
Still TODO:
* support for tables (really needed?!?)
* support BIDI (really needed?!?)
| | normal | min | gzip | min gzip |
|-------- |-------: |-------: |------: |--------: |

View file

@ -4,16 +4,6 @@
//header('Strict-Transport-Security: max-age=31536000');
/**
* Uncomment to use gzip compressed output
*/
//define('USE_GZIP', 1);
/**
* Uncomment to use brotli compressed output
*/
//define('USE_BROTLI', 1);
/**
* Uncomment to enable multiple domain installation.
*/
@ -34,3 +24,8 @@
* Additional configuration file name
*/
//define('APP_CONFIGURATION_NAME', $_SERVER['HTTP_HOST'].'.ini');
/**
* Also update extensions on upgrade
*/
//define('SNAPPYMAIL_UPDATE_PLUGINS', 1);

View file

@ -1,50 +0,0 @@
<IfModule mod_headers.c>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP:Accept-encoding} br
RewriteCond "%{REQUEST_FILENAME}\.br" -s
RewriteRule "^(.+\.js)$" "$1\.br" [L,T=application/javascript,QSA]
RewriteCond %{HTTP:Accept-encoding} gzip
RewriteCond "%{REQUEST_FILENAME}\.gz" -s
RewriteRule "^(.+\.js)$" "$1\.gz" [L,T=application/javascript,QSA]
RewriteCond %{HTTP:Accept-encoding} br
RewriteCond "%{REQUEST_FILENAME}\.br" -s
RewriteRule "^(.+\.css)$" "$1\.br" [L,T=text/css,QSA]
RewriteCond %{HTTP:Accept-encoding} gzip
RewriteCond "%{REQUEST_FILENAME}\.gz" -s
RewriteRule "^(.+\.css)$" "$1\.gz" [L,T=text/css,QSA]
<FilesMatch "\.(js|css)\.br$">
SetEnv no-gzip 1
SetEnv no-brotli 1
# Serve correct encoding type.
Header append Content-Encoding br
# Force proxies to cache brotli &
# non-brotli css/js files separately.
Header append Vary Accept-Encoding
</FilesMatch>
<FilesMatch "\.(js|css)\.gz$">
SetEnv no-gzip 1
SetEnv no-brotli 1
# Serve correct encoding type.
Header append Content-Encoding gzip
# Force proxies to cache gzipped &
# non-gzipped css/js files separately.
Header append Vary Accept-Encoding
</FilesMatch>
<FilesMatch "\.js\.(br|gz)$">
Header set Content-Type "application/javascript; charset=utf-8"
ForceType application/javascript
</FilesMatch>
<FilesMatch "\.css\.(br|gz)$">
Header set Content-Type "text/css; charset=utf-8"
ForceType text/css
</FilesMatch>
</IfModule>
</IfModule>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

38
build/cpanel.php Executable file
View file

@ -0,0 +1,38 @@
<?php
echo "\x1b[33;1m === cPanel === \x1b[0m\n";
$cpanel_destination = "{$destPath}snappymail-{$package->version}-cpanel.tar";
@unlink($cpanel_destination);
@unlink("{$cpanel_destination}.gz");
$cpanel_tar = new PharData($cpanel_destination);
$cpanel_tar->buildFromDirectory('./integrations/cpanel', "@integrations/cpanel/@");
$cpanel_path = '/usr/local/cpanel/base/3rdparty/snappymail/';
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('snappymail/v'), RecursiveIteratorIterator::SELF_FIRST);
foreach ($files as $file) {
if (is_file($file)) {
$newFile = str_replace('\\', '/', $file);
$cpanel_tar->addFile($file, "{$cpanel_path}{$newFile}");
}
}
$cpanel_tar->addFile('.htaccess', "{$cpanel_path}.htaccess");
$cpanel_tar->addFile('README.md', "{$cpanel_path}README.md");
$cpanel_tar->addFromString("{$cpanel_path}VERSION", $package->version);
$index = file_get_contents('index.php');
$index = str_replace('0.0.0', $package->version, $index);
$cpanel_tar->addFromString("{$cpanel_path}index.php", $index);
$data = file_get_contents('dev/serviceworker.js');
$cpanel_tar->addFromString("{$cpanel_path}serviceworker.js", $data);
$cpanel_tar->compress(Phar::GZ);
unlink($cpanel_destination);
$cpanel_destination .= '.gz';
echo "{$cpanel_destination} created\n";

View file

@ -1,46 +1,92 @@
<?php
echo "\x1b[33;1m === Nextcloud === \x1b[0m\n";
$cert_dir = $_SERVER['HOME'].'/.nextcloud/certificates';
$nc_destination = "{$destPath}snappymail-{$package->version}-nextcloud.tar";
@unlink($nc_destination);
@unlink("{$nc_destination}.gz");
$nc_tar = new PharData($nc_destination);
$hashes = [];
file_put_contents(ROOT_DIR . '/integrations/nextcloud/snappymail/VERSION', $package->version);
$file = ROOT_DIR . '/integrations/nextcloud/snappymail/appinfo/info.xml';
file_put_contents($file, preg_replace('/<version>[^<]*</', "<version>{$package->version}<", file_get_contents($file)));
$nc_tar->buildFromDirectory('./integrations/nextcloud', "@integrations/nextcloud/snappymail/@");
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('integrations/nextcloud/snappymail'));
foreach ($files as $file) {
if (is_file($file)) {
$name = str_replace('\\', '/', $file);
$name = str_replace('integrations/nextcloud/snappymail/', '', $name);
$hashes[$name] = hash_file('sha512', $file);
}
}
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('snappymail/v'), RecursiveIteratorIterator::SELF_FIRST);
foreach ($files as $file) {
if (is_file($file)) {
$newFile = str_replace('\\', '/', $file);
$newFile = str_replace("'snappymail/v/'.", '', $newFile);
$nc_tar->addFile($file, "snappymail/app/{$newFile}");
$newName = str_replace('/.htaccess', '/_htaccess', $newFile);
$nc_tar->addFile($file, "snappymail/app/{$newName}");
$hashes["app/{$newFile}"] = hash_file('sha512', $file);
}
}
/*
$nc_tar->addFile('data/.htaccess');
$nc_tar->addFromString('data/VERSION', $package->version);
$nc_tar->addFile('data/README.md');
$nc_tar->addFile('_include.php', 'snappymail/app/_include.php');
*/
$nc_tar->addFile('.htaccess', 'snappymail/app/.htaccess');
$nc_tar->addFile('.htaccess', 'snappymail/app/_htaccess');
$hashes['app/.htaccess'] = hash_file('sha512', '.htaccess');
$index = file_get_contents('index.php');
$index = str_replace('0.0.0', $package->version, $index);
$index = str_replace('snappymail/v/', '', $index);
//$index = str_replace('snappymail/v/', '', $index);
$nc_tar->addFromString('snappymail/app/index.php', $index);
$hashes['app/index.php'] = hash('sha512', $index);
$nc_tar->addFile('README.md', 'snappymail/app/README.md');
$hashes['app/README.md'] = hash_file('sha512', 'README.md');
$nc_tar->addFile('CHANGELOG.md', 'snappymail/CHANGELOG.md');
$hashes['CHANGELOG.md'] = hash_file('sha512', 'CHANGELOG.md');
$data = file_get_contents('dev/serviceworker.js');
$nc_tar->addFromString('snappymail/app/serviceworker.js', $data);
$hashes['app/serviceworker.js'] = hash('sha512', $data);
spl_autoload_register(function($name){
$file = __DIR__ . '/' . str_replace('\\', '/', $name) . '.php';
require $file;
});
ksort($hashes);
$cert = file_get_contents($cert_dir.'/snappymail.crt');
$rsa = new \phpseclib\Crypt\RSA();
$rsa->loadKey(file_get_contents($cert_dir.'/snappymail.key'));
$x509 = new \phpseclib\File\X509();
$x509->loadX509($cert);
$x509->setPrivateKey($rsa);
$rsa->setSignatureMode(\phpseclib\Crypt\RSA::SIGNATURE_PSS);
$rsa->setMGFHash('sha512');
$rsa->setSaltLength(0);
$signature = $rsa->sign(json_encode($hashes));
$nc_tar->addFromString('snappymail/appinfo/signature.json', json_encode([
'hashes' => $hashes,
'signature' => base64_encode($signature),
'certificate' => $cert
], JSON_PRETTY_PRINT));
$nc_tar->compress(Phar::GZ);
unlink($nc_destination);
$nc_destination .= '.gz';
$signature = shell_exec("openssl dgst -sha512 -sign ~/.nextcloud/certificates/snappymail.key {$nc_destination} | openssl base64");
$signature = shell_exec("openssl dgst -sha512 -sign {$cert_dir}/snappymail.key {$nc_destination} | openssl base64");
file_put_contents($nc_destination.'.sig', $signature);
echo "{$nc_destination} created\n";

100
build/owncloud.php Executable file
View file

@ -0,0 +1,100 @@
<?php
echo "\x1b[33;1m === ownCloud === \x1b[0m\n";
$cert_dir = false; // $_SERVER['HOME'].'/.owncloud/certificates';
$nc_destination = "{$destPath}snappymail-{$package->version}-owncloud.tar";
@unlink($nc_destination);
@unlink("{$nc_destination}.gz");
$nc_tar = new PharData($nc_destination);
$hashes = [];
file_put_contents(ROOT_DIR . '/integrations/owncloud/snappymail/VERSION', $package->version);
$file = ROOT_DIR . '/integrations/owncloud/snappymail/appinfo/info.xml';
file_put_contents($file, preg_replace('/<version>[^<]*</', "<version>{$package->version}<", file_get_contents($file)));
$nc_tar->buildFromDirectory('./integrations/owncloud', "@integrations/owncloud/snappymail/@");
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('integrations/owncloud/snappymail'));
foreach ($files as $file) {
if (is_file($file)) {
$name = str_replace('\\', '/', $file);
$name = str_replace('integrations/owncloud/snappymail/', '', $name);
$hashes[$name] = hash_file('sha512', $file);
}
}
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('snappymail/v'), RecursiveIteratorIterator::SELF_FIRST);
foreach ($files as $file) {
if (is_file($file)) {
$newFile = str_replace('\\', '/', $file);
// $newFile = str_replace("'snappymail/v/'.", '', $newFile);
$nc_tar->addFile($file, "snappymail/app/{$newFile}");
$hashes["app/{$newFile}"] = hash_file('sha512', $file);
}
}
$nc_tar->delete("snappymail/app/snappymail/v/{$package->version}/app/.htaccess");
$nc_tar->addFile("snappymail/v/{$package->version}/app/.htaccess", "snappymail/app/snappymail/v/{$package->version}/app/_htaccess");
$nc_tar->delete("snappymail/app/snappymail/v/{$package->version}/static/.htaccess");
$nc_tar->addFile("snappymail/v/{$package->version}/static/.htaccess", "snappymail/app/snappymail/v/{$package->version}/static/_htaccess");
/*
$nc_tar->addFile('data/.htaccess');
$nc_tar->addFromString('data/VERSION', $package->version);
$nc_tar->addFile('data/README.md');
$nc_tar->addFile('_include.php', 'snappymail/app/_include.php');
*/
$nc_tar->addFile('.htaccess', 'snappymail/app/_htaccess');
$hashes['app/.htaccess'] = hash_file('sha512', '.htaccess');
$index = file_get_contents('index.php');
$index = str_replace('0.0.0', $package->version, $index);
//$index = str_replace('snappymail/v/', '', $index);
$nc_tar->addFromString('snappymail/app/index.php', $index);
$hashes['app/index.php'] = hash('sha512', $index);
$nc_tar->addFile('README.md', 'snappymail/app/README.md');
$hashes['app/README.md'] = hash_file('sha512', 'README.md');
$nc_tar->addFile('CHANGELOG.md', 'snappymail/CHANGELOG.md');
$hashes['CHANGELOG.md'] = hash_file('sha512', 'CHANGELOG.md');
$data = file_get_contents('dev/serviceworker.js');
$nc_tar->addFromString('snappymail/app/serviceworker.js', $data);
$hashes['app/serviceworker.js'] = hash('sha512', $data);
if ($cert_dir) {
spl_autoload_register(function($name){
$file = __DIR__ . '/' . str_replace('\\', '/', $name) . '.php';
require $file;
});
ksort($hashes);
$cert = file_get_contents($cert_dir.'/snappymail.crt');
$rsa = new \phpseclib\Crypt\RSA();
$rsa->loadKey(file_get_contents($cert_dir.'/snappymail.key'));
$x509 = new \phpseclib\File\X509();
$x509->loadX509($cert);
$x509->setPrivateKey($rsa);
$rsa->setSignatureMode(\phpseclib\Crypt\RSA::SIGNATURE_PSS);
$rsa->setMGFHash('sha512');
$rsa->setSaltLength(0);
$signature = $rsa->sign(json_encode($hashes));
$nc_tar->addFromString('snappymail/appinfo/signature.json', json_encode([
'hashes' => $hashes,
'signature' => base64_encode($signature),
'certificate' => $cert
], JSON_PRETTY_PRINT));
}
$nc_tar->compress(Phar::GZ);
unlink($nc_destination);
$nc_destination .= '.gz';
if ($cert_dir) {
$signature = shell_exec("openssl dgst -sha512 -sign {$cert_dir}/snappymail.key {$nc_destination} | openssl base64");
file_put_contents($nc_destination.'.sig', $signature);
}
echo "{$nc_destination} created\n";

View file

@ -0,0 +1,893 @@
<?php
/**
* Pure-PHP implementations of keyed-hash message authentication codes (HMACs) and various cryptographic hashing functions.
*
* Uses hash() or mhash() if available and an internal implementation, otherwise. Currently supports the following:
*
* md2, md5, md5-96, sha1, sha1-96, sha256, sha256-96, sha384, and sha512, sha512-96
*
* If {@link self::setKey() setKey()} is called, {@link self::hash() hash()} will return the HMAC as opposed to
* the hash. If no valid algorithm is provided, sha1 will be used.
*
* PHP version 5
*
* {@internal The variable names are the same as those in
* {@link http://tools.ietf.org/html/rfc2104#section-2 RFC2104}.}}
*
* Here's a short example of how to use this library:
* <code>
* <?php
* include 'vendor/autoload.php';
*
* $hash = new \phpseclib\Crypt\Hash('sha1');
*
* $hash->setKey('abcdefg');
*
* echo base64_encode($hash->hash('abcdefg'));
* ?>
* </code>
*
* @category Crypt
* @package Hash
* @author Jim Wigginton <terrafrost@php.net>
* @copyright 2007 Jim Wigginton
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @link http://phpseclib.sourceforge.net
*/
namespace phpseclib\Crypt;
use phpseclib\Math\BigInteger;
/**
* Pure-PHP implementations of keyed-hash message authentication codes (HMACs) and various cryptographic hashing functions.
*
* @package Hash
* @author Jim Wigginton <terrafrost@php.net>
* @access public
*/
class Hash
{
/**#@+
* @access private
* @see \phpseclib\Crypt\Hash::__construct()
*/
/**
* Toggles the internal implementation
*/
const MODE_INTERNAL = 1;
/**
* Toggles the mhash() implementation, which has been deprecated on PHP 5.3.0+.
*/
const MODE_MHASH = 2;
/**
* Toggles the hash() implementation, which works on PHP 5.1.2+.
*/
const MODE_HASH = 3;
/**#@-*/
/**
* Hash Parameter
*
* @see self::setHash()
* @var int
* @access private
*/
var $hashParam;
/**
* Byte-length of compression blocks / key (Internal HMAC)
*
* @see self::setAlgorithm()
* @var int
* @access private
*/
var $b;
/**
* Byte-length of hash output (Internal HMAC)
*
* @see self::setHash()
* @var int
* @access private
*/
var $l = false;
/**
* Hash Algorithm
*
* @see self::setHash()
* @var string
* @access private
*/
var $hash;
/**
* Key
*
* @see self::setKey()
* @var string
* @access private
*/
var $key = false;
/**
* Computed Key
*
* @see self::_computeKey()
* @var string
* @access private
*/
var $computedKey = false;
/**
* Outer XOR (Internal HMAC)
*
* @see self::setKey()
* @var string
* @access private
*/
var $opad;
/**
* Inner XOR (Internal HMAC)
*
* @see self::setKey()
* @var string
* @access private
*/
var $ipad;
/**
* Engine
*
* @see self::setHash()
* @var string
* @access private
*/
var $engine;
/**
* Default Constructor.
*
* @param string $hash
* @return \phpseclib\Crypt\Hash
* @access public
*/
function __construct($hash = 'sha1')
{
if (!defined('CRYPT_HASH_MODE')) {
switch (true) {
case extension_loaded('hash'):
define('CRYPT_HASH_MODE', self::MODE_HASH);
break;
case extension_loaded('mhash'):
define('CRYPT_HASH_MODE', self::MODE_MHASH);
break;
default:
define('CRYPT_HASH_MODE', self::MODE_INTERNAL);
}
}
$this->setHash($hash);
}
/**
* Sets the key for HMACs
*
* Keys can be of any length.
*
* @access public
* @param string $key
*/
function setKey($key = false)
{
$this->key = $key;
$this->_computeKey();
}
/**
* Pre-compute the key used by the HMAC
*
* Quoting http://tools.ietf.org/html/rfc2104#section-2, "Applications that use keys longer than B bytes
* will first hash the key using H and then use the resultant L byte string as the actual key to HMAC."
*
* As documented in https://www.reddit.com/r/PHP/comments/9nct2l/symfonypolyfill_hash_pbkdf2_correct_fix_for/
* when doing an HMAC multiple times it's faster to compute the hash once instead of computing it during
* every call
*
* @access private
*/
function _computeKey()
{
if ($this->key === false) {
$this->computedKey = false;
return;
}
if (strlen($this->key) <= $this->b) {
$this->computedKey = $this->key;
return;
}
switch ($this->engine) {
case self::MODE_MHASH:
$this->computedKey = mhash($this->hash, $this->key);
break;
case self::MODE_HASH:
$this->computedKey = hash($this->hash, $this->key, true);
break;
case self::MODE_INTERNAL:
$this->computedKey = call_user_func($this->hash, $this->key);
}
}
/**
* Gets the hash function.
*
* As set by the constructor or by the setHash() method.
*
* @access public
* @return string
*/
function getHash()
{
return $this->hashParam;
}
/**
* Sets the hash function.
*
* @access public
* @param string $hash
*/
function setHash($hash)
{
$this->hashParam = $hash = strtolower($hash);
switch ($hash) {
case 'md5-96':
case 'sha1-96':
case 'sha256-96':
case 'sha512-96':
$hash = substr($hash, 0, -3);
$this->l = 12; // 96 / 8 = 12
break;
case 'md2':
case 'md5':
$this->l = 16;
break;
case 'sha1':
$this->l = 20;
break;
case 'sha256':
$this->l = 32;
break;
case 'sha384':
$this->l = 48;
break;
case 'sha512':
$this->l = 64;
}
switch ($hash) {
case 'md2-96':
case 'md2':
$this->b = 16;
case 'md5-96':
case 'sha1-96':
case 'sha224-96':
case 'sha256-96':
case 'md2':
case 'md5':
case 'sha1':
case 'sha224':
case 'sha256':
$this->b = 64;
break;
default:
$this->b = 128;
}
switch ($hash) {
case 'md2':
$this->engine = CRYPT_HASH_MODE == self::MODE_HASH && in_array('md2', hash_algos()) ?
self::MODE_HASH : self::MODE_INTERNAL;
break;
case 'sha384':
case 'sha512':
$this->engine = CRYPT_HASH_MODE == self::MODE_MHASH ? self::MODE_INTERNAL : CRYPT_HASH_MODE;
break;
default:
$this->engine = CRYPT_HASH_MODE;
}
switch ($this->engine) {
case self::MODE_MHASH:
switch ($hash) {
case 'md5':
$this->hash = MHASH_MD5;
break;
case 'sha256':
$this->hash = MHASH_SHA256;
break;
case 'sha1':
default:
$this->hash = MHASH_SHA1;
}
$this->_computeKey(self::MODE_MHASH);
return;
case self::MODE_HASH:
switch ($hash) {
case 'md5':
$this->hash = 'md5';
return;
case 'md2':
case 'sha256':
case 'sha384':
case 'sha512':
$this->hash = $hash;
return;
case 'sha1':
default:
$this->hash = 'sha1';
}
$this->_computeKey(self::MODE_HASH);
return;
}
switch ($hash) {
case 'md2':
$this->hash = array($this, '_md2');
break;
case 'md5':
$this->hash = array($this, '_md5');
break;
case 'sha256':
$this->hash = array($this, '_sha256');
break;
case 'sha384':
case 'sha512':
$this->hash = array($this, '_sha512');
break;
case 'sha1':
default:
$this->hash = array($this, '_sha1');
}
$this->ipad = str_repeat(chr(0x36), $this->b);
$this->opad = str_repeat(chr(0x5C), $this->b);
$this->_computeKey(self::MODE_INTERNAL);
}
/**
* Compute the HMAC.
*
* @access public
* @param string $text
* @return string
*/
function hash($text)
{
if (!empty($this->key) || is_string($this->key)) {
switch ($this->engine) {
case self::MODE_MHASH:
$output = mhash($this->hash, $text, $this->computedKey);
break;
case self::MODE_HASH:
$output = hash_hmac($this->hash, $text, $this->computedKey, true);
break;
case self::MODE_INTERNAL:
$key = str_pad($this->computedKey, $this->b, chr(0)); // step 1
$temp = $this->ipad ^ $key; // step 2
$temp .= $text; // step 3
$temp = call_user_func($this->hash, $temp); // step 4
$output = $this->opad ^ $key; // step 5
$output.= $temp; // step 6
$output = call_user_func($this->hash, $output); // step 7
}
} else {
switch ($this->engine) {
case self::MODE_MHASH:
$output = mhash($this->hash, $text);
break;
case self::MODE_HASH:
$output = hash($this->hash, $text, true);
break;
case self::MODE_INTERNAL:
$output = call_user_func($this->hash, $text);
}
}
return substr($output, 0, $this->l);
}
/**
* Returns the hash length (in bytes)
*
* @access public
* @return int
*/
function getLength()
{
return $this->l;
}
/**
* Wrapper for MD5
*
* @access private
* @param string $m
*/
function _md5($m)
{
return pack('H*', md5($m));
}
/**
* Wrapper for SHA1
*
* @access private
* @param string $m
*/
function _sha1($m)
{
return pack('H*', sha1($m));
}
/**
* Pure-PHP implementation of MD2
*
* See {@link http://tools.ietf.org/html/rfc1319 RFC1319}.
*
* @access private
* @param string $m
*/
function _md2($m)
{
static $s = array(
41, 46, 67, 201, 162, 216, 124, 1, 61, 54, 84, 161, 236, 240, 6,
19, 98, 167, 5, 243, 192, 199, 115, 140, 152, 147, 43, 217, 188,
76, 130, 202, 30, 155, 87, 60, 253, 212, 224, 22, 103, 66, 111, 24,
138, 23, 229, 18, 190, 78, 196, 214, 218, 158, 222, 73, 160, 251,
245, 142, 187, 47, 238, 122, 169, 104, 121, 145, 21, 178, 7, 63,
148, 194, 16, 137, 11, 34, 95, 33, 128, 127, 93, 154, 90, 144, 50,
39, 53, 62, 204, 231, 191, 247, 151, 3, 255, 25, 48, 179, 72, 165,
181, 209, 215, 94, 146, 42, 172, 86, 170, 198, 79, 184, 56, 210,
150, 164, 125, 182, 118, 252, 107, 226, 156, 116, 4, 241, 69, 157,
112, 89, 100, 113, 135, 32, 134, 91, 207, 101, 230, 45, 168, 2, 27,
96, 37, 173, 174, 176, 185, 246, 28, 70, 97, 105, 52, 64, 126, 15,
85, 71, 163, 35, 221, 81, 175, 58, 195, 92, 249, 206, 186, 197,
234, 38, 44, 83, 13, 110, 133, 40, 132, 9, 211, 223, 205, 244, 65,
129, 77, 82, 106, 220, 55, 200, 108, 193, 171, 250, 36, 225, 123,
8, 12, 189, 177, 74, 120, 136, 149, 139, 227, 99, 232, 109, 233,
203, 213, 254, 59, 0, 29, 57, 242, 239, 183, 14, 102, 88, 208, 228,
166, 119, 114, 248, 235, 117, 75, 10, 49, 68, 80, 180, 143, 237,
31, 26, 219, 153, 141, 51, 159, 17, 131, 20
);
// Step 1. Append Padding Bytes
$pad = 16 - (strlen($m) & 0xF);
$m.= str_repeat(chr($pad), $pad);
$length = strlen($m);
// Step 2. Append Checksum
$c = str_repeat(chr(0), 16);
$l = chr(0);
for ($i = 0; $i < $length; $i+= 16) {
for ($j = 0; $j < 16; $j++) {
// RFC1319 incorrectly states that C[j] should be set to S[c xor L]
//$c[$j] = chr($s[ord($m[$i + $j] ^ $l)]);
// per <http://www.rfc-editor.org/errata_search.php?rfc=1319>, however, C[j] should be set to S[c xor L] xor C[j]
$c[$j] = chr($s[ord($m[$i + $j] ^ $l)] ^ ord($c[$j]));
$l = $c[$j];
}
}
$m.= $c;
$length+= 16;
// Step 3. Initialize MD Buffer
$x = str_repeat(chr(0), 48);
// Step 4. Process Message in 16-Byte Blocks
for ($i = 0; $i < $length; $i+= 16) {
for ($j = 0; $j < 16; $j++) {
$x[$j + 16] = $m[$i + $j];
$x[$j + 32] = $x[$j + 16] ^ $x[$j];
}
$t = chr(0);
for ($j = 0; $j < 18; $j++) {
for ($k = 0; $k < 48; $k++) {
$x[$k] = $t = $x[$k] ^ chr($s[ord($t)]);
//$t = $x[$k] = $x[$k] ^ chr($s[ord($t)]);
}
$t = chr(ord($t) + $j);
}
}
// Step 5. Output
return substr($x, 0, 16);
}
/**
* Pure-PHP implementation of SHA256
*
* See {@link http://en.wikipedia.org/wiki/SHA_hash_functions#SHA-256_.28a_SHA-2_variant.29_pseudocode SHA-256 (a SHA-2 variant) pseudocode - Wikipedia}.
*
* @access private
* @param string $m
*/
function _sha256($m)
{
if (extension_loaded('suhosin')) {
return pack('H*', sha256($m));
}
// Initialize variables
$hash = array(
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
);
// Initialize table of round constants
// (first 32 bits of the fractional parts of the cube roots of the first 64 primes 2..311)
static $k = array(
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
);
// Pre-processing
$length = strlen($m);
// to round to nearest 56 mod 64, we'll add 64 - (length + (64 - 56)) % 64
$m.= str_repeat(chr(0), 64 - (($length + 8) & 0x3F));
$m[$length] = chr(0x80);
// we don't support hashing strings 512MB long
$m.= pack('N2', 0, $length << 3);
// Process the message in successive 512-bit chunks
$chunks = str_split($m, 64);
foreach ($chunks as $chunk) {
$w = array();
for ($i = 0; $i < 16; $i++) {
extract(unpack('Ntemp', $this->_string_shift($chunk, 4)));
$w[] = $temp;
}
// Extend the sixteen 32-bit words into sixty-four 32-bit words
for ($i = 16; $i < 64; $i++) {
// @codingStandardsIgnoreStart
$s0 = $this->_rightRotate($w[$i - 15], 7) ^
$this->_rightRotate($w[$i - 15], 18) ^
$this->_rightShift( $w[$i - 15], 3);
$s1 = $this->_rightRotate($w[$i - 2], 17) ^
$this->_rightRotate($w[$i - 2], 19) ^
$this->_rightShift( $w[$i - 2], 10);
// @codingStandardsIgnoreEnd
$w[$i] = $this->_add($w[$i - 16], $s0, $w[$i - 7], $s1);
}
// Initialize hash value for this chunk
list($a, $b, $c, $d, $e, $f, $g, $h) = $hash;
// Main loop
for ($i = 0; $i < 64; $i++) {
$s0 = $this->_rightRotate($a, 2) ^
$this->_rightRotate($a, 13) ^
$this->_rightRotate($a, 22);
$maj = ($a & $b) ^
($a & $c) ^
($b & $c);
$t2 = $this->_add($s0, $maj);
$s1 = $this->_rightRotate($e, 6) ^
$this->_rightRotate($e, 11) ^
$this->_rightRotate($e, 25);
$ch = ($e & $f) ^
($this->_not($e) & $g);
$t1 = $this->_add($h, $s1, $ch, $k[$i], $w[$i]);
$h = $g;
$g = $f;
$f = $e;
$e = $this->_add($d, $t1);
$d = $c;
$c = $b;
$b = $a;
$a = $this->_add($t1, $t2);
}
// Add this chunk's hash to result so far
$hash = array(
$this->_add($hash[0], $a),
$this->_add($hash[1], $b),
$this->_add($hash[2], $c),
$this->_add($hash[3], $d),
$this->_add($hash[4], $e),
$this->_add($hash[5], $f),
$this->_add($hash[6], $g),
$this->_add($hash[7], $h)
);
}
// Produce the final hash value (big-endian)
return pack('N8', $hash[0], $hash[1], $hash[2], $hash[3], $hash[4], $hash[5], $hash[6], $hash[7]);
}
/**
* Pure-PHP implementation of SHA384 and SHA512
*
* @access private
* @param string $m
*/
function _sha512($m)
{
static $init384, $init512, $k;
if (!isset($k)) {
// Initialize variables
$init384 = array( // initial values for SHA384
'cbbb9d5dc1059ed8', '629a292a367cd507', '9159015a3070dd17', '152fecd8f70e5939',
'67332667ffc00b31', '8eb44a8768581511', 'db0c2e0d64f98fa7', '47b5481dbefa4fa4'
);
$init512 = array( // initial values for SHA512
'6a09e667f3bcc908', 'bb67ae8584caa73b', '3c6ef372fe94f82b', 'a54ff53a5f1d36f1',
'510e527fade682d1', '9b05688c2b3e6c1f', '1f83d9abfb41bd6b', '5be0cd19137e2179'
);
for ($i = 0; $i < 8; $i++) {
$init384[$i] = new BigInteger($init384[$i], 16);
$init384[$i]->setPrecision(64);
$init512[$i] = new BigInteger($init512[$i], 16);
$init512[$i]->setPrecision(64);
}
// Initialize table of round constants
// (first 64 bits of the fractional parts of the cube roots of the first 80 primes 2..409)
$k = array(
'428a2f98d728ae22', '7137449123ef65cd', 'b5c0fbcfec4d3b2f', 'e9b5dba58189dbbc',
'3956c25bf348b538', '59f111f1b605d019', '923f82a4af194f9b', 'ab1c5ed5da6d8118',
'd807aa98a3030242', '12835b0145706fbe', '243185be4ee4b28c', '550c7dc3d5ffb4e2',
'72be5d74f27b896f', '80deb1fe3b1696b1', '9bdc06a725c71235', 'c19bf174cf692694',
'e49b69c19ef14ad2', 'efbe4786384f25e3', '0fc19dc68b8cd5b5', '240ca1cc77ac9c65',
'2de92c6f592b0275', '4a7484aa6ea6e483', '5cb0a9dcbd41fbd4', '76f988da831153b5',
'983e5152ee66dfab', 'a831c66d2db43210', 'b00327c898fb213f', 'bf597fc7beef0ee4',
'c6e00bf33da88fc2', 'd5a79147930aa725', '06ca6351e003826f', '142929670a0e6e70',
'27b70a8546d22ffc', '2e1b21385c26c926', '4d2c6dfc5ac42aed', '53380d139d95b3df',
'650a73548baf63de', '766a0abb3c77b2a8', '81c2c92e47edaee6', '92722c851482353b',
'a2bfe8a14cf10364', 'a81a664bbc423001', 'c24b8b70d0f89791', 'c76c51a30654be30',
'd192e819d6ef5218', 'd69906245565a910', 'f40e35855771202a', '106aa07032bbd1b8',
'19a4c116b8d2d0c8', '1e376c085141ab53', '2748774cdf8eeb99', '34b0bcb5e19b48a8',
'391c0cb3c5c95a63', '4ed8aa4ae3418acb', '5b9cca4f7763e373', '682e6ff3d6b2b8a3',
'748f82ee5defb2fc', '78a5636f43172f60', '84c87814a1f0ab72', '8cc702081a6439ec',
'90befffa23631e28', 'a4506cebde82bde9', 'bef9a3f7b2c67915', 'c67178f2e372532b',
'ca273eceea26619c', 'd186b8c721c0c207', 'eada7dd6cde0eb1e', 'f57d4f7fee6ed178',
'06f067aa72176fba', '0a637dc5a2c898a6', '113f9804bef90dae', '1b710b35131c471b',
'28db77f523047d84', '32caab7b40c72493', '3c9ebe0a15c9bebc', '431d67c49c100d4c',
'4cc5d4becb3e42b6', '597f299cfc657e2a', '5fcb6fab3ad6faec', '6c44198c4a475817'
);
for ($i = 0; $i < 80; $i++) {
$k[$i] = new BigInteger($k[$i], 16);
}
}
$hash = $this->l == 48 ? $init384 : $init512;
// Pre-processing
$length = strlen($m);
// to round to nearest 112 mod 128, we'll add 128 - (length + (128 - 112)) % 128
$m.= str_repeat(chr(0), 128 - (($length + 16) & 0x7F));
$m[$length] = chr(0x80);
// we don't support hashing strings 512MB long
$m.= pack('N4', 0, 0, 0, $length << 3);
// Process the message in successive 1024-bit chunks
$chunks = str_split($m, 128);
foreach ($chunks as $chunk) {
$w = array();
for ($i = 0; $i < 16; $i++) {
$temp = new BigInteger($this->_string_shift($chunk, 8), 256);
$temp->setPrecision(64);
$w[] = $temp;
}
// Extend the sixteen 32-bit words into eighty 32-bit words
for ($i = 16; $i < 80; $i++) {
$temp = array(
$w[$i - 15]->bitwise_rightRotate(1),
$w[$i - 15]->bitwise_rightRotate(8),
$w[$i - 15]->bitwise_rightShift(7)
);
$s0 = $temp[0]->bitwise_xor($temp[1]);
$s0 = $s0->bitwise_xor($temp[2]);
$temp = array(
$w[$i - 2]->bitwise_rightRotate(19),
$w[$i - 2]->bitwise_rightRotate(61),
$w[$i - 2]->bitwise_rightShift(6)
);
$s1 = $temp[0]->bitwise_xor($temp[1]);
$s1 = $s1->bitwise_xor($temp[2]);
$w[$i] = $w[$i - 16]->copy();
$w[$i] = $w[$i]->add($s0);
$w[$i] = $w[$i]->add($w[$i - 7]);
$w[$i] = $w[$i]->add($s1);
}
// Initialize hash value for this chunk
$a = $hash[0]->copy();
$b = $hash[1]->copy();
$c = $hash[2]->copy();
$d = $hash[3]->copy();
$e = $hash[4]->copy();
$f = $hash[5]->copy();
$g = $hash[6]->copy();
$h = $hash[7]->copy();
// Main loop
for ($i = 0; $i < 80; $i++) {
$temp = array(
$a->bitwise_rightRotate(28),
$a->bitwise_rightRotate(34),
$a->bitwise_rightRotate(39)
);
$s0 = $temp[0]->bitwise_xor($temp[1]);
$s0 = $s0->bitwise_xor($temp[2]);
$temp = array(
$a->bitwise_and($b),
$a->bitwise_and($c),
$b->bitwise_and($c)
);
$maj = $temp[0]->bitwise_xor($temp[1]);
$maj = $maj->bitwise_xor($temp[2]);
$t2 = $s0->add($maj);
$temp = array(
$e->bitwise_rightRotate(14),
$e->bitwise_rightRotate(18),
$e->bitwise_rightRotate(41)
);
$s1 = $temp[0]->bitwise_xor($temp[1]);
$s1 = $s1->bitwise_xor($temp[2]);
$temp = array(
$e->bitwise_and($f),
$g->bitwise_and($e->bitwise_not())
);
$ch = $temp[0]->bitwise_xor($temp[1]);
$t1 = $h->add($s1);
$t1 = $t1->add($ch);
$t1 = $t1->add($k[$i]);
$t1 = $t1->add($w[$i]);
$h = $g->copy();
$g = $f->copy();
$f = $e->copy();
$e = $d->add($t1);
$d = $c->copy();
$c = $b->copy();
$b = $a->copy();
$a = $t1->add($t2);
}
// Add this chunk's hash to result so far
$hash = array(
$hash[0]->add($a),
$hash[1]->add($b),
$hash[2]->add($c),
$hash[3]->add($d),
$hash[4]->add($e),
$hash[5]->add($f),
$hash[6]->add($g),
$hash[7]->add($h)
);
}
// Produce the final hash value (big-endian)
// (\phpseclib\Crypt\Hash::hash() trims the output for hashes but not for HMACs. as such, we trim the output here)
$temp = $hash[0]->toBytes() . $hash[1]->toBytes() . $hash[2]->toBytes() . $hash[3]->toBytes() .
$hash[4]->toBytes() . $hash[5]->toBytes();
if ($this->l != 48) {
$temp.= $hash[6]->toBytes() . $hash[7]->toBytes();
}
return $temp;
}
/**
* Right Rotate
*
* @access private
* @param int $int
* @param int $amt
* @see self::_sha256()
* @return int
*/
function _rightRotate($int, $amt)
{
$invamt = 32 - $amt;
$mask = (1 << $invamt) - 1;
return (($int << $invamt) & 0xFFFFFFFF) | (($int >> $amt) & $mask);
}
/**
* Right Shift
*
* @access private
* @param int $int
* @param int $amt
* @see self::_sha256()
* @return int
*/
function _rightShift($int, $amt)
{
$mask = (1 << (32 - $amt)) - 1;
return ($int >> $amt) & $mask;
}
/**
* Not
*
* @access private
* @param int $int
* @see self::_sha256()
* @return int
*/
function _not($int)
{
return ~$int & 0xFFFFFFFF;
}
/**
* Add
*
* _sha256() adds multiple unsigned 32-bit integers. Since PHP doesn't support unsigned integers and since the
* possibility of overflow exists, care has to be taken. BigInteger could be used but this should be faster.
*
* @return int
* @see self::_sha256()
* @access private
*/
function _add()
{
static $mod;
if (!isset($mod)) {
$mod = pow(2, 32);
}
$result = 0;
$arguments = func_get_args();
foreach ($arguments as $argument) {
$result+= $argument < 0 ? ($argument & 0x7FFFFFFF) + 0x80000000 : $argument;
}
if ((php_uname('m') & "\xDF\xDF\xDF") != 'ARM') {
return fmod($result, $mod);
}
return (fmod($result, 0x80000000) & 0x7FFFFFFF) |
((fmod(floor($result / 0x80000000), 2) & 1) << 31);
}
/**
* String Shift
*
* Inspired by array_shift
*
* @param string $string
* @param int $index
* @return string
* @access private
*/
function _string_shift(&$string, $index = 1)
{
$substr = substr($string, 0, $index);
$string = substr($string, $index);
return $substr;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,277 @@
<?php
/**
* Random Number Generator
*
* PHP version 5
*
* Here's a short example of how to use this library:
* <code>
* <?php
* include 'vendor/autoload.php';
*
* echo bin2hex(\phpseclib\Crypt\Random::string(8));
* ?>
* </code>
*
* @category Crypt
* @package Random
* @author Jim Wigginton <terrafrost@php.net>
* @copyright 2007 Jim Wigginton
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @link http://phpseclib.sourceforge.net
*/
namespace phpseclib\Crypt;
/**
* Pure-PHP Random Number Generator
*
* @package Random
* @author Jim Wigginton <terrafrost@php.net>
* @access public
*/
class Random
{
/**
* Generate a random string.
*
* Although microoptimizations are generally discouraged as they impair readability this function is ripe with
* microoptimizations because this function has the potential of being called a huge number of times.
* eg. for RSA key generation.
*
* @param int $length
* @return string
*/
static function string($length)
{
if (!$length) {
return '';
}
if (version_compare(PHP_VERSION, '7.0.0', '>=')) {
try {
return \random_bytes($length);
} catch (\Throwable $e) {
// If a sufficient source of randomness is unavailable, random_bytes() will throw an
// object that implements the Throwable interface (Exception, TypeError, Error).
// We don't actually need to do anything here. The string() method should just continue
// as normal. Note, however, that if we don't have a sufficient source of randomness for
// random_bytes(), most of the other calls here will fail too, so we'll end up using
// the PHP implementation.
}
}
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
// method 1. prior to PHP 5.3 this would call rand() on windows hence the function_exists('class_alias') call.
// ie. class_alias is a function that was introduced in PHP 5.3
if (extension_loaded('mcrypt') && function_exists('class_alias')) {
return @mcrypt_create_iv($length);
}
// method 2. openssl_random_pseudo_bytes was introduced in PHP 5.3.0 but prior to PHP 5.3.4 there was,
// to quote <http://php.net/ChangeLog-5.php#5.3.4>, "possible blocking behavior". as of 5.3.4
// openssl_random_pseudo_bytes and mcrypt_create_iv do the exact same thing on Windows. ie. they both
// call php_win32_get_random_bytes():
//
// https://github.com/php/php-src/blob/7014a0eb6d1611151a286c0ff4f2238f92c120d6/ext/openssl/openssl.c#L5008
// https://github.com/php/php-src/blob/7014a0eb6d1611151a286c0ff4f2238f92c120d6/ext/mcrypt/mcrypt.c#L1392
//
// php_win32_get_random_bytes() is defined thusly:
//
// https://github.com/php/php-src/blob/7014a0eb6d1611151a286c0ff4f2238f92c120d6/win32/winutil.c#L80
//
// we're calling it, all the same, in the off chance that the mcrypt extension is not available
if (extension_loaded('openssl') && version_compare(PHP_VERSION, '5.3.4', '>=')) {
return openssl_random_pseudo_bytes($length);
}
} else {
// method 1. the fastest
if (extension_loaded('openssl')) {
return openssl_random_pseudo_bytes($length);
}
// method 2
static $fp = true;
if ($fp === true) {
// warning's will be output unles the error suppression operator is used. errors such as
// "open_basedir restriction in effect", "Permission denied", "No such file or directory", etc.
$fp = @fopen('/dev/urandom', 'rb');
}
if ($fp !== true && $fp !== false) { // surprisingly faster than !is_bool() or is_resource()
$temp = fread($fp, $length);
if (strlen($temp) == $length) {
return $temp;
}
}
// method 3. pretty much does the same thing as method 2 per the following url:
// https://github.com/php/php-src/blob/7014a0eb6d1611151a286c0ff4f2238f92c120d6/ext/mcrypt/mcrypt.c#L1391
// surprisingly slower than method 2. maybe that's because mcrypt_create_iv does a bunch of error checking that we're
// not doing. regardless, this'll only be called if this PHP script couldn't open /dev/urandom due to open_basedir
// restrictions or some such
if (extension_loaded('mcrypt')) {
return @mcrypt_create_iv($length, MCRYPT_DEV_URANDOM);
}
}
// at this point we have no choice but to use a pure-PHP CSPRNG
// cascade entropy across multiple PHP instances by fixing the session and collecting all
// environmental variables, including the previous session data and the current session
// data.
//
// mt_rand seeds itself by looking at the PID and the time, both of which are (relatively)
// easy to guess at. linux uses mouse clicks, keyboard timings, etc, as entropy sources, but
// PHP isn't low level to be able to use those as sources and on a web server there's not likely
// going to be a ton of keyboard or mouse action. web servers do have one thing that we can use
// however, a ton of people visiting the website. obviously you don't want to base your seeding
// soley on parameters a potential attacker sends but (1) not everything in $_SERVER is controlled
// by the user and (2) this isn't just looking at the data sent by the current user - it's based
// on the data sent by all users. one user requests the page and a hash of their info is saved.
// another user visits the page and the serialization of their data is utilized along with the
// server envirnment stuff and a hash of the previous http request data (which itself utilizes
// a hash of the session data before that). certainly an attacker should be assumed to have
// full control over his own http requests. he, however, is not going to have control over
// everyone's http requests.
static $crypto = false, $v;
if ($crypto === false) {
// save old session data
$old_session_id = session_id();
$old_use_cookies = ini_get('session.use_cookies');
$old_session_cache_limiter = session_cache_limiter();
$_OLD_SESSION = isset($_SESSION) ? $_SESSION : false;
if ($old_session_id != '') {
session_write_close();
}
session_id(1);
ini_set('session.use_cookies', 0);
session_cache_limiter('');
session_start();
$v = $seed = $_SESSION['seed'] = pack('H*', sha1(
(isset($_SERVER) ? phpseclib_safe_serialize($_SERVER) : '') .
(isset($_POST) ? phpseclib_safe_serialize($_POST) : '') .
(isset($_GET) ? phpseclib_safe_serialize($_GET) : '') .
(isset($_COOKIE) ? phpseclib_safe_serialize($_COOKIE) : '') .
phpseclib_safe_serialize($GLOBALS) .
phpseclib_safe_serialize($_SESSION) .
phpseclib_safe_serialize($_OLD_SESSION)
));
if (!isset($_SESSION['count'])) {
$_SESSION['count'] = 0;
}
$_SESSION['count']++;
session_write_close();
// restore old session data
if ($old_session_id != '') {
session_id($old_session_id);
session_start();
ini_set('session.use_cookies', $old_use_cookies);
session_cache_limiter($old_session_cache_limiter);
} else {
if ($_OLD_SESSION !== false) {
$_SESSION = $_OLD_SESSION;
unset($_OLD_SESSION);
} else {
unset($_SESSION);
}
}
// in SSH2 a shared secret and an exchange hash are generated through the key exchange process.
// the IV client to server is the hash of that "nonce" with the letter A and for the encryption key it's the letter C.
// if the hash doesn't produce enough a key or an IV that's long enough concat successive hashes of the
// original hash and the current hash. we'll be emulating that. for more info see the following URL:
//
// http://tools.ietf.org/html/rfc4253#section-7.2
//
// see the is_string($crypto) part for an example of how to expand the keys
$key = pack('H*', sha1($seed . 'A'));
$iv = pack('H*', sha1($seed . 'C'));
// ciphers are used as per the nist.gov link below. also, see this link:
//
// http://en.wikipedia.org/wiki/Cryptographically_secure_pseudorandom_number_generator#Designs_based_on_cryptographic_primitives
switch (true) {
case class_exists('\phpseclib\Crypt\AES'):
$crypto = new AES(Base::MODE_CTR);
break;
case class_exists('\phpseclib\Crypt\Twofish'):
$crypto = new Twofish(Base::MODE_CTR);
break;
case class_exists('\phpseclib\Crypt\Blowfish'):
$crypto = new Blowfish(Base::MODE_CTR);
break;
case class_exists('\phpseclib\Crypt\TripleDES'):
$crypto = new TripleDES(Base::MODE_CTR);
break;
case class_exists('\phpseclib\Crypt\DES'):
$crypto = new DES(Base::MODE_CTR);
break;
case class_exists('\phpseclib\Crypt\RC4'):
$crypto = new RC4();
break;
default:
user_error(__CLASS__ . ' requires at least one symmetric cipher be loaded');
return false;
}
$crypto->setKey($key);
$crypto->setIV($iv);
$crypto->enableContinuousBuffer();
}
//return $crypto->encrypt(str_repeat("\0", $length));
// the following is based off of ANSI X9.31:
//
// http://csrc.nist.gov/groups/STM/cavp/documents/rng/931rngext.pdf
//
// OpenSSL uses that same standard for it's random numbers:
//
// http://www.opensource.apple.com/source/OpenSSL/OpenSSL-38/openssl/fips-1.0/rand/fips_rand.c
// (do a search for "ANS X9.31 A.2.4")
$result = '';
while (strlen($result) < $length) {
$i = $crypto->encrypt(microtime()); // strlen(microtime()) == 21
$r = $crypto->encrypt($i ^ $v); // strlen($v) == 20
$v = $crypto->encrypt($r ^ $i); // strlen($r) == 20
$result.= $r;
}
return substr($result, 0, $length);
}
}
if (!function_exists('phpseclib_safe_serialize')) {
/**
* Safely serialize variables
*
* If a class has a private __sleep() method it'll give a fatal error on PHP 5.2 and earlier.
* PHP 5.3 will emit a warning.
*
* @param mixed $arr
* @access public
*/
function phpseclib_safe_serialize(&$arr)
{
if (is_object($arr)) {
return '';
}
if (!is_array($arr)) {
return serialize($arr);
}
// prevent circular array recursion
if (isset($arr['__phpseclib_marker'])) {
return '';
}
$safearr = array();
$arr['__phpseclib_marker'] = true;
foreach (array_keys($arr) as $key) {
// do not recurse on the '__phpseclib_marker' key itself, for smaller memory usage
if ($key !== '__phpseclib_marker') {
$safearr[$key] = phpseclib_safe_serialize($arr[$key]);
}
}
unset($arr['__phpseclib_marker']);
return serialize($safearr);
}
}

View file

@ -0,0 +1,577 @@
<?php
/**
* Pure-PHP ANSI Decoder
*
* PHP version 5
*
* If you call read() in \phpseclib\Net\SSH2 you may get {@link http://en.wikipedia.org/wiki/ANSI_escape_code ANSI escape codes} back.
* They'd look like chr(0x1B) . '[00m' or whatever (0x1B = ESC). They tell a
* {@link http://en.wikipedia.org/wiki/Terminal_emulator terminal emulator} how to format the characters, what
* color to display them in, etc. \phpseclib\File\ANSI is a {@link http://en.wikipedia.org/wiki/VT100 VT100} terminal emulator.
*
* @category File
* @package ANSI
* @author Jim Wigginton <terrafrost@php.net>
* @copyright 2012 Jim Wigginton
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @link http://phpseclib.sourceforge.net
*/
namespace phpseclib\File;
/**
* Pure-PHP ANSI Decoder
*
* @package ANSI
* @author Jim Wigginton <terrafrost@php.net>
* @access public
*/
class ANSI
{
/**
* Max Width
*
* @var int
* @access private
*/
var $max_x;
/**
* Max Height
*
* @var int
* @access private
*/
var $max_y;
/**
* Max History
*
* @var int
* @access private
*/
var $max_history;
/**
* History
*
* @var array
* @access private
*/
var $history;
/**
* History Attributes
*
* @var array
* @access private
*/
var $history_attrs;
/**
* Current Column
*
* @var int
* @access private
*/
var $x;
/**
* Current Row
*
* @var int
* @access private
*/
var $y;
/**
* Old Column
*
* @var int
* @access private
*/
var $old_x;
/**
* Old Row
*
* @var int
* @access private
*/
var $old_y;
/**
* An empty attribute cell
*
* @var object
* @access private
*/
var $base_attr_cell;
/**
* The current attribute cell
*
* @var object
* @access private
*/
var $attr_cell;
/**
* An empty attribute row
*
* @var array
* @access private
*/
var $attr_row;
/**
* The current screen text
*
* @var array
* @access private
*/
var $screen;
/**
* The current screen attributes
*
* @var array
* @access private
*/
var $attrs;
/**
* Current ANSI code
*
* @var string
* @access private
*/
var $ansi;
/**
* Tokenization
*
* @var array
* @access private
*/
var $tokenization;
/**
* Default Constructor.
*
* @return \phpseclib\File\ANSI
* @access public
*/
function __construct()
{
$attr_cell = new \stdClass();
$attr_cell->bold = false;
$attr_cell->underline = false;
$attr_cell->blink = false;
$attr_cell->background = 'black';
$attr_cell->foreground = 'white';
$attr_cell->reverse = false;
$this->base_attr_cell = clone $attr_cell;
$this->attr_cell = clone $attr_cell;
$this->setHistory(200);
$this->setDimensions(80, 24);
}
/**
* Set terminal width and height
*
* Resets the screen as well
*
* @param int $x
* @param int $y
* @access public
*/
function setDimensions($x, $y)
{
$this->max_x = $x - 1;
$this->max_y = $y - 1;
$this->x = $this->y = 0;
$this->history = $this->history_attrs = array();
$this->attr_row = array_fill(0, $this->max_x + 2, $this->base_attr_cell);
$this->screen = array_fill(0, $this->max_y + 1, '');
$this->attrs = array_fill(0, $this->max_y + 1, $this->attr_row);
$this->ansi = '';
}
/**
* Set the number of lines that should be logged past the terminal height
*
* @param int $history
* @access public
*/
function setHistory($history)
{
$this->max_history = $history;
}
/**
* Load a string
*
* @param string $source
* @access public
*/
function loadString($source)
{
$this->setDimensions($this->max_x + 1, $this->max_y + 1);
$this->appendString($source);
}
/**
* Appdend a string
*
* @param string $source
* @access public
*/
function appendString($source)
{
$this->tokenization = array('');
for ($i = 0; $i < strlen($source); $i++) {
if (strlen($this->ansi)) {
$this->ansi.= $source[$i];
$chr = ord($source[$i]);
// http://en.wikipedia.org/wiki/ANSI_escape_code#Sequence_elements
// single character CSI's not currently supported
switch (true) {
case $this->ansi == "\x1B=":
$this->ansi = '';
continue 2;
case strlen($this->ansi) == 2 && $chr >= 64 && $chr <= 95 && $chr != ord('['):
case strlen($this->ansi) > 2 && $chr >= 64 && $chr <= 126:
break;
default:
continue 2;
}
$this->tokenization[] = $this->ansi;
$this->tokenization[] = '';
// http://ascii-table.com/ansi-escape-sequences-vt-100.php
switch ($this->ansi) {
case "\x1B[H": // Move cursor to upper left corner
$this->old_x = $this->x;
$this->old_y = $this->y;
$this->x = $this->y = 0;
break;
case "\x1B[J": // Clear screen from cursor down
$this->history = array_merge($this->history, array_slice(array_splice($this->screen, $this->y + 1), 0, $this->old_y));
$this->screen = array_merge($this->screen, array_fill($this->y, $this->max_y, ''));
$this->history_attrs = array_merge($this->history_attrs, array_slice(array_splice($this->attrs, $this->y + 1), 0, $this->old_y));
$this->attrs = array_merge($this->attrs, array_fill($this->y, $this->max_y, $this->attr_row));
if (count($this->history) == $this->max_history) {
array_shift($this->history);
array_shift($this->history_attrs);
}
case "\x1B[K": // Clear screen from cursor right
$this->screen[$this->y] = substr($this->screen[$this->y], 0, $this->x);
array_splice($this->attrs[$this->y], $this->x + 1, $this->max_x - $this->x, array_fill($this->x, $this->max_x - ($this->x - 1), $this->base_attr_cell));
break;
case "\x1B[2K": // Clear entire line
$this->screen[$this->y] = str_repeat(' ', $this->x);
$this->attrs[$this->y] = $this->attr_row;
break;
case "\x1B[?1h": // set cursor key to application
case "\x1B[?25h": // show the cursor
case "\x1B(B": // set united states g0 character set
break;
case "\x1BE": // Move to next line
$this->_newLine();
$this->x = 0;
break;
default:
switch (true) {
case preg_match('#\x1B\[(\d+)B#', $this->ansi, $match): // Move cursor down n lines
$this->old_y = $this->y;
$this->y+= $match[1];
break;
case preg_match('#\x1B\[(\d+);(\d+)H#', $this->ansi, $match): // Move cursor to screen location v,h
$this->old_x = $this->x;
$this->old_y = $this->y;
$this->x = $match[2] - 1;
$this->y = $match[1] - 1;
break;
case preg_match('#\x1B\[(\d+)C#', $this->ansi, $match): // Move cursor right n lines
$this->old_x = $this->x;
$this->x+= $match[1];
break;
case preg_match('#\x1B\[(\d+)D#', $this->ansi, $match): // Move cursor left n lines
$this->old_x = $this->x;
$this->x-= $match[1];
if ($this->x < 0) {
$this->x = 0;
}
break;
case preg_match('#\x1B\[(\d+);(\d+)r#', $this->ansi, $match): // Set top and bottom lines of a window
break;
case preg_match('#\x1B\[(\d*(?:;\d*)*)m#', $this->ansi, $match): // character attributes
$attr_cell = &$this->attr_cell;
$mods = explode(';', $match[1]);
foreach ($mods as $mod) {
switch ($mod) {
case '':
case '0': // Turn off character attributes
$attr_cell = clone $this->base_attr_cell;
break;
case '1': // Turn bold mode on
$attr_cell->bold = true;
break;
case '4': // Turn underline mode on
$attr_cell->underline = true;
break;
case '5': // Turn blinking mode on
$attr_cell->blink = true;
break;
case '7': // Turn reverse video on
$attr_cell->reverse = !$attr_cell->reverse;
$temp = $attr_cell->background;
$attr_cell->background = $attr_cell->foreground;
$attr_cell->foreground = $temp;
break;
default: // set colors
//$front = $attr_cell->reverse ? &$attr_cell->background : &$attr_cell->foreground;
$front = &$attr_cell->{ $attr_cell->reverse ? 'background' : 'foreground' };
//$back = $attr_cell->reverse ? &$attr_cell->foreground : &$attr_cell->background;
$back = &$attr_cell->{ $attr_cell->reverse ? 'foreground' : 'background' };
switch ($mod) {
// @codingStandardsIgnoreStart
case '30': $front = 'black'; break;
case '31': $front = 'red'; break;
case '32': $front = 'green'; break;
case '33': $front = 'yellow'; break;
case '34': $front = 'blue'; break;
case '35': $front = 'magenta'; break;
case '36': $front = 'cyan'; break;
case '37': $front = 'white'; break;
case '40': $back = 'black'; break;
case '41': $back = 'red'; break;
case '42': $back = 'green'; break;
case '43': $back = 'yellow'; break;
case '44': $back = 'blue'; break;
case '45': $back = 'magenta'; break;
case '46': $back = 'cyan'; break;
case '47': $back = 'white'; break;
// @codingStandardsIgnoreEnd
default:
//user_error('Unsupported attribute: ' . $mod);
$this->ansi = '';
break 2;
}
}
}
break;
default:
//user_error("{$this->ansi} is unsupported\r\n");
}
}
$this->ansi = '';
continue;
}
$this->tokenization[count($this->tokenization) - 1].= $source[$i];
switch ($source[$i]) {
case "\r":
$this->x = 0;
break;
case "\n":
$this->_newLine();
break;
case "\x08": // backspace
if ($this->x) {
$this->x--;
$this->attrs[$this->y][$this->x] = clone $this->base_attr_cell;
$this->screen[$this->y] = substr_replace(
$this->screen[$this->y],
$source[$i],
$this->x,
1
);
}
break;
case "\x0F": // shift
break;
case "\x1B": // start ANSI escape code
$this->tokenization[count($this->tokenization) - 1] = substr($this->tokenization[count($this->tokenization) - 1], 0, -1);
//if (!strlen($this->tokenization[count($this->tokenization) - 1])) {
// array_pop($this->tokenization);
//}
$this->ansi.= "\x1B";
break;
default:
$this->attrs[$this->y][$this->x] = clone $this->attr_cell;
if ($this->x > strlen($this->screen[$this->y])) {
$this->screen[$this->y] = str_repeat(' ', $this->x);
}
$this->screen[$this->y] = substr_replace(
$this->screen[$this->y],
$source[$i],
$this->x,
1
);
if ($this->x > $this->max_x) {
$this->x = 0;
$this->_newLine();
} else {
$this->x++;
}
}
}
}
/**
* Add a new line
*
* Also update the $this->screen and $this->history buffers
*
* @access private
*/
function _newLine()
{
//if ($this->y < $this->max_y) {
// $this->y++;
//}
while ($this->y >= $this->max_y) {
$this->history = array_merge($this->history, array(array_shift($this->screen)));
$this->screen[] = '';
$this->history_attrs = array_merge($this->history_attrs, array(array_shift($this->attrs)));
$this->attrs[] = $this->attr_row;
if (count($this->history) >= $this->max_history) {
array_shift($this->history);
array_shift($this->history_attrs);
}
$this->y--;
}
$this->y++;
}
/**
* Returns the current coordinate without preformating
*
* @access private
* @return string
*/
function _processCoordinate($last_attr, $cur_attr, $char)
{
$output = '';
if ($last_attr != $cur_attr) {
$close = $open = '';
if ($last_attr->foreground != $cur_attr->foreground) {
if ($cur_attr->foreground != 'white') {
$open.= '<span style="color: ' . $cur_attr->foreground . '">';
}
if ($last_attr->foreground != 'white') {
$close = '</span>' . $close;
}
}
if ($last_attr->background != $cur_attr->background) {
if ($cur_attr->background != 'black') {
$open.= '<span style="background: ' . $cur_attr->background . '">';
}
if ($last_attr->background != 'black') {
$close = '</span>' . $close;
}
}
if ($last_attr->bold != $cur_attr->bold) {
if ($cur_attr->bold) {
$open.= '<b>';
} else {
$close = '</b>' . $close;
}
}
if ($last_attr->underline != $cur_attr->underline) {
if ($cur_attr->underline) {
$open.= '<u>';
} else {
$close = '</u>' . $close;
}
}
if ($last_attr->blink != $cur_attr->blink) {
if ($cur_attr->blink) {
$open.= '<blink>';
} else {
$close = '</blink>' . $close;
}
}
$output.= $close . $open;
}
$output.= htmlspecialchars($char);
return $output;
}
/**
* Returns the current screen without preformating
*
* @access private
* @return string
*/
function _getScreen()
{
$output = '';
$last_attr = $this->base_attr_cell;
for ($i = 0; $i <= $this->max_y; $i++) {
for ($j = 0; $j <= $this->max_x; $j++) {
$cur_attr = $this->attrs[$i][$j];
$output.= $this->_processCoordinate($last_attr, $cur_attr, isset($this->screen[$i][$j]) ? $this->screen[$i][$j] : '');
$last_attr = $this->attrs[$i][$j];
}
$output.= "\r\n";
}
$output = substr($output, 0, -2);
// close any remaining open tags
$output.= $this->_processCoordinate($last_attr, $this->base_attr_cell, '');
return rtrim($output);
}
/**
* Returns the current screen
*
* @access public
* @return string
*/
function getScreen()
{
return '<pre width="' . ($this->max_x + 1) . '" style="color: white; background: black">' . $this->_getScreen() . '</pre>';
}
/**
* Returns the current screen and the x previous lines
*
* @access public
* @return string
*/
function getHistory()
{
$scrollback = '';
$last_attr = $this->base_attr_cell;
for ($i = 0; $i < count($this->history); $i++) {
for ($j = 0; $j <= $this->max_x + 1; $j++) {
$cur_attr = $this->history_attrs[$i][$j];
$scrollback.= $this->_processCoordinate($last_attr, $cur_attr, isset($this->history[$i][$j]) ? $this->history[$i][$j] : '');
$last_attr = $this->history_attrs[$i][$j];
}
$scrollback.= "\r\n";
}
$base_attr_cell = $this->base_attr_cell;
$this->base_attr_cell = $last_attr;
$scrollback.= $this->_getScreen();
$this->base_attr_cell = $base_attr_cell;
return '<pre width="' . ($this->max_x + 1) . '" style="color: white; background: black">' . $scrollback . '</span></pre>';
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,47 @@
<?php
/**
* Pure-PHP ASN.1 Parser
*
* PHP version 5
*
* @category File
* @package ASN1
* @author Jim Wigginton <terrafrost@php.net>
* @copyright 2012 Jim Wigginton
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @link http://phpseclib.sourceforge.net
*/
namespace phpseclib\File\ASN1;
/**
* ASN.1 Element
*
* Bypass normal encoding rules in phpseclib\File\ASN1::encodeDER()
*
* @package ASN1
* @author Jim Wigginton <terrafrost@php.net>
* @access public
*/
class Element
{
/**
* Raw element value
*
* @var string
* @access private
*/
var $element;
/**
* Constructor
*
* @param string $encoded
* @return \phpseclib\File\ASN1\Element
* @access public
*/
function __construct($encoded)
{
$this->element = $encoded;
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,6 @@
# minimalist openssl.cnf file for use with phpseclib
HOME = .
RANDFILE = $ENV::HOME/.rnd
[ v3_ca ]

View file

@ -1,5 +1,4 @@
<?php
define('ROOT_DIR', dirname(__DIR__));
define('PLUGINS_DEST_DIR', __DIR__ . '/dist/releases/plugins');
is_dir(PLUGINS_DEST_DIR) || mkdir(PLUGINS_DEST_DIR, 0777, true);
@ -11,6 +10,8 @@ foreach ($files as $fileinfo) {
$fileinfo->isDir() || unlink($fileinfo->getRealPath());
}
$terser = ROOT_DIR . '/node_modules/terser/bin/terser';
$manifest = [];
require ROOT_DIR . '/snappymail/v/0.0.0/app/libraries/RainLoop/Plugins/AbstractPlugin.php';
$keys = [
@ -42,6 +43,21 @@ foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
$version = $manifest_item['version'];
if (0 < floatval($version)) {
echo "+ {$name} {$version}\n";
// Minify JavaScript
foreach (glob("{$dir}/*.js") as $file) {
if (!strpos($file,'.min')) {
$mfile = str_replace('.js', '.min.js', $file);
passthru("{$terser} {$file} --output {$mfile} --compress 'drop_console' --ecma 6 --mangle");
}
}
foreach (glob("{$dir}/js/*.js") as $file) {
if (!strpos($file,'.min')) {
$mfile = str_replace('.js', '.min.js', $file);
passthru("{$terser} {$file} --output {$mfile} --compress 'drop_console' --ecma 6 --mangle");
}
}
$manifest_item['type'] = 'plugin';
$manifest_item['id'] = $name;
$manifest_item['file'] = "plugins/{$name}-{$version}.tgz";
@ -54,6 +70,7 @@ foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
$tar->compress(Phar::GZ);
unlink($tar_destination);
rename("{$tar_destination}.gz", $tgz_destination);
/*
if (Phar::canWrite()) {
$phar_destination = PLUGINS_DEST_DIR . "/{$name}.phar";
@unlink($phar_destination);
@ -63,6 +80,7 @@ foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
unlink($phar_destination);
rename("{$phar_destination}.gz", $phar_destination);
}
*/
if (isset($options['sign'])) {
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '.escapeshellarg($tgz_destination), $return_var);
$manifest_item['pgp_sig'] = trim(preg_replace('/-----(BEGIN|END) PGP SIGNATURE-----/', '', file_get_contents($tgz_destination.'.asc')));
@ -85,6 +103,6 @@ $manifest = str_replace('"}', "\"\n\t}", $manifest);
$manifest = str_replace('}]', "}\n]", $manifest);
$manifest = str_replace('","', "\",\n\t\t\"", $manifest);
$manifest = str_replace('\/', '/', $manifest);
file_put_contents(PLUGINS_DEST_DIR . "/packages.json", $manifest);
file_put_contents(dirname(PLUGINS_DEST_DIR) . "/packages.json", $manifest);
exit;

283
cli/release.php Executable file
View file

@ -0,0 +1,283 @@
#!/usr/bin/php
<?php
define('ROOT_DIR', dirname(__DIR__));
chdir(ROOT_DIR);
$options = getopt('', ['aur','docker','plugins','skip-gulp','debian','nextcloud','owncloud','cpanel','sign']);
if (isset($options['plugins'])) {
require(ROOT_DIR . '/build/plugins.php');
}
$gulp = trim(`which gulp`);
if (!$gulp) {
exit('gulp not installed, run as root: npm install --global gulp-cli');
}
$package = json_decode(file_get_contents('package.json'));
/**
* Update files that contain version
*/
// cloudron
$file = ROOT_DIR . '/integrations/cloudron/Dockerfile';
file_put_contents($file, preg_replace('/VERSION=[0-9.]+/', "VERSION={$package->version}", file_get_contents($file)));
$file = ROOT_DIR . '/integrations/cloudron/DESCRIPTION.md';
file_put_contents($file, preg_replace('/<upstream>[^<]*</', "<upstream>{$package->version}<", file_get_contents($file)));
// docker
$file = ROOT_DIR . '/.docker/release/files/usr/local/include/application.ini';
file_put_contents($file, preg_replace('/current = "[0-9.]+"/', "current = \"{$package->version}\"", file_get_contents($file)));
// virtualmin
$file = ROOT_DIR . '/integrations/virtualmin/snappymail.pl';
file_put_contents($file, preg_replace('/return \\( "[0-9]+\\.[0-9]+\\.[0-9]+" \\)/', "return ( \"{$package->version}\" )", file_get_contents($file)));
// Arch User Repository
// https://aur.archlinux.org/packages/snappymail/
$options['aur'] = isset($options['aur']);
// Docker build
$options['docker'] = isset($options['docker']);
if ($options['docker'] && !($docker = trim(`which docker`))) {
exit('docker not found');
}
if ($options['docker'] && $options['aur']) {
exit('Conflict between docker and aur');
}
$destPath = "build/dist/releases/webmail/{$package->version}/";
is_dir($destPath) || mkdir($destPath, 0777, true);
$zip_destination = "{$destPath}snappymail-{$package->version}.zip";
$tar_destination = "{$destPath}snappymail-{$package->version}.tar";
@unlink($zip_destination);
@unlink($tar_destination);
@unlink("{$tar_destination}.gz");
if (!isset($options['skip-gulp'])) {
echo "\x1b[33;1m === Gulp === \x1b[0m\n";
passthru($gulp, $return_var);
if ($return_var) {
exit("gulp failed with error code {$return_var}\n");
}
$cmddir = escapeshellcmd(ROOT_DIR) . '/snappymail/v/0.0.0/static';
if ($gzip = trim(`which gzip`)) {
echo "\x1b[33;1m === Gzip *.js and *.css === \x1b[0m\n";
passthru("{$gzip} -k --best {$cmddir}/js/*.js");
passthru("{$gzip} -k --best {$cmddir}/js/min/*.js");
passthru("{$gzip} -k --best {$cmddir}/css/admin*.css");
passthru("{$gzip} -k --best {$cmddir}/css/app*.css");
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/boot.js.gz');
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/min/boot.min.js.gz');
}
if ($brotli = trim(`which brotli`)) {
echo "\x1b[33;1m === Brotli *.js and *.css === \x1b[0m\n";
passthru("{$brotli} -k --best {$cmddir}/js/*.js");
passthru("{$brotli} -k --best {$cmddir}/js/min/*.js");
passthru("{$brotli} -k --best {$cmddir}/css/admin*.css");
passthru("{$brotli} -k --best {$cmddir}/css/app*.css");
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/boot.js.br');
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/min/boot.min.js.br');
}
}
// Temporary rename folder to speed up PharData
//if (!rename('snappymail/v/0.0.0', "snappymail/v/{$package->version}")){
if (!rename('snappymail/v/0.0.0', "snappymail/v/{$package->version}")) {
exit('Failed to temporary rename snappymail/v/0.0.0');
}
register_shutdown_function(function(){
// Rename folder back to original
@rename("snappymail/v/{$GLOBALS['package']->version}", 'snappymail/v/0.0.0');
});
echo "\x1b[33;1m === Zip/Tar === \x1b[0m\n";
$zip = new ZipArchive();
if (!$zip->open($zip_destination, ZIPARCHIVE::CREATE)) {
exit("Failed to create {$zip_destination}");
}
$tar = new PharData($tar_destination);
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('snappymail/v'), RecursiveIteratorIterator::SELF_FIRST);
foreach ($files as $file) {
$file = str_replace('\\', '/', $file);
//echo "{$file}\n";
// Ignore "." and ".." folders
if (!in_array(substr($file, strrpos($file, '/')+1), array('.', '..'))) {
if (is_dir($file)) {
$zip->addEmptyDir($file);
} else if (is_file($file)) {
$zip->addFile($file);
}
}
}
if ($options['docker']) {
$tar->buildFromDirectory('./snappymail/', "@v/{$package->version}@");
} else {
$tar->buildFromDirectory('./', "@snappymail/v/{$package->version}@");
}
//$zip->addFile('cli/upgrade.sh');
//$tar->addFile('cli/upgrade.sh');
$zip->addFile('data/.htaccess');
$tar->addFile('data/.htaccess');
$zip->addFromString('data/VERSION', $package->version);
$tar->addFromString('data/VERSION', $package->version);
$zip->addFile('data/README.md');
$tar->addFile('data/README.md');
if ($options['aur']) {
$data = '<?php
function __get_custom_data_full_path()
{
return \'/var/lib/snappymail\';
}
';
$zip->addFromString('include.php', $data);
$tar->addFromString('include.php', $data);
} else {
$zip->addFile('_include.php');
$tar->addFile('_include.php');
}
$zip->addFile('.htaccess');
$tar->addFile('.htaccess');
$index = file_get_contents('index.php');
$index = str_replace('0.0.0', $package->version, $index);
$zip->addFromString('index.php', $index);
$tar->addFromString('index.php', $index);
$zip->addFile('README.md');
$tar->addFile('README.md');
$zip->close();
$tar->compress(Phar::GZ);
unlink($tar_destination);
$tar_destination .= '.gz';
echo "{$zip_destination} created\n{$tar_destination} created\n";
if (isset($options['nextcloud'])) {
require(ROOT_DIR . '/build/nextcloud.php');
}
if (isset($options['owncloud'])) {
require(ROOT_DIR . '/build/owncloud.php');
}
if (isset($options['cpanel'])) {
require(ROOT_DIR . '/build/cpanel.php');
}
rename("snappymail/v/{$package->version}", 'snappymail/v/0.0.0');
file_put_contents("{$destPath}core.json", '{
"version": "'.$package->version.'",
"file": "../latest.tar.gz",
"warnings": []
}');
// Arch User Repository
if ($options['aur']) {
// extension_loaded('blake2')
if (!function_exists('b2sum') && $b2sum = trim(`which b2sum`)) {
function b2sum($file) {
$file = escapeshellarg($file);
exec("b2sum --binary {$file} 2>&1", $output, $exitcode);
$output = explode(' ', implode("\n", $output));
return $output[0];
}
}
$b2sums = function_exists('b2sum') ? [
b2sum($tar_destination),
b2sum(ROOT_DIR . '/build/arch/snappymail.sysusers'),
b2sum(ROOT_DIR . '/build/arch/snappymail.tmpfiles')
] : [];
file_put_contents('build/arch/.SRCINFO', 'pkgbase = snappymail
pkgdesc = modern PHP webmail client
pkgver = '.$package->version.'
pkgrel = 1
url = https://github.com/the-djmaze/snappymail
arch = any
license = AGPL3
makedepends = php
makedepends = nodejs
makedepends = yarn
makedepends = gulp
depends = php-fpm
optdepends = mariadb: storage backend for contacts
optdepends = php-pgsql: storage backend for contacts
optdepends = php-sqlite: storage backend for contacts
source = snappymail-'.$package->version.'.tar.gz::https://github.com/the-djmaze/snappymail/archive/v'.$package->version.'.tar.gz
source = snappymail.sysusers
source = snappymail.tmpfiles
b2sums = '.implode("\n b2sums = ", $b2sums).'
pkgname = snappymail
');
$file = ROOT_DIR . '/build/arch/PKGBUILD';
if (is_file($file)) {
$PKGBUILD = file_get_contents($file);
$PKGBUILD = preg_replace('/pkgver=[0-9.]+/', "pkgver={$package->version}", $PKGBUILD);
$PKGBUILD = preg_replace('/b2sums=\\([^)]+\\)/s', "b2sums=('".implode("'\n '", $b2sums)."')", $PKGBUILD);
file_put_contents($file, $PKGBUILD);
}
}
// Debian Repository
else if (isset($options['debian'])) {
require(ROOT_DIR . '/build/deb.php');
}
// Docker build
else if ($options['docker']) {
echo "\x1b[33;1m === Docker === \x1b[0m\n";
$zip_filename = "snappymail-{$package->version}.zip";
copy($zip_destination, "./.docker/release/{$zip_filename}");
if ($docker) {
passthru("{$docker} build --pull " . ROOT_DIR . "/.docker/release/ --build-arg FILES_ZIP={$zip_filename} -t snappymail:{$package->version}");
} else {
echo "Docker not installed!\n";
}
}
if (isset($options['sign'])) {
echo "\x1b[33;1m === PGP Sign === \x1b[0m\n";
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '.escapeshellarg($tar_destination), $return_var);
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '.escapeshellarg($zip_destination), $return_var);
if (isset($options['nextcloud'])) {
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '
.escapeshellarg("{$destPath}snappymail-{$package->version}-nextcloud.tar.gz"), $return_var);
}
if (isset($options['owncloud'])) {
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '
.escapeshellarg("{$destPath}snappymail-{$package->version}-owncloud.tar.gz"), $return_var);
}
if (isset($options['cpanel'])) {
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '
.escapeshellarg("{$destPath}snappymail-{$package->version}-cpanel.tar.gz"), $return_var);
}
if (isset($options['debian'])) {
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '
. escapeshellarg(ROOT_DIR . "/build/dist/releases/webmail/{$package->version}/" . basename(DEB_DEST_DIR.'.deb')), $return_var);
// https://github.com/the-djmaze/snappymail/issues/185#issuecomment-1059420588
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --digest-algo SHA512 --clearsign --output '
. escapeshellarg(ROOT_DIR . "/build/dist/releases/webmail/{$package->version}/InRelease") . ' '
. escapeshellarg(ROOT_DIR . "/build/dist/releases/webmail/{$package->version}/Release"), $return_var);
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --digest-algo SHA512 -abs --output '
. escapeshellarg(ROOT_DIR . "/build/dist/releases/webmail/{$package->version}/Release.gpg") . ' '
. escapeshellarg(ROOT_DIR . "/build/dist/releases/webmail/{$package->version}/Release"), $return_var);
}
}

View file

@ -10,36 +10,42 @@
# By Uwe Bieling <pychi@gmx.de>
# and Jordan S (https://github.com/jas8522)
if [[ $(id -u) -ne 0 ]] ; then echo -e "\033[1;31mPlease run as root\033[0m" ; exit 1 ; fi
SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
cd "${SCRIPT_DIR}/.."
if [ ! -d snappymail/v ] ; then echo -e "\033[1;31mThis script can only be run from the SnappyMail install directory\033[0m" ; exit 1 ; fi
LATEST_URL="https://snappymail.eu/repository/latest.tar.gz"
cd "${SCRIPT_DIR}"
OWNERGROUP=`stat -c "%U:%G" snappymail`
OLD_VERSION=`grep "define('APP_VERSION" index.php | awk -F\' '{print $4}'`
# Safety First ... make a backup
DSTAMP=`date +%Y-%m-%d`
echo -e "\033[1;33mBacking up snappymail $OLD_VERSION to ../backup_snappymail_${DSTAMP}.tar.gz\033[0m"
tar -czf ../backup_snappymail_${DSTAMP}.tar.gz .
# check if we are root
if [[ $(id -u) -ne 0 ]] ; then echo -e "\033[1;31mPlease run as root\033[0m" ; exit 1 ; fi
# check if we are in the correct dir
if [ ! -d snappymail/v ] ; then echo -e "\033[1;31mThis script can only be run from the SnappyMail install directory\033[0m" ; exit 1 ; fi
# Download last release to /tmp
echo -e "\033[1;33mDownloading last release\033[0m"
wget $LATEST_URL -O /tmp/snappymail_latest.tar.gz
wget $LATEST_URL -O /tmp/snappymail_latest.tar.gz >/dev/null 2>&1
NEW_VERSION=`tar -tf /tmp/snappymail_latest.tar.gz | grep "snappymail/v/.*/index.php" | awk -F/ '{print $3}'`
echo -e "\033[1;33mInstalling $NEW_VERSION\033[0m"
tar -xzf /tmp/snappymail_latest.tar.gz
if [ $OLD_VERSION != $NEW_VERSION ] ;
then
# Safety First ... make a backup
DSTAMP=`date +%Y-%m-%d`
echo -e "\033[1;33mBacking up snappymail $OLD_VERSION to ../backup_snappymail_${DSTAMP}.tar.gz\033[0m"
tar -czf ../backup_snappymail_${DSTAMP}.tar.gz .
# set permissions
echo -e "\033[1;33mSet permissions\033[0m"
find . -type d -exec chmod 755 {} \;
find . -type f -exec chmod 644 {} \;
chmod u+x "${SCRIPT_DIR}/upgrade.sh"
chown -R $OWNERGROUP *
# installing
echo -e "\033[1;33mInstalling $NEW_VERSION\033[0m";
tar -xzf /tmp/snappymail_latest.tar.gz;
echo -e "\033[1;32mFinished with snappymail upgrade from $OLD_VERSION to $NEW_VERSION... \033[0m"
# set permissions
echo -e "\033[1;33mSet permissions\033[0m"
find . -type d -exec chmod 755 {} \;
find . -type f -exec chmod 644 {} \;
chmod u+x "${SCRIPT_DIR}/upgrade.sh"
chown -R $OWNERGROUP $SCRIPT_DIR
echo -e "\033[1;32mFinished with snappymail upgrade from $OLD_VERSION to $NEW_VERSION... \033[0m"
else
echo -e "\033[1;31msame Version skipping.. \033[0m";
fi

View file

@ -4,7 +4,7 @@ import { logoutLink } from 'Common/Links';
import { i18nToNodes, initOnStartOrLangChange } from 'Common/Translator';
import { LanguageStore } from 'Stores/Language';
import { ThemeStore } from 'Stores/Theme';
import { initThemes } from 'Stores/Theme';
import { SelectComponent } from 'Component/Select';
import { CheckboxComponent } from 'Component/Checkbox';
@ -36,9 +36,6 @@ export class AbstractApp {
createViewModel: (params, componentInfo) => {
params = params || {};
i18nToNodes(componentInfo.element);
if (params.inline) {
componentInfo.element.style.display = 'inline-block';
}
return new ClassObject(params);
}
}
@ -49,7 +46,7 @@ export class AbstractApp {
initOnStartOrLangChange();
LanguageStore.populate();
ThemeStore.populate();
initThemes();
this.start();
}

View file

@ -1,7 +1,7 @@
import 'External/ko';
import { Settings, SettingsGet } from 'Common/Globals';
import { ThemeStore } from 'Stores/Theme';
import { initThemes } from 'Stores/Theme';
import Remote from 'Remote/Admin/Fetch';
@ -18,7 +18,7 @@ export class AdminApp extends AbstractApp {
}
refresh() {
ThemeStore.populate();
initThemes();
this.start();
}
@ -27,7 +27,7 @@ export class AdminApp extends AbstractApp {
rl.route.root();
setTimeout(() => location.href = '/', 1);
} else if (SettingsGet('Auth')) {
this.weakPassword(SettingsGet('WeakPassword'));
this.weakPassword(SettingsGet('weakPassword'));
startScreens([SettingsAdminScreen]);
} else {
startScreens([LoginAdminScreen]);

View file

@ -1,12 +1,11 @@
import 'External/User/ko';
import { SMAudio } from 'Common/Audio';
import { isArray, pString, changeTheme } from 'Common/Utils';
import { isArray, pInt } from 'Common/Utils';
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility } from 'Common/UtilsUser';
import {
FolderType,
SetSystemFoldersNotification,
ClientSideKeyNameFolderListSize
} from 'Common/EnumsUser';
@ -38,7 +37,7 @@ import { IdentityUserStore } from 'Stores/User/Identity';
import { FolderUserStore } from 'Stores/User/Folder';
import { PgpUserStore } from 'Stores/User/Pgp';
import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { ThemeStore } from 'Stores/Theme';
import { ThemeStore, initThemes } from 'Stores/Theme';
import { LanguageStore } from 'Stores/Language';
import { MessageUserStore } from 'Stores/User/Message';
@ -62,7 +61,7 @@ import { AskPopupView } from 'View/Popup/Ask';
import {
folderInformation,
folderInformationMultiply,
refreshFoldersInterval,
setRefreshFoldersInterval,
messagesMoveHelper,
messagesDeleteHelper
} from 'Common/Folders';
@ -80,7 +79,7 @@ export class AppUser extends AbstractApp {
(currentTime > (lastTime + interval + 1000))
&& Remote.request('Version',
iError => (100 < iError) && location.reload(),
{ Version: Settings.app('version') }
{ version: Settings.app('version') }
);
lastTime = currentTime;
}, interval);
@ -91,6 +90,7 @@ export class AppUser extends AbstractApp {
addEventListener('click', dropdownsDetectVisibility);
this.folderList = FolderUserStore.folderList;
this.messageList = MessagelistUserStore;
}
/**
@ -101,27 +101,27 @@ export class AppUser extends AbstractApp {
*/
moveMessagesToFolderType(iFolderType, sFromFolderFullName, oUids, bDelete) {
let oMoveFolder = null,
nSetSystemFoldersNotification = null;
nSetSystemFoldersNotification = 0;
switch (iFolderType) {
case FolderType.Spam:
case FolderType.Junk:
oMoveFolder = getFolderFromCacheList(FolderUserStore.spamFolder());
nSetSystemFoldersNotification = SetSystemFoldersNotification.Spam;
nSetSystemFoldersNotification = iFolderType;
bDelete = bDelete || UNUSED_OPTION_VALUE === FolderUserStore.spamFolder();
break;
case FolderType.NotSpam:
case FolderType.Inbox:
oMoveFolder = getFolderFromCacheList(getFolderInboxName());
break;
case FolderType.Trash:
oMoveFolder = getFolderFromCacheList(FolderUserStore.trashFolder());
nSetSystemFoldersNotification = SetSystemFoldersNotification.Trash;
nSetSystemFoldersNotification = iFolderType;
bDelete = bDelete || UNUSED_OPTION_VALUE === FolderUserStore.trashFolder()
|| sFromFolderFullName === FolderUserStore.spamFolder()
|| sFromFolderFullName === FolderUserStore.trashFolder();
break;
case FolderType.Archive:
oMoveFolder = getFolderFromCacheList(FolderUserStore.archiveFolder());
nSetSystemFoldersNotification = SetSystemFoldersNotification.Archive;
nSetSystemFoldersNotification = iFolderType;
bDelete = bDelete || UNUSED_OPTION_VALUE === FolderUserStore.archiveFolder();
break;
// no default
@ -152,41 +152,18 @@ export class AppUser extends AbstractApp {
IdentityUserStore.loading(false);
if (!iError) {
const
// counts = {},
accounts = oData.Result.Accounts,
mainEmail = SettingsGet('MainEmail');
let items = oData.Result.Accounts;
AccountUserStore(isArray(items)
? items.map(oValue => new AccountModel(oValue.email, oValue.name))
: []
);
AccountUserStore.unshift(new AccountModel(SettingsGet('mainEmail'), '', false));
if (isArray(accounts)) {
// AccountUserStore.accounts.forEach(oAccount => counts[oAccount.email] = oAccount.count());
AccountUserStore.accounts(
accounts.map(
sValue => new AccountModel(sValue/*, counts[sValue]*/)
)
);
// accounts.length &&
AccountUserStore.accounts.unshift(new AccountModel(mainEmail/*, counts[mainEmail]*/, false));
}
if (isArray(oData.Result.Identities)) {
IdentityUserStore(
oData.Result.Identities.map(identityData => {
const identity = new IdentityModel(
pString(identityData.Id),
pString(identityData.Email)
);
identity.name(pString(identityData.Name));
identity.replyTo(pString(identityData.ReplyTo));
identity.bcc(pString(identityData.Bcc));
identity.signature(pString(identityData.Signature));
identity.signatureInsertBefore(!!identityData.SignatureInsertBefore);
return identity;
})
);
}
items = oData.Result.Identities;
IdentityUserStore(isArray(items)
? items.map(identityData => IdentityModel.reviveFromJson(identityData))
: []
);
}
});
}
@ -200,31 +177,30 @@ export class AppUser extends AbstractApp {
}
logout() {
localStorage.removeItem('register_protocol_offered');
Remote.request('Logout', () => rl.logoutReload(Settings.app('customLogoutLink')));
}
bootstart() {
super.bootstart();
addEventListener('resize', () => leftPanelDisabled(ThemeStore.isMobile() || 1000 > innerWidth));
addEventListener('beforeunload', event => {
if (arePopupsVisible() || (ThemeStore.isMobile() && MessageUserStore.message())) {
if (arePopupsVisible() || (!SettingsUserStore.usePreviewPane() && MessageUserStore.message())) {
event.preventDefault();
return event.returnValue = "Are you sure you want to exit?";
return event.returnValue = i18n('POPUPS_ASK/EXIT_ARE_YOU_SURE');
}
}, {capture: true});
}
refresh() {
ThemeStore.populate();
LanguageStore.language(SettingsGet('Language'));
changeTheme(SettingsGet('Theme'));
initThemes();
LanguageStore.language(SettingsGet('language'));
this.start();
}
start() {
if (SettingsGet('Auth')) {
rl.setWindowTitle(i18n('GLOBAL/LOADING'));
rl.setTitle(i18n('GLOBAL/LOADING'));
SMAudio.notifications(!!SettingsGet('SoundNotification'));
NotificationUserStore.enabled(!!SettingsGet('DesktopNotifications'));
@ -242,13 +218,7 @@ export class AppUser extends AbstractApp {
SettingsUserScreen
]);
setInterval(() => {
const cF = FolderUserStore.currentFolderFullName(),
iF = getFolderInboxName();
folderInformation(iF);
iF === cF || folderInformation(cF);
folderInformationMultiply();
}, refreshFoldersInterval);
setRefreshFoldersInterval(pInt(SettingsGet('CheckMailInterval')));
ContactUserStore.init();
@ -269,30 +239,31 @@ export class AppUser extends AbstractApp {
// initLeftSideLayoutResizer
setTimeout(() => {
const left = elementById('rl-left'),
right = elementById('rl-right'),
fToggle = () =>
setLayoutResizer(left, right, ClientSideKeyNameFolderListSize,
setLayoutResizer(left, ClientSideKeyNameFolderListSize,
(ThemeStore.isMobile() || leftPanelDisabled()) ? 0 : 'Width');
if (left && right) {
if (left) {
fToggle();
leftPanelDisabled.subscribe(fToggle);
}
}, 1);
setInterval(reloadTime(), 60000);
setInterval(reloadTime, 60000);
PgpUserStore.init();
// When auto-login is active
try {
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')
(SettingsGet('title') || 'SnappyMail')
);
} catch (e) {} // eslint-disable-line no-empty
localStorage.setItem('register_protocol_offered', '1');
}
setTimeout(() => mailToHelper(SettingsGet('MailToEmail')), 500);
} else {
this.logout();
}

View file

@ -1,9 +1,5 @@
import { MessageSetAction } from 'Common/EnumsUser';
import { isArray } from 'Common/Utils';
let FOLDERS_CACHE = {},
FOLDERS_NAME_CACHE = {},
MESSAGE_FLAGS_CACHE = {},
let FOLDERS_CACHE = new Map,
FOLDERS_HASH_MAP = new Map,
inboxFolderName = 'INBOX';
export const
@ -11,18 +7,10 @@ export const
* @returns {void}
*/
clearCache = () => {
FOLDERS_CACHE = {};
FOLDERS_NAME_CACHE = {};
MESSAGE_FLAGS_CACHE = {};
FOLDERS_CACHE.clear();
FOLDERS_HASH_MAP.clear();
},
/**
* @param {string} folderFullName
* @param {string} uid
* @returns {string}
*/
getMessageKey = (folderFullName, uid) => folderFullName + '#' + uid,
/**
* @returns {string}
*/
@ -34,143 +22,35 @@ export const
setFolderInboxName = name => inboxFolderName = name,
/**
* @param {string} folderHash
* @param {string} fullNameHash
* @returns {string}
*/
getFolderFullName = folderHash => (folderHash && FOLDERS_NAME_CACHE[folderHash]) || '',
getFolderFromHashMap = fullNameHash => getFolderFromCacheList(FOLDERS_HASH_MAP.get(fullNameHash)),
/**
* @param {string} folderHash
* @param {string} folderFullName
* @param {?FolderModel} folder
*/
setFolder = folder => {
folder.hash = '';
FOLDERS_CACHE[folder.fullName] = folder;
FOLDERS_NAME_CACHE[folder.fullNameHash] = folder.fullName;
folder.etag = '';
FOLDERS_CACHE.set(folder.fullName, folder);
FOLDERS_HASH_MAP.set(folder.fullNameHash, folder.fullName);
},
/**
* @param {string} folderFullName
* @param {string} folderHash
* @param {string} folderETag
*/
setFolderHash = (folderFullName, folderHash) =>
FOLDERS_CACHE[folderFullName] && (FOLDERS_CACHE[folderFullName].hash = folderHash),
setFolderETag = (folderFullName, folderETag) =>
FOLDERS_CACHE.has(folderFullName) && (FOLDERS_CACHE.get(folderFullName).etag = folderETag),
/**
* @param {string} folderFullName
* @returns {?FolderModel}
*/
getFolderFromCacheList = folderFullName =>
FOLDERS_CACHE[folderFullName] ? FOLDERS_CACHE[folderFullName] : null,
FOLDERS_CACHE.get(folderFullName),
/**
* @param {string} folderFullName
*/
removeFolderFromCacheList = folderFullName => delete FOLDERS_CACHE[folderFullName];
export class MessageFlagsCache
{
/**
* @param {string} folderFullName
* @param {string} uid
* @param {string} flag
* @returns {bool}
*/
static hasFlag(folderFullName, uid, flag) {
return MESSAGE_FLAGS_CACHE[folderFullName]?.[uid]?.includes(flag);
}
/**
* @param {string} folderFullName
* @param {string} uid
* @returns {?Array}
*/
static getFor(folderFullName, uid) {
return MESSAGE_FLAGS_CACHE[folderFullName]?.[uid];
}
/**
* @param {string} folderFullName
* @param {string} uid
* @param {Array} flagsCache
*/
static setFor(folderFullName, uid, flags) {
if (isArray(flags)) {
if (!MESSAGE_FLAGS_CACHE[folderFullName]) {
MESSAGE_FLAGS_CACHE[folderFullName] = {};
}
MESSAGE_FLAGS_CACHE[folderFullName][uid] = flags;
}
}
/**
* @param {string} folderFullName
*/
static clearFolder(folderFullName) {
MESSAGE_FLAGS_CACHE[folderFullName] = {};
}
/**
* @param {(MessageModel|null)} message
*/
static initMessage(message) {
if (message) {
const uid = message.uid,
flags = this.getFor(message.folder, uid),
thread = message.threads();
isArray(flags) && message.flags(flags);
if (thread.length) {
message.hasUnseenSubMessage(!!thread.find(iSubUid =>
(uid !== iSubUid) && !this.hasFlag(message.folder, iSubUid, '\\seen')
));
message.hasFlaggedSubMessage(!!thread.find(iSubUid =>
(uid !== iSubUid) && this.hasFlag(message.folder, iSubUid, '\\flagged')
));
}
}
}
/**
* @param {(MessageModel|null)} message
*/
static store(message) {
message && this.setFor(message.folder, message.uid, message.flags());
}
/**
* @param {string} folder
* @param {string} uid
* @param {number} setAction
*/
static storeBySetAction(folder, uid, setAction) {
let flags = this.getFor(folder, uid) || [];
const
unread = flags.includes('\\seen') ? 0 : 1,
add = item => flags.includes(item) || flags.push(item),
remove = item => flags = flags.filter(flag => flag != item);
switch (setAction) {
case MessageSetAction.SetSeen:
add('\\seen');
break;
case MessageSetAction.UnsetSeen:
remove('\\seen');
break;
case MessageSetAction.SetFlag:
add('\\flagged');
break;
case MessageSetAction.UnsetFlag:
remove('\\flagged');
break;
// no default
}
this.setFor(folder, uid, flags);
return unread;
}
}
removeFolderFromCacheList = folderFullName => FOLDERS_CACHE.delete(folderFullName);

View file

@ -5,12 +5,10 @@ export const
/**
* @enum {string}
*/
Scope = {
MessageList: 'MessageList',
FolderList: 'FolderList',
MessageView: 'MessageView',
Settings: 'Settings'
},
ScopeMessageList = 'MessageList',
ScopeFolderList = 'FolderList',
ScopeMessageView = 'MessageView',
ScopeSettings = 'Settings',
/**
* @enum {number}
@ -39,7 +37,7 @@ SaveSettingStatus = {
/**
* @enum {number}
*/
Notification = {
Notifications = {
RequestError: 1,
RequestAborted: 2,
RequestTimeout: 3,

View file

@ -4,20 +4,33 @@
* @enum {number}
*/
export const FolderType = {
User: 0,
Inbox: 1,
Sent: 2,
Drafts: 3,
Spam: 4, // JUNK
Junk: 4, // Spam
Trash: 5,
Archive: 6,
NotSpam: 80
};
Archive: 6
/*
IMPORTANT : 10;
FLAGGED : 11;
ALL : 13;
// TODO: SnappyMail
TEMPLATES : 19;
// Kolab
CONFIGURATION : 20;
CALENDAR : 21;
CONTACTS : 22;
TASKS : 23;
NOTES : 24;
FILES : 25;
JOURNAL : 26;
*/
},
/**
* @enum {string}
*/
export const FolderMetadataKeys = {
FolderMetadataKeys = {
// RFC 5464
Comment: '/private/comment',
CommentShared: '/shared/comment',
@ -26,12 +39,12 @@ export const FolderMetadataKeys = {
// Kolab
KolabFolderType: '/private/vendor/kolab/folder-type',
KolabFolderTypeShared: '/shared/vendor/kolab/folder-type'
};
},
/**
* @enum {string}
*/
export const ComposeType = {
ComposeType = {
Empty: 0,
Reply: 1,
ReplyAll: 2,
@ -39,63 +52,32 @@ export const ComposeType = {
ForwardAsAttachment: 4,
Draft: 5,
EditAsNew: 6
};
},
/**
* @enum {number}
*/
export const SetSystemFoldersNotification = {
None: 0,
Sent: 1,
Draft: 2,
Spam: 3,
Trash: 4,
Archive: 5
};
ClientSideKeyNameExpandedFolders = 3,
ClientSideKeyNameFolderListSize = 4,
ClientSideKeyNameMessageListSize = 5,
ClientSideKeyNameLastSignMe = 7,
ClientSideKeyNameMessageHeaderFullInfo = 9,
ClientSideKeyNameMessageAttachmentControls = 10,
/**
* @enum {number}
*/
export const
ClientSideKeyNameExpandedFolders = 3,
ClientSideKeyNameFolderListSize = 4,
ClientSideKeyNameMessageListSize = 5,
ClientSideKeyNameLastSignMe = 7,
ClientSideKeyNameMessageHeaderFullInfo = 9,
ClientSideKeyNameMessageAttachmentControls = 10;
/**
* @enum {number}
*/
export const MessageSetAction = {
MessageSetAction = {
SetSeen: 0,
UnsetSeen: 1,
SetFlag: 2,
UnsetFlag: 3
};
},
/**
* @enum {number}
*/
export const MessagePriority = {
Low: 5,
Normal: 3,
High: 1
};
/**
* @enum {string}
*/
export const EditorDefaultType = {
Html: 'Html',
Plain: 'Plain'
};
/**
* @enum {number}
*/
export const Layout = {
NoPreview: 0,
SidePreview: 1,
BottomPreview: 2
};
//LayoutNoView = 0,
LayoutSideView = 1,
LayoutBottomView = 2
;

View file

@ -22,7 +22,8 @@ const
ics: 'text/calendar',
xml: 'text/xml',
json: app+'json',
asc: app+'pgp-signature',
// asc: app+'pgp-signature',
// asc: app+'pgp-keys',
p10: app+'pkcs10',
p7c: app+'pkcs7-mime',
p7m: app+'pkcs7-mime',

View file

@ -1,7 +1,6 @@
import { isArray, arrayLength } from 'Common/Utils';
import {
MessageFlagsCache,
setFolderHash,
setFolderETag,
getFolderInboxName,
getFolderFromCacheList
} from 'Common/Cache';
@ -9,13 +8,27 @@ import { SettingsUserStore } from 'Stores/User/Settings';
import { FolderUserStore } from 'Stores/User/Folder';
import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { getNotification } from 'Common/Translator';
import { Settings, } from 'Common/Globals';
import { serverRequest } from 'Common/Links';
import Remote from 'Remote/User/Fetch';
let refreshInterval,
// Default every 5 minutes
refreshFoldersInterval = 300000;
export const
setRefreshFoldersInterval = minutes => {
refreshFoldersInterval = Math.max(5, minutes) * 60000;
clearInterval(refreshInterval);
refreshInterval = setInterval(() => {
const cF = FolderUserStore.currentFolderFullName(),
iF = getFolderInboxName();
folderInformation(iF);
iF === cF || folderInformation(cF);
folderInformationMultiply();
}, refreshFoldersInterval);
},
sortFolders = folders => {
try {
let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
@ -27,37 +40,6 @@ sortFolders = folders => {
}
},
/**
* @param {?Function} fCallback
* @param {string} folder
* @param {Array=} list = []
*/
fetchFolderInformation = (fCallback, folder, list = []) => {
let fetch = !arrayLength(list);
const uids = [],
folderFromCache = getFolderFromCacheList(folder);
if (!fetch) {
list.forEach(messageListItem => {
MessageFlagsCache.getFor(folder, messageListItem.uid) || uids.push(messageListItem.uid);
messageListItem.threads.forEach(uid => {
MessageFlagsCache.getFor(folder, uid) || uids.push(uid);
});
});
fetch = uids.length;
}
if (fetch) {
Remote.request('FolderInformation', fCallback, {
Folder: folder,
FlagsUids: uids,
UidNext: folderFromCache?.uidNext || 0 // Used to check for new messages
});
} else if (SettingsUserStore.useThreads()) {
MessagelistUserStore.reloadFlagsAndCachedMessage();
}
},
/**
* @param {Array=} aDisabled
* @param {Array=} aHeaderLines
@ -118,56 +100,59 @@ folderListOptionsBuilder = (
return aResult;
},
// Every 5 minutes
refreshFoldersInterval = 300000,
/**
* @param {string} folder
* @param {Array=} list = []
*/
folderInformation = (folder, list) => {
if (folder?.trim()) {
fetchFolderInformation(
(iError, data) => {
let count = 1;
const uids = [];
if (arrayLength(list)) {
list.forEach(messageListItem => {
uids.push(messageListItem.uid);
messageListItem.threads.forEach(uid => uids.push(uid));
});
count = uids.length;
}
if (count) {
Remote.request('FolderInformation', (iError, data) => {
if (!iError && data.Result) {
const result = data.Result,
folderFromCache = getFolderFromCacheList(result.Folder);
folderFromCache = getFolderFromCacheList(result.name);
if (folderFromCache) {
const oldHash = folderFromCache.hash,
const oldHash = folderFromCache.etag,
unreadCountChange = (folderFromCache.unreadEmails() !== result.unreadEmails);
// folderFromCache.revivePropertiesFromJson(result);
// folderFromCache.revivePropertiesFromJson(result);
folderFromCache.expires = Date.now();
folderFromCache.uidNext = result.UidNext;
folderFromCache.hash = result.Hash;
folderFromCache.uidNext = result.uidNext;
folderFromCache.etag = result.etag;
folderFromCache.totalEmails(result.totalEmails);
folderFromCache.unreadEmails(result.unreadEmails);
unreadCountChange && MessageFlagsCache.clearFolder(folderFromCache.fullName);
MessagelistUserStore.notifyNewMessages(folderFromCache.fullName, result.newMessages);
if (result.MessagesFlags.length) {
result.MessagesFlags.forEach(message =>
MessageFlagsCache.setFor(folderFromCache.fullName, message.Uid.toString(), message.Flags)
);
MessagelistUserStore.reloadFlagsAndCachedMessage();
}
MessagelistUserStore.notifyNewMessages(folderFromCache.fullName, result.NewMessages);
if (!oldHash || unreadCountChange || result.Hash !== oldHash) {
if (!oldHash || unreadCountChange || result.etag !== oldHash) {
if (folderFromCache.fullName === FolderUserStore.currentFolderFullName()) {
MessagelistUserStore.reload();
/*
} else if (getFolderInboxName() === folderFromCache.fullName) {
Remote.messageList(null, {Folder: getFolderInboxName()}, true);
// Remote.messageList(null, {folder: getFolderFromCacheList(getFolderInboxName())}, true);
Remote.messageList(null, {folder: getFolderInboxName()}, true);
*/
}
}
}
}
},
folder,
list
);
}, {
folder: folder,
flagsUids: uids,
uidNext: getFolderFromCacheList(folder)?.uidNext || 0 // Used to check for new messages
});
}
}
},
@ -181,21 +166,18 @@ folderInformationMultiply = (boot = false) => {
if (!iError && arrayLength(oData.Result)) {
const utc = Date.now();
oData.Result.forEach(item => {
const folder = getFolderFromCacheList(item.Folder);
const folder = getFolderFromCacheList(item.name);
if (folder) {
const oldHash = folder.hash,
const oldHash = folder.etag,
unreadCountChange = folder.unreadEmails() !== item.unreadEmails;
// folder.revivePropertiesFromJson(item);
folder.expires = utc;
folder.hash = item.Hash;
folder.etag = item.etag;
folder.totalEmails(item.totalEmails);
folder.unreadEmails(item.unreadEmails);
unreadCountChange && MessageFlagsCache.clearFolder(folder.fullName);
if (!oldHash || item.Hash !== oldHash) {
if (!oldHash || item.etag !== oldHash) {
if (folder.fullName === FolderUserStore.currentFolderFullName()) {
MessagelistUserStore.reload();
}
@ -210,20 +192,20 @@ folderInformationMultiply = (boot = false) => {
boot && setTimeout(() => folderInformationMultiply(true), 2000);
}
}, {
Folders: folders
folders: folders
});
}
},
moveOrDeleteResponseHelper = (iError, oData) => {
if (iError) {
setFolderHash(FolderUserStore.currentFolderFullName(), '');
setFolderETag(FolderUserStore.currentFolderFullName(), '');
alert(getNotification(iError));
} else if (FolderUserStore.currentFolder()) {
if (2 === arrayLength(oData.Result)) {
setFolderHash(oData.Result[0], oData.Result[1]);
setFolderETag(oData.Result[0], oData.Result[1]);
} else {
setFolderHash(FolderUserStore.currentFolderFullName(), '');
setFolderETag(FolderUserStore.currentFolderFullName(), '');
}
MessagelistUserStore.reload(!MessagelistUserStore.length);
}
@ -235,24 +217,24 @@ messagesMoveHelper = (fromFolderFullName, toFolderFullName, uidsForMove) => {
isSpam = sSpamFolder === toFolderFullName,
isHam = !isSpam && sSpamFolder === fromFolderFullName && getFolderInboxName() === toFolderFullName;
Remote.abort('MessageList').request('MessageMove',
Remote.abort('MessageList', 'reload').request('MessageMove',
moveOrDeleteResponseHelper,
{
FromFolder: fromFolderFullName,
ToFolder: toFolderFullName,
Uids: [...uidsForMove].join(','),
MarkAsRead: (isSpam || FolderUserStore.trashFolder() === toFolderFullName) ? 1 : 0,
Learning: isSpam ? 'SPAM' : isHam ? 'HAM' : ''
fromFolder: fromFolderFullName,
toFolder: toFolderFullName,
uids: [...uidsForMove].join(','),
markAsRead: (isSpam || FolderUserStore.trashFolder() === toFolderFullName) ? 1 : 0,
learning: isSpam ? 'SPAM' : isHam ? 'HAM' : ''
}
);
},
messagesDeleteHelper = (sFromFolderFullName, aUidForRemove) => {
Remote.abort('MessageList').request('MessageDelete',
Remote.abort('MessageList', 'reload').request('MessageDelete',
moveOrDeleteResponseHelper,
{
Folder: sFromFolderFullName,
Uids: [...aUidForRemove].join(',')
folder: sFromFolderFullName,
uids: [...aUidForRemove].join(',')
}
);
},
@ -271,9 +253,9 @@ moveMessagesToFolder = (sFromFolderFullName, oUids, sToFolderFullName, bCopy) =>
if (oFromFolder && oToFolder) {
bCopy
? Remote.request('MessageCopy', null, {
FromFolder: oFromFolder.fullName,
ToFolder: oToFolder.fullName,
Uids: [...oUids].join(',')
fromFolder: oFromFolder.fullName,
toFolder: oToFolder.fullName,
uids: [...oUids].join(',')
})
: messagesMoveHelper(oFromFolder.fullName, oToFolder.fullName, oUids);
@ -286,28 +268,20 @@ moveMessagesToFolder = (sFromFolderFullName, oUids, sToFolderFullName, bCopy) =>
},
dropFilesInFolder = (sFolderFullName, files) => {
let count = 0,
fn = () => 0 == --count
&& FolderUserStore.currentFolderFullName() == sFolderFullName
&& MessagelistUserStore.reload(true, true);
let count = files.length;
for (const file of files) {
if ('message/rfc822' === file.type) {
++count;
let data = new FormData;
data.append('Folder', sFolderFullName);
data.append('AppendFile', file);
data.XToken = Settings.app('token');
fetch(serverRequest('Append'), {
method: 'POST',
mode: 'same-origin',
cache: 'no-cache',
redirect: 'error',
referrerPolicy: 'no-referrer',
credentials: 'same-origin',
body: data
})
.then(fn)
.catch(fn);
data.append('folder', sFolderFullName);
data.append('appendFile', file);
Remote.request('FolderAppend', (iError, data)=>{
iError && console.error(data.ErrorMessage);
0 == --count
&& FolderUserStore.currentFolderFullName() == sFolderFullName
&& MessagelistUserStore.reload(true, true);
}, data);
} else {
--count;
}
}
};

View file

@ -1,20 +1,19 @@
import { doc, elementById, $htmlCL } from 'Common/Globals';
import { doc, appEl, $htmlCL } from 'Common/Globals';
// Fullscreen must be on app, else other popups fail
export const
app = elementById('rl-app'),
appFullscreen = () => (doc.fullscreenElement || doc.webkitFullscreenElement) === app,
appFullscreen = () => (doc.fullscreenElement || doc.webkitFullscreenElement) === appEl,
exitFullscreen = () => appFullscreen() && (doc.exitFullscreen || doc.webkitExitFullscreen).call(doc),
isFullscreen = ko.observable(false),
toggleFullscreen = () => isFullscreen() ? exitFullscreen() : app.requestFullscreen();
toggleFullscreen = () => isFullscreen() ? exitFullscreen() : appEl.requestFullscreen();
if (app) {
if (appEl) {
let event = 'fullscreenchange';
if (!app.requestFullscreen && app.webkitRequestFullscreen) {
app.requestFullscreen = app.webkitRequestFullscreen;
if (!appEl.requestFullscreen && appEl.webkitRequestFullscreen) {
appEl.requestFullscreen = appEl.webkitRequestFullscreen;
event = 'webkit'+event;
}
if (app.requestFullscreen) {
if (appEl.requestFullscreen) {
doc.addEventListener(event, () => {
isFullscreen(appFullscreen());
$htmlCL.toggle('rl-fullscreen', appFullscreen());

View file

@ -11,6 +11,8 @@ export const
elementById = id => doc.getElementById(id),
appEl = elementById('rl-app'),
Settings = rl.settings,
SettingsGet = Settings.get,
SettingsCapa = name => name && !!(SettingsGet('Capa') || {})[name],
@ -18,8 +20,8 @@ export const
dropdowns = [],
dropdownVisibility = ko.observable(false).extend({ rateLimit: 0 }),
moveAction = ko.observable(false),
leftPanelDisabled = ko.observable(false),
toggleLeftPanel = () => leftPanelDisabled(!leftPanelDisabled()),
createElement = (name, attr) => {
let el = doc.createElement(name);
@ -31,6 +33,11 @@ export const
new CustomEvent(name, {detail:detail, cancelable: !!cancelable})
),
stopEvent = event => {
event.preventDefault();
event.stopPropagation();
},
formFieldFocused = () => doc.activeElement?.matches('input,textarea'),
addShortcut = (...args) => shortcuts.add(...args),
@ -68,10 +75,4 @@ dropdownVisibility.subscribe(value => {
}
});
leftPanelDisabled.toggle = () => leftPanelDisabled(!leftPanelDisabled());
leftPanelDisabled.subscribe(value => {
value && moveAction() && moveAction(false);
$htmlCL.toggle('rl-left-panel-disabled', value);
});
moveAction.subscribe(value => value && leftPanelDisabled() && leftPanelDisabled(false));
leftPanelDisabled.subscribe(value => $htmlCL.toggle('rl-left-panel-disabled', value));

View file

@ -3,7 +3,7 @@ import { forEachObjectEntry, pInt } from 'Common/Utils';
import { SettingsUserStore } from 'Stores/User/Settings';
const
tpl = createElement('template'),
tmpl = createElement('template'),
htmlre = /[&<>"']/g,
httpre = /^(https?:)?\/\//i,
htmlmap = {
@ -14,13 +14,32 @@ const
"'": '&#x27;'
},
disallowedTags = [
'svg','script','title','link','base','meta',
'input','output','select','button','textarea',
'bgsound','keygen','source','object','embed','applet','iframe','frame','frameset','video','audio','area','map'
// not supported by <template> element
// ,'html','head','body'
].join(','),
blockquoteSwitcher = () => {
SettingsUserStore.collapseBlockquotes() &&
// tmpl.content.querySelectorAll('blockquote').forEach(node => {
[...tmpl.content.querySelectorAll('blockquote')].reverse().forEach(node => {
const el = createElement('details', {class:'sm-bq-switcher'});
el.innerHTML = '<summary>•••</summary>';
node.replaceWith(el);
el.append(node);
});
},
replaceWithChildren = node => node.replaceWith(...[...node.childNodes]),
url = /(^|\s|\n|\/?>)(https?:\/\/[-A-Z0-9+&#/%?=()~_|!:,.;]*[-A-Z0-9+&#/%=~()_|])/gi,
urlRegExp = /https?:\/\/[^\p{C}\p{Z}]+[^\p{C}\p{Z}.]/gu,
// eslint-disable-next-line max-len
email = /(^|\s|\n|\/?>)((?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x21\x23-\x5b\x5d-\x7f]|\\[\x21\x23-\x5b\x5d-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x21-\x5a\x53-\x7f]|\\[\x21\x23-\x5b\x5d-\x7f])+)\]))/gi,
email = /(^|\r|\n|\p{C}\p{Z})((?:[^"(),.:;<>@[\]\\\p{C}\p{Z}]+(?:\.[^"(),.:;<>@[\]\\\p{C}\p{Z}]+)*|"(?:\\?[^"\\\p{C}\p{Z}])*")@[^@\p{C}\p{Z}]+[^@\p{C}\p{Z}.])/gui,
// rfc3966
tel = /(^|\s|\n|\/?>)(tel:(\+[0-9().-]+|[0-9*#().-]+(;phone-context=\+[0-9+().-]+)?))/g,
tel = /(tel:(\+[0-9().-]+|[0-9*#().-]+(;phone-context=\+[0-9+().-]+)?))/g,
// Strip tracking
/** TODO: implement other url strippers like from
@ -31,17 +50,21 @@ const
* https://github.com/M66B/FairEmail/blob/master/app/src/main/java/eu/faircode/email/UriHelper.java
*/
// eslint-disable-next-line max-len
stripParams = /^(utm_|ec_|fbclid|mc_eid|mkt_tok|_hsenc|vero_id|oly_enc_id|oly_anon_id|__s|Referrer|mailing|elq|bch|trc|ref|correlation_id|pd_|pf_)/i,
stripParams = /^(utm_|ec_|fbclid|mc_eid|mkt_tok|_hsenc|vero_id|oly_enc_id|oly_anon_id|__s|Referrer|mailing|elq|bch|trc|ref|correlation_id|pd_|pf_|email_hash)/i,
urlGetParam = (url, name) => new URL(url).searchParams.get(name) || url,
base64Url = data => atob(data.replace(/_/g,'/').replace(/-/g,'+')),
decode = decodeURIComponent,
stripTracking = url => {
try {
let nurl = url
// Copernica
.replace(/^.+\/(https%253A[^/?&]+).*$/i, (...m) => decode(decode(m[1])))
.replace(/tracking\.(printabout\.nl[^?]+)\?.*/i, (...m) => m[1])
.replace(/(zalando\.nl[^?]+)\?.*/i, (...m) => m[1])
.replace(/^.+(awstrack\.me|redditmail\.com)\/.+(https:%2F%2F[^/]+).*/i, (...m) => decodeURIComponent(m[2]))
.replace(/^.+(awstrack\.me|redditmail\.com)\/.+(https:%2F%2F[^/]+).*/i, (...m) => decode(m[2]))
.replace(/^.+(www\.google|safelinks\.protection\.outlook\.com|mailchimp\.com).+url=.+$/i,
() => urlGetParam(url, 'url'))
.replace(/^.+click\.godaddy\.com.+$/i, () => urlGetParam(url, 'redir'))
.replace(/^.+delivery-status\.com.+$/i, () => urlGetParam(url, 'fb'))
.replace(/^.+go\.dhlparcel\.nl.+\/([A-Za-z0-9_-]+)$/i, (...m) => base64Url(m[1]))
.replace(/^(.+mopinion\.com.+)\?.*$/i, (...m) => m[1])
@ -73,6 +96,93 @@ const
});
}
return url;
},
cleanCSS = source =>
source.trim().replace(/-(ms|webkit)-[^;]+(;|$)/g, '')
.replace(/white-space[^;]+(;|$)/g, '')
// Drop Microsoft Office style properties
// .replace(/mso-[^:;]+:[^;]+/gi, '')
,
/*
Parses given css string, and returns css object
keys as selectors and values are css rules
eliminates all css comments before parsing
@param source css string to be parsed
@return object css
*/
parseCSS = source => {
const css = [];
css.toString = () => css.reduce(
(ret, tmp) =>
ret + tmp.selector + ' {\n'
+ (tmp.type === 'media' ? tmp.subStyles.toString() : tmp.rules)
+ '}\n'
,
''
);
/**
* Given css array, parses it and then for every selector,
* prepends namespace to prevent css collision issues
*/
css.applyNamespace = namespace => css.forEach(obj => {
if (obj.type === 'media') {
obj.subStyles.applyNamespace(namespace);
} else {
obj.selector = obj.selector.split(',').map(selector =>
(namespace + ' .mail-body ' + selector.replace(/\./g, '.msg-'))
.replace(/\sbody/gi, '')
).join(',');
}
});
if (source) {
source = source
// strip comments
.replace(/\/\*[\s\S]*?\*\/|<!--|-->/gi, '')
// strip import statements
.replace(/@import .*?;/gi , '')
// strip keyframe statements
.replace(/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi, '');
// unified regex to match css & media queries together
let unified = /((\s*?(?:\/\*[\s\S]*?\*\/)?\s*?@media[\s\S]*?){([\s\S]*?)}\s*?})|(([\s\S]*?){([\s\S]*?)})/gi,
arr;
while (true) {
arr = unified.exec(source);
if (arr === null) {
break;
}
let selector = arr[arr[2] === undefined ? 5 : 2].split('\r\n').join('\n').trim()
// Never have more than a single line break in a row
.replace(/\n+/, "\n")
// Remove :root and html
.split(/\s+/g).map(item => item.replace(/^(:root|html)$/, '')).join(' ').trim();
// determine the type
if (selector.includes('@media')) {
// we have a media query
css.push({
selector: selector,
type: 'media',
subStyles: parseCSS(arr[3] + '\n}') //recursively parse media query inner css
});
} else if (selector && !selector.includes('@')) {
// we have standard css
css.push({
selector: selector,
rules: cleanCSS(arr[6])
});
}
}
}
return css;
};
export const
@ -88,28 +198,27 @@ export const
* @param {string} text
* @returns {string}
*/
cleanHtml = (html, oAttachments) => {
cleanHtml = (html, oAttachments, msgId) => {
let aColor;
const
debug = false, // Config()->Get('debug', 'enable', false);
detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'),
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
result = {
hasExternals: false
},
findAttachmentByCid = cid => oAttachments.findByCid(cid),
findLocationByCid = cid => {
const attachment = findAttachmentByCid(cid);
findAttachmentByCid = cId => oAttachments.findByCid(cId),
findLocationByCid = cId => {
const attachment = findAttachmentByCid(cId);
return attachment?.contentLocation ? attachment : 0;
},
// convert body attributes to CSS
tasks = {
link: value => {
if (/^#[a-fA-Z0-9]{3,6}$/.test(value)) {
tpl.content.querySelectorAll('a').forEach(node => node.style.color || (node.style.color = value))
}
},
link: value => aColor = value,
text: (value, node) => node.style.color = value,
topmargin: (value, node) => node.style.marginTop = pInt(value) + 'px',
leftmargin: (value, node) => node.style.marginLeft = pInt(value) + 'px',
@ -145,41 +254,65 @@ export const
// td
'colspan', 'rowspan', 'headers'
],
disallowedTags = [
'HEAD','STYLE','SVG','SCRIPT','TITLE','LINK','BASE','META',
'INPUT','OUTPUT','SELECT','BUTTON','TEXTAREA',
'BGSOUND','KEYGEN','SOURCE','OBJECT','EMBED','APPLET','IFRAME','FRAME','FRAMESET','VIDEO','AUDIO','AREA','MAP'
],
nonEmptyTags = [
'A','B','EM','I','SPAN','STRONG'
];
tpl.innerHTML = html
if (SettingsUserStore.allowStyles()) {
allowedAttributes.push('class');
} else {
msgId = 0;
}
tmpl.innerHTML = html
// .replace(/<pre[^>]*>[\s\S]*?<\/pre>/gi, pre => pre.replace(/\n/g, '\n<br>'))
.replace(/<!doctype[^>]*>/gi, '')
.replace(/<\?xml[^>]*\?>/gi, '')
// Not supported by <template> element
// .replace(/<!doctype[^>]*>/gi, '')
// .replace(/<\?xml[^>]*\?>/gi, '')
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
.replace(/<\/?(html|head)[^>]*>/gi, '')
// .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,' ')
.trim();
html = '';
// \MailSo\Base\HtmlUtils::ClearComments()
// https://github.com/the-djmaze/snappymail/issues/187
const nodeIterator = document.createNodeIterator(tpl.content, NodeFilter.SHOW_COMMENT);
// Strip all comments
const nodeIterator = document.createNodeIterator(tmpl.content, NodeFilter.SHOW_COMMENT);
while (nodeIterator.nextNode()) {
nodeIterator.referenceNode.remove();
}
tpl.content.querySelectorAll('*').forEach(oElement => {
tmpl.content.querySelectorAll(
disallowedTags
+ (0 < bqLevel ? ',' + (new Array(1 + bqLevel).fill('blockquote').join(' ')) : '')
).forEach(oElement => oElement.remove());
// https://github.com/the-djmaze/snappymail/issues/1125
tmpl.content.querySelectorAll('form,button').forEach(oElement => replaceWithChildren(oElement));
[...tmpl.content.querySelectorAll('*')].forEach(oElement => {
const name = oElement.tagName,
oStyle = oElement.style;
if ('STYLE' === name) {
let css = msgId ? parseCSS(oElement.textContent) : [];
if (css.length) {
css.applyNamespace(msgId);
css = css.toString();
if (SettingsUserStore.removeColors()) {
css = css.replace(/(background-)?color:[^};]+;?/g, '');
}
oElement.textContent = css;
} else {
oElement.remove();
}
return;
}
// \MailSo\Base\HtmlUtils::ClearTags()
if (disallowedTags.includes(name)
|| 'none' == oStyle.display
if ('none' == oStyle.display
|| 'hidden' == oStyle.visibility
// || (oStyle.lineHeight && 1 > parseFloat(oStyle.lineHeight)
// || (oStyle.maxHeight && 1 > parseFloat(oStyle.maxHeight)
@ -189,29 +322,24 @@ export const
oElement.remove();
return;
}
/*
// Idea to allow CSS
if ('STYLE' === name) {
msgId = '#rl-msg-061eb4d647771be4185943ce91f0039d';
oElement.textContent = oElement.textContent
.replace(/[^{}]+{/g, m => msgId + ' ' + m.replace(',', ', '+msgId+' '))
.replace(/(background-)color:[^};]+/g, '');
return;
}
*/
const aAttrsForRemove = [],
className = oElement.className,
hasAttribute = name => oElement.hasAttribute(name),
getAttribute = name => hasAttribute(name) ? oElement.getAttribute(name).trim() : '',
setAttribute = (name, value) => oElement.setAttribute(name, value),
delAttribute = name => oElement.removeAttribute(name);
delAttribute = name => {
let value = getAttribute(name);
oElement.removeAttribute(name);
return value;
};
if ('mail-body' === oElement.className) {
forEachObjectEntry(tasks, (name, cb) => {
if (hasAttribute(name)) {
cb(getAttribute(name), oElement);
delAttribute(name);
}
});
if ('mail-body' === className) {
forEachObjectEntry(tasks, (name, cb) =>
hasAttribute(name) && cb(delAttribute(name), oElement)
);
} else if (msgId && className) {
oElement.className = className.replace(/(^|\s+)/g, '$1msg-');
}
if (oElement.hasAttributes()) {
@ -229,32 +357,24 @@ export const
// if ('TABLE' === name || 'TD' === name || 'TH' === name) {
if (!oStyle.backgroundImage) {
value = oStyle.width;
if (hasAttribute('width')) {
if (!value) {
value = getAttribute('width');
if (1 < pInt(value)) {
oStyle.width = value.includes('%') ? value : value + 'px';
if ('TD' !== name && 'TH' !== name) {
['width','height'].forEach(key => {
if (hasAttribute(key)) {
value = delAttribute(key);
oStyle[key] || (oStyle[key] = value.includes('%') ? value : value + 'px');
}
}
delAttribute('width');
}
if (value && !value.includes('%')) {
oStyle.maxWidth = value;
if ('TD' !== name && 'TH' !== name) {
});
// Make width responsive
value = oStyle.width;
if (100 < parseInt(value,10) && !oStyle.maxWidth) {
oStyle.maxWidth = value;
oStyle.width = '100%';
}
}
if (hasAttribute('height')) {
value = getAttribute('height');
if (pInt(value)) {
oStyle.height = value.includes('%') ? value : value + 'px';
// Make height responsive
value = oStyle.removeProperty('height');
if (value && !oStyle.maxHeight) {
oStyle.maxHeight = value;
}
delAttribute('height');
}
value = oStyle.removeProperty('height');
if (value && !value.includes('%')) {
oStyle.maxHeight = value;
}
}
// } else
@ -269,10 +389,11 @@ export const
// setAttribute('rel', 'external nofollow noopener noreferrer');
}
setAttribute('tabindex', '-1');
aColor && !oElement.style.color && (oElement.style.color = aColor);
}
// if (['CENTER','FORM'].includes(name)) {
if ('FORM' === name || 'O:P' === name || (nonEmptyTags.includes(name) && ('' == oElement.textContent.trim()))) {
if ('O:P' === name || (nonEmptyTags.includes(name) && ('' == oElement.textContent.trim()))) {
('A' !== name || !oElement.querySelector('IMG')) && replaceWithChildren(oElement);
return;
}
@ -286,8 +407,7 @@ export const
let skipStyle = false;
if (hasAttribute('src')) {
value = stripTracking(getAttribute('src'));
delAttribute('src');
value = stripTracking(delAttribute('src'));
if ('IMG' === name) {
oElement.loading = 'lazy';
@ -348,18 +468,15 @@ export const
}
if (hasAttribute('background')) {
oStyle.backgroundImage = 'url("' + getAttribute('background') + '")';
delAttribute('background');
oStyle.backgroundImage = 'url("' + delAttribute('background') + '")';
}
if (hasAttribute('bgcolor')) {
oStyle.backgroundColor = getAttribute('bgcolor');
delAttribute('bgcolor');
oStyle.backgroundColor = delAttribute('bgcolor');
}
if (hasAttribute('color')) {
oStyle.color = getAttribute('color');
delAttribute('color');
oStyle.color = delAttribute('color');
}
if (!skipStyle) {
@ -410,11 +527,12 @@ export const
if (urls_broken.length) {
setAttribute('data-x-style-broken-urls', JSON.stringify(urls_broken));
}
/*
// https://github.com/the-djmaze/snappymail/issues/1082
if (11 > pInt(oStyle.fontSize)) {
oStyle.removeProperty('font-size');
}
*/
// Removes background and color
// Many e-mails incorrectly only define one, not both
// And in dark theme mode this kills the readability
@ -424,8 +542,7 @@ export const
oStyle.removeProperty('color');
}
// Drop Microsoft Office style properties
// oStyle.cssText = oStyle.cssText.replace(/mso-[^:;]+:[^;]+/gi, '');
oStyle.cssText = cleanCSS(oStyle.cssText);
}
if (debug && aAttrsForRemove.length) {
@ -433,8 +550,10 @@ export const
}
});
// return tpl.content.firstChild;
result.html = tpl.innerHTML.trim();
blockquoteSwitcher();
// return tmpl.content.firstChild;
result.html = tmpl.innerHTML.trim();
return result;
},
@ -445,7 +564,7 @@ export const
htmlToPlain = html => {
const
hr = '⎯'.repeat(64),
forEach = (selector, fn) => tpl.content.querySelectorAll(selector).forEach(fn),
forEach = (selector, fn) => tmpl.content.querySelectorAll(selector).forEach(fn),
blockquotes = node => {
let bq;
while ((bq = node.querySelector('blockquote'))) {
@ -473,7 +592,7 @@ export const
html = html.replace(/\n*<\/(div|tr)(\s[\s\S]*?)?>\n*/gi, '\n');
}
tpl.innerHTML = html
tmpl.innerHTML = html
.replace(/<t[dh](\s[\s\S]*?)?>/gi, '\t')
.replace(/<\/tr(\s[\s\S]*?)?>/gi, '\n');
@ -523,16 +642,16 @@ export const
forEach('i,em', i => i.replaceWith(`*${i.textContent}*`));
// Convert line-breaks
tpl.innerHTML = tpl.innerHTML
tmpl.innerHTML = tmpl.innerHTML
.replace(/\n{3,}/gm, '\n\n')
.replace(/\n<br[^>]*>/g, '\n')
.replace(/<br[^>]*>\n/g, '\n');
forEach('br', br => br.replaceWith('\n'));
// Blockquotes must be last
blockquotes(tpl.content);
blockquotes(tmpl.content);
return (tpl.content.textContent || '').trim();
return (tmpl.content.textContent || '').trim();
},
/**
@ -543,7 +662,9 @@ export const
plainToHtml = plain => {
plain = plain.toString()
.replace(/\r/g, '')
.replace(/^>[> ]>+/gm, ([match]) => (match ? match.replace(/[ ]+/g, '') : match));
.replace(/^>[> ]>+/gm, ([match]) => (match ? match.replace(/[ ]+/g, '') : match))
// https://github.com/the-djmaze/snappymail/issues/900
.replace(/\u2028/g,' ');
let bIn = false,
bDo = true,
@ -584,21 +705,40 @@ export const
aText = aNextText;
} while (bDo);
return aText.join('\n')
tmpl.innerHTML = aText.join('\n')
// .replace(/~~~\/blockquote~~~\n~~~blockquote~~~/g, '\n')
.replace(/&/g, '&amp;')
.replace(/>/g, '&gt;')
.replace(/</g, '&lt;')
.replace(url, (...m) => {
m[2] = stripTracking(m[2]);
return `${m[1]}<a href="${m[2]}" target="_blank">${m[2]}</a>`;
.replace(urlRegExp, (...m) => {
m[0] = stripTracking(m[0]);
return `<a href="${m[0]}" target="_blank">${m[0]}</a>`;
})
.replace(email, '$1<a href="mailto:$2">$2</a>')
.replace(tel, '$1<a href="$2">$2</a>')
.replace(tel, '<a href="$1">$1</a>')
.replace(/~~~blockquote~~~\s*/g, '<blockquote>')
.replace(/\s*~~~\/blockquote~~~/g, '</blockquote>')
.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 {
/**
@ -614,29 +754,22 @@ export class HtmlEditor {
this.onModeChange = onModeChange;
if (element) {
let editor;
onReady = onReady ? [onReady] : [];
this.onReady = fn => onReady.push(fn);
const readyCallback = () => {
// 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());
};
if (rl.createWYSIWYG) {
editor = rl.createWYSIWYG(element, readyCallback);
}
if (!editor) {
editor = new SquireUI(element);
setTimeout(readyCallback, 1);
}
editor.on('blur', () => this.blurTrigger());
editor.on('focus', () => this.blurTimer && clearTimeout(this.blurTimer));
editor.on('mode', () => {
this.blurTrigger();
this.onModeChange?.(!this.isPlain());
});
}
}

View file

@ -1,13 +1,13 @@
import { pInt } from 'Common/Utils';
import { Settings } from 'Common/Globals';
import { doc, Settings } from 'Common/Globals';
const
BASE = doc.location.pathname.replace(/\/+$/,'') + '/',
HASH_PREFIX = '#/',
SERVER_PREFIX = './?',
adminPath = () => rl.adminArea() && !Settings.app('adminHostUse'),
adminPath = () => rl.adminArea() && !Settings.app('adminHost'),
prefix = () => SERVER_PREFIX + (adminPath() ? Settings.app('adminPath') : '');
prefix = () => BASE + '?' + (adminPath() ? Settings.app('adminPath') : '');
export const
SUB_QUERY_PREFIX = '&q[]=',
@ -21,7 +21,7 @@ export const
/**
* @returns {string}
*/
logoutLink = () => adminPath() ? prefix() : './',
logoutLink = () => adminPath() ? prefix() : BASE,
/**
* @param {string} type
@ -30,8 +30,8 @@ export const
* @returns {string}
*/
serverRequestRaw = (type, hash) =>
SERVER_PREFIX + '/Raw/' + SUB_QUERY_PREFIX + '/'
+ '0/' // Settings.get('AccountHash') ?
BASE + '?/Raw/' + SUB_QUERY_PREFIX + '/'
+ '0/' // Settings.get('accountHash') ?
+ (type
? type + '/' + (hash ? SUB_QUERY_PREFIX + '/' + hash : '')
: ''),
@ -45,8 +45,9 @@ export const
serverRequestRaw('Download', download, customSpecSuffix),
proxy = url =>
SERVER_PREFIX + '/ProxyExternal/'
BASE + '?/ProxyExternal/'
+ btoa(url.replace(/ /g, '%20')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
// + b64EncodeJSONSafe(url.replace(/ /g, '%20')),
/**
* @param {string} type
@ -54,13 +55,16 @@ export const
*/
serverRequest = type => prefix() + '/' + type + '/' + SUB_QUERY_PREFIX + '/0/',
// Is '?/Css/0/Admin' needed?
cssLink = theme => BASE + '?/Css/0/User/-/' + encodeURI(theme) + '/-/' + Date.now() + '/Hash/-/Json/',
/**
* @param {string} lang
* @param {boolean} isAdmin
* @returns {string}
*/
langLink = (lang, isAdmin) =>
SERVER_PREFIX + '/Lang/0/' + (isAdmin ? 'Admin' : 'App')
BASE + '?/Lang/0/' + (isAdmin ? 'Admin' : 'App')
+ '/' + encodeURI(lang)
+ '/' + Settings.app('version') + '/',
@ -75,6 +79,10 @@ export const
* @returns {string}
*/
themePreviewLink = theme => {
if (theme.endsWith('@nextcloud')) {
theme = theme.slice(0, theme.length - 10).trim();
return parent.OC.webroot + '/themes/' + encodeURI(theme) + '/snappymail/preview.png';
}
let path = 'webVersionPath';
if (theme.endsWith('@custom')) {
theme = theme.slice(0, theme.length - 7).trim();

View file

@ -87,8 +87,7 @@ export class Selector {
* Below code is used to keep checked/focused/selected states when array is refreshed.
*/
let aCache = [],
aCheckedCache = [],
let aCheckedCache = [],
mFocused = null,
mSelected = null;
@ -99,7 +98,6 @@ export class Selector {
items.forEach(item => {
const uid = this.getItemUid(item);
if (uid) {
aCache.push(uid);
item.checked() && aCheckedCache.push(uid);
if (!mFocused && item.focused()) {
mFocused = uid;
@ -118,8 +116,7 @@ export class Selector {
koList.subscribe(aItems => {
selectedItemUseCallback = false;
koFocusedItem(null);
koSelectedItem(null);
this.unselect();
if (isArray(aItems)) {
let temp,
@ -182,7 +179,6 @@ export class Selector {
}
}
aCache = [];
aCheckedCache = [];
mFocused = null;
mSelected = null;

View file

@ -1,9 +1,10 @@
import ko from 'ko';
import { Notification, UploadErrorCode } from 'Common/Enums';
import { Notifications, UploadErrorCode } from 'Common/Enums';
import { langLink } from 'Common/Links';
import { doc, createElement } from 'Common/Globals';
import { getKeyByValue, forEachObjectEntry } from 'Common/Utils';
import { pInt } from 'Common/Utils';
import { LanguageStore } from 'Stores/Language';
let I18N_DATA = {};
@ -11,8 +12,8 @@ const
init = () => {
if (rl.I18N) {
I18N_DATA = rl.I18N;
Date.defineRelativeTimeFormat(rl.relativeTime || {});
rl.I18N = null;
doc.documentElement.dir = I18N_DATA.LANG_DIR;
return 1;
}
},
@ -20,13 +21,40 @@ const
i18nKey = key => key.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(),
getNotificationMessage = code => {
let key = getKeyByValue(Notification, code);
let key = getKeyByValue(Notifications, code);
return key ? I18N_DATA.NOTIFICATIONS[i18nKey(key).replace('_NOTIFICATION', '_ERROR')] : '';
};
},
fromNow = date => relativeTime(Math.round((date.getTime() - Date.now()) / 1000));
export const
translateTrigger = ko.observable(false),
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat
// see /snappymail/v/0.0.0/app/localization/relativetimeformat/
relativeTime = seconds => {
let unit = 'second',
t = [[60,'minute'],[3600,'hour'],[86400,'day'],[2628000,'month'],[31536000,'year']],
i = 5,
abs = Math.abs(seconds);
while (i--) {
if (t[i][0] <= abs) {
seconds = Math.round(seconds / t[i][0]);
unit = t[i][1];
break;
}
}
if (Intl.RelativeTimeFormat) {
let rtf = new Intl.RelativeTimeFormat(doc.documentElement.lang);
return rtf.format(seconds, unit);
}
// Safari < 14
abs = Math.abs(seconds);
let rtf = rl.relativeTime.long[unit][0 > seconds ? 'past' : 'future'],
plural = rl.relativeTime.plural(abs);
return (rtf[plural] || rtf).replace('{0}', abs);
},
/**
* @param {string} key
* @param {Object=} valueList
@ -77,27 +105,31 @@ export const
time = 0 < timeStampInUTC ? Math.min(now, timeStampInUTC * 1000) : (0 === timeStampInUTC ? now : 0);
if (31536000000 < time) {
const m = new Date(time);
const m = new Date(time), h = LanguageStore.hourCycle();
switch (formatStr) {
case 'FROMNOW':
return m.fromNow();
case 'SHORT': {
if (4 >= (now - time) / 3600000)
return m.fromNow();
const mt = m.getTime(), date = new Date,
return fromNow(m);
case 'AUTO': {
// 4 hours
if (14400000 >= now - time)
return fromNow(m);
const date = new Date,
dt = date.setHours(0,0,0,0);
if (mt > dt)
return i18n('MESSAGE_LIST/TODAY_AT', {TIME: m.format('LT')});
if (mt > dt - 86400000)
return i18n('MESSAGE_LIST/YESTERDAY_AT', {TIME: m.format('LT')});
if (date.getFullYear() === m.getFullYear())
return m.format('d M');
return m.format('LL');
return (time > dt - 86400000)
? i18n(
time > dt ? 'MESSAGE_LIST/TODAY_AT' : 'MESSAGE_LIST/YESTERDAY_AT',
{TIME: m.format('LT',0,h)}
)
: m.format(
date.getFullYear() === m.getFullYear()
? {day: '2-digit', month: 'short', hour: 'numeric', minute: 'numeric'}
: {dateStyle: 'medium', timeStyle: 'short'}
, 0, h);
}
case 'FULL':
return m.format('LLL');
return m.format('LLL',0,h);
default:
return m.format(formatStr);
return m.format(formatStr,0,h);
}
}
@ -107,7 +139,7 @@ export const
timeToNode = (element, time) => {
try {
if (time) {
element.dateTime = (new Date(time * 1000)).format('Y-m-d\\TH:i:s');
element.dateTime = new Date(time * 1000).toISOString();
} else {
time = Date.parse(element.dateTime) / 1000;
}
@ -115,10 +147,9 @@ export const
let key = element.dataset.momentFormat;
if (key) {
element.textContent = timestampToString(time, key);
}
if ((key = element.dataset.momentFormatTitle)) {
element.title = timestampToString(time, key);
if ('FULL' !== key && 'FROMNOW' !== key) {
element.title = timestampToString(time, 'FULL');
}
}
} catch (e) {
// prevent knockout crashes
@ -126,9 +157,7 @@ export const
}
},
reloadTime = () => setTimeout(() =>
doc.querySelectorAll('time').forEach(element => timeToNode(element))
, 1),
reloadTime = () => doc.querySelectorAll('time').forEach(element => timeToNode(element)),
/**
* @param {Function} startCallback
@ -148,7 +177,7 @@ export const
*/
getNotification = (code, message = '', defCode = 0) => {
code = pInt(code);
if (Notification.ClientViewError === code && message) {
if (Notifications.ClientViewError === code && message) {
return message;
}
@ -170,15 +199,15 @@ export const
* @param {boolean} admin
* @param {string} language
*/
translatorReload = (admin, language) =>
translatorReload = (language, admin) =>
new Promise((resolve, reject) => {
const script = createElement('script');
script.onload = () => {
// reload the data
if (init()) {
i18nToNodes(doc);
admin || reloadTime();
translateTrigger(!translateTrigger());
// admin || reloadTime();
}
script.remove();
resolve();

View file

@ -1,9 +1,3 @@
import { SaveSettingStatus } from 'Common/Enums';
import { elementById } from 'Common/Globals';
let __themeTimer = 0,
__themeJson = null;
export const
isArray = Array.isArray,
arrayLength = array => isArray(array) && array.length,
@ -19,52 +13,19 @@ export const
return isNaN(value) || !isFinite(value) ? defaultValue : value;
},
convertThemeName = theme => theme
.replace(/@custom$/, '')
.replace(/([A-Z])/g, ' $1')
.replace(/[^a-zA-Z0-9]+/g, ' ')
.trim(),
defaultOptionsAfterRender = (domItem, item) =>
item && undefined !== item.disabled && domItem?.classList.toggle('disabled', domItem.disabled = item.disabled),
// unescape(encodeURIComponent()) makes the UTF-16 DOMString to an UTF-8 string
b64EncodeJSON = data => btoa(unescape(encodeURIComponent(JSON.stringify(data)))),
b64Encode = data => btoa(unescape(encodeURIComponent(data))),
/* // Without deprecated 'unescape':
b64EncodeJSON = data => btoa(encodeURIComponent(JSON.stringify(data)).replace(
b64Encode = data => btoa(encodeURIComponent(data).replace(
/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode('0x' + p1)
)),
*/
b64EncodeJSON = data => b64Encode(JSON.stringify(data)),
b64EncodeJSONSafe = data => b64EncodeJSON(data).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
changeTheme = (value, themeTrigger = ()=>0) => {
const themeStyle = elementById('app-theme-style'),
clearTimer = () => {
__themeTimer = setTimeout(() => themeTrigger(SaveSettingStatus.Idle), 1000);
__themeJson = null;
},
url = themeStyle.dataset.href.replace(/(Admin|User)\/-\/[^/]+\//, '$1/-/' + value + '/') + 'Json/';
clearTimeout(__themeTimer);
themeTrigger(SaveSettingStatus.Saving);
if (__themeJson) {
__themeJson.abort();
}
let init = {};
if (window.AbortController) {
__themeJson = new AbortController();
init.signal = __themeJson.signal;
}
rl.fetchJSON(url, init)
.then(data => {
if (2 === arrayLength(data)) {
themeStyle.textContent = data[1];
themeTrigger(SaveSettingStatus.Success);
}
})
.then(clearTimer, clearTimer);
},
getKeyByValue = (o, v) => Object.keys(o).find(key => o[key] === v);

View file

@ -1,20 +1,21 @@
import { MessageFlagsCache } from 'Common/Cache';
import { Notification } from 'Common/Enums';
import { Notifications } from 'Common/Enums';
import { MessageSetAction, ComposeType/*, FolderType*/ } from 'Common/EnumsUser';
import { doc, createElement, elementById, dropdowns, dropdownVisibility, SettingsGet } from 'Common/Globals';
import { doc, createElement, elementById, dropdowns, dropdownVisibility, SettingsGet, leftPanelDisabled } from 'Common/Globals';
import { plainToHtml } from 'Common/Html';
import { getNotification } from 'Common/Translator';
import { EmailModel } from 'Model/Email';
import { MessageModel } from 'Model/Message';
import { EmailCollectionModel } from 'Model/EmailCollection';
import { MessageUserStore } from 'Stores/User/Message';
import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { SettingsUserStore } from 'Stores/User/Settings';
import * as Local from 'Storage/Client';
import { ThemeStore } from 'Stores/Theme';
import Remote from 'Remote/User/Fetch';
import { attachmentDownload } from 'Common/Links';
export const
moveAction = ko.observable(false),
dropdownsDetectVisibility = (() =>
dropdownVisibility(!!dropdowns.find(item => item.classList.contains('show')))
).debounce(50),
@ -40,6 +41,29 @@ download = (link, name = "") => {
}
},
downloadZip = (name, hashes, onError, fTrigger, folder) => {
if (hashes.length) {
let params = {
target: 'zip',
filename: name,
hashes: hashes
};
if (!onError) {
onError = () => alert('Download failed');
}
if (folder) {
params.folder = folder;
// params.uids = uids;
}
Remote.post('AttachmentsActions', fTrigger || null, params)
.then(result => {
let hash = result?.Result?.fileHash;
hash ? download(attachmentDownload(hash), hash+'.zip') : onError();
})
.catch(onError);
}
},
/**
* @returns {function}
*/
@ -48,14 +72,15 @@ computedPaginatorHelper = (koCurrentPage, koPageCount) => {
const currentPage = koCurrentPage(),
pageCount = koPageCount(),
result = [],
lang = doc.documentElement.lang,
fAdd = (index, push = true, customName = '') => {
const data = {
current: index === currentPage,
name: customName ? customName.toString() : index.toString(),
custom: !!customName,
title: customName ? index.toString() : '',
value: index.toString()
};
const name = index.toLocaleString(lang),
data = {
current: index === currentPage,
name: customName || name,
title: customName ? name : '',
value: index
};
push ? result.push(data) : result.unshift(data);
};
@ -131,29 +156,15 @@ mailToHelper = mailToUrl => {
mailToUrl = mailToUrl.slice(7).split('?');
const
email = mailToUrl[0],
email = decodeURIComponent(mailToUrl[0]),
params = new URLSearchParams(mailToUrl[1]),
toEmailModel = value => null != value ? EmailModel.parseEmailLine(value) : null;
to = params.get('to'),
toEmailModel = value => EmailCollectionModel.fromString(value);
showMessageComposer([
ComposeType.Empty,
null,
params.get('to')
? Object.values(
toEmailModel(email + ',' + params.get('to')).reduce((result, value) => {
if (value) {
if (result[value.email]) {
if (!result[value.email].name) {
result[value.email] = value;
}
} else {
result[value.email] = value;
}
}
return result;
}, {})
)
: EmailModel.parseEmailLine(email),
toEmailModel(to ? email + ',' + to : email),
toEmailModel(params.get('cc')),
toEmailModel(params.get('bcc')),
params.get('subject'),
@ -171,37 +182,24 @@ showMessageComposer = (params = []) =>
rl.app.showMessageComposer(params);
},
setLayoutResizer = (source, target, sClientSideKeyName, mode) =>
setLayoutResizer = (source, sClientSideKeyName, mode) =>
{
if (source.layoutResizer && source.layoutResizer.mode != mode) {
target.removeAttribute('style');
source.removeAttribute('style');
}
source.observer?.disconnect();
// source.classList.toggle('resizable', mode);
if (mode) {
const length = Local.get(sClientSideKeyName + mode) || SettingsGet('Resizer' + sClientSideKeyName + mode),
setTargetPos = mode => {
let value;
if ('Width' == mode) {
value = source.offsetWidth;
target.style.left = value + 'px';
} else {
value = source.offsetHeight;
target.style.top = (4 + source.offsetTop + value) + 'px';
}
return value;
};
const length = Local.get(sClientSideKeyName + mode) || SettingsGet('Resizer' + sClientSideKeyName + mode);
if (length) {
source.style[mode.toLowerCase()] = length + 'px';
setTargetPos(mode);
}
if (!source.layoutResizer) {
const resizer = createElement('div', {'class':'resizer'}),
save = (data => Remote.saveSettings(0, data)).debounce(500),
size = {},
store = () => {
const value = setTargetPos(resizer.mode),
const value = ('Width' == resizer.mode) ? source.offsetWidth : source.offsetHeight,
prop = resizer.key + resizer.mode;
(value == Local.get(prop)) || Local.set(prop, value);
(value == SettingsGet('Resizer' + prop)) || save({['Resizer' + prop]: value});
@ -248,111 +246,80 @@ setLayoutResizer = (source, target, sClientSideKeyName, mode) =>
}
},
viewMessage = (oMessage, popup) => {
if (popup) {
oMessage.viewPopupMessage();
} else {
MessageUserStore.error('');
let id = 'rl-msg-' + oMessage.hash,
body = oMessage.body || elementById(id);
if (!body) {
body = createElement('div',{
id:id,
hidden:'',
class:'b-text-part'
+ (oMessage.pgpSigned() ? ' openpgp-signed' : '')
+ (oMessage.pgpEncrypted() ? ' openpgp-encrypted' : '')
});
MessageUserStore.purgeCache();
}
body.message = oMessage;
oMessage.body = body;
if (!SettingsUserStore.viewHTML() || !oMessage.viewHtml()) {
oMessage.viewPlain();
}
MessageUserStore.bodiesDom().append(body);
MessageUserStore.loading(false);
oMessage.body.hidden = false;
if (oMessage.isUnseen()) {
MessageUserStore.MessageSeenTimer = setTimeout(
() => MessagelistUserStore.setAction(oMessage.folder, MessageSetAction.SetSeen, [oMessage]),
SettingsUserStore.messageReadDelay() * 1000 // seconds
);
}
}
},
populateMessageBody = (oMessage, popup) => {
if (oMessage) {
popup || MessageUserStore.message(oMessage);
popup || MessageUserStore.loading(true);
Remote.message((iError, oData/*, bCached*/) => {
if (iError) {
if (Notification.RequestAborted !== iError && !popup) {
MessageUserStore.message(null);
MessageUserStore.error(getNotification(iError));
}
} else {
let json = oData?.Result;
if (
json &&
MessageModel.validJson(json) &&
oMessage.folder === json.Folder
) {
const threads = oMessage.threads(),
isNew = !popup && oMessage.uid != json.Uid && threads.includes(json.Uid),
messagesDom = MessageUserStore.bodiesDom();
if (isNew) {
oMessage = MessageModel.reviveFromJson(json);
if (oMessage) {
oMessage.threads(threads);
MessageFlagsCache.initMessage(oMessage);
// Set clone
oMessage = MessageModel.fromMessageListItem(oMessage);
}
MessageUserStore.message(oMessage);
if (oMessage.body) {
viewMessage(oMessage, popup);
} else {
popup || MessageUserStore.loading(true);
Remote.message((iError, oData/*, bCached*/) => {
if (iError) {
if (Notifications.RequestAborted !== iError && !popup) {
MessageUserStore.message(null);
MessageUserStore.error(getNotification(iError));
}
if (oMessage && oMessage.uid == json.Uid) {
popup || MessageUserStore.error('');
} else {
let json = oData?.Result;
if (json
&& oMessage.hash === json.hash
// && oMessage.folder === json.folder
// && oMessage.uid == json.uid
&& oMessage.revivePropertiesFromJson(json)
) {
/*
if (bCached) {
delete json.Flags;
delete json.flags;
}
oMessage.body.remove();
*/
isNew || oMessage.revivePropertiesFromJson(json);
if (messagesDom) {
let id = 'rl-msg-' + oMessage.hash.replace(/[^a-zA-Z0-9]/g, ''),
body = elementById(id);
if (body) {
oMessage.body = body;
oMessage.isHtml(body.classList.contains('html'));
oMessage.hasImages(body.rlHasImages);
} else {
body = Element.fromHTML('<div id="' + id + '" hidden="" class="b-text-part '
+ (oMessage.pgpSigned() ? ' openpgp-signed' : '')
+ (oMessage.pgpEncrypted() ? ' openpgp-encrypted' : '')
+ '">'
+ '</div>');
oMessage.body = body;
if (!SettingsUserStore.viewHTML() || !oMessage.viewHtml()) {
oMessage.viewPlain();
}
MessageUserStore.purgeMessageBodyCache();
}
messagesDom.append(body);
popup || (oMessage.body.hidden = false);
popup && oMessage.viewPopupMessage();
}
MessageFlagsCache.initMessage(oMessage);
if (oMessage.isUnseen()) {
MessageUserStore.MessageSeenTimer = setTimeout(
() => MessagelistUserStore.setAction(oMessage.folder, MessageSetAction.SetSeen, [oMessage]),
SettingsUserStore.messageReadDelay() * 1000 // seconds
);
}
if (isNew) {
let selectedMessage = MessagelistUserStore.selectedMessage();
if (
selectedMessage &&
(oMessage.folder !== selectedMessage.folder || oMessage.uid != selectedMessage.uid)
) {
MessagelistUserStore.selectedMessage(null);
if (1 === MessagelistUserStore.length) {
MessagelistUserStore.focusedMessage(null);
}
} else if (!selectedMessage) {
selectedMessage = MessagelistUserStore.find(
subMessage =>
subMessage &&
subMessage.folder === oMessage.folder &&
subMessage.uid == oMessage.uid
);
if (selectedMessage) {
MessagelistUserStore.selectedMessage(selectedMessage);
MessagelistUserStore.focusedMessage(selectedMessage);
}
}
}
viewMessage(oMessage, popup);
}
}
}
popup || MessageUserStore.loading(false);
}, oMessage.folder, oMessage.uid);
popup || MessageUserStore.loading(false);
}, oMessage.folder, oMessage.uid);
}
}
};
leftPanelDisabled.subscribe(value => value && moveAction(false));
moveAction.subscribe(value => value && leftPanelDisabled(false));

View file

@ -1,5 +1,7 @@
export class CheckboxComponent {
constructor(params = {}) {
this.name = params.name;
this.value = ko.isObservable(params.value) ? params.value
: ko.observable(!!params.value);
@ -7,7 +9,6 @@ export class CheckboxComponent {
: ko.observable(undefined === params.enable || !!params.enable);
this.label = params.label;
this.inline = params.inline;
}
click() {

View file

@ -1,8 +1,29 @@
import { doc, createElement, addEventsListeners } from 'Common/Globals';
import { EmailModel } from 'Model/Email';
import { EmailModel, addressparser } from 'Model/Email';
const contentType = 'snappymail/emailaddress',
getAddressKey = li => li?.emailaddress?.key;
getAddressKey = li => li?.emailaddress?.key,
parseEmailLine = line => addressparser(line).map(item =>
(item.name || item.email)
? new EmailModel(item.email, item.name) : null
).filter(v => v),
splitEmailLine = line => {
const result = [];
let exists = false;
addressparser(line).forEach(item => {
const address = (item.name || item.email)
? new EmailModel(item.email, item.name)
: null;
if (address?.email) {
exists = true;
}
result.push(address ? address.toLine() : item.name);
});
return exists ? result : null;
};
let dragAddress, datalist;
@ -154,8 +175,8 @@ export class EmailAddressesComponent {
if (val) {
const self = this,
v = val.trim(),
hook = (v && [',', ';', '\n'].includes(v.slice(-1))) ? EmailModel.splitEmailLine(val) : null,
values = (hook || [val]).map(value => EmailModel.parseEmailLine(value))
hook = (v && [',', ';', '\n'].includes(v.slice(-1))) ? splitEmailLine(val) : null,
values = (hook || [val]).map(value => parseEmailLine(value))
.flat(Infinity)
.map(item => (item.toLine ? [item.toLine(), item] : [item, null]));

View file

@ -14,12 +14,13 @@ const
createElement = name => doc.createElement(name),
tpl = createElement('template'),
clr = createElement('input'),
trimLines = html => html.trim().replace(/^(<div>\s*<br\s*\/?>\s*<\/div>)+/, '').trim(),
htmlToPlain = html => rl.Utils.htmlToPlain(html).trim(),
plainToHtml = text => rl.Utils.plainToHtml(text),
forEachObjectValue = (obj, fn) => Object.values(obj).forEach(fn),
getFragmentOfChildren = parent => {
let frag = doc.createDocumentFragment();
frag.append(...parent.childNodes);
@ -28,11 +29,6 @@ const
SquireDefaultConfig = {
/*
blockTag: 'P',
undo: {
documentSizeThreshold: -1, // -1 means no threshold
undoLimit: -1 // -1 means no limit
},
addLinks: true // allow_smart_html_links
*/
sanitizeToDOMFragment: (html, isPaste/*, squire*/) => {
@ -61,18 +57,19 @@ const
}
};
clr.type = "color";
clr.style.display = 'none';
doc.body.append(clr);
class SquireUI
{
constructor(container) {
const
doClr = name => () => {
clr = createElement('input'),
doClr = name => input => {
// https://github.com/the-djmaze/snappymail/issues/826
clr.style.left = (input.offsetLeft + input.parentNode.offsetLeft) + 'px';
clr.style.width = input.offsetWidth + 'px';
clr.value = '';
clr.onchange = () => squire.setStyle({[name]:clr.value});
clr.click();
setTimeout(()=>clr.click(),1);
},
actions = {
@ -84,8 +81,7 @@ class SquireUI
[i18n('SETTINGS_GENERAL/EDITOR_HTML'),'wysiwyg'],
[i18n('SETTINGS_GENERAL/EDITOR_PLAIN'),'plain']
],
cmd: s => this.setMode('plain' == s.value ? 'plain' : 'wysiwyg'),
hint: i18n('EDITOR/TEXT_SWITCHER_PLAIN_TEXT', 'Plain')
cmd: s => this.setMode('plain' == s.value ? 'plain' : 'wysiwyg')
}
},
font: {
@ -112,19 +108,44 @@ class SquireUI
},
fontSize: {
select: ['11px','13px','16px','20px','24px','30px'],
defaultValueIndex: 2,
cmd: s => squire.setStyle({ fontSize: s.value })
// TODO: maybe consider using https://developer.mozilla.org/en-US/docs/Web/CSS/font-size#values
// example:
// select: ['xx-small', 'x-small',' small',' medium', 'large', 'x-large', 'xx-large', 'xxx-large'],
// defaultValueIndex: 3,
},
// dir: {
// select: [
// [i18n('EDITOR/DIR_LTR', 'LTR'),'ltr'],
// [i18n('EDITOR/DIR_RTL', 'RTL'),'rtl'],
// [i18n('EDITOR/DIR_AUTO', 'Auto'),'auto'],
// ['',''],
// ],
// cmd: s => {
// squire.setAttribute('dir', s.value || null);
// // squire.setStyle({ 'unicode-bidi': 'plaintext' });
// }
// }
},
dir: {
dir_ltr: {
html: '⁋',
cmd: () => squire.bidi('ltr')
},
dir_rtl: {
html: '¶',
cmd: () => squire.bidi('rtl')
}
},
colors: {
textColor: {
html: 'A<sub>▾</sub>',
cmd: doClr('color'),
hint: 'Text color'
cmd: doClr('color')
},
backgroundColor: {
html: '🎨', /* ▧ */
cmd: doClr('backgroundColor'),
hint: 'Background color'
cmd: doClr('backgroundColor')
},
},
inline: {
@ -132,37 +153,37 @@ class SquireUI
html: 'B',
cmd: () => this.doAction('bold'),
key: 'B',
hint: 'Bold'
matches: 'B,STRONT'
},
italic: {
html: 'I',
cmd: () => this.doAction('italic'),
key: 'I',
hint: 'Italic'
matches: 'I'
},
underline: {
html: '<u>U</u>',
cmd: () => this.doAction('underline'),
key: 'U',
hint: 'Underline'
matches: 'U'
},
strike: {
html: '<s>S</s>',
cmd: () => this.doAction('strikethrough'),
key: 'Shift + 7',
hint: 'Strikethrough'
matches: 'S'
},
sub: {
html: 'Xₙ',
cmd: () => this.doAction('subscript'),
key: 'Shift + 5',
hint: 'Subscript'
matches: 'SUB'
},
sup: {
html: 'Xⁿ',
cmd: () => this.doAction('superscript'),
key: 'Shift + 6',
hint: 'Superscript'
matches: 'SUP'
}
},
block: {
@ -170,13 +191,13 @@ class SquireUI
html: '#',
cmd: () => this.doList('OL'),
key: 'Shift + 8',
hint: 'Ordered list'
matches: 'OL'
},
ul: {
html: '⋮',
cmd: () => this.doList('UL'),
key: 'Shift + 9',
hint: 'Unordered list'
matches: 'UL'
},
quote: {
html: '"',
@ -184,19 +205,17 @@ class SquireUI
let parent = squire.getSelectionClosest('UL,OL,BLOCKQUOTE')?.nodeName;
('BLOCKQUOTE' == parent) ? squire.decreaseQuoteLevel() : squire.increaseQuoteLevel();
},
hint: 'Blockquote'
matches: 'BLOCKQUOTE'
},
indentDecrease: {
html: '⇤',
cmd: () => squire.changeIndentationLevel('decrease'),
key: ']',
hint: 'Decrease indent'
key: ']'
},
indentIncrease: {
html: '⇥',
cmd: () => squire.changeIndentationLevel('increase'),
key: '[',
hint: 'Increase indent'
key: '['
}
},
targets: {
@ -209,21 +228,21 @@ class SquireUI
url.length ? squire.makeLink(url) : (node && squire.removeLink());
}
},
hint: 'Link'
matches: 'A'
},
imageUrl: {
html: '🖼️',
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 && squire.detach(node));
},
hint: 'Image URL'
matches: 'IMG'
},
imageUpload: {
html: '📂️',
cmd: () => browseImage.click(),
hint: 'Image select',
matches: 'IMG'
}
},
/*
@ -235,19 +254,19 @@ class SquireUI
undo: {
html: '↶',
cmd: () => squire.undo(),
key: 'Z',
hint: 'Undo'
key: 'Z'
},
redo: {
html: '↷',
cmd: () => squire.redo(),
key: 'Y',
hint: 'Redo'
key: 'Y'
},
source: {
html: '👁',
cmd: () => this.setMode('source' == this.mode ? 'wysiwyg' : 'source'),
hint: i18n('EDITOR/TEXT_SWITCHER_SOURCE', 'Source')
cmd: btn => {
this.setMode('source' == this.mode ? 'wysiwyg' : 'source');
btn.classList.toggle('active', 'source' == this.mode);
}
}
}
},
@ -258,6 +277,9 @@ class SquireUI
browseImage = createElement('input'),
squire = new Squire(wysiwyg, SquireDefaultConfig);
clr.type = 'color';
toolbar.append(clr);
browseImage.type = 'file';
browseImage.accept = 'image/*';
browseImage.style.display = 'none';
@ -271,6 +293,7 @@ class SquireUI
plain.className = 'squire-plain';
wysiwyg.className = 'squire-wysiwyg';
wysiwyg.dir = 'auto';
this.mode = ''; // 'plain' | 'wysiwyg'
this.__plain = {
getRawData: () => this.plain.value,
@ -282,14 +305,11 @@ class SquireUI
this.plain = plain;
this.wysiwyg = wysiwyg;
dispatchEvent(new CustomEvent('squire-toolbar', {detail:{squire:this,actions:actions}}));
toolbar.className = 'squire-toolbar btn-toolbar';
let group, action/*, touchTap*/;
for (group in actions) {
/*
if ('bidi' == group && !rl.settings.app('allowHtmlEditorBitiButtons')) {
continue;
}
*/
let toolgroup = createElement('div');
toolgroup.className = 'btn-group';
toolgroup.id = 'squire-toolgroup-'+group;
@ -342,6 +362,7 @@ class SquireUI
*/
}
input.addEventListener(ev, () => cfg.cmd(input));
cfg.hint = i18n('EDITOR/' + action.toUpperCase());
if (cfg.hint) {
input.title = cfg.key ? cfg.hint + ' (' + ctrlKey + cfg.key + ')' : cfg.hint;
} else if (cfg.key) {
@ -364,21 +385,127 @@ class SquireUI
changes.redo.input.disabled = !state.canRedo;
});
actions.font.fontSize.input.selectedIndex = actions.font.fontSize.defaultValueIndex;
// squire.addEventListener('focus', () => shortcuts.off());
// squire.addEventListener('blur', () => shortcuts.on());
container.append(toolbar, wysiwyg, plain);
/**
* @param {string} fontName
* @return {string}
*/
const normalizeFontName = (fontName) => fontName.trim().replace(/(^["']*|["']*$)/g, '').trim().toLowerCase();
/** @type {string[]} - lower cased array of available font families*/
const fontFamiliesLowerCase = Object.values(actions.font.fontFamily.input.options).map(option => option.value.toLowerCase());
/**
* A theme might have CSS like div.squire-wysiwyg[contenteditable="true"] {
* font-family: 'Times New Roman', Times, serif; }
* so let's find the best match squire.getRoot()'s font
* it will also help to properly handle generic font names like 'sans-serif'
* @type {number}
*/
let defaultFontFamilyIndex = 0;
const squireRootFonts = getComputedStyle(squire.getRoot()).fontFamily.split(',').map(normalizeFontName);
fontFamiliesLowerCase.some((family, index) => {
const matchFound = family.split(',').some(availableFontName => {
const normalizedFontName = normalizeFontName(availableFontName);
return squireRootFonts.some(squireFontName => squireFontName === normalizedFontName);
});
if (matchFound) {
defaultFontFamilyIndex = index;
}
return matchFound;
});
/**
* Instead of comparing whole 'font-family' strings,
* we are going to look for individual font names, because we might be
* editing a Draft started in another email client for example
*
* @type {Object.<string,number>}
*/
const fontNamesMap = {};
/**
* @param {string} fontFamily
* @param {number} index
*/
const processFontFamilyString = (fontFamily, index) => {
fontFamily.split(',').forEach(fontName => {
const key = normalizeFontName(fontName);
if (fontNamesMap[key] === undefined) {
fontNamesMap[key] = index;
}
});
};
// first deal with the default font family
processFontFamilyString(fontFamiliesLowerCase[defaultFontFamilyIndex], defaultFontFamilyIndex);
// and now with the rest of the font families
fontFamiliesLowerCase.forEach((fontFamily, index) => {
if (index !== defaultFontFamilyIndex) {
processFontFamilyString(fontFamily, index);
}
});
// -----
squire.addEventListener('pathChange', e => {
const squireRoot = squire.getRoot();
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));
});
});
if (e.element) {
// 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 {
if (!familyFound && elm.style.fontFamily) {
familyFound = true;
familySelectedIndex = -1; // show empty select if we don't know the font
const fontNames = elm.style.fontFamily.split(',');
for (let i = 0; i < fontNames.length; i++) {
const index = fontNamesMap[normalizeFontName(fontNames[i])];
if (index !== undefined) {
familySelectedIndex = index;
break;
}
}
}
if (!sizeFound && elm.style.fontSize) {
sizeFound = true;
// -1 is ok because it will just show a black <select>
sizeSelectedIndex = actions.font.fontSize.select.indexOf(elm.style.fontSize);
}
elm = elm.parentElement;
} while ((!familyFound || !sizeFound) && elm && elm !== squireRoot);
actions.font.fontFamily.input.selectedIndex = familySelectedIndex;
actions.font.fontSize.input.selectedIndex = sizeSelectedIndex;
}
});
/*
squire.addEventListener('dragover', );
squire.addEventListener('drop', );
squire.addEventListener('pathChange', );
squire.addEventListener('cursor', );
squire.addEventListener('select', );
squire.addEventListener('input', );
squire.addEventListener('willPaste', );
squire.addEventListener( 'keydown keyup', monitorShiftKey )
squire.addEventListener( 'keydown', onKey )
squire.addEventListener('cursor', e => {
console.dir({cursor:e.range});
});
squire.addEventListener('select', e => {
console.dir({select:e.range});
});
*/
// CKEditor gimmicks used by HtmlEditor
@ -401,11 +528,11 @@ class SquireUI
fn = {UL:'makeUnorderedList',OL:'makeOrderedList'};
(parent == type) ? this.squire.removeList() : this.squire[fn[type]]();
}
/*
testPresenceinSelection(format, validation) {
return validation.test(this.squire.getPath()) || this.squire.hasFormat(format);
}
*/
setMode(mode) {
if (this.mode != mode) {
let cl = this.container.classList, source = 'source' == this.mode;
@ -493,7 +620,12 @@ class SquireUI
}
focus() {
('plain' == this.mode ? this.plain : this.squire).focus();
if ('plain' == this.mode) {
this.plain.focus();
this.plain.setSelectionRange(0, 0);
} else {
this.squire.focus();
}
}
}

View file

@ -2,7 +2,7 @@ import 'External/ko';
import ko from 'ko';
import { HtmlEditor } from 'Common/Html';
import { timeToNode } from 'Common/Translator';
import { doc, elementById, addEventsListeners, dropdowns } from 'Common/Globals';
import { doc, elementById, addEventsListeners, dropdowns, leftPanelDisabled } from 'Common/Globals';
import { dropdownsDetectVisibility } from 'Common/UtilsUser';
import { EmailAddressesComponent } from 'Component/EmailAddresses';
import { ThemeStore } from 'Stores/Theme';
@ -29,37 +29,39 @@ const rlContentType = 'snappymail/action',
},
dragTimer = {
id: 0,
stop: () => clearTimeout(dragTimer.id),
start: fn => dragTimer.id = setTimeout(fn, 500)
id: 0
},
fnStop = (e, element) => {
dragStop = (e, element) => {
e.preventDefault();
element.classList.remove('droppableHover');
dragTimer.stop();
element?.classList.remove('droppableHover');
if (dragTimer.node == element) {
dragTimer.node = null;
clearTimeout(dragTimer.id);
}
},
fnHover = (e, element, folder) => {
dragEnter = (e, element, folder) => {
let files = false;
// if (e.dataTransfer.types.includes('Files'))
for (const item of e.dataTransfer.items) {
files |= 'file' === item.kind && 'message/rfc822' === item.type;
}
if (files || dragMessages()) {
fnStop(e, element);
files && e.stopPropagation();
e.stopPropagation();
dragStop(e, dragTimer.node);
e.dataTransfer.dropEffect = files ? 'copy' : (e.ctrlKey ? 'copy' : 'move');
element.classList.add('droppableHover');
if (folder.collapsed()) {
dragTimer.start(() => {
dragTimer.node = element;
dragTimer.id = setTimeout(() => {
folder.collapsed(false);
setExpandedFolder(folder.fullName, true);
}, 500);
}
}
},
fnDrop = (e, element, folder, dragData) => {
fnStop(e, element);
dragDrop = (e, element, folder, dragData) => {
dragStop(e, element);
if (dragMessages() && 'copyMove' == e.dataTransfer.effectAllowed) {
moveMessagesToFolder(FolderUserStore.currentFolderFullName(), dragData.data, folder.fullName, e.ctrlKey);
} else if (e.dataTransfer.types.includes('Files')) {
@ -79,8 +81,8 @@ Object.assign(ko.bindingHandlers, {
let editor = null;
const fValue = fValueAccessor(),
fUpdateEditorValue = () => fValue?.__editor?.setHtmlOrPlain(fValue()),
fUpdateKoValue = () => fValue?.__editor && fValue(fValue.__editor.getDataWithHtmlMark()),
fUpdateEditorValue = () => fValue.__editor?.setHtmlOrPlain(fValue()),
fUpdateKoValue = () => fValue.__editor && fValue(fValue.__editor.getDataWithHtmlMark()),
fOnReady = () => {
fValue.__editor = editor;
fUpdateEditorValue();
@ -144,6 +146,8 @@ Object.assign(ko.bindingHandlers, {
// Remove the Chrome visibility
dragImage.style.cssText = '';
leftPanelDisabled(false);
} else {
e.preventDefault();
}
@ -158,10 +162,10 @@ Object.assign(ko.bindingHandlers, {
init: (element, fValueAccessor) => {
const folder = fValueAccessor(); // ko.dataFor(element)
folder && addEventsListeners(element, {
dragenter: e => fnHover(e, element, folder),
dragover: e => fnHover(e, element, folder),
dragleave: e => fnStop(e, element),
drop: e => fnDrop(e, element, folder, dragData)
dragenter: e => dragEnter(e, element, folder),
dragover: e => e.preventDefault(),
dragleave: e => dragStop(e, element),
drop: e => dragDrop(e, element, folder, dragData)
});
}
},

47
dev/External/ko.js vendored
View file

@ -28,6 +28,18 @@ export const
dispose = disposable => isFunction(disposable?.dispose) && disposable.dispose(),
onKey = (key, element, fValueAccessor, fAllBindings, model) => {
let fn = event => {
if (key == event.key) {
// stopEvent(event);
// element.dispatchEvent(new Event('change'));
fValueAccessor().call(model);
}
};
element.addEventListener('keydown', fn);
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keydown', fn));
},
// With this we don't need delegateRunOnDestroy
koArrayWithDestroy = data => {
data = ko.observableArray(data);
@ -56,41 +68,18 @@ Object.assign(ko.bindingHandlers, {
},
onEnter: {
init: (element, fValueAccessor, fAllBindings, viewModel) => {
let fn = event => {
if ('Enter' == event.key) {
element.dispatchEvent(new Event('change'));
fValueAccessor().call(viewModel);
}
};
element.addEventListener('keydown', fn);
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keydown', fn));
}
init: (element, fValueAccessor, fAllBindings, model) =>
onKey('Enter', element, fValueAccessor, fAllBindings, model)
},
onEsc: {
init: (element, fValueAccessor, fAllBindings, viewModel) => {
let fn = event => {
if ('Escape' == event.key) {
element.dispatchEvent(new Event('change'));
fValueAccessor().call(viewModel);
}
};
element.addEventListener('keyup', fn);
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keyup', fn));
}
init: (element, fValueAccessor, fAllBindings, model) =>
onKey('Escape', element, fValueAccessor, fAllBindings, model)
},
onSpace: {
init: (element, fValueAccessor, fAllBindings, viewModel) => {
let fn = event => {
if (' ' == event.key) {
fValueAccessor().call(viewModel, event);
}
};
element.addEventListener('keyup', fn);
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keyup', fn));
}
init: (element, fValueAccessor, fAllBindings, model) =>
onKey(' ', element, fValueAccessor, fAllBindings, model)
},
i18nUpdate: {

View file

@ -40,6 +40,12 @@ pre {
body > * {
padding: 0.5em 1em;
}
#attachments > * {
border: 1px solid rgba(125,128,128,0.5);
padding: 0.25em;
margin-right: 1em;
}
</style>
</head>
<body></body>

View file

@ -80,13 +80,11 @@ export class AbstractModel {
}
revivePropertiesFromJson(json) {
let model = this.constructor;
if (!model.validJson(json)) {
return false;
}
forEachObjectEntry(json, (key, value) => {
const model = this.constructor,
valid = model.validJson(json);
valid && forEachObjectEntry(json, (key, value) => {
if ('@' !== key[0]) try {
key = key[0].toLowerCase() + key.slice(1);
// key = key[0].toLowerCase() + key.slice(1);
switch (typeof this[key])
{
case 'function':
@ -102,9 +100,12 @@ export class AbstractModel {
case 'string':
this[key] = typeCast(this[key], value);
break;
// fall through
case 'undefined':
default:
console.log(`Undefined ${model.name}.${key} set`);
this[key] = value;
break;
// default:
// console.log((typeof this[key])+` ${model.name}.${key} not revived`);
// console.log((typeof this[key])+' '+(model.name)+'.'+key+' not revived');
}
} catch (e) {
@ -112,7 +113,7 @@ export class AbstractModel {
console.error(e);
}
});
return true;
return valid;
}
}

View file

@ -1,7 +1,7 @@
import ko from 'ko';
import { addObservablesTo, addComputablesTo, addSubscribablesTo } from 'External/ko';
import { keyScope, SettingsGet, leftPanelDisabled, elementById } from 'Common/Globals';
import { keyScope, addShortcut, SettingsGet, leftPanelDisabled, toggleLeftPanel, elementById } from 'Common/Globals';
import { ViewTypePopup, showScreenPopup } from 'Knoin/Knoin';
import { SaveSettingStatus } from 'Common/Enums';
@ -59,7 +59,8 @@ export class AbstractViewPopup extends AbstractView
super('Popups' + name, ViewTypePopup);
this.keyScope.scope = name;
this.modalVisible = ko.observable(false).extend({ rateLimit: 0 });
shortcuts.add('escape,close', '', name, () => {
this.close = () => this.modalVisible(false);
addShortcut('escape,close', '', name, () => {
if (this.modalVisible() && false !== this.onClose()) {
this.close();
}
@ -78,8 +79,6 @@ export class AbstractViewPopup extends AbstractView
afterShow() {} // Happens after showModal() animation transitionend
onHide() {} // Happens before animation transitionend
afterHide() {} // Happens after animation transitionend
close() {}
*/
}
@ -97,6 +96,7 @@ export class AbstractViewLeft extends AbstractView
{
super(templateID, 'left');
this.leftPanelDisabled = leftPanelDisabled;
this.toggleLeftPanel = toggleLeftPanel;
}
}

View file

@ -1,15 +1,19 @@
import ko from 'ko';
import { koComputable } from 'External/ko';
import { doc, $htmlCL, elementById, fireEvent } from 'Common/Globals';
import { forEachObjectValue, forEachObjectEntry } from 'Common/Utils';
import { doc, $htmlCL, elementById, createElement, fireEvent } from 'Common/Globals';
import { forEachObjectEntry } from 'Common/Utils';
import { i18nToNodes } from 'Common/Translator';
import { leftPanelDisabled } from 'Common/Globals';
import { ThemeStore } from 'Stores/Theme';
let
SCREENS = {},
currentScreen = null,
defaultScreenName = '';
const
SCREENS = new Map,
autofocus = dom => dom.querySelector('[autofocus]')?.focus(),
visiblePopups = new Set,
@ -18,7 +22,7 @@ const
* @param {string} screenName
* @returns {?Object}
*/
screen = screenName => (screenName && SCREENS[screenName]) || null,
screen = screenName => (screenName && SCREENS.get(screenName)) || null,
/**
* @param {Function} ViewModelClass
@ -35,27 +39,24 @@ const
dialog = ViewTypePopup === vm.viewType,
vmPlace = doc.getElementById(position);
fireEvent('rl-view-model.create', vm);
ViewModelClass.__builded = true;
ViewModelClass.__vm = vm;
if (vmPlace) {
vmDom = Element.fromHTML(dialog
? '<dialog id="V-'+ id + '"></dialog>'
: '<div id="V-'+ id + '" hidden=""></div>');
vmDom = dialog
? createElement('dialog',{id:'V-'+id})
: createElement('div',{id:'V-'+id,hidden:''})
vmPlace.append(vmDom);
vm.viewModelDom = ViewModelClass.__dom = vmDom;
if (dialog) {
vm.close = () => hideScreenPopup(ViewModelClass);
// Firefox < 98 / Safari < 15.4 HTMLDialogElement not defined
if (!vmDom.showModal) {
vmDom.className = 'polyfill';
vmDom.showModal = () => {
vmDom.backdrop ||
vmDom.before(vmDom.backdrop = Element.fromHTML('<div class="dialog-backdrop"></div>'));
vmDom.before(vmDom.backdrop = createElement('div',{class:'dialog-backdrop'}));
vmDom.setAttribute('open','');
vmDom.open = true;
vmDom.returnValue = null;
@ -79,7 +80,6 @@ const
const endShowHide = e => {
if (e.target === vmDom) {
if (vmDom.classList.contains('animate')) {
autofocus(vmDom);
vm.afterShow?.();
} else {
vmDom.close();
@ -98,6 +98,7 @@ const
vmDom.backdrop.style.zIndex = 3000 + (visiblePopups.size * 2);
}
vm.keyScope.set();
setTimeout(()=>autofocus(vmDom),1);
requestAnimationFrame(() => { // wait just before the next paint
vmDom.offsetHeight; // force a reflow
vmDom.classList.add('animate'); // trigger the transitions
@ -113,6 +114,8 @@ const
vmDom.addEventListener('transitionend', endShowHide);
}
fireEvent('rl-view-model.create', vm);
ko.applyBindingAccessorsToNode(
vmDom,
{
@ -129,7 +132,7 @@ const
}
}
return ViewModelClass && ViewModelClass.__vm;
return ViewModelClass?.__vm;
},
forEachViewModel = (screen, fn) => {
@ -151,16 +154,7 @@ const
vm.onHide?.();
destroy && vm.viewModelDom.remove();
});
},
/**
* @param {Function} ViewModelClassToHide
* @returns {void}
*/
hideScreenPopup = ViewModelClassToHide => {
if (ViewModelClassToHide?.__vm && ViewModelClassToHide?.__dom) {
ViewModelClassToHide.__vm.modalVisible(false);
}
ThemeStore.isMobile() && leftPanelDisabled(true);
},
/**
@ -169,69 +163,58 @@ const
* @returns {void}
*/
screenOnRoute = (screenName, subPart) => {
let vmScreen = null,
isSameScreen = false;
if (null == screenName || '' == screenName) {
screenName = defaultScreenName;
}
if (fireEvent('sm-show-screen', screenName, 1)) {
screenName = screenName || defaultScreenName;
if (screenName && fireEvent('sm-show-screen', screenName, 1)) {
// Close all popups
for (let vm of visiblePopups) {
(false === vm.onClose()) || vm.close();
}
if (screenName) {
vmScreen = screen(screenName);
if (!vmScreen) {
vmScreen = screen(defaultScreenName);
if (vmScreen) {
subPart = screenName + '/' + subPart;
screenName = defaultScreenName;
}
let vmScreen = screen(screenName);
if (!vmScreen) {
vmScreen = screen(defaultScreenName);
if (vmScreen) {
subPart = screenName + '/' + subPart;
screenName = defaultScreenName;
}
}
if (vmScreen?.__started) {
let isSameScreen = currentScreen && vmScreen === currentScreen;
if (!vmScreen.__builded) {
vmScreen.__builded = true;
vmScreen.viewModels.forEach(ViewModelClass =>
buildViewModel(ViewModelClass, vmScreen)
);
vmScreen.onBuild?.();
}
if (vmScreen?.__started) {
isSameScreen = currentScreen && vmScreen === currentScreen;
setTimeout(() => {
// hide screen
currentScreen && !isSameScreen && hideScreen(currentScreen);
// --
if (!vmScreen.__builded) {
vmScreen.__builded = true;
currentScreen = vmScreen;
vmScreen.viewModels.forEach(ViewModelClass =>
buildViewModel(ViewModelClass, vmScreen)
);
// show screen
if (!isSameScreen) {
vmScreen.onShow?.();
vmScreen.onBuild?.();
forEachViewModel(vmScreen, (vm, dom) => {
vm.beforeShow?.();
i18nToNodes(dom);
dom.hidden = false;
vm.onShow?.();
autofocus(dom);
});
}
// --
setTimeout(() => {
// hide screen
if (currentScreen && !isSameScreen) {
hideScreen(currentScreen);
}
// --
currentScreen = vmScreen;
// show screen
if (!isSameScreen) {
vmScreen.onShow?.();
forEachViewModel(vmScreen, (vm, dom) => {
vm.beforeShow?.();
i18nToNodes(dom);
dom.hidden = false;
vm.onShow?.();
autofocus(dom);
});
}
// --
vmScreen.__cross?.parse(subPart);
}, 1);
}
vmScreen.__cross?.parse(subPart);
}, 1);
}
}
};
@ -267,21 +250,19 @@ export const
*/
startScreens = screensClasses => {
hasher.clear();
forEachObjectValue(SCREENS, screen => hideScreen(screen, 1));
SCREENS = {};
SCREENS.forEach(screen => hideScreen(screen, 1));
SCREENS.clear();
currentScreen = null,
defaultScreenName = '';
screensClasses.forEach(CScreen => {
if (CScreen) {
const vmScreen = new CScreen(),
screenName = vmScreen.screenName;
defaultScreenName || (defaultScreenName = screenName);
SCREENS[screenName] = vmScreen;
}
const vmScreen = new CScreen(),
screenName = vmScreen.screenName;
defaultScreenName || (defaultScreenName = screenName);
SCREENS.set(screenName, vmScreen);
});
forEachObjectValue(SCREENS, vmScreen => {
SCREENS.forEach(vmScreen => {
if (!vmScreen.__started) {
vmScreen.onStart();
vmScreen.__started = true;
@ -289,7 +270,7 @@ export const
});
const cross = new Crossroads();
cross.addRoute(/^([a-zA-Z0-9-]*)\/?(.*)$/, screenOnRoute);
cross.addRoute(/^([^/]*)\/?(.*)$/, screenOnRoute);
hasher.add(cross.parse.bind(cross));
hasher.init();

View file

@ -1,8 +1,17 @@
//import { b64Encode } from 'Common/Utils';
const
// RFC2045
QPDecodeIn = /=([0-9A-F]{2})/g,
QPDecodeOut = (...args) => String.fromCharCode(parseInt(args[1], 16));
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});
}
};
export function ParseMime(text)
{
@ -40,22 +49,14 @@ export function ParseMime(text)
get body() {
let body = this.bodyRaw,
// charset = this.header('content-type')?.params.charset,
charset = this.header('content-type')?.params.charset,
encoding = this.headerValue('content-transfer-encoding');
if ('quoted-printable' == encoding) {
body = body.replace(/=\r?\n/g, '').replace(QPDecodeIn, QPDecodeOut);
body = QPDecode(body);
} else if ('base64' == encoding) {
body = atob(body.replace(/\r?\n/g, ''));
}
/*
try {
// https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings
return new TextDecoder(charset).decode(Uint8Array.from(body, c => c.charCodeAt(0)));
// return new TextDecoder(charset).decode(new TextEncoder().encode(body));
} catch (e) {
}
*/
return body;
return decodeText(charset, body);
}
get dataUrl() {
@ -65,9 +66,10 @@ export function ParseMime(text)
body = body.replace(/\r?\n/g, '');
} else {
if ('quoted-printable' == encoding) {
body = body.replace(/=\r?\n/g, '').replace(QPDecodeIn, QPDecodeOut);
body = QPDecode(body);
}
body = btoa(body);
// body = b64Encode(body);
}
return 'data:' + this.headerValue('content-type') + ';base64,' + body;
}
@ -111,8 +113,12 @@ 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()] = {
value: match[2].trim(),
value: match[2],
params: params
};
}

View file

@ -1,7 +1,6 @@
import { ParseMime } from 'Mime/Parser';
import { AttachmentModel } from 'Model/Attachment';
import { EmailModel } from 'Model/Email';
import { FileInfo } from 'Common/File';
import { BEGIN_PGP_MESSAGE } from 'Stores/User/Pgp';
@ -14,25 +13,20 @@ export function MimeToMessage(data, message)
let signed;
const struct = ParseMime(data);
if (struct.headers) {
let html = struct.getByContentType('text/html');
let html = struct.getByContentType('text/html'),
subject = struct.headerValue('subject');
html = html ? html.body : '';
if (struct.headers.subject) {
message.subject(struct.headers.subject.value);
}
['from','to'].forEach(name => {
if (struct.headers[name] && !message[name].length) {
let mail = new EmailModel;
mail.parse(struct.headers[name].value);
message[name].push(mail);
}
});
subject && message.subject(subject);
// EmailCollectionModel
['from','to'].forEach(name => message[name].fromString(struct.headerValue(name)));
struct.forEach(part => {
let cd = part.header('content-disposition'),
cid = part.header('content-id'),
cId = part.header('content-id'),
type = part.header('content-type');
if (cid || cd) {
if (cId || cd) {
// if (cd && 'attachment' === cd.value) {
let attachment = new AttachmentModel;
attachment.mimeType = type.value;
@ -40,17 +34,15 @@ export function MimeToMessage(data, message)
attachment.fileNameExt = attachment.fileName.replace(/^.+(\.[a-z]+)$/, '$1');
attachment.fileType = FileInfo.getType('', type.value);
attachment.url = part.dataUrl;
attachment.friendlySize = FileInfo.friendlySize(part.body.length);
attachment.estimatedSize = part.body.length;
/*
attachment.isThumbnail = false;
attachment.contentLocation = '';
attachment.download = '';
attachment.folder = '';
attachment.uid = '';
attachment.mimeIndex = part.id;
*/
attachment.cid = cid ? cid.value : '';
if (cid && html) {
attachment.cId = cId ? cId.value : '';
if (cId && html) {
let cid = 'cid:' + attachment.contentId(),
found = html.includes(cid);
attachment.isInline(found);
@ -64,9 +56,9 @@ export function MimeToMessage(data, message)
}
} else if ('multipart/signed' === type.value && 'application/pgp-signature' === type.params.protocol) {
signed = {
MicAlg: type.micalg,
BodyPart: part.parts[0],
SigPart: part.parts[1]
micAlg: type.micalg,
bodyPart: part.parts[0],
sigPart: part.parts[1]
};
}
});

View file

@ -1,5 +1,7 @@
import { AbstractModel } from 'Knoin/AbstractModel';
import { addObservablesTo } from 'External/ko';
import Remote from 'Remote/User/Fetch';
import { SettingsUserStore } from 'Stores/User/Settings';
export class AccountModel extends AbstractModel {
/**
@ -7,16 +9,52 @@ export class AccountModel extends AbstractModel {
* @param {boolean=} canBeDelete = true
* @param {number=} count = 0
*/
constructor(email/*, count = 0*/, isAdditional = true) {
constructor(email, name, isAdditional = true) {
super();
this.name = name;
this.email = email;
this.displayName = name ? name + ' <' + email + '>' : email;
addObservablesTo(this, {
// count: count || 0,
unreadEmails: null,
askDelete: false,
isAdditional: isAdditional
});
// Load at random between 3 and 30 seconds
SettingsUserStore.showUnreadCount() && isAdditional
&& setTimeout(()=>this.fetchUnread(), (Math.ceil(Math.random() * 10)) * 3000);
}
/**
* Get INBOX unread messages
*/
fetchUnread() {
Remote.request('AccountUnread', (iError, oData) => {
iError || this.unreadEmails(oData?.Result?.unreadEmails || null);
}, {
email: this.email
});
}
/**
* Imports all mail to main account
*//*
importAll(account) {
Remote.streamPerLine(line => {
try {
line = JSON.parse(line);
console.dir(line);
} catch (e) {
// OOPS
}
}, 'AccountImport', {
Action: 'AccountImport',
email: account.email
});
}
*/
}

View file

@ -1,6 +1,8 @@
import ko from 'ko';
import { FileInfo, FileType } from 'Common/File';
import { stopEvent, SettingsGet, SettingsCapa } from 'Common/Globals';
import { b64EncodeJSONSafe } from 'Common/Utils';
import {
attachmentDownload,
serverRequestRaw
@ -18,13 +20,12 @@ export class AttachmentModel extends AbstractModel {
this.checked = ko.observable(true);
this.mimeType = '';
// this.mimeTypeParams = '';
this.fileName = '';
this.fileNameExt = '';
this.fileType = FileType.Unknown;
this.isThumbnail = false;
this.cid = '';
this.cId = '';
this.contentLocation = '';
this.download = '';
this.folder = '';
this.uid = '';
this.url = '';
@ -51,12 +52,17 @@ export class AttachmentModel extends AbstractModel {
return attachment;
}
toggleChecked(self, event) {
stopEvent(event);
self.checked(!self.checked());
}
friendlySize() {
return FileInfo.friendlySize(this.estimatedSize) + (this.isLinked() ? ' 🔗' : '');
}
contentId() {
return this.cid.replace(/^<+|>+$/g, '');
return this.cId.replace(/^<+|>+$/g, '');
}
/**
@ -119,6 +125,17 @@ export class AttachmentModel extends AbstractModel {
);
}
get download() {
return b64EncodeJSONSafe({
folder: this.folder,
uid: this.uid,
mimeIndex: this.mimeIndex,
mimeType: this.mimeType,
fileName: this.fileName,
accountHash: SettingsGet('accountHash')
});
}
/**
* @returns {string}
*/
@ -137,7 +154,7 @@ export class AttachmentModel extends AbstractModel {
* @returns {boolean}
*/
hasThumbnail() {
return this.isThumbnail && !this.isLinked();
return SettingsCapa('AttachmentThumbnails') && this.isImage() && !this.isLinked();
}
/**
@ -146,7 +163,7 @@ export class AttachmentModel extends AbstractModel {
thumbnailStyle() {
return this.hasThumbnail()
? 'background:url(' + serverRequestRaw('ViewThumbnail', this.download) + ')'
: '';
: null;
}
/**

View file

@ -21,11 +21,11 @@ export class AttachmentCollectionModel extends AbstractCollectionModel
}
/**
* @param {string} cid
* @param {string} cId
* @returns {*}
*/
findByCid(cid) {
cid = cid.replace(/^<+|>+$/g, '');
return this.find(item => cid === item.contentId());
findByCid(cId) {
cId = cId.replace(/^<+|>+$/g, '');
return this.find(item => cId === item.contentId());
}
}

View file

@ -10,16 +10,16 @@ export class ComposeAttachmentModel extends AbstractModel {
* @param {?number=} size = null
* @param {boolean=} isInline = false
* @param {boolean=} isLinked = false
* @param {string=} CID = ''
* @param {string=} cId = ''
* @param {string=} contentLocation = ''
*/
constructor(id, fileName, size = null, isInline = false, isLinked = false, CID = '', contentLocation = '') {
constructor(id, fileName, size = null, isInline = false, isLinked = false, cId = '', contentLocation = '') {
super();
this.id = id;
this.isInline = !!isInline;
this.isLinked = !!isLinked;
this.CID = CID;
this.cId = cId;
this.contentLocation = contentLocation;
this.fromMessage = false;
@ -27,6 +27,7 @@ export class ComposeAttachmentModel extends AbstractModel {
fileName: fileName,
size: size,
tempName: '',
type: '', // application/octet-stream
progress: 0,
error: '',
@ -54,7 +55,7 @@ export class ComposeAttachmentModel extends AbstractModel {
return null === localSize ? '' : FileInfo.friendlySize(localSize);
},
mimeType: () => FileInfo.getContentType(this.fileName()),
mimeType: () => this.type() || FileInfo.getContentType(this.fileName()),
fileExt: () => FileInfo.getExtension(this.fileName()),
iconClass: () => FileInfo.getIconClass(this.fileExt(), this.mimeType())

View file

@ -119,7 +119,7 @@ export class ContactModel extends AbstractModel {
display: () => {
let a = this.fullName(),
b = this.email()?.[0]?.value,
b = this.email()[0]?.value(),
c = this.nickname();
return a || b || c;
}
@ -139,7 +139,7 @@ export class ContactModel extends AbstractModel {
*/
getNameAndEmailHelper() {
let name = (this.givenName() + ' ' + this.surName()).trim(),
email = this.email()[0];
email = this.email()[0]?.value();
/*
// this.jCard.getOne('fn')?.notEmpty() ||
this.jCard.parseFullName({set:true});
@ -304,7 +304,7 @@ export class ContactModel extends AbstractModel {
// jCard.set('rev', '2022-05-21T10:59:52Z')
return {
Uid: this.id,
uid: this.id,
jCard: JSON.stringify(jCard)
};
}

View file

@ -18,32 +18,83 @@ import { AbstractModel } from 'Knoin/AbstractModel';
* @param {String} str Address field
* @return {Array} An array of address objects
*/
function addressparser(str) {
var tokenizer = new Tokenizer(str);
var tokens = tokenizer.tokenize();
var addresses = [];
var address = [];
var parsedAddresses = [];
export function addressparser(str) {
str = (str || '').toString();
tokens.forEach(token => {
if (token.type === 'operator' && (token.value === ',' || token.value === ';')) {
address.length && addresses.push(address);
let
endOperator = '',
node = {
type: 'text',
value: ''
},
escaped = false,
address = [],
addresses = [];
const
/*
* Operator tokens and which tokens are expected to end the sequence
*/
OPERATORS = {
'"': '"',
'(': ')',
'<': '>',
',': '',
// Groups are ended by semicolons
':': ';',
// Semicolons are not a legal delimiter per the RFC2822 grammar other
// than for terminating a group, but they are also not valid for any
// other use in this context. Given that some mail clients have
// historically allowed the semicolon as a delimiter equivalent to the
// comma in their UI, it makes sense to treat them the same as a comma
// when used outside of a group.
';': ''
},
pushToken = token => {
token.value = (token.value || '').toString().trim();
token.value.length && address.push(token);
node = {
type: 'text',
value: ''
},
escaped = false;
},
pushAddress = () => {
if (address.length) {
address = _handleAddress(address);
if (address.length) {
addresses = addresses.concat(address);
}
}
address = [];
};
[...str].forEach(chr => {
if (!escaped && (chr === endOperator || (!endOperator && chr in OPERATORS))) {
pushToken(node);
if (',' === chr || ';' === chr) {
pushAddress();
} else {
endOperator = endOperator ? '' : OPERATORS[chr];
if ('<' === chr) {
node.type = 'email';
} else if ('(' === chr) {
node.type = 'comment';
} else if (':' === chr) {
node.type = 'group';
}
}
} else {
address.push(token);
node.value += chr;
escaped = !escaped && '\\' === chr;
}
});
pushToken(node);
address.length && addresses.push(address);
pushAddress();
addresses.forEach(address => {
address = _handleAddress(address);
if (address.length) {
parsedAddresses = parsedAddresses.concat(address);
}
});
return parsedAddresses;
return addresses;
// return addresses.map(item => (item.name || item.email) ? new EmailModel(item.email, item.name) : null).filter(v => v);
}
/**
@ -53,37 +104,20 @@ function addressparser(str) {
* @return {Object} Address object
*/
function _handleAddress(tokens) {
var isGroup = false;
var state = 'text';
var address = void 0;
var addresses = [];
var data = {
address: [],
comment: [],
group: [],
text: []
};
let
isGroup = false,
address = {},
addresses = [],
data = {
email: [],
comment: [],
group: [],
text: []
};
// Filter out <addresses>, (comments) and regular text
tokens.forEach(token => {
if (token.type === 'operator') {
switch (token.value) {
case '<':
state = 'address';
break;
case '(':
state = 'comment';
break;
case ':':
state = 'group';
isGroup = true;
break;
default:
state = 'text';
}
} else if (token.value) {
data[state].push(token.value);
}
isGroup = isGroup || 'group' === token.type;
data[token.type].push(token.value);
});
// If there is no text but a comment, replace the two
@ -94,181 +128,90 @@ function _handleAddress(tokens) {
if (isGroup) {
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
data.text = data.text.join(' ');
/*
addresses.push({
name: data.text || address && address.name,
group: data.group.length ? addressparser(data.group.join(',')) : []
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.address.length && data.text.length) {
if (!data.email.length && data.text.length) {
var i = data.text.length;
while (i--) {
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
data.address = data.text.splice(i, 1);
data.email = data.text.splice(i, 1);
break;
}
}
// still no address
if (!data.address.length) {
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.address.length) {
data.address = [address.trim()];
if (!data.email.length) {
data.email = [address.trim()];
return '';
}
return address.trim();
});
if (data.address.length) {
if (data.email.length) {
break;
}
}
}
}
// If there's still is no text but a comment exixts, replace the two
// 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.address.length > 1) {
data.text = data.text.concat(data.address.splice(1));
if (data.email.length > 1) {
data.text = data.text.concat(data.email.splice(1));
}
// Join values with spaces
data.text = data.text.join(' ');
data.address = data.address.join(' ');
if (!data.address && isGroup) {
return [];
}
address = {
address: data.address || data.text || '',
name: data.text || data.address || ''
// Join values with spaces
email: data.email.join(' ').trim(),
name: data.text.join(' ').trim()
// ,comment: data.comment.join(' ').trim()
};
if (address.address === address.name) {
if ((address.address || '').match(/@/)) {
if (address.email === address.name) {
if (address.email.includes('@')) {
address.name = '';
} else {
address.address = '';
address.email = '';
}
}
// address.email = address.email.replace(/^[<]+(.*)[>]+$/g, '$1');
addresses.push(address);
}
return addresses;
}
/*
* Operator tokens and which tokens are expected to end the sequence
*/
var OPERATORS = {
'"': '"',
'(': ')',
'<': '>',
',': '',
// Groups are ended by semicolons
':': ';',
// Semicolons are not a legal delimiter per the RFC2822 grammar other
// than for terminating a group, but they are also not valid for any
// other use in this context. Given that some mail clients have
// historically allowed the semicolon as a delimiter equivalent to the
// comma in their UI, it makes sense to treat them the same as a comma
// when used outside of a group.
';': ''
};
class Tokenizer
{
constructor(str) {
this.str = (str || '').toString();
this.operatorCurrent = '';
this.operatorExpecting = '';
this.node = null;
this.escaped = false;
this.list = [];
}
tokenize() {
var list = [];
[...this.str].forEach(c => this.checkChar(c));
this.list.forEach(node => {
node.value = (node.value || '').toString().trim();
node.value && list.push(node);
});
return list;
}
checkChar(chr) {
if ((chr in OPERATORS || chr === '\\') && this.escaped) {
this.escaped = false;
} else if (this.operatorExpecting && chr === this.operatorExpecting) {
this.node = {
type: 'operator',
value: chr
};
this.list.push(this.node);
this.node = null;
this.operatorExpecting = '';
this.escaped = false;
return;
} else if (!this.operatorExpecting && chr in OPERATORS) {
this.node = {
type: 'operator',
value: chr
};
this.list.push(this.node);
this.node = null;
this.operatorExpecting = OPERATORS[chr];
this.escaped = false;
return;
}
if (!this.escaped && chr === '\\') {
this.escaped = true;
return;
}
if (!this.node) {
this.node = {
type: 'text',
value: ''
};
this.list.push(this.node);
}
if (this.escaped && chr !== '\\') {
this.node.value += '\\';
}
this.node.value += chr;
this.escaped = false;
}
}
export class EmailModel extends AbstractModel {
/**
* @param {string=} email = ''
* @param {string=} name = ''
* @param {string=} dkimStatus = 'none'
* @param {string=} dkimValue = ''
*/
constructor(email = '', name = '', dkimStatus = 'none', dkimValue = '') {
constructor(email, name, dkimStatus = 'none') {
super();
this.email = email;
this.name = name;
this.email = email || '';
this.name = name || '';
this.dkimStatus = dkimStatus;
this.dkimValue = dkimValue;
this.clearDuplicateName();
this.cleanup();
}
/**
@ -278,61 +221,34 @@ export class EmailModel extends AbstractModel {
*/
static reviveFromJson(json) {
const email = super.reviveFromJson(json);
email?.clearDuplicateName();
return email;
}
/**
* @returns {void}
*/
clear() {
this.email = '';
this.name = '';
this.dkimStatus = 'none';
this.dkimValue = '';
email?.cleanup();
return email?.valid() ? email : null;
}
/**
* @returns {boolean}
*/
validate() {
valid() {
return this.name || this.email;
}
/**
* @param {boolean} withoutName = false
* @returns {string}
*/
hash(withoutName = false) {
return '#' + (withoutName ? '' : this.name) + '#' + this.email + '#';
}
/**
* @returns {void}
*/
clearDuplicateName() {
cleanup() {
if (this.name === this.email) {
this.name = '';
}
}
/**
* @param {string} query
* @returns {boolean}
*/
search(query) {
return (this.name + ' ' + this.email).toLowerCase().includes(query.toLowerCase());
}
/**
* @param {boolean} friendlyView = false
* @param {boolean} wrapWithLink = false
* @returns {string}
*/
toLine(friendlyView, wrapWithLink) {
let result = this.email,
name = this.name,
let name = this.name,
result = this.email,
toLink = text =>
'<a href="mailto:'
+ encodeHtml(result) + (name ? '?to=' + encodeURIComponent('"' + name + '" <' + result + '>') : '')
@ -351,47 +267,6 @@ export class EmailModel extends AbstractModel {
result = toLink();
}
}
return result;
}
static splitEmailLine(line) {
const result = [];
let exists = false;
addressparser(line).forEach(item => {
const address = item.address
? new EmailModel(item.address.replace(/^[<]+(.*)[>]+$/g, '$1'), item.name || '')
: null;
if (address?.email) {
exists = true;
}
result.push(address ? address.toLine() : item.name);
});
return exists ? result : null;
}
static parseEmailLine(line) {
return addressparser(line).map(item =>
item.address ? new EmailModel(item.address.replace(/^[<]+(.*)[>]+$/g, '$1'), item.name || '') : null
).filter(v => v);
}
/**
* @param {string} emailAddress
* @returns {boolean}
*/
parse(emailAddress) {
emailAddress = emailAddress.trim();
if (emailAddress) {
const result = addressparser(emailAddress);
if (result.length) {
this.name = result[0].name || '';
this.email = result[0].address || '';
this.clearDuplicateName();
return true;
}
}
return false;
return result || name;
}
}

View file

@ -1,5 +1,6 @@
import { AbstractCollectionModel } from 'Model/AbstractCollection';
import { EmailModel } from 'Model/Email';
import { EmailModel, addressparser } from 'Model/Email';
import { forEachObjectValue } from 'Common/Utils';
'use strict';
@ -13,6 +14,16 @@ export class EmailCollectionModel extends AbstractCollectionModel
return super.reviveFromJson(items, email => EmailModel.reviveFromJson(email));
}
/**
* @param {string} text
* @returns {EmailCollectionModel}
*/
static fromString(str) {
let list = new this();
list.fromString(str);
return list;
}
/**
* @param {boolean=} friendlyView = false
* @param {boolean=} wrapWithLink = false
@ -21,4 +32,23 @@ export class EmailCollectionModel extends AbstractCollectionModel
toString(friendlyView, wrapWithLink) {
return this.map(email => email.toLine(friendlyView, wrapWithLink)).join(', ');
}
/**
* @param {string} text
*/
fromString(str) {
if (str) {
let items = {}, key;
addressparser(str).forEach(item => {
item = new EmailModel(item.email, item.name);
// Make them unique
key = item.email || item.name;
if (key && (item.name || !items[key])) {
items[key] = item;
}
});
forEachObjectValue(items, item => this.push(item));
}
}
}

View file

@ -3,8 +3,9 @@ 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 { getFolderFromCacheList, setFolder, setFolderInboxName } from 'Common/Cache';
import { clearCache, getFolderFromCacheList, setFolder, setFolderInboxName, removeFolderFromCacheList } from 'Common/Cache';
import { Settings, SettingsGet, fireEvent } from 'Common/Globals';
import { Notifications } from 'Common/Enums';
import * as Local from 'Storage/Client';
@ -14,18 +15,18 @@ import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { SettingsUserStore } from 'Stores/User/Settings';
import { sortFolders } from 'Common/Folders';
import { i18n, translateTrigger } from 'Common/Translator';
import { i18n, translateTrigger, getNotification } from 'Common/Translator';
import { AbstractModel } from 'Knoin/AbstractModel';
import { koComputable, addObservablesTo } from 'External/ko';
import { /*koComputable,*/ addObservablesTo } from 'External/ko';
//import { mailBox } from 'Common/Links';
import Remote from 'Remote/User/Fetch';
const
isPosNumeric = value => null != value && /^[0-9]*$/.test(value.toString()),
// isPosNumeric = value => null != value && /^[0-9]*$/.test(value.toString()),
normalizeFolder = sFolderFullName => ('' === sFolderFullName
|| UNUSED_OPTION_VALUE === sFolderFullName
@ -37,7 +38,7 @@ const
Inbox: 0,
Sent: 0,
Drafts: 0,
Spam: 0,
Junk: 0, // Spam
Trash: 0,
Archive: 0
},
@ -62,7 +63,7 @@ const
case FolderType.Trash:
case FolderType.Archive:
return i18n('FOLDER_LIST/' + getKeyByValue(FolderType, type).toUpperCase() + '_NAME');
case FolderType.Spam:
case FolderType.Junk:
return i18n('GLOBAL/SPAM');
// no default
}
@ -91,6 +92,7 @@ export const
Remote.abort('Folders')
.post('Folders', FolderUserStore.foldersLoading)
.then(data => {
clearCache();
FolderCollectionModel.reviveFromJson(data.Result)?.storeIt();
fCallback?.(true);
// Repeat every 15 minutes?
@ -104,12 +106,11 @@ export class FolderCollectionModel extends AbstractCollectionModel
/*
constructor() {
super();
this.CountRec
this.IsThreadsSupported
this.Namespace;
this.Optimized
this.SystemFolders
this.Capabilities
this.quotaUsage;
this.quotaLimit;
this.namespace;
this.optimized
this.capabilities
}
*/
@ -119,20 +120,17 @@ export class FolderCollectionModel extends AbstractCollectionModel
*/
static reviveFromJson(object) {
const expandedFolders = Local.get(ClientSideKeyNameExpandedFolders);
if (object?.SystemFolders) {
forEachObjectEntry(SystemFolders, key =>
SystemFolders[key] = SettingsGet(key+'Folder') || object.SystemFolders[FolderType[key]]
);
}
forEachObjectEntry(SystemFolders, (key, value) =>
value || (SystemFolders[key] = SettingsGet(key+'Folder'))
);
const result = super.reviveFromJson(object, oFolder => {
let oCacheFolder = getFolderFromCacheList(oFolder.FullName),
type = FolderType[getKeyByValue(SystemFolders, oFolder.FullName)];
let oCacheFolder = getFolderFromCacheList(oFolder.fullName);
if (oCacheFolder) {
// oCacheFolder.revivePropertiesFromJson(oFolder);
if (oFolder.Hash) {
oCacheFolder.hash = oFolder.Hash;
if (oFolder.etag) {
oCacheFolder.etag = oFolder.etag;
}
if (null != oFolder.totalEmails) {
oCacheFolder.totalEmails(oFolder.totalEmails);
@ -144,17 +142,59 @@ export class FolderCollectionModel extends AbstractCollectionModel
oCacheFolder = FolderModel.reviveFromJson(oFolder);
if (!oCacheFolder)
return null;
if (1 == type) {
oCacheFolder.type(type);
setFolderInboxName(oFolder.FullName);
}
setFolder(oCacheFolder);
}
if (1 < type) {
oCacheFolder.type(type);
// JMAP RFC 8621
let role = oFolder.role;
/*
if (!role) {
// Kolab
let type = oFolder.metadata[FolderMetadataKeys.KolabFolderType]
|| oFolder.metadata[FolderMetadataKeys.KolabFolderTypeShared];
switch (type) {
case 'mail.inbox':
case 'mail.drafts':
role = type.replace('mail.', '');
break;
// case 'mail.outbox':
case 'mail.sentitems':
role = 'sent';
break;
case 'mail.junkemail':
role = 'spam';
break;
case 'mail.wastebasket':
role = 'trash';
break;
}
// Flags
if (oFolder.attributes.includes('\\sentmail')) {
role = 'sent';
}
if (oFolder.attributes.includes('\\spam')) {
role = 'junk';
}
if (oFolder.attributes.includes('\\bin')) {
role = 'trash';
}
if (oFolder.attributes.includes('\\important')) {
role = 'important';
}
if (oFolder.attributes.includes('\\starred')) {
role = 'flagged';
}
if (oFolder.attributes.includes('\\all') || oFolder.flags.includes('\\allmail')) {
role = 'all';
}
}
*/
if (role) {
role = role[0].toUpperCase() + role.slice(1);
SystemFolders[role] || (SystemFolders[role] = oFolder.fullName);
}
oCacheFolder.type(FolderType[getKeyByValue(SystemFolders, oFolder.fullName)] || 0);
oCacheFolder.collapsed(!expandedFolders
|| !isArray(expandedFolders)
@ -163,12 +203,42 @@ export class FolderCollectionModel extends AbstractCollectionModel
return oCacheFolder;
});
result.CountRec = result.length;
setFolderInboxName(SystemFolders.Inbox);
let i = result.length;
if (i) {
sortFolders(result);
try {
while (i--) {
let folder = result[i], parent = getFolderFromCacheList(folder.parentName);
if (!parent) {
// Create NonExistent parent folders
let delimiter = folder.delimiter;
if (delimiter) {
let parents = folder.fullName.split(delimiter);
parents.pop();
while (parents.length) {
let parentName = parents.join(delimiter),
name = parents.pop(),
pfolder = getFolderFromCacheList(parentName);
if (!pfolder) {
console.log('Create nonexistent folder ' + parentName);
pfolder = FolderModel.reviveFromJson({
'@Object': 'Object/Folder',
name: name,
fullName: parentName,
delimiter: delimiter,
attributes: ['\\nonexistent']
});
setFolder(pfolder);
result.splice(i, 0, pfolder);
++i;
}
}
parent = getFolderFromCacheList(folder.parentName);
}
}
if (parent) {
parent.subFolders.unshift(folder);
result.splice(i,1);
@ -188,7 +258,7 @@ export class FolderCollectionModel extends AbstractCollectionModel
if (!(
SettingsGet('SentFolder') +
SettingsGet('DraftsFolder') +
SettingsGet('SpamFolder') +
SettingsGet('JunkFolder') +
SettingsGet('TrashFolder') +
SettingsGet('ArchiveFolder')
)
@ -198,18 +268,19 @@ export class FolderCollectionModel extends AbstractCollectionModel
FolderUserStore.folderList(this);
FolderUserStore.namespace = this.Namespace;
FolderUserStore.namespace = this.namespace;
AppUserStore.threadsAllowed(!!(Settings.app('useImapThread') && this.IsThreadsSupported));
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
AppUserStore.threadsAllowed(!!this.capabilities.some(capa => capa.startsWith('THREAD=')));
FolderUserStore.folderListOptimized(!!this.Optimized);
// FolderUserStore.folderListOptimized(!!this.optimized);
FolderUserStore.quotaUsage(this.quotaUsage);
FolderUserStore.quotaLimit(this.quotaLimit);
FolderUserStore.capabilities(this.Capabilities);
FolderUserStore.capabilities(this.capabilities);
FolderUserStore.sentFolder(normalizeFolder(SystemFolders.Sent));
FolderUserStore.draftsFolder(normalizeFolder(SystemFolders.Drafts));
FolderUserStore.spamFolder(normalizeFolder(SystemFolders.Spam));
FolderUserStore.spamFolder(normalizeFolder(SystemFolders.Junk));
FolderUserStore.trashFolder(normalizeFolder(SystemFolders.Trash));
FolderUserStore.archiveFolder(normalizeFolder(SystemFolders.Archive));
@ -230,13 +301,14 @@ export class FolderModel extends AbstractModel {
this.exists = true;
this.hash = '';
// this.id = null;
this.uidNext = null;
this.etag = '';
this.id = 0;
this.uidNext = 0;
addObservablesTo(this, {
name: '',
type: FolderType.User,
type: 0,
role: null,
selectable: false,
focused: false,
@ -249,8 +321,8 @@ export class FolderModel extends AbstractModel {
nameForEdit: '',
errorMsg: '',
totalEmailsValue: 0,
unreadEmailsValue: 0,
totalEmails: 0,
unreadEmails: 0,
kolabType: null,
@ -259,17 +331,20 @@ export class FolderModel extends AbstractModel {
tagsAllowed: false
});
this.flags = ko.observableArray();
this.attributes = ko.observableArray();
// For messages
this.permanentFlags = ko.observableArray();
this.addSubscribables({
kolabType: sValue => this.metadata[FolderMetadataKeys.KolabFolderType] = sValue,
permanentFlags: aValue => this.tagsAllowed(aValue.includes('\\*'))
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)
});
this.subFolders = ko.observableArray(new FolderCollectionModel);
this.actionBlink = ko.observable(false).extend({ falseTimeout: 1000 });
/*
this.totalEmails = koComputable({
read: this.totalEmailsValue,
write: iValue =>
@ -283,6 +358,7 @@ export class FolderModel extends AbstractModel {
isPosNumeric(value) ? this.unreadEmailsValue(value) : this.unreadEmailsValue.valueHasMutated()
})
.extend({ notify: 'always' });
*/
/*
https://www.rfc-editor.org/rfc/rfc8621.html#section-2
"myRights": {
@ -297,6 +373,145 @@ export class FolderModel extends AbstractModel {
"mayReadItems": true
},
*/
this.addComputables({
isInbox: () => FolderType.Inbox === this.type(),
isFlagged: () => FolderUserStore.currentFolder() === this
&& MessagelistUserStore.listSearch().includes('flagged'),
// isSubscribed: () => this.attributes().includes('\\subscribed'),
hasVisibleSubfolders: () => !!this.subFolders().find(folder => folder.visible()),
hasSubscriptions: () => this.isSubscribed() | !!this.subFolders().find(
oFolder => {
const subscribed = oFolder.hasSubscriptions();
return !oFolder.isSystemFolder() && subscribed;
}
),
canBeEdited: () => !this.type() && this.exists/* && this.selectable()*/,
isSystemFolder: () => this.type()
| (FolderUserStore.allowKolab() && !!this.kolabType() & !SettingsUserStore.unhideKolabFolders()),
canBeSelected: () => this.selectable() && !this.isSystemFolder(),
canBeDeleted: () => this.canBeSelected() && this.exists,
canBeSubscribed: () => this.selectable()
&& !(this.isSystemFolder() | !SettingsUserStore.hideUnsubscribed()),
/**
* Folder is visible when:
* - hasVisibleSubfolders()
* Or when all below conditions are true:
* - selectable()
* - isSubscribed() OR hideUnsubscribed = false
* - 0 == type()
* - not kolabType()
*/
visible: () => {
const selectable = this.canBeSelected(),
name = this.name(),
filter = foldersFilter(),
visible = (this.isSubscribed() | !SettingsUserStore.hideUnsubscribed())
&& selectable
&& (!filter || name.toLowerCase().includes(filter.toLowerCase()));
return this.hasVisibleSubfolders() | visible;
},
unreadCount: () => this.unreadEmails() || null,
/*
{
// TODO: make this optional in Settings
// https://github.com/the-djmaze/snappymail/issues/457
// https://github.com/the-djmaze/snappymail/issues/567
const
unread = this.unreadEmails(),
type = this.type();
// return ((!this.isSystemFolder() || type == FolderType.Inbox) && unread) ? unread : null;
},
*/
localName: () => {
let name = this.name();
if (this.isSystemFolder()) {
translateTrigger();
name = getSystemFolderName(this.type(), name);
}
return name;
},
nameInfo: () => {
if (this.isSystemFolder()) {
translateTrigger();
let suffix = getSystemFolderName(this.type(), getKolabFolderName(this.kolabType()));
if (this.name() !== suffix && 'inbox' !== suffix.toLowerCase()) {
return ' (' + suffix + ')';
}
}
return '';
},
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)
});
}
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);
}
/**
@ -316,6 +531,7 @@ export class FolderModel extends AbstractModel {
const folder = super.reviveFromJson(json);
if (folder) {
const path = folder.fullName.split(folder.delimiter),
attr = name => folder.attributes.includes(name),
type = (folder.metadata[FolderMetadataKeys.KolabFolderType]
|| folder.metadata[FolderMetadataKeys.KolabFolderTypeShared]
|| ''
@ -325,105 +541,11 @@ export class FolderModel extends AbstractModel {
path.pop();
folder.parentName = path.join(folder.delimiter);
folder.isSubscribed(attr('\\subscribed'));
folder.exists = !attr('\\nonexistent');
folder.selectable(folder.exists && !attr('\\noselect'));
type && 'mail' != type && folder.kolabType(type);
folder.addComputables({
isInbox: () => FolderType.Inbox === folder.type(),
isFlagged: () => FolderUserStore.currentFolder() === folder
&& MessagelistUserStore.listSearch().includes('flagged'),
hasVisibleSubfolders: () => !!folder.subFolders().find(folder => folder.visible()),
hasSubscriptions: () => folder.isSubscribed() | !!folder.subFolders().find(
oFolder => {
const subscribed = oFolder.hasSubscriptions();
return !oFolder.isSystemFolder() && subscribed;
}
),
canBeEdited: () => FolderType.User === folder.type() && folder.exists/* && folder.selectable()*/,
isSystemFolder: () => FolderType.User !== folder.type()
| (FolderUserStore.allowKolab() && !!folder.kolabType() & !SettingsUserStore.unhideKolabFolders()),
canBeSelected: () => folder.selectable() && !folder.isSystemFolder(),
canBeDeleted: () => folder.canBeSelected() && folder.exists,
canBeSubscribed: () => folder.selectable()
&& !(folder.isSystemFolder() | !SettingsUserStore.hideUnsubscribed()),
/**
* Folder is visible when:
* - hasVisibleSubfolders()
* Or when all below conditions are true:
* - selectable()
* - isSubscribed() OR hideUnsubscribed = false
* - FolderType.User
* - not kolabType()
*/
visible: () => {
const selectable = folder.canBeSelected(),
name = folder.name(),
filter = foldersFilter(),
visible = (folder.isSubscribed() | !SettingsUserStore.hideUnsubscribed())
&& selectable
&& (!filter || name.toLowerCase().includes(filter.toLowerCase()));
return folder.hasVisibleSubfolders() | visible;
},
printableUnreadCount: () => folder.unreadEmails() || null,
/*
{
// TODO: make this optional in Settings
// https://github.com/the-djmaze/snappymail/issues/457
// https://github.com/the-djmaze/snappymail/issues/567
const
unread = folder.unreadEmails(),
type = folder.type();
// return ((!folder.isSystemFolder() || type == FolderType.Inbox) && unread) ? unread : null;
},
*/
localName: () => {
let name = folder.name();
if (folder.isSystemFolder()) {
translateTrigger();
name = getSystemFolderName(folder.type(), name);
}
return name;
},
manageFolderSystemName: () => {
if (folder.isSystemFolder()) {
translateTrigger();
let suffix = getSystemFolderName(folder.type(), getKolabFolderName(folder.kolabType()));
if (folder.name() !== suffix && 'inbox' !== suffix.toLowerCase()) {
return '(' + suffix + ')';
}
}
return '';
},
hasSubscribedUnreadMessagesSubfolders: () =>
!!folder.subFolders().find(
folder => folder.printableUnreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
)
/*
!!folder.subFolders().filter(
folder => folder.printableUnreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
).length
*/
// ,href: () => folder.canBeSelected() && mailBox(folder.fullNameHash)
});
folder.addSubscribables({
editing: value => value && folder.nameForEdit(folder.name()),
unreadEmails: unread => FolderType.Inbox === folder.type() && fireEvent('mailbox.inbox-unread-count', unread)
});
}
return folder;
}

View file

@ -6,12 +6,12 @@ export class IdentityModel extends AbstractModel {
* @param {string} id
* @param {string} email
*/
constructor(id, email) {
constructor() {
super();
addObservablesTo(this, {
id: id || '',
email: email,
id: '',
email: '',
name: '',
replyTo: '',

View file

@ -1,11 +1,10 @@
import ko from 'ko';
import { MessagePriority } from 'Common/EnumsUser';
import { i18n } from 'Common/Translator';
import { doc, SettingsGet } from 'Common/Globals';
import { encodeHtml, plainToHtml, htmlToPlain, cleanHtml } from 'Common/Html';
import { arrayLength, forEachObjectEntry } from 'Common/Utils';
import { forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
import { serverRequestRaw, proxy } from 'Common/Links';
import { addObservablesTo, addComputablesTo } from 'External/ko';
@ -19,17 +18,12 @@ import { AbstractModel } from 'Knoin/AbstractModel';
import PreviewHTML from 'Html/PreviewMessage.html';
//import { MessageFlagsCache } from 'Common/Cache';
import { LanguageStore } from 'Stores/Language';
import Remote from 'Remote/User/Fetch';
const
hcont = Element.fromHTML('<div area="hidden" style="position:absolute;left:-5000px"></div>'),
getRealHeight = el => {
hcont.innerHTML = el.outerHTML;
const result = hcont.clientHeight;
hcont.innerHTML = '';
return result;
},
msgHtml = msg => cleanHtml(msg.html(), msg.attachments(), '#rl-msg-' + msg.hash),
toggleTag = (message, keyword) => {
const lower = keyword.toLowerCase(),
@ -38,13 +32,12 @@ const
Remote.request('MessageSetKeyword', iError => {
if (!iError) {
isSet ? flags.remove(lower) : flags.push(lower);
// MessageFlagsCache.setFor(message.folder, message.uid, flags());
}
}, {
Folder: message.folder,
Uids: message.uid,
Keyword: keyword,
SetAction: isSet ? 0 : 1
folder: message.folder,
uids: message.uid,
keyword: keyword,
setAction: isSet ? 0 : 1
})
},
@ -58,13 +51,29 @@ const
unic[email.email] || localEmails.has(email.email) || localEmails.set(email.email, email)
);
doc.body.append(hcont);
export class MessageModel extends AbstractModel {
constructor() {
super();
this._reset();
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 = {};
addObservablesTo(this, {
subject: '',
@ -75,8 +84,9 @@ export class MessageModel extends AbstractModel {
spamResult: '',
isSpam: false,
hasVirus: null, // or boolean when scanned
dateTimeStampInUTC: 0,
priority: MessagePriority.Normal,
dateTimestamp: 0,
internalTimestamp: 0,
priority: 3, // Normal
senderEmailsString: '',
senderClearEmailsString: '',
@ -101,35 +111,28 @@ export class MessageModel extends AbstractModel {
readReceipt: '',
hasUnseenSubMessage: false,
hasFlaggedSubMessage: 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();
addComputablesTo(this, {
attachmentIconClass: () =>
this.encrypted() ? 'icon-lock' : FileInfo.getAttachmentsIconClass(this.attachments()),
threadsLen: () => this.threads().length,
listAttachments: () => this.attachments()
.filter(item => SettingsUserStore.listInlineAttachments() || !item.isLinked()),
hasAttachments: () => this.listAttachments().length,
threadsLen: () => rl.app.messageList.threadUid() ? 0 : this.threads().length,
threadUnseenLen: () => rl.app.messageList.threadUid() ? 0 : this.threadUnseen().length,
isUnseen: () => !this.flags().includes('\\seen'),
isFlagged: () => this.flags().includes('\\flagged'),
isReadReceipt: () => this.flags().includes('$mdnsent'),
// isJunk: () => this.flags().includes('$junk') && !this.flags().includes('$nonjunk'),
// isPhishing: () => this.flags().includes('$phishing'),
tagsToHTML: () => this.flags().map(value =>
isAllowedKeyword(value)
? '<span class="focused msgflag-'+value+'">' + i18n('MESSAGE_TAGS/'+value,0,value) + '</span>'
: ''
).join(' '),
tagOptions: () => {
const tagOptions = [];
FolderUserStore.currentFolder().permanentFlags.forEach(value => {
@ -145,8 +148,41 @@ export class MessageModel extends AbstractModel {
}
});
return tagOptions
}
},
whitelistOptions: () => {
let options = [];
if ('match' === SettingsUserStore.viewImages()) {
let from = this.from[0],
list = SettingsUserStore.viewImagesWhitelist(),
counts = {};
this.html().match(/src=["'][^"']+/g)?.forEach(m => {
m = m.replace(/^.+(:\/\/[^/]+).+$/, '$1');
if (counts[m]) {
++counts[m];
} else {
counts[m] = 1;
options.push(m);
}
});
options = options.filter(txt => !list.includes(txt)).sort((a,b) => (counts[a] < counts[b])
? 1
: (counts[a] > counts[b] ? -1 : a.localeCompare(b))
);
from && options.unshift(from.email);
}
return options;
}
});
}
get requestHash() {
return b64EncodeJSONSafe({
folder: this.folder,
uid: this.uid,
mimeType: 'message/rfc822',
fileName: (this.subject() || 'message-' + this.hash) + '.eml',
accountHash: SettingsGet('accountHash')
});
}
@ -154,67 +190,6 @@ export class MessageModel extends AbstractModel {
toggleTag(this, keyword);
}
_reset() {
this.folder = '';
this.uid = 0;
this.hash = '';
this.requestHash = '';
this.emails = [];
this.from = new EmailCollectionModel;
this.to = new EmailCollectionModel;
this.cc = new EmailCollectionModel;
this.bcc = new EmailCollectionModel;
this.replyTo = new EmailCollectionModel;
this.deliveredTo = new EmailCollectionModel;
this.body = null;
this.draftInfo = [];
this.messageId = '';
this.inReplyTo = '';
this.references = '';
}
clear() {
this._reset();
this.subject('');
this.html('');
this.plain('');
this.size(0);
this.spamScore(0);
this.spamResult('');
this.isSpam(false);
this.hasVirus(null);
this.dateTimeStampInUTC(0);
this.priority(MessagePriority.Normal);
this.senderEmailsString('');
this.senderClearEmailsString('');
this.deleted(false);
this.selected(false);
this.checked(false);
this.isHtml(false);
this.hasImages(false);
this.hasExternals(false);
this.attachments(new AttachmentCollectionModel);
this.pgpSigned(null);
this.pgpVerified(null);
this.pgpEncrypted(null);
this.pgpDecrypted(false);
this.priority(MessagePriority.Normal);
this.readReceipt('');
this.threads([]);
this.unsubsribeLinks([]);
this.hasUnseenSubMessage(false);
this.hasFlaggedSubMessage(false);
}
spamStatus() {
let spam = this.spamResult();
return spam ? i18n(this.isSpam() ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
@ -240,84 +215,15 @@ export class MessageModel extends AbstractModel {
* @returns {boolean}
*/
revivePropertiesFromJson(json) {
if ('Priority' in json && ![MessagePriority.High, MessagePriority.Low].includes(json.Priority)) {
json.Priority = MessagePriority.Normal;
}
if (super.revivePropertiesFromJson(json)) {
// this.foundCIDs = isArray(json.FoundCIDs) ? json.FoundCIDs : [];
// this.attachments(AttachmentCollectionModel.reviveFromJson(json.Attachments, this.foundCIDs));
// this.attachments(AttachmentCollectionModel.reviveFromJson(json.attachments, this.foundCIDs));
this.computeSenderEmail();
return true;
}
}
/**
* @returns {boolean}
*/
hasUnsubsribeLinks() {
return this.unsubsribeLinks().length;
}
/**
* @returns {string}
*/
getFirstUnsubsribeLink() {
return this.unsubsribeLinks()[0] || '';
}
/**
* @param {boolean} friendlyView
* @param {boolean=} wrapWithLink
* @returns {string}
*/
fromToLine(friendlyView, wrapWithLink) {
return this.from.toString(friendlyView, wrapWithLink);
}
/**
* @returns {string}
*/
fromDkimData() {
let result = ['none', ''];
if (1 === arrayLength(this.from) && this.from[0]?.dkimStatus) {
result = [this.from[0].dkimStatus, this.from[0].dkimValue || ''];
}
return result;
}
/**
* @param {boolean} friendlyView
* @param {boolean=} wrapWithLink
* @returns {string}
*/
toToLine(friendlyView, wrapWithLink) {
return this.to.toString(friendlyView, wrapWithLink);
}
/**
* @param {boolean} friendlyView
* @param {boolean=} wrapWithLink
* @returns {string}
*/
ccToLine(friendlyView, wrapWithLink) {
return this.cc.toString(friendlyView, wrapWithLink);
}
/**
* @returns {string}
*/
bccToLine() {
return this.bcc.toString();
}
/**
* @returns {string}
*/
replyToToLine() {
return this.replyTo.toString();
}
/**
* @return string
*/
@ -329,20 +235,22 @@ export class MessageModel extends AbstractModel {
checked: this.checked(),
unseen: this.isUnseen(),
focused: this.focused(),
priorityHigh: this.priority() === MessagePriority.High,
priorityHigh: this.priority() === 1,
withAttachments: !!this.attachments().length,
// hasChildrenMessage: 1 < this.threadsLen(),
hasUnseenSubMessage: this.hasUnseenSubMessage(),
hasFlaggedSubMessage: this.hasFlaggedSubMessage()
// hasChildrenMessage: 1 < this.threadsLen()
}, (key, value) => value && classes.push(key));
flags && this.flags().forEach(value => classes.push('msgflag-'+value));
return classes.join(' ');
}
indent() {
return this.level ? 'margin-left:'+this.level+'em' : null;
}
/**
* @returns {string}
*/
viewLink() {
viewRaw() {
return serverRequestRaw('ViewAsPlain', this.requestHash);
}
@ -386,83 +294,77 @@ export class MessageModel extends AbstractModel {
return [[...toResult.values()], [...ccResult.values()]];
}
viewHtml() {
viewBody(html) {
const body = this.body;
if (body && this.html()) {
let result = cleanHtml(this.html(), this.attachments());
this.hasExternals(result.hasExternals);
this.hasImages(body.rlHasImages = !!result.hasExternals);
body.innerHTML = result.html;
body.classList.toggle('html', 1);
body.classList.toggle('plain', 0);
if (SettingsUserStore.showImages()) {
this.showExternalImages();
if (body) {
if (html) {
let result = msgHtml(this);
this.hasExternals(result.hasExternals);
this.hasImages(!!result.hasExternals);
body.innerHTML = result.html;
if (!this.isSpam() && FolderUserStore.spamFolder() != this.folder) {
if ('always' === SettingsUserStore.viewImages()) {
this.showExternalImages();
}
if ('match' === SettingsUserStore.viewImages()) {
this.showExternalImages(1);
}
}
} else {
body.innerHTML = plainToHtml(
(this.plain()
? this.plain()
.replace(/-----BEGIN PGP (SIGNED MESSAGE-----(\r?\n[^\r\n]+)+|SIGNATURE-----[\s\S]*)/sg, '')
.trim()
: htmlToPlain(body.innerHTML)
)
);
this.hasImages(false);
}
this.isHtml(true);
this.initView();
body.classList.toggle('html', html);
body.classList.toggle('plain', !html);
this.isHtml(html);
return true;
}
}
viewHtml() {
return this.html() && this.viewBody(true);
}
viewPlain() {
const body = this.body;
if (body) {
body.classList.toggle('html', 0);
body.classList.toggle('plain', 1);
body.innerHTML = plainToHtml(
(this.plain()
? this.plain()
.replace(/-----BEGIN PGP (SIGNED MESSAGE-----(\r?\n[a-z][^\r\n]+)+|SIGNATURE-----[\s\S]*)/, '')
.trim()
: htmlToPlain(body.innerHTML)
)
);
this.isHtml(false);
this.hasImages(false);
this.initView();
return true;
}
}
initView() {
// init BlockquoteSwitcher
this.body.querySelectorAll('blockquote').forEach(node => {
if (node.textContent.trim()) {
let h = node.clientHeight || getRealHeight(node);
if (0 === h || 100 < h) {
const el = Element.fromHTML('<details class="sm-bq-switcher"><summary>•••</summary></details>');
node.replaceWith(el);
el.append(node);
}
}
});
return this.viewBody(false);
}
viewPopupMessage(print) {
const timeStampInUTC = this.dateTimeStampInUTC() || 0,
ccLine = this.ccToLine(),
const
timeStampInUTC = this.dateTimestamp() || 0,
ccLine = this.cc.toString(),
bccLine = this.bcc.toString(),
m = 0 < timeStampInUTC ? new Date(timeStampInUTC * 1000) : null,
win = open(''),
sdoc = win.document;
let subject = encodeHtml(this.subject()),
win = open('', 'sm-msg-'+this.requestHash
/*,newWindow ? 'innerWidth=' + elementById('V-MailMessageView').clientWidth : ''*/
),
sdoc = win.document,
subject = encodeHtml(this.subject()),
mode = this.isHtml() ? 'div' : 'pre',
cc = ccLine ? `<div>${encodeHtml(i18n('GLOBAL/CC'))}: ${encodeHtml(ccLine)}</div>` : '',
to = `<div>${encodeHtml(i18n('GLOBAL/TO'))}: ${encodeHtml(this.to)}</div>`
+ (ccLine ? `<div>${encodeHtml(i18n('GLOBAL/CC'))}: ${encodeHtml(ccLine)}</div>` : '')
+ (bccLine ? `<div>${encodeHtml(i18n('GLOBAL/BCC'))}: ${encodeHtml(bccLine)}</div>` : ''),
style = getComputedStyle(doc.querySelector('.messageView')),
prop = property => style.getPropertyValue(property);
let attachments = '';
this.attachments.forEach(attachment => {
attachments += `<a href="${attachment.linkDownload()}">${attachment.fileName}</a>`;
});
sdoc.write(PreviewHTML
.replace('<title>', '<title>'+subject)
// eslint-disable-next-line max-len
.replace('<body>', `<body style="background-color:${prop('background-color')};color:${prop('color')}"><header><h1>${subject}</h1><time>${encodeHtml(m ? m.format('LLL') : '')}</time><div>${encodeHtml(this.fromToLine())}</div><div>${encodeHtml(i18n('GLOBAL/TO'))}: ${encodeHtml(this.toToLine())}</div>${cc}</header><${mode}>${this.bodyAsHTML()}</${mode}>`)
.replace('<body>', `<body style="background-color:${prop('background-color')};color:${prop('color')}"><header><h1>${subject}</h1><time>${encodeHtml(m ? m.format('LLL',0,LanguageStore.hourCycle()) : '')}</time><div>${encodeHtml(this.from)}</div>${to}</header><${mode}>${this.bodyAsHTML()}</${mode}>`)
.replace('</body>', `<div id="attachments">${attachments}</div></body>`)
);
sdoc.close();
if (print) {
setTimeout(() => win.print(), 100);
}
print && setTimeout(() => win.print(), 100);
}
/**
@ -484,75 +386,72 @@ export class MessageModel extends AbstractModel {
}
/**
* @param {MessageModel} message
* @returns {MessageModel}
*/
static fromMessageListItem(message) {
*//*
clone() {
let self = new MessageModel();
if (message) {
self.folder = message.folder;
self.uid = message.uid;
self.hash = message.hash;
self.requestHash = message.requestHash;
self.subject(message.subject());
self.plain(message.plain());
self.html(message.html());
self.size(message.size());
self.spamScore(message.spamScore());
self.spamResult(message.spamResult());
self.isSpam(message.isSpam());
self.hasVirus(message.hasVirus());
self.dateTimeStampInUTC(message.dateTimeStampInUTC());
self.priority(message.priority());
self.hasExternals(message.hasExternals());
self.emails = message.emails;
self.from = message.from;
self.to = message.to;
self.cc = message.cc;
self.bcc = message.bcc;
self.replyTo = message.replyTo;
self.deliveredTo = message.deliveredTo;
self.unsubsribeLinks(message.unsubsribeLinks);
self.flags(message.flags());
self.priority(message.priority());
self.selected(message.selected());
self.checked(message.checked());
self.attachments(message.attachments());
self.threads(message.threads());
}
// Clone message values
forEachObjectEntry(this, (key, value) => {
if (ko.isObservable(value)) {
ko.isComputed(value) || self[key](value());
} else if (!isFunction(value)) {
self[key] = value;
}
});
self.computeSenderEmail();
return self;
}
}*/
showExternalImages() {
showExternalImages(regex) {
const body = this.body;
if (body && this.hasImages()) {
this.hasImages(false);
body.rlHasImages = false;
let attr = 'data-x-src',
src, useProxy = !!SettingsGet('UseLocalProxyForExternalImages');
if (regex) {
regex = [];
SettingsUserStore.viewImagesWhitelist().trim().split(/[\s\r\n,;]+/g).forEach(rule => {
rule = rule.split('+');
rule[0] = rule[0].trim();
if (rule[0]
&& (!rule.includes('spf') || 'pass' === this.spf[0]?.[0])
&& (!rule.includes('dkim') || 'pass' === this.dkim[0]?.[0])
&& (!rule.includes('dmarc') || 'pass' === this.dmarc[0]?.[0])
) {
regex.push(rule[0].replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'));
}
});
regex = regex.join('|').replace(/\|+/g, '|');
if (regex) {
console.log('whitelist images = '+regex);
regex = new RegExp(regex);
if (this.from[0]?.email.match(regex)) {
regex = null;
}
}
}
let hasImages = false,
isValid = src => {
if (null == regex || (regex && src.match(regex))) {
return true;
}
hasImages = true;
},
attr = 'data-x-src',
src, useProxy = !!SettingsGet('useLocalProxyForExternalImages');
body.querySelectorAll('img[' + attr + ']').forEach(node => {
src = node.getAttribute(attr);
node.src = useProxy ? proxy(src) : src;
if (isValid(src)) {
node.src = useProxy ? proxy(src) : src;
}
});
body.querySelectorAll('[data-x-style-url]').forEach(node => {
JSON.parse(node.dataset.xStyleUrl).forEach(data =>
node.style[data[0]] = "url('" + (useProxy ? proxy(data[1]) : data[1]) + "')"
);
JSON.parse(node.dataset.xStyleUrl).forEach(data => {
if (isValid(data[1])) {
node.style[data[0]] = "url('" + (useProxy ? proxy(data[1]) : data[1]) + "')"
}
});
});
this.hasImages(hasImages);
}
}
@ -567,7 +466,7 @@ export class MessageModel extends AbstractModel {
);
return clone.innerHTML;
}
let result = cleanHtml(this.html(), this.attachments())
let result = msgHtml(this);
return result.html || plainToHtml(this.plain());
}

View file

@ -1,9 +1,6 @@
import { AbstractCollectionModel } from 'Model/AbstractCollection';
import { MessageModel } from 'Model/Message';
import {
MessageFlagsCache
} from 'Common/Cache';
import { MessageUserStore } from 'Stores/User/Message';
'use strict';
@ -12,19 +9,16 @@ export class MessageCollectionModel extends AbstractCollectionModel
/*
constructor() {
super();
this.Filtered
this.Folder
this.FolderHash
this.FolderInfo
this.filtered
this.folder
this.totalEmails
this.unreadEmails
this.MessageResultCount
this.UidNext
this.ThreadUid
this.NewMessages
this.Offset
this.Limit
this.Search
this.totalThreads
this.threadUid
this.newMessages
this.offset
this.limit
this.search
this.limited
}
*/
@ -32,12 +26,14 @@ export class MessageCollectionModel extends AbstractCollectionModel
* @param {?Object} json
* @returns {MessageCollectionModel}
*/
static reviveFromJson(object, cached) {
static reviveFromJson(object/*, cached*/) {
let msg = MessageUserStore.message();
return super.reviveFromJson(object, message => {
message = MessageModel.reviveFromJson(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);
cached ? MessageFlagsCache.initMessage(message) : MessageFlagsCache.store(message);
return message;
}
});

View file

@ -1,4 +1,4 @@
import { Notification } from 'Common/Enums';
import { Notifications } from 'Common/Enums';
import { isArray, pInt, pString } from 'Common/Utils';
import { serverRequest } from 'Common/Links';
import { getNotification } from 'Common/Translator';
@ -9,17 +9,18 @@ const getURL = (add = '') => serverRequest('Json') + pString(add),
checkResponseError = data => {
const err = data ? data.ErrorCode : null;
if (Notification.InvalidToken === err) {
alert(getNotification(err));
if (Notifications.InvalidToken === err) {
console.error(getNotification(err));
// alert(getNotification(err));
rl.logoutReload();
} else if ([
Notification.AuthError,
Notification.ConnectionError,
Notification.DomainNotAllowed,
Notification.AccountNotAllowed,
Notification.MailServerError,
Notification.UnknownNotification,
Notification.UnknownError
Notifications.AuthError,
Notifications.ConnectionError,
Notifications.DomainNotAllowed,
Notifications.AccountNotAllowed,
Notifications.MailServerError,
Notifications.UnknownNotification,
Notifications.UnknownError
].includes(err)
) {
if (7 < ++iJsonErrorCount) {
@ -31,32 +32,34 @@ checkResponseError = data => {
oRequests = {},
abort = (sAction, sReason, bClearOnly) => {
if (oRequests[sAction]) {
if (!bClearOnly && oRequests[sAction].abort) {
// oRequests[sAction].__aborted = true;
oRequests[sAction].abort(sReason || 'AbortError');
}
oRequests[sAction] = null;
delete oRequests[sAction];
let controller = oRequests[sAction];
oRequests[sAction] = null;
if (controller) {
clearTimeout(controller.timeoutId);
bClearOnly || controller.abort(sReason || 'AbortError');
}
},
fetchJSON = (action, sGetAdd, params, timeout, jsonCallback) => {
params = params || {};
if (params instanceof FormData) {
params.set('Action', action);
} else {
params.Action = action;
fetchJSON = (action, sUrl, params, timeout, jsonCallback) => {
if (params) {
if (params instanceof FormData) {
params.set('Action', action);
} else {
params.Action = action;
}
}
// Don't abort, read https://github.com/the-djmaze/snappymail/issues/487
// abort(action);
// abort(action, 0, 1);
const controller = new AbortController(),
signal = controller.signal;
oRequests[action] = controller;
// Currently there is no way to combine multiple signals, so AbortSignal.timeout() not possible
timeout && setTimeout(() => abort(action, 'TimeoutError'), timeout);
return rl.fetchJSON(getURL(sGetAdd), {signal: signal}, sGetAdd ? null : params).then(jsonCallback).catch(err => {
controller.timeoutId = timeout && setTimeout(() => abort(action, 'TimeoutError'), timeout);
return rl.fetchJSON(sUrl, {signal: signal}, params).then(data => {
abort(action, 0, 1);
return jsonCallback ? jsonCallback(data) : Promise.resolve(data);
}).catch(err => {
clearTimeout(controller.timeoutId);
err.aborted = signal.aborted;
err.reason = signal.reason;
return Promise.reject(err);
@ -67,14 +70,14 @@ class FetchError extends Error
{
constructor(code, message) {
super(message);
this.code = code || Notification.JsonFalse;
this.code = code || Notifications.JsonFalse;
}
}
export class AbstractFetchRemote
{
abort(sAction) {
abort(sAction);
abort(sAction, sReason) {
abort(sAction, sReason);
return this;
}
@ -123,31 +126,17 @@ export class AbstractFetchRemote
* @param {?number=} iTimeout
* @param {string=} sGetAdd = ''
*/
request(sAction, fCallback, params, iTimeout, sGetAdd, abortActions) {
request(sAction, fCallback, params, iTimeout, sGetAdd) {
params = params || {};
const start = Date.now();
abortActions && console.error('abortActions is obsolete');
fetchJSON(sAction, sGetAdd,
params,
fetchJSON(sAction, getURL(sGetAdd),
sGetAdd ? null : (params || {}),
undefined === iTimeout ? 30000 : pInt(iTimeout),
data => {
let cached = false;
if (data?.Time) {
cached = pInt(data.Time) > Date.now() - start;
}
let iError = 0;
if (sAction && oRequests[sAction]) {
if (oRequests[sAction].__aborted) {
iError = 2;
}
abort(sAction, 0, 1);
}
if (!iError && data) {
if (data) {
/*
if (sAction !== data.Action) {
console.log(sAction + ' !== ' + data.Action);
@ -157,16 +146,19 @@ export class AbstractFetchRemote
iJsonErrorCount = 0;
} else {
checkResponseError(data);
iError = data.ErrorCode || Notification.UnknownError
iError = data.ErrorCode || Notifications.UnknownError
}
}
fCallback && fCallback(
iError,
data,
cached,
sAction,
params
/**
* Responses like "304 Not Modified" are returned as "200 OK"
* This is an attempt to detect if the request comes from cache.
* But when client has wrong date/time, it will fail.
*/
data?.epoch && data.epoch < Math.floor(start / 1000) - 60
);
}
)
@ -195,19 +187,23 @@ export class AbstractFetchRemote
}
}
get(action, url) {
return fetchJSON(action, url);
}
post(action, fTrigger, params, timeOut) {
this.setTrigger(fTrigger, true);
return fetchJSON(action, '', params, pInt(timeOut, 30000),
return fetchJSON(action, getURL(), params || {}, pInt(timeOut, 30000),
data => {
abort(action, 0, 1);
if (!data) {
return Promise.reject(new FetchError(Notification.JsonParse));
return Promise.reject(new FetchError(Notifications.JsonParse));
}
/*
let isCached = false, type = '';
if (data?.Time) {
isCached = pInt(data.Time) > microtime() - start;
if (data?.epoch) {
isCached = data.epoch > microtime() - start;
}
// backward capability
switch (true) {

View file

@ -9,57 +9,11 @@ import { SUB_QUERY_PREFIX } from 'Common/Links';
import { AppUserStore } from 'Stores/User/App';
import { SettingsUserStore } from 'Stores/User/Settings';
import { FolderUserStore } from 'Stores/User/Folder';
import { AbstractFetchRemote } from 'Remote/AbstractFetch';
class RemoteUserFetch extends AbstractFetchRemote {
/**
* @param {Function} fCallback
* @param {object} params
* @param {boolean=} bSilent = false
*/
messageList(fCallback, params, bSilent = false) {
const
sFolderFullName = pString(params.Folder),
folder = getFolderFromCacheList(sFolderFullName),
folderHash = folder?.hash || '';
params = Object.assign({
Offset: 0,
Limit: SettingsUserStore.messagesPerPage(),
Search: '',
UidNext: folder?.uidNext || 0, // Used to check for new messages
Sort: FolderUserStore.sortMode(),
Hash: folderHash + SettingsGet('AccountHash')
}, params);
params.Folder = sFolderFullName;
if (AppUserStore.threadsAllowed() && SettingsUserStore.useThreads()) {
params.UseThreads = 1;
} else {
params.ThreadUid = 0;
}
let sGetAdd = '';
if (folderHash && (!params.Search || !params.Search.includes('is:'))) {
sGetAdd = 'MessageList/' +
SUB_QUERY_PREFIX +
'/' +
b64EncodeJSONSafe(params);
params = {};
}
bSilent || this.abort('MessageList');
this.request('MessageList',
fCallback,
params,
60000, // 60 seconds before aborting
sGetAdd
);
}
/**
* @param {?Function} fCallback
* @param {string} sFolderFullName
@ -82,7 +36,7 @@ class RemoteUserFetch extends AbstractFetchRemote {
sFolderFullName,
iUid,
AppUserStore.threadsAllowed() && SettingsUserStore.useThreads() ? 1 : 0,
SettingsGet('AccountHash')
SettingsGet('accountHash')
])
);
@ -114,9 +68,9 @@ class RemoteUserFetch extends AbstractFetchRemote {
/*
folderMove(sPrevFolderFullName, sNewFolderFullName, bSubscribe) {
return this.post('FolderMove', FolderUserStore.foldersRenaming, {
Folder: sPrevFolderFullName,
NewFolder: sNewFolderFullName,
Subscribe: bSubscribe ? 1 : 0
folder: sPrevFolderFullName,
newFolder: sNewFolderFullName,
subscribe: bSubscribe ? 1 : 0
});
}
*/

View file

@ -2,7 +2,7 @@ import ko from 'ko';
import { pString } from 'Common/Utils';
import { settings } from 'Common/Links';
import { createElement, elementById } from 'Common/Globals';
import { createElement/*, elementById*/, fireEvent } from 'Common/Globals';
import { AbstractScreen } from 'Knoin/AbstractScreen';
import { i18nToNodes } from 'Common/Translator';
@ -25,35 +25,43 @@ export class AbstractSettingsScreen extends AbstractScreen {
let settingsScreen = null,
viewModelDom = null,
RoutedSettingsViewModel = VIEW_MODELS.find(
SettingsViewModel => subName === SettingsViewModel.__rlSettingsData.route
SettingsViewModel => subName === SettingsViewModel.route
);
if (RoutedSettingsViewModel) {
const vmPlace = elementById('rl-settings-subscreen');
if (RoutedSettingsViewModel.__vm) {
settingsScreen = RoutedSettingsViewModel.__vm;
// const vmPlace = elementById('V-SettingsPane') || elementById('V-AdminPane);
const vmPlace = this.viewModels[1].__dom,
SettingsViewModelClass = RoutedSettingsViewModel.vmc;
if (SettingsViewModelClass.__vm) {
settingsScreen = SettingsViewModelClass.__vm;
viewModelDom = settingsScreen.viewModelDom;
} else if (vmPlace) {
viewModelDom = createElement('div',{
id: 'V-Settings-' + RoutedSettingsViewModel.name.replace(/(User|Admin)Settings/,''),
id: 'V-Settings-' + SettingsViewModelClass.name.replace(/(User|Admin)Settings/,''),
hidden: ''
})
vmPlace.append(viewModelDom);
settingsScreen = new RoutedSettingsViewModel();
settingsScreen = new SettingsViewModelClass();
settingsScreen.viewModelDom = viewModelDom;
settingsScreen.viewModelTemplateID = RoutedSettingsViewModel.template;
RoutedSettingsViewModel.__dom = viewModelDom;
RoutedSettingsViewModel.__vm = settingsScreen;
SettingsViewModelClass.__dom = viewModelDom;
SettingsViewModelClass.__vm = settingsScreen;
fireEvent('rl-view-model.create', settingsScreen);
ko.applyBindingAccessorsToNode(
viewModelDom,
{
template: () => ({ name: RoutedSettingsViewModel.__rlSettingsData.template })
template: () => ({ name: RoutedSettingsViewModel.template })
},
settingsScreen
);
settingsScreen.onBuild?.(viewModelDom);
fireEvent('rl-view-model', settingsScreen);
} else {
console.log('Cannot find sub settings view model position: SettingsSubScreen');
}
@ -68,13 +76,13 @@ export class AbstractSettingsScreen extends AbstractScreen {
// show
settingsScreen.beforeShow?.();
i18nToNodes(settingsScreen.viewModelDom);
settingsScreen.viewModelDom.hidden = false;
i18nToNodes(viewModelDom);
viewModelDom.hidden = false;
settingsScreen.onShow?.();
this.menu.forEach(item => {
item.selected(
item.route === RoutedSettingsViewModel.__rlSettingsData.route
item.route === RoutedSettingsViewModel.route
);
});
@ -96,15 +104,17 @@ export class AbstractSettingsScreen extends AbstractScreen {
}
onBuild() {
VIEW_MODELS.forEach(SettingsViewModel => this.menu.push(SettingsViewModel.__rlSettingsData));
// TODO: issue on account switch
// When current domain has sieve but the new has not, or current has not and the new has
// SettingsViewModel.disabled() || this.menu.push()
VIEW_MODELS.forEach(SettingsViewModel => this.menu.push(SettingsViewModel));
}
routes() {
const DefaultViewModel = VIEW_MODELS.find(
SettingsViewModel => SettingsViewModel.__rlSettingsData.isDefault
SettingsViewModel => SettingsViewModel.isDefault
),
defaultRoute =
DefaultViewModel ? DefaultViewModel.__rlSettingsData.route : 'general',
defaultRoute = DefaultViewModel?.route || 'general',
rules = {
subname: /^(.*)$/,
normalize_: (rquest, vals) => {
@ -131,13 +141,12 @@ export class AbstractSettingsScreen extends AbstractScreen {
*/
export function settingsAddViewModel(SettingsViewModelClass, template, labelName, route, isDefault = false) {
let name = SettingsViewModelClass.name.replace(/(User|Admin)Settings/, '');
SettingsViewModelClass.__rlSettingsData = {
label: labelName || 'SETTINGS_LABELS/LABEL_' + name.toUpperCase() + '_NAME',
VIEW_MODELS.push({
vmc: SettingsViewModelClass,
label: labelName || 'SETTINGS_LABELS/' + name.toUpperCase(),
route: route || name.toLowerCase(),
selected: ko.observable(false),
template: template || SettingsViewModelClass.name,
isDefault: !!isDefault
};
VIEW_MODELS.push(SettingsViewModelClass);
});
}

View file

@ -8,6 +8,6 @@ export class LoginAdminScreen extends AbstractScreen {
}
onShow() {
rl.setWindowTitle();
rl.setTitle();
}
}

View file

@ -37,6 +37,6 @@ export class SettingsAdminScreen extends AbstractSettingsScreen {
}
onShow() {
rl.setWindowTitle();
rl.setTitle();
}
}

View file

@ -8,6 +8,6 @@ export class LoginUserScreen extends AbstractScreen {
}
onShow() {
rl.setWindowTitle();
rl.setTitle();
}
}

View file

@ -1,17 +1,14 @@
import { Scope } from 'Common/Enums';
import { Layout, ClientSideKeyNameMessageListSize } from 'Common/EnumsUser';
import { doc, createElement, leftPanelDisabled, moveAction, Settings, elementById } from 'Common/Globals';
import { ScopeMessageList } from 'Common/Enums';
import { doc, createElement, Settings } from 'Common/Globals';
import { pString, pInt } from 'Common/Utils';
import { setLayoutResizer } from 'Common/UtilsUser';
import { getFolderFromCacheList, getFolderFullName, getFolderInboxName } from 'Common/Cache';
import { moveAction } from 'Common/UtilsUser';
import { getFolderFromHashMap, getFolderInboxName } from 'Common/Cache';
import { i18n, initOnStartOrLangChange } from 'Common/Translator';
import { SettingsUserStore } from 'Stores/User/Settings';
import { AppUserStore } from 'Stores/User/App';
import { AccountUserStore } from 'Stores/User/Account';
import { FolderUserStore } from 'Stores/User/Folder';
import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { ThemeStore } from 'Stores/Theme';
import { SystemDropDownUserView } from 'View/User/SystemDropDown';
import { MailFolderList } from 'View/User/MailBox/FolderList';
@ -46,7 +43,7 @@ export class MailBoxUserScreen extends AbstractScreen {
const count = Settings.app('listPermanentFiltered') ? 0 : FolderUserStore.foldersInboxUnreadCount(),
email = AccountUserStore.email();
rl.setWindowTitle(
rl.setTitle(
(email
? '' + (0 < count ? '(' + count + ') ' : ' ') + email + ' - '
: ''
@ -59,11 +56,8 @@ export class MailBoxUserScreen extends AbstractScreen {
*/
onShow() {
this.updateWindowTitle();
AppUserStore.focusedState('none');
AppUserStore.focusedState(Scope.MessageList);
ThemeStore.isMobile() && leftPanelDisabled(true);
AppUserStore.focusedState(ScopeMessageList);
}
/**
@ -73,7 +67,7 @@ export class MailBoxUserScreen extends AbstractScreen {
* @returns {void}
*/
onRoute(folderHash, page, search, messageUid) {
const folder = getFolderFromCacheList(getFolderFullName(folderHash.replace(/~([\d]+)$/, '')));
const folder = getFolderFromHashMap(folderHash.replace(/~([\d]+)$/, ''));
if (folder) {
FolderUserStore.currentFolder(folder);
MessagelistUserStore.page(1 > page ? 1 : page);
@ -101,7 +95,7 @@ export class MailBoxUserScreen extends AbstractScreen {
FolderUserStore.foldersInboxUnreadCount(e.detail);
/* // Disabled in SystemDropDown.html
const email = AccountUserStore.email();
AccountUserStore.accounts.forEach(item =>
AccountUserStore.forEach(item =>
email === item?.email && item?.count(e.detail)
);
*/
@ -113,24 +107,6 @@ export class MailBoxUserScreen extends AbstractScreen {
* @returns {void}
*/
onBuild() {
setTimeout(() => {
// initMailboxLayoutResizer
const top = elementById('V-MailMessageList'),
bottom = elementById('V-MailMessageView'),
fToggle = () => {
let layout = SettingsUserStore.layout();
setLayoutResizer(top, bottom, ClientSideKeyNameMessageListSize,
(ThemeStore.isMobile() || Layout.NoPreview === layout)
? 0
: (Layout.SidePreview === layout ? 'Width' : 'Height')
);
};
if (top && bottom) {
fToggle();
addEventListener('rl-layout', fToggle);
}
}, 1);
doc.addEventListener('click', event =>
event.target.closest('#rl-right') && moveAction(false)
);

View file

@ -1,11 +1,10 @@
import { Scope } from 'Common/Enums';
import { keyScope, leftPanelDisabled, SettingsCapa } from 'Common/Globals';
import { ScopeSettings } from 'Common/Enums';
import { keyScope, SettingsCapa } from 'Common/Globals';
import { runSettingsViewModelHooks } from 'Common/Plugins';
import { initOnStartOrLangChange, i18n } from 'Common/Translator';
import { AppUserStore } from 'Stores/User/App';
import { AccountUserStore } from 'Stores/User/Account';
import { ThemeStore } from 'Stores/Theme';
import { AbstractSettingsScreen, settingsAddViewModel } from 'Screen/AbstractSettings';
@ -23,7 +22,7 @@ import { SettingsPaneUserView } from 'View/User/Settings/Pane';
export class SettingsUserScreen extends AbstractSettingsScreen {
constructor() {
super([SystemDropDownUserView, SettingsMenuUserView, SettingsPaneUserView]);
super([SettingsMenuUserView, SettingsPaneUserView, SystemDropDownUserView]);
const views = [
UserSettingsGeneral
@ -37,6 +36,8 @@ export class SettingsUserScreen extends AbstractSettingsScreen {
views.push(UserSettingsAccounts);
}
// TODO: issue on account switch
// When current domain has sieve but the new has not, or current has not and the new has
if (SettingsCapa('Sieve')) {
views.push(UserSettingsFilters);
}
@ -66,12 +67,11 @@ export class SettingsUserScreen extends AbstractSettingsScreen {
onShow() {
this.setSettingsTitle();
keyScope(Scope.Settings);
ThemeStore.isMobile() && leftPanelDisabled(true);
keyScope(ScopeSettings);
}
setSettingsTitle() {
const sEmail = AccountUserStore.email();
rl.setWindowTitle((sEmail ? sEmail + ' - ' : '') + this.sSettingsTitle);
rl.setTitle((sEmail ? sEmail + ' - ' : '') + this.sSettingsTitle);
}
}

View file

@ -16,6 +16,9 @@ export class AdminSettingsAbout /*extends AbstractViewSettings*/ {
coreWarning: false,
coreVersion: '',
coreVersionCompare: -2,
load1: 0,
load5: 0,
load15: 0,
errorDesc: ''
});
this.coreChecking = ko.observable(false).extend({ throttle: 100 });
@ -51,18 +54,21 @@ export class AdminSettingsAbout /*extends AbstractViewSettings*/ {
}
onBuild() {
Remote.request('AdminPHPExtensions', (iError, data) => iError || this.phpextensions(data.Result));
// beforeShow() {
this.coreChecking(true);
Remote.request('AdminUpdateInfo', (iError, data) => {
Remote.request('AdminInfo', (iError, data) => {
this.coreChecking(false);
if (!iError && data?.Result) {
data = data?.Result;
if (!iError && data) {
this.load1(data.system.load?.[0]);
this.load5(data.system.load?.[1]);
this.load15(data.system.load?.[2]);
this.phpextensions(data.php);
this.coreReal(true);
this.coreUpdatable(!!data.Result.Updatable);
this.coreWarning(!!data.Result.Warning);
this.coreVersion(data.Result.Version || '');
this.coreVersionCompare(data.Result.VersionCompare);
this.coreUpdatable(!!data.core.updatable);
this.coreWarning(!!data.core.warning);
this.coreVersion(data.core.version || '');
this.coreVersionCompare(data.core.versionCompare);
} else {
this.coreReal(false);
this.coreWarning(false);

View file

@ -3,8 +3,8 @@ import { AbstractViewSettings } from 'Knoin/AbstractViews';
export class AdminSettingsBranding extends AbstractViewSettings {
constructor() {
super();
this.addSetting('Title');
this.addSetting('LoadingDescription');
this.addSetting('FaviconUrl');
this.addSetting('title');
this.addSetting('loadingDescription');
this.addSetting('faviconUrl');
}
}

View file

@ -13,16 +13,20 @@ export class AdminSettingsContacts extends AbstractViewSettings {
super();
this.defaultOptionsAfterRender = defaultOptionsAfterRender;
this.addSetting('ContactsPdoDsn');
this.addSetting('ContactsPdoUser');
this.addSetting('ContactsPdoPassword');
this.addSetting('ContactsPdoType', () => {
this.addSetting('contactsPdoDsn');
this.addSetting('contactsPdoUser');
this.addSetting('contactsPdoPassword');
this.addSetting('contactsPdoType', () => {
this.testContactsSuccess(false);
this.testContactsError(false);
this.testContactsErrorMessage('');
});
this.addSettings(['ContactsEnable','ContactsSync']);
this.addSettings(['contactsEnable','contactsSync']);
this.addSetting('contactsMySQLSSLCA');
this.addSetting('contactsMySQLSSLVerify');
this.addSetting('contactsMySQLSSLCiphers');
addObservablesTo(this, {
testing: false,
@ -31,6 +35,8 @@ export class AdminSettingsContacts extends AbstractViewSettings {
testContactsErrorMessage: ''
});
this.addSetting('contactsSuggestionsLimit');
const supportedTypes = SettingsGet('supportedPdoDrivers') || [],
types = [{
id:'sqlite',
@ -90,10 +96,13 @@ export class AdminSettingsContacts extends AbstractViewSettings {
this.testing(false);
}, {
ContactsPdoType: this.contactsPdoType(),
ContactsPdoDsn: this.contactsPdoDsn(),
ContactsPdoUser: this.contactsPdoUser(),
ContactsPdoPassword: this.contactsPdoPassword()
PdoType: this.contactsPdoType(),
PdoDsn: this.contactsPdoDsn(),
PdoUser: this.contactsPdoUser(),
PdoPassword: this.contactsPdoPassword(),
MySQLSSLCA: this.contactsMySQLSSLCA(),
MySQLSSLVerify: this.contactsMySQLSSLVerify(),
MySQLSSLCiphers: this.contactsMySQLSSLCiphers()
}
);
}

View file

@ -11,10 +11,25 @@ import { DomainAliasPopupView } from 'View/Popup/DomainAlias';
export class AdminSettingsDomains /*extends AbstractViewSettings*/ {
constructor() {
this.domains = DomainAdminStore;
this.username = ko.observable('');
this.domainForDeletion = ko.observable(null).askDeleteHelper();
}
testUsername() {
Remote.request('AdminDomainMatch',
(iError, oData) => {
if (oData?.Result?.domain) {
alert(`${oData.Result.email} matched domain: ${oData.Result.domain.name}`);
} else {
alert('No domain match');
}
},
{
username: this.username
}
);
}
createDomain() {
showScreenPopup(DomainPopupView);
}
@ -26,15 +41,15 @@ export class AdminSettingsDomains /*extends AbstractViewSettings*/ {
deleteDomain(domain) {
DomainAdminStore.remove(domain);
Remote.request('AdminDomainDelete', DomainAdminStore.fetch, {
Name: domain.name
name: domain.name
});
}
disableDomain(domain) {
domain.disabled(!domain.disabled());
Remote.request('AdminDomainDisable', DomainAdminStore.fetch, {
Name: domain.name,
Disabled: domain.disabled() ? 1 : 0
name: domain.name,
disabled: domain.disabled() ? 1 : 0
});
}
@ -44,7 +59,7 @@ export class AdminSettingsDomains /*extends AbstractViewSettings*/ {
el && ko.dataFor(el) && Remote.request('AdminDomainLoad',
(iError, oData) => iError || showScreenPopup(DomainPopupView, [oData.Result]),
{
Name: ko.dataFor(el).name
name: ko.dataFor(el).name
}
);

View file

@ -1,9 +1,7 @@
import ko from 'ko';
import {
isArray,
changeTheme,
convertThemeName
isArray
} from 'Common/Utils';
import { addObservablesTo, addSubscribablesTo, addComputablesTo } from 'External/ko';
@ -17,7 +15,7 @@ import { showScreenPopup } from 'Knoin/Knoin';
import Remote from 'Remote/Admin/Fetch';
import { ThemeStore } from 'Stores/Theme';
import { ThemeStore, convertThemeName, changeTheme } from 'Stores/Theme';
import { LanguageStore } from 'Stores/Language';
import { LanguagesPopupView } from 'View/Popup/Languages';
@ -30,16 +28,14 @@ export class AdminSettingsGeneral extends AbstractViewSettings {
const aLanguagesAdmin = Settings.app('languagesAdmin');
this.languagesAdmin = ko.observableArray(isArray(aLanguagesAdmin) ? aLanguagesAdmin : []);
this.languageAdmin = ko.observable(SettingsGet('LanguageAdmin'));
this.languageAdmin = ko.observable(SettingsGet('languageAdmin'));
this.theme = ThemeStore.theme;
this.themes = ThemeStore.themes;
this.addSettings(['AllowLanguagesOnSettings']);
this.addSettings(['allowLanguagesOnSettings']);
addObservablesTo(this, {
attachmentLimitTrigger: SaveSettingStatus.Idle,
themeTrigger: SaveSettingStatus.Idle,
capaThemes: SettingsCapa('Themes'),
capaUserBackground: SettingsCapa('UserBackground'),
capaAdditionalAccounts: SettingsCapa('AdditionalAccounts'),
@ -57,14 +53,14 @@ export class AdminSettingsGeneral extends AbstractViewSettings {
*/
this.attachmentLimit = ko
.observable(SettingsGet('AttachmentLimit') / (1024 * 1024))
.observable(SettingsGet('attachmentLimit') / (1024 * 1024))
.extend({ debounce: 500 });
this.addSetting('Language');
this.addSetting('AttachmentLimit');
this.addSetting('language');
this.addSetting('attachmentLimit');
this.addSetting('Theme', value => changeTheme(value, this.themeTrigger));
this.uploadData = SettingsGet('PhpUploadSizes');
this.uploadData = SettingsGet('phpUploadSizes');
this.uploadDataDesc =
(this.uploadData?.upload_max_filesize || this.uploadData?.post_max_size)
? [
@ -93,9 +89,9 @@ export class AdminSettingsGeneral extends AbstractViewSettings {
addSubscribablesTo(this, {
languageAdmin: value => {
this.languageAdminTrigger(SaveSettingStatus.Saving);
translatorReload(true, value)
translatorReload(value, 1)
.then(fReloadLanguageHelper(SaveSettingStatus.Success), fReloadLanguageHelper(SaveSettingStatus.Failed))
.then(() => Remote.saveSetting('LanguageAdmin', value));
.then(() => Remote.saveSetting('languageAdmin', value));
},
capaAdditionalAccounts: fSaveHelper('CapaAdditionalAccounts'),
@ -118,7 +114,7 @@ export class AdminSettingsGeneral extends AbstractViewSettings {
showScreenPopup(LanguagesPopupView, [
this.languageAdmin,
this.languagesAdmin(),
SettingsGet('UserLanguageAdmin')
SettingsGet('languageUsers')
]);
}
}

View file

@ -3,7 +3,7 @@ import { AbstractViewSettings } from 'Knoin/AbstractViews';
export class AdminSettingsLogin extends AbstractViewSettings {
constructor() {
super();
this.addSetting('LoginDefaultDomain');
this.addSettings(['DetermineUserLanguage','DetermineUserDomain','AllowLanguagesOnLogin']);
this.addSetting('loginDefaultDomain');
this.addSettings(['determineUserLanguage','determineUserDomain','allowLanguagesOnLogin']);
}
}

View file

@ -1,6 +1,6 @@
import ko from 'ko';
import { Notification } from 'Common/Enums';
import { Notifications } from 'Common/Enums';
import { getNotification } from 'Common/Translator';
import { PackageAdminStore } from 'Stores/Admin/Package';
@ -15,7 +15,7 @@ export class AdminSettingsPackages extends AbstractViewSettings {
constructor() {
super();
this.addSettings(['EnabledPlugins']);
this.addSettings(['pluginsEnable']);
addObservablesTo(this, {
packagesError: ''
@ -46,7 +46,7 @@ export class AdminSettingsPackages extends AbstractViewSettings {
data && Remote.request('AdminPluginLoad',
(iError, data) => iError || showScreenPopup(PluginPopupView, [data.Result]),
{
Id: data.id
id: data.id
}
);
// disablePlugin
@ -67,7 +67,7 @@ export class AdminSettingsPackages extends AbstractViewSettings {
if (iError) {
this.packagesError(
getNotification(install ? Notification.CantInstallPackage : Notification.CantDeletePackage)
getNotification(install ? Notifications.CantInstallPackage : Notifications.CantDeletePackage)
+ (data.ErrorMessage ? ':\n' + data.ErrorMessage : '')
);
} else if (data.Result.Reload) {
@ -84,7 +84,7 @@ export class AdminSettingsPackages extends AbstractViewSettings {
Remote.request('AdminPackageDelete',
this.requestHelper(packageToDelete, false),
{
Id: packageToDelete.id
id: packageToDelete.id
}
);
}
@ -96,9 +96,9 @@ export class AdminSettingsPackages extends AbstractViewSettings {
Remote.request('AdminPackageInstall',
this.requestHelper(packageToInstall, true),
{
Id: packageToInstall.id,
Type: packageToInstall.type,
File: packageToInstall.file
id: packageToInstall.id,
type: packageToInstall.type,
file: packageToInstall.file
},
60000
);
@ -113,15 +113,15 @@ export class AdminSettingsPackages extends AbstractViewSettings {
if (iError) {
plugin.enabled(disable);
this.packagesError(
(Notification.UnsupportedPluginPackage === iError && data?.ErrorMessage)
(Notifications.UnsupportedPluginPackage === iError && data?.ErrorMessage)
? data.ErrorMessage
: getNotification(iError)
);
}
// PackageAdminStore.fetch();
}, {
Id: plugin.id,
Disabled: disable ? 1 : 0
id: plugin.id,
disabled: disable ? 1 : 0
}
);
}

View file

@ -10,12 +10,12 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
constructor() {
super();
this.addSettings(['UseLocalProxyForExternalImages','VerifySslCertificate','AllowSelfSigned']);
this.addSettings(['useLocalProxyForExternalImages']);
this.weakPassword = rl.app.weakPassword;
addObservablesTo(this, {
adminLogin: SettingsGet('AdminLogin'),
adminLogin: SettingsGet('adminLogin'),
adminLoginError: false,
adminPassword: '',
adminPasswordNew: '',
@ -68,7 +68,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
capaOpenPGP: value => Remote.saveSetting('CapaOpenPGP', value)
});
this.adminTOTP(SettingsGet('AdminTOTP'));
this.adminTOTP(SettingsGet('adminTOTP'));
decorateKoCommands(this, {
saveAdminUserCommand: self => self.adminLogin().trim() && self.adminPassword()
@ -102,10 +102,10 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
this.weakPassword(!!data.Result.Weak);
}
}, {
'Login': this.adminLogin(),
'Password': this.adminPassword(),
'NewPassword': this.adminPasswordNew(),
'TOTP': this.adminTOTP()
Login: this.adminLogin(),
Password: this.adminPassword(),
newPassword: this.adminPasswordNew(),
TOTP: this.adminTOTP()
});
return true;

View file

@ -1,9 +1,11 @@
import ko from 'ko';
//import { koComputable } from 'External/ko';
import { SettingsCapa, SettingsGet } from 'Common/Globals';
import { AccountUserStore } from 'Stores/User/Account';
import { IdentityUserStore } from 'Stores/User/Identity';
import { SettingsUserStore } from 'Stores/User/Settings';
import Remote from 'Remote/User/Fetch';
import { showScreenPopup } from 'Knoin/Knoin';
@ -16,13 +18,18 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
this.allowAdditionalAccount = SettingsCapa('AdditionalAccounts');
this.allowIdentities = SettingsCapa('Identities');
this.accounts = AccountUserStore.accounts;
this.accounts = AccountUserStore;
this.loading = AccountUserStore.loading;
this.identities = IdentityUserStore;
this.mainEmail = SettingsGet('MainEmail');
this.mainEmail = SettingsGet('mainEmail');
this.accountForDeletion = ko.observable(null).askDeleteHelper();
this.identityForDeletion = ko.observable(null).askDeleteHelper();
this.showUnread = SettingsUserStore.showUnreadCount;
SettingsUserStore.showUnreadCount.subscribe(value => Remote.saveSetting('ShowUnreadCount', value));
// this.additionalAccounts = koComputable(() => AccountUserStore.filter(account => account.isAdditional()));
}
addNewAccount() {
@ -60,7 +67,7 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
rl.app.accountsAndIdentities();
}
}, {
EmailToDelete: accountToRemove.email
emailToDelete: accountToRemove.email
});
}
}
@ -74,14 +81,14 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
this.identityForDeletion(null);
IdentityUserStore.remove(oIdentity => identityToRemove === oIdentity);
Remote.request('IdentityDelete', () => rl.app.accountsAndIdentities(), {
IdToDelete: identityToRemove.id()
idToDelete: identityToRemove.id()
});
}
}
accountsAndIdentitiesAfterMove() {
Remote.request('AccountsAndIdentitiesSortOrder', null, {
Accounts: AccountUserStore.getEmailAddresses().filter(v => v != SettingsGet('MainEmail')),
Accounts: AccountUserStore.getEmailAddresses().filter(v => v != SettingsGet('mainEmail')),
Identities: IdentityUserStore.map(item => (item ? item.id() : ""))
});
}

View file

@ -1,6 +1,6 @@
import { koComputable, addObservablesTo } from 'External/ko';
import { FolderUserStore } from 'Stores/User/Folder';
import { SettingsGet } from 'Common/Globals';
import { SettingsGet/*, SettingsCapa*/ } from 'Common/Globals';
//export class UserSettingsFilters /*extends AbstractViewSettings*/ {
export class UserSettingsFilters /*extends AbstractViewSettings*/ {
@ -27,6 +27,14 @@ export class UserSettingsFilters /*extends AbstractViewSettings*/ {
this.scriptForDeletion = ko.observable(null).askDeleteHelper();
}
/*
// TODO: issue on account switch
// When current domain has sieve but the new has not, or current has not and the new has
disabled() {
return !SettingsCapa('Sieve');
}
*/
addScript() {
this.editScript();
}

View file

@ -1,12 +1,11 @@
import ko from 'ko';
import { Notification } from 'Common/Enums';
import { Notifications } from 'Common/Enums';
import { FolderMetadataKeys } from 'Common/EnumsUser';
import { getNotification } from 'Common/Translator';
import { setFolder, getFolderFromCacheList, removeFolderFromCacheList } from 'Common/Cache';
import { getFolderFromCacheList, removeFolderFromCacheList } from 'Common/Cache';
import { defaultOptionsAfterRender } from 'Common/Utils';
import { sortFolders } from 'Common/Folders';
import { initOnStartOrLangChange, i18n } from 'Common/Translator';
import { FolderUserStore } from 'Stores/User/Folder';
@ -18,7 +17,6 @@ import { showScreenPopup } from 'Knoin/Knoin';
import { FolderCreatePopupView } from 'View/Popup/FolderCreate';
import { FolderSystemPopupView } from 'View/Popup/FolderSystem';
import { loadFolders } from 'Model/FolderCollection';
const folderForDeletion = ko.observable(null).askDeleteHelper();
@ -52,52 +50,10 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
this.folderForDeletion = folderForDeletion;
this.folderForEdit = ko.observable(null).extend({ toggleSubscribeProperty: [this, 'editing'] });
SettingsUserStore.hideUnsubscribed.subscribe(value => Remote.saveSetting('HideUnsubscribed', value));
SettingsUserStore.unhideKolabFolders.subscribe(value => Remote.saveSetting('UnhideKolabFolders', value));
}
folderEditOnEnter(folder) {
const 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, '', Notification.CantRenameFolder)
+ '.\n' + error.message);
});
}
// this.folderForEdit(null);
folder.editing(false);
}
folderEditOnEsc(folder) {
// this.folderForEdit(null);
folder?.editing(false);
}
onShow() {
FolderUserStore.folderListError('');
}
@ -119,17 +75,17 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
&& folderToRemove.askDelete()
) {
if (0 < folderToRemove.totalEmails()) {
// FolderUserStore.folderListError(getNotification(Notification.CantDeleteNonEmptyFolder));
folderToRemove.errorMsg(getNotification(Notification.CantDeleteNonEmptyFolder));
// FolderUserStore.folderListError(getNotification(Notifications.CantDeleteNonEmptyFolder));
folderToRemove.errorMsg(getNotification(Notifications.CantDeleteNonEmptyFolder));
} else {
folderForDeletion(null);
if (folderToRemove) {
Remote.abort('Folders').post('FolderDelete', FolderUserStore.foldersDeleting, {
Folder: folderToRemove.fullName
folder: folderToRemove.fullName
}).then(
() => {
// folderToRemove.flags.push('\\nonexistent');
// folderToRemove.attributes.push('\\nonexistent');
folderToRemove.selectable(false);
// folderToRemove.isSubscribed(false);
// folderToRemove.checkable(false);
@ -141,7 +97,7 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
},
error => {
FolderUserStore.folderListError(
getNotification(error.code, '', Notification.CantDeleteFolder)
getNotification(error.code, '', Notifications.CantDeleteFolder)
+ '.\n' + error.message
);
}
@ -151,13 +107,17 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
}
}
hideError() {
this.folderListError('');
}
toggleFolderKolabType(folder, event) {
let type = event.target.value;
// TODO: append '.default' ?
Remote.request('FolderSetMetadata', null, {
Folder: folder.fullName,
Key: FolderMetadataKeys.KolabFolderType,
Value: type
folder: folder.fullName,
key: FolderMetadataKeys.KolabFolderType,
value: type
});
folder.kolabType(type);
}
@ -165,8 +125,8 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
toggleFolderSubscription(folder) {
let subscribe = !folder.isSubscribed();
Remote.request('FolderSubscribe', null, {
Folder: folder.fullName,
Subscribe: subscribe ? 1 : 0
folder: folder.fullName,
subscribe: subscribe ? 1 : 0
});
folder.isSubscribed(subscribe);
}
@ -174,8 +134,8 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
toggleFolderCheckable(folder) {
let checkable = !folder.checkable();
Remote.request('FolderCheckable', null, {
Folder: folder.fullName,
Checkable: checkable ? 1 : 0
folder: folder.fullName,
checkable: checkable ? 1 : 0
});
folder.checkable(checkable);
}

View file

@ -2,7 +2,8 @@ import ko from 'ko';
import { SMAudio } from 'Common/Audio';
import { SaveSettingStatus } from 'Common/Enums';
import { EditorDefaultType, Layout } from 'Common/EnumsUser';
import { LayoutSideView, LayoutBottomView } from 'Common/EnumsUser';
import { setRefreshFoldersInterval } from 'Common/Folders';
import { Settings, SettingsGet } from 'Common/Globals';
import { isArray } from 'Common/Utils';
import { addSubscribablesTo, addComputablesTo } from 'External/ko';
@ -16,7 +17,6 @@ import { LanguageStore } from 'Stores/Language';
import { SettingsUserStore } from 'Stores/User/Settings';
import { IdentityUserStore } from 'Stores/User/Identity';
import { NotificationUserStore } from 'Stores/User/Notification';
import { MessageUserStore } from 'Stores/User/Message';
import { MessagelistUserStore } from 'Stores/User/Messagelist';
import Remote from 'Remote/User/Fetch';
@ -30,23 +30,26 @@ export class UserSettingsGeneral extends AbstractViewSettings {
this.language = LanguageStore.language;
this.languages = LanguageStore.languages;
this.hourCycle = LanguageStore.hourCycle;
this.soundNotification = SMAudio.notifications;
this.notificationSound = ko.observable(SettingsGet('NotificationSound'));
this.notificationSounds = ko.observableArray(SettingsGet('NewMailSounds'));
this.notificationSounds = ko.observableArray(SettingsGet('newMailSounds'));
this.desktopNotification = NotificationUserStore.enabled;
this.desktopNotifications = NotificationUserStore.enabled;
this.isDesktopNotificationAllowed = NotificationUserStore.allowed;
this.threadsAllowed = AppUserStore.threadsAllowed;
['layout', 'messageReadDelay', 'messagesPerPage',
'editorDefaultType', 'requestReadReceipt', 'requestDsn', 'pgpSign', 'pgpEncrypt',
'viewHTML', 'showImages', 'removeColors', 'hideDeleted', 'listInlineAttachments',
'useCheckboxesInList', 'useThreads', 'replySameFolder', 'msgDefaultAction'
['layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
'editorDefaultType', 'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
'viewHTML', 'viewImages', 'viewImagesWhitelist', 'removeColors', 'allowStyles', 'allowDraftAutosave',
'hideDeleted', 'listInlineAttachments', 'simpleAttachmentsList', 'collapseBlockquotes', 'maxBlockquotesLevel',
'useCheckboxesInList', 'listGrouped', 'useThreads', 'replySameFolder', 'msgDefaultAction', 'allowSpellcheck',
'showNextMessage'
].forEach(name => this[name] = SettingsUserStore[name]);
this.allowLanguagesOnSettings = !!SettingsGet('AllowLanguagesOnSettings');
this.allowLanguagesOnSettings = !!SettingsGet('allowLanguagesOnSettings');
this.languageTrigger = ko.observable(SaveSettingStatus.Idle);
@ -68,8 +71,8 @@ export class UserSettingsGeneral extends AbstractViewSettings {
editorDefaultTypes: () => {
translateTrigger();
return [
{ id: EditorDefaultType.Html, name: i18n('SETTINGS_GENERAL/EDITOR_HTML') },
{ id: EditorDefaultType.Plain, name: i18n('SETTINGS_GENERAL/EDITOR_PLAIN') }
{ id: 'Html', name: i18n('SETTINGS_GENERAL/EDITOR_HTML') },
{ id: 'Plain', name: i18n('SETTINGS_GENERAL/EDITOR_PLAIN') }
];
},
@ -84,9 +87,9 @@ export class UserSettingsGeneral extends AbstractViewSettings {
layoutTypes: () => {
translateTrigger();
return [
{ id: Layout.NoPreview, name: i18n('SETTINGS_GENERAL/LAYOUT_NO_SPLIT') },
{ id: Layout.SidePreview, name: i18n('SETTINGS_GENERAL/LAYOUT_VERTICAL_SPLIT') },
{ id: Layout.BottomPreview, name: i18n('SETTINGS_GENERAL/LAYOUT_HORIZONTAL_SPLIT') }
{ id: 0, name: i18n('SETTINGS_GENERAL/LAYOUT_NO_SPLIT') },
{ id: LayoutSideView, name: i18n('SETTINGS_GENERAL/LAYOUT_VERTICAL_SPLIT') },
{ id: LayoutBottomView, name: i18n('SETTINGS_GENERAL/LAYOUT_HORIZONTAL_SPLIT') }
];
}
});
@ -95,12 +98,14 @@ export class UserSettingsGeneral extends AbstractViewSettings {
this.addSetting('MsgDefaultAction');
this.addSetting('MessageReadDelay');
this.addSetting('MessagesPerPage');
this.addSetting('CheckMailInterval');
this.addSetting('Layout');
this.addSetting('MaxBlockquotesLevel');
this.addSettings(['ViewHTML', 'ShowImages', 'HideDeleted', 'ListInlineAttachments',
'UseCheckboxesInList', 'ReplySameFolder',
'requestReadReceipt', 'requestDsn', 'pgpSign', 'pgpEncrypt',
'DesktopNotifications', 'SoundNotification']);
this.addSettings(['ViewHTML', 'ViewImages', 'ViewImagesWhitelist', 'HideDeleted', 'RemoveColors', 'AllowStyles',
'ListInlineAttachments', 'simpleAttachmentsList', 'UseCheckboxesInList', 'listGrouped', 'ReplySameFolder',
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt', 'allowSpellcheck',
'DesktopNotifications', 'SoundNotification', 'CollapseBlockquotes', 'AllowDraftAutosave', 'showNextMessage']);
const fReloadLanguageHelper = (saveSettingsStep) => () => {
this.languageTrigger(saveSettingsStep);
@ -110,18 +115,13 @@ export class UserSettingsGeneral extends AbstractViewSettings {
addSubscribablesTo(this, {
language: value => {
this.languageTrigger(SaveSettingStatus.Saving);
translatorReload(false, value)
translatorReload(value)
.then(fReloadLanguageHelper(SaveSettingStatus.Success), fReloadLanguageHelper(SaveSettingStatus.Failed))
.then(() => Remote.saveSetting('Language', value));
.then(() => Remote.saveSetting('language', value));
},
removeColors: value => {
let dom = MessageUserStore.bodiesDom();
if (dom) {
dom.innerHTML = '';
}
Remote.saveSetting('RemoveColors', value);
},
hourCycle: value =>
Remote.saveSetting('hourCycle', value),
notificationSound: value => {
Remote.saveSetting('NotificationSound', value);
@ -131,6 +131,10 @@ export class UserSettingsGeneral extends AbstractViewSettings {
useThreads: value => {
MessagelistUserStore([]);
Remote.saveSetting('UseThreads', value);
},
checkMailInterval: () => {
setRefreshFoldersInterval(SettingsUserStore.checkMailInterval());
}
});
}

View file

@ -1,7 +1,7 @@
import { koComputable } from 'External/ko';
import { SettingsCapa } from 'Common/Globals';
import { i18n, translateTrigger } from 'Common/Translator';
import { i18n, translateTrigger, relativeTime } from 'Common/Translator';
import { AbstractViewSettings } from 'Knoin/AbstractViews';
@ -10,30 +10,29 @@ import { SettingsUserStore } from 'Stores/User/Settings';
import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import Remote from 'Remote/User/Fetch';
import { showScreenPopup } from 'Knoin/Knoin';
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
import { OpenPgpGeneratePopupView } from 'View/Popup/OpenPgpGenerate';
//import Remote from 'Remote/User/Fetch';
export class UserSettingsSecurity extends AbstractViewSettings {
constructor() {
super();
let i18nLogout = (key, params) => i18n('SETTINGS_SECURITY/AUTOLOGIN_' + key, params);
this.autoLogout = SettingsUserStore.autoLogout;
this.autoLogoutOptions = koComputable(() => {
translateTrigger();
return [
{ id: 0, name: i18nLogout('NEVER_OPTION_NAME') },
{ id: 5, name: i18nLogout('MINUTES_OPTION_NAME', { MINUTES: 5 }) },
{ id: 10, name: i18nLogout('MINUTES_OPTION_NAME', { MINUTES: 10 }) },
{ id: 30, name: i18nLogout('MINUTES_OPTION_NAME', { MINUTES: 30 }) },
{ id: 60, name: i18nLogout('MINUTES_OPTION_NAME', { MINUTES: 60 }) },
{ id: 60 * 2, name: i18nLogout('HOURS_OPTION_NAME', { HOURS: 2 }) },
{ id: 60 * 5, name: i18nLogout('HOURS_OPTION_NAME', { HOURS: 5 }) },
{ id: 60 * 10, name: i18nLogout('HOURS_OPTION_NAME', { HOURS: 10 }) }
{ id: 0, name: i18n('SETTINGS_SECURITY/AUTOLOGIN_NEVER_OPTION_NAME') },
{ id: 5, name: relativeTime(300) },
{ id: 10, name: relativeTime(600) },
{ id: 30, name: relativeTime(1800) },
{ id: 60, name: relativeTime(3600) },
{ id: 120, name: relativeTime(7200) },
{ id: 300, name: relativeTime(18000) },
{ id: 600, name: relativeTime(36000) }
];
});
this.addSetting('AutoLogout');
@ -47,10 +46,6 @@ export class UserSettingsSecurity extends AbstractViewSettings {
this.canOpenPGP = SettingsCapa('OpenPGP');
this.canGnuPG = GnuPGUserStore.isSupported();
this.canMailvelope = !!window.mailvelope;
this.allowDraftAutosave = SettingsUserStore.allowDraftAutosave;
this.allowDraftAutosave.subscribe(value => Remote.saveSetting('AllowDraftAutosave', value))
}
addOpenPgpKey() {
@ -67,5 +62,11 @@ export class UserSettingsSecurity extends AbstractViewSettings {
* The iframe will be injected into the container identified by selector.
*/
window.mailvelope && mailvelope.createSettingsContainer('#mailvelope-settings'/*[, keyring], options*/);
/**
* https://github.com/the-djmaze/snappymail/issues/973
Remote.request('GetStoredPGPKeys', (iError, data) => {
console.dir([iError, data]);
});
*/
}
}

View file

@ -1,12 +1,12 @@
import { addObservablesTo } from 'External/ko';
import { SaveSettingStatus, UploadErrorCode } from 'Common/Enums';
import { changeTheme, convertThemeName } from 'Common/Utils';
import { themePreviewLink, serverRequest } from 'Common/Links';
import { i18n } from 'Common/Translator';
import { SettingsCapa } from 'Common/Globals';
import { ThemeStore } from 'Stores/Theme';
import { ThemeStore, convertThemeName, changeTheme } from 'Stores/Theme';
import { addSubscribablesTo } from 'External/ko';
import Remote from 'Remote/User/Fetch';
@ -25,20 +25,22 @@ export class UserSettingsThemes /*extends AbstractViewSettings*/ {
this.fontSansSerif = ThemeStore.fontSansSerif;
this.fontSerif = ThemeStore.fontSerif;
this.fontMono = ThemeStore.fontMono;
ThemeStore.fontSansSerif.subscribe(value => {
Remote.saveSettings(null, {
fontSansSerif: value
});
});
ThemeStore.fontSerif.subscribe(value => {
Remote.saveSettings(null, {
fontSerif: value
});
});
ThemeStore.fontMono.subscribe(value => {
Remote.saveSettings(null, {
fontMono: value
});
addSubscribablesTo(ThemeStore, {
fontSansSerif: value => {
Remote.saveSettings(null, {
fontSansSerif: value
});
},
fontSerif: value => {
Remote.saveSettings(null, {
fontSerif: value
});
},
fontMono: value => {
Remote.saveSettings(null, {
fontMono: value
});
}
});
this.theme = ThemeStore.theme;
@ -61,6 +63,10 @@ export class UserSettingsThemes /*extends AbstractViewSettings*/ {
});
}
setTheme(theme) {
ThemeStore.theme(theme.name);
}
onBuild() {
const currentTheme = ThemeStore.theme();
@ -89,8 +95,8 @@ export class UserSettingsThemes /*extends AbstractViewSettings*/ {
})
.on('onComplete', (id, result, data) => {
themeBackground.loading(false);
themeBackground.name(data?.Result?.Name || '');
themeBackground.hash(data?.Result?.Hash || '');
themeBackground.name(data?.Result?.name || '');
themeBackground.hash(data?.Result?.hash || '');
if (!themeBackground.name() || !themeBackground.hash()) {
let errorMsg = '';
if (data.ErrorCode) {

View file

@ -82,11 +82,9 @@ export class AbstractModel {
}
revivePropertiesFromJson(json) {
let model = this.constructor;
if (!model.validJson(json)) {
return false;
}
forEachObjectEntry(json, (key, value) => {
const model = this.constructor,
valid = model.validJson(json);
valid && forEachObjectEntry(json, (key, value) => {
if ('@' !== key[0]) try {
key = key[0].toLowerCase() + key.slice(1);
switch (typeof this[key])
@ -104,9 +102,12 @@ export class AbstractModel {
case 'string':
this[key] = typeCast(this[key], value);
break;
// fall through
case 'undefined':
default:
console.log(`Undefined ${model.name}.${key} not set`);
// this[key] = value;
break;
// default:
// console.log((typeof this[key])+` ${model.name}.${key} not revived`);
// console.log((typeof this[key])+' '+(model.name)+'.'+key+' not revived');
}
} catch (e) {
@ -114,7 +115,7 @@ export class AbstractModel {
console.error(e);
}
});
return true;
return valid;
}
}

View file

@ -53,10 +53,10 @@ export class FilterModel extends AbstractModel {
actionValueFourth: '',
actionValueFourthError: false,
actionMarkAsRead: false,
markAsRead: false,
actionKeep: true,
actionNoStop: false,
keep: true,
stop: true,
actionType: FilterAction.MoveTo
});
@ -181,24 +181,24 @@ export class FilterModel extends AbstractModel {
return true;
}
toJson() {
toJSON() {
return {
// '@Object': 'Object/Filter',
ID: this.id,
Enabled: this.enabled() ? 1 : 0,
Name: this.name(),
Conditions: this.conditions.map(item => item.toJson()),
ConditionsType: this.conditionsType(),
Name: this.name,
Conditions: this.conditions,
ConditionsType: this.conditionsType,
ActionType: this.actionType(),
ActionValue: this.actionValue(),
ActionValueSecond: this.actionValueSecond(),
ActionValueThird: this.actionValueThird(),
ActionValueFourth: this.actionValueFourth(),
ActionValue: this.actionValue,
ActionValueSecond: this.actionValueSecond,
ActionValueThird: this.actionValueThird,
ActionValueFourth: this.actionValueFourth,
Keep: this.actionKeep() ? 1 : 0,
Stop: this.actionNoStop() ? 0 : 1,
MarkAsRead: this.actionMarkAsRead() ? 1 : 0
Keep: this.keep() ? 1 : 0,
Stop: this.stop() ? 1 : 0,
MarkAsRead: this.markAsRead() ? 1 : 0
};
}
@ -210,25 +210,20 @@ export class FilterModel extends AbstractModel {
this.conditions.remove(oConditionToDelete);
}
setRecipients() {
// this.actionValueFourth(AccountUserStore.getEmailAddresses().join(', '));
}
/**
* @static
* @param {FetchJsonFilter} json
* @returns {?FilterModel}
*/
static reviveFromJson(json) {
json.id = json.ID;
delete json.ID;
const filter = super.reviveFromJson(json);
if (filter) {
filter.id = '' + (filter.id || '');
filter.conditions(
(json.Conditions || []).map(aData => FilterConditionModel.reviveFromJson(aData)).filter(v => v)
);
filter.actionKeep(0 != json.Keep);
filter.actionNoStop(0 == json.Stop);
filter.actionMarkAsRead(1 == json.MarkAsRead);
}
return filter;
}
@ -245,7 +240,7 @@ export class FilterModel extends AbstractModel {
filter.conditionsType(this.conditionsType());
filter.actionMarkAsRead(this.actionMarkAsRead());
filter.markAsRead(this.markAsRead());
filter.actionType(this.actionType());
@ -256,8 +251,8 @@ export class FilterModel extends AbstractModel {
filter.actionValueThird(this.actionValueThird());
filter.actionValueFourth(this.actionValueFourth());
filter.actionKeep(this.actionKeep());
filter.actionNoStop(this.actionNoStop());
filter.keep(this.keep());
filter.stop(this.stop());
filter.conditions(this.conditions.map(item => item.cloneSelf()));

View file

@ -81,13 +81,13 @@ export class FilterConditionModel extends AbstractModel {
// static reviveFromJson(json) {}
toJson() {
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
};
}

View file

@ -125,7 +125,7 @@ function filtersToSieveScript(filters)
// actions
block ? result.push('{') : (sTab = '');
if (filter.actionMarkAsRead() && ['None','MoveTo','Forward'].includes(filter.actionType())) {
if (filter.markAsRead() && ['None','MoveTo','Forward'].includes(filter.actionType())) {
require.imap4flags = 1;
result.push(sTab + 'addflag "\\\\Seen";');
}
@ -182,7 +182,7 @@ function filtersToSieveScript(filters)
break; }
case 'Forward':
if (value) {
if (filter.actionKeep()) {
if (filter.keep()) {
require.fileinto = 1;
result.push(sTab + 'fileinto "INBOX";');
}
@ -201,7 +201,7 @@ function filtersToSieveScript(filters)
break;
}
filter.actionNoStop() || result.push(sTab + 'stop;');
filter.stop() && result.push(sTab + 'stop;');
block && result.push('}');
@ -213,7 +213,7 @@ function filtersToSieveScript(filters)
'/*',
'BEGIN:FILTER:' + filter.id,
'BEGIN:HEADER',
btoa(unescape(encodeURIComponent(JSON.stringify(filter.toJson())))).match(split).join(eol) + 'END:HEADER',
btoa(unescape(encodeURIComponent(JSON.stringify(filter)))).match(split).join(eol) + 'END:HEADER',
'*/',
filter.enabled() ? '' : '/* @Filter is disabled ',
filterToString(filter, require),
@ -290,13 +290,12 @@ export class SieveScriptModel extends AbstractModel
return !this.nameError();
}
toJson() {
toJSON() {
return {
name: this.name(),
active: this.active() ? 1 : 0,
body: this.body()
name: this.name,
active: this.active,
body: this.body
// body: this.allowFilters() ? this.body() : this.filtersToRaw()
// filters: this.filters.map(item => item.toJson())
};
}
@ -316,13 +315,7 @@ export class SieveScriptModel extends AbstractModel
const script = super.reviveFromJson(json);
if (script) {
if (script.allowFilters()) {
script.filters(
Array.isArray(json.filters) && json.filters.length
? json.filters.map(aData => FilterModel.reviveFromJson(aData)).filter(v => v)
: sieveScriptToFilters(script.body())
);
} else {
script.filters([]);
script.filters(sieveScriptToFilters(script.body()));
}
script.canBeDeleted(SIEVE_FILE_NAME !== json.name);
script.exists(true);

View file

@ -25,13 +25,13 @@ export const
arr.map(item => item.toString?.() || item).join(separator),
/*
getNotificationMessage = code => {
let key = getKeyByValue(Notification, code);
let key = getKeyByValue(Notifications, code);
return key ? I18N_DATA.NOTIFICATIONS[i18nKey(key).replace('_NOTIFICATION', '_ERROR')] : '';
rl.i18n('NOTIFICATIONS/')
},
getNotification = (code, message = '', defCode = 0) => {
code = parseInt(code, 10) || 0;
if (Notification.ClientViewError === code && message) {
if (Notifications.ClientViewError === code && message) {
return message;
}

View file

@ -17,31 +17,29 @@ const
// import { folderListOptionsBuilder } from 'Common/Folders';
/**
* @param {Array=} aDisabled
* @param {Array=} aHeaderLines
* @param {Function=} fRenameCallback
* @returns {Array}
*/
folderListOptionsBuilder = (
aDisabled,
aHeaderLines,
fRenameCallback
) => {
folderListOptionsBuilder = () => {
const
aResult = [],
aResult = [{
id: '',
name: '',
system: false,
disabled: false
}],
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) {
aResult.push({
id: oItem.fullName,
name:
sDeepPrefix.repeat(oItem.deep) +
fRenameCallback(oItem),
name: sDeepPrefix.repeat(oItem.deep) + oItem.detailedName(),
system: false,
disabled: !oItem.selectable() || aDisabled.includes(oItem.fullName)
disabled: !oItem.selectable() || disabled == oItem.fullName
});
}
@ -52,18 +50,6 @@ const
};
fRenameCallback = fRenameCallback || (oItem => oItem.name());
Array.isArray(aDisabled) || (aDisabled = []);
Array.isArray(aHeaderLines) && aHeaderLines.forEach(line =>
aResult.push({
id: line[0],
name: line[1],
system: false,
disabled: false
})
);
// FolderUserStore.folderList()
foldersWalk(window.Sieve.folderList() || []);
@ -82,13 +68,7 @@ export class FilterPopupView extends rl.pluginPopupView {
});
this.defaultOptionsAfterRender = defaultOptionsAfterRender;
this.folderSelectList = koComputable(() =>
folderListOptionsBuilder(
[rl.settings.get('SieveAllowFileintoInbox') ? '' : 'INBOX'],
[['', '']],
item => item?.localName() || ''
)
);
this.folderSelectList = koComputable(() => folderListOptionsBuilder());
this.selectedFolderValue.subscribe(() => this.filter().actionValueError(false));

View file

@ -86,7 +86,7 @@ export class SieveScriptPopupView extends rl.pluginPopupView {
// this.close();
}
},
script.toJson()
script.toJSON()
);
}
}

View file

@ -27,8 +27,8 @@ try {
setItem: (key, value) => {
data[key] = ''+value; // forces the value to a string
document.cookie = sName+'='+encodeURIComponent(JSON.stringify(data))
+"; expires="+((new Date(Date.now()+(365*24*60*60*1000))).toGMTString())
+"; path=/; samesite=strict";
+";expires="+((new Date(Date.now()+(365*24*60*60*1000))).toGMTString())
+";path=/;samesite=strict";
}
};
}

View file

@ -0,0 +1 @@
export const Passphrases = new Map();

View file

@ -20,6 +20,6 @@ DomainAdminStore.fetch = () => {
);
}
}, {
IncludeAliases: 1
includeAliases: 1
});
};

View file

@ -1,5 +1,6 @@
import ko from 'ko';
import { isArray } from 'Common/Utils';
import { getNotification } from 'Common/Translator';
import Remote from 'Remote/Admin/Fetch';
export const PackageAdminStore = ko.observableArray();
@ -16,6 +17,11 @@ PackageAdminStore.fetch = () => {
PackageAdminStore.loading(false);
if (iError) {
PackageAdminStore.real(false);
PackageAdminStore.error(getNotification(iError));
// let error = getNotification(iError);
// if (data.message) { error = data.message + error; }
// if (data.reason) { error = data.reason + " " + error; }
// PackageAdminStore.error(error);
} else {
PackageAdminStore.real(!!data.Result.Real);
PackageAdminStore.error(data.Result.Error);

View file

@ -6,11 +6,13 @@ export const LanguageStore = {
language: ko.observable(''),
languages: ko.observableArray(),
userLanguage: ko.observable(''),
hourCycle: ko.observable(''),
populate: function() {
const aLanguages = Settings.app('languages');
this.languages(isArray(aLanguages) ? aLanguages : []);
this.language(SettingsGet('Language'));
this.userLanguage(SettingsGet('UserLanguage'));
this.language(SettingsGet('language'));
this.userLanguage(SettingsGet('userLanguage'));
this.hourCycle(SettingsGet('hourCycle'));
}
}

View file

@ -1,69 +1,109 @@
import ko from 'ko';
import { doc, $htmlCL, elementById, leftPanelDisabled, Settings, SettingsGet } from 'Common/Globals';
import { isArray } from 'Common/Utils';
import { serverRequestRaw } from 'Common/Links';
import { $htmlCL, appEl, elementById, leftPanelDisabled, Settings, SettingsGet } from 'Common/Globals';
import { isArray, arrayLength } from 'Common/Utils';
import { cssLink, serverRequestRaw } from 'Common/Links';
import { SaveSettingStatus } from 'Common/Enums';
import { addSubscribablesTo } from 'External/ko';
export const ThemeStore = {
theme: ko.observable(''),
themes: ko.observableArray(),
userBackgroundName: ko.observable(''),
userBackgroundHash: ko.observable(''),
fontSansSerif: ko.observable(''),
fontSerif: ko.observable(''),
fontMono: ko.observable(''),
isMobile: ko.observable($htmlCL.contains('rl-mobile')),
let __themeTimer = 0;
populate: () => {
const themes = Settings.app('themes');
export const
// Also see Styles/_Values.less @maxMobileWidth
isMobile = matchMedia('(max-width: 799px)'),
ThemeStore = {
theme: ko.observable(''),
themes: ko.observableArray(),
userBackgroundName: ko.observable(''),
userBackgroundHash: ko.observable(''),
fontSansSerif: ko.observable(''),
fontSerif: ko.observable(''),
fontMono: ko.observable(''),
isMobile: ko.observable(false)
},
initThemes = () => {
const theme = SettingsGet('Theme'),
themes = Settings.app('themes');
ThemeStore.themes(isArray(themes) ? themes : []);
ThemeStore.theme(SettingsGet('Theme'));
ThemeStore.theme(theme);
changeTheme(theme);
if (!ThemeStore.isMobile()) {
ThemeStore.userBackgroundName(SettingsGet('UserBackgroundName'));
ThemeStore.userBackgroundHash(SettingsGet('UserBackgroundHash'));
ThemeStore.userBackgroundName(SettingsGet('userBackgroundName'));
ThemeStore.userBackgroundHash(SettingsGet('userBackgroundHash'));
}
ThemeStore.fontSansSerif(SettingsGet('fontSansSerif'));
ThemeStore.fontSerif(SettingsGet('fontSerif'));
ThemeStore.fontMono(SettingsGet('fontMono'));
leftPanelDisabled(ThemeStore.isMobile());
},
changeTheme = (value, themeTrigger = ()=>0) => {
const themeStyle = elementById('app-theme-style'),
clearTimer = () => {
__themeTimer = setTimeout(() => themeTrigger(SaveSettingStatus.Idle), 1000);
},
url = cssLink(value);
if (themeStyle.dataset.name != value) {
clearTimeout(__themeTimer);
themeTrigger(SaveSettingStatus.Saving);
rl.app.Remote.abort('theme').get('theme', url)
.then(data => {
if (2 === arrayLength(data)) {
themeStyle.textContent = data[1];
themeStyle.dataset.name = value;
themeTrigger(SaveSettingStatus.Success);
}
clearTimer();
}, clearTimer);
}
},
convertThemeName = theme => theme.replace(/@[a-z]+$/, '').replace(/([A-Z])/g, ' $1').trim();
addSubscribablesTo(ThemeStore, {
fontSansSerif: value => {
if (null != value) {
let cl = appEl.classList;
cl.forEach(name => {
if (name.startsWith('font') && !/font(Serif|Mono)/.test(name)) {
cl.remove(name);
}
});
value && cl.add('font'+value);
}
},
fontSerif: value => {
if (null != value) {
let cl = appEl.classList;
cl.forEach(name => name.startsWith('fontSerif') && cl.remove(name));
value && cl.add('fontSerif'+value);
}
},
fontMono: value => {
if (null != value) {
let cl = appEl.classList;
cl.forEach(name => name.startsWith('fontMono') && cl.remove(name));
value && cl.add('fontMono'+value);
}
},
userBackgroundHash: value => {
appEl.classList.toggle('UserBackground', !!value);
appEl.style.backgroundImage = value ? "url("+serverRequestRaw('UserBackground', value)+")" : null;
}
});
isMobile.onchange = e => {
ThemeStore.isMobile(e.matches);
$htmlCL.toggle('rl-mobile', e.matches);
leftPanelDisabled(e.matches);
};
ThemeStore.isMobile.subscribe(value => $htmlCL.toggle('rl-mobile', value));
ThemeStore.fontSansSerif.subscribe(value => {
if (null != value) {
let cl = elementById('rl-app').classList;
cl.forEach(name => {
if (name.startsWith('font') && !/font(Serif|Mono)/.test(name)) {
cl.remove(name);
}
});
value && cl.add('font'+value);
}
});
ThemeStore.fontSerif.subscribe(value => {
if (null != value) {
let cl = elementById('rl-app').classList;
cl.forEach(name => name.startsWith('fontSerif') && cl.remove(name));
value && cl.add('fontSerif'+value);
}
});
ThemeStore.fontMono.subscribe(value => {
if (null != value) {
let cl = elementById('rl-app').classList;
cl.forEach(name => name.startsWith('fontMono') && cl.remove(name));
value && cl.add('fontMono'+value);
}
});
ThemeStore.userBackgroundHash.subscribe(value => {
if (value) {
$htmlCL.add('UserBackground');
doc.body.style.backgroundImage = "url("+serverRequestRaw('UserBackground', value)+")";
} else {
$htmlCL.remove('UserBackground');
doc.body.removeAttribute('style');
}
});
isMobile.onchange(isMobile);

View file

@ -1,11 +1,10 @@
import { addObservablesTo, koArrayWithDestroy } from 'External/ko';
export const AccountUserStore = {
accounts: koArrayWithDestroy(),
loading: ko.observable(false).extend({ debounce: 100 }),
export const AccountUserStore = koArrayWithDestroy();
getEmailAddresses: () => AccountUserStore.accounts.map(item => item.email)
};
AccountUserStore.loading = ko.observable(false).extend({ debounce: 100 });
AccountUserStore.getEmailAddresses = () => AccountUserStore.map(item => item.email);
addObservablesTo(AccountUserStore, {
email: '',

View file

@ -1,9 +1,10 @@
import { keyScope, leftPanelDisabled, SettingsGet, elementById } from 'Common/Globals';
import { addObservablesTo } from 'External/ko';
import { ThemeStore } from 'Stores/Theme';
import { arePopupsVisible } from 'Knoin/Knoin';
export const AppUserStore = {
allowContacts: () => !!SettingsGet('ContactsIsAllowed')
allowContacts: () => !!SettingsGet('contactsAllowed')
};
addObservablesTo(AppUserStore, {
@ -15,10 +16,9 @@ addObservablesTo(AppUserStore, {
AppUserStore.focusedState.subscribe(value => {
['FolderList','MessageList','MessageView'].forEach(name => {
if (name === value) {
keyScope(value);
arePopupsVisible() || keyScope(value);
ThemeStore.isMobile() && leftPanelDisabled('FolderList' !== value);
}
let dom = elementById('V-Mail'+name);
dom?.classList.toggle('focused', name === value);
elementById('V-Mail'+name).classList.toggle('focused', name === value);
});
});

Some files were not shown because too many files have changed in this diff Show more