Merge pull request #1 from the-djmaze/master

updating from upstream
This commit is contained in:
Philipp Mundhenk 2024-05-26 17:00:22 +02:00 committed by GitHub
commit 92bfa7bd75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1300 changed files with 67163 additions and 24559 deletions

View file

@ -3,7 +3,7 @@ module.exports = {
// extends: ['eslint:recommended', 'plugin:prettier/recommended'],
extends: ['eslint:recommended'],
parserOptions: {
ecmaVersion: 6,
ecmaVersion: 11,
sourceType: 'module'
},
env: {
@ -35,7 +35,9 @@ module.exports = {
// vendors/bootstrap/bootstrap.native.js
'BSN': "readonly",
// Mailvelope
'mailvelope': "readonly"
'mailvelope': "readonly",
// Punycode
'IDN': "readonly"
},
// http://eslint.org/docs/rules/
rules: {

View file

@ -1,3 +1,399 @@
## 2.36.2 2024-05-26
### Added
- "copy to" action in menu's for
[#1559](https://github.com/the-djmaze/snappymail/issues/1559)
- Log signal info for
[#1569](https://github.com/the-djmaze/snappymail/issues/1569)
- OpenPGP.js automatically import backup keys from server
### Changed
- Improved "remember me" cookie handling
- Update Basque by @Thadah
- Update Portuguese by @ner00
### Fixed
- "Account already exists"
[#1561](https://github.com/the-djmaze/snappymail/issues/1561)
- Properly escape path separator in tar.php file list regex by @sevmonster
[#1562](https://github.com/the-djmaze/snappymail/pull/1562)
- Prevent mkdir() error
[#1565](https://github.com/the-djmaze/snappymail/issues/1565)
- SCRAM Exception when trying to log in to SMTP
[#1575](https://github.com/the-djmaze/snappymail/issues/1575)
- Error when redirected back to instance after Gmail OAuth
[#1580](https://github.com/the-djmaze/snappymail/issues/1580)
- Uncaught TypeError: hasPublicKeyForEmails(...).then is not a function
[#1589](https://github.com/the-djmaze/snappymail/issues/1589)
- Undefined variable $sFilename
- GPG/PGP exec() return false handling
### Nextcloud
- OIDC login active again by @avinash-0007
[#1572](https://github.com/the-djmaze/snappymail/pull/1572)
## 2.36.1 2024-04-23
### Added
- Autoconfig detect through DNS SRV (RFC 6186 & 8314) and disable MX
- Have I Been Pwned class to check breached passwords and email addresses
- Handle RFC 5987 in Content-Disposition header
- Ignore text/x-amp-html
- Show SMTP error to user
[#1521](https://github.com/the-djmaze/snappymail/issues/1521)
- OAuth2 for login using gmail (and others)
### Changed
- logMask all AUTHENTICATE requests
- ErrorTip use white-space: pre
- Simplify LoginProcess handling
- ES2020 everywhere (require Safari 13.1)
- Modified Squire to be more in line with v2.2.8
- CSS set min-width for .attachmentParent and .flagParent to line them up
- cPanel use extension login-cpanel instead of login-remote
- Improved login credentials handling
- Speedup Knockout a bit
- Update Belarusian by @spoooyders
- Update Chinese by @mayswind
- Update French by @hguilbert
- Update Polish by @tinola
- Update Portuguese by @ner00
### Fixed
- Content encoding and type detection in JavaScript could fail due to case-sensitivity.
- Extensions set logger failed
- GnuPG check open_basedir and if shell_exec is disabled
[#1385](https://github.com/the-djmaze/snappymail/issues/1385)
[#1496](https://github.com/the-djmaze/snappymail/issues/1496)
[#1555](https://github.com/the-djmaze/snappymail/issues/1555)
- Hide pagination when search result has no messages
- Prevent mbstring error before setup.php
- Prevent MessagesPerPage Infinity
[#1540](https://github.com/the-djmaze/snappymail/issues/1540)
- Reseal CryptKey failed
[#1543](https://github.com/the-djmaze/snappymail/issues/1543)
### Nextcloud
- Add an occ command to set up the login settings
[#1552](https://github.com/the-djmaze/snappymail/issues/1552)
## 2.36.0 2024-03-18
### Added
- Allow setting the supported THREAD algorithm
- Icon to system folders
- Remove remembered password after 15 minutes of inactivity
[#1142](https://github.com/the-djmaze/snappymail/issues/1142)
- Swap background and text color for unreadable text on dark background
[#1486](https://github.com/the-djmaze/snappymail/issues/1486)
- Generate TOTP code at ?Admin -> Security
[#1501](https://github.com/the-djmaze/snappymail/issues/1501)
- Button to change S/MIME private key passphrase
[#1505](https://github.com/the-djmaze/snappymail/issues/1505)
- Belarusian by @spoooyders
[#1512](https://github.com/the-djmaze/snappymail/pull/1512)
- Log some domain idn_to_ascii issues
[#1513](https://github.com/the-djmaze/snappymail/pull/1513)
### Changed
- On folder/mailbox rename, also rename all children instead of reloading all
- Seal MainAccount CryptKey and on error ask old login passphrase to reseal key.
- Moved cache drivers outside core into extensions
- Sieve always allow fileinto INBOX
[#1510](https://github.com/the-djmaze/snappymail/issues/1510)
- Moved application.ini `sieve_auth_plain_initial` to per domain config
- Languages use rfc5646, by using the shortest ISO 639 code by default
- Update French by @hguilbert
- Update Portuguese by @ner00
### Fixed
- On folder/mailbox rename, the old fullName must be removed from cache
- On folder/mailbox rename, the checkable option was not renamed
- Sort accounts drag & drop
- S/MIME encrypted and opaque signed not visible
[#1450](https://github.com/the-djmaze/snappymail/issues/1450)
- Wrong last UID of thread
[#1507](https://github.com/the-djmaze/snappymail/issues/1507)
- Creation of dynamic property SnappyMail\DAV\Client::$HTTP
[#1509](https://github.com/the-djmaze/snappymail/issues/1509)
- "Download as ZIP" fails for messages
[#1514](https://github.com/the-djmaze/snappymail/issues/1514)
- SMTP "Authentication failed" when IMAP uses `shortLogin` and SMTP not
[#1517](https://github.com/the-djmaze/snappymail/issues/1517)
## 2.35.4 2024-03-16
### Added
- \SnappyMail\IDN::toAscii()
### Changed
- OpenPGP.js to v5.11.1
- punycode.js lowercase domain names
- application.ini `login_lowercase` removed and now configurable per domain JSON `lowerLogin`
- Update Portuguese by @ner00
### Fixed
- Raise JS TypeEroor "toLowerCase" after update
[#1491](https://github.com/the-djmaze/snappymail/issues/1491)
- Call to undefined function shell_exec
[#1496](https://github.com/the-djmaze/snappymail/issues/1496)
- Download attachments as ZIP doesn't work for PGP encrypted mail
[#1499](https://github.com/the-djmaze/snappymail/issues/1499)
- Importing or downloading a PGP public key attachment from a PGP encrypted message doesn't work
[#1500](https://github.com/the-djmaze/snappymail/issues/1500)
- VCard PHP Notice: Undefined index: ENCODING
### Nextcloud
- Changed stored password handling
- Can't login from nextcloud with 2.35.3 bug Nextcloud
[#1490](https://github.com/the-djmaze/snappymail/issues/1490)
## 2.35.3 2024-03-12
### Added
- GnuPG can be disabled
- Missing strings for localization inside identity popup (Cryptography > S/MIME)
[#1458](https://github.com/the-djmaze/snappymail/issues/1458)
- Automatically verify PGP and S/MIME signed messages
- TNEFDecoder for
[#1012](https://github.com/the-djmaze/snappymail/discussions/1012)
- RTF to HTML converter for
[#1012](https://github.com/the-djmaze/snappymail/discussions/1012)
- Polyfill for PHP ctype
[#1250](https://github.com/the-djmaze/snappymail/issues/1250)
### Changed
- `new Error()` to `Error()`
- Reduce KnockoutJS footprint by removing unused code
- CSS reposition rainloopErrorTip location
- Improved error handling on PGP and S/MIME decrypt
- Improved OpenPGP.js import keys
- Use Identity S/MIME key and certificate from server instead of POST
- application.ini `[webmail]language_admin` to `[admin_panel]language`
- application.ini `[security]admin_panel_host` to `[admin_panel]host`
- application.ini `[security]admin_panel_key` to `[admin_panel]key`
- Drop deprecated Domain::SetConfig()
- Internationalized domain names are now handled as punycode
- Cacher->Get() can now return NULL
- Update French by @hguilbert
- Update Polish by @tinola
- Update Portuguese by @ner00
### Fixed
- Handling of Internationalized Domain Names in several areas
- Decrypt error message
- Stalwart ManageSieve Error 352 when getting Filters
[#1455](https://github.com/the-djmaze/snappymail/issues/1455)
- Nextcloud V25+ theme slightly broken
[#1463](https://github.com/the-djmaze/snappymail/issues/1463)
- PGP decryption fails with "Not armored text"
[#1462](https://github.com/the-djmaze/snappymail/issues/1462)
- AUTH_BASIC falling through as AUTH_BEARER; change AUTH_BEARER to a different value
[#1461](https://github.com/the-djmaze/snappymail/issues/1461)
- SetPassword expects \SnappyMail\SensitiveString
- Crash on importing corrupt OpenPGP keys
- Crash on old browsers instead of showing error
- Ignore popups on logoutReload()
[#1467](https://github.com/the-djmaze/snappymail/pull/1467)
- Custom SASLMechanisms fail in IMAP when the connection is secure
[#1484](https://github.com/the-djmaze/snappymail/pull/1484)
## 2.35.2 2024-02-27
### Added
- GnuPG error handling
- Missing strings for localization inside identity popup (Cryptography > S/MIME)
[#1458](https://github.com/the-djmaze/snappymail/issues/1458)
### Changed
- Update Portuguese by @ner00
### Fixed
- Drop support for gnupg PECL extension as it fails with "no passphrase" issues
- Error 352 when getting Filters
[#1455](https://github.com/the-djmaze/snappymail/issues/1455)
### Nextcloud
- SetPassword(): Argument #1 must be of type SensitiveString, string given
[#1456](https://github.com/the-djmaze/snappymail/issues/1456)
## 2.35.1 2024-02-26
### Added
- Search functionality in Admin -> Config
- Cache S/MIME passphrases when "remember" is checked
- Import S/MIME certificate popup
- pre-verify S/MIME opaque signed messages so we have a body to view
- Sort PGP keys and S/MIME certificates on email address
- Optionally use existing private key to generate S/MIME certificate
### Changed
- Better handling to detect which PGP or S/MIME sign/encrypt to use
- Improved StorageType handling
- Cleanup and improved Capa handling
- OPEN_PGP should be OPENPGP as it is one word
- Use get_debug_type() instead of gettype()
- Require OpenSSL due to S/MIME
- AbstractProvider::IsActive() is now an abstract method and must be defined in child class
- Make better use of SnappyMail\SensitiveString
- Update Polish translation by @tinola
### Fixed
- Verify S/MIME signatures got broken allong the way while implementing this
- Generate S/MIME self-signed certificate failed to keep existing private key
- MIME parser RegExp didn't escape boundary which caused issues
- TypeError: b64Encode(...).match(...) is null on saving compose draft
- Fix timestampToString() for future dates
## 2.35.0 2024-02-20
### Added
- S/MIME support
[#259](https://github.com/the-djmaze/snappymail/issues/259)
### Changed
- Disable IMAP METADATA by default (hardly used)
- Update Polish translation by @tinola
- Rename CSS .openpgp-control to .crypto-control
- Renamed some methods in PHP
### Fixed
- When moving a folder/mailbox check for parent delimiter
- Mask `passphrase` in the logs for PHP < 8.2
- Added some missing translations
- Sign messages using PGP
- Check for CONDSTORE or QRESYNC to get the HIGHESTMODSEQ
- Unable to login on certain IMAP server since 2.34.2
[#1438](https://github.com/the-djmaze/snappymail/issues/1438)
### Nextcloud
- Save as .eml
[#1425](https://github.com/the-djmaze/snappymail/issues/1425)
## 2.34.2 2024-02-14
### Fixed
- Message was sent but not saved to sent items folder
[#1432](https://github.com/the-djmaze/snappymail/issues/1432)
- Login with scram failed
[#1433](https://github.com/the-djmaze/snappymail/issues/1433)
## 2.34.1 2024-02-13
### Added
- Autocrypt support
[#342](https://github.com/the-djmaze/snappymail/issues/342)
- Load the mailboxes/folders of all namespaces (other users, global, shared, etc.)
- Load keys from server into OpenPGP.js
[#973](https://github.com/the-djmaze/snappymail/issues/973)
- Import PGP Keys from remote key servers
- Sort Inbox Folders with Unread Messages First
[#1427](https://github.com/the-djmaze/snappymail/issues/1427)
- Define JMAP FolderModel.myRights
- Identity Management: add identity display name
[#1405](https://github.com/the-djmaze/snappymail/issues/1405)
- Identity Management: add per-identity "sent" folder
[#1404](https://github.com/the-djmaze/snappymail/issues/1404)
- Some support for JSON-LD / Structured Email
[#1422](https://github.com/the-djmaze/snappymail/issues/1422)
- Domain Autoconfig and Microsoft's autodiscover (and also as extension/plugin)
- View MMS messages that are received via email
[#1294](https://github.com/the-djmaze/snappymail/issues/1294)
- Draft code for S/MIME
[#259](https://github.com/the-djmaze/snappymail/issues/259)
### Changed
- Many OpenPGP improvements
[#89](https://github.com/the-djmaze/snappymail/issues/89)
- Allow CSP connect-src CORS for keys.openpgp.org to directly fetch PGP keys
- Improved handling of visible folders
- KnockoutJS Replace some ko.exportSymbol('*') in favour of ko['*']
- KnockoutJS use Symbol for isObservableArray()
- Simplify generating folderListVisible
- Drop the bSearchSecretWords param from logger
- Transparent background for text
[#1412](https://github.com/the-djmaze/snappymail/issues/1412)
- Enable OpenPGP.js by default at install
- Added folder edit popup for improved IMAP ACL Support
[#157](https://github.com/the-djmaze/snappymail/issues/157)
- Process all IMAP namespaces
- Update Polish by @tinola
- Update Portuguese by @ner00
### Fixed
- Make time_zone a select list due to PEBKAC
- Workaround Outlook generated double spacing
[#1415](https://github.com/the-djmaze/snappymail/issues/1415)
- HTML Parser is not picking up the full Unsubscribe URL in the attached text file
[#1225](https://github.com/the-djmaze/snappymail/issues/1225)
- Contacts - it auto "Select All", after entry delete
[#1411](https://github.com/the-djmaze/snappymail/issues/1411)
- Message header parsing issue
[#1403](https://github.com/the-djmaze/snappymail/issues/1403)
- apple-touch-icon should not be transparent
[#1408](https://github.com/the-djmaze/snappymail/issues/1408)
- Creation of dynamic property is deprecated
[#1409](https://github.com/the-djmaze/snappymail/issues/1409)
- Ask/send readReceipt was broken
- OpenPGP public key can not be removed anymore after importing private key of subkey
[#1384](https://github.com/the-djmaze/snappymail/issues/1384)
- KnockoutJS failed to output text '0'
- JavaScript friendlySize() failed on 0
- Workaround Dovecot `PREAUTH [CAPABILITY (null)]` issue
- Workaround disabled ACL could cause "Disconnected: Too many invalid IMAP commands"
### Nextcloud
- Save multiple as .eml
[#1425](https://github.com/the-djmaze/snappymail/issues/1425)
- Disabled support for Nextcloud OpenID Connect
[#1420](https://github.com/the-djmaze/snappymail/issues/1420)
## 2.33.0 2024-01-22
### Added
- Feature to use the SQLite AddressBook per login account instead of global (on by default).
- Return all fetched messages headers in JSON.
### Changed
- Docker hub use Alpine linux 3.18.5 and PHP 8.2
- Some InvalidArgumentException to the better suited ValueError
- Removed some unused KnockoutJS code
- KnockoutJS drop unused rateLimit method
- Cleanup some data-bind=""
- Drop the disabled KnockoutJS twoWayBindings
- Drop support for KnockoutJS _ko_property_writers and for two-way binding they must be observables
- Login form use method="POST" to prevent uri exposure when javascript fails
- Merge code to generate MIME PGP parts and MIME Plain parts
- SMTP sendRequestWithCheck for future support of RFC's
- Cleanup mime header handling
### Fixed
- Sorting not supported since 2.32.0
[#1373](https://github.com/the-djmaze/snappymail/issues/1373)
- FILE_ON_SAVING_ERROR is not defined
[#1379](https://github.com/the-djmaze/snappymail/issues/1379)
- Saving EML files with same subject result in only saving latest email
[#1381](https://github.com/the-djmaze/snappymail/issues/1381)
- Some Sieve parser issues
- Handling of RainLoop Sieve script
- Sieve rfc5429 RejectCommand and ErejectCommand
- KnockoutJS title:value was removed, use attr:{title:value}
- dataBaseUpgrade() always runs on sqlite and pgsql
- Message was sent but not saved to sent items folder
[#1397](https://github.com/the-djmaze/snappymail/issues/1397)
- DKIM `pass` detection sometimes failed
## 2.32.0 2023-12-26
### Added

View file

@ -49,7 +49,7 @@ This fork of RainLoop has the following changes:
* Admin uses password_hash/password_verify
* Auth failed attempts written to syslog
* Added Fail2ban instructions
* ES2018
* ES2020
* PHP 7.4+ required
* PHP mbstring extension required
* PHP replaced pclZip with PharData and ZipArchive
@ -140,28 +140,28 @@ RainLoop 1.17 vs SnappyMail
|js/* |RainLoop |Snappy |
|--------------- |--------: |--------: |
|admin.js |2.170.153 | 80.370 |
|app.js |4.207.787 | 408.429 |
|boot.js | 868.735 | 4.142 |
|libs.js | 658.812 | 193.230 |
|sieve.js | 0 | 85.085 |
|admin.js |2.170.153 | 83.494 |
|app.js |4.207.787 | 437.079 |
|boot.js | 868.735 | 4.147 |
|libs.js | 658.812 | 193.716 |
|sieve.js | 0 | 84.598 |
|polyfills.js | 334.608 | 0 |
|serviceworker.js | 0 | 285 |
|TOTAL |8.240.095 | 771.541 |
|TOTAL |8.240.095 | 803.319 |
|js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|--------------- |--------: |--------: |------: |------: |--------: |--------: |
|admin.min.js | 256.831 | 39.285 | 73.606 | 13.183 | 60.877 | 11.806 |
|app.min.js | 515.367 | 186.270 |139.456 | 63.111 |110.485 | 54.218 |
|boot.min.js | 84.659 | 2.084 | 26.998 | 1.202 | 23.643 | 1.003 |
|libs.min.js | 584.772 | 93.401 |180.901 | 34.765 |155.182 | 31.194 |
|sieve.min.js | 0 | 41.316 | 0 | 10.364 | 0 | 9.352 |
|admin.min.js | 256.831 | 40.856 | 73.606 | 13.776 | 60.877 | 12.345 |
|app.min.js | 515.367 | 197.782 |139.456 | 67.143 |110.485 | 57.251 |
|boot.min.js | 84.659 | 2.087 | 26.998 | 1.204 | 23.643 | 1.002 |
|libs.min.js | 584.772 | 92.365 |180.901 | 34.487 |155.182 | 30.830 |
|sieve.min.js | 0 | 41.093 | 0 | 10.325 | 0 | 9.327 |
|polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 |
|TOTAL user |1.217.635 | 281.755 |358.761 | 99.078 |299.485 | 86.415 |
|TOTAL user+sieve |1.217.635 | 323.071 |358.761 |109.442 |299.485 | 95.767 |
|TOTAL admin | 959.099 | 134.770 |292.911 | 49.150 |249.877 | 44.003 |
|TOTAL user |1.217.635 | 292.234 |358.761 |102.834 |299.485 | 89.083 |
|TOTAL user+sieve |1.217.635 | 333.327 |358.761 |113.159 |299.485 | 98.410 |
|TOTAL admin | 959.099 | 135.308 |292.911 | 49.467 |249.877 | 44.177 |
For a user it is around 69% smaller and faster than traditional RainLoop.
For a user it is around 68% smaller and faster than traditional RainLoop.
### CSS changes
@ -188,12 +188,12 @@ For a user it is around 69% smaller and faster than traditional RainLoop.
|css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli |
|------------ |-------: |------: |------: |------: |--------: |
|app.css | 340.331 | 84.484 | 46.946 | 17.627 | 15.110 |
|app.min.css | 274.947 | 67.910 | 39.647 | 15.541 | 13.547 |
|app.css | 340.331 | 84.629 | 46.946 | 17.652 | 15.127 |
|app.min.css | 274.947 | 67.995 | 39.647 | 15.562 | 13.568 |
|boot.css | | 1.326 | | 664 | 545 |
|boot.min.css | | 1.071 | | 590 | 474 |
|admin.css | | 30.641 | | 7.028 | 6.111 |
|admin.min.css | | 24.742 | | 6.352 | 5.599 |
|admin.css | | 30.528 | | 7.001 | 6.088 |
|admin.min.css | | 24.652 | | 6.325 | 5.574 |
### PGP
RainLoop uses the old OpenPGP.js v2
@ -207,7 +207,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 | 541.176 |102.388 | 168.266 | 84.241 | 138.278 |
|openpgp.min.js | 330.742 | 546.309 |102.388 | 169.249 | 84.241 | 138.751 |
|openpgp.worker | 1.499 | | 824 | | 695 | |
@ -224,5 +224,3 @@ Still TODO:
|ckeditor | ? | 520.035 | ? | 155.916 |
CKEditor including the 7 asset requests (css,language,plugins,icons) is 633.46 KB / 180.47 KB (gzip).
To use the old CKEditor, you must install the plugin.

View file

@ -6,15 +6,15 @@ Currently due to the fast development only the latest version receives security
| Version | Supported |
| -------- | --------- |
| 2.13.x | ✔ |
| < 2.13.0 | |
| 2.30.x | ✔ |
| < 2.30.0 | |
## Reporting a Vulnerability
Please report security issues or vulnerabilities as an encrypted email to [security@snappymail.eu](mailto:security@snappymail.eu).
Your report should be detailed enough with clear steps to reproduce and classify the found vulnerability.
You can find the PGP public key below and on the major public keyservers like [pgp.key-server.io](https://pgp.key-server.io).
You can find the PGP public key below and on the major public keyservers like [keys.openpgp.org](https://keys.openpgp.org).
```
-----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: Type: 255-bit EdDSA

View file

@ -3,6 +3,8 @@ import ko from 'ko';
import { logoutLink } from 'Common/Links';
import { i18nToNodes, initOnStartOrLangChange } from 'Common/Translator';
import { arePopupsVisible } from 'Knoin/Knoin';
import { LanguageStore } from 'Stores/Language';
import { initThemes } from 'Stores/Theme';
@ -18,6 +20,7 @@ export class AbstractApp {
}
logoutReload(url) {
arePopupsVisible(false);
url = url || logoutLink();
if (location.href !== url) {
setTimeout(() => location.href = url, 100);

View file

@ -1,6 +1,6 @@
import 'External/ko';
import { Settings, SettingsGet } from 'Common/Globals';
import { SettingsGet, SettingsAdmin } from 'Common/Globals';
import { initThemes } from 'Stores/Theme';
import Remote from 'Remote/Admin/Fetch';
@ -11,6 +11,8 @@ import { LoginAdminScreen } from 'Screen/Admin/Login';
import { startScreens } from 'Knoin/Knoin';
import { AbstractApp } from 'App/Abstract';
import { AskPopupView } from 'View/Popup/Ask';
export class AdminApp extends AbstractApp {
constructor() {
super(Remote);
@ -23,7 +25,8 @@ export class AdminApp extends AbstractApp {
}
start() {
if (!Settings.app('adminAllowed')) {
// if (!Settings.app('adminAllowed')) {
if (!SettingsAdmin('allowed')) {
rl.route.root();
setTimeout(() => location.href = '/', 1);
} else if (SettingsGet('Auth')) {
@ -34,3 +37,16 @@ export class AdminApp extends AbstractApp {
}
}
}
AskPopupView.credentials = function(sAskDesc, btnText) {
return new Promise(resolve => {
this.showModal([
sAskDesc,
view => resolve({username:view.username(), password:view.passphrase()}),
() => resolve(null),
true,
3,
btnText
]);
});
};

View file

@ -1,8 +1,8 @@
import 'External/User/ko';
import { SMAudio } from 'Common/Audio';
import { isArray, pInt } from 'Common/Utils';
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility } from 'Common/UtilsUser';
import { pInt } from 'Common/Utils';
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser';
import {
FolderType,
@ -33,9 +33,9 @@ import { SettingsUserStore } from 'Stores/User/Settings';
import { NotificationUserStore } from 'Stores/User/Notification';
import { AccountUserStore } from 'Stores/User/Account';
import { ContactUserStore } from 'Stores/User/Contact';
import { IdentityUserStore } from 'Stores/User/Identity';
import { FolderUserStore } from 'Stores/User/Folder';
import { PgpUserStore } from 'Stores/User/Pgp';
import { SMimeUserStore } from 'Stores/User/SMime';
import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { ThemeStore, initThemes } from 'Stores/Theme';
import { LanguageStore } from 'Stores/Language';
@ -43,9 +43,6 @@ import { MessageUserStore } from 'Stores/User/Message';
import Remote from 'Remote/User/Fetch';
import { AccountModel } from 'Model/Account';
import { IdentityModel } from 'Model/Identity';
import { LoginUserScreen } from 'Screen/User/Login';
import { MailBoxUserScreen } from 'Screen/User/MailBox';
import { SettingsUserScreen } from 'Screen/User/Settings';
@ -89,6 +86,8 @@ export class AppUser extends AbstractApp {
this.folderList = FolderUserStore.folderList;
this.messageList = MessagelistUserStore;
this.ask = AskPopupView;
}
/**
@ -139,31 +138,6 @@ export class AppUser extends AbstractApp {
}
}
accountsAndIdentities() {
AccountUserStore.loading(true);
IdentityUserStore.loading(true);
Remote.request('AccountsAndIdentities', (iError, oData) => {
AccountUserStore.loading(false);
IdentityUserStore.loading(false);
if (!iError) {
let items = oData.Result.Accounts;
AccountUserStore(isArray(items)
? items.map(oValue => new AccountModel(oValue.email, oValue.name))
: []
);
AccountUserStore.unshift(new AccountModel(SettingsGet('mainEmail'), '', false));
items = oData.Result.Identities;
IdentityUserStore(isArray(items)
? items.map(identityData => IdentityModel.reviveFromJson(identityData))
: []
);
}
});
}
/**
* @param {string} folder
* @param {Array=} list = []
@ -216,9 +190,7 @@ export class AppUser extends AbstractApp {
setRefreshFoldersInterval(pInt(SettingsGet('CheckMailInterval')));
ContactUserStore.init();
this.accountsAndIdentities();
loadAccountsAndIdentities();
setTimeout(() => {
const cF = FolderUserStore.currentFolderFullName();
@ -247,6 +219,7 @@ export class AppUser extends AbstractApp {
setInterval(reloadTime, 60000);
PgpUserStore.init();
SMimeUserStore.loadCertificates();
setTimeout(() => mailToHelper(SettingsGet('mailToEmail')), 500);
@ -267,7 +240,6 @@ export class AppUser extends AbstractApp {
console.error(e);
}
});
} else {
startScreens([LoginUserScreen]);
}
@ -278,3 +250,50 @@ export class AppUser extends AbstractApp {
showScreenPopup(ComposePopupView, params);
}
}
AskPopupView.password = function(sAskDesc, btnText, ask) {
return new Promise(resolve => {
this.showModal([
sAskDesc,
view => resolve({
password:view.passphrase(),
username:/*ask & 2 ? */view.username(),
remember:/*ask & 4 ? */view.remember()
}),
() => resolve(null),
true,
ask || 1,
btnText
]);
});
};
AskPopupView.cryptkey = () => new Promise(resolve => {
const fn = () => AskPopupView.showModal([
i18n('CRYPTO/ASK_CRYPTKEY_PASS'),
view => {
let pass = view.passphrase();
if (pass) {
Remote.post('ResealCryptKey', null, {
passphrase: pass
}).then(response => {
resolve(response?.Result);
}).catch(e => {
if (111 === e.code) {
fn();
} else {
console.error(e);
resolve(null);
}
});
} else {
resolve(null);
}
},
() => resolve(null),
true,
1,
i18n('CRYPTO/DECRYPT')
]);
fn();
});

View file

@ -50,6 +50,7 @@ Notifications = {
ConnectionError: 104,
DomainNotAllowed: 109,
AccountNotAllowed: 110,
CryptKeyError: 111,
ContactsSyncError: 140,
@ -95,7 +96,6 @@ Notifications = {
JsonParse: 952,
// JsonTimeout: 953,
UnknownNotification: 998,
UnknownError: 999,
// Admin

View file

@ -1,7 +1,7 @@
/* eslint key-spacing: 0 */
/* eslint quote-props: 0 */
import { arrayLength } from 'Common/Utils';
import { arrayLength, pInt } from 'Common/Utils';
export const RFC822 = 'message/rfc822';
@ -30,6 +30,8 @@ const
p7c: app+'pkcs7-mime',
p7m: app+'pkcs7-mime',
p7s: app+'pkcs7-signature',
p12: app+'pkcs12',
pfx: app+'x-pkcs12',
torrent: app+'x-bittorrent',
// scripts
@ -136,7 +138,7 @@ export const FileInfo = {
getContentType: fileName => {
fileName = lowerCase(fileName);
if ('winmail.dat' === fileName) {
return app + 'ms-tnef';
return app + 'vnd.ms-tnef';
}
let ext = fileName.split('.').pop();
if (/^(txt|text|def|list|in|ini|log|sql|cfg|conf)$/.test(ext))
@ -164,7 +166,7 @@ export const FileInfo = {
*/
getType: (ext, mimeType) => {
ext = lowerCase(ext);
mimeType = lowerCase(mimeType).replace('csv/plain', 'text/csv');
mimeType = lowerCase(mimeType).replace('csv/plain', 'text/csv').replace('x-','');
let key = ext + mimeType;
if (cache[key]) {
@ -173,7 +175,7 @@ export const FileInfo = {
let result = FileType.Unknown;
const mimeTypeParts = mimeType.split('/'),
type = mimeTypeParts[1].replace('x-','').replace('-compressed',''),
type = mimeTypeParts[1].replace('-compressed',''),
match = str => mimeType.includes(str),
archive = /^(zip|7z|tar|rar|gzip|bzip|bzip2)$/;
@ -205,9 +207,8 @@ export const FileInfo = {
case 'pdf' == type || 'pdf' == ext:
result = FileType.Pdf;
break;
case [app+'pgp-signature', app+'pgp-keys'].includes(mimeType)
|| ['asc', 'pem', 'ppk'].includes(ext)
|| [app+'pkcs7-signature'].includes(mimeType) || 'p7s' == ext:
case [app+'pgp-signature', app+'pgp-keys', exts.p7m, exts.p7s, exts.p12, exts.pfx].includes(mimeType)
|| ['asc', 'pem', 'ppk', 'p7s', 'p7m', 'p12', 'pfx'].includes(ext):
result = FileType.Certificate;
break;
case match(msOffice+'.wordprocessingml') || match(openDoc+'.text') || match('vnd.ms-word')
@ -273,8 +274,8 @@ export const FileInfo = {
},
friendlySize: bytes => {
bytes = parseInt(bytes, 10) || 0;
let i = Math.floor(Math.log(bytes) / Math.log(1024));
bytes = pInt(bytes);
let i = bytes ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0;
return (bytes / Math.pow(1024, i)).toFixed(2>i ? 0 : 1) + ' ' + sizes[i];
}

View file

@ -1,5 +1,6 @@
import { RFC822 } from 'Common/File';
import { getFolderInboxName, getFolderFromCacheList } from 'Common/Cache';
import { baseCollator } from 'Common/Translator';
import { isArray, arrayLength } from 'Common/Utils';
import { SettingsUserStore } from 'Stores/User/Settings';
import { FolderUserStore } from 'Stores/User/Folder';
@ -27,7 +28,7 @@ setRefreshFoldersInterval = minutes => {
sortFolders = folders => {
try {
let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
let collator = baseCollator(true);
folders.sort((a, b) =>
a.isInbox() ? -1 : (b.isInbox() ? 1 : collator.compare(a.fullName, b.fullName))
);

View file

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

View file

@ -1,5 +1,5 @@
import { createElement } from 'Common/Globals';
import { forEachObjectEntry, pInt } from 'Common/Utils';
import { forEachObjectEntry, isArray, pInt } from 'Common/Utils';
import { SettingsUserStore } from 'Stores/User/Settings';
const
@ -207,7 +207,9 @@ export const
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
result = {
hasExternals: false
hasExternals: false,
tracking: false,
linkedData: []
},
findAttachmentByCid = cId => oAttachments.findByCid(cId),
@ -269,12 +271,15 @@ export const
// Not supported by <template> element
// .replace(/<!doctype[^>]*>/gi, '')
// .replace(/<\?xml[^>]*\?>/gi, '')
.replace(/<(\/?)head(\s[^>]*)?>/gi, '')
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
// .replace(/<\/?(html|head)[^>]*>/gi, '')
// Fix Reddit https://github.com/the-djmaze/snappymail/issues/540
.replace(/<span class="preview-text"[\s\S]+?<\/span>/, '')
// https://github.com/the-djmaze/snappymail/issues/900
.replace(/\u2028/g,' ')
// https://github.com/the-djmaze/snappymail/issues/1415
.replace(/<br>\s*<\/p>/gi,'</p>')
.trim();
html = '';
@ -284,6 +289,21 @@ export const
nodeIterator.referenceNode.remove();
}
/**
* Basic support for Linked Data (Structured Email)
* https://json-ld.org/
* https://structured.email/
**/
tmpl.content.querySelectorAll('script[type="application/ld+json"]').forEach(oElement => {
// Could be array of objects or single object
try {
const data = JSON.parse(oElement.textContent);
(isArray(data) ? data : [data]).forEach(entry => result.linkedData.push(entry));
} catch (e) {
console.error(e, oElement.textContent);
}
});
tmpl.content.querySelectorAll(
disallowedTags
+ (0 < bqLevel ? ',' + (new Array(1 + bqLevel).fill('blockquote').join(' ')) : '')
@ -381,10 +401,14 @@ export const
if ('A' === name) {
value = oElement.href;
if (!/^([a-z]+):/i.test(value)) {
setAttribute('data-x-broken-href', value);
setAttribute('data-x-href-broken', value);
delAttribute('href');
} else {
oElement.href = stripTracking(value);
if (oElement.href != value) {
result.tracking = true;
setAttribute('data-x-href-tracking', value);
}
setAttribute('target', '_blank');
// setAttribute('rel', 'external nofollow noopener noreferrer');
}
@ -406,9 +430,8 @@ export const
*/
let skipStyle = false;
if (hasAttribute('src')) {
value = stripTracking(delAttribute('src'));
value = delAttribute('src');
if (value) {
if ('IMG' === name) {
oElement.loading = 'lazy';
let attachment;
@ -445,12 +468,18 @@ export const
oStyle.display = 'none';
// setAttribute('style', 'display:none');
setAttribute('data-x-src-hidden', value);
// result.tracking = true;
}
else if (httpre.test(value))
{
setAttribute('data-x-src', value);
let src = stripTracking(value);
if (src != value) {
result.tracking = true;
setAttribute('data-x-src-tracking', value);
}
setAttribute('data-x-src', src);
result.hasExternals = true;
oElement.alt || (oElement.alt = value.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
oElement.alt || (oElement.alt = src.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
}
else if (value.startsWith('data:image/'))
{
@ -721,184 +750,7 @@ export const
.replace(/\n/g, '<br>');
blockquoteSwitcher();
return tmpl.innerHTML.trim();
},
WYSIWYGS = ko.observableArray();
WYSIWYGS.push(['Squire', (owner, container, onReady)=>{
let squire = new SquireUI(container);
setTimeout(()=>onReady(squire), 1);
/*
squire.on('blur', () => owner.blurTrigger());
squire.on('focus', () => clearTimeout(owner.blurTimer));
squire.on('mode', () => {
owner.blurTrigger();
owner.onModeChange?.(!owner.isPlain());
});
*/
}]);
rl.registerWYSIWYG = (name, construct) => WYSIWYGS.push([name, construct]);
export class HtmlEditor {
/**
* @param {Object} element
* @param {Function=} onBlur
* @param {Function=} onReady
* @param {Function=} onModeChange
*/
constructor(element, onBlur = null, onReady = null, onModeChange = null) {
this.blurTimer = 0;
this.onBlur = onBlur;
this.onModeChange = onModeChange;
if (element) {
onReady = onReady ? [onReady] : [];
this.onReady = fn => onReady.push(fn);
// TODO: make 'which' user configurable
// const which = 'CKEditor4',
// wysiwyg = WYSIWYGS.find(item => which == item[0]) || WYSIWYGS.find(item => 'Squire' == item[0]);
const wysiwyg = WYSIWYGS.find(item => 'Squire' == item[0]);
wysiwyg[1](this, element, editor => {
this.editor = editor;
editor.on('blur', () => this.blurTrigger());
editor.on('focus', () => clearTimeout(this.blurTimer));
editor.on('mode', () => {
this.blurTrigger();
this.onModeChange?.(!this.isPlain());
});
this.onReady = fn => fn();
onReady.forEach(fn => fn());
});
}
}
blurTrigger() {
if (this.onBlur) {
clearTimeout(this.blurTimer);
this.blurTimer = setTimeout(() => this.onBlur?.(), 200);
}
}
/**
* @returns {boolean}
*/
isHtml() {
return this.editor ? !this.isPlain() : false;
}
/**
* @returns {boolean}
*/
isPlain() {
return this.editor ? 'plain' === this.editor.mode : false;
}
/**
* @returns {void}
*/
clearCachedSignature() {
this.onReady(() => this.editor.execCommand('insertSignature', {
clearCache: true
}));
}
/**
* @param {string} signature
* @param {bool} html
* @param {bool} insertBefore
* @returns {void}
*/
setSignature(signature, html, insertBefore = false) {
this.onReady(() => this.editor.execCommand('insertSignature', {
isHtml: html,
insertBefore: insertBefore,
signature: signature
}));
}
/**
* @param {boolean=} wrapIsHtml = false
* @returns {string}
*/
getData() {
let result = '';
if (this.editor) {
try {
if (this.isPlain() && this.editor.plugins.plain && this.editor.__plain) {
result = this.editor.__plain.getRawData();
} else {
result = this.editor.getData();
}
} catch (e) {} // eslint-disable-line no-empty
}
return result;
}
/**
* @returns {string}
*/
getDataWithHtmlMark() {
return (this.isHtml() ? ':HTML:' : '') + this.getData();
}
modeWysiwyg() {
this.onReady(() => this.editor.setMode('wysiwyg'));
}
modePlain() {
this.onReady(() => this.editor.setMode('plain'));
}
setHtmlOrPlain(text) {
text.startsWith(':HTML:')
? this.setHtml(text.slice(6))
: this.setPlain(text);
}
setData(mode, data) {
this.onReady(() => {
const editor = this.editor;
this.clearCachedSignature();
try {
editor.setMode(mode);
if (this.isPlain() && editor.plugins.plain && editor.__plain) {
editor.__plain.setRawData(data);
} else {
editor.setData(data);
}
} catch (e) { console.error(e); }
});
}
setHtml(html) {
this.setData('wysiwyg', html/*.replace(/<p[^>]*><\/p>/gi, '')*/);
}
setPlain(txt) {
this.setData('plain', txt);
}
focus() {
this.onReady(() => this.editor.focus());
}
hasFocus() {
try {
return !!this.editor?.focusManager.hasFocus;
} catch (e) {
return false;
}
}
blur() {
this.onReady(() => this.editor.focusManager.blur(true));
}
clear() {
this.onReady(() => this.isPlain() ? this.setPlain('') : this.setHtml(''));
}
}
};
rl.Utils = {
htmlToPlain: htmlToPlain,

161
dev/Common/HtmlEditor.js Normal file
View file

@ -0,0 +1,161 @@
import { SettingsUserStore } from 'Stores/User/Settings';
export const
WYSIWYGS = ko.observableArray();
WYSIWYGS.push({
name: 'Squire',
construct: (owner, container, onReady) => onReady(new SquireUI(container))
});
rl.registerWYSIWYG = (name, construct) => WYSIWYGS.push({name, construct});
export class HtmlEditor {
/**
* @param {Object} element
* @param {Function=} onBlur
* @param {Function=} onReady
* @param {Function=} onModeChange
*/
constructor(element, onReady = null, onModeChange = null, onBlur = null) {
this.blurTimer = 0;
this.onBlur = onBlur;
this.onModeChange = onModeChange;
if (element) {
onReady = onReady ? [onReady] : [];
this.onReady = fn => onReady.push(fn);
const which = SettingsUserStore.editorWysiwyg(),
wysiwyg = WYSIWYGS.find(item => which == item.name) || WYSIWYGS.find(item => 'Squire' == item.name);
wysiwyg.construct(this, element, editor => setTimeout(()=>{
this.editor = editor;
editor.on('blur', () => this.blurTrigger());
editor.on('focus', () => clearTimeout(this.blurTimer));
editor.on('mode', () => {
this.blurTrigger();
this.onModeChange?.(!this.isPlain());
});
this.onReady = fn => fn();
onReady.forEach(fn => fn());
},1));
}
}
blurTrigger() {
if (this.onBlur) {
clearTimeout(this.blurTimer);
this.blurTimer = setTimeout(() => this.onBlur?.(), 200);
}
}
/**
* @returns {boolean}
*/
isHtml() {
return this.editor ? !this.isPlain() : false;
}
/**
* @returns {boolean}
*/
isPlain() {
return this.editor ? 'plain' === this.editor.mode : false;
}
/**
* @returns {void}
*/
clearCachedSignature() {
this.onReady(() => this.editor.execCommand('insertSignature', {
clearCache: true
}));
}
/**
* @param {string} signature
* @param {bool} html
* @param {bool} insertBefore
* @returns {void}
*/
setSignature(signature, html, insertBefore = false) {
this.onReady(() => this.editor.execCommand('insertSignature', {
isHtml: html,
insertBefore: insertBefore,
signature: signature
}));
}
/**
* @param {boolean=} wrapIsHtml = false
* @returns {string}
*/
getData() {
let result = '';
if (this.editor) {
try {
if (this.isPlain()) {
result = this.editor.getPlainData();
} else {
result = this.editor.getData();
}
} catch (e) {} // eslint-disable-line no-empty
}
return result;
}
/**
* @returns {string}
*/
getDataWithHtmlMark() {
return (this.isHtml() ? ':HTML:' : '') + this.getData();
}
modeWysiwyg() {
this.onReady(() => this.editor.setMode('wysiwyg'));
}
modePlain() {
this.onReady(() => this.editor.setMode('plain'));
}
setHtmlOrPlain(text) {
text.startsWith(':HTML:')
? this.setHtml(text.slice(6))
: this.setPlain(text);
}
setData(mode, data) {
this.onReady(() => {
const editor = this.editor;
this.clearCachedSignature();
try {
editor.setMode(mode);
if (this.isPlain()) {
editor.setPlainData(data);
} else {
editor.setData(data);
}
} catch (e) { console.error(e); }
});
}
setHtml(html) {
this.setData('wysiwyg', html/*.replace(/<p[^>]*><\/p>/gi, '')*/);
}
setPlain(txt) {
this.setData('plain', txt);
}
focus() {
this.onReady(() => this.editor.focus());
}
blur() {
this.onReady(() => this.editor.blur());
}
clear() {
this.onReady(() => this.isPlain() ? this.setPlain('') : this.setHtml(''));
}
}

View file

@ -1,13 +1,13 @@
import { pInt } from 'Common/Utils';
import { doc, Settings } from 'Common/Globals';
import { doc, Settings, SettingsAdmin } from 'Common/Globals';
const
BASE = doc.location.pathname.replace(/\/+$/,'') + '/',
HASH_PREFIX = '#/',
adminPath = () => rl.adminArea() && !Settings.app('adminHost'),
adminPath = () => rl.adminArea() && !SettingsAdmin('host'),
prefix = () => BASE + '?' + (adminPath() ? Settings.app('adminPath') : '');
prefix = () => BASE + '?' + (adminPath() ? SettingsAdmin('path') : '');
export const
SUB_QUERY_PREFIX = '&q[]=',
@ -38,11 +38,10 @@ export const
/**
* @param {string} download
* @param {string=} customSpecSuffix
* @returns {string}
*/
attachmentDownload = (download, customSpecSuffix) =>
serverRequestRaw('Download', download, customSpecSuffix),
attachmentDownload = (download) =>
serverRequestRaw('Download', download),
proxy = url =>
BASE + '?/ProxyExternal/'

View file

@ -22,7 +22,7 @@ const
getNotificationMessage = code => {
let key = getKeyByValue(Notifications, code);
return key ? I18N_DATA.NOTIFICATIONS[i18nKey(key).replace('_NOTIFICATION', '_ERROR')] : '';
return key ? I18N_DATA.NOTIFICATIONS[key] : '';
},
fromNow = date => relativeTime(Math.round((date.getTime() - Date.now()) / 1000));
@ -45,8 +45,7 @@ export const
}
}
if (Intl.RelativeTimeFormat) {
let rtf = new Intl.RelativeTimeFormat(doc.documentElement.lang);
return rtf.format(seconds, unit);
return (new Intl.RelativeTimeFormat(doc.documentElement.lang)).format(seconds, unit);
}
// Safari < 14
abs = Math.abs(seconds);
@ -62,7 +61,7 @@ export const
* @returns {string}
*/
i18n = (key, valueList, defaulValue) => {
let result = null == defaulValue ? key : defaulValue;
let result = defaulValue ?? key;
let path = key.split('/');
if (I18N_DATA[path[0]] && path[1]) {
result = I18N_DATA[path[0]][path[1]] || result;
@ -102,8 +101,7 @@ export const
timestampToString = (timeStampInUTC, formatStr) => {
const now = Date.now(),
time = 0 < timeStampInUTC ? Math.min(now, timeStampInUTC * 1000) : (0 === timeStampInUTC ? now : 0);
time = 0 < timeStampInUTC ? timeStampInUTC * 1000 : (0 === timeStampInUTC ? now : 0);
if (31536000000 < time) {
const m = new Date(time), h = LanguageStore.hourCycle();
switch (formatStr) {
@ -144,7 +142,7 @@ export const
time = Date.parse(element.dateTime) / 1000;
}
let key = element.dataset.momentFormat;
let key = element.dataset.timeFormat;
if (key) {
element.textContent = timestampToString(time, key);
if ('FULL' !== key && 'FROMNOW' !== key) {
@ -212,14 +210,13 @@ export const
script.remove();
resolve();
};
script.onerror = () => reject(new Error('Language '+language+' failed'));
script.onerror = () => reject(Error('Language '+language+' failed'));
script.src = langLink(language, admin);
// script.async = true;
doc.head.append(script);
}),
/**
*
* @param {string} language
* @param {boolean=} isEng = false
* @returns {string}
@ -229,6 +226,8 @@ export const
'LANGS_NAMES' + (true === isEng ? '_EN' : '') + '/' + language,
null,
language
);
),
baseCollator = numeric => new Intl.Collator(doc.documentElement.lang, {numeric: !!numeric, sensitivity: 'base'});
init();

View file

@ -10,7 +10,7 @@ export const
pInt = (value, defaultValue = 0) => {
value = parseInt(value, 10);
return isNaN(value) || !isFinite(value) ? defaultValue : value;
return isFinite(value) ? value : defaultValue;
},
defaultOptionsAfterRender = (domItem, item) =>

View file

@ -12,14 +12,47 @@ import { ThemeStore } from 'Stores/Theme';
import Remote from 'Remote/User/Fetch';
import { attachmentDownload } from 'Common/Links';
import { AccountModel } from 'Model/Account';
import { IdentityModel } from 'Model/Identity';
import { AccountUserStore } from 'Stores/User/Account';
import { IdentityUserStore } from 'Stores/User/Identity';
import { isArray } from 'Common/Utils';
export const
moveAction = ko.observable(false),
// 1 = move, 2 = copy
moveAction = ko.observable(0),
dropdownsDetectVisibility = (() =>
dropdownVisibility(!!dropdowns.find(item => item.classList.contains('show')))
).debounce(50),
loadAccountsAndIdentities = () => {
AccountUserStore.loading(true);
IdentityUserStore.loading(true);
Remote.request('AccountsAndIdentities', (iError, oData) => {
AccountUserStore.loading(false);
IdentityUserStore.loading(false);
if (!iError) {
let items = oData.Result.Accounts;
AccountUserStore(isArray(items)
? items.map(oValue => new AccountModel(oValue.email, oValue.name))
: []
);
AccountUserStore.unshift(new AccountModel(SettingsGet('mainEmail'), '', false));
items = oData.Result.Identities;
IdentityUserStore(isArray(items)
? items.map(identityData => IdentityModel.reviveFromJson(identityData))
: []
);
}
});
},
/**
* @param {string} link
* @returns {boolean}
@ -89,7 +122,7 @@ computedPaginatorHelper = (koCurrentPage, koPageCount) => {
next = 0,
limit = 2;
if (1 < pageCount || (0 < pageCount && pageCount < currentPage)) {
if (1 < pageCount) {
if (pageCount < currentPage) {
fAdd(pageCount);
prev = pageCount;
@ -260,6 +293,8 @@ viewMessage = (oMessage, popup) => {
class:'b-text-part'
+ (oMessage.pgpSigned() ? ' openpgp-signed' : '')
+ (oMessage.pgpEncrypted() ? ' openpgp-encrypted' : '')
+ (oMessage.smimeSigned() ? ' smime-signed' : '')
+ (oMessage.smimeEncrypted() ? ' smime-encrypted' : '')
});
MessageUserStore.purgeCache();
}
@ -325,5 +360,5 @@ populateMessageBody = (oMessage, popup) => {
}
};
leftPanelDisabled.subscribe(value => value && moveAction(false));
leftPanelDisabled.subscribe(value => value && moveAction(0));
moveAction.subscribe(value => value && leftPanelDisabled(false));

View file

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

View file

@ -1,5 +1,6 @@
import { doc, createElement, addEventsListeners } from 'Common/Globals';
import { EmailModel, addressparser } from 'Model/Email';
import { EmailModel } from 'Model/Email';
import { addressparser } from 'Mime/Address';
const contentType = 'snappymail/emailaddress',
getAddressKey = li => li?.emailaddress?.key,

View file

@ -15,7 +15,7 @@ export class JCard {
if (input) {
// read from jCard
if (typeof input !== 'object') {
throw new Error('error reading vcard')
throw Error('error reading vcard')
}
this.parseFromJCard(input)
}
@ -87,7 +87,7 @@ export class JCard {
arg = new VCardProperty(String(arg), value, params, type);
}
if (!(arg instanceof VCardProperty)) {
throw new Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty');
throw Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty');
}
let field = arg.getField();
this.props.set(field, [arg]);
@ -101,7 +101,7 @@ export class JCard {
arg = new VCardProperty(String(arg), value, params, type);
}
if (!(arg instanceof VCardProperty)) {
throw new Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty');
throw Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty');
}
// VCardProperty arguments
let field = arg.getField();
@ -124,15 +124,15 @@ export class JCard {
// VCardProperty argument
else if (arg instanceof VCardProperty) {
let propArray = this.props.get(arg.getField());
if (!(propArray === null || propArray === void 0 ? void 0 : propArray.includes(arg)))
throw new Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg));
if (!propArray?.includes(arg))
throw Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg));
propArray.splice(propArray.indexOf(arg), 1);
if (propArray.length === 0)
this.props.delete(arg.getField());
}
// incorrect arguments
else
throw new Error('invalid argument of VCard.remove(), expects ' +
throw Error('invalid argument of VCard.remove(), expects ' +
'string and optional param filter or a VCardProperty');
}
@ -202,7 +202,7 @@ export class JCard {
parseFullName(options) {
let n = this.getOne('n');
if (n === undefined) {
throw new Error('\'fn\' VCardProperty not present in card, cannot parse full name');
throw Error('\'fn\' VCardProperty not present in card, cannot parse full name');
}
let fnString = '';
// Position in n -> position in fn

View file

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

View file

@ -133,11 +133,11 @@ class SquireUI
dir: {
dir_ltr: {
html: '⁋',
cmd: () => squire.bidi('ltr')
cmd: () => squire.setTextDirection('ltr')
},
dir_rtl: {
html: '¶',
cmd: () => squire.bidi('rtl')
cmd: () => squire.setTextDirection('rtl')
}
},
colors: {
@ -237,7 +237,7 @@ class SquireUI
cmd: () => {
let node = squire.getSelectionClosest('IMG'),
src = prompt("Image", node?.src || "https://");
src?.length ? squire.insertImage(src) : (node && squire.detach(node));
src?.length ? squire.insertImage(src) : node?.remove();
},
matches: 'IMG'
},
@ -270,6 +270,13 @@ class SquireUI
btn.classList.toggle('active', 'source' == this.mode);
}
}
},
clear: {
removeStyle: {
html: '⎚',
cmd: () => squire.setStyle()
}
}
},
@ -318,11 +325,6 @@ class SquireUI
wysiwyg.className = 'squire-wysiwyg';
wysiwyg.dir = 'auto';
this.mode = ''; // 'plain' | 'wysiwyg'
this.__plain = {
getRawData: () => this.plain.value,
setRawData: plain => this.plain.value = plain
};
this.container = container;
this.squire = squire;
this.plain = plain;
@ -403,9 +405,9 @@ class SquireUI
let changes = actions.changes;
changes.undo.input.disabled = changes.redo.input.disabled = true;
squire.addEventListener('undoStateChange', state => {
changes.undo.input.disabled = !state.canUndo;
changes.redo.input.disabled = !state.canRedo;
squire.addEventListener('undoStateChange', e => {
changes.undo.input.disabled = !e.detail.canUndo;
changes.redo.input.disabled = !e.detail.canRedo;
});
actions.font.fontSize.input.selectedIndex = actions.font.fontSize.defaultValueIndex;
@ -478,21 +480,21 @@ class SquireUI
squire.addEventListener('pathChange', e => {
const squireRoot = squire.getRoot();
let elm = e.detail.element;
forEachObjectValue(actions, entries => {
forEachObjectValue(entries, cfg => {
// cfg.matches && cfg.input.classList.toggle('active', e.element && e.element.matches(cfg.matches));
cfg.matches && cfg.input.classList.toggle('active', e.element && e.element.closestWithin(cfg.matches, squireRoot));
// cfg.matches && cfg.input.classList.toggle('active', elm && elm.matches(cfg.matches));
cfg.matches && cfg.input.classList.toggle('active', elm && elm.closestWithin(cfg.matches, squireRoot));
});
});
if (e.element) {
if (elm) {
// try to find font-family and/or font-size and set "select" elements' values
let sizeSelectedIndex = actions.font.fontSize.defaultValueIndex;
let familySelectedIndex = defaultFontFamilyIndex;
let elm = e.element;
let familyFound = false;
let sizeFound = false;
do {
@ -524,21 +526,12 @@ class SquireUI
});
/*
squire.addEventListener('cursor', e => {
console.dir({cursor:e.range});
console.dir({cursor:e.detail.range});
});
squire.addEventListener('select', e => {
console.dir({select:e.range});
console.dir({select:e.detail.range});
});
*/
// CKEditor gimmicks used by HtmlEditor
this.plugins = {
plain: true
};
this.focusManager = {
hasFocus: () => squire._isFocused,
blur: () => squire.blur()
};
}
doAction(name) {
@ -576,7 +569,6 @@ class SquireUI
this.modeSelect.selectedIndex = 'plain' == this.mode ? 1 : 0;
}
// CKeditor gimmicks used by HtmlEditor
on(type, fn) {
if ('mode' == type) {
this.onModeChange = fn;
@ -643,6 +635,18 @@ class SquireUI
squire.setSelection( range );
}
getPlainData() {
return this.plain.value;
}
setPlainData(text) {
this.plain.value = text;
}
blur() {
this.squire.blur();
}
focus() {
if ('plain' == this.mode) {
this.plain.focus();

View file

@ -1,7 +1,7 @@
import 'External/ko';
import ko from 'ko';
import { RFC822 } from 'Common/File';
import { HtmlEditor } from 'Common/Html';
import { HtmlEditor } from 'Common/HtmlEditor';
import { timeToNode } from 'Common/Translator';
import { doc, elementById, addEventsListeners, dropdowns, leftPanelDisabled } from 'Common/Globals';
import { EmailAddressesComponent } from 'Component/EmailAddresses';
@ -91,7 +91,7 @@ Object.assign(ko.bindingHandlers, {
};
if (ko.isObservable(fValue)) {
editor = new HtmlEditor(element, fUpdateKoValue, fOnReady, fUpdateKoValue);
editor = new HtmlEditor(element, fOnReady, fUpdateKoValue, fUpdateKoValue);
fValue.__fetchEditorValue = fUpdateKoValue;
@ -103,7 +103,7 @@ Object.assign(ko.bindingHandlers, {
}
},
moment: {
time: {
init: ttn,
update: ttn
},

22
dev/External/ko.js vendored
View file

@ -28,6 +28,11 @@ export const
dispose = disposable => isFunction(disposable?.dispose) && disposable.dispose(),
onEvent = (element, event, fn) => {
element.addEventListener(event, fn);
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener(event, fn));
},
onKey = (key, element, fValueAccessor, fAllBindings, model) => {
let fn = event => {
if (key == event.key) {
@ -36,8 +41,7 @@ export const
fValueAccessor().call(model);
}
};
element.addEventListener('keydown', fn);
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keydown', fn));
onEvent(element, 'keydown', fn);
},
// With this we don't need delegateRunOnDestroy
@ -62,8 +66,7 @@ Object.assign(ko.bindingHandlers, {
},
update: (element, fValueAccessor) => {
let value = ko.unwrap(fValueAccessor());
value = isFunction(value) ? value() : value;
errorTip(element, value);
errorTip(element, isFunction(value) ? value() : value);
}
},
@ -82,6 +85,15 @@ Object.assign(ko.bindingHandlers, {
onKey(' ', element, fValueAccessor, fAllBindings, model)
},
toggle: {
init: (element, fValueAccessor) => {
let observable = fValueAccessor(),
fn = () => observable(!observable());
onEvent(element, 'click', fn);
onEvent(element, 'keydown', event => ' ' == event.key && fn());
}
},
i18nUpdate: {
update: (element, fValueAccessor) => {
ko.unwrap(fValueAccessor());
@ -94,7 +106,7 @@ Object.assign(ko.bindingHandlers, {
const command = fValueAccessor();
if (!command || !command.canExecute) {
throw new Error('Value should be a command');
throw Error('Value should be a command');
}
ko.bindingHandlers['FORM'==element.nodeName ? 'submit' : 'click'].init(

View file

@ -5,9 +5,13 @@ function typeCast(curValue, newValue) {
if (null != curValue) {
switch (typeof curValue)
{
case 'boolean': return 0 != newValue && !!newValue;
case 'number': return isFinite(newValue) ? parseFloat(newValue) : 0;
case 'string': return null != newValue ? '' + newValue : '';
case 'boolean':
return 0 != newValue && !!newValue;
case 'number':
newValue = parseFloat(newValue);
return isFinite(newValue) ? newValue : 0;
case 'string':
return null != newValue ? '' + newValue : '';
case 'object':
if (curValue.constructor.reviveFromJson) {
return curValue.constructor.reviveFromJson(newValue);
@ -23,7 +27,7 @@ export class AbstractModel {
constructor() {
/*
if (new.target === AbstractModel) {
throw new Error("Can't instantiate AbstractModel!");
throw Error("Can't instantiate AbstractModel!");
}
*/
Object.defineProperty(this, 'disposables', {value: []});

View file

@ -116,6 +116,11 @@ export class AbstractViewSettings
onHide() {}
viewModelDom
*/
/**
* When this[name] does not exists, create as observable with value of SettingsGet(name)
* When this[name+'Trigger'] does not exists, create as observable
* Subscribe to this[name], and handle saving the setting
*/
addSetting(name, valueCb)
{
let prop = name[0].toLowerCase() + name.slice(1),
@ -131,6 +136,7 @@ export class AbstractViewSettings
rl.app.Remote.saveSetting(name, value,
iError => {
this[trigger](iError ? SaveSettingStatus.Failed : SaveSettingStatus.Success);
// iError || Settings.set(name, value);
setTimeout(() => this[trigger](SaveSettingStatus.Idle), 1000);
}
);
@ -138,6 +144,10 @@ export class AbstractViewSettings
});
}
/**
* Foreach name if this[name] does not exists, create as observable with value of SettingsGet(name)
* Subscribe to this[name], for saving the setting
*/
addSettings(names)
{
names.forEach(name => {

197
dev/Mime/Address.js Normal file
View file

@ -0,0 +1,197 @@
import { decodeEncodedWords } from 'Mime/Encoding';
/**
* Parses structured e-mail addresses from an address/mailbox(-list) field
* https://datatracker.ietf.org/doc/html/rfc2822#section-3.4
*
* Example:
*
* "Name <address@domain>"
*
* will be converted to
*
* [{name: "Name", email: "address@domain"}]
*
* @param {String} str Address field
* @return {Array} An array of address objects
*/
export function addressparser(str) {
str = (str || '').toString();
let
endOperator = '',
node = {
type: 'text',
value: ''
},
escaped = false,
address = [],
addresses = [];
const
/*
* Operator tokens and which tokens are expected to end the sequence
*/
OPERATORS = {
'"': '"',
'(': ')',
'<': '>',
',': '',
// Groups are ended by semicolons
':': ';',
// Semicolons are not a legal delimiter per the RFC2822 grammar other
// than for terminating a group, but they are also not valid for any
// other use in this context. Given that some mail clients have
// historically allowed the semicolon as a delimiter equivalent to the
// comma in their UI, it makes sense to treat them the same as a comma
// when used outside of a group.
';': ''
},
pushToken = token => {
token.value = (token.value || '').toString().trim();
token.value.length && address.push(token);
node = {
type: 'text',
value: ''
},
escaped = false;
},
pushAddress = () => {
if (address.length) {
address = _handleAddress(address);
if (address.length) {
addresses = addresses.concat(address);
}
}
address = [];
};
[...str].forEach(chr => {
if (!escaped && (chr === endOperator || (!endOperator && chr in OPERATORS))) {
pushToken(node);
if (',' === chr || ';' === chr) {
pushAddress();
} else {
endOperator = endOperator ? '' : OPERATORS[chr];
if ('<' === chr) {
node.type = 'email';
} else if ('(' === chr) {
node.type = 'comment';
} else if (':' === chr) {
node.type = 'group';
}
}
} else {
node.value += chr;
escaped = !escaped && '\\' === chr;
}
});
pushToken(node);
pushAddress();
return addresses;
}
/**
* Converts tokens for a single address into an address object
*
* @param {Array} tokens Tokens object
* @return {Object} Address object
*/
function _handleAddress(tokens) {
let
isGroup = false,
address = {},
addresses = [],
data = {
email: [],
comment: [],
group: [],
text: []
};
tokens.forEach(token => {
isGroup = isGroup || 'group' === token.type;
data[token.type].push(token.value);
});
// If there is no text but a comment, replace the two
if (!data.text.length && data.comment.length) {
data.text = data.comment;
data.comment = [];
}
if (isGroup) {
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
/*
addresses.push({
email: '',
name: data.text.join(' ').trim(),
group: addressparser(data.group.join(','))
// ,comment: data.comment.join(' ').trim()
});
*/
addresses = addresses.concat(addressparser(data.group.join(',')));
} else {
// If no address was found, try to detect one from regular text
if (!data.email.length && data.text.length) {
var i = data.text.length;
while (i--) {
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
data.email = data.text.splice(i, 1);
break;
}
}
// still no address
if (!data.email.length) {
i = data.text.length;
while (i--) {
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, address => {
if (!data.email.length) {
data.email = [address.trim()];
return '';
}
return address.trim();
});
if (data.email.length) {
break;
}
}
}
}
// If there's still no text but a comment exists, replace the two
if (!data.text.length && data.comment.length) {
data.text = data.comment;
data.comment = [];
}
// Keep only the first address occurence, push others to regular text
if (data.email.length > 1) {
data.text = data.text.concat(data.email.splice(1));
}
address = {
// Join values with spaces
email: decodeEncodedWords(data.email.join(' ').trim()),
name: decodeEncodedWords(data.text.join(' ').trim())
// ,comment: data.comment.join(' ').trim()
};
if (address.email === address.name) {
if (address.email.includes('@')) {
address.name = '';
} else {
address.email = '';
}
}
// address.email = address.email.replace(/^[<]+(.*)[>]+$/g, '$1');
addresses.push(address);
}
return addresses;
}

35
dev/Mime/Encoding.js Normal file
View file

@ -0,0 +1,35 @@
const
QPDecodeParams = [/=([0-9A-F]{2})/g, (...args) => String.fromCharCode(parseInt(args[1], 16))];
export const
// https://datatracker.ietf.org/doc/html/rfc2045#section-6.8
BDecode = atob,
// unescape(encodeURIComponent()) makes the UTF-16 DOMString to an UTF-8 string
BEncode = data => btoa(unescape(encodeURIComponent(data))),
/* // Without deprecated 'unescape':
BEncode = data => btoa(encodeURIComponent(data).replace(
/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode('0x' + p1)
)),
*/
// https://datatracker.ietf.org/doc/html/rfc2045#section-6.7
QPDecode = data => data.replace(/=\r?\n/g, '').replace(...QPDecodeParams),
// https://datatracker.ietf.org/doc/html/rfc2047#section-4.1
// https://datatracker.ietf.org/doc/html/rfc2047#section-4.2
// encoded-word = "=?" charset "?" encoding "?" encoded-text "?="
decodeEncodedWords = data =>
data.replace(/=\?([^?]+)\?(B|Q)\?(.+?)\?=/g, (m, charset, encoding, text) =>
decodeText(charset, 'B' == encoding ? BDecode(text) : QPDecode(text))
)
,
decodeText = (charset, data) => {
try {
// https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings
return new TextDecoder(charset).decode(Uint8Array.from(data, c => c.charCodeAt(0)));
} catch (e) {
console.error({charset:charset,error:e});
}
};

View file

@ -1,17 +1,5 @@
//import { b64Encode } from 'Common/Utils';
const
// RFC2045
QPDecodeParams = [/=([0-9A-F]{2})/g, (...args) => String.fromCharCode(parseInt(args[1], 16))],
QPDecode = data => data.replace(/=\r?\n/g, '').replace(...QPDecodeParams),
decodeText = (charset, data) => {
try {
// https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings
return new TextDecoder(charset).decode(Uint8Array.from(data, c => c.charCodeAt(0)));
} catch (e) {
console.error({charset:charset,error:e});
}
};
import { decodeEncodedWords, BDecode, BEncode, QPDecode, decodeText } from 'Mime/Encoding';
import { addressparser } from 'Mime/Address';
export function ParseMime(text)
{
@ -27,7 +15,52 @@ export function ParseMime(text)
this.bodyEnd = 0;
this.boundary = '';
this.bodyText = '';
this.headers = {};
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6
// https://datatracker.ietf.org/doc/html/rfc4021
this.headers = {
// Required
date = null,
from = [], // mailbox-list
// Optional
sender = [], // mailbox MUST occur with multi-address
'reply-to' = [], // address-list
to = [], // address-list
cc = [], // address-list
bcc = [], // address-list
'message-id' = '', // msg-id SHOULD be present
'in-reply-to' = '', // 1*msg-id SHOULD occur in some replies
references = '', // 1*msg-id SHOULD occur in some replies
subject = '', // unstructured
// Optional unlimited
comments = [], // unstructured
keywords = [], // phrase *("," phrase)
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6.6
'resent-date' = [],
'resent-from' = [],
'resent-sender' = [],
'resent-to' = [],
'resent-cc' = [],
'resent-bcc' = [],
'resent-msg-id' = [],
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6.7
trace = [],
'return-path' = '', // angle-addr
received = [],
// optional others outside RFC2822
'mime-version' = '', // RFC2045
'content-transfer-encoding' = '',
'content-type' = '',
'delivered-to' = [], // RFC9228 addr-spec
'authentication-results' = '', // dkim, spf, dmarc
'dkim-signature' = '',
'x-rspamd-queue-id' = '',
'x-rspamd-action' = '',
'x-spamd-bar' = '',
'x-rspamd-server' = '',
'x-spamd-result' = '',
'x-remote-address' = '',
// etc.
};
}
*/
@ -50,26 +83,25 @@ export function ParseMime(text)
get body() {
let body = this.bodyRaw,
charset = this.header('content-type')?.params.charset,
encoding = this.headerValue('content-transfer-encoding');
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
if ('quoted-printable' == encoding) {
body = QPDecode(body);
} else if ('base64' == encoding) {
body = atob(body.replace(/\r?\n/g, ''));
body = BDecode(body.replace(/\r?\n/g, ''));
}
return decodeText(charset, body);
}
get dataUrl() {
let body = this.bodyRaw,
encoding = this.headerValue('content-transfer-encoding');
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
if ('base64' == encoding) {
body = body.replace(/\r?\n/g, '');
} else {
if ('quoted-printable' == encoding) {
body = QPDecode(body);
}
body = btoa(body);
// body = b64Encode(body);
body = BEncode(body);
}
return 'data:' + this.headerValue('content-type') + ';base64,' + body;
}
@ -80,7 +112,7 @@ export function ParseMime(text)
}
getByContentType(type) {
if (type == this.headerValue('content-type')) {
if (type == this.headerValue('content-type')?.toLowerCase()) {
return this;
}
let i = 0, p = this.parts, part;
@ -92,6 +124,9 @@ export function ParseMime(text)
}
}
// mailbox-list or address-list
const lists = ['from','reply-to','to','cc','bcc'];
const ParsePart = (mimePart, start_pos = 0, id = '') =>
{
let part = new MimePart,
@ -113,11 +148,19 @@ export function ParseMime(text)
[...header.matchAll(/;\s*([^;=]+)=\s*"?([^;"]+)"?/g)].forEach(param =>
params[param[1].trim().toLowerCase()] = param[2].trim()
);
// encoded-word = "=?" charset "?" encoding "?" encoded-text "?="
match[2] = match[2].trim().replace(/=\?([^?]+)\?(B|Q)\?(.+?)\?=/g, (m, charset, encoding, text) =>
decodeText(charset, 'B' == encoding ? atob(text) : QPDecode(text))
);
headers[match[1].trim().toLowerCase()] = {
let field = match[1].trim().toLowerCase();
if (lists.includes(field)) {
match[2] = addressparser(match[2]);
} else if ('keywords' === field) {
match[2] = match[2].split(',').forEach(entry => decodeEncodedWords(entry.trim()));
match[2] = (headers[field]?.value || []).concat(match[2]);
} else {
match[2] = decodeEncodedWords(match[2].trim());
if ('comments' === field) {
match[2] = (headers[field]?.value || []).push(match[2]);
}
}
headers[field] = {
value: match[2],
params: params
};
@ -132,7 +175,7 @@ export function ParseMime(text)
let boundary = headers['content-type']?.params.boundary;
if (boundary) {
part.boundary = boundary;
let regex = new RegExp('(?:^|\r?\n)--' + boundary + '(?:--)?(?:\r?\n|$)', 'g'),
let regex = new RegExp('(?:^|\r?\n)--' + RegExp.escape(boundary) + '(?:--)?(?:\r?\n|$)', 'g'),
body = mimePart.slice(head.length),
bodies = body.split(regex),
pos = part.bodyStart;

View file

@ -4,23 +4,34 @@ import { AttachmentModel } from 'Model/Attachment';
import { FileInfo } from 'Common/File';
import { BEGIN_PGP_MESSAGE } from 'Stores/User/Pgp';
import { EmailModel } from 'Model/Email';
/**
* @param string data
* @param MessageModel message
*/
export function MimeToMessage(data, message)
{
let signed;
const struct = ParseMime(data);
if (struct.headers) {
let html = struct.getByContentType('text/html'),
subject = struct.headerValue('subject');
html = html ? html.body : '';
// Content-Type: ...; protected-headers="v1"
subject && message.subject(subject);
// EmailCollectionModel
['from','to'].forEach(name => message[name].fromString(struct.headerValue(name)));
['from','to'].forEach(name => {
const items = message[name];
struct.headerValue(name)?.forEach(item => {
item = new EmailModel(item.email, item.name);
// Make them unique
if (item.email && item.name || !items.find(address => address.email == item.email)) {
items.push(item);
}
});
});
struct.forEach(part => {
let cd = part.header('content-disposition'),
@ -54,12 +65,28 @@ export function MimeToMessage(data, message)
} else {
message.attachments.push(attachment);
}
} else if ('multipart/signed' === type.value && 'application/pgp-signature' === type.params.protocol) {
signed = {
} else if ('multipart/signed' === type.value) {
let protocol = type.params.protocol;
if ('application/pgp-signature' === protocol) {
message.pgpSigned({
micAlg: type.micalg,
bodyPart: part.parts[0],
sigPart: part.parts[1]
});
} else if ('application/pkcs7-signature' === protocol.replace('x-')) {
message.smimeSigned({
micAlg: type.micalg,
bodyPart: part,
sigPart: part.parts[1], // For importing
detached: true
});
}
} else if ('application/pkcs7-mime' === type.value /*&& 'signed-data' === type.params['smime-type']=*/) {
message.smimeSigned({
micAlg: type.micalg,
bodyPart: part.parts[0],
sigPart: part.parts[1]
};
bodyPart: part,
detached: false
});
}
});
@ -70,10 +97,7 @@ export function MimeToMessage(data, message)
message.plain(data);
}
if (!signed && message.plain().includes(BEGIN_PGP_MESSAGE)) {
signed = true;
if (message.plain().includes(BEGIN_PGP_MESSAGE)) {
message.pgpSigned(true);
}
message.pgpSigned(signed);
// TODO: Verify instantly?
}

View file

@ -5,7 +5,7 @@ export class AbstractCollectionModel extends Array
constructor() {
/*
if (new.target === AbstractCollectionModel) {
throw new Error("Can't instantiate AbstractCollectionModel!");
throw Error("Can't instantiate AbstractCollectionModel!");
}
*/
super();

View file

@ -28,6 +28,10 @@ export class AccountModel extends AbstractModel {
&& setTimeout(()=>this.fetchUnread(), (Math.ceil(Math.random() * 10)) * 3000);
}
label() {
return this.name || IDN.toUnicode(this.email);
}
/**
* Get INBOX unread messages
*/

View file

@ -126,7 +126,10 @@ export class AttachmentModel extends AbstractModel {
}
get download() {
return b64EncodeJSONSafe({
return b64EncodeJSONSafe(this.url ? {
fileName: this.fileName,
data: this.url.replace(/^.+,/, '')
} : {
folder: this.folder,
uid: this.uid,
mimeIndex: this.mimeIndex,

View file

@ -1,3 +1,4 @@
import { baseCollator } from 'Common/Translator';
import { AbstractCollectionModel } from 'Model/AbstractCollection';
import { AttachmentModel } from 'Model/Attachment';
@ -11,7 +12,7 @@ export class AttachmentCollectionModel extends AbstractCollectionModel
*/
static reviveFromJson(items) {
const attachments = super.reviveFromJson(items, attachment => AttachmentModel.reviveFromJson(attachment));
let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
let collator = baseCollator(true);
attachments.sort((a, b) => {
if (a.isInline()) {
if (!b.isInline()) {

View file

@ -194,13 +194,6 @@ export class ContactModel extends AbstractModel {
return contact;
}
/**
* @returns {string}
*/
generateUid() {
return '' + this.id;
}
addEmail() {
// home, work
this.email.push({

View file

@ -4,202 +4,6 @@ import { AbstractModel } from 'Knoin/AbstractModel';
'use strict';
/**
* Parses structured e-mail addresses from an address field
*
* Example:
*
* "Name <address@domain>"
*
* will be converted to
*
* [{name: "Name", address: "address@domain"}]
*
* @param {String} str Address field
* @return {Array} An array of address objects
*/
export function addressparser(str) {
str = (str || '').toString();
let
endOperator = '',
node = {
type: 'text',
value: ''
},
escaped = false,
address = [],
addresses = [];
const
/*
* Operator tokens and which tokens are expected to end the sequence
*/
OPERATORS = {
'"': '"',
'(': ')',
'<': '>',
',': '',
// Groups are ended by semicolons
':': ';',
// Semicolons are not a legal delimiter per the RFC2822 grammar other
// than for terminating a group, but they are also not valid for any
// other use in this context. Given that some mail clients have
// historically allowed the semicolon as a delimiter equivalent to the
// comma in their UI, it makes sense to treat them the same as a comma
// when used outside of a group.
';': ''
},
pushToken = token => {
token.value = (token.value || '').toString().trim();
token.value.length && address.push(token);
node = {
type: 'text',
value: ''
},
escaped = false;
},
pushAddress = () => {
if (address.length) {
address = _handleAddress(address);
if (address.length) {
addresses = addresses.concat(address);
}
}
address = [];
};
[...str].forEach(chr => {
if (!escaped && (chr === endOperator || (!endOperator && chr in OPERATORS))) {
pushToken(node);
if (',' === chr || ';' === chr) {
pushAddress();
} else {
endOperator = endOperator ? '' : OPERATORS[chr];
if ('<' === chr) {
node.type = 'email';
} else if ('(' === chr) {
node.type = 'comment';
} else if (':' === chr) {
node.type = 'group';
}
}
} else {
node.value += chr;
escaped = !escaped && '\\' === chr;
}
});
pushToken(node);
pushAddress();
return addresses;
// return addresses.map(item => (item.name || item.email) ? new EmailModel(item.email, item.name) : null).filter(v => v);
}
/**
* Converts tokens for a single address into an address object
*
* @param {Array} tokens Tokens object
* @return {Object} Address object
*/
function _handleAddress(tokens) {
let
isGroup = false,
address = {},
addresses = [],
data = {
email: [],
comment: [],
group: [],
text: []
};
tokens.forEach(token => {
isGroup = isGroup || 'group' === token.type;
data[token.type].push(token.value);
});
// If there is no text but a comment, replace the two
if (!data.text.length && data.comment.length) {
data.text = data.comment;
data.comment = [];
}
if (isGroup) {
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
/*
addresses.push({
email: '',
name: data.text.join(' ').trim(),
group: addressparser(data.group.join(','))
// ,comment: data.comment.join(' ').trim()
});
*/
addresses = addresses.concat(addressparser(data.group.join(',')));
} else {
// If no address was found, try to detect one from regular text
if (!data.email.length && data.text.length) {
var i = data.text.length;
while (i--) {
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
data.email = data.text.splice(i, 1);
break;
}
}
// still no address
if (!data.email.length) {
i = data.text.length;
while (i--) {
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, address => {
if (!data.email.length) {
data.email = [address.trim()];
return '';
}
return address.trim();
});
if (data.email.length) {
break;
}
}
}
}
// If there's still no text but a comment exists, replace the two
if (!data.text.length && data.comment.length) {
data.text = data.comment;
data.comment = [];
}
// Keep only the first address occurence, push others to regular text
if (data.email.length > 1) {
data.text = data.text.concat(data.email.splice(1));
}
address = {
// Join values with spaces
email: data.email.join(' ').trim(),
name: data.text.join(' ').trim()
// ,comment: data.comment.join(' ').trim()
};
if (address.email === address.name) {
if (address.email.includes('@')) {
address.name = '';
} else {
address.email = '';
}
}
// address.email = address.email.replace(/^[<]+(.*)[>]+$/g, '$1');
addresses.push(address);
}
return addresses;
}
export class EmailModel extends AbstractModel {
/**
* @param {string=} email = ''

View file

@ -1,6 +1,7 @@
import { AbstractCollectionModel } from 'Model/AbstractCollection';
import { EmailModel, addressparser } from 'Model/Email';
import { EmailModel } from 'Model/Email';
import { forEachObjectValue } from 'Common/Utils';
import { addressparser } from 'Mime/Address';
'use strict';
@ -51,4 +52,24 @@ export class EmailCollectionModel extends AbstractCollectionModel
}
}
/**
* @param {array} [{name: "Name", email: "address@domain"}]
*/
/*
static fromArray(addresses) {
let list = new this();
list.fromArray(addresses);
return list;
}
fromArray(addresses) {
addresses.forEach(item => {
item = new EmailModel(item.email, item.name);
// Make them unique
if (item.email && item.name || !this.find(address => address.email == item.email)) {
this.push(item);
}
});
}
*/
}

View file

@ -3,9 +3,8 @@ import { AbstractCollectionModel } from 'Model/AbstractCollection';
import { UNUSED_OPTION_VALUE } from 'Common/Consts';
import { isArray, getKeyByValue, forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
import { ClientSideKeyNameExpandedFolders, FolderType, FolderMetadataKeys } from 'Common/EnumsUser';
import { clearCache, getFolderFromCacheList, setFolder, setFolderInboxName, removeFolderFromCacheList } from 'Common/Cache';
import { clearCache, getFolderFromCacheList, setFolder, setFolderInboxName } from 'Common/Cache';
import { Settings, SettingsGet, fireEvent } from 'Common/Globals';
import { Notifications } from 'Common/Enums';
import * as Local from 'Storage/Client';
@ -15,18 +14,23 @@ import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { SettingsUserStore } from 'Stores/User/Settings';
import { sortFolders } from 'Common/Folders';
import { i18n, translateTrigger, getNotification } from 'Common/Translator';
import { i18n, translateTrigger } from 'Common/Translator';
import { AbstractModel } from 'Knoin/AbstractModel';
import { /*koComputable,*/ addObservablesTo } from 'External/ko';
//import { mailBox } from 'Common/Links';
import { mailBox } from 'Common/Links';
import Remote from 'Remote/User/Fetch';
import { FileInfo } from 'Common/File';
import { FolderPopupView } from 'View/Popup/Folder';
import { showScreenPopup } from 'Knoin/Knoin';
import { isAllowedKeyword } from 'Stores/User/Folder';
const
// isPosNumeric = value => null != value && /^[0-9]*$/.test(value.toString()),
@ -113,6 +117,8 @@ export class FolderCollectionModel extends AbstractCollectionModel
this.namespace;
this.optimized
this.capabilities
this.allow; // allow adding
// this.exist;
}
*/
@ -254,6 +260,10 @@ export class FolderCollectionModel extends AbstractCollectionModel
return result;
}
visible() {
return this.filter(folder => folder.visible());
}
storeIt() {
FolderUserStore.displaySpecSetting(Settings.app('folderSpecLimit') < this.CountRec);
@ -296,6 +306,7 @@ export class FolderModel extends AbstractModel {
super();
this.fullName = '';
this.parentName = '';
this.delimiter = '';
this.deep = 0;
this.expires = 0;
@ -316,12 +327,10 @@ export class FolderModel extends AbstractModel {
focused: false,
selected: false,
editing: false,
isSubscribed: true,
checkable: false, // Check for new messages
askDelete: false,
nameForEdit: '',
errorMsg: '',
totalEmails: 0,
@ -341,7 +350,6 @@ export class FolderModel extends AbstractModel {
this.addSubscribables({
kolabType: sValue => this.metadata[FolderMetadataKeys.KolabFolderType] = sValue,
permanentFlags: aValue => this.tagsAllowed(aValue.includes('\\*')),
editing: value => value && this.nameForEdit(this.name()),
unreadEmails: unread => FolderType.Inbox === this.type() && fireEvent('mailbox.inbox-unread-count', unread)
});
@ -363,20 +371,19 @@ export class FolderModel extends AbstractModel {
.extend({ notify: 'always' });
*/
/*
https://www.rfc-editor.org/rfc/rfc8621.html#section-2
"myRights": {
"mayAddItems": true,
"mayRename": false,
"maySubmit": true,
"mayDelete": false,
"maySetKeywords": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"maySetSeen": true,
"mayReadItems": true
},
// https://www.rfc-editor.org/rfc/rfc8621.html#section-2
this.myRights = {
'mayAddItems': true,
'mayCreateChild': true,
'mayDelete': true,
'mayReadItems': true,
'mayRemoveItems': true,
'mayRename': true,
'maySetKeywords': true,
'maySetSeen': true,
'maySubmit': true
};
*/
this.addComputables({
isInbox: () => FolderType.Inbox === this.type(),
@ -387,6 +394,7 @@ export class FolderModel extends AbstractModel {
// isSubscribed: () => this.attributes().includes('\\subscribed'),
hasVisibleSubfolders: () => !!this.subFolders().find(folder => folder.visible()),
visibleSubfolders: () => this.subFolders().visible(),
hasSubscriptions: () => this.isSubscribed() | !!this.subFolders().find(
oFolder => {
@ -395,8 +403,6 @@ export class FolderModel extends AbstractModel {
}
),
canBeEdited: () => !this.type() && this.exists/* && this.selectable()*/,
isSystemFolder: () => this.type()
| (FolderUserStore.allowKolab() && !!this.kolabType() & !SettingsUserStore.unhideKolabFolders()),
@ -407,6 +413,8 @@ export class FolderModel extends AbstractModel {
canBeSubscribed: () => this.selectable()
&& !(this.isSystemFolder() | !SettingsUserStore.hideUnsubscribed()),
optionalTags: () => this.permanentFlags.filter(isAllowedKeyword),
/**
* Folder is visible when:
* - hasVisibleSubfolders()
@ -463,60 +471,30 @@ export class FolderModel extends AbstractModel {
detailedName: () => this.name() + ' ' + this.nameInfo(),
hasSubscribedUnreadMessagesSubfolders: () =>
!!this.subFolders().find(
folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
)
/*
!!this.subFolders().filter(
folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
).length
*/
// ,href: () => this.canBeSelected() && mailBox(this.fullNameHash)
icon: () => {
switch (this.type())
{
case 1: return '📥'; // FolderType.Inbox
case 2: return '📧'; // FolderType.Sent icon-paper-plane
case 3: return '🗎'; // FolderType.Drafts
case 4: return '⚠'; // FolderType.Junk
case 5: return '🗑'; // FolderType.Trash
case 6: return '🗄'; // FolderType.Archive
}
return null;
},
hasUnreadInSub: () =>
this.subFolders().some(
folder => folder.unreadEmails() | folder.hasUnreadInSub()
),
href: () => this.canBeSelected() && mailBox(this.fullNameHash)
});
}
edit() {
this.canBeEdited() && this.editing(true);
}
unedit() {
this.editing(false);
}
rename() {
const folder = this,
nameToEdit = folder.nameForEdit().trim();
if (nameToEdit && folder.name() !== nameToEdit) {
Remote.abort('Folders').post('FolderRename', FolderUserStore.foldersRenaming, {
folder: folder.fullName,
newFolderName: nameToEdit,
subscribe: folder.isSubscribed() ? 1 : 0
})
.then(data => {
folder.name(nameToEdit/*data.name*/);
if (folder.subFolders.length) {
Remote.setTrigger(FolderUserStore.foldersLoading, true);
// clearTimeout(Remote.foldersTimeout);
// Remote.foldersTimeout = setTimeout(loadFolders, 500);
setTimeout(loadFolders, 500);
// TODO: rename all subfolders with folder.delimiter to prevent reload?
} else {
removeFolderFromCacheList(folder.fullName);
folder.fullName = data.Result.fullName;
setFolder(folder);
const parent = getFolderFromCacheList(folder.parentName);
sortFolders(parent ? parent.subFolders : FolderUserStore.folderList);
}
})
.catch(error => {
FolderUserStore.error(
getNotification(error.code, '', Notifications.CantRenameFolder)
+ '.\n' + error.message);
});
}
folder.editing(false);
showScreenPopup(FolderPopupView, [this]);
}
/**

View file

@ -1,5 +1,5 @@
import { AbstractModel } from 'Knoin/AbstractModel';
import { addObservablesTo } from 'External/ko';
import { addObservablesTo, addComputablesTo } from 'External/ko';
export class IdentityModel extends AbstractModel {
/**
@ -11,17 +11,31 @@ export class IdentityModel extends AbstractModel {
addObservablesTo(this, {
id: '',
label: '',
email: '',
name: '',
replyTo: '',
bcc: '',
sentFolder: '',
signature: '',
signatureInsertBefore: false,
pgpSign: false,
pgpEncrypt: false,
smimeKey: '',
smimeCertificate: '',
askDelete: false
});
addComputablesTo(this, {
smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----'),
smimeKeyValid: () => /^-----BEGIN (ENCRYPTED |RSA )?PRIVATE KEY-----/.test(this.smimeKey()),
smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate())
});
}
/**
@ -29,8 +43,8 @@ export class IdentityModel extends AbstractModel {
*/
formattedName() {
const name = this.name(),
email = this.email();
return name ? name + ' <' + email + '>' : email;
email = this.email(),
label = this.label();
return (name ? `${name} ` : '') + `<${email}>` + (label ? ` (${label})` : '');
}
}

View file

@ -8,12 +8,14 @@ import { forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
import { serverRequestRaw, proxy } from 'Common/Links';
import { addObservablesTo, addComputablesTo } from 'External/ko';
import { FolderUserStore, isAllowedKeyword } from 'Stores/User/Folder';
import { FolderUserStore } from 'Stores/User/Folder';
import { SettingsUserStore } from 'Stores/User/Settings';
import { FileInfo, RFC822 } from 'Common/File';
import { AttachmentCollectionModel } from 'Model/AttachmentCollection';
import { EmailCollectionModel } from 'Model/EmailCollection';
import { MimeHeaderCollectionModel } from 'Model/MimeHeaderCollection';
//import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
import { AbstractModel } from 'Knoin/AbstractModel';
import PreviewHTML from 'Html/PreviewMessage.html';
@ -22,6 +24,8 @@ import { LanguageStore } from 'Stores/Language';
import Remote from 'Remote/User/Fetch';
import { MimeToMessage } from 'Mime/Utils';
const
msgHtml = msg => cleanHtml(msg.html(), msg.attachments(), '#rl-msg-' + msg.hash),
@ -55,42 +59,50 @@ export class MessageModel extends AbstractModel {
constructor() {
super();
this.folder = '';
this.uid = 0;
this.hash = '';
this.from = new EmailCollectionModel;
this.to = new EmailCollectionModel;
this.cc = new EmailCollectionModel;
this.bcc = new EmailCollectionModel;
this.sender = new EmailCollectionModel;
this.replyTo = new EmailCollectionModel;
this.deliveredTo = new EmailCollectionModel;
this.body = null;
this.draftInfo = [];
this.dkim = [];
this.spf = [];
this.dmarc = [];
this.messageId = '';
this.inReplyTo = '';
this.references = '';
this.autocrypt = {};
Object.assign(this, {
folder: '',
uid: 0,
hash: '',
from: new EmailCollectionModel,
to: new EmailCollectionModel,
cc: new EmailCollectionModel,
bcc: new EmailCollectionModel,
sender: new EmailCollectionModel,
replyTo: new EmailCollectionModel,
deliveredTo: new EmailCollectionModel,
body: null,
draftInfo: [],
dkim: [],
spf: [],
dmarc: [],
messageId: '',
inReplyTo: '',
references: '',
// autocrypt: ko.observableArray(),
hasVirus: null, // or boolean when scanned
priority: 3, // Normal
senderEmailsString: '',
senderClearEmailsString: '',
isSpam: false,
spamScore: 0,
spamResult: '',
size: 0,
readReceipt: '',
preview: null,
attachments: ko.observableArray(new AttachmentCollectionModel),
threads: ko.observableArray(),
threadUnseen: ko.observableArray(),
unsubsribeLinks: ko.observableArray(),
flags: ko.observableArray(),
headers: ko.observableArray(new MimeHeaderCollectionModel)
});
addObservablesTo(this, {
subject: '',
plain: '',
html: '',
preview: null,
size: 0,
spamScore: 0,
spamResult: '',
isSpam: false,
hasVirus: null, // or boolean when scanned
dateTimestamp: 0,
internalTimestamp: 0,
priority: 3, // Normal
senderEmailsString: '',
senderClearEmailsString: '',
deleted: false,
@ -102,26 +114,29 @@ export class MessageModel extends AbstractModel {
isHtml: false,
hasImages: false,
hasExternals: false,
pgpSigned: null,
pgpVerified: null,
hasTracking: false,
encrypted: false,
pgpSigned: null,
pgpEncrypted: null,
pgpDecrypted: false,
readReceipt: '',
smimeSigned: null,
smimeEncrypted: null,
smimeDecrypted: false,
// rfc8621
id: '',
// threadId: ''
});
this.attachments = ko.observableArray(new AttachmentCollectionModel);
this.threads = ko.observableArray();
this.threadUnseen = ko.observableArray();
this.unsubsribeLinks = ko.observableArray();
this.flags = ko.observableArray();
/**
* Basic support for Linked Data (Structured Email)
* https://json-ld.org/
* https://structured.email/
**/
linkedData: []
});
addComputablesTo(this, {
attachmentIconClass: () =>
@ -141,17 +156,15 @@ export class MessageModel extends AbstractModel {
tagOptions: () => {
const tagOptions = [];
FolderUserStore.currentFolder().permanentFlags.forEach(value => {
if (isAllowedKeyword(value)) {
let lower = value.toLowerCase();
tagOptions.push({
css: 'msgflag-' + lower,
value: value,
checked: this.flags().includes(lower),
label: i18n('MESSAGE_TAGS/'+lower, 0, value),
toggle: (/*obj*/) => toggleTag(this, value)
});
}
FolderUserStore.currentFolder().optionalTags().forEach(value => {
let lower = value.toLowerCase();
tagOptions.push({
css: 'msgflag-' + lower,
value: value,
checked: this.flags().includes(lower),
label: i18n('MESSAGE_TAGS/'+lower, 0, value),
toggle: (/*obj*/) => toggleTag(this, value)
});
});
return tagOptions
},
@ -180,6 +193,10 @@ export class MessageModel extends AbstractModel {
return options;
}
});
this.smimeSigned.subscribe(value =>
value?.body && MimeToMessage(value.body, this)
);
}
get requestHash() {
@ -197,23 +214,23 @@ export class MessageModel extends AbstractModel {
}
spamStatus() {
let spam = this.spamResult();
return spam ? i18n(this.isSpam() ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
let spam = this.spamResult;
return spam ? i18n(this.isSpam ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
}
/**
* @returns {string}
*/
friendlySize() {
return FileInfo.friendlySize(this.size());
return FileInfo.friendlySize(this.size);
}
computeSenderEmail() {
const list = this[
[FolderUserStore.sentFolder(), FolderUserStore.draftsFolder()].includes(this.folder) ? 'to' : 'from'
];
this.senderEmailsString(list.toString(true));
this.senderClearEmailsString(list.map(email => email?.email).filter(email => email).join(', '));
this.senderEmailsString = list.toString(true);
this.senderClearEmailsString = list.map(email => email?.email).filter(email => email).join(', ');
}
/**
@ -224,8 +241,63 @@ export class MessageModel extends AbstractModel {
if (super.revivePropertiesFromJson(json)) {
// this.foundCIDs = isArray(json.FoundCIDs) ? json.FoundCIDs : [];
// this.attachments(AttachmentCollectionModel.reviveFromJson(json.attachments, this.foundCIDs));
// this.headers(MimeHeaderCollectionModel.reviveFromJson(json.headers));
this.computeSenderEmail();
let value, headers = this.headers();
/* // These could be by Envelope or MIME
this.messageId = headers.valueByName('Message-Id');
this.subject(headers.valueByName('Subject'));
this.sender = EmailCollectionModel.fromString(headers.valueByName('Sender'));
this.from = EmailCollectionModel.fromArray(headers.valueByName('From'));
this.replyTo = EmailCollectionModel.fromArray(headers.valueByName('Reply-To'));
this.to = EmailCollectionModel.fromArray(headers.valueByName('To'));
this.cc = EmailCollectionModel.fromArray(headers.valueByName('Cc'));
this.bcc = EmailCollectionModel.fromArray(headers.valueByName('Bcc'));
this.inReplyTo = headers.valueByName('In-Reply-To');
this.deliveredTo = EmailCollectionModel.fromString(headers.valueByName('Delivered-To'));
*/
// Priority
value = headers.valueByName('X-MSMail-Priority')
|| headers.valueByName('Importance')
|| headers.valueByName('X-Priority');
if (value) {
if (/[h12]/.test(value[0])) {
this.priority = 1;
} else if (/[l45]/.test(value[0])) {
this.priority = 5;
}
}
// Unsubscribe links
if (value = headers.valueByName('List-Unsubscribe')) {
this.unsubsribeLinks(value.split(',').map(
link => link.replace(/^[ <>]+|[ <>]+$/g, '')
));
}
if (headers.valueByName('X-Virus')) {
this.hasVirus = true;
}
if (value = headers.valueByName('X-Virus-Status')) {
if (value.includes('infected')) {
this.hasVirus = true;
} else if (value.includes('clean')) {
this.hasVirus = false;
}
}
/*
if (value = headers.valueByName('X-Virus-Scanned')) {
this.virusScanned(value);
}
// https://autocrypt.org/level1.html#the-autocrypt-header
headers.valuesByName('Autocrypt').forEach(value => {
this.autocrypt.push(new MimeHeaderAutocryptModel(value));
});
*/
return true;
}
}
@ -241,7 +313,7 @@ export class MessageModel extends AbstractModel {
checked: this.checked(),
unseen: this.isUnseen(),
focused: this.focused(),
priorityHigh: this.priority() === 1,
priorityHigh: this.priority === 1,
withAttachments: !!this.attachments().length,
// hasChildrenMessage: 1 < this.threadsLen()
}, (key, value) => value && classes.push(key));
@ -307,8 +379,10 @@ export class MessageModel extends AbstractModel {
let result = msgHtml(this);
this.hasExternals(result.hasExternals);
this.hasImages(!!result.hasExternals);
this.hasTracking(!!result.tracking);
this.linkedData(result.linkedData);
body.innerHTML = result.html;
if (!this.isSpam() && FolderUserStore.spamFolder() != this.folder) {
if (!this.isSpam && FolderUserStore.spamFolder() != this.folder) {
if ('always' === SettingsUserStore.viewImages()) {
this.showExternalImages();
}
@ -343,6 +417,11 @@ export class MessageModel extends AbstractModel {
return this.viewBody(false);
}
swapColors() {
const cl = this.body?.classList;
cl && cl.toggle('swapColors');
}
/**
* @param {boolean=} print = false
*/
@ -381,13 +460,6 @@ export class MessageModel extends AbstractModel {
this.popupMessage(true);
}
/**
* @returns {string}
*/
generateUid() {
return this.folder + '/' + this.uid;
}
/**
* @returns {MessageModel}
*//*
@ -438,7 +510,7 @@ export class MessageModel extends AbstractModel {
hasImages = true;
},
attr = 'data-x-src',
src, useProxy = !!SettingsGet('useLocalProxyForExternalImages');
src, useProxy = !!SettingsGet('proxyExternalImages');
body.querySelectorAll('img[' + attr + ']').forEach(node => {
src = node.getAttribute(attr);
if (isValid(src)) {

13
dev/Model/MimeHeader.js Normal file
View file

@ -0,0 +1,13 @@
import ko from 'ko';
import { AbstractModel } from 'Knoin/AbstractModel';
export class MimeHeaderModel extends AbstractModel
{
constructor() {
super();
this.name = '';
this.value = '';
this.parameters = ko.observableArray();
}
}

View file

@ -0,0 +1,32 @@
//import { AbstractModel } from 'Knoin/AbstractModel';
export class MimeHeaderAutocryptModel/* extends AbstractModel*/
{
constructor(value) {
// super();
this.addr = '';
this.prefer_encrypt = 'nopreference', // nopreference or mutual
this.keydata = '';
if (value) {
value.split(';').forEach(entry => {
entry = entry.split('=');
const trim = str => (str || '').trim().replace(/^["']|["']+$/g, '');
this[trim(entry[0]).replace('-', '_')] = trim(entry[1]);
});
}
}
toString() {
if ('mutual' === this.prefer_encrypt) {
return `addr=${this.addr}; prefer-encrypt=mutual; keydata=${this.keydata}`;
}
return `addr=${this.addr}; keydata=${this.keydata}`;
}
pem() {
return '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n'
+ this.keydata
+ '\n-----END PGP PUBLIC KEY BLOCK-----';
}
}

View file

@ -0,0 +1,38 @@
import { AbstractCollectionModel } from 'Model/AbstractCollection';
import { MimeHeaderModel } from 'Model/MimeHeader';
'use strict';
export class MimeHeaderCollectionModel extends AbstractCollectionModel
{
/**
* @param {?Array} json
* @returns {MimeHeaderCollectionModel}
*/
static reviveFromJson(items) {
return super.reviveFromJson(items, header => MimeHeaderModel.reviveFromJson(header));
}
/**
* @param {string} name
* @returns {?MimeHeader}
*/
getByName(name)
{
name = name.toLowerCase();
return this.find(header => header.name.toLowerCase() === name);
}
valueByName(name)
{
const header = this.getByName(name);
return header ? header.value : '';
}
valuesByName(name)
{
name = name.toLowerCase();
return this.filter(header => header.name.toLowerCase() === name).map(header => header.value);
}
}

View file

@ -19,7 +19,6 @@ checkResponseError = data => {
Notifications.DomainNotAllowed,
Notifications.AccountNotAllowed,
Notifications.MailServerError,
Notifications.UnknownNotification,
Notifications.UnknownError
].includes(err)
) {
@ -132,8 +131,8 @@ export class AbstractFetchRemote
fetchJSON(sAction, getURL(sGetAdd),
sGetAdd ? null : (params || {}),
undefined === iTimeout ? 30000 : pInt(iTimeout),
data => {
pInt(iTimeout ?? 30000),
async data => {
let iError = 0;
if (data) {
/*
@ -149,6 +148,10 @@ export class AbstractFetchRemote
}
}
if (111 === iError && rl.app.ask && await rl.app.ask.cryptkey()) {
return this.request(sAction, fCallback, params, iTimeout, sGetAdd);
}
fCallback && fCallback(
iError,
data,
@ -170,13 +173,6 @@ export class AbstractFetchRemote
});
}
/**
* @param {?Function} fCallback
*/
getPublicKey(fCallback) {
this.request('GetPublicKey', fCallback);
}
setTrigger(trigger, value) {
if (trigger) {
value = !!value;
@ -193,12 +189,16 @@ export class AbstractFetchRemote
post(action, fTrigger, params, timeOut) {
this.setTrigger(fTrigger, true);
return fetchJSON(action, getURL(), params || {}, pInt(timeOut, 30000),
data => {
async data => {
abort(action, 0, 1);
if (!data) {
return Promise.reject(new FetchError(Notifications.JsonParse));
}
if (111 === data?.ErrorCode && rl.app.ask && await rl.app.ask.cryptkey()) {
return this.post(action, fTrigger, params, timeOut);
}
/*
let isCached = false, type = '';
if (data?.epoch) {

View file

@ -64,16 +64,6 @@ class RemoteUserFetch extends AbstractFetchRemote {
[key]: value
});
}
/*
folderMove(sPrevFolderFullName, sNewFolderFullName, bSubscribe) {
return this.post('FolderMove', FolderUserStore.foldersRenaming, {
folder: sPrevFolderFullName,
newFolder: sNewFolderFullName,
subscribe: bSubscribe ? 1 : 0
});
}
*/
}
export default new RemoteUserFetch();

View file

@ -117,7 +117,7 @@ export class AbstractSettingsScreen extends AbstractScreen {
rules = {
subname: /^(.*)$/,
normalize_: (rquest, vals) => {
vals.subname = null == vals.subname ? defaultRoute : pString(vals.subname);
vals.subname = pString(vals.subname ?? defaultRoute);
return [vals.subname];
}
};

View file

@ -67,6 +67,7 @@ export class MailBoxUserScreen extends AbstractScreen {
* @returns {void}
*/
onRoute(folderHash, page, search, messageUid) {
// Only works when FolderUserStore.folderList() is loaded
const folder = getFolderFromHashMap(folderHash.replace(/~([\d]+)$/, ''));
if (folder) {
FolderUserStore.currentFolder(folder);
@ -108,7 +109,7 @@ export class MailBoxUserScreen extends AbstractScreen {
*/
onBuild() {
doc.addEventListener('click', event =>
event.target.closest('#rl-right') && moveAction(false)
event.target.closest('#rl-right') && moveAction(0)
);
}

View file

@ -2,12 +2,40 @@ import ko from 'ko';
import Remote from 'Remote/Admin/Fetch';
import { forEachObjectEntry } from 'Common/Utils';
import { SettingsAdmin } from 'Common/Globals';
import { LanguageStore } from 'Stores/Language';
import { ThemeStore } from 'Stores/Theme';
export class AdminSettingsConfig /*extends AbstractViewSettings*/ {
constructor() {
this.config = ko.observableArray();
this.search = ko.observableArray();
this.saved = ko.observable(false).extend({ falseTimeout: 5000 });
this.search.subscribe(value => {
const v = value.toLowerCase(),
qsa = (node, selector, fn) => node.querySelectorAll(selector).forEach(fn),
match = node => node.textContent.toLowerCase().includes(v);
if (v.length) {
qsa(this.viewModelDom, 'tbody', tbody => {
let show = match(tbody.querySelector('th'));
if (show) {
qsa(tbody, '[hidden]', n => n.hidden = false);
} else {
qsa(tbody, 'tbody td:first-child', td => {
let hide = !match(td);
show = show || !hide;
// td.closest('tr').hidden = hide;
td.parentNode.hidden = hide;
});
}
tbody.hidden = !show;
});
} else {
qsa(this.viewModelDom, 'table [hidden]', n => n.hidden = false);
}
});
}
beforeShow() {
@ -28,13 +56,19 @@ export class AdminSettingsConfig /*extends AbstractViewSettings*/ {
items: []
};
forEachObjectEntry(items, (skey, item) => {
if ('language' === skey) {
item[2] = ('webmail' === key) ? LanguageStore.languages : SettingsAdmin('languages');
} else if ('theme' === skey) {
item[2] = ThemeStore.themes;
}
'admin_password' === skey ||
section.items.push({
key: `config[${key}][${skey}]`,
name: skey,
value: item[0],
type: getInputType(item[0], skey.includes('password')),
comment: item[1]
comment: item[1],
options: item[2]
});
});
cfg.push(section);

View file

@ -28,6 +28,8 @@ export class AdminSettingsContacts extends AbstractViewSettings {
this.addSetting('contactsMySQLSSLVerify');
this.addSetting('contactsMySQLSSLCiphers');
this.addSetting('contactsSQLiteGlobal');
addObservablesTo(this, {
testing: false,
testContactsSuccess: false,
@ -102,7 +104,8 @@ export class AdminSettingsContacts extends AbstractViewSettings {
PdoPassword: this.contactsPdoPassword(),
MySQLSSLCA: this.contactsMySQLSSLCA(),
MySQLSSLVerify: this.contactsMySQLSSLVerify(),
MySQLSSLCiphers: this.contactsMySQLSSLCiphers()
MySQLSSLCiphers: this.contactsMySQLSSLCiphers(),
SQLiteGlobal: this.contactsSQLiteGlobal()
}
);
}

View file

@ -1,13 +1,9 @@
import ko from 'ko';
import {
isArray
} from 'Common/Utils';
import { addObservablesTo, addSubscribablesTo, addComputablesTo } from 'External/ko';
import { SaveSettingStatus } from 'Common/Enums';
import { Settings, SettingsGet, SettingsCapa } from 'Common/Globals';
import { SettingsAdmin, SettingsGet, SettingsCapa } from 'Common/Globals';
import { translatorReload, convertLangName } from 'Common/Translator';
import { AbstractViewSettings } from 'Knoin/AbstractViews';
@ -24,11 +20,7 @@ export class AdminSettingsGeneral extends AbstractViewSettings {
super();
this.language = LanguageStore.language;
this.languages = LanguageStore.languages;
const aLanguagesAdmin = Settings.app('languagesAdmin');
this.languagesAdmin = ko.observableArray(isArray(aLanguagesAdmin) ? aLanguagesAdmin : []);
this.languageAdmin = ko.observable(SettingsGet('languageAdmin'));
this.languageAdmin = ko.observable(SettingsAdmin('language'));
this.theme = ThemeStore.theme;
this.themes = ThemeStore.themes;
@ -107,14 +99,18 @@ export class AdminSettingsGeneral extends AbstractViewSettings {
}
selectLanguage() {
showScreenPopup(LanguagesPopupView, [this.language, this.languages(), LanguageStore.userLanguage()]);
showScreenPopup(LanguagesPopupView, [
this.language,
LanguageStore.languages,
LanguageStore.userLanguage()
]);
}
selectLanguageAdmin() {
showScreenPopup(LanguagesPopupView, [
this.languageAdmin,
this.languagesAdmin(),
SettingsGet('languageUsers')
SettingsAdmin('languages'),
SettingsAdmin('clientLanguage')
]);
}
}

View file

@ -10,7 +10,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
constructor() {
super();
this.addSettings(['useLocalProxyForExternalImages']);
this.addSettings(['proxyExternalImages', 'autoVerifySignatures']);
this.weakPassword = rl.app.weakPassword;
@ -28,6 +28,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
viewQRCode: '',
capaGnuPG: SettingsCapa('GnuPG'),
capaOpenPGP: SettingsCapa('OpenPGP')
});
@ -65,7 +66,8 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
adminPasswordNew2: reset,
capaOpenPGP: value => Remote.saveSetting('CapaOpenPGP', value)
capaGnuPG: value => Remote.saveSetting('capaGnuPG', value),
capaOpenPGP: value => Remote.saveSetting('capaOpenPGP', value)
});
this.adminTOTP(SettingsGet('adminTOTP'));
@ -75,6 +77,16 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
});
}
generateTOTP() {
let CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
length = 16,
secret = '';
while (0 < length--) {
secret += CHARS[Math.floor(Math.random() * 32)];
}
this.adminTOTP(secret);
}
saveAdminUserCommand() {
if (!this.adminLogin().trim()) {
this.adminLoginError(true);

View file

@ -2,6 +2,7 @@ import ko from 'ko';
//import { koComputable } from 'External/ko';
import { SettingsCapa, SettingsGet } from 'Common/Globals';
import { loadAccountsAndIdentities } from 'Common/UtilsUser';
import { AccountUserStore } from 'Stores/User/Account';
import { IdentityUserStore } from 'Stores/User/Identity';
@ -64,7 +65,7 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
rl.route.root();
setTimeout(() => location.reload(), 1);
} else {
rl.app.accountsAndIdentities();
loadAccountsAndIdentities();
}
}, {
emailToDelete: accountToRemove.email
@ -88,7 +89,7 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
accountsAndIdentitiesAfterMove() {
Remote.request('AccountsAndIdentitiesSortOrder', null, {
Accounts: AccountUserStore.getEmailAddresses().filter(v => v != SettingsGet('mainEmail')),
Accounts: AccountUserStore.filter(item => item.isAdditional()).map(item => item.email),
Identities: IdentityUserStore.map(item => (item ? item.id() : ""))
});
}

View file

@ -15,6 +15,7 @@ import Remote from 'Remote/User/Fetch';
import { showScreenPopup } from 'Knoin/Knoin';
//import { FolderPopupView } from 'View/Popup/Folder';
import { FolderCreatePopupView } from 'View/Popup/FolderCreate';
import { FolderSystemPopupView } from 'View/Popup/FolderSystem';
@ -108,7 +109,7 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
}
hideError() {
this.error('');
FolderUserStore.error('');
}
toggleFolderKolabType(folder, event) {

View file

@ -5,6 +5,7 @@ import { SaveSettingStatus } from 'Common/Enums';
import { LayoutSideView, LayoutBottomView } from 'Common/EnumsUser';
import { setRefreshFoldersInterval } from 'Common/Folders';
import { Settings, SettingsGet } from 'Common/Globals';
import { WYSIWYGS } from 'Common/HtmlEditor';
import { isArray } from 'Common/Utils';
import { addSubscribablesTo, addComputablesTo } from 'External/ko';
import { i18n, translateTrigger, translatorReload, convertLangName } from 'Common/Translator';
@ -14,6 +15,7 @@ import { showScreenPopup } from 'Knoin/Knoin';
import { AppUserStore } from 'Stores/User/App';
import { LanguageStore } from 'Stores/Language';
import { FolderUserStore } from 'Stores/User/Folder';
import { SettingsUserStore } from 'Stores/User/Settings';
import { IdentityUserStore } from 'Stores/User/Identity';
import { NotificationUserStore } from 'Stores/User/Notification';
@ -40,11 +42,18 @@ export class UserSettingsGeneral extends AbstractViewSettings {
this.isDesktopNotificationAllowed = NotificationUserStore.allowed;
this.threadsAllowed = AppUserStore.threadsAllowed;
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
this.threadAlgorithms = ko.observableArray();
FolderUserStore.capabilities.forEach(capa =>
capa.startsWith('THREAD=') && this.threadAlgorithms.push(capa.slice(7))
);
this.threadAlgorithms.sort((a, b) => a.length - b.length);
this.threadAlgorithm = SettingsUserStore.threadAlgorithm;
['useThreads',
['useThreads', 'threadAlgorithm',
// These use addSetting()
'layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
'editorDefaultType', 'msgDefaultAction', 'maxBlockquotesLevel',
'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel',
// These are in addSettings()
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
'viewHTML', 'viewImages', 'viewImagesWhitelist', 'removeColors', 'allowStyles', 'allowDraftAutosave',
@ -59,6 +68,8 @@ export class UserSettingsGeneral extends AbstractViewSettings {
this.identities = IdentityUserStore;
this.wysiwygs = WYSIWYGS;
addComputablesTo(this, {
languageFullName: () => convertLangName(this.language()),
@ -80,6 +91,8 @@ export class UserSettingsGeneral extends AbstractViewSettings {
];
},
hasWysiwygs: () => 1 < WYSIWYGS().length,
msgDefaultActions: () => {
translateTrigger();
return [
@ -99,6 +112,7 @@ export class UserSettingsGeneral extends AbstractViewSettings {
});
this.addSetting('EditorDefaultType');
this.addSetting('editorWysiwyg');
this.addSetting('MsgDefaultAction');
this.addSetting('MessageReadDelay');
this.addSetting('MessagesPerPage');
@ -140,6 +154,11 @@ export class UserSettingsGeneral extends AbstractViewSettings {
Remote.saveSetting('UseThreads', value);
},
threadAlgorithm: value => {
MessagelistUserStore([]);
Remote.saveSetting('threadAlgorithm', value);
},
checkMailInterval: () => {
setRefreshFoldersInterval(SettingsUserStore.checkMailInterval());
}

View file

@ -15,7 +15,8 @@ import { showScreenPopup } from 'Knoin/Knoin';
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
import { OpenPgpGeneratePopupView } from 'View/Popup/OpenPgpGenerate';
//import Remote from 'Remote/User/Fetch';
import { SMimeUserStore } from 'Stores/User/SMime';
import { SMimeImportPopupView } from 'View/Popup/SMimeImport';
export class UserSettingsSecurity extends AbstractViewSettings {
constructor() {
@ -43,6 +44,8 @@ export class UserSettingsSecurity extends AbstractViewSettings {
this.openpgpkeysPublic = OpenPGPUserStore.publicKeys;
this.openpgpkeysPrivate = OpenPGPUserStore.privateKeys;
this.smimeCertificates = SMimeUserStore;
this.canOpenPGP = SettingsCapa('OpenPGP');
this.canGnuPG = GnuPGUserStore.isSupported();
this.canMailvelope = !!window.mailvelope;
@ -56,6 +59,14 @@ export class UserSettingsSecurity extends AbstractViewSettings {
showScreenPopup(OpenPgpGeneratePopupView);
}
importToOpenPGP() {
OpenPGPUserStore.loadBackupKeys();
}
importToSMime() {
showScreenPopup(SMimeImportPopupView);
}
onBuild() {
/**
* Create an iframe to display the Mailvelope keyring settings.

View file

@ -22,7 +22,7 @@ export class AbstractModel {
constructor() {
/*
if (new.target === AbstractModel) {
throw new Error("Can't instantiate AbstractModel!");
throw Error("Can't instantiate AbstractModel!");
}
*/
Object.defineProperty(this, 'disposables', {value: []});

View file

@ -30,8 +30,6 @@ const
sDeepPrefix = '\u00A0\u00A0\u00A0',
showUnsubscribed = true/*!SettingsUserStore.hideUnsubscribed()*/,
disabled = rl.settings.get('sieveAllowFileintoInbox') ? '' : 'INBOX',
foldersWalk = folders => {
folders.forEach(oItem => {
if (showUnsubscribed || oItem.hasSubscriptions() || !oItem.exists) {
@ -39,7 +37,7 @@ const
id: oItem.fullName,
name: sDeepPrefix.repeat(oItem.deep) + oItem.detailedName(),
system: false,
disabled: !oItem.selectable() || disabled == oItem.fullName
disabled: !oItem.selectable()
});
}

View file

@ -23,7 +23,7 @@ try {
data = data ? decodeURIComponent(data[2]) : null;
data = data ? JSON.parse(data) : {};
win[sName] = {
getItem: key => data[key] == null ? null : data[key],
getItem: key => data[key] ?? null,
setItem: (key, value) => {
data[key] = ''+value; // forces the value to a string
document.cookie = sName+'='+encodeURIComponent(JSON.stringify(data))

View file

@ -1 +1,19 @@
export const Passphrases = new Map();
import { AskPopupView } from 'View/Popup/Ask';
export const Passphrases = new WeakMap();
Passphrases.ask = async (key, sAskDesc, btnText) =>
Passphrases.has(key)
? {password:Passphrases.handle(key)/*, remember:false*/}
: await AskPopupView.password(sAskDesc, btnText, 5);
const timeouts = {};
// get/set accessor to control deletion after 15 minutes of inactivity
Passphrases.handle = (key, pass) => {
if (!timeouts[key]) {
timeouts[key] = (()=>Passphrases.delete(key)).debounce(900000);
}
pass && Passphrases.set(key, pass);
timeouts[key]();
return Passphrases.get(key);
};

View file

@ -13,6 +13,7 @@ DomainAdminStore.fetch = () => {
if (!iError) {
DomainAdminStore(
data.Result.map(item => {
item.name = IDN.toUnicode(item.name);
item.disabled = ko.observable(item.disabled);
item.askDelete = ko.observable(false);
return item;

View file

@ -12,7 +12,7 @@ export const LanguageStore = {
const aLanguages = Settings.app('languages');
this.languages(isArray(aLanguages) ? aLanguages : []);
this.language(SettingsGet('language'));
this.userLanguage(SettingsGet('userLanguage'));
this.userLanguage(SettingsGet('clientLanguage'));
this.hourCycle(SettingsGet('hourCycle'));
}
}

View file

@ -2,8 +2,6 @@ import { addObservablesTo, koArrayWithDestroy } from 'External/ko';
export const AccountUserStore = koArrayWithDestroy();
AccountUserStore.getEmailAddresses = () => AccountUserStore.map(item => item.email);
addObservablesTo(AccountUserStore, {
email: '',
loading: false

View file

@ -9,25 +9,17 @@ import Remote from 'Remote/User/Fetch';
import { showScreenPopup } from 'Knoin/Knoin';
import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey';
import { AskPopupView } from 'View/Popup/Ask';
import { Passphrases } from 'Storage/Passphrases';
const
askPassphrase = async (privateKey, btnTxt = 'LABEL_SIGN') => {
const key = privateKey.id,
pass = Passphrases.has(key)
? {password:Passphrases.get(key), remember:false}
: await AskPopupView.password('GnuPG key<br>' + key + ' ' + privateKey.emails[0], 'OPENPGP/'+btnTxt);
pass && pass.remember && Passphrases.set(key, pass.password);
return pass.password;
},
import { baseCollator } from 'Common/Translator';
const
findGnuPGKey = (keys, query/*, sign*/) =>
keys.find(key =>
// key[sign ? 'can_sign' : 'can_decrypt']
(key.can_sign || key.can_decrypt)
&& (key.emails.includes(query) || key.subkeys.find(key => query == key.keyid || query == key.fingerprint))
&& (key.for(query) || key.subkeys.find(key => query == key.keyid || query == key.fingerprint))
);
export const GnuPGUserStore = new class {
@ -45,7 +37,8 @@ export const GnuPGUserStore = new class {
this.keyring = null;
this.publicKeys([]);
this.privateKeys([]);
Remote.request('GnupgGetKeys',
SettingsCapa('GnuPG')
&& Remote.request('GnupgGetKeys',
(iError, oData) => {
if (oData?.Result) {
this.keyring = oData.Result;
@ -55,6 +48,7 @@ export const GnuPGUserStore = new class {
key.fingerprint = key.subkeys[0].fingerprint;
key.uids.forEach(uid => uid.email && aEmails.push(uid.email));
key.emails = aEmails;
key.for = email => aEmails.includes(IDN.toASCII(email));
key.askDelete = ko.observable(false);
key.openForDeletion = ko.observable(null).askDeleteHelper();
key.remove = () => {
@ -77,33 +71,49 @@ export const GnuPGUserStore = new class {
);
}
};
key.view = () => {
const fetch = pass => Remote.request('GnupgExportKey',
(iError, oData) => {
if (oData?.Result) {
key.armor = oData.Result;
showScreenPopup(OpenPgpKeyPopupView, [key]);
} else {
Passphrases.delete(key.id);
}
}, {
keyId: key.id,
isPrivate: isPrivate,
passphrase: pass
}
if (isPrivate) {
key.password = async btnTxt => {
const pass = await Passphrases.ask(key,
'GnuPG key<br>' + key.id + ' ' + key.emails[0],
btnTxt
);
if (isPrivate) {
askPassphrase(key, 'POPUP_VIEW_TITLE').then(passphrase => {
(null !== passphrase) && fetch(passphrase);
});
pass && pass.remember && Passphrases.handle(key, pass.password);
return pass?.password;
};
}
key.fetch = async callback => {
if (key.armor) {
callback && callback();
} else {
fetch('');
let pass = isPrivate ? await key.password('OPENPGP/POPUP_VIEW_TITLE') : '';
if (null != pass) try {
const result = await Remote.post('GnupgExportKey', null, {
keyId: key.id,
isPrivate: isPrivate,
passphrase: pass
});
if (result?.Result) {
key.armor = result.Result;
callback && callback();
} else {
Passphrases.delete(key);
}
} catch (e) {
Passphrases.delete(key);
alert(e.message);
}
}
return key.armor;
};
key.view = () => key.fetch(() => showScreenPopup(OpenPgpKeyPopupView, [key]));
return key;
};
this.publicKeys(oData.Result.public.map(key => initKey(key, 0)));
this.privateKeys(oData.Result.private.map(key => initKey(key, 1)));
},
collator = baseCollator(),
sort = keys => keys.sort(
(a, b) => collator.compare(a.emails[0], b.emails[0]) || collator.compare(a.id, b.id)
);
this.publicKeys(sort(oData.Result.public.map(key => initKey(key, 0))));
this.privateKeys(sort(oData.Result.private.map(key => initKey(key, 1))));
console.log('gnupg ready');
}
}
@ -117,19 +127,6 @@ export const GnuPGUserStore = new class {
return SettingsCapa('GnuPG');
}
importKey(key, callback) {
Remote.request('GnupgImportKey',
(iError, oData) => {
if (oData?.Result/* && (oData.Result.imported || oData.Result.secretimported)*/) {
this.loadKeyrings();
}
callback?.(iError, oData);
}, {
key: key
}
);
}
/**
keyPair.privateKey
keyPair.publicKey
@ -155,7 +152,7 @@ export const GnuPGUserStore = new class {
const count = recipients.length,
length = count ? recipients.filter(email =>
// (key.can_verify || key.can_encrypt) &&
this.publicKeys.find(key => key.emails.includes(email))
this.publicKeys.find(key => key.for(email))
).length : 0;
return length && length === count;
}
@ -163,13 +160,13 @@ export const GnuPGUserStore = new class {
getPublicKeyFingerprints(recipients) {
const fingerprints = [];
recipients.forEach(email => {
fingerprints.push(this.publicKeys.find(key => key.emails.includes(email)).fingerprint);
fingerprints.push(this.publicKeys.find(key => key.for(email)).fingerprint);
});
return fingerprints;
}
getPrivateKeyFor(query, sign) {
return findGnuPGKey(this.privateKeys, query, sign);
getPrivateKeyFor(query/*, sign*/) {
return findGnuPGKey(this.privateKeys, query/*, sign*/);
}
async decrypt(message) {
@ -191,22 +188,27 @@ export const GnuPGUserStore = new class {
uid: message.uid,
partId: pgpInfo.partId,
keyId: key.id,
passphrase: await askPassphrase(key, 'BUTTON_DECRYPT'),
passphrase: await key.password('CRYPTO/DECRYPT'),
data: '' // message.plain() optional
}
if (null !== params.passphrase) {
const result = await Remote.post('GnupgDecrypt', null, params);
if (result?.Result && false !== result.Result.data) {
return result.Result;
if (null != params.passphrase) {
try {
const response = await Remote.post('GnupgDecrypt', null, params);
if (response?.Result?.data) {
return response.Result;
}
throw response;
} catch (e) {
Passphrases.delete(key);
throw e;
}
Passphrases.delete(key.id);
}
}
}
}
async verify(message) {
let data = message.pgpSigned(); // { bodyPartId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
let data = message.pgpSigned(); // { partId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
if (data) {
data = { ...data }; // clone
// const sender = message.from[0].email;
@ -217,18 +219,19 @@ export const GnuPGUserStore = new class {
data.bodyPart = data.bodyPart.raw;
data.sigPart = data.sigPart.body;
}
let response = await Remote.post('MessagePgpVerify', null, data);
let response = await Remote.post('PgpVerifyMessage', null, data);
if (response?.Result) {
return {
fingerprint: response.Result.fingerprint,
success: 0 == response.Result.status // GOODSIG
success: 0 == response.Result.status, // GOODSIG
error: response.Result.message
};
}
}
}
async sign(privateKey) {
return await askPassphrase(privateKey);
return await privateKey.password('CRYPTO/SIGN');
}
};

View file

@ -0,0 +1,124 @@
import { SettingsGet } from 'Common/Globals';
// https://mailvelope.github.io/mailvelope/Keyring.html
let mailvelopeKeyring = null;
export const MailvelopeUserStore = {
keyring: null,
loadKeyring(identifier) {
identifier = identifier || SettingsGet('Email');
if (window.mailvelope) {
const fn = keyring => {
mailvelopeKeyring = keyring;
console.log('mailvelope ready');
};
mailvelope.getKeyring().then(fn, err => {
if (identifier) {
// attempt to create a new keyring for this app/user
mailvelope.createKeyring(identifier).then(fn, err => console.error(err));
} else {
console.error(err);
}
});
addEventListener('mailvelope-disconnect', event => {
alert('Mailvelope is updated to version ' + event.detail.version + '. Reload page');
}, false);
} else {
addEventListener('mailvelope', () => this.loadKeyring(identifier));
}
},
async hasPublicKeyForEmails(recipients) {
const
mailvelope = mailvelopeKeyring && await mailvelopeKeyring.validKeyForAddress(recipients)
/*.then(LookupResult => Object.entries(LookupResult))*/,
entries = mailvelope && Object.entries(mailvelope);
return entries && entries.filter(value => value[1]).length === recipients.length;
},
async getPrivateKeyFor(email/*, sign*/) {
if (mailvelopeKeyring && await mailvelopeKeyring.hasPrivateKey({email:email})) {
return ['mailvelope', email];
}
return false;
},
async decrypt(message) {
// Try Mailvelope (does not support inline images)
if (mailvelopeKeyring) {
const sender = message.from[0].email,
armoredText = message.plain();
try {
let emails = [...message.from,...message.to,...message.cc].validUnique(),
i = emails.length;
while (i--) {
if (await this.getPrivateKeyFor(emails[i].email)) {
/**
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createEncryptedFormContainer
* Creates an iframe to display an encrypted form
*/
// mailvelope.createEncryptedFormContainer('#mailvelope-form');
/**
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createDisplayContainer
* Creates an iframe to display the decrypted content of the encrypted mail.
*/
const body = message.body;
body.textContent = '';
let result = await mailvelope.createDisplayContainer(
'#'+body.id,
armoredText,
mailvelopeKeyring,
{
senderAddress: sender
// emails[i].email
}
);
if (result) {
if (result.error?.message) {
if ('PWD_DIALOG_CANCEL' !== result.error.code) {
alert(result.error.code + ': ' + result.error.message);
}
} else {
body.classList.add('mailvelope');
return true;
}
}
break;
}
}
} catch (err) {
console.error(err);
}
}
}
/**
* Returns headers that should be added to an outgoing email.
* So far this is only the autocrypt header.
*/
/*
mailvelopeKeyring.additionalHeadersForOutgoingEmail(from: 'abc@web.de')
.then(function(additional) {
console.log('additionalHeadersForOutgoingEmail', additional);
// logs: {autocrypt: "addr=abc@web.de; prefer-encrypt=mutual; keydata=..."}
});
mailvelopeKeyring.addSyncHandler(syncHandlerObj)
mailvelopeKeyring.createKeyBackupContainer(selector, options)
mailvelopeKeyring.createKeyGenContainer(selector, {
// userIds: [],
keySize: 4096
})
mailvelopeKeyring.exportOwnPublicKey(emailAddr).then(<AsciiArmored, Error>)
mailvelopeKeyring.importPublicKey(armored)
// https://mailvelope.github.io/mailvelope/global.html#SyncHandlerObject
mailvelopeKeyring.addSyncHandler({
uploadSync
downloadSync
backup
restore
});
*/
};

View file

@ -32,6 +32,8 @@ import { SettingsGet } from 'Common/Globals';
import { SUB_QUERY_PREFIX } from 'Common/Links';
import { AppUserStore } from 'Stores/User/App';
import { baseCollator } from 'Common/Translator';
const
isChecked = item => item.checked(),
replaceHash = hash => {
@ -254,17 +256,14 @@ MessagelistUserStore.reload = (bDropPagePosition = false, bDropCurrentFolderCach
let flags = folderInfo.permanentFlags || [];
if (flags.includes('\\*')) {
/** Add Thunderbird labels */
let i = 6;
while (--i) {
flags.includes('$label'+i) || flags.push('$label'+i);
}
/** TODO: add others by default? */
}
flags.sort((a, b) => {
a = a.toUpperCase();
b = b.toUpperCase();
return (a < b) ? -1 : ((a > b) ? 1 : 0);
});
folder.permanentFlags(flags);
folder.permanentFlags(flags.sort(baseCollator().compare));
MessagelistUserStore.notifyNewMessages(folder.fullName, collection.newMessages);
}
@ -314,6 +313,7 @@ MessagelistUserStore.reload = (bDropPagePosition = false, bDropCurrentFolderCach
if (AppUserStore.threadsAllowed() && SettingsUserStore.useThreads()) {
params.useThreads = 1;
params.threadAlgorithm = SettingsUserStore.threadAlgorithm();
params.threadUid = MessagelistUserStore.threadUid();
} else {
params.threadUid = 0;

View file

@ -10,38 +10,48 @@ import Remote from 'Remote/User/Fetch';
import { showScreenPopup } from 'Knoin/Knoin';
import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey';
import { AskPopupView } from 'View/Popup/Ask';
import { Passphrases } from 'Storage/Passphrases';
import { baseCollator } from 'Common/Translator';
const
loaded = () => !!window.openpgp,
findOpenPGPKey = (keys, query/*, sign*/) =>
keys.find(key =>
key.emails.includes(query) || query == key.id || query == key.fingerprint
key.for(query) || query == key.id || query == key.fingerprint
),
decryptKey = async (privateKey, btnTxt = 'LABEL_SIGN') => {
decryptKey = async (privateKey, btnTxt = 'SIGN') => {
if (privateKey.key.isDecrypted()) {
return privateKey.key;
}
const key = privateKey.id,
pass = Passphrases.has(key)
? {password:Passphrases.get(key), remember:false}
: await AskPopupView.password(
'OpenPGP.js key<br>' + key + ' ' + privateKey.emails[0],
'OPENPGP/'+btnTxt
);
pass = await Passphrases.ask(privateKey,
'OpenPGP.js key<br>' + key + ' ' + privateKey.emails[0],
'CRYPTO/'+btnTxt
);
if (pass) {
const passphrase = pass.password,
result = await openpgp.decryptKey({
privateKey: privateKey.key,
passphrase
});
result && pass.remember && Passphrases.set(key, passphrase);
result && pass.remember && Passphrases.handle(privateKey, passphrase);
return result;
}
},
collator = baseCollator(),
sort = keys => keys.sort(
// (a, b) => collator.compare(a.emails[0], b.emails[0]) || collator.compare(a.fingerprint, b.fingerprint)
(a, b) => collator.compare(a.emails[0], b.emails[0]) || collator.compare(a.id, b.id)
),
dedup = keys => sort((keys || [])
.filter((v, i, a) => a.findIndex(entry => entry.fingerprint == v.fingerprint) === i)
),
/**
* OpenPGP.js v5 removed the localStorage (keyring)
* This should be compatible with the old OpenPGP.js v2
@ -53,9 +63,11 @@ const
let keys = [], key,
armoredKeys = JSON.parse(storage.getItem(itemname)),
i = arrayLength(armoredKeys);
while (i--) {
while (i--) try {
key = await openpgp.readKey({armoredKey:armoredKeys[i]});
key.err || keys.push(new OpenPgpKeyModel(armoredKeys[i], key));
} catch (e) {
console.error(e);
}
return keys;
},
@ -71,15 +83,11 @@ const
class OpenPgpKeyModel {
constructor(armor, key) {
this.key = key;
const aEmails = [];
if (key.users) {
key.users.forEach(user => user.userID.email && aEmails.push(user.userID.email));
}
this.id = key.getKeyID().toHex().toUpperCase();
this.fingerprint = key.getFingerprint();
this.can_encrypt = !!key.getEncryptionKey();
this.can_sign = !!key.getSigningKey();
this.emails = aEmails;
this.emails = key.users.map(user => IDN.toASCII(user.userID.email)).filter(email => email);
this.armor = armor;
this.askDelete = ko.observable(false);
this.openForDeletion = ko.observable(null).askDeleteHelper();
@ -87,6 +95,18 @@ class OpenPgpKeyModel {
// key.getPrimaryUser()
}
/*
get id() { return this.key.getKeyID().toHex().toUpperCase(); }
get fingerprint() { return this.key.getFingerprint(); }
get can_encrypt() { return !!this.key.getEncryptionKey(); }
get can_sign() { return !!this.key.getSigningKey(); }
get emails() { return this.key.users.map(user => IDN.toASCII(user.userID.email)).filter(email => email); }
get armor() { return this.key.armor(); }
*/
for(email) {
return this.emails.includes(IDN.toASCII(email));
}
view() {
showScreenPopup(OpenPgpKeyPopupView, [this]);
}
@ -116,37 +136,62 @@ export const OpenPGPUserStore = new class {
}
loadKeyrings() {
if (window.openpgp) {
loadOpenPgpKeys(publicKeysItem).then(keys => {
this.publicKeys(keys || []);
if (loaded()) {
loadOpenPgpKeys(publicKeysItem)
.then(keys => {
this.publicKeys(dedup(keys));
console.log('openpgp.js public keys loaded');
});
loadOpenPgpKeys(privateKeysItem).then(keys => {
this.privateKeys(keys || [])
console.log('openpgp.js private keys loaded');
})
.finally(() => {
loadOpenPgpKeys(privateKeysItem)
.then(keys => {
this.privateKeys(dedup(keys));
console.log('openpgp.js private keys loaded');
})
.finally(() => {
/*SettingsGet('loadBackupKeys') && */this.loadBackupKeys();
});
});
}
}
loadBackupKeys() {
Remote.request('GetPGPKeys',
(iError, oData) => !iError && oData.Result && this.importKeys(oData.Result)
);
}
/**
* @returns {boolean}
*/
isSupported() {
return !!window.openpgp;
return loaded();
}
importKey(armoredKey) {
window.openpgp && openpgp.readKey({armoredKey:armoredKey}).then(key => {
if (!key.err) {
if (key.isPrivate()) {
this.privateKeys.push(new OpenPgpKeyModel(armoredKey, key));
storeOpenPgpKeys(this.privateKeys, privateKeysItem);
} else {
this.publicKeys.push(new OpenPgpKeyModel(armoredKey, key));
storeOpenPgpKeys(this.publicKeys, publicKeysItem);
this.importKeys([armoredKey]);
}
async importKeys(keys) {
if (loaded()) {
const privateKeys = this.privateKeys(),
publicKeys = this.publicKeys();
for (const armoredKey of keys) try {
let key = await openpgp.readKey({armoredKey:armoredKey});
if (!key.err) {
key = new OpenPgpKeyModel(armoredKey, key);
const keys = key.key.isPrivate() ? privateKeys : publicKeys;
keys.find(entry => entry.fingerprint == key.fingerprint)
|| keys.push(key);
}
} catch (e) {
console.error(e, armoredKey);
}
});
this.privateKeys(sort(privateKeys));
this.publicKeys(sort(publicKeys));
storeOpenPgpKeys(privateKeys, privateKeysItem);
storeOpenPgpKeys(publicKeys, publicKeysItem);
}
}
/**
@ -155,7 +200,7 @@ export const OpenPGPUserStore = new class {
keyPair.revocationCertificate
*/
storeKeyPair(keyPair) {
if (window.openpgp) {
if (loaded()) {
openpgp.readKey({armoredKey:keyPair.publicKey}).then(key => {
this.publicKeys.push(new OpenPgpKeyModel(keyPair.publicKey, key));
storeOpenPgpKeys(this.publicKeys, publicKeysItem);
@ -173,7 +218,7 @@ export const OpenPGPUserStore = new class {
hasPublicKeyForEmails(recipients) {
const count = recipients.length,
length = count ? recipients.filter(email =>
this.publicKeys().find(key => key.emails.includes(email))
this.publicKeys().find(key => key.for(email))
).length : 0;
return length && length === count;
}
@ -201,7 +246,7 @@ export const OpenPGPUserStore = new class {
}
}
if (privateKey) try {
const decryptedKey = await decryptKey(privateKey, 'BUTTON_DECRYPT');
const decryptedKey = await decryptKey(privateKey, 'DECRYPT');
if (decryptedKey) {
const publicKey = findOpenPGPKey(this.publicKeys, sender/*, sign*/);
return await openpgp.decrypt({
@ -222,15 +267,15 @@ export const OpenPGPUserStore = new class {
* https://docs.openpgpjs.org/#sign-and-verify-cleartext-messages
*/
async verify(message) {
const data = message.pgpSigned(), // { bodyPartId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
publicKey = this.publicKeys().find(key => key.emails.includes(message.from[0].email));
const data = message.pgpSigned(), // { partId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
publicKey = this.publicKeys().find(key => key.for(message.from[0].email));
if (data && publicKey) {
data.folder = message.folder;
data.uid = message.uid;
data.tryGnuPG = 0;
let response;
if (data.sigPartId) {
response = await Remote.post('MessagePgpVerify', null, data);
response = await Remote.post('PgpVerifyMessage', null, data);
} else if (data.bodyPart) {
// MimePart
response = { Result: { text: data.bodyPart.raw, signature: data.sigPart.body } };
@ -282,7 +327,7 @@ export const OpenPGPUserStore = new class {
*/
async encrypt(text, recipients, signPrivateKey) {
const count = recipients.length;
recipients = recipients.map(email => this.publicKeys().find(key => key.emails.includes(email))).filter(key => key);
recipients = recipients.map(email => this.publicKeys().find(key => key.for(email))).filter(key => key);
if (count === recipients.length) {
if (signPrivateKey) {
signPrivateKey = await decryptKey(signPrivateKey);

View file

@ -8,14 +8,16 @@ import { SettingsCapa, SettingsGet } from 'Common/Globals';
import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import { MailvelopeUserStore } from 'Stores/User/Mailvelope';
// https://mailvelope.github.io/mailvelope/Keyring.html
let mailvelopeKeyring = null;
import Remote from 'Remote/User/Fetch';
export const
BEGIN_PGP_MESSAGE = '-----BEGIN PGP MESSAGE-----',
// BEGIN_PGP_SIGNATURE = '-----BEGIN PGP SIGNATURE-----',
// BEGIN_PGP_SIGNED = '-----BEGIN PGP SIGNED MESSAGE-----',
// BEGIN_PGP_PUBLIC_KEY = '-----BEGIN PGP PUBLIC KEY BLOCK-----',
// END_PGP_PUBLIC_KEY = '-----END PGP PUBLIC KEY BLOCK-----',
PgpUserStore = new class {
init() {
@ -33,32 +35,9 @@ export const
}
loadKeyrings(identifier) {
identifier = identifier || SettingsGet('Email');
if (window.mailvelope) {
const fn = keyring => {
mailvelopeKeyring = keyring;
console.log('mailvelope ready');
};
mailvelope.getKeyring().then(fn, err => {
if (identifier) {
// attempt to create a new keyring for this app/user
mailvelope.createKeyring(identifier).then(fn, err => console.error(err));
} else {
console.error(err);
}
});
addEventListener('mailvelope-disconnect', event => {
alert('Mailvelope is updated to version ' + event.detail.version + '. Reload page');
}, false);
} else {
addEventListener('mailvelope', () => this.loadKeyrings(identifier));
}
MailvelopeUserStore.loadKeyring(identifier);
OpenPGPUserStore.loadKeyrings();
if (SettingsCapa('GnuPG')) {
GnuPGUserStore.loadKeyrings();
}
GnuPGUserStore.loadKeyrings();
}
/**
@ -75,21 +54,28 @@ export const
return 0 === text.trim().indexOf(BEGIN_PGP_MESSAGE);
}
async mailvelopeHasPublicKeyForEmails(recipients) {
const
mailvelope = mailvelopeKeyring && await mailvelopeKeyring.validKeyForAddress(recipients)
/*.then(LookupResult => Object.entries(LookupResult))*/,
entries = mailvelope && Object.entries(mailvelope);
return entries && entries.filter(value => value[1]).length === recipients.length;
importKey(key, gnuPG, backup) {
if (gnuPG || backup) {
Remote.request('PgpImportKey',
(iError, oData) => {
if (gnuPG && oData?.Result/* && (oData.Result.imported || oData.Result.secretimported)*/) {
GnuPGUserStore.loadKeyrings();
}
iError && alert(oData.ErrorMessage);
}, {
key, gnuPG, backup
}
);
}
OpenPGPUserStore.importKey(key);
}
/**
* Checks if verifying/encrypting a message is possible with given email addresses.
* Returns the first library that can.
*/
async hasPublicKeyForEmails(recipients) {
const count = recipients.length;
if (count) {
hasPublicKeyForEmails(recipients) {
if (recipients.length) {
if (GnuPGUserStore.hasPublicKeyForEmails(recipients)) {
return 'gnupg';
}
@ -100,40 +86,15 @@ export const
return false;
}
async getMailvelopePrivateKeyFor(email/*, sign*/) {
if (mailvelopeKeyring && await mailvelopeKeyring.hasPrivateKey({email:email})) {
return ['mailvelope', email];
}
return false;
}
/**
* Checks if signing a message is possible with given email address.
* Returns the first library that can.
*/
async getKeyForSigning(email) {
let key = OpenPGPUserStore.getPrivateKeyFor(email, 1);
if (key) {
return ['openpgp', key];
}
key = GnuPGUserStore.getPrivateKeyFor(email, 1);
if (key) {
return ['gnupg', key];
}
// return await this.getMailvelopePrivateKeyFor(email, 1);
}
async decrypt(message) {
const sender = message.from[0].email,
armoredText = message.plain();
const armoredText = message.plain();
if (!this.isEncrypted(armoredText)) {
throw Error('Not armored text');
}
// Try OpenPGP.js
if (OpenPGPUserStore.isSupported()) {
const sender = message.from[0].email;
let result = await OpenPGPUserStore.decrypt(armoredText, sender);
if (result) {
return result;
@ -141,68 +102,20 @@ export const
}
// Try Mailvelope (does not support inline images)
if (mailvelopeKeyring) {
try {
let emails = [...message.from,...message.to,...message.cc].validUnique(),
i = emails.length;
while (i--) {
if (await this.getMailvelopePrivateKeyFor(emails[i].email)) {
/**
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createEncryptedFormContainer
* Creates an iframe to display an encrypted form
*/
// mailvelope.createEncryptedFormContainer('#mailvelope-form');
/**
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createDisplayContainer
* Creates an iframe to display the decrypted content of the encrypted mail.
*/
const body = message.body;
body.textContent = '';
let result = await mailvelope.createDisplayContainer(
'#'+body.id,
armoredText,
mailvelopeKeyring,
{
senderAddress: sender
// emails[i].email
}
);
if (result) {
if (result.error?.message) {
if ('PWD_DIALOG_CANCEL' !== result.error.code) {
alert(result.error.code + ': ' + result.error.message);
}
} else {
body.classList.add('mailvelope');
return true;
}
}
break;
}
}
} catch (err) {
console.error(err);
}
}
// Now try GnuPG
return GnuPGUserStore.decrypt(message);
return (await MailvelopeUserStore.decrypt(message))
// Or try GnuPG
|| GnuPGUserStore.decrypt(message);
}
async verify(message) {
const signed = message.pgpSigned();
const signed = message.pgpSigned(),
sender = message.from[0].email;
if (signed) {
const sender = message.from[0].email,
gnupg = GnuPGUserStore.hasPublicKeyForEmails([sender]),
openpgp = OpenPGPUserStore.hasPublicKeyForEmails([sender]);
// Detached signature use GnuPG first, else we must download whole message
if (gnupg && signed.sigPartId) {
return GnuPGUserStore.verify(message);
}
if (openpgp) {
// OpenPGP only when inline, else we must download the whole message
if (!signed.sigPartId && OpenPGPUserStore.hasPublicKeyForEmails([sender])) {
return OpenPGPUserStore.verify(message);
}
if (gnupg) {
if (GnuPGUserStore.hasPublicKeyForEmails([sender])) {
return GnuPGUserStore.verify(message);
}
// Mailvelope can't
@ -210,29 +123,23 @@ export const
}
}
/**
* Returns headers that should be added to an outgoing email.
* So far this is only the autocrypt header.
*/
/*
mailvelopeKeyring.additionalHeadersForOutgoingEmail(headers)
mailvelopeKeyring.addSyncHandler(syncHandlerObj)
mailvelopeKeyring.createKeyBackupContainer(selector, options)
mailvelopeKeyring.createKeyGenContainer(selector, {
// userIds: [],
keySize: 4096
})
mailvelopeKeyring.exportOwnPublicKey(emailAddr).then(<AsciiArmored, Error>)
mailvelopeKeyring.importPublicKey(armored)
// https://mailvelope.github.io/mailvelope/global.html#SyncHandlerObject
mailvelopeKeyring.addSyncHandler({
uploadSync
downloadSync
backup
restore
});
*/
getPublicKeyOfEmails(recipients) {
if (recipients.length) {
let result = {};
recipients.forEach(email => {
OpenPGPUserStore.publicKeys().forEach(key => {
if (key.for(email)) {
result[email] = key.armor;
}
});
GnuPGUserStore.publicKeys.map(async key => {
if (!result[email] && key.for(email)) {
result[email] = await key.fetch();
}
});
});
return result;
}
return false;
}
};

21
dev/Stores/User/SMime.js Normal file
View file

@ -0,0 +1,21 @@
import { addObservablesTo, koArrayWithDestroy } from 'External/ko';
import { baseCollator } from 'Common/Translator';
import Remote from 'Remote/User/Fetch';
export const SMimeUserStore = koArrayWithDestroy();
addObservablesTo(SMimeUserStore, {
loading: false
});
SMimeUserStore.loadCertificates = () => {
SMimeUserStore([]);
SMimeUserStore.loading(true);
Remote.request('SMimeGetCertificates', (iError, oData) => {
SMimeUserStore.loading(false);
const collator = baseCollator();
iError || SMimeUserStore(oData.Result.sort(
(a, b) => collator.compare(a.emailAddress, b.emailAddress) || (b.validTo_time_t - a.validTo_time_t)
));
});
};

View file

@ -29,6 +29,7 @@ export const SettingsUserStore = new class {
showNextMessage: 0,
allowDraftAutosave: 1,
useThreads: 0,
threadAlgorithm: '',
replySameFolder: 0,
hideUnsubscribed: 0,
hideDeleted: 1,
@ -47,6 +48,7 @@ export const SettingsUserStore = new class {
layout: 1,
editorDefaultType: 'Html',
editorWysiwyg: 'Squire',
msgDefaultAction: 1
});
@ -78,9 +80,10 @@ export const SettingsUserStore = new class {
init() {
const self = this;
self.editorDefaultType(SettingsGet('EditorDefaultType'));
[
'EditorDefaultType',
'editorWysiwyg',
'messageNewWindow',
'messageReadAuto',
'MsgDefaultAction',
@ -97,7 +100,8 @@ export const SettingsUserStore = new class {
'listGrouped',
'showNextMessage',
'AllowDraftAutosave',
'UseThreads',
'useThreads',
'threadAlgorithm',
'ReplySameFolder',
'HideUnsubscribed',
'HideDeleted',
@ -109,6 +113,39 @@ export const SettingsUserStore = new class {
'pgpSign',
'pgpEncrypt',
'allowSpellcheck'
/*
'MessagesPerPage',
'MessageReadDelay',
'SoundNotification',
'NotificationSound',
'DesktopNotifications',
'Layout',
'AutoLogout',
'ContactsAutosave',
'contactsAllowed',
'CheckMailInterval',
'SentFolder',
'DraftsFolder',
'JunkFolder',
'TrashFolder',
'ArchiveFolder',
'hourCycle',
'Resizer4Width',
'Resizer5Width',
'Resizer5Height',
'fontSansSerif',
'fontSerif',
'fontMono',
'userBackgroundName',
'userBackgroundHash',
'autoVerifySignatures',
'allowLanguagesOnSettings',
'attachmentLimit',
'Theme',
'language',
'clientLanguage',
'StaticLibsJs',
*/
].forEach(name => {
let value = SettingsGet(name);
name = name[0].toLowerCase() + name.slice(1);

View file

@ -7,16 +7,12 @@
max-width: 810px;
.domain-desc {
color: #666;
line-height: 20px;
background-color: #f9f9f9;
padding: 8px;
border: 1px solid #eee;
border: 1px solid;
border-radius: var(--input-border-radius, 3px);
margin: -5px 0;
i {
font-style: normal;
color: red;
strong {
color: var(--error-clr, #b94a48);
}
}

View file

@ -17,6 +17,15 @@
width: 100%;
}
/* https://github.com/the-djmaze/snappymail/issues/1427 */
#V-MailFolderList .e-checkbox {
margin: 0 4px;
width: calc(100% - 8px);
}
#V-MailFolderList .unreadOnly li a:not(.unread-sub):not([data-unread]) {
display:none;
}
.b-folders {
display: flex;
flex-direction: column;
@ -56,6 +65,7 @@
overflow: hidden;
overflow-y: auto;
min-width: 100px;
color: var(--folders-color, #333);
}
hr {
@ -68,22 +78,18 @@
white-space: nowrap;
a {
display: block;
position: relative;
z-index: 1;
line-height: 34px;
background-color: transparent;
vertical-align: middle;
color: var(--folders-disabled-color, #666);
border-left: 3px solid transparent;
color: var(--folders-color, #333);
display: block;
line-height: 34px;
padding: 0 2em 0 @folderItemPadding;
position: relative;
text-decoration: none;
vertical-align: middle;
z-index: 1;
&.selectable {
color: var(--folders-color, #333);
cursor: pointer;
&:hover {
@ -106,6 +112,7 @@
}
}
&:not(.selectable) {
color: var(--folders-disabled-color, #666);
cursor: default;
font-style: italic;
}
@ -185,27 +192,6 @@
li li li li li a {
text-indent: 4em;
}
/**/
&.single-root-inbox .b-folders-user > li > a {
display: none !important;
}
&.single-root-inbox {
li li a {
text-indent: 0;
}
li li li a {
text-indent: 1em;
}
li li li li a {
text-indent: 2em;
}
li li li li li a {
text-indent: 3em;
}
}
/**/
}
#rl-left .buttonCompose {

View file

@ -2,9 +2,15 @@
max-width: 770px;
.tab-content {
grid-column-end: 5;
padding-top: 20px;
}
.e-signature-place {
border: 1px solid #ccc;
border-radius: var(--input-border-radius, 3px);
margin-bottom: 1.43em;
}
.textEmail {

View file

@ -327,6 +327,7 @@
.attachmentParent,
.flagParent {
margin: 0 10px 0 5px;
min-width: 1em;
}
.flagParent::after {
content: '☆'; /*⚐*/

View file

@ -198,7 +198,7 @@
}
}
.openpgp-control {
.crypto-control {
padding: 0.5em;
span {
@ -331,6 +331,11 @@
padding: 10px;
position: relative;
&.swapColors {
background-color: var(--main-color, currentcolor);
color: var(--main-bg-color);
}
* {
box-sizing: unset;
/* unicode-bidi: plaintext;*/

View file

@ -9,6 +9,10 @@
margin-top: 1em;
}
td:first-child {
cursor: pointer;
}
td + td {
white-space: nowrap;
width: 1em;
@ -24,10 +28,6 @@
opacity: 0.6;
}
.folder-name.can-be-edited {
cursor: pointer;
}
select {
width: auto;
}

View file

@ -9,14 +9,14 @@
color: red;
content: attr(data-rainloopErrorTip);
font-size: 90%;
left: 100%;
left: 0;
opacity: 1;
padding: 0.25em;
position : absolute;
text-align: var(--left, left);
top: 100%;
transition: opacity 0.2s ease-in-out;
white-space: nowrap;
white-space: pre;
z-index: 2001;
}
@media screen and (max-width: @maxMobileWidth) {

View file

@ -123,4 +123,8 @@ html[dir="rtl"] {
border-right: 6px solid #eee;
}
}
[data-rainloopErrorTip]::before {
left: 100%;
}
}

View file

@ -1,5 +1,6 @@
import { addObservablesTo } from 'External/ko';
import { getNotification } from 'Common/Translator';
import { loadAccountsAndIdentities } from 'Common/UtilsUser';
import Remote from 'Remote/User/Fetch';
@ -37,7 +38,7 @@ export class AccountPopupView extends AbstractViewPopup {
this.submitError(getNotification(iError));
this.submitErrorAdditional(data?.ErrorMessageAdditional);
} else {
rl.app.accountsAndIdentities();
loadAccountsAndIdentities();
this.close();
}
}, data

View file

@ -32,14 +32,12 @@ export class AdvancedSearchPopupView extends AbstractViewPopup {
// Almost the same as MessageModel.tagOptions
keywords: () => {
const keywords = [{value:'',label:''}];
FolderUserStore.currentFolder().permanentFlags.forEach(value => {
if (isAllowedKeyword(value)) {
let lower = value.toLowerCase();
keywords.push({
value: value,
label: i18n('MESSAGE_TAGS/'+lower, 0, lower)
});
}
FolderUserStore.currentFolder().optionalTags().forEach(value => {
let lower = value.toLowerCase();
keywords.push({
value: value,
label: i18n('MESSAGE_TAGS/'+lower, 0, lower)
});
});
return keywords
},

View file

@ -85,29 +85,3 @@ export class AskPopupView extends AbstractViewPopup {
});
}
}
AskPopupView.password = function(sAskDesc, btnText) {
return new Promise(resolve => {
this.showModal([
sAskDesc,
view => resolve({password:view.passphrase(), remember:view.remember()}),
() => resolve(null),
true,
5,
btnText
]);
});
}
AskPopupView.credentials = function(sAskDesc, btnText) {
return new Promise(resolve => {
this.showModal([
sAskDesc,
view => resolve({username:view.username(), password:view.passphrase(), remember:view.remember()}),
() => resolve(null),
true,
3,
btnText
]);
});
}

View file

@ -11,7 +11,8 @@ import {
} from 'Common/EnumsUser';
import { pInt, isArray, arrayLength, b64Encode } from 'Common/Utils';
import { encodeHtml, HtmlEditor, htmlToPlain } from 'Common/Html';
import { encodeHtml, htmlToPlain } from 'Common/Html';
import { HtmlEditor } from 'Common/HtmlEditor';
import { koArrayWithDestroy, addObservablesTo, addComputablesTo, addSubscribablesTo } from 'External/ko';
import { UNUSED_OPTION_VALUE } from 'Common/Consts';
@ -27,16 +28,24 @@ import { SettingsUserStore } from 'Stores/User/Settings';
import { IdentityUserStore } from 'Stores/User/Identity';
import { AccountUserStore } from 'Stores/User/Account';
import { FolderUserStore } from 'Stores/User/Folder';
import { PgpUserStore } from 'Stores/User/Pgp';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { MailvelopeUserStore } from 'Stores/User/Mailvelope';
//import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
import { SMimeUserStore } from 'Stores/User/SMime';
import { Passphrases } from 'Storage/Passphrases';
import { MessageUserStore } from 'Stores/User/Message';
import { MessagelistUserStore } from 'Stores/User/Messagelist';
import Remote from 'Remote/User/Fetch';
import { ComposeAttachmentModel } from 'Model/ComposeAttachment';
import { EmailModel, addressparser } from 'Model/Email';
import { EmailModel } from 'Model/Email';
import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
import { addressparser } from 'Mime/Address';
import { decorateKoCommands, showScreenPopup } from 'Knoin/Knoin';
import { AbstractViewPopup } from 'Knoin/AbstractViews';
@ -44,6 +53,7 @@ import { AbstractViewPopup } from 'Knoin/AbstractViews';
import { FolderSystemPopupView } from 'View/Popup/FolderSystem';
import { AskPopupView } from 'View/Popup/Ask';
import { ContactsPopupView } from 'View/Popup/Contacts';
/*
import { ThemeStore } from 'Stores/Theme';
@ -56,7 +66,7 @@ const
tpl = createElement('template'),
base64_encode = text => b64Encode(text).match(/.{1,76}/g).join('\r\n'),
base64_encode = text => text ? b64Encode(text).match(/.{1,76}/g).join('\r\n') : '',
getEmail = value => addressparser(value)[0]?.email || false,
@ -243,11 +253,8 @@ export class ComposePopupView extends AbstractViewPopup {
showBcc: false,
showReplyTo: false,
pgpSign: false,
canPgpSign: false,
pgpEncrypt: false,
canPgpEncrypt: false,
canMailvelope: false,
doSign: false,
doEncrypt: false,
draftsFolder: '',
draftUid: 0,
@ -271,6 +278,8 @@ export class ComposePopupView extends AbstractViewPopup {
});
this.attachments = koArrayWithDestroy();
this.encryptOptions = koArrayWithDestroy();
this.signOptions = koArrayWithDestroy();
this.dragAndDropOver = ko.observable(false).extend({ debounce: 1 });
this.dragAndDropVisible = ko.observable(false).extend({ debounce: 1 });
@ -323,6 +332,13 @@ export class ComposePopupView extends AbstractViewPopup {
attachmentsInProcessCount: () => this.attachmentsInProcess.length,
isDraft: () => this.draftsFolder() && this.draftUid(),
canEncrypt: () => this.encryptOptions().length,
canMailvelope: () => this.encryptOptions.includes('Mailvelope'),
canSign: () => this.signOptions().length,
encryptOptionsText: () => this.encryptOptions().join(', '),
signOptionsText: () => this.signOptions().map(o => o[0]).join(', '),
identitiesOptions: () =>
IdentityUserStore.map(item => ({
item: item,
@ -340,32 +356,31 @@ export class ComposePopupView extends AbstractViewPopup {
sendSuccessButSaveError: value => !value && this.savedErrorDesc(''),
currentIdentity: value => value && this.from(value.formattedName()),
currentIdentity: value => {
if (value) {
this.from(value.formattedName());
this.doEncrypt(value.pgpEncrypt() || SettingsUserStore.pgpEncrypt());
this.doSign(value.pgpSign() || SettingsUserStore.pgpSign());
}
},
from: value => {
this.canPgpSign(false);
value = getEmail(value);
value && PgpUserStore.getKeyForSigning(value).then(result => {
console.log({
email: value,
canPgpSign:result
});
this.canPgpSign(result)
});
from: () => {
this.initSign();
this.initEncrypt();
},
cc: value => {
if (false === this.showCc() && value.length) {
this.showCc(true);
}
this.initPgpEncrypt();
this.initEncrypt();
},
bcc: value => {
if (false === this.showBcc() && value.length) {
this.showBcc(true);
}
this.initPgpEncrypt();
this.initEncrypt();
},
replyTo: value => {
@ -384,7 +399,7 @@ export class ComposePopupView extends AbstractViewPopup {
if (this.emptyToError() && value.length) {
this.emptyToError(false);
}
this.initPgpEncrypt();
this.initEncrypt();
},
attachmentsInProcess: value => {
@ -412,7 +427,7 @@ export class ComposePopupView extends AbstractViewPopup {
case 'K': quota *= 1024;
}
// Issue: can't select signing key
// this.pgpSign(this.pgpSign() || confirm('Sign this message?'));
// this.doSign(this.doSign() || confirm('Sign this message?'));
mailvelope.createEditorContainer('#mailvelope-editor', PgpUserStore.mailvelopeKeyring, {
// https://mailvelope.github.io/mailvelope/global.html#EditorContainerOptions
quota: Math.max(2048, (quota / 1024)) - 48, // (text + attachments) limit in kilobytes
@ -424,7 +439,7 @@ export class ComposePopupView extends AbstractViewPopup {
quotedMailHeader: '', // header to be added before the quoted mail
keepAttachments: false, // add attachments of quotedMail to editor (default: false)
// Issue: can't select signing key
signMsg: this.pgpSign()
signMsg: this.doSign()
*/
}).then(editor => this.mailvelope = editor);
}
@ -443,7 +458,8 @@ export class ComposePopupView extends AbstractViewPopup {
}
sendCommand() {
let sSentFolder = FolderUserStore.sentFolder();
const identity = this.currentIdentity();
let sSentFolder = identity?.sentFolder?.() || FolderUserStore.sentFolder();
this.attachmentsInProcessError(false);
this.attachmentsInErrorError(false);
@ -465,8 +481,7 @@ export class ComposePopupView extends AbstractViewPopup {
if (SettingsUserStore.replySameFolder()) {
if (
3 === arrayLength(this.aDraftInfo) &&
null != this.aDraftInfo[2] &&
this.aDraftInfo[2].length
this.aDraftInfo[2]?.length
) {
sSentFolder = this.aDraftInfo[2];
}
@ -474,63 +489,91 @@ export class ComposePopupView extends AbstractViewPopup {
if (!sSentFolder) {
showScreenPopup(FolderSystemPopupView, [FolderType.Sent]);
} else try {
this.sendError(false);
this.sending(true);
sSentFolder = UNUSED_OPTION_VALUE === sSentFolder ? '' : sSentFolder;
this.getMessageRequestParams(sSentFolder).then(params => {
Remote.request('SendMessage',
(iError, data) => {
this.sending(false);
if (iError) {
if (Notifications.CantSaveMessage === iError) {
this.sendSuccessButSaveError(true);
this.savedErrorDesc(i18n('COMPOSE/SAVED_ERROR_ON_SEND').trim());
} else {
this.sendError(true);
this.sendErrorDesc(getNotification(iError, data?.ErrorMessage)
|| getNotification(Notifications.CantSendMessage));
}
} else {
if (arrayLength(this.aDraftInfo) > 0) {
const flag = {
'reply': '\\answered',
'forward': '$forwarded'
}[this.aDraftInfo[0]];
if (flag) {
const aFlags = oLastMessage.flags();
if (aFlags.indexOf(flag) === -1) {
aFlags.push(flag);
oLastMessage.flags(aFlags);
}
}
}
this.close();
}
setFolderETag(this.draftsFolder(), '');
setFolderETag(sSentFolder, '');
if (3 === arrayLength(this.aDraftInfo)) {
const folder = this.aDraftInfo[2];
setFolderETag(folder, '');
}
reloadDraftFolder();
},
params,
30000
);
}).catch(e => {
} else {
const sendError = e => {
console.error(e);
this.sendError(true);
this.sendErrorDesc(e);
this.sending(false);
});
} catch (e) {
console.error(e);
this.sendError(true);
this.sendErrorDesc(e);
this.sending(false);
};
const sendFailed = (iError, data) => {
this.sendError(true);
this.sendErrorDesc(
getNotification(iError, data?.ErrorMessage, Notifications.CantSendMessage)
+ "\n" + data?.ErrorMessageAdditional
);
};
try {
this.sendError(false);
this.sending(true);
sSentFolder = UNUSED_OPTION_VALUE === sSentFolder ? '' : sSentFolder;
const sendMessage = params => {
Remote.request('SendMessage',
(iError, data) => {
this.sending(false);
if (iError) {
/*
if (Notifications.AuthError === iError && !params.auth) {
AskPopupView.password('SMTP login', 'retry', 3).then(result => {
if (result) {
this.sending(true);
params.auth = result;
sendMessage(params);
} else {
sendFailed(iError, data);
}
});
} else
*/
if (Notifications.CantSaveMessage === iError) {
this.sendSuccessButSaveError(true);
let msg = i18n('COMPOSE/SAVED_ERROR_ON_SEND');
if (data?.ErrorMessageAdditional) {
msg = msg + "\n" + data?.ErrorMessageAdditional;
}
this.savedErrorDesc(msg);
} else {
params.signPassphrase && Passphrases.delete(identity);
this.sendError(true);
sendFailed(iError, data);
}
} else {
if (arrayLength(this.aDraftInfo) > 0) {
const flag = {
'reply': '\\answered',
'forward': '$forwarded'
}[this.aDraftInfo[0]];
if (flag) {
const aFlags = oLastMessage.flags();
if (aFlags.indexOf(flag) === -1) {
aFlags.push(flag);
oLastMessage.flags(aFlags);
}
}
}
this.close();
}
setFolderETag(this.draftsFolder(), '');
setFolderETag(sSentFolder, '');
if (3 === arrayLength(this.aDraftInfo)) {
const folder = this.aDraftInfo[2];
setFolderETag(folder, '');
}
reloadDraftFolder();
},
params,
30000
);
};
this.getMessageRequestParams(sSentFolder)
.then(sendMessage)
.catch(sendError);
} catch (e) {
sendError(e);
}
}
}
}
@ -700,7 +743,6 @@ export class ComposePopupView extends AbstractViewPopup {
// setTimeout(() => {
this.oEditor = new HtmlEditor(
this.editorArea(),
null,
() => fOnInit(this.oEditor),
bHtml => this.isHtml(!!bHtml)
);
@ -853,7 +895,10 @@ export class ComposePopupView extends AbstractViewPopup {
switch (options.mode) {
case ComposeType.Reply:
case ComposeType.ReplyAll:
case ComposeType.ReplyAll: {
// if (1 == oLastMessage.to.length) {
// setTimeout(() => this.from(emailArrayToStringLineHelper(oLastMessage.to)), 1);
// }
if (ComposeType.Reply === options.mode) {
this.to(emailArrayToStringLineHelper(oLastMessage.replyEmails(excludeEmail)));
} else {
@ -866,9 +911,15 @@ export class ComposePopupView extends AbstractViewPopup {
this.aDraftInfo = ['reply', oLastMessage.uid, oLastMessage.folder];
this.sInReplyTo = oLastMessage.messageId;
this.sReferences = (oLastMessage.references + ' ' + oLastMessage.messageId).trim();
// OpenPGP “Transferable Public Key”
// oLastMessage.autocrypt?.keydata
break;
oLastMessage.headers().valuesByName('autocrypt').forEach(value => {
let autocrypt = new MimeHeaderAutocryptModel(value);
if (autocrypt.addr && autocrypt.keydata) {
PgpUserStore.hasPublicKeyForEmails([autocrypt.addr])
|| PgpUserStore.importKey(autocrypt.pem(), true, true)
// || showScreenPopup(OpenPgpImportPopupView, [autocrypt.pem()])
}
});
} break;
case ComposeType.Forward:
case ComposeType.ForwardAsAttachment:
@ -1190,7 +1241,7 @@ export class ComposePopupView extends AbstractViewPopup {
const attachment = new ComposeAttachmentModel(
message.requestHash,
message.subject() /*+ '-' + Jua.randomId()*/ + '.eml',
message.size()
message.size
);
attachment.fromMessage = true;
attachment.complete(true);
@ -1301,8 +1352,8 @@ export class ComposePopupView extends AbstractViewPopup {
this.showBcc(false);
this.showReplyTo(false);
this.pgpSign(SettingsUserStore.pgpSign());
this.pgpEncrypt(SettingsUserStore.pgpEncrypt());
this.doSign(SettingsUserStore.pgpSign());
this.doEncrypt(SettingsUserStore.pgpEncrypt());
this.attachments([]);
@ -1338,28 +1389,55 @@ export class ComposePopupView extends AbstractViewPopup {
].join(',').split(',').map(value => getEmail(value.trim())).validUnique();
}
initPgpEncrypt() {
const recipients = this.allRecipients();
PgpUserStore.hasPublicKeyForEmails(recipients).then(result => {
console.log({canPgpEncrypt:result});
this.canPgpEncrypt(result);
});
PgpUserStore.mailvelopeHasPublicKeyForEmails(recipients).then(result => {
console.log({canMailvelope:result});
this.canMailvelope(result);
if (!result) {
/**
* Checks if signing a message is possible with from email address.
* And sets all that can.
*/
initSign() {
let options = [],
identity = this.currentIdentity(),
email = getEmail(this.from()),
key = OpenPGPUserStore.getPrivateKeyFor(email, 1);
key && options.push(['OpenPGP', key]);
key = GnuPGUserStore.getPrivateKeyFor(email, 1);
key && options.push(['GnuPG', key]);
identity.smimeKeyValid() && identity.smimeCertificateValid() && identity.email() === email
&& options.push(['S/MIME']);
console.dir({signOptions: options});
this.signOptions(options);
}
async initEncrypt() {
const recipients = this.allRecipients(),
options = [];
if (recipients.length) {
GnuPGUserStore.hasPublicKeyForEmails(recipients)
&& options.push('GnuPG');
OpenPGPUserStore.hasPublicKeyForEmails(recipients)
&& options.push('OpenPGP');
const count = recipients.length,
identity = this.currentIdentity(),
from = (identity.smimeKey() && identity.smimeCertificate()) ? identity.email() : null;
count
&& count === recipients.filter(email =>
email == from
|| SMimeUserStore.find(certificate => email == certificate.emailAddress && certificate.smimeencrypt)
).length
&& options.push('S/MIME');
if (await MailvelopeUserStore.hasPublicKeyForEmails(recipients)) {
options.push('Mailvelope');
} else {
'mailvelope' === this.viewArea() && this.bodyArea();
// this.dropMailvelope();
}
});
}
}
togglePgpSign() {
this.pgpSign(!this.pgpSign()/* && this.canPgpSign()*/);
}
togglePgpEncrypt() {
this.pgpEncrypt(!this.pgpEncrypt()/* && this.canPgpEncrypt()*/);
console.dir({encryptOptions:options});
this.encryptOptions(options);
}
async getMessageRequestParams(sSaveFolder, draft)
@ -1410,11 +1488,18 @@ export class ComposePopupView extends AbstractViewPopup {
// Only used at send, not at save:
dsn: this.requestDsn() ? 1 : 0,
requireTLS: this.requireTLS() ? 1 : 0,
readReceiptRequest: this.requestReadReceipt() ? 1 : 0
readReceiptRequest: this.requestReadReceipt() ? 1 : 0,
autocrypt: [],
/**
* Basic support for Linked Data (Structured Email)
* https://json-ld.org/
* https://structured.email/
**/
linkedData: []
},
recipients = draft ? [identity.email()] : this.allRecipients(),
sign = !draft && this.pgpSign() && this.canPgpSign(),
encrypt = this.pgpEncrypt() && this.canPgpEncrypt(),
signOptions = !draft && this.doSign() && this.signOptions(),
encryptOptions = this.doEncrypt() && this.encryptOptions(),
isHtml = this.oEditor.isHtml();
if (isHtml) {
@ -1436,7 +1521,12 @@ export class ComposePopupView extends AbstractViewPopup {
params.encrypted = draft
? await this.mailvelope.createDraft()
: await this.mailvelope.encrypt(recipients);
} else if (sign || encrypt) {
/*
Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) =>
params.autocrypt.push({addr:k, keydata:v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g).trim()})
);
*/
} else if (signOptions.length || encryptOptions.length) {
if (!draft && !hasAttachments && !Text.length) {
throw i18n('COMPOSE/ERROR_EMPTY_BODY');
}
@ -1456,45 +1546,108 @@ export class ComposePopupView extends AbstractViewPopup {
alternative.children.push(data);
data = alternative;
}
if (!draft && sign?.[1]) {
if ('openpgp' == sign[0]) {
// Doesn't sign attachments
params.html = params.plain = '';
let signed = new MimePart;
signed.headers['Content-Type'] =
'multipart/signed; micalg="pgp-sha256"; protocol="application/pgp-signature"';
signed.headers['Content-Transfer-Encoding'] = '7Bit';
signed.children.push(data);
let signature = new MimePart;
signature.headers['Content-Type'] = 'application/pgp-signature; name="signature.asc"';
signature.headers['Content-Transfer-Encoding'] = '7Bit';
signature.body = await OpenPGPUserStore.sign(data.toString(), sign[1], 1);
signed.children.push(signature);
params.signed = signed.toString();
params.boundary = signed.boundary;
data = signed;
} else if ('gnupg' == sign[0]) {
let sign = true;
for (let i = 0; i < signOptions.length; ++i) {
if ('OpenPGP' == signOptions[i][0]) {
try {
// Doesn't sign attachments
params.html = params.plain = '';
let signed = new MimePart;
signed.headers['Content-Type'] =
'multipart/signed; micalg="pgp-sha256"; protocol="application/pgp-signature"';
signed.headers['Content-Transfer-Encoding'] = '7Bit';
signed.children.push(data);
let signature = new MimePart;
signature.headers['Content-Type'] = 'application/pgp-signature; name="signature.asc"';
signature.headers['Content-Transfer-Encoding'] = '7Bit';
signature.body = await OpenPGPUserStore.sign(data.toString(), signOptions[i][1], 1);
signed.children.push(signature);
params.signed = signed.toString();
params.boundary = signed.boundary;
data = signed;
/*
Object.entries(PgpUserStore.getPublicKeyOfEmails([getEmail(this.from())]) || {})
.forEach(([k,v]) => params.publicKey = v);
*/
} catch (e) {
sign = false;
console.error(e);
}
break;
}
if ('GnuPG' == signOptions[i][0]) {
// TODO: sign in PHP fails
// params.signData = data.toString();
params.signFingerprint = sign[1].fingerprint;
params.signPassphrase = await GnuPGUserStore.sign(sign[1]);
} else {
throw 'Signing with ' + sign[0] + ' not yet implemented';
let pass = await GnuPGUserStore.sign(signOptions[i][1]);
if (null != pass) {
// params.signData = data.toString();
params.signFingerprint = signOptions[i][1].fingerprint;
params.signPassphrase = pass;
// params.attachPublicKey = false;
} else {
sign = false;
}
break;
}
if ('S/MIME' == signOptions[i][0]) {
// TODO: sign in PHP fails
params.sign = 'S/MIME';
// params.signCertificate = identity.smimeCertificate();
// params.signPrivateKey = identity.smimeKey();
// params.attachCertificate = false;
if (identity.smimeKeyEncrypted()) {
const pass = await Passphrases.ask(identity,
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}),
'CRYPTO/SIGN'
);
if (null != pass) {
params.signPassphrase = pass.password;
// pass.remember && Passphrases.handle(identity, pass.password);
} else {
sign = false;
}
}
}
}
if (encrypt) {
if ('openpgp' == encrypt) {
// Doesn't encrypt attachments
params.encrypted = await OpenPGPUserStore.encrypt(data.toString(), recipients);
params.signed = '';
} else if ('gnupg' == encrypt) {
// Does encrypt attachments
params.encryptFingerprints = JSON.stringify(GnuPGUserStore.getPublicKeyFingerprints(recipients));
} else {
throw 'Encryption with ' + encrypt + ' not yet implemented';
if (signOptions.length && !sign) {
throw 'Signing failed';
}
if (encryptOptions.length) {
const autocrypt = () =>
Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) =>
params.autocrypt.push({
addr: k,
keydata: v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '').trim()
})
);
for (let i = 0; i < encryptOptions.length; ++i) {
if ('OpenPGP' == encryptOptions[i]) {
// Doesn't encrypt attachments
params.encrypted = await OpenPGPUserStore.encrypt(data.toString(), recipients);
params.signed = '';
autocrypt();
break;
}
if ('GnuPG' == encryptOptions[i]) {
// Does encrypt attachments
params.encryptFingerprints = JSON.stringify(GnuPGUserStore.getPublicKeyFingerprints(recipients));
autocrypt();
break;
}
if ('S/MIME' == encryptOptions[i]) {
params.encryptCertificates = [identity.smimeCertificate()];
SMimeUserStore.forEach(certificate => {
certificate.emailAddress != identity.email()
&& recipients.includes(certificate.emailAddress)
&& params.encryptCertificates.push(certificate.id)
});
break;
}
// We skip Mailvelope as it has its own window
}
}
}
return params;
}
}

View file

@ -62,7 +62,7 @@ export class ContactsPopupView extends AbstractViewPopup {
this.selector.on('ItemSelect', contact => this.populateViewContact(contact));
this.selector.on('ItemGetUid', contact => contact ? contact.generateUid() : '');
this.selector.on('ItemGetUid', contact => contact ? contact.id() : '');
addComputablesTo(this, {
contactsPaginator: computedPaginatorHelper(

View file

@ -26,6 +26,7 @@ const
imapType: 0,
imapTimeout: 300,
imapShortLogin: false,
imapLowerLogin: true,
// SSL
imapSslVerify_peer: false,
imapSslAllow_self_signed: false,
@ -44,12 +45,14 @@ const
sievePort: 4190,
sieveType: 0,
sieveTimeout: 10,
sieveAuthLiteral: true,
smtpHost: '',
smtpPort: 25,
smtpType: 0,
smtpTimeout: 60,
smtpShortLogin: false,
smtpLowerLogin: true,
smtpUseAuth: true,
smtpSetSender: false,
smtpAuthPlainLine: false,
@ -69,6 +72,7 @@ const
secure: pInt(oDomain.imapType()),
timeout: oDomain.imapTimeout,
shortLogin: !!oDomain.imapShortLogin(),
lowerLogin: !!oDomain.imapLowerLogin(),
ssl: {
verify_peer: !!oDomain.imapSslVerify_peer(),
verify_peer_name: !!oDomain.imapSslVerify_peer(),
@ -92,6 +96,7 @@ const
secure: pInt(oDomain.smtpType()),
timeout: oDomain.smtpTimeout,
shortLogin: !!oDomain.smtpShortLogin(),
lowerLogin: !!oDomain.smtpLowerLogin(),
ssl: {
verify_peer: !!oDomain.smtpSslVerify_peer(),
verify_peer_name: !!oDomain.smtpSslVerify_peer(),
@ -104,11 +109,13 @@ const
},
Sieve: {
enabled: !!oDomain.sieveEnabled(),
authLiteral: !!oDomain.sieveAuthLiteral(),
host: oDomain.sieveHost,
port: oDomain.sievePort,
secure: pInt(oDomain.sieveType()),
timeout: oDomain.sieveTimeout,
shortLogin: !!oDomain.imapShortLogin(),
lowerLogin: !!oDomain.imapLowerLogin(),
ssl: {
verify_peer: !!oDomain.imapSslVerify_peer(),
verify_peer_name: !!oDomain.imapSslVerify_peer(),
@ -306,21 +313,43 @@ export class DomainPopupView extends AbstractViewPopup {
this.testingSmtpError(false);
}
autoconfig() {
let domain = this.name();
Remote.request('AdminDomainAutoconfig', (iError, oData) => {
if (oData?.Result?.config) {
let server = oData.Result.config.incomingServer[0];
this.imapHost(server.hostname);
this.imapPort(server.port);
this.imapType('STARTTLS' === server.socketType ? 2 : ('SSL' === server.socketType ? 1 : 0));
this.imapShortLogin('%EMAILADDRESS%' !== server.username);
server = oData.Result.config.outgoingServer[0];
this.smtpHost(server.hostname);
this.smtpPort(server.port);
this.smtpType('STARTTLS' === server.socketType ? 2 : ('SSL' === server.socketType ? 1 : 0));
this.smtpShortLogin('%EMAILADDRESS%' !== server.username);
this.smtpUseAuth(!!server.authentication);
this.smtpUsePhpMail(false);
}
}, {domain});
}
onShow(oDomain) {
this.saving(false);
this.clearTesting();
this.edit(false);
this.imapCapabilities([
'BINARY',
'LIST-STATUS',
'METADATA',
'MOVE',
'NAMESPACE',
'PREVIEW',
'SORT',
'THREAD',
'BINARY',
'STATUS=SIZE',
'PREVIEW'
'THREAD'
]);
this.imapDisabled_capabilities(['PREVIEW','STATUS=SIZE']);
this.imapDisabled_capabilities(['METADATA','PREVIEW','STATUS=SIZE']);
forEachObjectEntry(domainDefaults, (key, value) => this[key](value));
this.enableSmartPorts(true);
if (oDomain) {
@ -343,6 +372,8 @@ export class DomainPopupView extends AbstractViewPopup {
this[key]?.(value);
}
});
this.name(IDN.toUnicode(this.name()));
this.aliasName(IDN.toUnicode(this.aliasName()));
this.imapCapabilities(this.imapCapabilities.concat(this.imapDisabled_capabilities()).unique());
this.enableSmartPorts(true);
}

152
dev/View/Popup/Folder.js Normal file
View file

@ -0,0 +1,152 @@
import { AbstractViewPopup } from 'Knoin/AbstractViews';
import { addObservablesTo, koComputable } from 'External/ko';
import Remote from 'Remote/User/Fetch';
import { FolderUserStore } from 'Stores/User/Folder';
import { getFolderFromCacheList, setFolder, removeFolderFromCacheList } from 'Common/Cache';
import { Notifications } from 'Common/Enums';
import { FolderMetadataKeys } from 'Common/EnumsUser';
import { folderListOptionsBuilder, sortFolders } from 'Common/Folders';
import { initOnStartOrLangChange, i18n, getNotification } from 'Common/Translator';
import { defaultOptionsAfterRender } from 'Common/Utils';
export class FolderPopupView extends AbstractViewPopup {
constructor() {
super('Folder');
addObservablesTo(this, {
folder: null, // FolderModel
parentFolder: '',
name: '',
editing: false
});
this.ACLAllowed = FolderUserStore.hasCapability('ACL');
this.ACL = ko.observableArray();
this.parentFolderSelectList = koComputable(() =>
folderListOptionsBuilder(
[],
[['', '']],
oItem => oItem ? oItem.detailedName() : '',
item => !item.subFolders.allow
|| (FolderUserStore.namespace && !item.fullName.startsWith(FolderUserStore.namespace))
)
);
this.displaySpecSetting = FolderUserStore.displaySpecSetting;
this.showKolab = FolderUserStore.allowKolab();
this.kolabTypeOptions = ko.observableArray();
let i18nFilter = key => i18n('SETTINGS_FOLDERS/TYPE_' + key);
initOnStartOrLangChange(()=>{
this.kolabTypeOptions([
{ id: '', name: '' },
{ id: 'event', name: i18nFilter('CALENDAR') },
{ id: 'contact', name: i18nFilter('CONTACTS') },
{ id: 'task', name: i18nFilter('TASKS') },
{ id: 'note', name: i18nFilter('NOTES') },
{ id: 'file', name: i18nFilter('FILES') },
{ id: 'journal', name: i18nFilter('JOURNAL') },
{ id: 'configuration', name: i18nFilter('CONFIGURATION') }
]);
});
this.defaultOptionsAfterRender = defaultOptionsAfterRender;
}
afterHide() {
this.editing(false);
}
submitForm(/*form*/) {
const
folder = this.folder(),
nameToEdit = this.name().trim(),
newParentName = this.parentFolder(),
oldParent = getFolderFromCacheList(folder.parentName),
newParent = getFolderFromCacheList(newParentName),
folderList = FolderUserStore.folderList,
newFolderList = newParent ? newParent.subFolders : folderList,
delimiter = (newParent || folder).delimiter,
oldFullname = folder.fullName,
newFullname = (newParent ? newParentName + delimiter : '') + nameToEdit;
if (nameToEdit && newFullname != oldFullname) {
Remote.abort('Folders').post('FolderRename', FolderUserStore.foldersRenaming, {
oldName: oldFullname,
newName: newFullname,
// toggleFolderSubscription / FolderSubscribe
subscribe: folder.isSubscribed() ? 1 : 0,
// toggleFolderCheckable / FolderCheckable
checkable: folder.checkable() ? 1 : 0,
// toggleFolderKolabType / FolderSetMetadata
kolab: {
// TODO: append '.default' ?
type: FolderMetadataKeys.KolabFolderType,
value: folder.kolabType()
}
})
.then(() => {
const
renameFolder = (folder, parent) => {
removeFolderFromCacheList(folder.fullName);
folder.parentName = (parent ? parent.fullName : '');
folder.fullName = (parent ? parent.fullName + delimiter : '') + folder.name();
folder.delimiter = delimiter;
folder.deep = (parent ? parent.deep : -1) + 1;
setFolder(folder);
},
renameChildren = folder => {
folder.subFolders.forEach(child => {
renameFolder(child, folder);
renameChildren(child);
})
};
folder.name(nameToEdit);
renameFolder(folder, newParent);
if (folder.subFolders.length || newParent != oldParent) {
// Rename all subfolders to prevent reload
renameChildren(folder);
}
(oldParent ? oldParent.subFolders : folderList).remove(folder);
newFolderList.push(folder);
sortFolders(newFolderList);
})
.catch(error => {
console.error(error);
FolderUserStore.error(
getNotification(error.code, '', Notifications.CantRenameFolder) + '.\n' + error.message
);
});
} else {
Remote.request('FolderSettings', null, {
folder: folder.fullName,
// toggleFolderSubscription / FolderSubscribe
subscribe: folder.isSubscribed() ? 1 : 0,
// toggleFolderCheckable / FolderCheckable
checkable: folder.checkable() ? 1 : 0,
// toggleFolderKolabType / FolderSetMetadata
kolab: {
// TODO: append '.default' ?
type: FolderMetadataKeys.KolabFolderType,
value: folder.kolabType()
}
});
}
this.close();
}
beforeShow(folder) {
this.ACL([]);
this.ACLAllowed && Remote.request('FolderACL', (iError, data) => {
if (!iError && data.Result) {
this.ACL(Object.values(data.Result));
}
}, {
folder: folder.fullName
});
this.editing(!folder.type() && folder.exists && folder.selectable());
this.name(folder.name()),
this.parentFolder(folder.parentName);
this.folder(folder);
}
}

View file

@ -3,7 +3,7 @@ import { koComputable, addObservablesTo } from 'External/ko';
import { Notifications } from 'Common/Enums';
import { defaultOptionsAfterRender } from 'Common/Utils';
import { folderListOptionsBuilder, sortFolders } from 'Common/Folders';
import { getNotification } from 'Common/Translator';
import { getNotification/*, baseCollator*/ } from 'Common/Translator';
import { FolderUserStore } from 'Stores/User/Folder';
@ -56,7 +56,7 @@ export class FolderCreatePopupView extends AbstractViewPopup {
folders.push(subFolder);
sortFolders(folders);
/*
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
var collator = baseCollator(true);
console.log((folder ? folder.subFolders : FolderUserStore.folderList).sort(collator.compare));
*/
},

View file

@ -1,7 +1,8 @@
import { addObservablesTo } from 'External/ko';
import { addObservablesTo, koComputable } from 'External/ko';
import { getNotification } from 'Common/Translator';
import { loadAccountsAndIdentities } from 'Common/UtilsUser';
import Remote from 'Remote/User/Fetch';
@ -9,29 +10,21 @@ import { AbstractViewPopup } from 'Knoin/AbstractViews';
import { IdentityModel } from 'Model/Identity';
import { folderListOptionsBuilder } from 'Common/Folders';
import { i18n } from 'Common/Translator';
import { defaultOptionsAfterRender } from 'Common/Utils';
import { AskPopupView } from 'View/Popup/Ask';
export class IdentityPopupView extends AbstractViewPopup {
constructor() {
super('Identity');
addObservablesTo(this, {
id: '',
identity: null,
edit: false,
email: '',
emailFocused: false,
name: '',
labelFocused: false,
nameFocused: false,
replyTo: '',
showReplyTo: false,
bcc: '',
showBcc: false,
signature: '',
signatureInsertBefore: false,
submitRequest: false,
submitError: ''
});
@ -40,21 +33,79 @@ export class IdentityPopupView extends AbstractViewPopup {
this.replyTo.valueHasMutated();
this.bcc.valueHasMutated();
*/
this.folderSelectList = koComputable(() =>
folderListOptionsBuilder(
[],
[['', '('+i18n('GLOBAL/DEFAULT')+')']]
)
);
this.defaultOptionsAfterRender = defaultOptionsAfterRender;
this.createSelfSigned = this.createSelfSigned.bind(this);
this.setSMimeKeyPass = this.setSMimeKeyPass.bind(this);
}
createSelfSigned() {
AskPopupView.password('', 'CRYPTO/CREATE_SELF_SIGNED').then(result => {
if (result) {
const identity = this.identity();
Remote.request('SMimeCreateCertificate', (iError, oData) => {
if (oData.Result.x509) {
identity.smimeKey(oData.Result.pkey);
identity.smimeCertificate(oData.Result.x509);
} else {
this.submitError(oData.ErrorMessage);
}
}, {
name: identity.name(),
email: identity.email(),
privateKey: identity.smimeKey(),
passphrase: result.password
});
}
});
}
async setSMimeKeyPass() {
const identity = this.identity();
let old = null
if (identity.smimeKeyEncrypted()) {
old = await AskPopupView.password(i18n('CRYPTO/CURRENT_PASS'), 'CRYPTO/DECRYPT');
if (!old) {
return;
}
}
AskPopupView.password(i18n('CRYPTO/NEW_PASS'), 'GLOBAL/SAVE').then(result => {
if (result) {
Remote.request('SMimeExportPrivateKey', (iError, oData) => {
if (oData.Result) {
identity.smimeKey(oData.Result);
} else {
this.submitError(oData.ErrorMessage);
}
}, {
privateKey: identity.smimeKey(),
oldPassphrase: old?.password,
newPassphrase: result.password
});
}
});
}
submitForm(form) {
if (!this.submitRequest() && form.reportValidity()) {
this.signature?.__fetchEditorValue?.();
let identity = this.identity();
identity.signature?.__fetchEditorValue?.();
this.submitRequest(true);
const data = new FormData(form);
data.set('Id', this.id());
data.set('Signature', this.signature());
data.set('Id', identity.id());
data.set('Signature', identity.signature());
Remote.request('IdentityUpdate', iError => {
this.submitRequest(false);
if (iError) {
this.submitError(getNotification(iError));
} else {
rl.app.accountsAndIdentities();
loadAccountsAndIdentities();
this.close();
}
}, data
@ -66,12 +117,8 @@ export class IdentityPopupView extends AbstractViewPopup {
* @param {?IdentityModel} oIdentity
*/
onShow(identity) {
this.showBcc(false);
this.showReplyTo(false);
this.submitRequest(false);
this.submitError('');
if (identity) {
this.edit(true);
} else {
@ -79,18 +126,10 @@ export class IdentityPopupView extends AbstractViewPopup {
identity = new IdentityModel;
identity.id(Jua.randomId());
}
this.id(identity.id() || '');
this.name(identity.name());
this.email(identity.email());
this.replyTo(identity.replyTo());
this.showReplyTo(0 < identity.replyTo().length);
this.bcc(identity.bcc());
this.showBcc(0 < identity.bcc().length);
this.signature(identity.signature());
this.signatureInsertBefore(identity.signatureInsertBefore());
this.identity(identity);
}
afterShow() {
this.id() ? this.emailFocused(true) : this.nameFocused(true);
this.identity().id() ? this.labelFocused(true) : this.nameFocused(true);
}
}

View file

@ -43,7 +43,7 @@ export class OpenPgpGeneratePopupView extends AbstractViewPopup {
const type = this.keyType().toLowerCase(),
userId = {
name: this.name(),
email: this.email()
email: IDN.toASCII(this.email())
},
cfg = {
type: type,

View file

@ -1,20 +1,25 @@
import { addObservablesTo } from 'External/ko';
import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import { PgpUserStore } from 'Stores/User/Pgp';
import { AbstractViewPopup } from 'Knoin/AbstractViews';
import Remote from 'Remote/User/Fetch';
import { i18n } from 'Common/Translator';
export class OpenPgpImportPopupView extends AbstractViewPopup {
constructor() {
super('OpenPgpImport');
addObservablesTo(this, {
search: '',
key: '',
keyError: false,
keyErrorMessage: '',
saveGnuPG: true,
saveServer: false
saveServer: true
});
this.canGnuPG = GnuPGUserStore.isSupported();
@ -25,6 +30,44 @@ export class OpenPgpImportPopupView extends AbstractViewPopup {
});
}
searchPGP() {
this.key(i18n('SUGGESTIONS/SEARCHING_DESC'));
const fn = () => Remote.request('PgpSearchKey',
(iError, oData) => {
if (iError) {
this.key(oData.ErrorMessage);
} else {
this.key(oData.Result);
}
}, {
query: this.search()
}
);
fetch(
`https://keys.openpgp.org/pks/lookup?op=get&options=mr&search=${this.search()}`,
{
method: 'GET',
mode: 'cors',
cache: 'no-cache',
redirect: 'error',
referrerPolicy: 'no-referrer',
credentials: 'omit'
}
)
.then(response => {
if ('application/pgp-keys' == response.headers.get('Content-Type')) {
response.text().then(body => this.key(body));
} else {
fn();
}
})
.catch(e => {
this.key('keys.openpgp.org: ' + e?.message + '\nTrying local...');
fn();
throw e;
});
}
submitForm() {
let keyTrimmed = this.key().trim();
@ -35,38 +78,30 @@ export class OpenPgpImportPopupView extends AbstractViewPopup {
this.keyError(!keyTrimmed);
this.keyErrorMessage('');
if (!keyTrimmed) {
return;
}
let match = null,
count = 30,
done = false;
// eslint-disable-next-line max-len
const reg = /[-]{3,6}BEGIN[\s]PGP[\s](PRIVATE|PUBLIC)[\s]KEY[\s]BLOCK[-]{3,6}[\s\S]+?[-]{3,6}END[\s]PGP[\s](PRIVATE|PUBLIC)[\s]KEY[\s]BLOCK[-]{3,6}/gi;
do {
match = reg.exec(keyTrimmed);
if (match && 0 < count) {
if (match[0] && match[1] && match[2] && match[1] === match[2]) {
this.saveGnuPG() && GnuPGUserStore.isSupported() && GnuPGUserStore.importKey(this.key(), (iError, oData) => {
iError && alert(oData.ErrorMessage);
});
OpenPGPUserStore.isSupported() && OpenPGPUserStore.importKey(this.key());
}
--count;
if (keyTrimmed) {
let match = null,
count = 30,
done = false;
} else {
done = true;
}
} while (!done);
const GnuPG = this.saveGnuPG() && GnuPGUserStore.isSupported(),
backup = this.saveServer(),
// eslint-disable-next-line max-len
reg = /[-]{3,6}BEGIN[\s]PGP[\s](PRIVATE|PUBLIC)[\s]KEY[\s]BLOCK[-]{3,6}[\s\S]+?[-]{3,6}END[\s]PGP[\s](PRIVATE|PUBLIC)[\s]KEY[\s]BLOCK[-]{3,6}/gi;
if (this.keyError()) {
return;
do {
match = reg.exec(keyTrimmed);
if (match && 0 < count) {
if (match[0] && match[1] && match[2] && match[1] === match[2]) {
PgpUserStore.importKey(this.key(), GnuPG, backup);
}
--count;
done = false;
} else {
done = true;
}
} while (!done);
this.close();
}
this.close();
}
onShow(key) {

View file

@ -0,0 +1,50 @@
import { addObservablesTo } from 'External/ko';
import { getNotification } from 'Common/Translator';
import { AbstractViewPopup } from 'Knoin/AbstractViews';
import Remote from 'Remote/User/Fetch';
export class SMimeImportPopupView extends AbstractViewPopup {
constructor() {
super('SMimeImport');
addObservablesTo(this, {
pem: '',
pemError: false,
pemErrorMessage: '',
pemValid: false
});
this.pem.subscribe(value => {
this.pemError(false);
this.pemErrorMessage('');
this.pemValid(value && value.includes('-----BEGIN CERTIFICATE-----'));
});
}
submitForm() {
if (this.pemValid()) {
Remote.request('SMimeImportCertificate',
(iError, oData) => {
if (iError) {
this.pemError(true);
this.pemErrorMessage(getNotification(iError, oData?.ErrorMessage));
// oData?.ErrorMessageAdditional;
} else {
this.close();
}
},
{pem:this.pem()}
);
} else {
this.pemError(true);
}
}
onShow() {
this.pem('');
this.pemError(false);
this.pemErrorMessage('');
}
}

View file

@ -92,10 +92,6 @@ export class LoginUserView extends AbstractViewLogin {
this.submitError('');
}
toggleSignMe() {
this.signMe(!this.signMe());
}
submitCommand(self, event) {
const email = this.email().trim();
this.email(email);
@ -124,7 +120,7 @@ export class LoginUserView extends AbstractViewLogin {
iError = Notifications.AuthError;
}
this.submitError(getNotification(iError, oData?.ErrorMessage,
Notifications.UnknownNotification));
Notifications.UnknownError));
this.submitErrorAdditional(oData?.ErrorMessageAdditional);
} else {
rl.setData(oData.Result);
@ -142,25 +138,20 @@ export class LoginUserView extends AbstractViewLogin {
onBuild(dom) {
super.onBuild(dom);
const signMe = (SettingsGet('signMe') || '').toLowerCase();
let signMe = SettingsGet('signMe');
switch (signMe) {
case 'defaultoff':
case 'defaulton':
this.signMeType(
'defaulton' === signMe ? SignMeOn : SignMeOff
);
case SignMeOff:
case SignMeOn:
switch (Local.get(ClientSideKeyNameLastSignMe)) {
case '-1-':
this.signMeType(SignMeOn);
signMe = SignMeOn;
break;
case '-0-':
this.signMeType(SignMeOff);
signMe = SignMeOff;
break;
// no default
}
this.signMeType(signMe);
break;
default:
this.signMeType(SignMeUnused);

View file

@ -35,31 +35,18 @@ export class MailFolderList extends AbstractViewLeft {
this.moveAction = moveAction;
this.foldersListWithSingleInboxRootFolder = ko.observable(false);
this.allowContacts = AppUserStore.allowContacts();
this.foldersFilter = foldersFilter;
this.filterUnseen = ko.observable(false);
addComputablesTo(this, {
foldersFilterVisible: () => 20 < FolderUserStore.folderList().CountRec,
folderListVisible: () => {
let multiple = false,
inbox, visible,
result = FolderUserStore.folderList().filter(folder => {
if (folder.isInbox()) {
inbox = folder;
}
visible = folder.visible();
multiple |= visible && !folder.isInbox();
return visible;
});
if (inbox && !multiple) {
inbox.collapsed(false);
}
this.foldersListWithSingleInboxRootFolder(!multiple);
return result;
let result = FolderUserStore.folderList().visible();
return 1 === result.length && result[0].isInbox() ? result[0].visibleSubfolders() : result;
}
});
}
@ -90,13 +77,14 @@ export class MailFolderList extends AbstractViewLeft {
const folder = ko.dataFor(el);
if (folder) {
if (moveAction()) {
moveAction(false);
let messages = MessagelistUserStore.listCheckedOrSelectedUidsWithSubMails();
const copy = event.ctrlKey || 2 === moveAction(),
messages = MessagelistUserStore.listCheckedOrSelectedUidsWithSubMails();
moveAction(0);
messages.size && MessagelistUserStore.moveMessages(
messages.folder,
messages,
folder.fullName,
event.ctrlKey
copy
);
} else {
if (!SettingsUserStore.usePreviewPane()) {
@ -173,7 +161,7 @@ export class MailFolderList extends AbstractViewLeft {
// addShortcut('tab', 'shift', ScopeFolderList, () => {
addShortcut('escape,tab,arrowright', '', ScopeFolderList, () => {
AppUserStore.focusedState(ScopeMessageList);
moveAction(false);
moveAction(0);
return false;
});
}

View file

@ -162,11 +162,11 @@ export class MailMessageList extends AbstractViewRight {
let list = [], current, sort = FolderUserStore.sortMode() || 'DATE';
if (sort.includes('FROM')) {
MessagelistUserStore.forEach(msg => {
let email = msg.from[0].email;
let email = msg.from[0]?.email;
if (!current || email != current.id) {
current = {
id: email,
label: msg.from[0].toLine(),
label: msg.from[0]?.toLine(),
search: 'from=' + email,
messages: []
};
@ -243,7 +243,7 @@ export class MailMessageList extends AbstractViewRight {
this.selector.on('MiddleClick', message => populateMessageBody(message, true));
this.selector.on('ItemGetUid', message => (message ? message.generateUid() : ''));
this.selector.on('ItemGetUid', message => (message ? message.folder + '/' + message.uid : ''));
this.selector.on('canSelect', () => MessagelistUserStore.canSelect());
@ -331,6 +331,7 @@ export class MailMessageList extends AbstractViewRight {
spamCommand: canBeMovedHelper,
notSpamCommand: canBeMovedHelper,
moveCommand: canBeMovedHelper,
copyCommand: canBeMovedHelper
});
}
@ -415,18 +416,26 @@ export class MailMessageList extends AbstractViewRight {
moveMessagesToFolderType(FolderType.Inbox);
}
moveCommand(vm, event) {
moveOrCopy(vm, event, mode) {
if (canBeMovedHelper()) {
if (vm && event?.preventDefault) {
stopEvent(event);
}
let b = moveAction();
AppUserStore.focusedState(b ? ScopeMessageList : ScopeFolderList);
moveAction(!b);
let i = moveAction();
AppUserStore.focusedState(i ? ScopeMessageList : ScopeFolderList);
moveAction(i ? 0 : mode);
}
}
moveCommand(vm, event) {
this.moveOrCopy(vm, event, 1);
}
copyCommand(vm, event) {
this.moveOrCopy(vm, event, 2);
}
composeClick() {
showMessageComposer();
}

View file

@ -54,6 +54,9 @@ import { showScreenPopup } from 'Knoin/Knoin';
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import { IdentityUserStore } from 'Stores/User/Identity';
import { Passphrases } from 'Storage/Passphrases';
const
oMessageScrollerDom = () => elementById('messageItem') || {},
@ -109,7 +112,8 @@ export class MailMessageView extends AbstractViewRight {
actionsMenu: null,
// viewer
viewFromShort: '',
dkimData: ['none', '', '']
dkimData: ['none', '', ''],
nowTracking: false
});
this.moveAction = moveAction;
@ -142,7 +146,7 @@ export class MailMessageView extends AbstractViewRight {
downloadAsZipAllowed: () => this.attachmentsActions.includes('zip')
&& (currentMessage()?.attachments || [])
.filter(item => item?.download /*&& !item?.isLinked()*/ && item?.checked())
.filter(item => item?.checked() && item?.download /*&& !item?.isLinked()*/)
.length,
tagsAllowed: () => FolderUserStore.currentFolder()?.tagsAllowed(),
@ -155,10 +159,10 @@ export class MailMessageView extends AbstractViewRight {
: ''
).join(' '),
askReadReceipt: () =>
(MessagelistUserStore.isDraftFolder() || MessagelistUserStore.isSentFolder())
&& currentMessage()?.readReceipt()
&& currentMessage()?.flags().includes('$mdnsent'),
askReadReceipt: () => currentMessage()?.readReceipt
&& !(MessagelistUserStore.isDraftFolder() || MessagelistUserStore.isSentFolder())
&& !currentMessage()?.flags().includes('$mdnsent')
&& !currentMessage()?.flags().includes('\\answered'),
listAttachments: () => currentMessage()?.attachments()
.filter(item => SettingsUserStore.listInlineAttachments() || !item.isLinked()),
@ -170,18 +174,17 @@ export class MailMessageView extends AbstractViewRight {
canBeRepliedOrForwarded: () => !MessagelistUserStore.isDraftFolder() && this.messageVisible(),
viewDkimIcon: () => 'none' !== this.dkimData()[0],
dkimIconClass:() => {
dkimIcon: () => {
switch (this.dkimData()[0]) {
case 'none':
return '';
case 'pass':
return 'icon-ok iconcolor-green'; // ✔️
return '✔';
default:
return 'icon-cross iconcolor-red'; // ✖ ❌
return '✖';
}
},
dkimIconClass: () => 'pass' === this.dkimData()[0] ? 'iconcolor-green' : 'iconcolor-red',
dkimTitle:() => {
const dkim = this.dkimData();
@ -208,6 +211,7 @@ export class MailMessageView extends AbstractViewRight {
// TODO: make first param a user setting #683
this.viewFromShort(message.from.toString(false, true));
this.dkimData(message.dkim[0] || ['none', '', '']);
this.nowTracking(false);
} else {
MessagelistUserStore.selectedMessage(null);
@ -236,6 +240,7 @@ export class MailMessageView extends AbstractViewRight {
decorateKoCommands(this, {
editCommand: self => self.messageVisible(),
moveCommand: self => self.messageVisible(),
copyCommand: self => self.messageVisible(),
goUpCommand: self => !self.messageListOrViewLoading(),
goDownCommand: self => !self.messageListOrViewLoading()
});
@ -253,13 +258,21 @@ export class MailMessageView extends AbstractViewRight {
currentMessage() && showMessageComposer([ComposeType.Draft, currentMessage()]);
}
moveCommand(vm, event) {
moveOrCopy(vm, event, mode) {
if (vm && event?.preventDefault) {
stopEvent(event);
}
this.actionsMenu().ddBtn.hide();
AppUserStore.focusedState(ScopeFolderList);
moveAction(true);
moveAction(mode);
}
moveCommand(vm, event) {
this.moveOrCopy(vm, event, 1);
}
copyCommand(vm, event) {
this.moveOrCopy(vm, event, 2);
}
setUnseen() {
@ -500,6 +513,17 @@ export class MailMessageView extends AbstractViewRight {
currentMessage().showExternalImages();
}
showTracking() {
const msg = currentMessage(), body = msg?.body;
if (body && msg.hasTracking()) {
let attr = 'data-x-href-tracking';
body.querySelectorAll('a['+attr+']').forEach(node => node.href = node.getAttribute(attr));
// attr = 'data-x-src-tracking';
// body.querySelectorAll('img['+attr+']').forEach(node => node.src = node.getAttribute(attr));
this.nowTracking(true);
}
}
whitelistText(txt) {
let value = (SettingsUserStore.viewImagesWhitelist().trim() + '\n' + txt).trim();
/*
@ -525,20 +549,19 @@ export class MailMessageView extends AbstractViewRight {
* @returns {void}
*/
readReceipt() {
let oMessage = currentMessage()
if (oMessage.readReceipt()) {
Remote.request('SendReadReceiptMessage', iError => {
if (!iError) {
oMessage.flags.push('$mdnsent');
// oMessage.flags.valueHasMutated();
let oMessage = currentMessage();
if (oMessage.readReceipt) {
oMessage.flags.push('$mdnsent');
Remote.request('SendReadReceiptMessage',
iError => iError && oMessage.flags.remove('$mdnsent'),
{
messageFolder: oMessage.folder,
messageUid: oMessage.uid,
readReceipt: oMessage.readReceipt,
subject: i18n('READ_RECEIPT/SUBJECT', { SUBJECT: oMessage.subject() }),
plain: i18n('READ_RECEIPT/BODY', { 'READ-RECEIPT': AccountUserStore.email() })
}
}, {
messageFolder: oMessage.folder,
messageUid: oMessage.uid,
readReceipt: oMessage.readReceipt(),
subject: i18n('READ_RECEIPT/SUBJECT', { SUBJECT: oMessage.subject() }),
plain: i18n('READ_RECEIPT/BODY', { 'READ-RECEIPT': AccountUserStore.email() })
});
);
}
}
@ -554,40 +577,45 @@ export class MailMessageView extends AbstractViewRight {
}
pgpDecrypt() {
const oMessage = currentMessage();
const oMessage = currentMessage(),
data = oMessage.pgpEncrypted();
delete data.error;
PgpUserStore.decrypt(oMessage).then(result => {
if (result) {
oMessage.pgpDecrypted(true);
if (result.data) {
MimeToMessage(result.data, oMessage);
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
if (result.signatures?.length) {
oMessage.pgpSigned(true);
oMessage.pgpVerified({
signatures: result.signatures,
success: !!result.signatures.length
});
}
}
} else {
if (!result) {
// TODO: translate
alert('Decryption failed, canceled or not possible');
throw Error('Decryption failed, canceled or not possible');
}
oMessage.pgpDecrypted(true);
if (result.data) {
MimeToMessage(result.data, oMessage);
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
if (result.signatures?.length) {
oMessage.pgpSigned({
signatures: result.signatures,
success: !!result.signatures.length
});
}
}
})
.catch(e => console.error(e));
.catch(e => {
data.error = e.message;
})
.finally(() => {
oMessage.pgpEncrypted(data);
});
}
pgpVerify(/*self, event*/) {
const oMessage = currentMessage()/*, ctrl = event.target.closest('.openpgp-control')*/;
PgpUserStore.verify(oMessage).then(result => {
if (result) {
oMessage.pgpVerified(result);
oMessage.pgpSigned(result);
} else {
alert('Verification failed or no valid public key found');
}
/*
if (result?.success) {
i18n('OPENPGP/GOOD_SIGNATURE', {
i18n('CRYPTO/GOOD_SIGNATURE', {
USER: validKey.user + ' (' + validKey.id + ')'
});
message.getText()
@ -597,7 +625,8 @@ export class MailMessageView extends AbstractViewRight {
? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ')
: '';
i18n('OPENPGP/ERROR', {
i18n('CRYPTO/ERROR', {
TYPE: 'OpenPGP',
ERROR: 'message'
}) + (additional ? ' (' + additional + ')' : '');
}
@ -605,4 +634,68 @@ export class MailMessageView extends AbstractViewRight {
});
}
async smimeDecrypt() {
const message = currentMessage();
const addresses = message.from.concat(message.to, message.cc, message.bcc).map(item => item.email),
identity = IdentityUserStore.find(item => addresses.includes(item.email())),
data = message.smimeEncrypted(); // { partId: "1" }
if (data && identity) {
delete data.error;
let pass, params = { ...data }; // clone
params.folder = message.folder;
params.uid = message.uid;
// params.bodyPart = params.bodyPart?.raw;
params.certificate = identity.smimeCertificate();
params.privateKey = identity.smimeKey();
if (identity.smimeKeyEncrypted()) {
pass = await Passphrases.ask(identity,
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}),
'CRYPTO/DECRYPT'
);
if (!pass) {
return;
}
params.passphrase = pass?.password;
}
Remote.post('SMimeDecryptMessage', null, params).then(response => {
if (response?.Result?.data) {
message.smimeDecrypted(true);
MimeToMessage(response.Result.data, message);
message.html() ? message.viewHtml() : message.viewPlain();
pass && pass.remember && Passphrases.handle(identity, pass.password);
if ('signed' in response.Result) {
message.smimeSigned(response.Result.signed);
}
}
}).catch(e => {
data.error = e.message
})
.finally(() => {
message.smimeEncrypted(data);
});
}
}
smimeVerify(/*self, event*/) {
const message = currentMessage(),
data = message.smimeSigned(); // { partId: "1", micAlg: "pgp-sha256" }
if (data) {
const params = { ...data }; // clone
params.folder = message.folder;
params.uid = message.uid;
params.bodyPart = data.bodyPart?.raw;
params.sigPart = data.sigPart?.bodyRaw;
Remote.post('SMimeVerifyMessage', null, params).then(response => {
if (response?.Result) {
if (response.Result.body) {
MimeToMessage(response.Result.body, message);
message.html() ? message.viewHtml() : message.viewPlain();
}
data.success = response.Result.success;
message.smimeSigned(data);
}
});
}
}
}

View file

@ -87,9 +87,8 @@ export class SystemDropDownUserView extends AbstractViewRight {
}
accountName() {
let email = AccountUserStore.email(),
account = AccountUserStore.find(account => account.email == email);
return account?.name || email;
const email = AccountUserStore.email();
return AccountUserStore.find(account => account.email == email)?.label() || IDN.toUnicode(email);
}
settingsClick() {

View file

@ -3,7 +3,7 @@
const
qUri = path => doc.location.pathname.replace(/\/+$/,'') + '/?/' + path,
eId = id => doc.getElementById('rl-'+id),
admin = '1' == eId('app')?.dataset?.admin,
admin = '1' == eId('app').dataset.admin,
toggle = div => {
eId('loading').hidden = true;
@ -69,7 +69,7 @@ window.rl = {
headers: {}
}, init);
let asJSON = 1,
XToken = RL_APP_DATA.System?.token,
XToken = (RL_APP_DATA.System || {}).token,
object = {};
if (postData) {
init.method = 'POST';

View file

@ -75,8 +75,8 @@
// create drag image from custom element or drag source
img = src.cloneNode(true);
copyStyle(src, img);
img._x = xOffset == null ? src.clientWidth / 2 : xOffset;
img._y = yOffset == null ? src.clientHeight / 2 : yOffset;
img._x = xOffset ?? src.clientWidth / 2;
img._y = yOffset ?? src.clientHeight / 2;
}
},

7
dev/prototype.js vendored
View file

@ -44,6 +44,13 @@
return template.content.firstChild;
};
/**
* https://github.com/tc39/proposal-regex-escaping
*/
if (!RegExp.escape){
RegExp.escape = s => String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
}
/**
* Every time the function is executed,
* it will delay the execution with the given amount of milliseconds.

View file

@ -0,0 +1,106 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
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
YXYPCv6njzAv1+fTgemAZYyomQ1qhVg=
=a9os
-----END PGP PRIVATE KEY BLOCK-----

View file

@ -0,0 +1,50 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
=0ljX
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -0,0 +1,16 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
lIYEYfQDqBYJKwYBBAHaRw8BAQdAy+llquGb/U4M0kD2xyJoQ2pwlDN02C7XD906
7I8zdB3+BwMC7y2sGFhuzTHldCm0gfdOH3H2PkmhDfM/OSTYGVEu1mKInC3xmp2F
HmC2wUd+yM+hzMrQMgHn61iT9CHDeunUhQqG8PoXfAUAL7syGiXQ0rQZZGVtbyA8
ZGVtb0BzbmFwcHltYWlsLmV1PoiMBBAWCgAdBQJh9AOoBAsJBwgDFQgKBBYAAgEC
GQECGwMCHgEAIQkQXzpc3AmtiuMWIQQsIj8g6irbTLaPgdlfOlzcCa2K4/nPAP4u
UrWr39wv+YKsNcLwHwOpljyu59iHOXA3halUbVCeJwD/dY6JXCwMDgG+BmurPcJh
S/S8Q6fjlN9hUi/za3acYASciwRh9AOoEgorBgEEAZdVAQUBAQdAvXl+RCkqtUqN
VQ3Fj3bFTZjZOeNlI3ibK2eN6EjlnwcDAQgH/gcDAjAzqrbZEDg05XxnZqG/vHCg
1bJ/OofU+0oYZy6DHhAVDa5IhNLkC7kDeA8gXtQnNVuOd5kSIWvLcVRMZZval8iI
Xfrt037sAAciOIft1uSIeAQYFggACQUCYfQDqAIbDAAhCRBfOlzcCa2K4xYhBCwi
PyDqKttMto+B2V86XNwJrYrjolQBAMqdz8VkgMYjM7tinwUUTe4JjZoCsuhHPN6S
pQLd/UzKAQDgRlbAdrl042/nJcdrBrQz3+wVzkaF0ehvihBf4/tfDw==
=5A1c
-----END PGP PRIVATE KEY BLOCK-----

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