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', 'plugin:prettier/recommended'],
extends: ['eslint:recommended'], extends: ['eslint:recommended'],
parserOptions: { parserOptions: {
ecmaVersion: 6, ecmaVersion: 11,
sourceType: 'module' sourceType: 'module'
}, },
env: { env: {
@ -35,7 +35,9 @@ module.exports = {
// vendors/bootstrap/bootstrap.native.js // vendors/bootstrap/bootstrap.native.js
'BSN': "readonly", 'BSN': "readonly",
// Mailvelope // Mailvelope
'mailvelope': "readonly" 'mailvelope': "readonly",
// Punycode
'IDN': "readonly"
}, },
// http://eslint.org/docs/rules/ // http://eslint.org/docs/rules/
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 ## 2.32.0 2023-12-26
### Added ### Added

View file

@ -49,7 +49,7 @@ This fork of RainLoop has the following changes:
* Admin uses password_hash/password_verify * Admin uses password_hash/password_verify
* Auth failed attempts written to syslog * Auth failed attempts written to syslog
* Added Fail2ban instructions * Added Fail2ban instructions
* ES2018 * ES2020
* PHP 7.4+ required * PHP 7.4+ required
* PHP mbstring extension required * PHP mbstring extension required
* PHP replaced pclZip with PharData and ZipArchive * PHP replaced pclZip with PharData and ZipArchive
@ -140,28 +140,28 @@ RainLoop 1.17 vs SnappyMail
|js/* |RainLoop |Snappy | |js/* |RainLoop |Snappy |
|--------------- |--------: |--------: | |--------------- |--------: |--------: |
|admin.js |2.170.153 | 80.370 | |admin.js |2.170.153 | 83.494 |
|app.js |4.207.787 | 408.429 | |app.js |4.207.787 | 437.079 |
|boot.js | 868.735 | 4.142 | |boot.js | 868.735 | 4.147 |
|libs.js | 658.812 | 193.230 | |libs.js | 658.812 | 193.716 |
|sieve.js | 0 | 85.085 | |sieve.js | 0 | 84.598 |
|polyfills.js | 334.608 | 0 | |polyfills.js | 334.608 | 0 |
|serviceworker.js | 0 | 285 | |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 | |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 | |admin.min.js | 256.831 | 40.856 | 73.606 | 13.776 | 60.877 | 12.345 |
|app.min.js | 515.367 | 186.270 |139.456 | 63.111 |110.485 | 54.218 | |app.min.js | 515.367 | 197.782 |139.456 | 67.143 |110.485 | 57.251 |
|boot.min.js | 84.659 | 2.084 | 26.998 | 1.202 | 23.643 | 1.003 | |boot.min.js | 84.659 | 2.087 | 26.998 | 1.204 | 23.643 | 1.002 |
|libs.min.js | 584.772 | 93.401 |180.901 | 34.765 |155.182 | 31.194 | |libs.min.js | 584.772 | 92.365 |180.901 | 34.487 |155.182 | 30.830 |
|sieve.min.js | 0 | 41.316 | 0 | 10.364 | 0 | 9.352 | |sieve.min.js | 0 | 41.093 | 0 | 10.325 | 0 | 9.327 |
|polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 | |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 |1.217.635 | 292.234 |358.761 |102.834 |299.485 | 89.083 |
|TOTAL user+sieve |1.217.635 | 323.071 |358.761 |109.442 |299.485 | 95.767 | |TOTAL user+sieve |1.217.635 | 333.327 |358.761 |113.159 |299.485 | 98.410 |
|TOTAL admin | 959.099 | 134.770 |292.911 | 49.150 |249.877 | 44.003 | |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 ### 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 | |css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli |
|------------ |-------: |------: |------: |------: |--------: | |------------ |-------: |------: |------: |------: |--------: |
|app.css | 340.331 | 84.484 | 46.946 | 17.627 | 15.110 | |app.css | 340.331 | 84.629 | 46.946 | 17.652 | 15.127 |
|app.min.css | 274.947 | 67.910 | 39.647 | 15.541 | 13.547 | |app.min.css | 274.947 | 67.995 | 39.647 | 15.562 | 13.568 |
|boot.css | | 1.326 | | 664 | 545 | |boot.css | | 1.326 | | 664 | 545 |
|boot.min.css | | 1.071 | | 590 | 474 | |boot.min.css | | 1.071 | | 590 | 474 |
|admin.css | | 30.641 | | 7.028 | 6.111 | |admin.css | | 30.528 | | 7.001 | 6.088 |
|admin.min.css | | 24.742 | | 6.352 | 5.599 | |admin.min.css | | 24.652 | | 6.325 | 5.574 |
### PGP ### PGP
RainLoop uses the old OpenPGP.js v2 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 |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 | | |openpgp.worker | 1.499 | | 824 | | 695 | |
@ -224,5 +224,3 @@ Still TODO:
|ckeditor | ? | 520.035 | ? | 155.916 | |ckeditor | ? | 520.035 | ? | 155.916 |
CKEditor including the 7 asset requests (css,language,plugins,icons) is 633.46 KB / 180.47 KB (gzip). 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 | | Version | Supported |
| -------- | --------- | | -------- | --------- |
| 2.13.x | ✔ | | 2.30.x | ✔ |
| < 2.13.0 | | | < 2.30.0 | |
## Reporting a Vulnerability ## Reporting a Vulnerability
Please report security issues or vulnerabilities as an encrypted email to [security@snappymail.eu](mailto:security@snappymail.eu). 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. 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----- -----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: Type: 255-bit EdDSA Comment: Type: 255-bit EdDSA

View file

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

View file

@ -1,6 +1,6 @@
import 'External/ko'; import 'External/ko';
import { Settings, SettingsGet } from 'Common/Globals'; import { SettingsGet, SettingsAdmin } from 'Common/Globals';
import { initThemes } from 'Stores/Theme'; import { initThemes } from 'Stores/Theme';
import Remote from 'Remote/Admin/Fetch'; import Remote from 'Remote/Admin/Fetch';
@ -11,6 +11,8 @@ import { LoginAdminScreen } from 'Screen/Admin/Login';
import { startScreens } from 'Knoin/Knoin'; import { startScreens } from 'Knoin/Knoin';
import { AbstractApp } from 'App/Abstract'; import { AbstractApp } from 'App/Abstract';
import { AskPopupView } from 'View/Popup/Ask';
export class AdminApp extends AbstractApp { export class AdminApp extends AbstractApp {
constructor() { constructor() {
super(Remote); super(Remote);
@ -23,7 +25,8 @@ export class AdminApp extends AbstractApp {
} }
start() { start() {
if (!Settings.app('adminAllowed')) { // if (!Settings.app('adminAllowed')) {
if (!SettingsAdmin('allowed')) {
rl.route.root(); rl.route.root();
setTimeout(() => location.href = '/', 1); setTimeout(() => location.href = '/', 1);
} else if (SettingsGet('Auth')) { } 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 'External/User/ko';
import { SMAudio } from 'Common/Audio'; import { SMAudio } from 'Common/Audio';
import { isArray, pInt } from 'Common/Utils'; import { pInt } from 'Common/Utils';
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility } from 'Common/UtilsUser'; import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser';
import { import {
FolderType, FolderType,
@ -33,9 +33,9 @@ import { SettingsUserStore } from 'Stores/User/Settings';
import { NotificationUserStore } from 'Stores/User/Notification'; import { NotificationUserStore } from 'Stores/User/Notification';
import { AccountUserStore } from 'Stores/User/Account'; import { AccountUserStore } from 'Stores/User/Account';
import { ContactUserStore } from 'Stores/User/Contact'; import { ContactUserStore } from 'Stores/User/Contact';
import { IdentityUserStore } from 'Stores/User/Identity';
import { FolderUserStore } from 'Stores/User/Folder'; import { FolderUserStore } from 'Stores/User/Folder';
import { PgpUserStore } from 'Stores/User/Pgp'; import { PgpUserStore } from 'Stores/User/Pgp';
import { SMimeUserStore } from 'Stores/User/SMime';
import { MessagelistUserStore } from 'Stores/User/Messagelist'; import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { ThemeStore, initThemes } from 'Stores/Theme'; import { ThemeStore, initThemes } from 'Stores/Theme';
import { LanguageStore } from 'Stores/Language'; import { LanguageStore } from 'Stores/Language';
@ -43,9 +43,6 @@ import { MessageUserStore } from 'Stores/User/Message';
import Remote from 'Remote/User/Fetch'; import Remote from 'Remote/User/Fetch';
import { AccountModel } from 'Model/Account';
import { IdentityModel } from 'Model/Identity';
import { LoginUserScreen } from 'Screen/User/Login'; import { LoginUserScreen } from 'Screen/User/Login';
import { MailBoxUserScreen } from 'Screen/User/MailBox'; import { MailBoxUserScreen } from 'Screen/User/MailBox';
import { SettingsUserScreen } from 'Screen/User/Settings'; import { SettingsUserScreen } from 'Screen/User/Settings';
@ -89,6 +86,8 @@ export class AppUser extends AbstractApp {
this.folderList = FolderUserStore.folderList; this.folderList = FolderUserStore.folderList;
this.messageList = MessagelistUserStore; 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 {string} folder
* @param {Array=} list = [] * @param {Array=} list = []
@ -216,9 +190,7 @@ export class AppUser extends AbstractApp {
setRefreshFoldersInterval(pInt(SettingsGet('CheckMailInterval'))); setRefreshFoldersInterval(pInt(SettingsGet('CheckMailInterval')));
ContactUserStore.init(); loadAccountsAndIdentities();
this.accountsAndIdentities();
setTimeout(() => { setTimeout(() => {
const cF = FolderUserStore.currentFolderFullName(); const cF = FolderUserStore.currentFolderFullName();
@ -247,6 +219,7 @@ export class AppUser extends AbstractApp {
setInterval(reloadTime, 60000); setInterval(reloadTime, 60000);
PgpUserStore.init(); PgpUserStore.init();
SMimeUserStore.loadCertificates();
setTimeout(() => mailToHelper(SettingsGet('mailToEmail')), 500); setTimeout(() => mailToHelper(SettingsGet('mailToEmail')), 500);
@ -267,7 +240,6 @@ export class AppUser extends AbstractApp {
console.error(e); console.error(e);
} }
}); });
} else { } else {
startScreens([LoginUserScreen]); startScreens([LoginUserScreen]);
} }
@ -278,3 +250,50 @@ export class AppUser extends AbstractApp {
showScreenPopup(ComposePopupView, params); 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, ConnectionError: 104,
DomainNotAllowed: 109, DomainNotAllowed: 109,
AccountNotAllowed: 110, AccountNotAllowed: 110,
CryptKeyError: 111,
ContactsSyncError: 140, ContactsSyncError: 140,
@ -95,7 +96,6 @@ Notifications = {
JsonParse: 952, JsonParse: 952,
// JsonTimeout: 953, // JsonTimeout: 953,
UnknownNotification: 998,
UnknownError: 999, UnknownError: 999,
// Admin // Admin

View file

@ -1,7 +1,7 @@
/* eslint key-spacing: 0 */ /* eslint key-spacing: 0 */
/* eslint quote-props: 0 */ /* eslint quote-props: 0 */
import { arrayLength } from 'Common/Utils'; import { arrayLength, pInt } from 'Common/Utils';
export const RFC822 = 'message/rfc822'; export const RFC822 = 'message/rfc822';
@ -30,6 +30,8 @@ const
p7c: app+'pkcs7-mime', p7c: app+'pkcs7-mime',
p7m: app+'pkcs7-mime', p7m: app+'pkcs7-mime',
p7s: app+'pkcs7-signature', p7s: app+'pkcs7-signature',
p12: app+'pkcs12',
pfx: app+'x-pkcs12',
torrent: app+'x-bittorrent', torrent: app+'x-bittorrent',
// scripts // scripts
@ -136,7 +138,7 @@ export const FileInfo = {
getContentType: fileName => { getContentType: fileName => {
fileName = lowerCase(fileName); fileName = lowerCase(fileName);
if ('winmail.dat' === fileName) { if ('winmail.dat' === fileName) {
return app + 'ms-tnef'; return app + 'vnd.ms-tnef';
} }
let ext = fileName.split('.').pop(); let ext = fileName.split('.').pop();
if (/^(txt|text|def|list|in|ini|log|sql|cfg|conf)$/.test(ext)) if (/^(txt|text|def|list|in|ini|log|sql|cfg|conf)$/.test(ext))
@ -164,7 +166,7 @@ export const FileInfo = {
*/ */
getType: (ext, mimeType) => { getType: (ext, mimeType) => {
ext = lowerCase(ext); 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; let key = ext + mimeType;
if (cache[key]) { if (cache[key]) {
@ -173,7 +175,7 @@ export const FileInfo = {
let result = FileType.Unknown; let result = FileType.Unknown;
const mimeTypeParts = mimeType.split('/'), const mimeTypeParts = mimeType.split('/'),
type = mimeTypeParts[1].replace('x-','').replace('-compressed',''), type = mimeTypeParts[1].replace('-compressed',''),
match = str => mimeType.includes(str), match = str => mimeType.includes(str),
archive = /^(zip|7z|tar|rar|gzip|bzip|bzip2)$/; archive = /^(zip|7z|tar|rar|gzip|bzip|bzip2)$/;
@ -205,9 +207,8 @@ export const FileInfo = {
case 'pdf' == type || 'pdf' == ext: case 'pdf' == type || 'pdf' == ext:
result = FileType.Pdf; result = FileType.Pdf;
break; break;
case [app+'pgp-signature', app+'pgp-keys'].includes(mimeType) case [app+'pgp-signature', app+'pgp-keys', exts.p7m, exts.p7s, exts.p12, exts.pfx].includes(mimeType)
|| ['asc', 'pem', 'ppk'].includes(ext) || ['asc', 'pem', 'ppk', 'p7s', 'p7m', 'p12', 'pfx'].includes(ext):
|| [app+'pkcs7-signature'].includes(mimeType) || 'p7s' == ext:
result = FileType.Certificate; result = FileType.Certificate;
break; break;
case match(msOffice+'.wordprocessingml') || match(openDoc+'.text') || match('vnd.ms-word') case match(msOffice+'.wordprocessingml') || match(openDoc+'.text') || match('vnd.ms-word')
@ -273,8 +274,8 @@ export const FileInfo = {
}, },
friendlySize: bytes => { friendlySize: bytes => {
bytes = parseInt(bytes, 10) || 0; bytes = pInt(bytes);
let i = Math.floor(Math.log(bytes) / Math.log(1024)); 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]; 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 { RFC822 } from 'Common/File';
import { getFolderInboxName, getFolderFromCacheList } from 'Common/Cache'; import { getFolderInboxName, getFolderFromCacheList } from 'Common/Cache';
import { baseCollator } from 'Common/Translator';
import { isArray, arrayLength } from 'Common/Utils'; import { isArray, arrayLength } from 'Common/Utils';
import { SettingsUserStore } from 'Stores/User/Settings'; import { SettingsUserStore } from 'Stores/User/Settings';
import { FolderUserStore } from 'Stores/User/Folder'; import { FolderUserStore } from 'Stores/User/Folder';
@ -27,7 +28,7 @@ setRefreshFoldersInterval = minutes => {
sortFolders = folders => { sortFolders = folders => {
try { try {
let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}); let collator = baseCollator(true);
folders.sort((a, b) => folders.sort((a, b) =>
a.isInbox() ? -1 : (b.isInbox() ? 1 : collator.compare(a.fullName, b.fullName)) a.isInbox() ? -1 : (b.isInbox() ? 1 : collator.compare(a.fullName, b.fullName))
); );

View file

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

View file

@ -1,5 +1,5 @@
import { createElement } from 'Common/Globals'; import { createElement } from 'Common/Globals';
import { forEachObjectEntry, pInt } from 'Common/Utils'; import { forEachObjectEntry, isArray, pInt } from 'Common/Utils';
import { SettingsUserStore } from 'Stores/User/Settings'; import { SettingsUserStore } from 'Stores/User/Settings';
const const
@ -207,7 +207,9 @@ export const
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()), bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
result = { result = {
hasExternals: false hasExternals: false,
tracking: false,
linkedData: []
}, },
findAttachmentByCid = cId => oAttachments.findByCid(cId), findAttachmentByCid = cId => oAttachments.findByCid(cId),
@ -269,12 +271,15 @@ export const
// Not supported by <template> element // Not supported by <template> element
// .replace(/<!doctype[^>]*>/gi, '') // .replace(/<!doctype[^>]*>/gi, '')
// .replace(/<\?xml[^>]*\?>/gi, '') // .replace(/<\?xml[^>]*\?>/gi, '')
.replace(/<(\/?)head(\s[^>]*)?>/gi, '')
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>') .replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
// .replace(/<\/?(html|head)[^>]*>/gi, '') // .replace(/<\/?(html|head)[^>]*>/gi, '')
// Fix Reddit https://github.com/the-djmaze/snappymail/issues/540 // Fix Reddit https://github.com/the-djmaze/snappymail/issues/540
.replace(/<span class="preview-text"[\s\S]+?<\/span>/, '') .replace(/<span class="preview-text"[\s\S]+?<\/span>/, '')
// https://github.com/the-djmaze/snappymail/issues/900 // https://github.com/the-djmaze/snappymail/issues/900
.replace(/\u2028/g,' ') .replace(/\u2028/g,' ')
// https://github.com/the-djmaze/snappymail/issues/1415
.replace(/<br>\s*<\/p>/gi,'</p>')
.trim(); .trim();
html = ''; html = '';
@ -284,6 +289,21 @@ export const
nodeIterator.referenceNode.remove(); 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( tmpl.content.querySelectorAll(
disallowedTags disallowedTags
+ (0 < bqLevel ? ',' + (new Array(1 + bqLevel).fill('blockquote').join(' ')) : '') + (0 < bqLevel ? ',' + (new Array(1 + bqLevel).fill('blockquote').join(' ')) : '')
@ -381,10 +401,14 @@ export const
if ('A' === name) { if ('A' === name) {
value = oElement.href; value = oElement.href;
if (!/^([a-z]+):/i.test(value)) { if (!/^([a-z]+):/i.test(value)) {
setAttribute('data-x-broken-href', value); setAttribute('data-x-href-broken', value);
delAttribute('href'); delAttribute('href');
} else { } else {
oElement.href = stripTracking(value); oElement.href = stripTracking(value);
if (oElement.href != value) {
result.tracking = true;
setAttribute('data-x-href-tracking', value);
}
setAttribute('target', '_blank'); setAttribute('target', '_blank');
// setAttribute('rel', 'external nofollow noopener noreferrer'); // setAttribute('rel', 'external nofollow noopener noreferrer');
} }
@ -406,9 +430,8 @@ export const
*/ */
let skipStyle = false; let skipStyle = false;
if (hasAttribute('src')) { value = delAttribute('src');
value = stripTracking(delAttribute('src')); if (value) {
if ('IMG' === name) { if ('IMG' === name) {
oElement.loading = 'lazy'; oElement.loading = 'lazy';
let attachment; let attachment;
@ -445,12 +468,18 @@ export const
oStyle.display = 'none'; oStyle.display = 'none';
// setAttribute('style', 'display:none'); // setAttribute('style', 'display:none');
setAttribute('data-x-src-hidden', value); setAttribute('data-x-src-hidden', value);
// result.tracking = true;
} }
else if (httpre.test(value)) 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; 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/')) else if (value.startsWith('data:image/'))
{ {
@ -721,184 +750,7 @@ export const
.replace(/\n/g, '<br>'); .replace(/\n/g, '<br>');
blockquoteSwitcher(); blockquoteSwitcher();
return tmpl.innerHTML.trim(); 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 = { rl.Utils = {
htmlToPlain: htmlToPlain, 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 { pInt } from 'Common/Utils';
import { doc, Settings } from 'Common/Globals'; import { doc, Settings, SettingsAdmin } from 'Common/Globals';
const const
BASE = doc.location.pathname.replace(/\/+$/,'') + '/', BASE = doc.location.pathname.replace(/\/+$/,'') + '/',
HASH_PREFIX = '#/', 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 export const
SUB_QUERY_PREFIX = '&q[]=', SUB_QUERY_PREFIX = '&q[]=',
@ -38,11 +38,10 @@ export const
/** /**
* @param {string} download * @param {string} download
* @param {string=} customSpecSuffix
* @returns {string} * @returns {string}
*/ */
attachmentDownload = (download, customSpecSuffix) => attachmentDownload = (download) =>
serverRequestRaw('Download', download, customSpecSuffix), serverRequestRaw('Download', download),
proxy = url => proxy = url =>
BASE + '?/ProxyExternal/' BASE + '?/ProxyExternal/'

View file

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

View file

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

View file

@ -12,14 +12,47 @@ import { ThemeStore } from 'Stores/Theme';
import Remote from 'Remote/User/Fetch'; import Remote from 'Remote/User/Fetch';
import { attachmentDownload } from 'Common/Links'; 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 export const
moveAction = ko.observable(false), // 1 = move, 2 = copy
moveAction = ko.observable(0),
dropdownsDetectVisibility = (() => dropdownsDetectVisibility = (() =>
dropdownVisibility(!!dropdowns.find(item => item.classList.contains('show'))) dropdownVisibility(!!dropdowns.find(item => item.classList.contains('show')))
).debounce(50), ).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 * @param {string} link
* @returns {boolean} * @returns {boolean}
@ -89,7 +122,7 @@ computedPaginatorHelper = (koCurrentPage, koPageCount) => {
next = 0, next = 0,
limit = 2; limit = 2;
if (1 < pageCount || (0 < pageCount && pageCount < currentPage)) { if (1 < pageCount) {
if (pageCount < currentPage) { if (pageCount < currentPage) {
fAdd(pageCount); fAdd(pageCount);
prev = pageCount; prev = pageCount;
@ -260,6 +293,8 @@ viewMessage = (oMessage, popup) => {
class:'b-text-part' class:'b-text-part'
+ (oMessage.pgpSigned() ? ' openpgp-signed' : '') + (oMessage.pgpSigned() ? ' openpgp-signed' : '')
+ (oMessage.pgpEncrypted() ? ' openpgp-encrypted' : '') + (oMessage.pgpEncrypted() ? ' openpgp-encrypted' : '')
+ (oMessage.smimeSigned() ? ' smime-signed' : '')
+ (oMessage.smimeEncrypted() ? ' smime-encrypted' : '')
}); });
MessageUserStore.purgeCache(); 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)); moveAction.subscribe(value => value && leftPanelDisabled(false));

View file

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

View file

@ -1,5 +1,6 @@
import { doc, createElement, addEventsListeners } from 'Common/Globals'; 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', const contentType = 'snappymail/emailaddress',
getAddressKey = li => li?.emailaddress?.key, getAddressKey = li => li?.emailaddress?.key,

View file

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

View file

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

View file

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

View file

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

22
dev/External/ko.js vendored
View file

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

View file

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

View file

@ -116,6 +116,11 @@ export class AbstractViewSettings
onHide() {} onHide() {}
viewModelDom 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) addSetting(name, valueCb)
{ {
let prop = name[0].toLowerCase() + name.slice(1), let prop = name[0].toLowerCase() + name.slice(1),
@ -131,6 +136,7 @@ export class AbstractViewSettings
rl.app.Remote.saveSetting(name, value, rl.app.Remote.saveSetting(name, value,
iError => { iError => {
this[trigger](iError ? SaveSettingStatus.Failed : SaveSettingStatus.Success); this[trigger](iError ? SaveSettingStatus.Failed : SaveSettingStatus.Success);
// iError || Settings.set(name, value);
setTimeout(() => this[trigger](SaveSettingStatus.Idle), 1000); 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) addSettings(names)
{ {
names.forEach(name => { 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'; import { decodeEncodedWords, BDecode, BEncode, QPDecode, decodeText } from 'Mime/Encoding';
import { addressparser } from 'Mime/Address';
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});
}
};
export function ParseMime(text) export function ParseMime(text)
{ {
@ -27,7 +15,52 @@ export function ParseMime(text)
this.bodyEnd = 0; this.bodyEnd = 0;
this.boundary = ''; this.boundary = '';
this.bodyText = ''; 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() { get body() {
let body = this.bodyRaw, let body = this.bodyRaw,
charset = this.header('content-type')?.params.charset, charset = this.header('content-type')?.params.charset,
encoding = this.headerValue('content-transfer-encoding'); encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
if ('quoted-printable' == encoding) { if ('quoted-printable' == encoding) {
body = QPDecode(body); body = QPDecode(body);
} else if ('base64' == encoding) { } else if ('base64' == encoding) {
body = atob(body.replace(/\r?\n/g, '')); body = BDecode(body.replace(/\r?\n/g, ''));
} }
return decodeText(charset, body); return decodeText(charset, body);
} }
get dataUrl() { get dataUrl() {
let body = this.bodyRaw, let body = this.bodyRaw,
encoding = this.headerValue('content-transfer-encoding'); encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
if ('base64' == encoding) { if ('base64' == encoding) {
body = body.replace(/\r?\n/g, ''); body = body.replace(/\r?\n/g, '');
} else { } else {
if ('quoted-printable' == encoding) { if ('quoted-printable' == encoding) {
body = QPDecode(body); body = QPDecode(body);
} }
body = btoa(body); body = BEncode(body);
// body = b64Encode(body);
} }
return 'data:' + this.headerValue('content-type') + ';base64,' + body; return 'data:' + this.headerValue('content-type') + ';base64,' + body;
} }
@ -80,7 +112,7 @@ export function ParseMime(text)
} }
getByContentType(type) { getByContentType(type) {
if (type == this.headerValue('content-type')) { if (type == this.headerValue('content-type')?.toLowerCase()) {
return this; return this;
} }
let i = 0, p = this.parts, part; 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 = '') => const ParsePart = (mimePart, start_pos = 0, id = '') =>
{ {
let part = new MimePart, let part = new MimePart,
@ -113,11 +148,19 @@ export function ParseMime(text)
[...header.matchAll(/;\s*([^;=]+)=\s*"?([^;"]+)"?/g)].forEach(param => [...header.matchAll(/;\s*([^;=]+)=\s*"?([^;"]+)"?/g)].forEach(param =>
params[param[1].trim().toLowerCase()] = param[2].trim() params[param[1].trim().toLowerCase()] = param[2].trim()
); );
// encoded-word = "=?" charset "?" encoding "?" encoded-text "?=" let field = match[1].trim().toLowerCase();
match[2] = match[2].trim().replace(/=\?([^?]+)\?(B|Q)\?(.+?)\?=/g, (m, charset, encoding, text) => if (lists.includes(field)) {
decodeText(charset, 'B' == encoding ? atob(text) : QPDecode(text)) match[2] = addressparser(match[2]);
); } else if ('keywords' === field) {
headers[match[1].trim().toLowerCase()] = { 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], value: match[2],
params: params params: params
}; };
@ -132,7 +175,7 @@ export function ParseMime(text)
let boundary = headers['content-type']?.params.boundary; let boundary = headers['content-type']?.params.boundary;
if (boundary) { if (boundary) {
part.boundary = 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), body = mimePart.slice(head.length),
bodies = body.split(regex), bodies = body.split(regex),
pos = part.bodyStart; pos = part.bodyStart;

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import { baseCollator } from 'Common/Translator';
import { AbstractCollectionModel } from 'Model/AbstractCollection'; import { AbstractCollectionModel } from 'Model/AbstractCollection';
import { AttachmentModel } from 'Model/Attachment'; import { AttachmentModel } from 'Model/Attachment';
@ -11,7 +12,7 @@ export class AttachmentCollectionModel extends AbstractCollectionModel
*/ */
static reviveFromJson(items) { static reviveFromJson(items) {
const attachments = super.reviveFromJson(items, attachment => AttachmentModel.reviveFromJson(attachment)); 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) => { attachments.sort((a, b) => {
if (a.isInline()) { if (a.isInline()) {
if (!b.isInline()) { if (!b.isInline()) {

View file

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

View file

@ -4,202 +4,6 @@ import { AbstractModel } from 'Knoin/AbstractModel';
'use strict'; '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 { export class EmailModel extends AbstractModel {
/** /**
* @param {string=} email = '' * @param {string=} email = ''

View file

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

View file

@ -1,5 +1,5 @@
import { AbstractModel } from 'Knoin/AbstractModel'; import { AbstractModel } from 'Knoin/AbstractModel';
import { addObservablesTo } from 'External/ko'; import { addObservablesTo, addComputablesTo } from 'External/ko';
export class IdentityModel extends AbstractModel { export class IdentityModel extends AbstractModel {
/** /**
@ -11,17 +11,31 @@ export class IdentityModel extends AbstractModel {
addObservablesTo(this, { addObservablesTo(this, {
id: '', id: '',
label: '',
email: '', email: '',
name: '', name: '',
replyTo: '', replyTo: '',
bcc: '', bcc: '',
sentFolder: '',
signature: '', signature: '',
signatureInsertBefore: false, signatureInsertBefore: false,
pgpSign: false,
pgpEncrypt: false,
smimeKey: '',
smimeCertificate: '',
askDelete: false 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() { formattedName() {
const name = this.name(), const name = this.name(),
email = this.email(); email = this.email(),
label = this.label();
return name ? name + ' <' + email + '>' : email; 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 { serverRequestRaw, proxy } from 'Common/Links';
import { addObservablesTo, addComputablesTo } from 'External/ko'; 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 { SettingsUserStore } from 'Stores/User/Settings';
import { FileInfo, RFC822 } from 'Common/File'; import { FileInfo, RFC822 } from 'Common/File';
import { AttachmentCollectionModel } from 'Model/AttachmentCollection'; import { AttachmentCollectionModel } from 'Model/AttachmentCollection';
import { EmailCollectionModel } from 'Model/EmailCollection'; import { EmailCollectionModel } from 'Model/EmailCollection';
import { MimeHeaderCollectionModel } from 'Model/MimeHeaderCollection';
//import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
import { AbstractModel } from 'Knoin/AbstractModel'; import { AbstractModel } from 'Knoin/AbstractModel';
import PreviewHTML from 'Html/PreviewMessage.html'; import PreviewHTML from 'Html/PreviewMessage.html';
@ -22,6 +24,8 @@ import { LanguageStore } from 'Stores/Language';
import Remote from 'Remote/User/Fetch'; import Remote from 'Remote/User/Fetch';
import { MimeToMessage } from 'Mime/Utils';
const const
msgHtml = msg => cleanHtml(msg.html(), msg.attachments(), '#rl-msg-' + msg.hash), msgHtml = msg => cleanHtml(msg.html(), msg.attachments(), '#rl-msg-' + msg.hash),
@ -55,42 +59,50 @@ export class MessageModel extends AbstractModel {
constructor() { constructor() {
super(); super();
this.folder = ''; Object.assign(this, {
this.uid = 0; folder: '',
this.hash = ''; uid: 0,
this.from = new EmailCollectionModel; hash: '',
this.to = new EmailCollectionModel; from: new EmailCollectionModel,
this.cc = new EmailCollectionModel; to: new EmailCollectionModel,
this.bcc = new EmailCollectionModel; cc: new EmailCollectionModel,
this.sender = new EmailCollectionModel; bcc: new EmailCollectionModel,
this.replyTo = new EmailCollectionModel; sender: new EmailCollectionModel,
this.deliveredTo = new EmailCollectionModel; replyTo: new EmailCollectionModel,
this.body = null; deliveredTo: new EmailCollectionModel,
this.draftInfo = []; body: null,
this.dkim = []; draftInfo: [],
this.spf = []; dkim: [],
this.dmarc = []; spf: [],
this.messageId = ''; dmarc: [],
this.inReplyTo = ''; messageId: '',
this.references = ''; inReplyTo: '',
this.autocrypt = {}; 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, { addObservablesTo(this, {
subject: '', subject: '',
plain: '', plain: '',
html: '', html: '',
preview: null,
size: 0,
spamScore: 0,
spamResult: '',
isSpam: false,
hasVirus: null, // or boolean when scanned
dateTimestamp: 0, dateTimestamp: 0,
internalTimestamp: 0,
priority: 3, // Normal
senderEmailsString: '',
senderClearEmailsString: '',
deleted: false, deleted: false,
@ -102,26 +114,29 @@ export class MessageModel extends AbstractModel {
isHtml: false, isHtml: false,
hasImages: false, hasImages: false,
hasExternals: false, hasExternals: false,
hasTracking: false,
pgpSigned: null,
pgpVerified: null,
encrypted: false, encrypted: false,
pgpSigned: null,
pgpEncrypted: null, pgpEncrypted: null,
pgpDecrypted: false, pgpDecrypted: false,
readReceipt: '', smimeSigned: null,
smimeEncrypted: null,
smimeDecrypted: false,
// rfc8621 // rfc8621
id: '', id: '',
// threadId: '' // threadId: ''
});
this.attachments = ko.observableArray(new AttachmentCollectionModel); /**
this.threads = ko.observableArray(); * Basic support for Linked Data (Structured Email)
this.threadUnseen = ko.observableArray(); * https://json-ld.org/
this.unsubsribeLinks = ko.observableArray(); * https://structured.email/
this.flags = ko.observableArray(); **/
linkedData: []
});
addComputablesTo(this, { addComputablesTo(this, {
attachmentIconClass: () => attachmentIconClass: () =>
@ -141,17 +156,15 @@ export class MessageModel extends AbstractModel {
tagOptions: () => { tagOptions: () => {
const tagOptions = []; const tagOptions = [];
FolderUserStore.currentFolder().permanentFlags.forEach(value => { FolderUserStore.currentFolder().optionalTags().forEach(value => {
if (isAllowedKeyword(value)) { let lower = value.toLowerCase();
let lower = value.toLowerCase(); tagOptions.push({
tagOptions.push({ css: 'msgflag-' + lower,
css: 'msgflag-' + lower, value: value,
value: value, checked: this.flags().includes(lower),
checked: this.flags().includes(lower), label: i18n('MESSAGE_TAGS/'+lower, 0, value),
label: i18n('MESSAGE_TAGS/'+lower, 0, value), toggle: (/*obj*/) => toggleTag(this, value)
toggle: (/*obj*/) => toggleTag(this, value) });
});
}
}); });
return tagOptions return tagOptions
}, },
@ -180,6 +193,10 @@ export class MessageModel extends AbstractModel {
return options; return options;
} }
}); });
this.smimeSigned.subscribe(value =>
value?.body && MimeToMessage(value.body, this)
);
} }
get requestHash() { get requestHash() {
@ -197,23 +214,23 @@ export class MessageModel extends AbstractModel {
} }
spamStatus() { spamStatus() {
let spam = this.spamResult(); let spam = this.spamResult;
return spam ? i18n(this.isSpam() ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : ''; return spam ? i18n(this.isSpam ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
} }
/** /**
* @returns {string} * @returns {string}
*/ */
friendlySize() { friendlySize() {
return FileInfo.friendlySize(this.size()); return FileInfo.friendlySize(this.size);
} }
computeSenderEmail() { computeSenderEmail() {
const list = this[ const list = this[
[FolderUserStore.sentFolder(), FolderUserStore.draftsFolder()].includes(this.folder) ? 'to' : 'from' [FolderUserStore.sentFolder(), FolderUserStore.draftsFolder()].includes(this.folder) ? 'to' : 'from'
]; ];
this.senderEmailsString(list.toString(true)); this.senderEmailsString = list.toString(true);
this.senderClearEmailsString(list.map(email => email?.email).filter(email => email).join(', ')); this.senderClearEmailsString = list.map(email => email?.email).filter(email => email).join(', ');
} }
/** /**
@ -224,8 +241,63 @@ export class MessageModel extends AbstractModel {
if (super.revivePropertiesFromJson(json)) { if (super.revivePropertiesFromJson(json)) {
// this.foundCIDs = isArray(json.FoundCIDs) ? json.FoundCIDs : []; // this.foundCIDs = isArray(json.FoundCIDs) ? json.FoundCIDs : [];
// this.attachments(AttachmentCollectionModel.reviveFromJson(json.attachments, this.foundCIDs)); // this.attachments(AttachmentCollectionModel.reviveFromJson(json.attachments, this.foundCIDs));
// this.headers(MimeHeaderCollectionModel.reviveFromJson(json.headers));
this.computeSenderEmail(); 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; return true;
} }
} }
@ -241,7 +313,7 @@ export class MessageModel extends AbstractModel {
checked: this.checked(), checked: this.checked(),
unseen: this.isUnseen(), unseen: this.isUnseen(),
focused: this.focused(), focused: this.focused(),
priorityHigh: this.priority() === 1, priorityHigh: this.priority === 1,
withAttachments: !!this.attachments().length, withAttachments: !!this.attachments().length,
// hasChildrenMessage: 1 < this.threadsLen() // hasChildrenMessage: 1 < this.threadsLen()
}, (key, value) => value && classes.push(key)); }, (key, value) => value && classes.push(key));
@ -307,8 +379,10 @@ export class MessageModel extends AbstractModel {
let result = msgHtml(this); let result = msgHtml(this);
this.hasExternals(result.hasExternals); this.hasExternals(result.hasExternals);
this.hasImages(!!result.hasExternals); this.hasImages(!!result.hasExternals);
this.hasTracking(!!result.tracking);
this.linkedData(result.linkedData);
body.innerHTML = result.html; body.innerHTML = result.html;
if (!this.isSpam() && FolderUserStore.spamFolder() != this.folder) { if (!this.isSpam && FolderUserStore.spamFolder() != this.folder) {
if ('always' === SettingsUserStore.viewImages()) { if ('always' === SettingsUserStore.viewImages()) {
this.showExternalImages(); this.showExternalImages();
} }
@ -343,6 +417,11 @@ export class MessageModel extends AbstractModel {
return this.viewBody(false); return this.viewBody(false);
} }
swapColors() {
const cl = this.body?.classList;
cl && cl.toggle('swapColors');
}
/** /**
* @param {boolean=} print = false * @param {boolean=} print = false
*/ */
@ -381,13 +460,6 @@ export class MessageModel extends AbstractModel {
this.popupMessage(true); this.popupMessage(true);
} }
/**
* @returns {string}
*/
generateUid() {
return this.folder + '/' + this.uid;
}
/** /**
* @returns {MessageModel} * @returns {MessageModel}
*//* *//*
@ -438,7 +510,7 @@ export class MessageModel extends AbstractModel {
hasImages = true; hasImages = true;
}, },
attr = 'data-x-src', attr = 'data-x-src',
src, useProxy = !!SettingsGet('useLocalProxyForExternalImages'); src, useProxy = !!SettingsGet('proxyExternalImages');
body.querySelectorAll('img[' + attr + ']').forEach(node => { body.querySelectorAll('img[' + attr + ']').forEach(node => {
src = node.getAttribute(attr); src = node.getAttribute(attr);
if (isValid(src)) { 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.DomainNotAllowed,
Notifications.AccountNotAllowed, Notifications.AccountNotAllowed,
Notifications.MailServerError, Notifications.MailServerError,
Notifications.UnknownNotification,
Notifications.UnknownError Notifications.UnknownError
].includes(err) ].includes(err)
) { ) {
@ -132,8 +131,8 @@ export class AbstractFetchRemote
fetchJSON(sAction, getURL(sGetAdd), fetchJSON(sAction, getURL(sGetAdd),
sGetAdd ? null : (params || {}), sGetAdd ? null : (params || {}),
undefined === iTimeout ? 30000 : pInt(iTimeout), pInt(iTimeout ?? 30000),
data => { async data => {
let iError = 0; let iError = 0;
if (data) { 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( fCallback && fCallback(
iError, iError,
data, data,
@ -170,13 +173,6 @@ export class AbstractFetchRemote
}); });
} }
/**
* @param {?Function} fCallback
*/
getPublicKey(fCallback) {
this.request('GetPublicKey', fCallback);
}
setTrigger(trigger, value) { setTrigger(trigger, value) {
if (trigger) { if (trigger) {
value = !!value; value = !!value;
@ -193,12 +189,16 @@ export class AbstractFetchRemote
post(action, fTrigger, params, timeOut) { post(action, fTrigger, params, timeOut) {
this.setTrigger(fTrigger, true); this.setTrigger(fTrigger, true);
return fetchJSON(action, getURL(), params || {}, pInt(timeOut, 30000), return fetchJSON(action, getURL(), params || {}, pInt(timeOut, 30000),
data => { async data => {
abort(action, 0, 1); abort(action, 0, 1);
if (!data) { if (!data) {
return Promise.reject(new FetchError(Notifications.JsonParse)); 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 = ''; let isCached = false, type = '';
if (data?.epoch) { if (data?.epoch) {

View file

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

View file

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

View file

@ -67,6 +67,7 @@ export class MailBoxUserScreen extends AbstractScreen {
* @returns {void} * @returns {void}
*/ */
onRoute(folderHash, page, search, messageUid) { onRoute(folderHash, page, search, messageUid) {
// Only works when FolderUserStore.folderList() is loaded
const folder = getFolderFromHashMap(folderHash.replace(/~([\d]+)$/, '')); const folder = getFolderFromHashMap(folderHash.replace(/~([\d]+)$/, ''));
if (folder) { if (folder) {
FolderUserStore.currentFolder(folder); FolderUserStore.currentFolder(folder);
@ -108,7 +109,7 @@ export class MailBoxUserScreen extends AbstractScreen {
*/ */
onBuild() { onBuild() {
doc.addEventListener('click', event => 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 Remote from 'Remote/Admin/Fetch';
import { forEachObjectEntry } from 'Common/Utils'; 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*/ { export class AdminSettingsConfig /*extends AbstractViewSettings*/ {
constructor() { constructor() {
this.config = ko.observableArray(); this.config = ko.observableArray();
this.search = ko.observableArray();
this.saved = ko.observable(false).extend({ falseTimeout: 5000 }); 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() { beforeShow() {
@ -28,13 +56,19 @@ export class AdminSettingsConfig /*extends AbstractViewSettings*/ {
items: [] items: []
}; };
forEachObjectEntry(items, (skey, item) => { 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 || 'admin_password' === skey ||
section.items.push({ section.items.push({
key: `config[${key}][${skey}]`, key: `config[${key}][${skey}]`,
name: skey, name: skey,
value: item[0], value: item[0],
type: getInputType(item[0], skey.includes('password')), type: getInputType(item[0], skey.includes('password')),
comment: item[1] comment: item[1],
options: item[2]
}); });
}); });
cfg.push(section); cfg.push(section);

View file

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

View file

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

View file

@ -10,7 +10,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
constructor() { constructor() {
super(); super();
this.addSettings(['useLocalProxyForExternalImages']); this.addSettings(['proxyExternalImages', 'autoVerifySignatures']);
this.weakPassword = rl.app.weakPassword; this.weakPassword = rl.app.weakPassword;
@ -28,6 +28,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
viewQRCode: '', viewQRCode: '',
capaGnuPG: SettingsCapa('GnuPG'),
capaOpenPGP: SettingsCapa('OpenPGP') capaOpenPGP: SettingsCapa('OpenPGP')
}); });
@ -65,7 +66,8 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
adminPasswordNew2: reset, adminPasswordNew2: reset,
capaOpenPGP: value => Remote.saveSetting('CapaOpenPGP', value) capaGnuPG: value => Remote.saveSetting('capaGnuPG', value),
capaOpenPGP: value => Remote.saveSetting('capaOpenPGP', value)
}); });
this.adminTOTP(SettingsGet('adminTOTP')); 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() { saveAdminUserCommand() {
if (!this.adminLogin().trim()) { if (!this.adminLogin().trim()) {
this.adminLoginError(true); this.adminLoginError(true);

View file

@ -2,6 +2,7 @@ import ko from 'ko';
//import { koComputable } from 'External/ko'; //import { koComputable } from 'External/ko';
import { SettingsCapa, SettingsGet } from 'Common/Globals'; import { SettingsCapa, SettingsGet } from 'Common/Globals';
import { loadAccountsAndIdentities } from 'Common/UtilsUser';
import { AccountUserStore } from 'Stores/User/Account'; import { AccountUserStore } from 'Stores/User/Account';
import { IdentityUserStore } from 'Stores/User/Identity'; import { IdentityUserStore } from 'Stores/User/Identity';
@ -64,7 +65,7 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
rl.route.root(); rl.route.root();
setTimeout(() => location.reload(), 1); setTimeout(() => location.reload(), 1);
} else { } else {
rl.app.accountsAndIdentities(); loadAccountsAndIdentities();
} }
}, { }, {
emailToDelete: accountToRemove.email emailToDelete: accountToRemove.email
@ -88,7 +89,7 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
accountsAndIdentitiesAfterMove() { accountsAndIdentitiesAfterMove() {
Remote.request('AccountsAndIdentitiesSortOrder', null, { 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() : "")) 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 { showScreenPopup } from 'Knoin/Knoin';
//import { FolderPopupView } from 'View/Popup/Folder';
import { FolderCreatePopupView } from 'View/Popup/FolderCreate'; import { FolderCreatePopupView } from 'View/Popup/FolderCreate';
import { FolderSystemPopupView } from 'View/Popup/FolderSystem'; import { FolderSystemPopupView } from 'View/Popup/FolderSystem';
@ -108,7 +109,7 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
} }
hideError() { hideError() {
this.error(''); FolderUserStore.error('');
} }
toggleFolderKolabType(folder, event) { toggleFolderKolabType(folder, event) {

View file

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

View file

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

View file

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

View file

@ -30,8 +30,6 @@ const
sDeepPrefix = '\u00A0\u00A0\u00A0', sDeepPrefix = '\u00A0\u00A0\u00A0',
showUnsubscribed = true/*!SettingsUserStore.hideUnsubscribed()*/, showUnsubscribed = true/*!SettingsUserStore.hideUnsubscribed()*/,
disabled = rl.settings.get('sieveAllowFileintoInbox') ? '' : 'INBOX',
foldersWalk = folders => { foldersWalk = folders => {
folders.forEach(oItem => { folders.forEach(oItem => {
if (showUnsubscribed || oItem.hasSubscriptions() || !oItem.exists) { if (showUnsubscribed || oItem.hasSubscriptions() || !oItem.exists) {
@ -39,7 +37,7 @@ const
id: oItem.fullName, id: oItem.fullName,
name: sDeepPrefix.repeat(oItem.deep) + oItem.detailedName(), name: sDeepPrefix.repeat(oItem.deep) + oItem.detailedName(),
system: false, 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 ? decodeURIComponent(data[2]) : null;
data = data ? JSON.parse(data) : {}; data = data ? JSON.parse(data) : {};
win[sName] = { win[sName] = {
getItem: key => data[key] == null ? null : data[key], getItem: key => data[key] ?? null,
setItem: (key, value) => { setItem: (key, value) => {
data[key] = ''+value; // forces the value to a string data[key] = ''+value; // forces the value to a string
document.cookie = sName+'='+encodeURIComponent(JSON.stringify(data)) 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) { if (!iError) {
DomainAdminStore( DomainAdminStore(
data.Result.map(item => { data.Result.map(item => {
item.name = IDN.toUnicode(item.name);
item.disabled = ko.observable(item.disabled); item.disabled = ko.observable(item.disabled);
item.askDelete = ko.observable(false); item.askDelete = ko.observable(false);
return item; return item;

View file

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

View file

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

View file

@ -9,25 +9,17 @@ import Remote from 'Remote/User/Fetch';
import { showScreenPopup } from 'Knoin/Knoin'; import { showScreenPopup } from 'Knoin/Knoin';
import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey'; import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey';
import { AskPopupView } from 'View/Popup/Ask';
import { Passphrases } from 'Storage/Passphrases'; import { Passphrases } from 'Storage/Passphrases';
const import { baseCollator } from 'Common/Translator';
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;
},
const
findGnuPGKey = (keys, query/*, sign*/) => findGnuPGKey = (keys, query/*, sign*/) =>
keys.find(key => keys.find(key =>
// key[sign ? 'can_sign' : 'can_decrypt'] // key[sign ? 'can_sign' : 'can_decrypt']
(key.can_sign || key.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 { export const GnuPGUserStore = new class {
@ -45,7 +37,8 @@ export const GnuPGUserStore = new class {
this.keyring = null; this.keyring = null;
this.publicKeys([]); this.publicKeys([]);
this.privateKeys([]); this.privateKeys([]);
Remote.request('GnupgGetKeys', SettingsCapa('GnuPG')
&& Remote.request('GnupgGetKeys',
(iError, oData) => { (iError, oData) => {
if (oData?.Result) { if (oData?.Result) {
this.keyring = oData.Result; this.keyring = oData.Result;
@ -55,6 +48,7 @@ export const GnuPGUserStore = new class {
key.fingerprint = key.subkeys[0].fingerprint; key.fingerprint = key.subkeys[0].fingerprint;
key.uids.forEach(uid => uid.email && aEmails.push(uid.email)); key.uids.forEach(uid => uid.email && aEmails.push(uid.email));
key.emails = aEmails; key.emails = aEmails;
key.for = email => aEmails.includes(IDN.toASCII(email));
key.askDelete = ko.observable(false); key.askDelete = ko.observable(false);
key.openForDeletion = ko.observable(null).askDeleteHelper(); key.openForDeletion = ko.observable(null).askDeleteHelper();
key.remove = () => { key.remove = () => {
@ -77,33 +71,49 @@ export const GnuPGUserStore = new class {
); );
} }
}; };
key.view = () => { if (isPrivate) {
const fetch = pass => Remote.request('GnupgExportKey', key.password = async btnTxt => {
(iError, oData) => { const pass = await Passphrases.ask(key,
if (oData?.Result) { 'GnuPG key<br>' + key.id + ' ' + key.emails[0],
key.armor = oData.Result; btnTxt
showScreenPopup(OpenPgpKeyPopupView, [key]);
} else {
Passphrases.delete(key.id);
}
}, {
keyId: key.id,
isPrivate: isPrivate,
passphrase: pass
}
); );
if (isPrivate) { pass && pass.remember && Passphrases.handle(key, pass.password);
askPassphrase(key, 'POPUP_VIEW_TITLE').then(passphrase => { return pass?.password;
(null !== passphrase) && fetch(passphrase); };
}); }
key.fetch = async callback => {
if (key.armor) {
callback && callback();
} else { } 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; return key;
}; },
this.publicKeys(oData.Result.public.map(key => initKey(key, 0))); collator = baseCollator(),
this.privateKeys(oData.Result.private.map(key => initKey(key, 1))); 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'); console.log('gnupg ready');
} }
} }
@ -117,19 +127,6 @@ export const GnuPGUserStore = new class {
return SettingsCapa('GnuPG'); 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.privateKey
keyPair.publicKey keyPair.publicKey
@ -155,7 +152,7 @@ export const GnuPGUserStore = new class {
const count = recipients.length, const count = recipients.length,
length = count ? recipients.filter(email => length = count ? recipients.filter(email =>
// (key.can_verify || key.can_encrypt) && // (key.can_verify || key.can_encrypt) &&
this.publicKeys.find(key => key.emails.includes(email)) this.publicKeys.find(key => key.for(email))
).length : 0; ).length : 0;
return length && length === count; return length && length === count;
} }
@ -163,13 +160,13 @@ export const GnuPGUserStore = new class {
getPublicKeyFingerprints(recipients) { getPublicKeyFingerprints(recipients) {
const fingerprints = []; const fingerprints = [];
recipients.forEach(email => { 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; return fingerprints;
} }
getPrivateKeyFor(query, sign) { getPrivateKeyFor(query/*, sign*/) {
return findGnuPGKey(this.privateKeys, query, sign); return findGnuPGKey(this.privateKeys, query/*, sign*/);
} }
async decrypt(message) { async decrypt(message) {
@ -191,22 +188,27 @@ export const GnuPGUserStore = new class {
uid: message.uid, uid: message.uid,
partId: pgpInfo.partId, partId: pgpInfo.partId,
keyId: key.id, keyId: key.id,
passphrase: await askPassphrase(key, 'BUTTON_DECRYPT'), passphrase: await key.password('CRYPTO/DECRYPT'),
data: '' // message.plain() optional data: '' // message.plain() optional
} }
if (null !== params.passphrase) { if (null != params.passphrase) {
const result = await Remote.post('GnupgDecrypt', null, params); try {
if (result?.Result && false !== result.Result.data) { const response = await Remote.post('GnupgDecrypt', null, params);
return result.Result; if (response?.Result?.data) {
return response.Result;
}
throw response;
} catch (e) {
Passphrases.delete(key);
throw e;
} }
Passphrases.delete(key.id);
} }
} }
} }
} }
async verify(message) { 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) { if (data) {
data = { ...data }; // clone data = { ...data }; // clone
// const sender = message.from[0].email; // const sender = message.from[0].email;
@ -217,18 +219,19 @@ export const GnuPGUserStore = new class {
data.bodyPart = data.bodyPart.raw; data.bodyPart = data.bodyPart.raw;
data.sigPart = data.sigPart.body; data.sigPart = data.sigPart.body;
} }
let response = await Remote.post('MessagePgpVerify', null, data); let response = await Remote.post('PgpVerifyMessage', null, data);
if (response?.Result) { if (response?.Result) {
return { return {
fingerprint: response.Result.fingerprint, fingerprint: response.Result.fingerprint,
success: 0 == response.Result.status // GOODSIG success: 0 == response.Result.status, // GOODSIG
error: response.Result.message
}; };
} }
} }
} }
async sign(privateKey) { 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 { SUB_QUERY_PREFIX } from 'Common/Links';
import { AppUserStore } from 'Stores/User/App'; import { AppUserStore } from 'Stores/User/App';
import { baseCollator } from 'Common/Translator';
const const
isChecked = item => item.checked(), isChecked = item => item.checked(),
replaceHash = hash => { replaceHash = hash => {
@ -254,17 +256,14 @@ MessagelistUserStore.reload = (bDropPagePosition = false, bDropCurrentFolderCach
let flags = folderInfo.permanentFlags || []; let flags = folderInfo.permanentFlags || [];
if (flags.includes('\\*')) { if (flags.includes('\\*')) {
/** Add Thunderbird labels */
let i = 6; let i = 6;
while (--i) { while (--i) {
flags.includes('$label'+i) || flags.push('$label'+i); flags.includes('$label'+i) || flags.push('$label'+i);
} }
/** TODO: add others by default? */
} }
flags.sort((a, b) => { folder.permanentFlags(flags.sort(baseCollator().compare));
a = a.toUpperCase();
b = b.toUpperCase();
return (a < b) ? -1 : ((a > b) ? 1 : 0);
});
folder.permanentFlags(flags);
MessagelistUserStore.notifyNewMessages(folder.fullName, collection.newMessages); MessagelistUserStore.notifyNewMessages(folder.fullName, collection.newMessages);
} }
@ -314,6 +313,7 @@ MessagelistUserStore.reload = (bDropPagePosition = false, bDropCurrentFolderCach
if (AppUserStore.threadsAllowed() && SettingsUserStore.useThreads()) { if (AppUserStore.threadsAllowed() && SettingsUserStore.useThreads()) {
params.useThreads = 1; params.useThreads = 1;
params.threadAlgorithm = SettingsUserStore.threadAlgorithm();
params.threadUid = MessagelistUserStore.threadUid(); params.threadUid = MessagelistUserStore.threadUid();
} else { } else {
params.threadUid = 0; params.threadUid = 0;

View file

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

View file

@ -8,14 +8,16 @@ import { SettingsCapa, SettingsGet } from 'Common/Globals';
import { GnuPGUserStore } from 'Stores/User/GnuPG'; import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP'; import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import { MailvelopeUserStore } from 'Stores/User/Mailvelope';
// https://mailvelope.github.io/mailvelope/Keyring.html import Remote from 'Remote/User/Fetch';
let mailvelopeKeyring = null;
export const export const
BEGIN_PGP_MESSAGE = '-----BEGIN PGP MESSAGE-----', BEGIN_PGP_MESSAGE = '-----BEGIN PGP MESSAGE-----',
// BEGIN_PGP_SIGNATURE = '-----BEGIN PGP SIGNATURE-----', // BEGIN_PGP_SIGNATURE = '-----BEGIN PGP SIGNATURE-----',
// BEGIN_PGP_SIGNED = '-----BEGIN PGP SIGNED MESSAGE-----', // 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 { PgpUserStore = new class {
init() { init() {
@ -33,32 +35,9 @@ export const
} }
loadKeyrings(identifier) { loadKeyrings(identifier) {
identifier = identifier || SettingsGet('Email'); MailvelopeUserStore.loadKeyring(identifier);
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));
}
OpenPGPUserStore.loadKeyrings(); OpenPGPUserStore.loadKeyrings();
GnuPGUserStore.loadKeyrings();
if (SettingsCapa('GnuPG')) {
GnuPGUserStore.loadKeyrings();
}
} }
/** /**
@ -75,21 +54,28 @@ export const
return 0 === text.trim().indexOf(BEGIN_PGP_MESSAGE); return 0 === text.trim().indexOf(BEGIN_PGP_MESSAGE);
} }
async mailvelopeHasPublicKeyForEmails(recipients) { importKey(key, gnuPG, backup) {
const if (gnuPG || backup) {
mailvelope = mailvelopeKeyring && await mailvelopeKeyring.validKeyForAddress(recipients) Remote.request('PgpImportKey',
/*.then(LookupResult => Object.entries(LookupResult))*/, (iError, oData) => {
entries = mailvelope && Object.entries(mailvelope); if (gnuPG && oData?.Result/* && (oData.Result.imported || oData.Result.secretimported)*/) {
return entries && entries.filter(value => value[1]).length === recipients.length; GnuPGUserStore.loadKeyrings();
}
iError && alert(oData.ErrorMessage);
}, {
key, gnuPG, backup
}
);
}
OpenPGPUserStore.importKey(key);
} }
/** /**
* Checks if verifying/encrypting a message is possible with given email addresses. * Checks if verifying/encrypting a message is possible with given email addresses.
* Returns the first library that can. * Returns the first library that can.
*/ */
async hasPublicKeyForEmails(recipients) { hasPublicKeyForEmails(recipients) {
const count = recipients.length; if (recipients.length) {
if (count) {
if (GnuPGUserStore.hasPublicKeyForEmails(recipients)) { if (GnuPGUserStore.hasPublicKeyForEmails(recipients)) {
return 'gnupg'; return 'gnupg';
} }
@ -100,40 +86,15 @@ export const
return false; 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) { async decrypt(message) {
const sender = message.from[0].email, const armoredText = message.plain();
armoredText = message.plain();
if (!this.isEncrypted(armoredText)) { if (!this.isEncrypted(armoredText)) {
throw Error('Not armored text'); throw Error('Not armored text');
} }
// Try OpenPGP.js // Try OpenPGP.js
if (OpenPGPUserStore.isSupported()) { if (OpenPGPUserStore.isSupported()) {
const sender = message.from[0].email;
let result = await OpenPGPUserStore.decrypt(armoredText, sender); let result = await OpenPGPUserStore.decrypt(armoredText, sender);
if (result) { if (result) {
return result; return result;
@ -141,68 +102,20 @@ export const
} }
// Try Mailvelope (does not support inline images) // Try Mailvelope (does not support inline images)
if (mailvelopeKeyring) { return (await MailvelopeUserStore.decrypt(message))
try { // Or try GnuPG
let emails = [...message.from,...message.to,...message.cc].validUnique(), || GnuPGUserStore.decrypt(message);
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);
} }
async verify(message) { async verify(message) {
const signed = message.pgpSigned(); const signed = message.pgpSigned(),
sender = message.from[0].email;
if (signed) { if (signed) {
const sender = message.from[0].email, // OpenPGP only when inline, else we must download the whole message
gnupg = GnuPGUserStore.hasPublicKeyForEmails([sender]), if (!signed.sigPartId && OpenPGPUserStore.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) {
return OpenPGPUserStore.verify(message); return OpenPGPUserStore.verify(message);
} }
if (gnupg) { if (GnuPGUserStore.hasPublicKeyForEmails([sender])) {
return GnuPGUserStore.verify(message); return GnuPGUserStore.verify(message);
} }
// Mailvelope can't // Mailvelope can't
@ -210,29 +123,23 @@ export const
} }
} }
/** getPublicKeyOfEmails(recipients) {
* Returns headers that should be added to an outgoing email. if (recipients.length) {
* So far this is only the autocrypt header. let result = {};
*/ recipients.forEach(email => {
/* OpenPGPUserStore.publicKeys().forEach(key => {
mailvelopeKeyring.additionalHeadersForOutgoingEmail(headers) if (key.for(email)) {
mailvelopeKeyring.addSyncHandler(syncHandlerObj) result[email] = key.armor;
mailvelopeKeyring.createKeyBackupContainer(selector, options) }
mailvelopeKeyring.createKeyGenContainer(selector, { });
// userIds: [], GnuPGUserStore.publicKeys.map(async key => {
keySize: 4096 if (!result[email] && key.for(email)) {
}) result[email] = await key.fetch();
}
mailvelopeKeyring.exportOwnPublicKey(emailAddr).then(<AsciiArmored, Error>) });
mailvelopeKeyring.importPublicKey(armored) });
return result;
// https://mailvelope.github.io/mailvelope/global.html#SyncHandlerObject }
mailvelopeKeyring.addSyncHandler({ return false;
uploadSync }
downloadSync
backup
restore
});
*/
}; };

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, showNextMessage: 0,
allowDraftAutosave: 1, allowDraftAutosave: 1,
useThreads: 0, useThreads: 0,
threadAlgorithm: '',
replySameFolder: 0, replySameFolder: 0,
hideUnsubscribed: 0, hideUnsubscribed: 0,
hideDeleted: 1, hideDeleted: 1,
@ -47,6 +48,7 @@ export const SettingsUserStore = new class {
layout: 1, layout: 1,
editorDefaultType: 'Html', editorDefaultType: 'Html',
editorWysiwyg: 'Squire',
msgDefaultAction: 1 msgDefaultAction: 1
}); });
@ -78,9 +80,10 @@ export const SettingsUserStore = new class {
init() { init() {
const self = this; const self = this;
self.editorDefaultType(SettingsGet('EditorDefaultType'));
[ [
'EditorDefaultType',
'editorWysiwyg',
'messageNewWindow', 'messageNewWindow',
'messageReadAuto', 'messageReadAuto',
'MsgDefaultAction', 'MsgDefaultAction',
@ -97,7 +100,8 @@ export const SettingsUserStore = new class {
'listGrouped', 'listGrouped',
'showNextMessage', 'showNextMessage',
'AllowDraftAutosave', 'AllowDraftAutosave',
'UseThreads', 'useThreads',
'threadAlgorithm',
'ReplySameFolder', 'ReplySameFolder',
'HideUnsubscribed', 'HideUnsubscribed',
'HideDeleted', 'HideDeleted',
@ -109,6 +113,39 @@ export const SettingsUserStore = new class {
'pgpSign', 'pgpSign',
'pgpEncrypt', 'pgpEncrypt',
'allowSpellcheck' '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 => { ].forEach(name => {
let value = SettingsGet(name); let value = SettingsGet(name);
name = name[0].toLowerCase() + name.slice(1); name = name[0].toLowerCase() + name.slice(1);

View file

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

View file

@ -17,6 +17,15 @@
width: 100%; 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 { .b-folders {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -56,6 +65,7 @@
overflow: hidden; overflow: hidden;
overflow-y: auto; overflow-y: auto;
min-width: 100px; min-width: 100px;
color: var(--folders-color, #333);
} }
hr { hr {
@ -68,22 +78,18 @@
white-space: nowrap; white-space: nowrap;
a { a {
display: block;
position: relative;
z-index: 1;
line-height: 34px;
background-color: transparent; background-color: transparent;
vertical-align: middle;
color: var(--folders-disabled-color, #666);
border-left: 3px solid transparent; border-left: 3px solid transparent;
color: var(--folders-color, #333);
display: block;
line-height: 34px;
padding: 0 2em 0 @folderItemPadding; padding: 0 2em 0 @folderItemPadding;
position: relative;
text-decoration: none; text-decoration: none;
vertical-align: middle;
z-index: 1;
&.selectable { &.selectable {
color: var(--folders-color, #333);
cursor: pointer; cursor: pointer;
&:hover { &:hover {
@ -106,6 +112,7 @@
} }
} }
&:not(.selectable) { &:not(.selectable) {
color: var(--folders-disabled-color, #666);
cursor: default; cursor: default;
font-style: italic; font-style: italic;
} }
@ -185,27 +192,6 @@
li li li li li a { li li li li li a {
text-indent: 4em; 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 { #rl-left .buttonCompose {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -32,14 +32,12 @@ export class AdvancedSearchPopupView extends AbstractViewPopup {
// Almost the same as MessageModel.tagOptions // Almost the same as MessageModel.tagOptions
keywords: () => { keywords: () => {
const keywords = [{value:'',label:''}]; const keywords = [{value:'',label:''}];
FolderUserStore.currentFolder().permanentFlags.forEach(value => { FolderUserStore.currentFolder().optionalTags().forEach(value => {
if (isAllowedKeyword(value)) { let lower = value.toLowerCase();
let lower = value.toLowerCase(); keywords.push({
keywords.push({ value: value,
value: value, label: i18n('MESSAGE_TAGS/'+lower, 0, lower)
label: i18n('MESSAGE_TAGS/'+lower, 0, lower) });
});
}
}); });
return keywords 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'; } from 'Common/EnumsUser';
import { pInt, isArray, arrayLength, b64Encode } from 'Common/Utils'; 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 { koArrayWithDestroy, addObservablesTo, addComputablesTo, addSubscribablesTo } from 'External/ko';
import { UNUSED_OPTION_VALUE } from 'Common/Consts'; import { UNUSED_OPTION_VALUE } from 'Common/Consts';
@ -27,16 +28,24 @@ import { SettingsUserStore } from 'Stores/User/Settings';
import { IdentityUserStore } from 'Stores/User/Identity'; import { IdentityUserStore } from 'Stores/User/Identity';
import { AccountUserStore } from 'Stores/User/Account'; import { AccountUserStore } from 'Stores/User/Account';
import { FolderUserStore } from 'Stores/User/Folder'; import { FolderUserStore } from 'Stores/User/Folder';
import { PgpUserStore } from 'Stores/User/Pgp'; import { PgpUserStore } from 'Stores/User/Pgp';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP'; import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import { GnuPGUserStore } from 'Stores/User/GnuPG'; 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 { MessageUserStore } from 'Stores/User/Message';
import { MessagelistUserStore } from 'Stores/User/Messagelist'; import { MessagelistUserStore } from 'Stores/User/Messagelist';
import Remote from 'Remote/User/Fetch'; import Remote from 'Remote/User/Fetch';
import { ComposeAttachmentModel } from 'Model/ComposeAttachment'; 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 { decorateKoCommands, showScreenPopup } from 'Knoin/Knoin';
import { AbstractViewPopup } from 'Knoin/AbstractViews'; import { AbstractViewPopup } from 'Knoin/AbstractViews';
@ -44,6 +53,7 @@ import { AbstractViewPopup } from 'Knoin/AbstractViews';
import { FolderSystemPopupView } from 'View/Popup/FolderSystem'; import { FolderSystemPopupView } from 'View/Popup/FolderSystem';
import { AskPopupView } from 'View/Popup/Ask'; import { AskPopupView } from 'View/Popup/Ask';
import { ContactsPopupView } from 'View/Popup/Contacts'; import { ContactsPopupView } from 'View/Popup/Contacts';
/* /*
import { ThemeStore } from 'Stores/Theme'; import { ThemeStore } from 'Stores/Theme';
@ -56,7 +66,7 @@ const
tpl = createElement('template'), 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, getEmail = value => addressparser(value)[0]?.email || false,
@ -243,11 +253,8 @@ export class ComposePopupView extends AbstractViewPopup {
showBcc: false, showBcc: false,
showReplyTo: false, showReplyTo: false,
pgpSign: false, doSign: false,
canPgpSign: false, doEncrypt: false,
pgpEncrypt: false,
canPgpEncrypt: false,
canMailvelope: false,
draftsFolder: '', draftsFolder: '',
draftUid: 0, draftUid: 0,
@ -271,6 +278,8 @@ export class ComposePopupView extends AbstractViewPopup {
}); });
this.attachments = koArrayWithDestroy(); this.attachments = koArrayWithDestroy();
this.encryptOptions = koArrayWithDestroy();
this.signOptions = koArrayWithDestroy();
this.dragAndDropOver = ko.observable(false).extend({ debounce: 1 }); this.dragAndDropOver = ko.observable(false).extend({ debounce: 1 });
this.dragAndDropVisible = 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, attachmentsInProcessCount: () => this.attachmentsInProcess.length,
isDraft: () => this.draftsFolder() && this.draftUid(), 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: () => identitiesOptions: () =>
IdentityUserStore.map(item => ({ IdentityUserStore.map(item => ({
item: item, item: item,
@ -340,32 +356,31 @@ export class ComposePopupView extends AbstractViewPopup {
sendSuccessButSaveError: value => !value && this.savedErrorDesc(''), 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 => { from: () => {
this.canPgpSign(false); this.initSign();
value = getEmail(value); this.initEncrypt();
value && PgpUserStore.getKeyForSigning(value).then(result => {
console.log({
email: value,
canPgpSign:result
});
this.canPgpSign(result)
});
}, },
cc: value => { cc: value => {
if (false === this.showCc() && value.length) { if (false === this.showCc() && value.length) {
this.showCc(true); this.showCc(true);
} }
this.initPgpEncrypt(); this.initEncrypt();
}, },
bcc: value => { bcc: value => {
if (false === this.showBcc() && value.length) { if (false === this.showBcc() && value.length) {
this.showBcc(true); this.showBcc(true);
} }
this.initPgpEncrypt(); this.initEncrypt();
}, },
replyTo: value => { replyTo: value => {
@ -384,7 +399,7 @@ export class ComposePopupView extends AbstractViewPopup {
if (this.emptyToError() && value.length) { if (this.emptyToError() && value.length) {
this.emptyToError(false); this.emptyToError(false);
} }
this.initPgpEncrypt(); this.initEncrypt();
}, },
attachmentsInProcess: value => { attachmentsInProcess: value => {
@ -412,7 +427,7 @@ export class ComposePopupView extends AbstractViewPopup {
case 'K': quota *= 1024; case 'K': quota *= 1024;
} }
// Issue: can't select signing key // 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, { mailvelope.createEditorContainer('#mailvelope-editor', PgpUserStore.mailvelopeKeyring, {
// https://mailvelope.github.io/mailvelope/global.html#EditorContainerOptions // https://mailvelope.github.io/mailvelope/global.html#EditorContainerOptions
quota: Math.max(2048, (quota / 1024)) - 48, // (text + attachments) limit in kilobytes 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 quotedMailHeader: '', // header to be added before the quoted mail
keepAttachments: false, // add attachments of quotedMail to editor (default: false) keepAttachments: false, // add attachments of quotedMail to editor (default: false)
// Issue: can't select signing key // Issue: can't select signing key
signMsg: this.pgpSign() signMsg: this.doSign()
*/ */
}).then(editor => this.mailvelope = editor); }).then(editor => this.mailvelope = editor);
} }
@ -443,7 +458,8 @@ export class ComposePopupView extends AbstractViewPopup {
} }
sendCommand() { sendCommand() {
let sSentFolder = FolderUserStore.sentFolder(); const identity = this.currentIdentity();
let sSentFolder = identity?.sentFolder?.() || FolderUserStore.sentFolder();
this.attachmentsInProcessError(false); this.attachmentsInProcessError(false);
this.attachmentsInErrorError(false); this.attachmentsInErrorError(false);
@ -465,8 +481,7 @@ export class ComposePopupView extends AbstractViewPopup {
if (SettingsUserStore.replySameFolder()) { if (SettingsUserStore.replySameFolder()) {
if ( if (
3 === arrayLength(this.aDraftInfo) && 3 === arrayLength(this.aDraftInfo) &&
null != this.aDraftInfo[2] && this.aDraftInfo[2]?.length
this.aDraftInfo[2].length
) { ) {
sSentFolder = this.aDraftInfo[2]; sSentFolder = this.aDraftInfo[2];
} }
@ -474,63 +489,91 @@ export class ComposePopupView extends AbstractViewPopup {
if (!sSentFolder) { if (!sSentFolder) {
showScreenPopup(FolderSystemPopupView, [FolderType.Sent]); showScreenPopup(FolderSystemPopupView, [FolderType.Sent]);
} else try { } else {
this.sendError(false); const sendError = e => {
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 => {
console.error(e); console.error(e);
this.sendError(true); this.sendError(true);
this.sendErrorDesc(e); this.sendErrorDesc(e);
this.sending(false); this.sending(false);
}); };
} catch (e) { const sendFailed = (iError, data) => {
console.error(e); this.sendError(true);
this.sendError(true); this.sendErrorDesc(
this.sendErrorDesc(e); getNotification(iError, data?.ErrorMessage, Notifications.CantSendMessage)
this.sending(false); + "\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(() => { // setTimeout(() => {
this.oEditor = new HtmlEditor( this.oEditor = new HtmlEditor(
this.editorArea(), this.editorArea(),
null,
() => fOnInit(this.oEditor), () => fOnInit(this.oEditor),
bHtml => this.isHtml(!!bHtml) bHtml => this.isHtml(!!bHtml)
); );
@ -853,7 +895,10 @@ export class ComposePopupView extends AbstractViewPopup {
switch (options.mode) { switch (options.mode) {
case ComposeType.Reply: 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) { if (ComposeType.Reply === options.mode) {
this.to(emailArrayToStringLineHelper(oLastMessage.replyEmails(excludeEmail))); this.to(emailArrayToStringLineHelper(oLastMessage.replyEmails(excludeEmail)));
} else { } else {
@ -866,9 +911,15 @@ export class ComposePopupView extends AbstractViewPopup {
this.aDraftInfo = ['reply', oLastMessage.uid, oLastMessage.folder]; this.aDraftInfo = ['reply', oLastMessage.uid, oLastMessage.folder];
this.sInReplyTo = oLastMessage.messageId; this.sInReplyTo = oLastMessage.messageId;
this.sReferences = (oLastMessage.references + ' ' + oLastMessage.messageId).trim(); this.sReferences = (oLastMessage.references + ' ' + oLastMessage.messageId).trim();
// OpenPGP “Transferable Public Key” oLastMessage.headers().valuesByName('autocrypt').forEach(value => {
// oLastMessage.autocrypt?.keydata let autocrypt = new MimeHeaderAutocryptModel(value);
break; 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.Forward:
case ComposeType.ForwardAsAttachment: case ComposeType.ForwardAsAttachment:
@ -1190,7 +1241,7 @@ export class ComposePopupView extends AbstractViewPopup {
const attachment = new ComposeAttachmentModel( const attachment = new ComposeAttachmentModel(
message.requestHash, message.requestHash,
message.subject() /*+ '-' + Jua.randomId()*/ + '.eml', message.subject() /*+ '-' + Jua.randomId()*/ + '.eml',
message.size() message.size
); );
attachment.fromMessage = true; attachment.fromMessage = true;
attachment.complete(true); attachment.complete(true);
@ -1301,8 +1352,8 @@ export class ComposePopupView extends AbstractViewPopup {
this.showBcc(false); this.showBcc(false);
this.showReplyTo(false); this.showReplyTo(false);
this.pgpSign(SettingsUserStore.pgpSign()); this.doSign(SettingsUserStore.pgpSign());
this.pgpEncrypt(SettingsUserStore.pgpEncrypt()); this.doEncrypt(SettingsUserStore.pgpEncrypt());
this.attachments([]); this.attachments([]);
@ -1338,28 +1389,55 @@ export class ComposePopupView extends AbstractViewPopup {
].join(',').split(',').map(value => getEmail(value.trim())).validUnique(); ].join(',').split(',').map(value => getEmail(value.trim())).validUnique();
} }
initPgpEncrypt() { /**
const recipients = this.allRecipients(); * Checks if signing a message is possible with from email address.
PgpUserStore.hasPublicKeyForEmails(recipients).then(result => { * And sets all that can.
console.log({canPgpEncrypt:result}); */
this.canPgpEncrypt(result); initSign() {
}); let options = [],
PgpUserStore.mailvelopeHasPublicKeyForEmails(recipients).then(result => { identity = this.currentIdentity(),
console.log({canMailvelope:result}); email = getEmail(this.from()),
this.canMailvelope(result); key = OpenPGPUserStore.getPrivateKeyFor(email, 1);
if (!result) { 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(); 'mailvelope' === this.viewArea() && this.bodyArea();
// this.dropMailvelope(); // this.dropMailvelope();
} }
}); }
}
togglePgpSign() { console.dir({encryptOptions:options});
this.pgpSign(!this.pgpSign()/* && this.canPgpSign()*/); this.encryptOptions(options);
}
togglePgpEncrypt() {
this.pgpEncrypt(!this.pgpEncrypt()/* && this.canPgpEncrypt()*/);
} }
async getMessageRequestParams(sSaveFolder, draft) async getMessageRequestParams(sSaveFolder, draft)
@ -1410,11 +1488,18 @@ export class ComposePopupView extends AbstractViewPopup {
// Only used at send, not at save: // Only used at send, not at save:
dsn: this.requestDsn() ? 1 : 0, dsn: this.requestDsn() ? 1 : 0,
requireTLS: this.requireTLS() ? 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(), recipients = draft ? [identity.email()] : this.allRecipients(),
sign = !draft && this.pgpSign() && this.canPgpSign(), signOptions = !draft && this.doSign() && this.signOptions(),
encrypt = this.pgpEncrypt() && this.canPgpEncrypt(), encryptOptions = this.doEncrypt() && this.encryptOptions(),
isHtml = this.oEditor.isHtml(); isHtml = this.oEditor.isHtml();
if (isHtml) { if (isHtml) {
@ -1436,7 +1521,12 @@ export class ComposePopupView extends AbstractViewPopup {
params.encrypted = draft params.encrypted = draft
? await this.mailvelope.createDraft() ? await this.mailvelope.createDraft()
: await this.mailvelope.encrypt(recipients); : 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) { if (!draft && !hasAttachments && !Text.length) {
throw i18n('COMPOSE/ERROR_EMPTY_BODY'); throw i18n('COMPOSE/ERROR_EMPTY_BODY');
} }
@ -1456,45 +1546,108 @@ export class ComposePopupView extends AbstractViewPopup {
alternative.children.push(data); alternative.children.push(data);
data = alternative; data = alternative;
} }
if (!draft && sign?.[1]) { let sign = true;
if ('openpgp' == sign[0]) { for (let i = 0; i < signOptions.length; ++i) {
// Doesn't sign attachments if ('OpenPGP' == signOptions[i][0]) {
params.html = params.plain = ''; try {
let signed = new MimePart; // Doesn't sign attachments
signed.headers['Content-Type'] = params.html = params.plain = '';
'multipart/signed; micalg="pgp-sha256"; protocol="application/pgp-signature"'; let signed = new MimePart;
signed.headers['Content-Transfer-Encoding'] = '7Bit'; signed.headers['Content-Type'] =
signed.children.push(data); 'multipart/signed; micalg="pgp-sha256"; protocol="application/pgp-signature"';
let signature = new MimePart; signed.headers['Content-Transfer-Encoding'] = '7Bit';
signature.headers['Content-Type'] = 'application/pgp-signature; name="signature.asc"'; signed.children.push(data);
signature.headers['Content-Transfer-Encoding'] = '7Bit'; let signature = new MimePart;
signature.body = await OpenPGPUserStore.sign(data.toString(), sign[1], 1); signature.headers['Content-Type'] = 'application/pgp-signature; name="signature.asc"';
signed.children.push(signature); signature.headers['Content-Transfer-Encoding'] = '7Bit';
params.signed = signed.toString(); signature.body = await OpenPGPUserStore.sign(data.toString(), signOptions[i][1], 1);
params.boundary = signed.boundary; signed.children.push(signature);
data = signed; params.signed = signed.toString();
} else if ('gnupg' == sign[0]) { 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 // TODO: sign in PHP fails
// params.signData = data.toString(); let pass = await GnuPGUserStore.sign(signOptions[i][1]);
params.signFingerprint = sign[1].fingerprint; if (null != pass) {
params.signPassphrase = await GnuPGUserStore.sign(sign[1]); // params.signData = data.toString();
} else { params.signFingerprint = signOptions[i][1].fingerprint;
throw 'Signing with ' + sign[0] + ' not yet implemented'; 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 (signOptions.length && !sign) {
if ('openpgp' == encrypt) { throw 'Signing failed';
// Doesn't encrypt attachments }
params.encrypted = await OpenPGPUserStore.encrypt(data.toString(), recipients);
params.signed = ''; if (encryptOptions.length) {
} else if ('gnupg' == encrypt) { const autocrypt = () =>
// Does encrypt attachments Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) =>
params.encryptFingerprints = JSON.stringify(GnuPGUserStore.getPublicKeyFingerprints(recipients)); params.autocrypt.push({
} else { addr: k,
throw 'Encryption with ' + encrypt + ' not yet implemented'; 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; return params;
} }
} }

View file

@ -62,7 +62,7 @@ export class ContactsPopupView extends AbstractViewPopup {
this.selector.on('ItemSelect', contact => this.populateViewContact(contact)); 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, { addComputablesTo(this, {
contactsPaginator: computedPaginatorHelper( contactsPaginator: computedPaginatorHelper(

View file

@ -26,6 +26,7 @@ const
imapType: 0, imapType: 0,
imapTimeout: 300, imapTimeout: 300,
imapShortLogin: false, imapShortLogin: false,
imapLowerLogin: true,
// SSL // SSL
imapSslVerify_peer: false, imapSslVerify_peer: false,
imapSslAllow_self_signed: false, imapSslAllow_self_signed: false,
@ -44,12 +45,14 @@ const
sievePort: 4190, sievePort: 4190,
sieveType: 0, sieveType: 0,
sieveTimeout: 10, sieveTimeout: 10,
sieveAuthLiteral: true,
smtpHost: '', smtpHost: '',
smtpPort: 25, smtpPort: 25,
smtpType: 0, smtpType: 0,
smtpTimeout: 60, smtpTimeout: 60,
smtpShortLogin: false, smtpShortLogin: false,
smtpLowerLogin: true,
smtpUseAuth: true, smtpUseAuth: true,
smtpSetSender: false, smtpSetSender: false,
smtpAuthPlainLine: false, smtpAuthPlainLine: false,
@ -69,6 +72,7 @@ const
secure: pInt(oDomain.imapType()), secure: pInt(oDomain.imapType()),
timeout: oDomain.imapTimeout, timeout: oDomain.imapTimeout,
shortLogin: !!oDomain.imapShortLogin(), shortLogin: !!oDomain.imapShortLogin(),
lowerLogin: !!oDomain.imapLowerLogin(),
ssl: { ssl: {
verify_peer: !!oDomain.imapSslVerify_peer(), verify_peer: !!oDomain.imapSslVerify_peer(),
verify_peer_name: !!oDomain.imapSslVerify_peer(), verify_peer_name: !!oDomain.imapSslVerify_peer(),
@ -92,6 +96,7 @@ const
secure: pInt(oDomain.smtpType()), secure: pInt(oDomain.smtpType()),
timeout: oDomain.smtpTimeout, timeout: oDomain.smtpTimeout,
shortLogin: !!oDomain.smtpShortLogin(), shortLogin: !!oDomain.smtpShortLogin(),
lowerLogin: !!oDomain.smtpLowerLogin(),
ssl: { ssl: {
verify_peer: !!oDomain.smtpSslVerify_peer(), verify_peer: !!oDomain.smtpSslVerify_peer(),
verify_peer_name: !!oDomain.smtpSslVerify_peer(), verify_peer_name: !!oDomain.smtpSslVerify_peer(),
@ -104,11 +109,13 @@ const
}, },
Sieve: { Sieve: {
enabled: !!oDomain.sieveEnabled(), enabled: !!oDomain.sieveEnabled(),
authLiteral: !!oDomain.sieveAuthLiteral(),
host: oDomain.sieveHost, host: oDomain.sieveHost,
port: oDomain.sievePort, port: oDomain.sievePort,
secure: pInt(oDomain.sieveType()), secure: pInt(oDomain.sieveType()),
timeout: oDomain.sieveTimeout, timeout: oDomain.sieveTimeout,
shortLogin: !!oDomain.imapShortLogin(), shortLogin: !!oDomain.imapShortLogin(),
lowerLogin: !!oDomain.imapLowerLogin(),
ssl: { ssl: {
verify_peer: !!oDomain.imapSslVerify_peer(), verify_peer: !!oDomain.imapSslVerify_peer(),
verify_peer_name: !!oDomain.imapSslVerify_peer(), verify_peer_name: !!oDomain.imapSslVerify_peer(),
@ -306,21 +313,43 @@ export class DomainPopupView extends AbstractViewPopup {
this.testingSmtpError(false); 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) { onShow(oDomain) {
this.saving(false); this.saving(false);
this.clearTesting(); this.clearTesting();
this.edit(false); this.edit(false);
this.imapCapabilities([ this.imapCapabilities([
'BINARY',
'LIST-STATUS', 'LIST-STATUS',
'METADATA', 'METADATA',
'MOVE', 'MOVE',
'NAMESPACE',
'PREVIEW',
'SORT', 'SORT',
'THREAD',
'BINARY',
'STATUS=SIZE', 'STATUS=SIZE',
'PREVIEW' 'THREAD'
]); ]);
this.imapDisabled_capabilities(['PREVIEW','STATUS=SIZE']); this.imapDisabled_capabilities(['METADATA','PREVIEW','STATUS=SIZE']);
forEachObjectEntry(domainDefaults, (key, value) => this[key](value)); forEachObjectEntry(domainDefaults, (key, value) => this[key](value));
this.enableSmartPorts(true); this.enableSmartPorts(true);
if (oDomain) { if (oDomain) {
@ -343,6 +372,8 @@ export class DomainPopupView extends AbstractViewPopup {
this[key]?.(value); 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.imapCapabilities(this.imapCapabilities.concat(this.imapDisabled_capabilities()).unique());
this.enableSmartPorts(true); 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 { Notifications } from 'Common/Enums';
import { defaultOptionsAfterRender } from 'Common/Utils'; import { defaultOptionsAfterRender } from 'Common/Utils';
import { folderListOptionsBuilder, sortFolders } from 'Common/Folders'; import { folderListOptionsBuilder, sortFolders } from 'Common/Folders';
import { getNotification } from 'Common/Translator'; import { getNotification/*, baseCollator*/ } from 'Common/Translator';
import { FolderUserStore } from 'Stores/User/Folder'; import { FolderUserStore } from 'Stores/User/Folder';
@ -56,7 +56,7 @@ export class FolderCreatePopupView extends AbstractViewPopup {
folders.push(subFolder); folders.push(subFolder);
sortFolders(folders); 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)); 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 { getNotification } from 'Common/Translator';
import { loadAccountsAndIdentities } from 'Common/UtilsUser';
import Remote from 'Remote/User/Fetch'; import Remote from 'Remote/User/Fetch';
@ -9,29 +10,21 @@ import { AbstractViewPopup } from 'Knoin/AbstractViews';
import { IdentityModel } from 'Model/Identity'; 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 { export class IdentityPopupView extends AbstractViewPopup {
constructor() { constructor() {
super('Identity'); super('Identity');
addObservablesTo(this, { addObservablesTo(this, {
id: '', identity: null,
edit: false, edit: false,
labelFocused: false,
email: '',
emailFocused: false,
name: '',
nameFocused: false, nameFocused: false,
replyTo: '',
showReplyTo: false,
bcc: '',
showBcc: false,
signature: '',
signatureInsertBefore: false,
submitRequest: false, submitRequest: false,
submitError: '' submitError: ''
}); });
@ -40,21 +33,79 @@ export class IdentityPopupView extends AbstractViewPopup {
this.replyTo.valueHasMutated(); this.replyTo.valueHasMutated();
this.bcc.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) { submitForm(form) {
if (!this.submitRequest() && form.reportValidity()) { if (!this.submitRequest() && form.reportValidity()) {
this.signature?.__fetchEditorValue?.(); let identity = this.identity();
identity.signature?.__fetchEditorValue?.();
this.submitRequest(true); this.submitRequest(true);
const data = new FormData(form); const data = new FormData(form);
data.set('Id', this.id()); data.set('Id', identity.id());
data.set('Signature', this.signature()); data.set('Signature', identity.signature());
Remote.request('IdentityUpdate', iError => { Remote.request('IdentityUpdate', iError => {
this.submitRequest(false); this.submitRequest(false);
if (iError) { if (iError) {
this.submitError(getNotification(iError)); this.submitError(getNotification(iError));
} else { } else {
rl.app.accountsAndIdentities(); loadAccountsAndIdentities();
this.close(); this.close();
} }
}, data }, data
@ -66,12 +117,8 @@ export class IdentityPopupView extends AbstractViewPopup {
* @param {?IdentityModel} oIdentity * @param {?IdentityModel} oIdentity
*/ */
onShow(identity) { onShow(identity) {
this.showBcc(false);
this.showReplyTo(false);
this.submitRequest(false); this.submitRequest(false);
this.submitError(''); this.submitError('');
if (identity) { if (identity) {
this.edit(true); this.edit(true);
} else { } else {
@ -79,18 +126,10 @@ export class IdentityPopupView extends AbstractViewPopup {
identity = new IdentityModel; identity = new IdentityModel;
identity.id(Jua.randomId()); identity.id(Jua.randomId());
} }
this.id(identity.id() || ''); this.identity(identity);
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());
} }
afterShow() { 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(), const type = this.keyType().toLowerCase(),
userId = { userId = {
name: this.name(), name: this.name(),
email: this.email() email: IDN.toASCII(this.email())
}, },
cfg = { cfg = {
type: type, type: type,

View file

@ -1,20 +1,25 @@
import { addObservablesTo } from 'External/ko'; import { addObservablesTo } from 'External/ko';
import { GnuPGUserStore } from 'Stores/User/GnuPG'; import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP'; import { PgpUserStore } from 'Stores/User/Pgp';
import { AbstractViewPopup } from 'Knoin/AbstractViews'; import { AbstractViewPopup } from 'Knoin/AbstractViews';
import Remote from 'Remote/User/Fetch';
import { i18n } from 'Common/Translator';
export class OpenPgpImportPopupView extends AbstractViewPopup { export class OpenPgpImportPopupView extends AbstractViewPopup {
constructor() { constructor() {
super('OpenPgpImport'); super('OpenPgpImport');
addObservablesTo(this, { addObservablesTo(this, {
search: '',
key: '', key: '',
keyError: false, keyError: false,
keyErrorMessage: '', keyErrorMessage: '',
saveGnuPG: true, saveGnuPG: true,
saveServer: false saveServer: true
}); });
this.canGnuPG = GnuPGUserStore.isSupported(); 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() { submitForm() {
let keyTrimmed = this.key().trim(); let keyTrimmed = this.key().trim();
@ -35,38 +78,30 @@ export class OpenPgpImportPopupView extends AbstractViewPopup {
this.keyError(!keyTrimmed); this.keyError(!keyTrimmed);
this.keyErrorMessage(''); this.keyErrorMessage('');
if (!keyTrimmed) { if (keyTrimmed) {
return; let match = null,
} count = 30,
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;
done = false; done = false;
} else { const GnuPG = this.saveGnuPG() && GnuPGUserStore.isSupported(),
done = true; backup = this.saveServer(),
} // eslint-disable-next-line max-len
} while (!done); 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()) { do {
return; 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) { 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(''); this.submitError('');
} }
toggleSignMe() {
this.signMe(!this.signMe());
}
submitCommand(self, event) { submitCommand(self, event) {
const email = this.email().trim(); const email = this.email().trim();
this.email(email); this.email(email);
@ -124,7 +120,7 @@ export class LoginUserView extends AbstractViewLogin {
iError = Notifications.AuthError; iError = Notifications.AuthError;
} }
this.submitError(getNotification(iError, oData?.ErrorMessage, this.submitError(getNotification(iError, oData?.ErrorMessage,
Notifications.UnknownNotification)); Notifications.UnknownError));
this.submitErrorAdditional(oData?.ErrorMessageAdditional); this.submitErrorAdditional(oData?.ErrorMessageAdditional);
} else { } else {
rl.setData(oData.Result); rl.setData(oData.Result);
@ -142,25 +138,20 @@ export class LoginUserView extends AbstractViewLogin {
onBuild(dom) { onBuild(dom) {
super.onBuild(dom); super.onBuild(dom);
const signMe = (SettingsGet('signMe') || '').toLowerCase(); let signMe = SettingsGet('signMe');
switch (signMe) { switch (signMe) {
case 'defaultoff': case SignMeOff:
case 'defaulton': case SignMeOn:
this.signMeType(
'defaulton' === signMe ? SignMeOn : SignMeOff
);
switch (Local.get(ClientSideKeyNameLastSignMe)) { switch (Local.get(ClientSideKeyNameLastSignMe)) {
case '-1-': case '-1-':
this.signMeType(SignMeOn); signMe = SignMeOn;
break; break;
case '-0-': case '-0-':
this.signMeType(SignMeOff); signMe = SignMeOff;
break; break;
// no default // no default
} }
this.signMeType(signMe);
break; break;
default: default:
this.signMeType(SignMeUnused); this.signMeType(SignMeUnused);

View file

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

View file

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

View file

@ -54,6 +54,9 @@ import { showScreenPopup } from 'Knoin/Knoin';
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport'; import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
import { GnuPGUserStore } from 'Stores/User/GnuPG'; import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP'; import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import { IdentityUserStore } from 'Stores/User/Identity';
import { Passphrases } from 'Storage/Passphrases';
const const
oMessageScrollerDom = () => elementById('messageItem') || {}, oMessageScrollerDom = () => elementById('messageItem') || {},
@ -109,7 +112,8 @@ export class MailMessageView extends AbstractViewRight {
actionsMenu: null, actionsMenu: null,
// viewer // viewer
viewFromShort: '', viewFromShort: '',
dkimData: ['none', '', ''] dkimData: ['none', '', ''],
nowTracking: false
}); });
this.moveAction = moveAction; this.moveAction = moveAction;
@ -142,7 +146,7 @@ export class MailMessageView extends AbstractViewRight {
downloadAsZipAllowed: () => this.attachmentsActions.includes('zip') downloadAsZipAllowed: () => this.attachmentsActions.includes('zip')
&& (currentMessage()?.attachments || []) && (currentMessage()?.attachments || [])
.filter(item => item?.download /*&& !item?.isLinked()*/ && item?.checked()) .filter(item => item?.checked() && item?.download /*&& !item?.isLinked()*/)
.length, .length,
tagsAllowed: () => FolderUserStore.currentFolder()?.tagsAllowed(), tagsAllowed: () => FolderUserStore.currentFolder()?.tagsAllowed(),
@ -155,10 +159,10 @@ export class MailMessageView extends AbstractViewRight {
: '' : ''
).join(' '), ).join(' '),
askReadReceipt: () => askReadReceipt: () => currentMessage()?.readReceipt
(MessagelistUserStore.isDraftFolder() || MessagelistUserStore.isSentFolder()) && !(MessagelistUserStore.isDraftFolder() || MessagelistUserStore.isSentFolder())
&& currentMessage()?.readReceipt() && !currentMessage()?.flags().includes('$mdnsent')
&& currentMessage()?.flags().includes('$mdnsent'), && !currentMessage()?.flags().includes('\\answered'),
listAttachments: () => currentMessage()?.attachments() listAttachments: () => currentMessage()?.attachments()
.filter(item => SettingsUserStore.listInlineAttachments() || !item.isLinked()), .filter(item => SettingsUserStore.listInlineAttachments() || !item.isLinked()),
@ -170,18 +174,17 @@ export class MailMessageView extends AbstractViewRight {
canBeRepliedOrForwarded: () => !MessagelistUserStore.isDraftFolder() && this.messageVisible(), canBeRepliedOrForwarded: () => !MessagelistUserStore.isDraftFolder() && this.messageVisible(),
viewDkimIcon: () => 'none' !== this.dkimData()[0], dkimIcon: () => {
dkimIconClass:() => {
switch (this.dkimData()[0]) { switch (this.dkimData()[0]) {
case 'none': case 'none':
return ''; return '';
case 'pass': case 'pass':
return 'icon-ok iconcolor-green'; // ✔️ return '✔';
default: default:
return 'icon-cross iconcolor-red'; // ✖ ❌ return '✖';
} }
}, },
dkimIconClass: () => 'pass' === this.dkimData()[0] ? 'iconcolor-green' : 'iconcolor-red',
dkimTitle:() => { dkimTitle:() => {
const dkim = this.dkimData(); const dkim = this.dkimData();
@ -208,6 +211,7 @@ export class MailMessageView extends AbstractViewRight {
// TODO: make first param a user setting #683 // TODO: make first param a user setting #683
this.viewFromShort(message.from.toString(false, true)); this.viewFromShort(message.from.toString(false, true));
this.dkimData(message.dkim[0] || ['none', '', '']); this.dkimData(message.dkim[0] || ['none', '', '']);
this.nowTracking(false);
} else { } else {
MessagelistUserStore.selectedMessage(null); MessagelistUserStore.selectedMessage(null);
@ -236,6 +240,7 @@ export class MailMessageView extends AbstractViewRight {
decorateKoCommands(this, { decorateKoCommands(this, {
editCommand: self => self.messageVisible(), editCommand: self => self.messageVisible(),
moveCommand: self => self.messageVisible(), moveCommand: self => self.messageVisible(),
copyCommand: self => self.messageVisible(),
goUpCommand: self => !self.messageListOrViewLoading(), goUpCommand: self => !self.messageListOrViewLoading(),
goDownCommand: self => !self.messageListOrViewLoading() goDownCommand: self => !self.messageListOrViewLoading()
}); });
@ -253,13 +258,21 @@ export class MailMessageView extends AbstractViewRight {
currentMessage() && showMessageComposer([ComposeType.Draft, currentMessage()]); currentMessage() && showMessageComposer([ComposeType.Draft, currentMessage()]);
} }
moveCommand(vm, event) { moveOrCopy(vm, event, mode) {
if (vm && event?.preventDefault) { if (vm && event?.preventDefault) {
stopEvent(event); stopEvent(event);
} }
this.actionsMenu().ddBtn.hide(); this.actionsMenu().ddBtn.hide();
AppUserStore.focusedState(ScopeFolderList); AppUserStore.focusedState(ScopeFolderList);
moveAction(true); moveAction(mode);
}
moveCommand(vm, event) {
this.moveOrCopy(vm, event, 1);
}
copyCommand(vm, event) {
this.moveOrCopy(vm, event, 2);
} }
setUnseen() { setUnseen() {
@ -500,6 +513,17 @@ export class MailMessageView extends AbstractViewRight {
currentMessage().showExternalImages(); 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) { whitelistText(txt) {
let value = (SettingsUserStore.viewImagesWhitelist().trim() + '\n' + txt).trim(); let value = (SettingsUserStore.viewImagesWhitelist().trim() + '\n' + txt).trim();
/* /*
@ -525,20 +549,19 @@ export class MailMessageView extends AbstractViewRight {
* @returns {void} * @returns {void}
*/ */
readReceipt() { readReceipt() {
let oMessage = currentMessage() let oMessage = currentMessage();
if (oMessage.readReceipt()) { if (oMessage.readReceipt) {
Remote.request('SendReadReceiptMessage', iError => { oMessage.flags.push('$mdnsent');
if (!iError) { Remote.request('SendReadReceiptMessage',
oMessage.flags.push('$mdnsent'); iError => iError && oMessage.flags.remove('$mdnsent'),
// oMessage.flags.valueHasMutated(); {
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() { pgpDecrypt() {
const oMessage = currentMessage(); const oMessage = currentMessage(),
data = oMessage.pgpEncrypted();
delete data.error;
PgpUserStore.decrypt(oMessage).then(result => { PgpUserStore.decrypt(oMessage).then(result => {
if (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 {
// TODO: translate // 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*/) { pgpVerify(/*self, event*/) {
const oMessage = currentMessage()/*, ctrl = event.target.closest('.openpgp-control')*/; const oMessage = currentMessage()/*, ctrl = event.target.closest('.openpgp-control')*/;
PgpUserStore.verify(oMessage).then(result => { PgpUserStore.verify(oMessage).then(result => {
if (result) { if (result) {
oMessage.pgpVerified(result); oMessage.pgpSigned(result);
} else { } else {
alert('Verification failed or no valid public key found'); alert('Verification failed or no valid public key found');
} }
/* /*
if (result?.success) { if (result?.success) {
i18n('OPENPGP/GOOD_SIGNATURE', { i18n('CRYPTO/GOOD_SIGNATURE', {
USER: validKey.user + ' (' + validKey.id + ')' USER: validKey.user + ' (' + validKey.id + ')'
}); });
message.getText() message.getText()
@ -597,7 +625,8 @@ export class MailMessageView extends AbstractViewRight {
? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ') ? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ')
: ''; : '';
i18n('OPENPGP/ERROR', { i18n('CRYPTO/ERROR', {
TYPE: 'OpenPGP',
ERROR: 'message' ERROR: 'message'
}) + (additional ? ' (' + additional + ')' : ''); }) + (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() { accountName() {
let email = AccountUserStore.email(), const email = AccountUserStore.email();
account = AccountUserStore.find(account => account.email == email); return AccountUserStore.find(account => account.email == email)?.label() || IDN.toUnicode(email);
return account?.name || email;
} }
settingsClick() { settingsClick() {

View file

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

View file

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

7
dev/prototype.js vendored
View file

@ -44,6 +44,13 @@
return template.content.firstChild; 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, * Every time the function is executed,
* it will delay the execution with the given amount of milliseconds. * it will delay the execution with the given amount of milliseconds.

View file

@ -0,0 +1,106 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
lQdGBGNyBMoBEAC+x1PNa+DHQA+CJ0pznECiL11cmTzfkas6ahPNTYqadnA0dab6
w81tQ6Q/QH92WRbQnqfGqtI+LoyHsga4Abspqj5rKTprC06f/Q5TCF4KS7gRmVUW
8KhTtlIWv9aA+0aTNcjT/OPr8W5l7fb/T/dPHo9Pp3929XjVtVlNhANPRT6h7/M7
PzrOVT0mwhQnbZYZndD+PpU4MgH/TuKwxh0cI+IrpGZKCNR5ZYeLe3C9R8qojzrz
KxWFaj3Tev6qDDbqIAOp8r+fSC5TZPVtuvcqEZDtr9FLDq2oEgpWY+qKV7HiVo8h
lV4on0syIGOCd+SUW9ZS7qcb8qOPynvVzur4DTamrOdOqNd3ZO9hHh241JdGOdzw
eb9ASnvbtM52DW8LrfzGrtTc6HkzFHiqluwAS9sz99bxdlmT9zcmVDxYoQrbU3ML
ImZjwZZZedca/dDyNLLjehrViC9hI8rK2js6ndBUy9bvvxLtzpJA3K/urAlMsvGU
G1CRpSjys4XQnEJftxH+RssCi8N3mc3dV4YRDLKAHu+DU7d3ytUW82bkIZuYz0t3
umt45h2dOdgxqam6I8u5t3Ry72vtkaQciaitTBBa2yWxaoi1ib2rCwofVddW65QK
kCDsjV8GeS+t5UMiB28/OOiWSlCWEwdXVew0IVXSZao4/R3r2h+mVl8pVQARAQAB
/gcDArr2sIgJEqz269dc2L7650YxaMd6U+DTHOZLXjEOXI8vzgR+Z4dkKVC/2a4s
qdjSLKDV/DkhTbvJun7DqRlwqwuNp/U3/+K31iF9B25xqSWdDy8+iX0hrar2Rt4E
U+pAkfu+hrC+t4c2f/20tIUSocNKXZ5D56iORqPMbnwDu6nuwdYmnVMHisrf+aKe
/F2WmzivQ2ZLSlFTVU6fQoqMDPZxPHttD7Pm4BVuEvF8KPiOiwtChQvvO09DYCK/
EDRJWn7dDP1CoPVge5LntVN8xbtTG2Fi0DrYrLqF1mH3RoBn61KUo1wUOz+zh5F6
0jHIjyscm+SIYPVTsnEVEBsTTsMhPj5vMEKD5qqbrD6cBSsJ25awt7FzovEI+5EK
nl7xZAXvC2NxS+TNFe4hpsfD/Q/UYyOfV5cqQ+6O9m7/Z38xDvgE/oeX6eHmMvJk
GfFAj51I0SNZq71QPqQALG1M5d5adw5WAPWHAgke2xB50dPm7prUIPUXF5zm2+8c
KE6oOq9gQyRVL1nXVBXyDpc3a4vXzz6VNvQlBNLXc7wrO+/HvUc2y9qnWm+IqAvd
JRZvvX4Es/85KUuhPH083XLFfkuSP3lVPaCEppdvDwVom7iggVU0aw8uhJ66qz8S
w6oBl9B/k3bD9VNEGtlo3bXfv4fyFDBwX8eorhcdLv8TnbalVecOHY4BIZHkWaCZ
8PqT+GjNhEEgQDxREY0bBrF+KYE+GtJe+2bwyFTQaioszeaaRmN2WMHZaLn+04nH
Iu8hpABejerOPPY2QGPGhhggQFS45MjOd/2aBGiAOG1Tyia8lxVReb0IGat0Ifm+
sNZBI26MfYuscsapfOp0obIgGL6CKL30IByiYTAFr24167d87wMt0J709S4o+G90
vdjsQZv5oHCL2t1F2n4Ib6P0ZicRn7bmO2pfwbQOCxNBMnLfK170QmUBGbBwJTse
9zaPOIPoBp1yQz6KC6wzyVLdACHPntEdaZA88SJJv3uwcK/WsiM+uRE38FJ3q4AX
WNBx1C+HvV3Ybocr6ebhDEj1H0GRRWs5M/I7309tc9waB2O+R8MsMUaCnFLDippo
igBg8ZNIw4rPuLvGRQqUb+VAtKpr2MX4r00heHFSB9joaBoyHEEZCLiAxzk83Jvc
fY8KeJGA07T50q1BseISTZuXdxLNM5r6aZcfk3/zuURs7oqKBSxH8VGfGQxocB1i
4rEAodCKjxVVy1L2YRj0kZMgcUgrbQKLzEcsEmkoehiiVfbc4MZRXLM+yvVRRAhi
PABALLqI0oxhbqya1rGo15kvHKzoJqcsudLHtjkqXlrfxgFtoeEuHPCq+0Nmsgnj
iuxW6WLSEVH4DPIci6nnMnQhS0357i3a8LAKteIX9K/0A2RgjMY7FGgYXnGj8JHM
C0nkYQ95lTb5tF0ZzTtQIf4+gG7gK5kG7PJCzbBQr9a8ll6CjpqbZmMwwUPEwCvv
W9U/Ds+Nf0AaLVoJdNNw/GLru4Ms90SjgITQ49IXlaX0ict5Vpavyu9I23DIlLo5
r+3JkgCFkYjB6PrLmjzMN/REJ/I6iG0iXUm9tGMJ5azgo2bJmn+VxI1jdvySNxSI
AWsAuZvLpL6qXKQKVYJLKoZACgPq9fyncvPePIUAhVJdAv/zjoJ31zrskEwbrazR
Mmf/3Rm7GGV3hSPqkYRfwTg2/Ef6c9VumevRoWdjxOIOaITueOxb4evzsBGqZKLr
BW05fILbmeCwL4EtjP/ykC8r+ndbqG04KBYGM9rWkznn0NTvbRQi0020GURlbW8g
PGRlbW9Ac25hcHB5bWFpbC5ldT6JAjUEEAEIACkFAmNyBMsGCwkHCAMCCRAxQImF
SPBulwQVCAoCAxYCAQIZAQIbAwIeAQAAQGsP/3iQz6K9Q0bDg0PNSBzP/6v9j5GG
A5k5piiudlIcZcfSDgGmFwVsknJnGjaPXp62/rnFIUm0oHEgkP36eyK15Cycd+qn
KWSlaeWMAcv70tKJDQjweQyyM9896lCXKkdM7SZNvBLfjh9YQdBIayb9QAbk+4Mf
hIQ+miybS4PUeoWkx2RR5cFEiOOMU2+HwgNbcbtzFRRuMpho7mI2E8lTuWWokKAx
ENeNMeuzcRW0FoYzrURvxBtbe0P6fr/673RVs8mdfxumUIyCI0OgaouII0EGqs6R
W+MeMVMmZ5fpFPXcwYzNQJ401VpjL1MQyAIRqTaIX7EERzp6tAtgdqDEJzHYwRtC
n/aAmtCnauNw9rmoakDueLIRZ9FaU4OhYHv6g30FEMA+hrTxLNYs696fbXoB2/4u
KkOtY8pC6D5nvihBkcU1UEd5w4O8HM1V4Vn3ZXAZBY5Y66v/xSrPorDFc4gSXmOq
NgZE6/YMFVmB95mb0WNaKT1Xu8ZFZILAMZseNYd1YrxYg/OdkOHcoO+4h9GFu+Rq
5C/1BbAqrMssf+st+rRolhJ4TcgtdbCZzm5BO2ljnWCqEa3dciTU4jdPrgWi3Vpf
OcX42cmK45H18XbN7r+WbCsEDGmOMK4daGF+417hivREWbsrrEPqxp9FvCci6y8N
iq40mUCT+eVfFHGRnQdGBGNyBMoBEADWxp5f4mhqm6GkUpJJ12fKHsUFb3QNr5Kb
UkDXXd+vqD7mMl/28mAd15BzAIeQD0kawN3/cEUbc+X1tqSZGbVlUGNy0B6NHsdd
OvqBQQ41bBFTlchk7cORit1KYdSyg0QKTzJ90QrMe7/xL+LoM8QrrJjta4tEqZ8p
MQFeqGotnPW4kEQCKA2L8z9AQNeErwTUTL0GZAz8sY3ozCRvzKOMq+MCOAZNc0da
xk6JCtNrySSCefiLsFWDzk1CzbdwjNCzENPmUf6bRISULHuXr3EFNWz5XB0UHn3O
QezmZ+WFGTG63Kd5rsHHO6mZI4Fh+B9H3weF1WkYA+RNbAmhTTjTHbsuZ+tQnAOU
SmArQKLFysVg3ieFQSLrl7o1CceNG63nscR9nmtrcte5l5UJRMqnGP/nMkl0DvMo
0DLb2RykytwFzvZBOW2q4Wn/rgsDSeKoB/NCTHWRL/hYKkk3Q3iys2cJ8XK2datP
NPWfB4pl+M8ik2QGU7y3NZVN+KOacX7/+oNACU0Azhw2+1thFfpOtfYsm5/zVHyS
MtEWb3d3BUmXb++JGDWkeRMLxFjmLNYcVvFljeqfRmLjid76FAKw6dPv4f7ipDLa
z+I+qvzJEIdWtek+E2tOoWdL7+T+mmlkw2S2+eutWxsA1RZvuKLaDGkFxm6rT7z6
4KdtDqcq0QARAQAB/gcDAizfuR2QYFd36+tujgnFtbXOqjPLHgZ9D+4PlTV9tfJy
srqR7/9951k2wkMMJ33NrntZ5iVsRgEBaBf7ZAABBrl+LFOARYsO/wxe+ilf4fNq
49e4hvE57CiV4YAcGyDu2/EOBmIPQcIbUUUJLylD2M8fAeMrO9L3IX6qF+Z5rHdV
7A+45ZNT743Yy6iQzFllNkBGOquu8PUUPQHTomgZl8gX+UETskb3JSihQiwyZZmG
RqaWwMikgKSYYtPBluyXQMBD48PRKxtMQOI3S+fFFP2IIKXar1q4RHZsBgwYrRlf
PfKLnICZLedYSBgL1NiymLlKsmQpt7PfDRQfyMeRzP0bYv2295sczKX0oyxfRGdF
fmtj4P83XiTfJvW3xbit3vTNVWzQQtXdYc5GPMc+CXeH/lzaefbuWRlxv4JLawnK
gGTmKHa9DaAZOQ9Cq8wbzitnobTeK8JDPRrFrZ+pksDxk2XJCrddR5kmYdXYGygQ
brcLeENVM2sJ9T38qMFJz0Oj7SaYH3l/ErYJiyQgPIpHETqefSj7ZZ/o0fnZP400
2cjGoHIVDH/D/kW0clfR/WtNavAzT84tUPBgoXVjMwInZngD39XhQKMeWAEpoOAX
yPdu0NisYo1jC+ILMpcnHuAAWhdaKNXoTbPR3PFiKDbdRIEN1+9J3SKA+nY9UeOn
klEnPCZ7/1LWRjgidTsDwHHyDuEmYe0luRl/YAvzyiiQLWJKUerAod0EW3lxTwQ+
ukMBMqzHjF/rpn/l5A84K4Z3YnAare0C97ldjUWjxYA2UKBYq6dB4R7Jif/6cHcx
ReqCyPqW9S4fbZG1BgwdhEigbo+DU0DmcITX9nW7r6U4ZwMGKsFPU+7ZIZmwfxPy
aRqqzKr9nw0/DbH2xLUN+tjs4QGpzXIUYxgShPt9SvfO0I79nW/6nVsKzQ3Vq2Id
SI8vLNH/lD0+C5bsmCwwCAtkkbHkeUUtyj/yve2bsCtzRFA5XlfibnpT9kPe6D4G
qdBI9Y9Xme3NUYW2Jw1kLbUfHhwHD/KoiRWb6znuGtkcWb7jx0YXNtoObTmJpoiq
uruTMk7tZRj8BqILOm1Hob6h24uyZBro4v5nQukii67Cuf7I99X0P4Dpfxc7Mmnk
ebZoRsMWHCgASir1eG4KJk0SekU4/aYJiZhK0hat0G5l4d4oi1s4ajzMAAbH97FB
fC220wsmZ3YbVDN8tAkukCuSW0kSYdYfXN1iHpqb9W6rodNyqMNqrSQSmZICqDZM
Mg+JI7YwcPsMwMr94iOaCy7W+jsAb6Eon7CMztsAMNKdDbCJxKF0fwxB8QfD4mVg
E4vT44zWnWCvB2XSHhMz0iJJRVlMFqbTdhMS8L26BLBzJfAX4OCUNJdpm6T1gVUR
X56v36FMQH+Iz96whc5psLVsjLiizZGLIJy2FBRTtFtGkdNJOyPaWLGUO2bDeWI7
Rd9CDiWZD6koVtZT2JB3mk7N4ASQ0KttqaMDBRoIqJlpdjqSKtJxSjPHaNBDjert
jCDE6OwE401/Mnh9AeT5+vr6OVwmk/wbZoC+X9jXzfuqMGqvo6ElLPbSKJpIWtnE
0aqhfjBL+nDIwaqNZAaHZd/sFmVkDh1MJLKgWdCEWWHdReXWKIlSRcNkXMGZ93NQ
kUB1sqp1qmfK56SS90iJAHGAOKbac15dTcW1J/YJz9ZXr4xgEZAuiFCUFMWAEVOo
sCBs1rP2pUOqsQLiLjNleQ2Grz52aPNuCIajQwdjl8K6Ly8EXHcp9qznB6AP5+59
YdwhmUiJAh8EGAEIABMFAmNyBMwJEDFAiYVI8G6XAhsMAADVEg/8D502NBHE0cnT
FuBJIbz1/lVc272ka0MMaknCHc8OV451HnIJ0foqNOqd1hEtUP1dk+8krgR+Izdo
322WlR7t7LAPHmh7As1iBNHB43+oas0nXcrnnAr7kSGB3a0AVxP62f4ogUIgZkEc
3gRz7QvzUvpVVQSd1SONfnjprEu5M22gMKZT93CkzsIYMumuCB7nlRJ6Fjy9zWem
TOCTDpnfQHgpWBu4Zo6EUKw4SxMhPsT6ngfdAAQtp9Eyxg6Mv0Oj4zYAhu7OMmGV
MWmouHTAluXEa/BjH+W2wa4dk2wZXBwZi/y1ZYVM4hFJcNk74XymsJbZAyX4aIAP
JeSzEyrYltJjBjMv/4DSF5ppznzpl7G4OrojqfngHxABwd5zl87lZ2Pde0XFZnK0
Ltw7K+pMYswqJlrH1/bKy1oYtrBFlvF2MdLfHJzClr3IbBB1qEaUxz1JMm1AXz+c
y90ESbjZTvf6a0PQmxoPMyBqXryyt0ZEcoccTz02X1VWfCGgpqf4oVdxhVwUq6oV
LV8pPd+oaAzEcUrQlvVMgSCJGpqzibYRNVAVWUwI3v23YzXip8v9hLabSY7B1PRw
eojXyE9btmOUxD4AoJ949b2zklg/faPqBgbHbUeoN6yvWysFcROAdnGA/wHLv/zI
YXYPCv6njzAv1+fTgemAZYyomQ1qhVg=
=a9os
-----END PGP PRIVATE KEY BLOCK-----

View file

@ -0,0 +1,50 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGNyBMoBEAC+x1PNa+DHQA+CJ0pznECiL11cmTzfkas6ahPNTYqadnA0dab6
w81tQ6Q/QH92WRbQnqfGqtI+LoyHsga4Abspqj5rKTprC06f/Q5TCF4KS7gRmVUW
8KhTtlIWv9aA+0aTNcjT/OPr8W5l7fb/T/dPHo9Pp3929XjVtVlNhANPRT6h7/M7
PzrOVT0mwhQnbZYZndD+PpU4MgH/TuKwxh0cI+IrpGZKCNR5ZYeLe3C9R8qojzrz
KxWFaj3Tev6qDDbqIAOp8r+fSC5TZPVtuvcqEZDtr9FLDq2oEgpWY+qKV7HiVo8h
lV4on0syIGOCd+SUW9ZS7qcb8qOPynvVzur4DTamrOdOqNd3ZO9hHh241JdGOdzw
eb9ASnvbtM52DW8LrfzGrtTc6HkzFHiqluwAS9sz99bxdlmT9zcmVDxYoQrbU3ML
ImZjwZZZedca/dDyNLLjehrViC9hI8rK2js6ndBUy9bvvxLtzpJA3K/urAlMsvGU
G1CRpSjys4XQnEJftxH+RssCi8N3mc3dV4YRDLKAHu+DU7d3ytUW82bkIZuYz0t3
umt45h2dOdgxqam6I8u5t3Ry72vtkaQciaitTBBa2yWxaoi1ib2rCwofVddW65QK
kCDsjV8GeS+t5UMiB28/OOiWSlCWEwdXVew0IVXSZao4/R3r2h+mVl8pVQARAQAB
tBlEZW1vIDxkZW1vQHNuYXBweW1haWwuZXU+iQI1BBABCAApBQJjcgTLBgsJBwgD
AgkQMUCJhUjwbpcEFQgKAgMWAgECGQECGwMCHgEAAEBrD/94kM+ivUNGw4NDzUgc
z/+r/Y+RhgOZOaYornZSHGXH0g4BphcFbJJyZxo2j16etv65xSFJtKBxIJD9+nsi
teQsnHfqpylkpWnljAHL+9LSiQ0I8HkMsjPfPepQlypHTO0mTbwS344fWEHQSGsm
/UAG5PuDH4SEPposm0uD1HqFpMdkUeXBRIjjjFNvh8IDW3G7cxUUbjKYaO5iNhPJ
U7llqJCgMRDXjTHrs3EVtBaGM61Eb8QbW3tD+n6/+u90VbPJnX8bplCMgiNDoGqL
iCNBBqrOkVvjHjFTJmeX6RT13MGMzUCeNNVaYy9TEMgCEak2iF+xBEc6erQLYHag
xCcx2MEbQp/2gJrQp2rjcPa5qGpA7niyEWfRWlODoWB7+oN9BRDAPoa08SzWLOve
n216Adv+LipDrWPKQug+Z74oQZHFNVBHecODvBzNVeFZ92VwGQWOWOur/8Uqz6Kw
xXOIEl5jqjYGROv2DBVZgfeZm9FjWik9V7vGRWSCwDGbHjWHdWK8WIPznZDh3KDv
uIfRhbvkauQv9QWwKqzLLH/rLfq0aJYSeE3ILXWwmc5uQTtpY51gqhGt3XIk1OI3
T64Fot1aXznF+NnJiuOR9fF2ze6/lmwrBAxpjjCuHWhhfuNe4Yr0RFm7K6xD6saf
RbwnIusvDYquNJlAk/nlXxRxkbkCDQRjcgTKARAA1saeX+JoapuhpFKSSddnyh7F
BW90Da+Sm1JA113fr6g+5jJf9vJgHdeQcwCHkA9JGsDd/3BFG3Pl9bakmRm1ZVBj
ctAejR7HXTr6gUEONWwRU5XIZO3DkYrdSmHUsoNECk8yfdEKzHu/8S/i6DPEK6yY
7WuLRKmfKTEBXqhqLZz1uJBEAigNi/M/QEDXhK8E1Ey9BmQM/LGN6Mwkb8yjjKvj
AjgGTXNHWsZOiQrTa8kkgnn4i7BVg85NQs23cIzQsxDT5lH+m0SElCx7l69xBTVs
+VwdFB59zkHs5mflhRkxutynea7BxzupmSOBYfgfR98HhdVpGAPkTWwJoU040x27
LmfrUJwDlEpgK0CixcrFYN4nhUEi65e6NQnHjRut57HEfZ5ra3LXuZeVCUTKpxj/
5zJJdA7zKNAy29kcpMrcBc72QTltquFp/64LA0niqAfzQkx1kS/4WCpJN0N4srNn
CfFytnWrTzT1nweKZfjPIpNkBlO8tzWVTfijmnF+//qDQAlNAM4cNvtbYRX6TrX2
LJuf81R8kjLRFm93dwVJl2/viRg1pHkTC8RY5izWHFbxZY3qn0Zi44ne+hQCsOnT
7+H+4qQy2s/iPqr8yRCHVrXpPhNrTqFnS+/k/pppZMNktvnrrVsbANUWb7ii2gxp
BcZuq0+8+uCnbQ6nKtEAEQEAAYkCHwQYAQgAEwUCY3IEzAkQMUCJhUjwbpcCGwwA
ANUSD/wPnTY0EcTRydMW4EkhvPX+VVzbvaRrQwxqScIdzw5XjnUecgnR+io06p3W
ES1Q/V2T7ySuBH4jN2jfbZaVHu3ssA8eaHsCzWIE0cHjf6hqzSddyuecCvuRIYHd
rQBXE/rZ/iiBQiBmQRzeBHPtC/NS+lVVBJ3VI41+eOmsS7kzbaAwplP3cKTOwhgy
6a4IHueVEnoWPL3NZ6ZM4JMOmd9AeClYG7hmjoRQrDhLEyE+xPqeB90ABC2n0TLG
Doy/Q6PjNgCG7s4yYZUxaai4dMCW5cRr8GMf5bbBrh2TbBlcHBmL/LVlhUziEUlw
2TvhfKawltkDJfhogA8l5LMTKtiW0mMGMy//gNIXmmnOfOmXsbg6uiOp+eAfEAHB
3nOXzuVnY917RcVmcrQu3Dsr6kxizComWsfX9srLWhi2sEWW8XYx0t8cnMKWvchs
EHWoRpTHPUkybUBfP5zL3QRJuNlO9/prQ9CbGg8zIGpevLK3RkRyhxxPPTZfVVZ8
IaCmp/ihV3GFXBSrqhUtXyk936hoDMRxStCW9UyBIIkamrOJthE1UBVZTAje/bdj
NeKny/2EtptJjsHU9HB6iNfIT1u2Y5TEPgCgn3j1vbOSWD99o+oGBsdtR6g3rK9b
KwVxE4B2cYD/Acu//Mhhdg8K/qePMC/X59OB6YBljKiZDWqFWA==
=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