mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-07-08 22:18:28 +03:00
commit
92bfa7bd75
1300 changed files with 67163 additions and 24559 deletions
|
|
@ -3,7 +3,7 @@ module.exports = {
|
|||
// extends: ['eslint:recommended', 'plugin:prettier/recommended'],
|
||||
extends: ['eslint:recommended'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 6,
|
||||
ecmaVersion: 11,
|
||||
sourceType: 'module'
|
||||
},
|
||||
env: {
|
||||
|
|
@ -35,7 +35,9 @@ module.exports = {
|
|||
// vendors/bootstrap/bootstrap.native.js
|
||||
'BSN': "readonly",
|
||||
// Mailvelope
|
||||
'mailvelope': "readonly"
|
||||
'mailvelope': "readonly",
|
||||
// Punycode
|
||||
'IDN': "readonly"
|
||||
},
|
||||
// http://eslint.org/docs/rules/
|
||||
rules: {
|
||||
|
|
|
|||
396
CHANGELOG.md
396
CHANGELOG.md
|
|
@ -1,3 +1,399 @@
|
|||
## 2.36.2 – 2024-05-26
|
||||
|
||||
### Added
|
||||
- "copy to" action in menu's for
|
||||
[#1559](https://github.com/the-djmaze/snappymail/issues/1559)
|
||||
- Log signal info for
|
||||
[#1569](https://github.com/the-djmaze/snappymail/issues/1569)
|
||||
- OpenPGP.js automatically import backup keys from server
|
||||
|
||||
### Changed
|
||||
- Improved "remember me" cookie handling
|
||||
- Update Basque by @Thadah
|
||||
- Update Portuguese by @ner00
|
||||
|
||||
### Fixed
|
||||
- "Account already exists"
|
||||
[#1561](https://github.com/the-djmaze/snappymail/issues/1561)
|
||||
- Properly escape path separator in tar.php file list regex by @sevmonster
|
||||
[#1562](https://github.com/the-djmaze/snappymail/pull/1562)
|
||||
- Prevent mkdir() error
|
||||
[#1565](https://github.com/the-djmaze/snappymail/issues/1565)
|
||||
- SCRAM Exception when trying to log in to SMTP
|
||||
[#1575](https://github.com/the-djmaze/snappymail/issues/1575)
|
||||
- Error when redirected back to instance after Gmail OAuth
|
||||
[#1580](https://github.com/the-djmaze/snappymail/issues/1580)
|
||||
- Uncaught TypeError: hasPublicKeyForEmails(...).then is not a function
|
||||
[#1589](https://github.com/the-djmaze/snappymail/issues/1589)
|
||||
- Undefined variable $sFilename
|
||||
- GPG/PGP exec() return false handling
|
||||
|
||||
### Nextcloud
|
||||
- OIDC login active again by @avinash-0007
|
||||
[#1572](https://github.com/the-djmaze/snappymail/pull/1572)
|
||||
|
||||
|
||||
## 2.36.1 – 2024-04-23
|
||||
|
||||
### Added
|
||||
- Autoconfig detect through DNS SRV (RFC 6186 & 8314) and disable MX
|
||||
- Have I Been Pwned class to check breached passwords and email addresses
|
||||
- Handle RFC 5987 in Content-Disposition header
|
||||
- Ignore text/x-amp-html
|
||||
- Show SMTP error to user
|
||||
[#1521](https://github.com/the-djmaze/snappymail/issues/1521)
|
||||
- OAuth2 for login using gmail (and others)
|
||||
|
||||
### Changed
|
||||
- logMask all AUTHENTICATE requests
|
||||
- ErrorTip use white-space: pre
|
||||
- Simplify LoginProcess handling
|
||||
- ES2020 everywhere (require Safari 13.1)
|
||||
- Modified Squire to be more in line with v2.2.8
|
||||
- CSS set min-width for .attachmentParent and .flagParent to line them up
|
||||
- cPanel use extension login-cpanel instead of login-remote
|
||||
- Improved login credentials handling
|
||||
- Speedup Knockout a bit
|
||||
- Update Belarusian by @spoooyders
|
||||
- Update Chinese by @mayswind
|
||||
- Update French by @hguilbert
|
||||
- Update Polish by @tinola
|
||||
- Update Portuguese by @ner00
|
||||
|
||||
### Fixed
|
||||
- Content encoding and type detection in JavaScript could fail due to case-sensitivity.
|
||||
- Extensions set logger failed
|
||||
- GnuPG check open_basedir and if shell_exec is disabled
|
||||
[#1385](https://github.com/the-djmaze/snappymail/issues/1385)
|
||||
[#1496](https://github.com/the-djmaze/snappymail/issues/1496)
|
||||
[#1555](https://github.com/the-djmaze/snappymail/issues/1555)
|
||||
- Hide pagination when search result has no messages
|
||||
- Prevent mbstring error before setup.php
|
||||
- Prevent MessagesPerPage Infinity
|
||||
[#1540](https://github.com/the-djmaze/snappymail/issues/1540)
|
||||
- Reseal CryptKey failed
|
||||
[#1543](https://github.com/the-djmaze/snappymail/issues/1543)
|
||||
|
||||
### Nextcloud
|
||||
- Add an occ command to set up the login settings
|
||||
[#1552](https://github.com/the-djmaze/snappymail/issues/1552)
|
||||
|
||||
|
||||
## 2.36.0 – 2024-03-18
|
||||
|
||||
### Added
|
||||
- Allow setting the supported THREAD algorithm
|
||||
- Icon to system folders
|
||||
- Remove remembered password after 15 minutes of inactivity
|
||||
[#1142](https://github.com/the-djmaze/snappymail/issues/1142)
|
||||
- Swap background and text color for unreadable text on dark background
|
||||
[#1486](https://github.com/the-djmaze/snappymail/issues/1486)
|
||||
- Generate TOTP code at ?Admin -> Security
|
||||
[#1501](https://github.com/the-djmaze/snappymail/issues/1501)
|
||||
- Button to change S/MIME private key passphrase
|
||||
[#1505](https://github.com/the-djmaze/snappymail/issues/1505)
|
||||
- Belarusian by @spoooyders
|
||||
[#1512](https://github.com/the-djmaze/snappymail/pull/1512)
|
||||
- Log some domain idn_to_ascii issues
|
||||
[#1513](https://github.com/the-djmaze/snappymail/pull/1513)
|
||||
|
||||
### Changed
|
||||
- On folder/mailbox rename, also rename all children instead of reloading all
|
||||
- Seal MainAccount CryptKey and on error ask old login passphrase to reseal key.
|
||||
- Moved cache drivers outside core into extensions
|
||||
- Sieve always allow fileinto INBOX
|
||||
[#1510](https://github.com/the-djmaze/snappymail/issues/1510)
|
||||
- Moved application.ini `sieve_auth_plain_initial` to per domain config
|
||||
- Languages use rfc5646, by using the shortest ISO 639 code by default
|
||||
- Update French by @hguilbert
|
||||
- Update Portuguese by @ner00
|
||||
|
||||
### Fixed
|
||||
- On folder/mailbox rename, the old fullName must be removed from cache
|
||||
- On folder/mailbox rename, the checkable option was not renamed
|
||||
- Sort accounts drag & drop
|
||||
- S/MIME encrypted and opaque signed not visible
|
||||
[#1450](https://github.com/the-djmaze/snappymail/issues/1450)
|
||||
- Wrong last UID of thread
|
||||
[#1507](https://github.com/the-djmaze/snappymail/issues/1507)
|
||||
- Creation of dynamic property SnappyMail\DAV\Client::$HTTP
|
||||
[#1509](https://github.com/the-djmaze/snappymail/issues/1509)
|
||||
- "Download as ZIP" fails for messages
|
||||
[#1514](https://github.com/the-djmaze/snappymail/issues/1514)
|
||||
- SMTP "Authentication failed" when IMAP uses `shortLogin` and SMTP not
|
||||
[#1517](https://github.com/the-djmaze/snappymail/issues/1517)
|
||||
|
||||
|
||||
## 2.35.4 – 2024-03-16
|
||||
|
||||
### Added
|
||||
- \SnappyMail\IDN::toAscii()
|
||||
|
||||
### Changed
|
||||
- OpenPGP.js to v5.11.1
|
||||
- punycode.js lowercase domain names
|
||||
- application.ini `login_lowercase` removed and now configurable per domain JSON `lowerLogin`
|
||||
- Update Portuguese by @ner00
|
||||
|
||||
### Fixed
|
||||
- Raise JS TypeEroor "toLowerCase" after update
|
||||
[#1491](https://github.com/the-djmaze/snappymail/issues/1491)
|
||||
- Call to undefined function shell_exec
|
||||
[#1496](https://github.com/the-djmaze/snappymail/issues/1496)
|
||||
- Download attachments as ZIP doesn't work for PGP encrypted mail
|
||||
[#1499](https://github.com/the-djmaze/snappymail/issues/1499)
|
||||
- Importing or downloading a PGP public key attachment from a PGP encrypted message doesn't work
|
||||
[#1500](https://github.com/the-djmaze/snappymail/issues/1500)
|
||||
- VCard PHP Notice: Undefined index: ENCODING
|
||||
|
||||
### Nextcloud
|
||||
- Changed stored password handling
|
||||
- Can't login from nextcloud with 2.35.3 bug Nextcloud
|
||||
[#1490](https://github.com/the-djmaze/snappymail/issues/1490)
|
||||
|
||||
|
||||
## 2.35.3 – 2024-03-12
|
||||
|
||||
### Added
|
||||
- GnuPG can be disabled
|
||||
- Missing strings for localization inside identity popup (Cryptography > S/MIME)
|
||||
[#1458](https://github.com/the-djmaze/snappymail/issues/1458)
|
||||
- Automatically verify PGP and S/MIME signed messages
|
||||
- TNEFDecoder for
|
||||
[#1012](https://github.com/the-djmaze/snappymail/discussions/1012)
|
||||
- RTF to HTML converter for
|
||||
[#1012](https://github.com/the-djmaze/snappymail/discussions/1012)
|
||||
- Polyfill for PHP ctype
|
||||
[#1250](https://github.com/the-djmaze/snappymail/issues/1250)
|
||||
|
||||
### Changed
|
||||
- `new Error()` to `Error()`
|
||||
- Reduce KnockoutJS footprint by removing unused code
|
||||
- CSS reposition rainloopErrorTip location
|
||||
- Improved error handling on PGP and S/MIME decrypt
|
||||
- Improved OpenPGP.js import keys
|
||||
- Use Identity S/MIME key and certificate from server instead of POST
|
||||
- application.ini `[webmail]language_admin` to `[admin_panel]language`
|
||||
- application.ini `[security]admin_panel_host` to `[admin_panel]host`
|
||||
- application.ini `[security]admin_panel_key` to `[admin_panel]key`
|
||||
- Drop deprecated Domain::SetConfig()
|
||||
- Internationalized domain names are now handled as punycode
|
||||
- Cacher->Get() can now return NULL
|
||||
- Update French by @hguilbert
|
||||
- Update Polish by @tinola
|
||||
- Update Portuguese by @ner00
|
||||
|
||||
### Fixed
|
||||
- Handling of Internationalized Domain Names in several areas
|
||||
- Decrypt error message
|
||||
- Stalwart ManageSieve Error 352 when getting Filters
|
||||
[#1455](https://github.com/the-djmaze/snappymail/issues/1455)
|
||||
- Nextcloud V25+ theme slightly broken
|
||||
[#1463](https://github.com/the-djmaze/snappymail/issues/1463)
|
||||
- PGP decryption fails with "Not armored text"
|
||||
[#1462](https://github.com/the-djmaze/snappymail/issues/1462)
|
||||
- AUTH_BASIC falling through as AUTH_BEARER; change AUTH_BEARER to a different value
|
||||
[#1461](https://github.com/the-djmaze/snappymail/issues/1461)
|
||||
- SetPassword expects \SnappyMail\SensitiveString
|
||||
- Crash on importing corrupt OpenPGP keys
|
||||
- Crash on old browsers instead of showing error
|
||||
- Ignore popups on logoutReload()
|
||||
[#1467](https://github.com/the-djmaze/snappymail/pull/1467)
|
||||
- Custom SASLMechanisms fail in IMAP when the connection is secure
|
||||
[#1484](https://github.com/the-djmaze/snappymail/pull/1484)
|
||||
|
||||
|
||||
## 2.35.2 – 2024-02-27
|
||||
|
||||
### Added
|
||||
- GnuPG error handling
|
||||
- Missing strings for localization inside identity popup (Cryptography > S/MIME)
|
||||
[#1458](https://github.com/the-djmaze/snappymail/issues/1458)
|
||||
|
||||
### Changed
|
||||
- Update Portuguese by @ner00
|
||||
|
||||
### Fixed
|
||||
- Drop support for gnupg PECL extension as it fails with "no passphrase" issues
|
||||
- Error 352 when getting Filters
|
||||
[#1455](https://github.com/the-djmaze/snappymail/issues/1455)
|
||||
|
||||
### Nextcloud
|
||||
- SetPassword(): Argument #1 must be of type SensitiveString, string given
|
||||
[#1456](https://github.com/the-djmaze/snappymail/issues/1456)
|
||||
|
||||
|
||||
## 2.35.1 – 2024-02-26
|
||||
|
||||
### Added
|
||||
- Search functionality in Admin -> Config
|
||||
- Cache S/MIME passphrases when "remember" is checked
|
||||
- Import S/MIME certificate popup
|
||||
- pre-verify S/MIME opaque signed messages so we have a body to view
|
||||
- Sort PGP keys and S/MIME certificates on email address
|
||||
- Optionally use existing private key to generate S/MIME certificate
|
||||
|
||||
### Changed
|
||||
- Better handling to detect which PGP or S/MIME sign/encrypt to use
|
||||
- Improved StorageType handling
|
||||
- Cleanup and improved Capa handling
|
||||
- OPEN_PGP should be OPENPGP as it is one word
|
||||
- Use get_debug_type() instead of gettype()
|
||||
- Require OpenSSL due to S/MIME
|
||||
- AbstractProvider::IsActive() is now an abstract method and must be defined in child class
|
||||
- Make better use of SnappyMail\SensitiveString
|
||||
- Update Polish translation by @tinola
|
||||
|
||||
### Fixed
|
||||
- Verify S/MIME signatures got broken allong the way while implementing this
|
||||
- Generate S/MIME self-signed certificate failed to keep existing private key
|
||||
- MIME parser RegExp didn't escape boundary which caused issues
|
||||
- TypeError: b64Encode(...).match(...) is null on saving compose draft
|
||||
- Fix timestampToString() for future dates
|
||||
|
||||
|
||||
## 2.35.0 – 2024-02-20
|
||||
|
||||
### Added
|
||||
- S/MIME support
|
||||
[#259](https://github.com/the-djmaze/snappymail/issues/259)
|
||||
|
||||
### Changed
|
||||
- Disable IMAP METADATA by default (hardly used)
|
||||
- Update Polish translation by @tinola
|
||||
- Rename CSS .openpgp-control to .crypto-control
|
||||
- Renamed some methods in PHP
|
||||
|
||||
### Fixed
|
||||
- When moving a folder/mailbox check for parent delimiter
|
||||
- Mask `passphrase` in the logs for PHP < 8.2
|
||||
- Added some missing translations
|
||||
- Sign messages using PGP
|
||||
- Check for CONDSTORE or QRESYNC to get the HIGHESTMODSEQ
|
||||
- Unable to login on certain IMAP server since 2.34.2
|
||||
[#1438](https://github.com/the-djmaze/snappymail/issues/1438)
|
||||
|
||||
### Nextcloud
|
||||
- Save as .eml
|
||||
[#1425](https://github.com/the-djmaze/snappymail/issues/1425)
|
||||
|
||||
|
||||
## 2.34.2 – 2024-02-14
|
||||
|
||||
### Fixed
|
||||
- Message was sent but not saved to sent items folder
|
||||
[#1432](https://github.com/the-djmaze/snappymail/issues/1432)
|
||||
- Login with scram failed
|
||||
[#1433](https://github.com/the-djmaze/snappymail/issues/1433)
|
||||
|
||||
|
||||
## 2.34.1 – 2024-02-13
|
||||
|
||||
### Added
|
||||
- Autocrypt support
|
||||
[#342](https://github.com/the-djmaze/snappymail/issues/342)
|
||||
- Load the mailboxes/folders of all namespaces (other users, global, shared, etc.)
|
||||
- Load keys from server into OpenPGP.js
|
||||
[#973](https://github.com/the-djmaze/snappymail/issues/973)
|
||||
- Import PGP Keys from remote key servers
|
||||
- Sort Inbox Folders with Unread Messages First
|
||||
[#1427](https://github.com/the-djmaze/snappymail/issues/1427)
|
||||
- Define JMAP FolderModel.myRights
|
||||
- Identity Management: add identity display name
|
||||
[#1405](https://github.com/the-djmaze/snappymail/issues/1405)
|
||||
- Identity Management: add per-identity "sent" folder
|
||||
[#1404](https://github.com/the-djmaze/snappymail/issues/1404)
|
||||
- Some support for JSON-LD / Structured Email
|
||||
[#1422](https://github.com/the-djmaze/snappymail/issues/1422)
|
||||
- Domain Autoconfig and Microsoft's autodiscover (and also as extension/plugin)
|
||||
- View MMS messages that are received via email
|
||||
[#1294](https://github.com/the-djmaze/snappymail/issues/1294)
|
||||
- Draft code for S/MIME
|
||||
[#259](https://github.com/the-djmaze/snappymail/issues/259)
|
||||
|
||||
### Changed
|
||||
- Many OpenPGP improvements
|
||||
[#89](https://github.com/the-djmaze/snappymail/issues/89)
|
||||
- Allow CSP connect-src CORS for keys.openpgp.org to directly fetch PGP keys
|
||||
- Improved handling of visible folders
|
||||
- KnockoutJS Replace some ko.exportSymbol('*') in favour of ko['*']
|
||||
- KnockoutJS use Symbol for isObservableArray()
|
||||
- Simplify generating folderListVisible
|
||||
- Drop the bSearchSecretWords param from logger
|
||||
- Transparent background for text
|
||||
[#1412](https://github.com/the-djmaze/snappymail/issues/1412)
|
||||
- Enable OpenPGP.js by default at install
|
||||
- Added folder edit popup for improved IMAP ACL Support
|
||||
[#157](https://github.com/the-djmaze/snappymail/issues/157)
|
||||
- Process all IMAP namespaces
|
||||
- Update Polish by @tinola
|
||||
- Update Portuguese by @ner00
|
||||
|
||||
### Fixed
|
||||
- Make time_zone a select list due to PEBKAC
|
||||
- Workaround Outlook generated double spacing
|
||||
[#1415](https://github.com/the-djmaze/snappymail/issues/1415)
|
||||
- HTML Parser is not picking up the full Unsubscribe URL in the attached text file
|
||||
[#1225](https://github.com/the-djmaze/snappymail/issues/1225)
|
||||
- Contacts - it auto "Select All", after entry delete
|
||||
[#1411](https://github.com/the-djmaze/snappymail/issues/1411)
|
||||
- Message header parsing issue
|
||||
[#1403](https://github.com/the-djmaze/snappymail/issues/1403)
|
||||
- apple-touch-icon should not be transparent
|
||||
[#1408](https://github.com/the-djmaze/snappymail/issues/1408)
|
||||
- Creation of dynamic property is deprecated
|
||||
[#1409](https://github.com/the-djmaze/snappymail/issues/1409)
|
||||
- Ask/send readReceipt was broken
|
||||
- OpenPGP public key can not be removed anymore after importing private key of subkey
|
||||
[#1384](https://github.com/the-djmaze/snappymail/issues/1384)
|
||||
- KnockoutJS failed to output text '0'
|
||||
- JavaScript friendlySize() failed on 0
|
||||
- Workaround Dovecot `PREAUTH [CAPABILITY (null)]` issue
|
||||
- Workaround disabled ACL could cause "Disconnected: Too many invalid IMAP commands"
|
||||
|
||||
### Nextcloud
|
||||
- Save multiple as .eml
|
||||
[#1425](https://github.com/the-djmaze/snappymail/issues/1425)
|
||||
- Disabled support for Nextcloud OpenID Connect
|
||||
[#1420](https://github.com/the-djmaze/snappymail/issues/1420)
|
||||
|
||||
|
||||
## 2.33.0 – 2024-01-22
|
||||
|
||||
### Added
|
||||
- Feature to use the SQLite AddressBook per login account instead of global (on by default).
|
||||
- Return all fetched messages headers in JSON.
|
||||
|
||||
### Changed
|
||||
- Docker hub use Alpine linux 3.18.5 and PHP 8.2
|
||||
- Some InvalidArgumentException to the better suited ValueError
|
||||
- Removed some unused KnockoutJS code
|
||||
- KnockoutJS drop unused rateLimit method
|
||||
- Cleanup some data-bind=""
|
||||
- Drop the disabled KnockoutJS twoWayBindings
|
||||
- Drop support for KnockoutJS _ko_property_writers and for two-way binding they must be observables
|
||||
- Login form use method="POST" to prevent uri exposure when javascript fails
|
||||
- Merge code to generate MIME PGP parts and MIME Plain parts
|
||||
- SMTP sendRequestWithCheck for future support of RFC's
|
||||
- Cleanup mime header handling
|
||||
|
||||
### Fixed
|
||||
- Sorting not supported since 2.32.0
|
||||
[#1373](https://github.com/the-djmaze/snappymail/issues/1373)
|
||||
- FILE_ON_SAVING_ERROR is not defined
|
||||
[#1379](https://github.com/the-djmaze/snappymail/issues/1379)
|
||||
- Saving EML files with same subject result in only saving latest email
|
||||
[#1381](https://github.com/the-djmaze/snappymail/issues/1381)
|
||||
- Some Sieve parser issues
|
||||
- Handling of RainLoop Sieve script
|
||||
- Sieve rfc5429 RejectCommand and ErejectCommand
|
||||
- KnockoutJS title:value was removed, use attr:{title:value}
|
||||
- dataBaseUpgrade() always runs on sqlite and pgsql
|
||||
- Message was sent but not saved to sent items folder
|
||||
[#1397](https://github.com/the-djmaze/snappymail/issues/1397)
|
||||
- DKIM `pass` detection sometimes failed
|
||||
|
||||
|
||||
## 2.32.0 – 2023-12-26
|
||||
|
||||
### Added
|
||||
|
|
|
|||
44
README.md
44
README.md
|
|
@ -49,7 +49,7 @@ This fork of RainLoop has the following changes:
|
|||
* Admin uses password_hash/password_verify
|
||||
* Auth failed attempts written to syslog
|
||||
* Added Fail2ban instructions
|
||||
* ES2018
|
||||
* ES2020
|
||||
* PHP 7.4+ required
|
||||
* PHP mbstring extension required
|
||||
* PHP replaced pclZip with PharData and ZipArchive
|
||||
|
|
@ -140,28 +140,28 @@ RainLoop 1.17 vs SnappyMail
|
|||
|
||||
|js/* |RainLoop |Snappy |
|
||||
|--------------- |--------: |--------: |
|
||||
|admin.js |2.170.153 | 80.370 |
|
||||
|app.js |4.207.787 | 408.429 |
|
||||
|boot.js | 868.735 | 4.142 |
|
||||
|libs.js | 658.812 | 193.230 |
|
||||
|sieve.js | 0 | 85.085 |
|
||||
|admin.js |2.170.153 | 83.494 |
|
||||
|app.js |4.207.787 | 437.079 |
|
||||
|boot.js | 868.735 | 4.147 |
|
||||
|libs.js | 658.812 | 193.716 |
|
||||
|sieve.js | 0 | 84.598 |
|
||||
|polyfills.js | 334.608 | 0 |
|
||||
|serviceworker.js | 0 | 285 |
|
||||
|TOTAL |8.240.095 | 771.541 |
|
||||
|TOTAL |8.240.095 | 803.319 |
|
||||
|
||||
|js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|
||||
|--------------- |--------: |--------: |------: |------: |--------: |--------: |
|
||||
|admin.min.js | 256.831 | 39.285 | 73.606 | 13.183 | 60.877 | 11.806 |
|
||||
|app.min.js | 515.367 | 186.270 |139.456 | 63.111 |110.485 | 54.218 |
|
||||
|boot.min.js | 84.659 | 2.084 | 26.998 | 1.202 | 23.643 | 1.003 |
|
||||
|libs.min.js | 584.772 | 93.401 |180.901 | 34.765 |155.182 | 31.194 |
|
||||
|sieve.min.js | 0 | 41.316 | 0 | 10.364 | 0 | 9.352 |
|
||||
|admin.min.js | 256.831 | 40.856 | 73.606 | 13.776 | 60.877 | 12.345 |
|
||||
|app.min.js | 515.367 | 197.782 |139.456 | 67.143 |110.485 | 57.251 |
|
||||
|boot.min.js | 84.659 | 2.087 | 26.998 | 1.204 | 23.643 | 1.002 |
|
||||
|libs.min.js | 584.772 | 92.365 |180.901 | 34.487 |155.182 | 30.830 |
|
||||
|sieve.min.js | 0 | 41.093 | 0 | 10.325 | 0 | 9.327 |
|
||||
|polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 |
|
||||
|TOTAL user |1.217.635 | 281.755 |358.761 | 99.078 |299.485 | 86.415 |
|
||||
|TOTAL user+sieve |1.217.635 | 323.071 |358.761 |109.442 |299.485 | 95.767 |
|
||||
|TOTAL admin | 959.099 | 134.770 |292.911 | 49.150 |249.877 | 44.003 |
|
||||
|TOTAL user |1.217.635 | 292.234 |358.761 |102.834 |299.485 | 89.083 |
|
||||
|TOTAL user+sieve |1.217.635 | 333.327 |358.761 |113.159 |299.485 | 98.410 |
|
||||
|TOTAL admin | 959.099 | 135.308 |292.911 | 49.467 |249.877 | 44.177 |
|
||||
|
||||
For a user it is around 69% smaller and faster than traditional RainLoop.
|
||||
For a user it is around 68% smaller and faster than traditional RainLoop.
|
||||
|
||||
### CSS changes
|
||||
|
||||
|
|
@ -188,12 +188,12 @@ For a user it is around 69% smaller and faster than traditional RainLoop.
|
|||
|
||||
|css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli |
|
||||
|------------ |-------: |------: |------: |------: |--------: |
|
||||
|app.css | 340.331 | 84.484 | 46.946 | 17.627 | 15.110 |
|
||||
|app.min.css | 274.947 | 67.910 | 39.647 | 15.541 | 13.547 |
|
||||
|app.css | 340.331 | 84.629 | 46.946 | 17.652 | 15.127 |
|
||||
|app.min.css | 274.947 | 67.995 | 39.647 | 15.562 | 13.568 |
|
||||
|boot.css | | 1.326 | | 664 | 545 |
|
||||
|boot.min.css | | 1.071 | | 590 | 474 |
|
||||
|admin.css | | 30.641 | | 7.028 | 6.111 |
|
||||
|admin.min.css | | 24.742 | | 6.352 | 5.599 |
|
||||
|admin.css | | 30.528 | | 7.001 | 6.088 |
|
||||
|admin.min.css | | 24.652 | | 6.325 | 5.574 |
|
||||
|
||||
### PGP
|
||||
RainLoop uses the old OpenPGP.js v2
|
||||
|
|
@ -207,7 +207,7 @@ See https://github.com/the-djmaze/openpgpjs for development
|
|||
|
||||
|OpenPGP |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|
||||
|--------------- |--------: |--------: |------: |-------: |--------: |--------: |
|
||||
|openpgp.min.js | 330.742 | 541.176 |102.388 | 168.266 | 84.241 | 138.278 |
|
||||
|openpgp.min.js | 330.742 | 546.309 |102.388 | 169.249 | 84.241 | 138.751 |
|
||||
|openpgp.worker | 1.499 | | 824 | | 695 | |
|
||||
|
||||
|
||||
|
|
@ -224,5 +224,3 @@ Still TODO:
|
|||
|ckeditor | ? | 520.035 | ? | 155.916 |
|
||||
|
||||
CKEditor including the 7 asset requests (css,language,plugins,icons) is 633.46 KB / 180.47 KB (gzip).
|
||||
|
||||
To use the old CKEditor, you must install the plugin.
|
||||
|
|
|
|||
|
|
@ -6,15 +6,15 @@ Currently due to the fast development only the latest version receives security
|
|||
|
||||
| Version | Supported |
|
||||
| -------- | --------- |
|
||||
| 2.13.x | ✔ |
|
||||
| < 2.13.0 | ❌ |
|
||||
| 2.30.x | ✔ |
|
||||
| < 2.30.0 | ❌ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security issues or vulnerabilities as an encrypted email to [security@snappymail.eu](mailto:security@snappymail.eu).
|
||||
Your report should be detailed enough with clear steps to reproduce and classify the found vulnerability.
|
||||
|
||||
You can find the PGP public key below and on the major public keyservers like [pgp.key-server.io](https://pgp.key-server.io).
|
||||
You can find the PGP public key below and on the major public keyservers like [keys.openpgp.org](https://keys.openpgp.org).
|
||||
```
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Comment: Type: 255-bit EdDSA
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import ko from 'ko';
|
|||
import { logoutLink } from 'Common/Links';
|
||||
import { i18nToNodes, initOnStartOrLangChange } from 'Common/Translator';
|
||||
|
||||
import { arePopupsVisible } from 'Knoin/Knoin';
|
||||
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
import { initThemes } from 'Stores/Theme';
|
||||
|
||||
|
|
@ -18,6 +20,7 @@ export class AbstractApp {
|
|||
}
|
||||
|
||||
logoutReload(url) {
|
||||
arePopupsVisible(false);
|
||||
url = url || logoutLink();
|
||||
if (location.href !== url) {
|
||||
setTimeout(() => location.href = url, 100);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'External/ko';
|
||||
|
||||
import { Settings, SettingsGet } from 'Common/Globals';
|
||||
import { SettingsGet, SettingsAdmin } from 'Common/Globals';
|
||||
import { initThemes } from 'Stores/Theme';
|
||||
|
||||
import Remote from 'Remote/Admin/Fetch';
|
||||
|
|
@ -11,6 +11,8 @@ import { LoginAdminScreen } from 'Screen/Admin/Login';
|
|||
import { startScreens } from 'Knoin/Knoin';
|
||||
import { AbstractApp } from 'App/Abstract';
|
||||
|
||||
import { AskPopupView } from 'View/Popup/Ask';
|
||||
|
||||
export class AdminApp extends AbstractApp {
|
||||
constructor() {
|
||||
super(Remote);
|
||||
|
|
@ -23,7 +25,8 @@ export class AdminApp extends AbstractApp {
|
|||
}
|
||||
|
||||
start() {
|
||||
if (!Settings.app('adminAllowed')) {
|
||||
// if (!Settings.app('adminAllowed')) {
|
||||
if (!SettingsAdmin('allowed')) {
|
||||
rl.route.root();
|
||||
setTimeout(() => location.href = '/', 1);
|
||||
} else if (SettingsGet('Auth')) {
|
||||
|
|
@ -34,3 +37,16 @@ export class AdminApp extends AbstractApp {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
AskPopupView.credentials = function(sAskDesc, btnText) {
|
||||
return new Promise(resolve => {
|
||||
this.showModal([
|
||||
sAskDesc,
|
||||
view => resolve({username:view.username(), password:view.passphrase()}),
|
||||
() => resolve(null),
|
||||
true,
|
||||
3,
|
||||
btnText
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import 'External/User/ko';
|
||||
|
||||
import { SMAudio } from 'Common/Audio';
|
||||
import { isArray, pInt } from 'Common/Utils';
|
||||
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility } from 'Common/UtilsUser';
|
||||
import { pInt } from 'Common/Utils';
|
||||
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser';
|
||||
|
||||
import {
|
||||
FolderType,
|
||||
|
|
@ -33,9 +33,9 @@ import { SettingsUserStore } from 'Stores/User/Settings';
|
|||
import { NotificationUserStore } from 'Stores/User/Notification';
|
||||
import { AccountUserStore } from 'Stores/User/Account';
|
||||
import { ContactUserStore } from 'Stores/User/Contact';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
import { PgpUserStore } from 'Stores/User/Pgp';
|
||||
import { SMimeUserStore } from 'Stores/User/SMime';
|
||||
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
||||
import { ThemeStore, initThemes } from 'Stores/Theme';
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
|
|
@ -43,9 +43,6 @@ import { MessageUserStore } from 'Stores/User/Message';
|
|||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { AccountModel } from 'Model/Account';
|
||||
import { IdentityModel } from 'Model/Identity';
|
||||
|
||||
import { LoginUserScreen } from 'Screen/User/Login';
|
||||
import { MailBoxUserScreen } from 'Screen/User/MailBox';
|
||||
import { SettingsUserScreen } from 'Screen/User/Settings';
|
||||
|
|
@ -89,6 +86,8 @@ export class AppUser extends AbstractApp {
|
|||
|
||||
this.folderList = FolderUserStore.folderList;
|
||||
this.messageList = MessagelistUserStore;
|
||||
|
||||
this.ask = AskPopupView;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -139,31 +138,6 @@ export class AppUser extends AbstractApp {
|
|||
}
|
||||
}
|
||||
|
||||
accountsAndIdentities() {
|
||||
AccountUserStore.loading(true);
|
||||
IdentityUserStore.loading(true);
|
||||
|
||||
Remote.request('AccountsAndIdentities', (iError, oData) => {
|
||||
AccountUserStore.loading(false);
|
||||
IdentityUserStore.loading(false);
|
||||
|
||||
if (!iError) {
|
||||
let items = oData.Result.Accounts;
|
||||
AccountUserStore(isArray(items)
|
||||
? items.map(oValue => new AccountModel(oValue.email, oValue.name))
|
||||
: []
|
||||
);
|
||||
AccountUserStore.unshift(new AccountModel(SettingsGet('mainEmail'), '', false));
|
||||
|
||||
items = oData.Result.Identities;
|
||||
IdentityUserStore(isArray(items)
|
||||
? items.map(identityData => IdentityModel.reviveFromJson(identityData))
|
||||
: []
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} folder
|
||||
* @param {Array=} list = []
|
||||
|
|
@ -216,9 +190,7 @@ export class AppUser extends AbstractApp {
|
|||
|
||||
setRefreshFoldersInterval(pInt(SettingsGet('CheckMailInterval')));
|
||||
|
||||
ContactUserStore.init();
|
||||
|
||||
this.accountsAndIdentities();
|
||||
loadAccountsAndIdentities();
|
||||
|
||||
setTimeout(() => {
|
||||
const cF = FolderUserStore.currentFolderFullName();
|
||||
|
|
@ -247,6 +219,7 @@ export class AppUser extends AbstractApp {
|
|||
setInterval(reloadTime, 60000);
|
||||
|
||||
PgpUserStore.init();
|
||||
SMimeUserStore.loadCertificates();
|
||||
|
||||
setTimeout(() => mailToHelper(SettingsGet('mailToEmail')), 500);
|
||||
|
||||
|
|
@ -267,7 +240,6 @@ export class AppUser extends AbstractApp {
|
|||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
startScreens([LoginUserScreen]);
|
||||
}
|
||||
|
|
@ -278,3 +250,50 @@ export class AppUser extends AbstractApp {
|
|||
showScreenPopup(ComposePopupView, params);
|
||||
}
|
||||
}
|
||||
|
||||
AskPopupView.password = function(sAskDesc, btnText, ask) {
|
||||
return new Promise(resolve => {
|
||||
this.showModal([
|
||||
sAskDesc,
|
||||
view => resolve({
|
||||
password:view.passphrase(),
|
||||
username:/*ask & 2 ? */view.username(),
|
||||
remember:/*ask & 4 ? */view.remember()
|
||||
}),
|
||||
() => resolve(null),
|
||||
true,
|
||||
ask || 1,
|
||||
btnText
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
AskPopupView.cryptkey = () => new Promise(resolve => {
|
||||
const fn = () => AskPopupView.showModal([
|
||||
i18n('CRYPTO/ASK_CRYPTKEY_PASS'),
|
||||
view => {
|
||||
let pass = view.passphrase();
|
||||
if (pass) {
|
||||
Remote.post('ResealCryptKey', null, {
|
||||
passphrase: pass
|
||||
}).then(response => {
|
||||
resolve(response?.Result);
|
||||
}).catch(e => {
|
||||
if (111 === e.code) {
|
||||
fn();
|
||||
} else {
|
||||
console.error(e);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
() => resolve(null),
|
||||
true,
|
||||
1,
|
||||
i18n('CRYPTO/DECRYPT')
|
||||
]);
|
||||
fn();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ Notifications = {
|
|||
ConnectionError: 104,
|
||||
DomainNotAllowed: 109,
|
||||
AccountNotAllowed: 110,
|
||||
CryptKeyError: 111,
|
||||
|
||||
ContactsSyncError: 140,
|
||||
|
||||
|
|
@ -95,7 +96,6 @@ Notifications = {
|
|||
JsonParse: 952,
|
||||
// JsonTimeout: 953,
|
||||
|
||||
UnknownNotification: 998,
|
||||
UnknownError: 999,
|
||||
|
||||
// Admin
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint key-spacing: 0 */
|
||||
/* eslint quote-props: 0 */
|
||||
|
||||
import { arrayLength } from 'Common/Utils';
|
||||
import { arrayLength, pInt } from 'Common/Utils';
|
||||
|
||||
export const RFC822 = 'message/rfc822';
|
||||
|
||||
|
|
@ -30,6 +30,8 @@ const
|
|||
p7c: app+'pkcs7-mime',
|
||||
p7m: app+'pkcs7-mime',
|
||||
p7s: app+'pkcs7-signature',
|
||||
p12: app+'pkcs12',
|
||||
pfx: app+'x-pkcs12',
|
||||
torrent: app+'x-bittorrent',
|
||||
|
||||
// scripts
|
||||
|
|
@ -136,7 +138,7 @@ export const FileInfo = {
|
|||
getContentType: fileName => {
|
||||
fileName = lowerCase(fileName);
|
||||
if ('winmail.dat' === fileName) {
|
||||
return app + 'ms-tnef';
|
||||
return app + 'vnd.ms-tnef';
|
||||
}
|
||||
let ext = fileName.split('.').pop();
|
||||
if (/^(txt|text|def|list|in|ini|log|sql|cfg|conf)$/.test(ext))
|
||||
|
|
@ -164,7 +166,7 @@ export const FileInfo = {
|
|||
*/
|
||||
getType: (ext, mimeType) => {
|
||||
ext = lowerCase(ext);
|
||||
mimeType = lowerCase(mimeType).replace('csv/plain', 'text/csv');
|
||||
mimeType = lowerCase(mimeType).replace('csv/plain', 'text/csv').replace('x-','');
|
||||
|
||||
let key = ext + mimeType;
|
||||
if (cache[key]) {
|
||||
|
|
@ -173,7 +175,7 @@ export const FileInfo = {
|
|||
|
||||
let result = FileType.Unknown;
|
||||
const mimeTypeParts = mimeType.split('/'),
|
||||
type = mimeTypeParts[1].replace('x-','').replace('-compressed',''),
|
||||
type = mimeTypeParts[1].replace('-compressed',''),
|
||||
match = str => mimeType.includes(str),
|
||||
archive = /^(zip|7z|tar|rar|gzip|bzip|bzip2)$/;
|
||||
|
||||
|
|
@ -205,9 +207,8 @@ export const FileInfo = {
|
|||
case 'pdf' == type || 'pdf' == ext:
|
||||
result = FileType.Pdf;
|
||||
break;
|
||||
case [app+'pgp-signature', app+'pgp-keys'].includes(mimeType)
|
||||
|| ['asc', 'pem', 'ppk'].includes(ext)
|
||||
|| [app+'pkcs7-signature'].includes(mimeType) || 'p7s' == ext:
|
||||
case [app+'pgp-signature', app+'pgp-keys', exts.p7m, exts.p7s, exts.p12, exts.pfx].includes(mimeType)
|
||||
|| ['asc', 'pem', 'ppk', 'p7s', 'p7m', 'p12', 'pfx'].includes(ext):
|
||||
result = FileType.Certificate;
|
||||
break;
|
||||
case match(msOffice+'.wordprocessingml') || match(openDoc+'.text') || match('vnd.ms-word')
|
||||
|
|
@ -273,8 +274,8 @@ export const FileInfo = {
|
|||
},
|
||||
|
||||
friendlySize: bytes => {
|
||||
bytes = parseInt(bytes, 10) || 0;
|
||||
let i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
bytes = pInt(bytes);
|
||||
let i = bytes ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0;
|
||||
return (bytes / Math.pow(1024, i)).toFixed(2>i ? 0 : 1) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { RFC822 } from 'Common/File';
|
||||
import { getFolderInboxName, getFolderFromCacheList } from 'Common/Cache';
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
import { isArray, arrayLength } from 'Common/Utils';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
|
|
@ -27,7 +28,7 @@ setRefreshFoldersInterval = minutes => {
|
|||
|
||||
sortFolders = folders => {
|
||||
try {
|
||||
let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
|
||||
let collator = baseCollator(true);
|
||||
folders.sort((a, b) =>
|
||||
a.isInbox() ? -1 : (b.isInbox() ? 1 : collator.compare(a.fullName, b.fullName))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const
|
|||
|
||||
Settings = rl.settings,
|
||||
SettingsGet = Settings.get,
|
||||
SettingsAdmin = name => (SettingsGet('Admin') || {})[name],
|
||||
SettingsCapa = name => name && !!(SettingsGet('Capa') || {})[name],
|
||||
|
||||
dropdowns = [],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createElement } from 'Common/Globals';
|
||||
import { forEachObjectEntry, pInt } from 'Common/Utils';
|
||||
import { forEachObjectEntry, isArray, pInt } from 'Common/Utils';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
const
|
||||
|
|
@ -207,7 +207,9 @@ export const
|
|||
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
|
||||
|
||||
result = {
|
||||
hasExternals: false
|
||||
hasExternals: false,
|
||||
tracking: false,
|
||||
linkedData: []
|
||||
},
|
||||
|
||||
findAttachmentByCid = cId => oAttachments.findByCid(cId),
|
||||
|
|
@ -269,12 +271,15 @@ export const
|
|||
// Not supported by <template> element
|
||||
// .replace(/<!doctype[^>]*>/gi, '')
|
||||
// .replace(/<\?xml[^>]*\?>/gi, '')
|
||||
.replace(/<(\/?)head(\s[^>]*)?>/gi, '')
|
||||
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
|
||||
// .replace(/<\/?(html|head)[^>]*>/gi, '')
|
||||
// Fix Reddit https://github.com/the-djmaze/snappymail/issues/540
|
||||
.replace(/<span class="preview-text"[\s\S]+?<\/span>/, '')
|
||||
// https://github.com/the-djmaze/snappymail/issues/900
|
||||
.replace(/\u2028/g,' ')
|
||||
// https://github.com/the-djmaze/snappymail/issues/1415
|
||||
.replace(/<br>\s*<\/p>/gi,'</p>')
|
||||
.trim();
|
||||
html = '';
|
||||
|
||||
|
|
@ -284,6 +289,21 @@ export const
|
|||
nodeIterator.referenceNode.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic support for Linked Data (Structured Email)
|
||||
* https://json-ld.org/
|
||||
* https://structured.email/
|
||||
**/
|
||||
tmpl.content.querySelectorAll('script[type="application/ld+json"]').forEach(oElement => {
|
||||
// Could be array of objects or single object
|
||||
try {
|
||||
const data = JSON.parse(oElement.textContent);
|
||||
(isArray(data) ? data : [data]).forEach(entry => result.linkedData.push(entry));
|
||||
} catch (e) {
|
||||
console.error(e, oElement.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
tmpl.content.querySelectorAll(
|
||||
disallowedTags
|
||||
+ (0 < bqLevel ? ',' + (new Array(1 + bqLevel).fill('blockquote').join(' ')) : '')
|
||||
|
|
@ -381,10 +401,14 @@ export const
|
|||
if ('A' === name) {
|
||||
value = oElement.href;
|
||||
if (!/^([a-z]+):/i.test(value)) {
|
||||
setAttribute('data-x-broken-href', value);
|
||||
setAttribute('data-x-href-broken', value);
|
||||
delAttribute('href');
|
||||
} else {
|
||||
oElement.href = stripTracking(value);
|
||||
if (oElement.href != value) {
|
||||
result.tracking = true;
|
||||
setAttribute('data-x-href-tracking', value);
|
||||
}
|
||||
setAttribute('target', '_blank');
|
||||
// setAttribute('rel', 'external nofollow noopener noreferrer');
|
||||
}
|
||||
|
|
@ -406,9 +430,8 @@ export const
|
|||
*/
|
||||
|
||||
let skipStyle = false;
|
||||
if (hasAttribute('src')) {
|
||||
value = stripTracking(delAttribute('src'));
|
||||
|
||||
value = delAttribute('src');
|
||||
if (value) {
|
||||
if ('IMG' === name) {
|
||||
oElement.loading = 'lazy';
|
||||
let attachment;
|
||||
|
|
@ -445,12 +468,18 @@ export const
|
|||
oStyle.display = 'none';
|
||||
// setAttribute('style', 'display:none');
|
||||
setAttribute('data-x-src-hidden', value);
|
||||
// result.tracking = true;
|
||||
}
|
||||
else if (httpre.test(value))
|
||||
{
|
||||
setAttribute('data-x-src', value);
|
||||
let src = stripTracking(value);
|
||||
if (src != value) {
|
||||
result.tracking = true;
|
||||
setAttribute('data-x-src-tracking', value);
|
||||
}
|
||||
setAttribute('data-x-src', src);
|
||||
result.hasExternals = true;
|
||||
oElement.alt || (oElement.alt = value.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
|
||||
oElement.alt || (oElement.alt = src.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
|
||||
}
|
||||
else if (value.startsWith('data:image/'))
|
||||
{
|
||||
|
|
@ -721,184 +750,7 @@ export const
|
|||
.replace(/\n/g, '<br>');
|
||||
blockquoteSwitcher();
|
||||
return tmpl.innerHTML.trim();
|
||||
},
|
||||
|
||||
WYSIWYGS = ko.observableArray();
|
||||
|
||||
WYSIWYGS.push(['Squire', (owner, container, onReady)=>{
|
||||
let squire = new SquireUI(container);
|
||||
setTimeout(()=>onReady(squire), 1);
|
||||
/*
|
||||
squire.on('blur', () => owner.blurTrigger());
|
||||
squire.on('focus', () => clearTimeout(owner.blurTimer));
|
||||
squire.on('mode', () => {
|
||||
owner.blurTrigger();
|
||||
owner.onModeChange?.(!owner.isPlain());
|
||||
});
|
||||
*/
|
||||
}]);
|
||||
|
||||
rl.registerWYSIWYG = (name, construct) => WYSIWYGS.push([name, construct]);
|
||||
|
||||
export class HtmlEditor {
|
||||
/**
|
||||
* @param {Object} element
|
||||
* @param {Function=} onBlur
|
||||
* @param {Function=} onReady
|
||||
* @param {Function=} onModeChange
|
||||
*/
|
||||
constructor(element, onBlur = null, onReady = null, onModeChange = null) {
|
||||
this.blurTimer = 0;
|
||||
|
||||
this.onBlur = onBlur;
|
||||
this.onModeChange = onModeChange;
|
||||
|
||||
if (element) {
|
||||
onReady = onReady ? [onReady] : [];
|
||||
this.onReady = fn => onReady.push(fn);
|
||||
// TODO: make 'which' user configurable
|
||||
// const which = 'CKEditor4',
|
||||
// wysiwyg = WYSIWYGS.find(item => which == item[0]) || WYSIWYGS.find(item => 'Squire' == item[0]);
|
||||
const wysiwyg = WYSIWYGS.find(item => 'Squire' == item[0]);
|
||||
wysiwyg[1](this, element, editor => {
|
||||
this.editor = editor;
|
||||
editor.on('blur', () => this.blurTrigger());
|
||||
editor.on('focus', () => clearTimeout(this.blurTimer));
|
||||
editor.on('mode', () => {
|
||||
this.blurTrigger();
|
||||
this.onModeChange?.(!this.isPlain());
|
||||
});
|
||||
this.onReady = fn => fn();
|
||||
onReady.forEach(fn => fn());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
blurTrigger() {
|
||||
if (this.onBlur) {
|
||||
clearTimeout(this.blurTimer);
|
||||
this.blurTimer = setTimeout(() => this.onBlur?.(), 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isHtml() {
|
||||
return this.editor ? !this.isPlain() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPlain() {
|
||||
return this.editor ? 'plain' === this.editor.mode : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
clearCachedSignature() {
|
||||
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||
clearCache: true
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} signature
|
||||
* @param {bool} html
|
||||
* @param {bool} insertBefore
|
||||
* @returns {void}
|
||||
*/
|
||||
setSignature(signature, html, insertBefore = false) {
|
||||
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||
isHtml: html,
|
||||
insertBefore: insertBefore,
|
||||
signature: signature
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean=} wrapIsHtml = false
|
||||
* @returns {string}
|
||||
*/
|
||||
getData() {
|
||||
let result = '';
|
||||
if (this.editor) {
|
||||
try {
|
||||
if (this.isPlain() && this.editor.plugins.plain && this.editor.__plain) {
|
||||
result = this.editor.__plain.getRawData();
|
||||
} else {
|
||||
result = this.editor.getData();
|
||||
}
|
||||
} catch (e) {} // eslint-disable-line no-empty
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
getDataWithHtmlMark() {
|
||||
return (this.isHtml() ? ':HTML:' : '') + this.getData();
|
||||
}
|
||||
|
||||
modeWysiwyg() {
|
||||
this.onReady(() => this.editor.setMode('wysiwyg'));
|
||||
}
|
||||
modePlain() {
|
||||
this.onReady(() => this.editor.setMode('plain'));
|
||||
}
|
||||
|
||||
setHtmlOrPlain(text) {
|
||||
text.startsWith(':HTML:')
|
||||
? this.setHtml(text.slice(6))
|
||||
: this.setPlain(text);
|
||||
}
|
||||
|
||||
setData(mode, data) {
|
||||
this.onReady(() => {
|
||||
const editor = this.editor;
|
||||
this.clearCachedSignature();
|
||||
try {
|
||||
editor.setMode(mode);
|
||||
if (this.isPlain() && editor.plugins.plain && editor.__plain) {
|
||||
editor.__plain.setRawData(data);
|
||||
} else {
|
||||
editor.setData(data);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
|
||||
setHtml(html) {
|
||||
this.setData('wysiwyg', html/*.replace(/<p[^>]*><\/p>/gi, '')*/);
|
||||
}
|
||||
|
||||
setPlain(txt) {
|
||||
this.setData('plain', txt);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.onReady(() => this.editor.focus());
|
||||
}
|
||||
|
||||
hasFocus() {
|
||||
try {
|
||||
return !!this.editor?.focusManager.hasFocus;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
blur() {
|
||||
this.onReady(() => this.editor.focusManager.blur(true));
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.onReady(() => this.isPlain() ? this.setPlain('') : this.setHtml(''));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rl.Utils = {
|
||||
htmlToPlain: htmlToPlain,
|
||||
|
|
|
|||
161
dev/Common/HtmlEditor.js
Normal file
161
dev/Common/HtmlEditor.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
export const
|
||||
WYSIWYGS = ko.observableArray();
|
||||
|
||||
WYSIWYGS.push({
|
||||
name: 'Squire',
|
||||
construct: (owner, container, onReady) => onReady(new SquireUI(container))
|
||||
});
|
||||
|
||||
rl.registerWYSIWYG = (name, construct) => WYSIWYGS.push({name, construct});
|
||||
|
||||
export class HtmlEditor {
|
||||
/**
|
||||
* @param {Object} element
|
||||
* @param {Function=} onBlur
|
||||
* @param {Function=} onReady
|
||||
* @param {Function=} onModeChange
|
||||
*/
|
||||
constructor(element, onReady = null, onModeChange = null, onBlur = null) {
|
||||
this.blurTimer = 0;
|
||||
|
||||
this.onBlur = onBlur;
|
||||
this.onModeChange = onModeChange;
|
||||
|
||||
if (element) {
|
||||
onReady = onReady ? [onReady] : [];
|
||||
this.onReady = fn => onReady.push(fn);
|
||||
const which = SettingsUserStore.editorWysiwyg(),
|
||||
wysiwyg = WYSIWYGS.find(item => which == item.name) || WYSIWYGS.find(item => 'Squire' == item.name);
|
||||
wysiwyg.construct(this, element, editor => setTimeout(()=>{
|
||||
this.editor = editor;
|
||||
editor.on('blur', () => this.blurTrigger());
|
||||
editor.on('focus', () => clearTimeout(this.blurTimer));
|
||||
editor.on('mode', () => {
|
||||
this.blurTrigger();
|
||||
this.onModeChange?.(!this.isPlain());
|
||||
});
|
||||
this.onReady = fn => fn();
|
||||
onReady.forEach(fn => fn());
|
||||
},1));
|
||||
}
|
||||
}
|
||||
|
||||
blurTrigger() {
|
||||
if (this.onBlur) {
|
||||
clearTimeout(this.blurTimer);
|
||||
this.blurTimer = setTimeout(() => this.onBlur?.(), 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isHtml() {
|
||||
return this.editor ? !this.isPlain() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPlain() {
|
||||
return this.editor ? 'plain' === this.editor.mode : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
clearCachedSignature() {
|
||||
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||
clearCache: true
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} signature
|
||||
* @param {bool} html
|
||||
* @param {bool} insertBefore
|
||||
* @returns {void}
|
||||
*/
|
||||
setSignature(signature, html, insertBefore = false) {
|
||||
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||
isHtml: html,
|
||||
insertBefore: insertBefore,
|
||||
signature: signature
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean=} wrapIsHtml = false
|
||||
* @returns {string}
|
||||
*/
|
||||
getData() {
|
||||
let result = '';
|
||||
if (this.editor) {
|
||||
try {
|
||||
if (this.isPlain()) {
|
||||
result = this.editor.getPlainData();
|
||||
} else {
|
||||
result = this.editor.getData();
|
||||
}
|
||||
} catch (e) {} // eslint-disable-line no-empty
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
getDataWithHtmlMark() {
|
||||
return (this.isHtml() ? ':HTML:' : '') + this.getData();
|
||||
}
|
||||
|
||||
modeWysiwyg() {
|
||||
this.onReady(() => this.editor.setMode('wysiwyg'));
|
||||
}
|
||||
modePlain() {
|
||||
this.onReady(() => this.editor.setMode('plain'));
|
||||
}
|
||||
|
||||
setHtmlOrPlain(text) {
|
||||
text.startsWith(':HTML:')
|
||||
? this.setHtml(text.slice(6))
|
||||
: this.setPlain(text);
|
||||
}
|
||||
|
||||
setData(mode, data) {
|
||||
this.onReady(() => {
|
||||
const editor = this.editor;
|
||||
this.clearCachedSignature();
|
||||
try {
|
||||
editor.setMode(mode);
|
||||
if (this.isPlain()) {
|
||||
editor.setPlainData(data);
|
||||
} else {
|
||||
editor.setData(data);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
|
||||
setHtml(html) {
|
||||
this.setData('wysiwyg', html/*.replace(/<p[^>]*><\/p>/gi, '')*/);
|
||||
}
|
||||
|
||||
setPlain(txt) {
|
||||
this.setData('plain', txt);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.onReady(() => this.editor.focus());
|
||||
}
|
||||
|
||||
blur() {
|
||||
this.onReady(() => this.editor.blur());
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.onReady(() => this.isPlain() ? this.setPlain('') : this.setHtml(''));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { pInt } from 'Common/Utils';
|
||||
import { doc, Settings } from 'Common/Globals';
|
||||
import { doc, Settings, SettingsAdmin } from 'Common/Globals';
|
||||
|
||||
const
|
||||
BASE = doc.location.pathname.replace(/\/+$/,'') + '/',
|
||||
HASH_PREFIX = '#/',
|
||||
|
||||
adminPath = () => rl.adminArea() && !Settings.app('adminHost'),
|
||||
adminPath = () => rl.adminArea() && !SettingsAdmin('host'),
|
||||
|
||||
prefix = () => BASE + '?' + (adminPath() ? Settings.app('adminPath') : '');
|
||||
prefix = () => BASE + '?' + (adminPath() ? SettingsAdmin('path') : '');
|
||||
|
||||
export const
|
||||
SUB_QUERY_PREFIX = '&q[]=',
|
||||
|
|
@ -38,11 +38,10 @@ export const
|
|||
|
||||
/**
|
||||
* @param {string} download
|
||||
* @param {string=} customSpecSuffix
|
||||
* @returns {string}
|
||||
*/
|
||||
attachmentDownload = (download, customSpecSuffix) =>
|
||||
serverRequestRaw('Download', download, customSpecSuffix),
|
||||
attachmentDownload = (download) =>
|
||||
serverRequestRaw('Download', download),
|
||||
|
||||
proxy = url =>
|
||||
BASE + '?/ProxyExternal/'
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const
|
|||
|
||||
getNotificationMessage = code => {
|
||||
let key = getKeyByValue(Notifications, code);
|
||||
return key ? I18N_DATA.NOTIFICATIONS[i18nKey(key).replace('_NOTIFICATION', '_ERROR')] : '';
|
||||
return key ? I18N_DATA.NOTIFICATIONS[key] : '';
|
||||
},
|
||||
|
||||
fromNow = date => relativeTime(Math.round((date.getTime() - Date.now()) / 1000));
|
||||
|
|
@ -45,8 +45,7 @@ export const
|
|||
}
|
||||
}
|
||||
if (Intl.RelativeTimeFormat) {
|
||||
let rtf = new Intl.RelativeTimeFormat(doc.documentElement.lang);
|
||||
return rtf.format(seconds, unit);
|
||||
return (new Intl.RelativeTimeFormat(doc.documentElement.lang)).format(seconds, unit);
|
||||
}
|
||||
// Safari < 14
|
||||
abs = Math.abs(seconds);
|
||||
|
|
@ -62,7 +61,7 @@ export const
|
|||
* @returns {string}
|
||||
*/
|
||||
i18n = (key, valueList, defaulValue) => {
|
||||
let result = null == defaulValue ? key : defaulValue;
|
||||
let result = defaulValue ?? key;
|
||||
let path = key.split('/');
|
||||
if (I18N_DATA[path[0]] && path[1]) {
|
||||
result = I18N_DATA[path[0]][path[1]] || result;
|
||||
|
|
@ -102,8 +101,7 @@ export const
|
|||
|
||||
timestampToString = (timeStampInUTC, formatStr) => {
|
||||
const now = Date.now(),
|
||||
time = 0 < timeStampInUTC ? Math.min(now, timeStampInUTC * 1000) : (0 === timeStampInUTC ? now : 0);
|
||||
|
||||
time = 0 < timeStampInUTC ? timeStampInUTC * 1000 : (0 === timeStampInUTC ? now : 0);
|
||||
if (31536000000 < time) {
|
||||
const m = new Date(time), h = LanguageStore.hourCycle();
|
||||
switch (formatStr) {
|
||||
|
|
@ -144,7 +142,7 @@ export const
|
|||
time = Date.parse(element.dateTime) / 1000;
|
||||
}
|
||||
|
||||
let key = element.dataset.momentFormat;
|
||||
let key = element.dataset.timeFormat;
|
||||
if (key) {
|
||||
element.textContent = timestampToString(time, key);
|
||||
if ('FULL' !== key && 'FROMNOW' !== key) {
|
||||
|
|
@ -212,14 +210,13 @@ export const
|
|||
script.remove();
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => reject(new Error('Language '+language+' failed'));
|
||||
script.onerror = () => reject(Error('Language '+language+' failed'));
|
||||
script.src = langLink(language, admin);
|
||||
// script.async = true;
|
||||
doc.head.append(script);
|
||||
}),
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} language
|
||||
* @param {boolean=} isEng = false
|
||||
* @returns {string}
|
||||
|
|
@ -229,6 +226,8 @@ export const
|
|||
'LANGS_NAMES' + (true === isEng ? '_EN' : '') + '/' + language,
|
||||
null,
|
||||
language
|
||||
);
|
||||
),
|
||||
|
||||
baseCollator = numeric => new Intl.Collator(doc.documentElement.lang, {numeric: !!numeric, sensitivity: 'base'});
|
||||
|
||||
init();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const
|
|||
|
||||
pInt = (value, defaultValue = 0) => {
|
||||
value = parseInt(value, 10);
|
||||
return isNaN(value) || !isFinite(value) ? defaultValue : value;
|
||||
return isFinite(value) ? value : defaultValue;
|
||||
},
|
||||
|
||||
defaultOptionsAfterRender = (domItem, item) =>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,47 @@ import { ThemeStore } from 'Stores/Theme';
|
|||
import Remote from 'Remote/User/Fetch';
|
||||
import { attachmentDownload } from 'Common/Links';
|
||||
|
||||
import { AccountModel } from 'Model/Account';
|
||||
import { IdentityModel } from 'Model/Identity';
|
||||
import { AccountUserStore } from 'Stores/User/Account';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
import { isArray } from 'Common/Utils';
|
||||
|
||||
export const
|
||||
|
||||
moveAction = ko.observable(false),
|
||||
// 1 = move, 2 = copy
|
||||
moveAction = ko.observable(0),
|
||||
|
||||
dropdownsDetectVisibility = (() =>
|
||||
dropdownVisibility(!!dropdowns.find(item => item.classList.contains('show')))
|
||||
).debounce(50),
|
||||
|
||||
|
||||
loadAccountsAndIdentities = () => {
|
||||
AccountUserStore.loading(true);
|
||||
IdentityUserStore.loading(true);
|
||||
|
||||
Remote.request('AccountsAndIdentities', (iError, oData) => {
|
||||
AccountUserStore.loading(false);
|
||||
IdentityUserStore.loading(false);
|
||||
|
||||
if (!iError) {
|
||||
let items = oData.Result.Accounts;
|
||||
AccountUserStore(isArray(items)
|
||||
? items.map(oValue => new AccountModel(oValue.email, oValue.name))
|
||||
: []
|
||||
);
|
||||
AccountUserStore.unshift(new AccountModel(SettingsGet('mainEmail'), '', false));
|
||||
|
||||
items = oData.Result.Identities;
|
||||
IdentityUserStore(isArray(items)
|
||||
? items.map(identityData => IdentityModel.reviveFromJson(identityData))
|
||||
: []
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} link
|
||||
* @returns {boolean}
|
||||
|
|
@ -89,7 +122,7 @@ computedPaginatorHelper = (koCurrentPage, koPageCount) => {
|
|||
next = 0,
|
||||
limit = 2;
|
||||
|
||||
if (1 < pageCount || (0 < pageCount && pageCount < currentPage)) {
|
||||
if (1 < pageCount) {
|
||||
if (pageCount < currentPage) {
|
||||
fAdd(pageCount);
|
||||
prev = pageCount;
|
||||
|
|
@ -260,6 +293,8 @@ viewMessage = (oMessage, popup) => {
|
|||
class:'b-text-part'
|
||||
+ (oMessage.pgpSigned() ? ' openpgp-signed' : '')
|
||||
+ (oMessage.pgpEncrypted() ? ' openpgp-encrypted' : '')
|
||||
+ (oMessage.smimeSigned() ? ' smime-signed' : '')
|
||||
+ (oMessage.smimeEncrypted() ? ' smime-encrypted' : '')
|
||||
});
|
||||
MessageUserStore.purgeCache();
|
||||
}
|
||||
|
|
@ -325,5 +360,5 @@ populateMessageBody = (oMessage, popup) => {
|
|||
}
|
||||
};
|
||||
|
||||
leftPanelDisabled.subscribe(value => value && moveAction(false));
|
||||
leftPanelDisabled.subscribe(value => value && moveAction(0));
|
||||
moveAction.subscribe(value => value && leftPanelDisabled(false));
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export class CheckboxComponent {
|
|||
: ko.observable(!!params.value);
|
||||
|
||||
this.enable = ko.isObservable(params.enable) ? params.enable
|
||||
: ko.observable(undefined === params.enable || !!params.enable);
|
||||
: ko.observable(params.enable ?? 1);
|
||||
|
||||
this.label = params.label;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { doc, createElement, addEventsListeners } from 'Common/Globals';
|
||||
import { EmailModel, addressparser } from 'Model/Email';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { addressparser } from 'Mime/Address';
|
||||
|
||||
const contentType = 'snappymail/emailaddress',
|
||||
getAddressKey = li => li?.emailaddress?.key,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export class JCard {
|
|||
if (input) {
|
||||
// read from jCard
|
||||
if (typeof input !== 'object') {
|
||||
throw new Error('error reading vcard')
|
||||
throw Error('error reading vcard')
|
||||
}
|
||||
this.parseFromJCard(input)
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ export class JCard {
|
|||
arg = new VCardProperty(String(arg), value, params, type);
|
||||
}
|
||||
if (!(arg instanceof VCardProperty)) {
|
||||
throw new Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty');
|
||||
throw Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty');
|
||||
}
|
||||
let field = arg.getField();
|
||||
this.props.set(field, [arg]);
|
||||
|
|
@ -101,7 +101,7 @@ export class JCard {
|
|||
arg = new VCardProperty(String(arg), value, params, type);
|
||||
}
|
||||
if (!(arg instanceof VCardProperty)) {
|
||||
throw new Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty');
|
||||
throw Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty');
|
||||
}
|
||||
// VCardProperty arguments
|
||||
let field = arg.getField();
|
||||
|
|
@ -124,15 +124,15 @@ export class JCard {
|
|||
// VCardProperty argument
|
||||
else if (arg instanceof VCardProperty) {
|
||||
let propArray = this.props.get(arg.getField());
|
||||
if (!(propArray === null || propArray === void 0 ? void 0 : propArray.includes(arg)))
|
||||
throw new Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg));
|
||||
if (!propArray?.includes(arg))
|
||||
throw Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg));
|
||||
propArray.splice(propArray.indexOf(arg), 1);
|
||||
if (propArray.length === 0)
|
||||
this.props.delete(arg.getField());
|
||||
}
|
||||
// incorrect arguments
|
||||
else
|
||||
throw new Error('invalid argument of VCard.remove(), expects ' +
|
||||
throw Error('invalid argument of VCard.remove(), expects ' +
|
||||
'string and optional param filter or a VCardProperty');
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +202,7 @@ export class JCard {
|
|||
parseFullName(options) {
|
||||
let n = this.getOne('n');
|
||||
if (n === undefined) {
|
||||
throw new Error('\'fn\' VCardProperty not present in card, cannot parse full name');
|
||||
throw Error('\'fn\' VCardProperty not present in card, cannot parse full name');
|
||||
}
|
||||
let fnString = '';
|
||||
// Position in n -> position in fn
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export class VCardProperty {
|
|||
}
|
||||
// invalid property
|
||||
else {
|
||||
throw new Error('invalid Property constructor');
|
||||
throw Error('invalid Property constructor');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
58
dev/External/SquireUI.js
vendored
58
dev/External/SquireUI.js
vendored
|
|
@ -133,11 +133,11 @@ class SquireUI
|
|||
dir: {
|
||||
dir_ltr: {
|
||||
html: '⁋',
|
||||
cmd: () => squire.bidi('ltr')
|
||||
cmd: () => squire.setTextDirection('ltr')
|
||||
},
|
||||
dir_rtl: {
|
||||
html: '¶',
|
||||
cmd: () => squire.bidi('rtl')
|
||||
cmd: () => squire.setTextDirection('rtl')
|
||||
}
|
||||
},
|
||||
colors: {
|
||||
|
|
@ -237,7 +237,7 @@ class SquireUI
|
|||
cmd: () => {
|
||||
let node = squire.getSelectionClosest('IMG'),
|
||||
src = prompt("Image", node?.src || "https://");
|
||||
src?.length ? squire.insertImage(src) : (node && squire.detach(node));
|
||||
src?.length ? squire.insertImage(src) : node?.remove();
|
||||
},
|
||||
matches: 'IMG'
|
||||
},
|
||||
|
|
@ -270,6 +270,13 @@ class SquireUI
|
|||
btn.classList.toggle('active', 'source' == this.mode);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clear: {
|
||||
removeStyle: {
|
||||
html: '⎚',
|
||||
cmd: () => squire.setStyle()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -318,11 +325,6 @@ class SquireUI
|
|||
wysiwyg.className = 'squire-wysiwyg';
|
||||
wysiwyg.dir = 'auto';
|
||||
this.mode = ''; // 'plain' | 'wysiwyg'
|
||||
this.__plain = {
|
||||
getRawData: () => this.plain.value,
|
||||
setRawData: plain => this.plain.value = plain
|
||||
};
|
||||
|
||||
this.container = container;
|
||||
this.squire = squire;
|
||||
this.plain = plain;
|
||||
|
|
@ -403,9 +405,9 @@ class SquireUI
|
|||
|
||||
let changes = actions.changes;
|
||||
changes.undo.input.disabled = changes.redo.input.disabled = true;
|
||||
squire.addEventListener('undoStateChange', state => {
|
||||
changes.undo.input.disabled = !state.canUndo;
|
||||
changes.redo.input.disabled = !state.canRedo;
|
||||
squire.addEventListener('undoStateChange', e => {
|
||||
changes.undo.input.disabled = !e.detail.canUndo;
|
||||
changes.redo.input.disabled = !e.detail.canRedo;
|
||||
});
|
||||
|
||||
actions.font.fontSize.input.selectedIndex = actions.font.fontSize.defaultValueIndex;
|
||||
|
|
@ -478,21 +480,21 @@ class SquireUI
|
|||
squire.addEventListener('pathChange', e => {
|
||||
|
||||
const squireRoot = squire.getRoot();
|
||||
let elm = e.detail.element;
|
||||
|
||||
forEachObjectValue(actions, entries => {
|
||||
forEachObjectValue(entries, cfg => {
|
||||
// cfg.matches && cfg.input.classList.toggle('active', e.element && e.element.matches(cfg.matches));
|
||||
cfg.matches && cfg.input.classList.toggle('active', e.element && e.element.closestWithin(cfg.matches, squireRoot));
|
||||
// cfg.matches && cfg.input.classList.toggle('active', elm && elm.matches(cfg.matches));
|
||||
cfg.matches && cfg.input.classList.toggle('active', elm && elm.closestWithin(cfg.matches, squireRoot));
|
||||
});
|
||||
});
|
||||
|
||||
if (e.element) {
|
||||
if (elm) {
|
||||
// try to find font-family and/or font-size and set "select" elements' values
|
||||
|
||||
let sizeSelectedIndex = actions.font.fontSize.defaultValueIndex;
|
||||
let familySelectedIndex = defaultFontFamilyIndex;
|
||||
|
||||
let elm = e.element;
|
||||
let familyFound = false;
|
||||
let sizeFound = false;
|
||||
do {
|
||||
|
|
@ -524,21 +526,12 @@ class SquireUI
|
|||
});
|
||||
/*
|
||||
squire.addEventListener('cursor', e => {
|
||||
console.dir({cursor:e.range});
|
||||
console.dir({cursor:e.detail.range});
|
||||
});
|
||||
squire.addEventListener('select', e => {
|
||||
console.dir({select:e.range});
|
||||
console.dir({select:e.detail.range});
|
||||
});
|
||||
*/
|
||||
|
||||
// CKEditor gimmicks used by HtmlEditor
|
||||
this.plugins = {
|
||||
plain: true
|
||||
};
|
||||
this.focusManager = {
|
||||
hasFocus: () => squire._isFocused,
|
||||
blur: () => squire.blur()
|
||||
};
|
||||
}
|
||||
|
||||
doAction(name) {
|
||||
|
|
@ -576,7 +569,6 @@ class SquireUI
|
|||
this.modeSelect.selectedIndex = 'plain' == this.mode ? 1 : 0;
|
||||
}
|
||||
|
||||
// CKeditor gimmicks used by HtmlEditor
|
||||
on(type, fn) {
|
||||
if ('mode' == type) {
|
||||
this.onModeChange = fn;
|
||||
|
|
@ -643,6 +635,18 @@ class SquireUI
|
|||
squire.setSelection( range );
|
||||
}
|
||||
|
||||
getPlainData() {
|
||||
return this.plain.value;
|
||||
}
|
||||
|
||||
setPlainData(text) {
|
||||
this.plain.value = text;
|
||||
}
|
||||
|
||||
blur() {
|
||||
this.squire.blur();
|
||||
}
|
||||
|
||||
focus() {
|
||||
if ('plain' == this.mode) {
|
||||
this.plain.focus();
|
||||
|
|
|
|||
6
dev/External/User/ko.js
vendored
6
dev/External/User/ko.js
vendored
|
|
@ -1,7 +1,7 @@
|
|||
import 'External/ko';
|
||||
import ko from 'ko';
|
||||
import { RFC822 } from 'Common/File';
|
||||
import { HtmlEditor } from 'Common/Html';
|
||||
import { HtmlEditor } from 'Common/HtmlEditor';
|
||||
import { timeToNode } from 'Common/Translator';
|
||||
import { doc, elementById, addEventsListeners, dropdowns, leftPanelDisabled } from 'Common/Globals';
|
||||
import { EmailAddressesComponent } from 'Component/EmailAddresses';
|
||||
|
|
@ -91,7 +91,7 @@ Object.assign(ko.bindingHandlers, {
|
|||
};
|
||||
|
||||
if (ko.isObservable(fValue)) {
|
||||
editor = new HtmlEditor(element, fUpdateKoValue, fOnReady, fUpdateKoValue);
|
||||
editor = new HtmlEditor(element, fOnReady, fUpdateKoValue, fUpdateKoValue);
|
||||
|
||||
fValue.__fetchEditorValue = fUpdateKoValue;
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ Object.assign(ko.bindingHandlers, {
|
|||
}
|
||||
},
|
||||
|
||||
moment: {
|
||||
time: {
|
||||
init: ttn,
|
||||
update: ttn
|
||||
},
|
||||
|
|
|
|||
22
dev/External/ko.js
vendored
22
dev/External/ko.js
vendored
|
|
@ -28,6 +28,11 @@ export const
|
|||
|
||||
dispose = disposable => isFunction(disposable?.dispose) && disposable.dispose(),
|
||||
|
||||
onEvent = (element, event, fn) => {
|
||||
element.addEventListener(event, fn);
|
||||
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener(event, fn));
|
||||
},
|
||||
|
||||
onKey = (key, element, fValueAccessor, fAllBindings, model) => {
|
||||
let fn = event => {
|
||||
if (key == event.key) {
|
||||
|
|
@ -36,8 +41,7 @@ export const
|
|||
fValueAccessor().call(model);
|
||||
}
|
||||
};
|
||||
element.addEventListener('keydown', fn);
|
||||
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keydown', fn));
|
||||
onEvent(element, 'keydown', fn);
|
||||
},
|
||||
|
||||
// With this we don't need delegateRunOnDestroy
|
||||
|
|
@ -62,8 +66,7 @@ Object.assign(ko.bindingHandlers, {
|
|||
},
|
||||
update: (element, fValueAccessor) => {
|
||||
let value = ko.unwrap(fValueAccessor());
|
||||
value = isFunction(value) ? value() : value;
|
||||
errorTip(element, value);
|
||||
errorTip(element, isFunction(value) ? value() : value);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -82,6 +85,15 @@ Object.assign(ko.bindingHandlers, {
|
|||
onKey(' ', element, fValueAccessor, fAllBindings, model)
|
||||
},
|
||||
|
||||
toggle: {
|
||||
init: (element, fValueAccessor) => {
|
||||
let observable = fValueAccessor(),
|
||||
fn = () => observable(!observable());
|
||||
onEvent(element, 'click', fn);
|
||||
onEvent(element, 'keydown', event => ' ' == event.key && fn());
|
||||
}
|
||||
},
|
||||
|
||||
i18nUpdate: {
|
||||
update: (element, fValueAccessor) => {
|
||||
ko.unwrap(fValueAccessor());
|
||||
|
|
@ -94,7 +106,7 @@ Object.assign(ko.bindingHandlers, {
|
|||
const command = fValueAccessor();
|
||||
|
||||
if (!command || !command.canExecute) {
|
||||
throw new Error('Value should be a command');
|
||||
throw Error('Value should be a command');
|
||||
}
|
||||
|
||||
ko.bindingHandlers['FORM'==element.nodeName ? 'submit' : 'click'].init(
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@ function typeCast(curValue, newValue) {
|
|||
if (null != curValue) {
|
||||
switch (typeof curValue)
|
||||
{
|
||||
case 'boolean': return 0 != newValue && !!newValue;
|
||||
case 'number': return isFinite(newValue) ? parseFloat(newValue) : 0;
|
||||
case 'string': return null != newValue ? '' + newValue : '';
|
||||
case 'boolean':
|
||||
return 0 != newValue && !!newValue;
|
||||
case 'number':
|
||||
newValue = parseFloat(newValue);
|
||||
return isFinite(newValue) ? newValue : 0;
|
||||
case 'string':
|
||||
return null != newValue ? '' + newValue : '';
|
||||
case 'object':
|
||||
if (curValue.constructor.reviveFromJson) {
|
||||
return curValue.constructor.reviveFromJson(newValue);
|
||||
|
|
@ -23,7 +27,7 @@ export class AbstractModel {
|
|||
constructor() {
|
||||
/*
|
||||
if (new.target === AbstractModel) {
|
||||
throw new Error("Can't instantiate AbstractModel!");
|
||||
throw Error("Can't instantiate AbstractModel!");
|
||||
}
|
||||
*/
|
||||
Object.defineProperty(this, 'disposables', {value: []});
|
||||
|
|
|
|||
|
|
@ -116,6 +116,11 @@ export class AbstractViewSettings
|
|||
onHide() {}
|
||||
viewModelDom
|
||||
*/
|
||||
/**
|
||||
* When this[name] does not exists, create as observable with value of SettingsGet(name)
|
||||
* When this[name+'Trigger'] does not exists, create as observable
|
||||
* Subscribe to this[name], and handle saving the setting
|
||||
*/
|
||||
addSetting(name, valueCb)
|
||||
{
|
||||
let prop = name[0].toLowerCase() + name.slice(1),
|
||||
|
|
@ -131,6 +136,7 @@ export class AbstractViewSettings
|
|||
rl.app.Remote.saveSetting(name, value,
|
||||
iError => {
|
||||
this[trigger](iError ? SaveSettingStatus.Failed : SaveSettingStatus.Success);
|
||||
// iError || Settings.set(name, value);
|
||||
setTimeout(() => this[trigger](SaveSettingStatus.Idle), 1000);
|
||||
}
|
||||
);
|
||||
|
|
@ -138,6 +144,10 @@ export class AbstractViewSettings
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreach name if this[name] does not exists, create as observable with value of SettingsGet(name)
|
||||
* Subscribe to this[name], for saving the setting
|
||||
*/
|
||||
addSettings(names)
|
||||
{
|
||||
names.forEach(name => {
|
||||
|
|
|
|||
197
dev/Mime/Address.js
Normal file
197
dev/Mime/Address.js
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { decodeEncodedWords } from 'Mime/Encoding';
|
||||
|
||||
/**
|
||||
* Parses structured e-mail addresses from an address/mailbox(-list) field
|
||||
* https://datatracker.ietf.org/doc/html/rfc2822#section-3.4
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* "Name <address@domain>"
|
||||
*
|
||||
* will be converted to
|
||||
*
|
||||
* [{name: "Name", email: "address@domain"}]
|
||||
*
|
||||
* @param {String} str Address field
|
||||
* @return {Array} An array of address objects
|
||||
*/
|
||||
export function addressparser(str) {
|
||||
str = (str || '').toString();
|
||||
|
||||
let
|
||||
endOperator = '',
|
||||
node = {
|
||||
type: 'text',
|
||||
value: ''
|
||||
},
|
||||
escaped = false,
|
||||
address = [],
|
||||
addresses = [];
|
||||
|
||||
const
|
||||
/*
|
||||
* Operator tokens and which tokens are expected to end the sequence
|
||||
*/
|
||||
OPERATORS = {
|
||||
'"': '"',
|
||||
'(': ')',
|
||||
'<': '>',
|
||||
',': '',
|
||||
// Groups are ended by semicolons
|
||||
':': ';',
|
||||
// Semicolons are not a legal delimiter per the RFC2822 grammar other
|
||||
// than for terminating a group, but they are also not valid for any
|
||||
// other use in this context. Given that some mail clients have
|
||||
// historically allowed the semicolon as a delimiter equivalent to the
|
||||
// comma in their UI, it makes sense to treat them the same as a comma
|
||||
// when used outside of a group.
|
||||
';': ''
|
||||
},
|
||||
pushToken = token => {
|
||||
token.value = (token.value || '').toString().trim();
|
||||
token.value.length && address.push(token);
|
||||
node = {
|
||||
type: 'text',
|
||||
value: ''
|
||||
},
|
||||
escaped = false;
|
||||
},
|
||||
pushAddress = () => {
|
||||
if (address.length) {
|
||||
address = _handleAddress(address);
|
||||
if (address.length) {
|
||||
addresses = addresses.concat(address);
|
||||
}
|
||||
}
|
||||
address = [];
|
||||
};
|
||||
|
||||
[...str].forEach(chr => {
|
||||
if (!escaped && (chr === endOperator || (!endOperator && chr in OPERATORS))) {
|
||||
pushToken(node);
|
||||
if (',' === chr || ';' === chr) {
|
||||
pushAddress();
|
||||
} else {
|
||||
endOperator = endOperator ? '' : OPERATORS[chr];
|
||||
if ('<' === chr) {
|
||||
node.type = 'email';
|
||||
} else if ('(' === chr) {
|
||||
node.type = 'comment';
|
||||
} else if (':' === chr) {
|
||||
node.type = 'group';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
node.value += chr;
|
||||
escaped = !escaped && '\\' === chr;
|
||||
}
|
||||
});
|
||||
pushToken(node);
|
||||
|
||||
pushAddress();
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts tokens for a single address into an address object
|
||||
*
|
||||
* @param {Array} tokens Tokens object
|
||||
* @return {Object} Address object
|
||||
*/
|
||||
function _handleAddress(tokens) {
|
||||
let
|
||||
isGroup = false,
|
||||
address = {},
|
||||
addresses = [],
|
||||
data = {
|
||||
email: [],
|
||||
comment: [],
|
||||
group: [],
|
||||
text: []
|
||||
};
|
||||
|
||||
tokens.forEach(token => {
|
||||
isGroup = isGroup || 'group' === token.type;
|
||||
data[token.type].push(token.value);
|
||||
});
|
||||
|
||||
// If there is no text but a comment, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
||||
/*
|
||||
addresses.push({
|
||||
email: '',
|
||||
name: data.text.join(' ').trim(),
|
||||
group: addressparser(data.group.join(','))
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
});
|
||||
*/
|
||||
addresses = addresses.concat(addressparser(data.group.join(',')));
|
||||
} else {
|
||||
// If no address was found, try to detect one from regular text
|
||||
if (!data.email.length && data.text.length) {
|
||||
var i = data.text.length;
|
||||
while (i--) {
|
||||
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
||||
data.email = data.text.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// still no address
|
||||
if (!data.email.length) {
|
||||
i = data.text.length;
|
||||
while (i--) {
|
||||
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, address => {
|
||||
if (!data.email.length) {
|
||||
data.email = [address.trim()];
|
||||
return '';
|
||||
}
|
||||
return address.trim();
|
||||
});
|
||||
if (data.email.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's still no text but a comment exists, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
|
||||
// Keep only the first address occurence, push others to regular text
|
||||
if (data.email.length > 1) {
|
||||
data.text = data.text.concat(data.email.splice(1));
|
||||
}
|
||||
|
||||
address = {
|
||||
// Join values with spaces
|
||||
email: decodeEncodedWords(data.email.join(' ').trim()),
|
||||
name: decodeEncodedWords(data.text.join(' ').trim())
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
};
|
||||
|
||||
if (address.email === address.name) {
|
||||
if (address.email.includes('@')) {
|
||||
address.name = '';
|
||||
} else {
|
||||
address.email = '';
|
||||
}
|
||||
}
|
||||
|
||||
// address.email = address.email.replace(/^[<]+(.*)[>]+$/g, '$1');
|
||||
|
||||
addresses.push(address);
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
35
dev/Mime/Encoding.js
Normal file
35
dev/Mime/Encoding.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
const
|
||||
QPDecodeParams = [/=([0-9A-F]{2})/g, (...args) => String.fromCharCode(parseInt(args[1], 16))];
|
||||
|
||||
export const
|
||||
// https://datatracker.ietf.org/doc/html/rfc2045#section-6.8
|
||||
BDecode = atob,
|
||||
|
||||
// unescape(encodeURIComponent()) makes the UTF-16 DOMString to an UTF-8 string
|
||||
BEncode = data => btoa(unescape(encodeURIComponent(data))),
|
||||
/* // Without deprecated 'unescape':
|
||||
BEncode = data => btoa(encodeURIComponent(data).replace(
|
||||
/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode('0x' + p1)
|
||||
)),
|
||||
*/
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2045#section-6.7
|
||||
QPDecode = data => data.replace(/=\r?\n/g, '').replace(...QPDecodeParams),
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2047#section-4.1
|
||||
// https://datatracker.ietf.org/doc/html/rfc2047#section-4.2
|
||||
// encoded-word = "=?" charset "?" encoding "?" encoded-text "?="
|
||||
decodeEncodedWords = data =>
|
||||
data.replace(/=\?([^?]+)\?(B|Q)\?(.+?)\?=/g, (m, charset, encoding, text) =>
|
||||
decodeText(charset, 'B' == encoding ? BDecode(text) : QPDecode(text))
|
||||
)
|
||||
,
|
||||
|
||||
decodeText = (charset, data) => {
|
||||
try {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings
|
||||
return new TextDecoder(charset).decode(Uint8Array.from(data, c => c.charCodeAt(0)));
|
||||
} catch (e) {
|
||||
console.error({charset:charset,error:e});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,17 +1,5 @@
|
|||
//import { b64Encode } from 'Common/Utils';
|
||||
|
||||
const
|
||||
// RFC2045
|
||||
QPDecodeParams = [/=([0-9A-F]{2})/g, (...args) => String.fromCharCode(parseInt(args[1], 16))],
|
||||
QPDecode = data => data.replace(/=\r?\n/g, '').replace(...QPDecodeParams),
|
||||
decodeText = (charset, data) => {
|
||||
try {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings
|
||||
return new TextDecoder(charset).decode(Uint8Array.from(data, c => c.charCodeAt(0)));
|
||||
} catch (e) {
|
||||
console.error({charset:charset,error:e});
|
||||
}
|
||||
};
|
||||
import { decodeEncodedWords, BDecode, BEncode, QPDecode, decodeText } from 'Mime/Encoding';
|
||||
import { addressparser } from 'Mime/Address';
|
||||
|
||||
export function ParseMime(text)
|
||||
{
|
||||
|
|
@ -27,7 +15,52 @@ export function ParseMime(text)
|
|||
this.bodyEnd = 0;
|
||||
this.boundary = '';
|
||||
this.bodyText = '';
|
||||
this.headers = {};
|
||||
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6
|
||||
// https://datatracker.ietf.org/doc/html/rfc4021
|
||||
this.headers = {
|
||||
// Required
|
||||
date = null,
|
||||
from = [], // mailbox-list
|
||||
// Optional
|
||||
sender = [], // mailbox MUST occur with multi-address
|
||||
'reply-to' = [], // address-list
|
||||
to = [], // address-list
|
||||
cc = [], // address-list
|
||||
bcc = [], // address-list
|
||||
'message-id' = '', // msg-id SHOULD be present
|
||||
'in-reply-to' = '', // 1*msg-id SHOULD occur in some replies
|
||||
references = '', // 1*msg-id SHOULD occur in some replies
|
||||
subject = '', // unstructured
|
||||
// Optional unlimited
|
||||
comments = [], // unstructured
|
||||
keywords = [], // phrase *("," phrase)
|
||||
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6.6
|
||||
'resent-date' = [],
|
||||
'resent-from' = [],
|
||||
'resent-sender' = [],
|
||||
'resent-to' = [],
|
||||
'resent-cc' = [],
|
||||
'resent-bcc' = [],
|
||||
'resent-msg-id' = [],
|
||||
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6.7
|
||||
trace = [],
|
||||
'return-path' = '', // angle-addr
|
||||
received = [],
|
||||
// optional others outside RFC2822
|
||||
'mime-version' = '', // RFC2045
|
||||
'content-transfer-encoding' = '',
|
||||
'content-type' = '',
|
||||
'delivered-to' = [], // RFC9228 addr-spec
|
||||
'authentication-results' = '', // dkim, spf, dmarc
|
||||
'dkim-signature' = '',
|
||||
'x-rspamd-queue-id' = '',
|
||||
'x-rspamd-action' = '',
|
||||
'x-spamd-bar' = '',
|
||||
'x-rspamd-server' = '',
|
||||
'x-spamd-result' = '',
|
||||
'x-remote-address' = '',
|
||||
// etc.
|
||||
};
|
||||
}
|
||||
*/
|
||||
|
||||
|
|
@ -50,26 +83,25 @@ export function ParseMime(text)
|
|||
get body() {
|
||||
let body = this.bodyRaw,
|
||||
charset = this.header('content-type')?.params.charset,
|
||||
encoding = this.headerValue('content-transfer-encoding');
|
||||
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
|
||||
if ('quoted-printable' == encoding) {
|
||||
body = QPDecode(body);
|
||||
} else if ('base64' == encoding) {
|
||||
body = atob(body.replace(/\r?\n/g, ''));
|
||||
body = BDecode(body.replace(/\r?\n/g, ''));
|
||||
}
|
||||
return decodeText(charset, body);
|
||||
}
|
||||
|
||||
get dataUrl() {
|
||||
let body = this.bodyRaw,
|
||||
encoding = this.headerValue('content-transfer-encoding');
|
||||
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
|
||||
if ('base64' == encoding) {
|
||||
body = body.replace(/\r?\n/g, '');
|
||||
} else {
|
||||
if ('quoted-printable' == encoding) {
|
||||
body = QPDecode(body);
|
||||
}
|
||||
body = btoa(body);
|
||||
// body = b64Encode(body);
|
||||
body = BEncode(body);
|
||||
}
|
||||
return 'data:' + this.headerValue('content-type') + ';base64,' + body;
|
||||
}
|
||||
|
|
@ -80,7 +112,7 @@ export function ParseMime(text)
|
|||
}
|
||||
|
||||
getByContentType(type) {
|
||||
if (type == this.headerValue('content-type')) {
|
||||
if (type == this.headerValue('content-type')?.toLowerCase()) {
|
||||
return this;
|
||||
}
|
||||
let i = 0, p = this.parts, part;
|
||||
|
|
@ -92,6 +124,9 @@ export function ParseMime(text)
|
|||
}
|
||||
}
|
||||
|
||||
// mailbox-list or address-list
|
||||
const lists = ['from','reply-to','to','cc','bcc'];
|
||||
|
||||
const ParsePart = (mimePart, start_pos = 0, id = '') =>
|
||||
{
|
||||
let part = new MimePart,
|
||||
|
|
@ -113,11 +148,19 @@ export function ParseMime(text)
|
|||
[...header.matchAll(/;\s*([^;=]+)=\s*"?([^;"]+)"?/g)].forEach(param =>
|
||||
params[param[1].trim().toLowerCase()] = param[2].trim()
|
||||
);
|
||||
// encoded-word = "=?" charset "?" encoding "?" encoded-text "?="
|
||||
match[2] = match[2].trim().replace(/=\?([^?]+)\?(B|Q)\?(.+?)\?=/g, (m, charset, encoding, text) =>
|
||||
decodeText(charset, 'B' == encoding ? atob(text) : QPDecode(text))
|
||||
);
|
||||
headers[match[1].trim().toLowerCase()] = {
|
||||
let field = match[1].trim().toLowerCase();
|
||||
if (lists.includes(field)) {
|
||||
match[2] = addressparser(match[2]);
|
||||
} else if ('keywords' === field) {
|
||||
match[2] = match[2].split(',').forEach(entry => decodeEncodedWords(entry.trim()));
|
||||
match[2] = (headers[field]?.value || []).concat(match[2]);
|
||||
} else {
|
||||
match[2] = decodeEncodedWords(match[2].trim());
|
||||
if ('comments' === field) {
|
||||
match[2] = (headers[field]?.value || []).push(match[2]);
|
||||
}
|
||||
}
|
||||
headers[field] = {
|
||||
value: match[2],
|
||||
params: params
|
||||
};
|
||||
|
|
@ -132,7 +175,7 @@ export function ParseMime(text)
|
|||
let boundary = headers['content-type']?.params.boundary;
|
||||
if (boundary) {
|
||||
part.boundary = boundary;
|
||||
let regex = new RegExp('(?:^|\r?\n)--' + boundary + '(?:--)?(?:\r?\n|$)', 'g'),
|
||||
let regex = new RegExp('(?:^|\r?\n)--' + RegExp.escape(boundary) + '(?:--)?(?:\r?\n|$)', 'g'),
|
||||
body = mimePart.slice(head.length),
|
||||
bodies = body.split(regex),
|
||||
pos = part.bodyStart;
|
||||
|
|
|
|||
|
|
@ -4,23 +4,34 @@ import { AttachmentModel } from 'Model/Attachment';
|
|||
import { FileInfo } from 'Common/File';
|
||||
import { BEGIN_PGP_MESSAGE } from 'Stores/User/Pgp';
|
||||
|
||||
import { EmailModel } from 'Model/Email';
|
||||
|
||||
/**
|
||||
* @param string data
|
||||
* @param MessageModel message
|
||||
*/
|
||||
export function MimeToMessage(data, message)
|
||||
{
|
||||
let signed;
|
||||
const struct = ParseMime(data);
|
||||
if (struct.headers) {
|
||||
let html = struct.getByContentType('text/html'),
|
||||
subject = struct.headerValue('subject');
|
||||
html = html ? html.body : '';
|
||||
|
||||
// Content-Type: ...; protected-headers="v1"
|
||||
subject && message.subject(subject);
|
||||
|
||||
// EmailCollectionModel
|
||||
['from','to'].forEach(name => message[name].fromString(struct.headerValue(name)));
|
||||
['from','to'].forEach(name => {
|
||||
const items = message[name];
|
||||
struct.headerValue(name)?.forEach(item => {
|
||||
item = new EmailModel(item.email, item.name);
|
||||
// Make them unique
|
||||
if (item.email && item.name || !items.find(address => address.email == item.email)) {
|
||||
items.push(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
struct.forEach(part => {
|
||||
let cd = part.header('content-disposition'),
|
||||
|
|
@ -54,12 +65,28 @@ export function MimeToMessage(data, message)
|
|||
} else {
|
||||
message.attachments.push(attachment);
|
||||
}
|
||||
} else if ('multipart/signed' === type.value && 'application/pgp-signature' === type.params.protocol) {
|
||||
signed = {
|
||||
} else if ('multipart/signed' === type.value) {
|
||||
let protocol = type.params.protocol;
|
||||
if ('application/pgp-signature' === protocol) {
|
||||
message.pgpSigned({
|
||||
micAlg: type.micalg,
|
||||
bodyPart: part.parts[0],
|
||||
sigPart: part.parts[1]
|
||||
});
|
||||
} else if ('application/pkcs7-signature' === protocol.replace('x-')) {
|
||||
message.smimeSigned({
|
||||
micAlg: type.micalg,
|
||||
bodyPart: part,
|
||||
sigPart: part.parts[1], // For importing
|
||||
detached: true
|
||||
});
|
||||
}
|
||||
} else if ('application/pkcs7-mime' === type.value /*&& 'signed-data' === type.params['smime-type']=*/) {
|
||||
message.smimeSigned({
|
||||
micAlg: type.micalg,
|
||||
bodyPart: part.parts[0],
|
||||
sigPart: part.parts[1]
|
||||
};
|
||||
bodyPart: part,
|
||||
detached: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -70,10 +97,7 @@ export function MimeToMessage(data, message)
|
|||
message.plain(data);
|
||||
}
|
||||
|
||||
if (!signed && message.plain().includes(BEGIN_PGP_MESSAGE)) {
|
||||
signed = true;
|
||||
if (message.plain().includes(BEGIN_PGP_MESSAGE)) {
|
||||
message.pgpSigned(true);
|
||||
}
|
||||
message.pgpSigned(signed);
|
||||
|
||||
// TODO: Verify instantly?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export class AbstractCollectionModel extends Array
|
|||
constructor() {
|
||||
/*
|
||||
if (new.target === AbstractCollectionModel) {
|
||||
throw new Error("Can't instantiate AbstractCollectionModel!");
|
||||
throw Error("Can't instantiate AbstractCollectionModel!");
|
||||
}
|
||||
*/
|
||||
super();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ export class AccountModel extends AbstractModel {
|
|||
&& setTimeout(()=>this.fetchUnread(), (Math.ceil(Math.random() * 10)) * 3000);
|
||||
}
|
||||
|
||||
label() {
|
||||
return this.name || IDN.toUnicode(this.email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get INBOX unread messages
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -126,7 +126,10 @@ export class AttachmentModel extends AbstractModel {
|
|||
}
|
||||
|
||||
get download() {
|
||||
return b64EncodeJSONSafe({
|
||||
return b64EncodeJSONSafe(this.url ? {
|
||||
fileName: this.fileName,
|
||||
data: this.url.replace(/^.+,/, '')
|
||||
} : {
|
||||
folder: this.folder,
|
||||
uid: this.uid,
|
||||
mimeIndex: this.mimeIndex,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { baseCollator } from 'Common/Translator';
|
||||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||
import { AttachmentModel } from 'Model/Attachment';
|
||||
|
||||
|
|
@ -11,7 +12,7 @@ export class AttachmentCollectionModel extends AbstractCollectionModel
|
|||
*/
|
||||
static reviveFromJson(items) {
|
||||
const attachments = super.reviveFromJson(items, attachment => AttachmentModel.reviveFromJson(attachment));
|
||||
let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
|
||||
let collator = baseCollator(true);
|
||||
attachments.sort((a, b) => {
|
||||
if (a.isInline()) {
|
||||
if (!b.isInline()) {
|
||||
|
|
|
|||
|
|
@ -194,13 +194,6 @@ export class ContactModel extends AbstractModel {
|
|||
return contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
generateUid() {
|
||||
return '' + this.id;
|
||||
}
|
||||
|
||||
addEmail() {
|
||||
// home, work
|
||||
this.email.push({
|
||||
|
|
|
|||
|
|
@ -4,202 +4,6 @@ import { AbstractModel } from 'Knoin/AbstractModel';
|
|||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parses structured e-mail addresses from an address field
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* "Name <address@domain>"
|
||||
*
|
||||
* will be converted to
|
||||
*
|
||||
* [{name: "Name", address: "address@domain"}]
|
||||
*
|
||||
* @param {String} str Address field
|
||||
* @return {Array} An array of address objects
|
||||
*/
|
||||
export function addressparser(str) {
|
||||
str = (str || '').toString();
|
||||
|
||||
let
|
||||
endOperator = '',
|
||||
node = {
|
||||
type: 'text',
|
||||
value: ''
|
||||
},
|
||||
escaped = false,
|
||||
address = [],
|
||||
addresses = [];
|
||||
|
||||
const
|
||||
/*
|
||||
* Operator tokens and which tokens are expected to end the sequence
|
||||
*/
|
||||
OPERATORS = {
|
||||
'"': '"',
|
||||
'(': ')',
|
||||
'<': '>',
|
||||
',': '',
|
||||
// Groups are ended by semicolons
|
||||
':': ';',
|
||||
// Semicolons are not a legal delimiter per the RFC2822 grammar other
|
||||
// than for terminating a group, but they are also not valid for any
|
||||
// other use in this context. Given that some mail clients have
|
||||
// historically allowed the semicolon as a delimiter equivalent to the
|
||||
// comma in their UI, it makes sense to treat them the same as a comma
|
||||
// when used outside of a group.
|
||||
';': ''
|
||||
},
|
||||
pushToken = token => {
|
||||
token.value = (token.value || '').toString().trim();
|
||||
token.value.length && address.push(token);
|
||||
node = {
|
||||
type: 'text',
|
||||
value: ''
|
||||
},
|
||||
escaped = false;
|
||||
},
|
||||
pushAddress = () => {
|
||||
if (address.length) {
|
||||
address = _handleAddress(address);
|
||||
if (address.length) {
|
||||
addresses = addresses.concat(address);
|
||||
}
|
||||
}
|
||||
address = [];
|
||||
};
|
||||
|
||||
[...str].forEach(chr => {
|
||||
if (!escaped && (chr === endOperator || (!endOperator && chr in OPERATORS))) {
|
||||
pushToken(node);
|
||||
if (',' === chr || ';' === chr) {
|
||||
pushAddress();
|
||||
} else {
|
||||
endOperator = endOperator ? '' : OPERATORS[chr];
|
||||
if ('<' === chr) {
|
||||
node.type = 'email';
|
||||
} else if ('(' === chr) {
|
||||
node.type = 'comment';
|
||||
} else if (':' === chr) {
|
||||
node.type = 'group';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
node.value += chr;
|
||||
escaped = !escaped && '\\' === chr;
|
||||
}
|
||||
});
|
||||
pushToken(node);
|
||||
|
||||
pushAddress();
|
||||
|
||||
return addresses;
|
||||
// return addresses.map(item => (item.name || item.email) ? new EmailModel(item.email, item.name) : null).filter(v => v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts tokens for a single address into an address object
|
||||
*
|
||||
* @param {Array} tokens Tokens object
|
||||
* @return {Object} Address object
|
||||
*/
|
||||
function _handleAddress(tokens) {
|
||||
let
|
||||
isGroup = false,
|
||||
address = {},
|
||||
addresses = [],
|
||||
data = {
|
||||
email: [],
|
||||
comment: [],
|
||||
group: [],
|
||||
text: []
|
||||
};
|
||||
|
||||
tokens.forEach(token => {
|
||||
isGroup = isGroup || 'group' === token.type;
|
||||
data[token.type].push(token.value);
|
||||
});
|
||||
|
||||
// If there is no text but a comment, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
||||
/*
|
||||
addresses.push({
|
||||
email: '',
|
||||
name: data.text.join(' ').trim(),
|
||||
group: addressparser(data.group.join(','))
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
});
|
||||
*/
|
||||
addresses = addresses.concat(addressparser(data.group.join(',')));
|
||||
} else {
|
||||
// If no address was found, try to detect one from regular text
|
||||
if (!data.email.length && data.text.length) {
|
||||
var i = data.text.length;
|
||||
while (i--) {
|
||||
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
||||
data.email = data.text.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// still no address
|
||||
if (!data.email.length) {
|
||||
i = data.text.length;
|
||||
while (i--) {
|
||||
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, address => {
|
||||
if (!data.email.length) {
|
||||
data.email = [address.trim()];
|
||||
return '';
|
||||
}
|
||||
return address.trim();
|
||||
});
|
||||
if (data.email.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's still no text but a comment exists, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
|
||||
// Keep only the first address occurence, push others to regular text
|
||||
if (data.email.length > 1) {
|
||||
data.text = data.text.concat(data.email.splice(1));
|
||||
}
|
||||
|
||||
address = {
|
||||
// Join values with spaces
|
||||
email: data.email.join(' ').trim(),
|
||||
name: data.text.join(' ').trim()
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
};
|
||||
|
||||
if (address.email === address.name) {
|
||||
if (address.email.includes('@')) {
|
||||
address.name = '';
|
||||
} else {
|
||||
address.email = '';
|
||||
}
|
||||
}
|
||||
|
||||
// address.email = address.email.replace(/^[<]+(.*)[>]+$/g, '$1');
|
||||
|
||||
addresses.push(address);
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
export class EmailModel extends AbstractModel {
|
||||
/**
|
||||
* @param {string=} email = ''
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||
import { EmailModel, addressparser } from 'Model/Email';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { forEachObjectValue } from 'Common/Utils';
|
||||
import { addressparser } from 'Mime/Address';
|
||||
|
||||
'use strict';
|
||||
|
||||
|
|
@ -51,4 +52,24 @@ export class EmailCollectionModel extends AbstractCollectionModel
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {array} [{name: "Name", email: "address@domain"}]
|
||||
*/
|
||||
/*
|
||||
static fromArray(addresses) {
|
||||
let list = new this();
|
||||
list.fromArray(addresses);
|
||||
return list;
|
||||
}
|
||||
fromArray(addresses) {
|
||||
addresses.forEach(item => {
|
||||
item = new EmailModel(item.email, item.name);
|
||||
// Make them unique
|
||||
if (item.email && item.name || !this.find(address => address.email == item.email)) {
|
||||
this.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
|||
import { UNUSED_OPTION_VALUE } from 'Common/Consts';
|
||||
import { isArray, getKeyByValue, forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
|
||||
import { ClientSideKeyNameExpandedFolders, FolderType, FolderMetadataKeys } from 'Common/EnumsUser';
|
||||
import { clearCache, getFolderFromCacheList, setFolder, setFolderInboxName, removeFolderFromCacheList } from 'Common/Cache';
|
||||
import { clearCache, getFolderFromCacheList, setFolder, setFolderInboxName } from 'Common/Cache';
|
||||
import { Settings, SettingsGet, fireEvent } from 'Common/Globals';
|
||||
import { Notifications } from 'Common/Enums';
|
||||
|
||||
import * as Local from 'Storage/Client';
|
||||
|
||||
|
|
@ -15,18 +14,23 @@ import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
|||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
import { sortFolders } from 'Common/Folders';
|
||||
import { i18n, translateTrigger, getNotification } from 'Common/Translator';
|
||||
import { i18n, translateTrigger } from 'Common/Translator';
|
||||
|
||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
|
||||
import { /*koComputable,*/ addObservablesTo } from 'External/ko';
|
||||
|
||||
//import { mailBox } from 'Common/Links';
|
||||
import { mailBox } from 'Common/Links';
|
||||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { FileInfo } from 'Common/File';
|
||||
|
||||
import { FolderPopupView } from 'View/Popup/Folder';
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
|
||||
import { isAllowedKeyword } from 'Stores/User/Folder';
|
||||
|
||||
const
|
||||
// isPosNumeric = value => null != value && /^[0-9]*$/.test(value.toString()),
|
||||
|
||||
|
|
@ -113,6 +117,8 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
|||
this.namespace;
|
||||
this.optimized
|
||||
this.capabilities
|
||||
this.allow; // allow adding
|
||||
// this.exist;
|
||||
}
|
||||
*/
|
||||
|
||||
|
|
@ -254,6 +260,10 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
|||
return result;
|
||||
}
|
||||
|
||||
visible() {
|
||||
return this.filter(folder => folder.visible());
|
||||
}
|
||||
|
||||
storeIt() {
|
||||
FolderUserStore.displaySpecSetting(Settings.app('folderSpecLimit') < this.CountRec);
|
||||
|
||||
|
|
@ -296,6 +306,7 @@ export class FolderModel extends AbstractModel {
|
|||
super();
|
||||
|
||||
this.fullName = '';
|
||||
this.parentName = '';
|
||||
this.delimiter = '';
|
||||
this.deep = 0;
|
||||
this.expires = 0;
|
||||
|
|
@ -316,12 +327,10 @@ export class FolderModel extends AbstractModel {
|
|||
|
||||
focused: false,
|
||||
selected: false,
|
||||
editing: false,
|
||||
isSubscribed: true,
|
||||
checkable: false, // Check for new messages
|
||||
askDelete: false,
|
||||
|
||||
nameForEdit: '',
|
||||
errorMsg: '',
|
||||
|
||||
totalEmails: 0,
|
||||
|
|
@ -341,7 +350,6 @@ export class FolderModel extends AbstractModel {
|
|||
this.addSubscribables({
|
||||
kolabType: sValue => this.metadata[FolderMetadataKeys.KolabFolderType] = sValue,
|
||||
permanentFlags: aValue => this.tagsAllowed(aValue.includes('\\*')),
|
||||
editing: value => value && this.nameForEdit(this.name()),
|
||||
unreadEmails: unread => FolderType.Inbox === this.type() && fireEvent('mailbox.inbox-unread-count', unread)
|
||||
});
|
||||
|
||||
|
|
@ -363,20 +371,19 @@ export class FolderModel extends AbstractModel {
|
|||
.extend({ notify: 'always' });
|
||||
*/
|
||||
/*
|
||||
https://www.rfc-editor.org/rfc/rfc8621.html#section-2
|
||||
"myRights": {
|
||||
"mayAddItems": true,
|
||||
"mayRename": false,
|
||||
"maySubmit": true,
|
||||
"mayDelete": false,
|
||||
"maySetKeywords": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"maySetSeen": true,
|
||||
"mayReadItems": true
|
||||
},
|
||||
// https://www.rfc-editor.org/rfc/rfc8621.html#section-2
|
||||
this.myRights = {
|
||||
'mayAddItems': true,
|
||||
'mayCreateChild': true,
|
||||
'mayDelete': true,
|
||||
'mayReadItems': true,
|
||||
'mayRemoveItems': true,
|
||||
'mayRename': true,
|
||||
'maySetKeywords': true,
|
||||
'maySetSeen': true,
|
||||
'maySubmit': true
|
||||
};
|
||||
*/
|
||||
|
||||
this.addComputables({
|
||||
|
||||
isInbox: () => FolderType.Inbox === this.type(),
|
||||
|
|
@ -387,6 +394,7 @@ export class FolderModel extends AbstractModel {
|
|||
// isSubscribed: () => this.attributes().includes('\\subscribed'),
|
||||
|
||||
hasVisibleSubfolders: () => !!this.subFolders().find(folder => folder.visible()),
|
||||
visibleSubfolders: () => this.subFolders().visible(),
|
||||
|
||||
hasSubscriptions: () => this.isSubscribed() | !!this.subFolders().find(
|
||||
oFolder => {
|
||||
|
|
@ -395,8 +403,6 @@ export class FolderModel extends AbstractModel {
|
|||
}
|
||||
),
|
||||
|
||||
canBeEdited: () => !this.type() && this.exists/* && this.selectable()*/,
|
||||
|
||||
isSystemFolder: () => this.type()
|
||||
| (FolderUserStore.allowKolab() && !!this.kolabType() & !SettingsUserStore.unhideKolabFolders()),
|
||||
|
||||
|
|
@ -407,6 +413,8 @@ export class FolderModel extends AbstractModel {
|
|||
canBeSubscribed: () => this.selectable()
|
||||
&& !(this.isSystemFolder() | !SettingsUserStore.hideUnsubscribed()),
|
||||
|
||||
optionalTags: () => this.permanentFlags.filter(isAllowedKeyword),
|
||||
|
||||
/**
|
||||
* Folder is visible when:
|
||||
* - hasVisibleSubfolders()
|
||||
|
|
@ -463,60 +471,30 @@ export class FolderModel extends AbstractModel {
|
|||
|
||||
detailedName: () => this.name() + ' ' + this.nameInfo(),
|
||||
|
||||
hasSubscribedUnreadMessagesSubfolders: () =>
|
||||
!!this.subFolders().find(
|
||||
folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
|
||||
)
|
||||
/*
|
||||
!!this.subFolders().filter(
|
||||
folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
|
||||
).length
|
||||
*/
|
||||
// ,href: () => this.canBeSelected() && mailBox(this.fullNameHash)
|
||||
icon: () => {
|
||||
switch (this.type())
|
||||
{
|
||||
case 1: return '📥'; // FolderType.Inbox
|
||||
case 2: return '📧'; // FolderType.Sent icon-paper-plane
|
||||
case 3: return '🗎'; // FolderType.Drafts
|
||||
case 4: return '⚠'; // FolderType.Junk
|
||||
case 5: return '🗑'; // FolderType.Trash
|
||||
case 6: return '🗄'; // FolderType.Archive
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
hasUnreadInSub: () =>
|
||||
this.subFolders().some(
|
||||
folder => folder.unreadEmails() | folder.hasUnreadInSub()
|
||||
),
|
||||
|
||||
href: () => this.canBeSelected() && mailBox(this.fullNameHash)
|
||||
});
|
||||
}
|
||||
|
||||
edit() {
|
||||
this.canBeEdited() && this.editing(true);
|
||||
}
|
||||
|
||||
unedit() {
|
||||
this.editing(false);
|
||||
}
|
||||
|
||||
rename() {
|
||||
const folder = this,
|
||||
nameToEdit = folder.nameForEdit().trim();
|
||||
if (nameToEdit && folder.name() !== nameToEdit) {
|
||||
Remote.abort('Folders').post('FolderRename', FolderUserStore.foldersRenaming, {
|
||||
folder: folder.fullName,
|
||||
newFolderName: nameToEdit,
|
||||
subscribe: folder.isSubscribed() ? 1 : 0
|
||||
})
|
||||
.then(data => {
|
||||
folder.name(nameToEdit/*data.name*/);
|
||||
if (folder.subFolders.length) {
|
||||
Remote.setTrigger(FolderUserStore.foldersLoading, true);
|
||||
// clearTimeout(Remote.foldersTimeout);
|
||||
// Remote.foldersTimeout = setTimeout(loadFolders, 500);
|
||||
setTimeout(loadFolders, 500);
|
||||
// TODO: rename all subfolders with folder.delimiter to prevent reload?
|
||||
} else {
|
||||
removeFolderFromCacheList(folder.fullName);
|
||||
folder.fullName = data.Result.fullName;
|
||||
setFolder(folder);
|
||||
const parent = getFolderFromCacheList(folder.parentName);
|
||||
sortFolders(parent ? parent.subFolders : FolderUserStore.folderList);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
FolderUserStore.error(
|
||||
getNotification(error.code, '', Notifications.CantRenameFolder)
|
||||
+ '.\n' + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
folder.editing(false);
|
||||
showScreenPopup(FolderPopupView, [this]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
import { addObservablesTo } from 'External/ko';
|
||||
import { addObservablesTo, addComputablesTo } from 'External/ko';
|
||||
|
||||
export class IdentityModel extends AbstractModel {
|
||||
/**
|
||||
|
|
@ -11,17 +11,31 @@ export class IdentityModel extends AbstractModel {
|
|||
|
||||
addObservablesTo(this, {
|
||||
id: '',
|
||||
label: '',
|
||||
email: '',
|
||||
name: '',
|
||||
|
||||
replyTo: '',
|
||||
bcc: '',
|
||||
sentFolder: '',
|
||||
|
||||
signature: '',
|
||||
signatureInsertBefore: false,
|
||||
|
||||
pgpSign: false,
|
||||
pgpEncrypt: false,
|
||||
|
||||
smimeKey: '',
|
||||
smimeCertificate: '',
|
||||
|
||||
askDelete: false
|
||||
});
|
||||
|
||||
addComputablesTo(this, {
|
||||
smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----'),
|
||||
smimeKeyValid: () => /^-----BEGIN (ENCRYPTED |RSA )?PRIVATE KEY-----/.test(this.smimeKey()),
|
||||
smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate())
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -29,8 +43,8 @@ export class IdentityModel extends AbstractModel {
|
|||
*/
|
||||
formattedName() {
|
||||
const name = this.name(),
|
||||
email = this.email();
|
||||
|
||||
return name ? name + ' <' + email + '>' : email;
|
||||
email = this.email(),
|
||||
label = this.label();
|
||||
return (name ? `${name} ` : '') + `<${email}>` + (label ? ` (${label})` : '');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ import { forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
|
|||
import { serverRequestRaw, proxy } from 'Common/Links';
|
||||
import { addObservablesTo, addComputablesTo } from 'External/ko';
|
||||
|
||||
import { FolderUserStore, isAllowedKeyword } from 'Stores/User/Folder';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
import { FileInfo, RFC822 } from 'Common/File';
|
||||
import { AttachmentCollectionModel } from 'Model/AttachmentCollection';
|
||||
import { EmailCollectionModel } from 'Model/EmailCollection';
|
||||
import { MimeHeaderCollectionModel } from 'Model/MimeHeaderCollection';
|
||||
//import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
|
||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
|
||||
import PreviewHTML from 'Html/PreviewMessage.html';
|
||||
|
|
@ -22,6 +24,8 @@ import { LanguageStore } from 'Stores/Language';
|
|||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { MimeToMessage } from 'Mime/Utils';
|
||||
|
||||
const
|
||||
msgHtml = msg => cleanHtml(msg.html(), msg.attachments(), '#rl-msg-' + msg.hash),
|
||||
|
||||
|
|
@ -55,42 +59,50 @@ export class MessageModel extends AbstractModel {
|
|||
constructor() {
|
||||
super();
|
||||
|
||||
this.folder = '';
|
||||
this.uid = 0;
|
||||
this.hash = '';
|
||||
this.from = new EmailCollectionModel;
|
||||
this.to = new EmailCollectionModel;
|
||||
this.cc = new EmailCollectionModel;
|
||||
this.bcc = new EmailCollectionModel;
|
||||
this.sender = new EmailCollectionModel;
|
||||
this.replyTo = new EmailCollectionModel;
|
||||
this.deliveredTo = new EmailCollectionModel;
|
||||
this.body = null;
|
||||
this.draftInfo = [];
|
||||
this.dkim = [];
|
||||
this.spf = [];
|
||||
this.dmarc = [];
|
||||
this.messageId = '';
|
||||
this.inReplyTo = '';
|
||||
this.references = '';
|
||||
this.autocrypt = {};
|
||||
Object.assign(this, {
|
||||
folder: '',
|
||||
uid: 0,
|
||||
hash: '',
|
||||
from: new EmailCollectionModel,
|
||||
to: new EmailCollectionModel,
|
||||
cc: new EmailCollectionModel,
|
||||
bcc: new EmailCollectionModel,
|
||||
sender: new EmailCollectionModel,
|
||||
replyTo: new EmailCollectionModel,
|
||||
deliveredTo: new EmailCollectionModel,
|
||||
body: null,
|
||||
draftInfo: [],
|
||||
dkim: [],
|
||||
spf: [],
|
||||
dmarc: [],
|
||||
messageId: '',
|
||||
inReplyTo: '',
|
||||
references: '',
|
||||
// autocrypt: ko.observableArray(),
|
||||
hasVirus: null, // or boolean when scanned
|
||||
priority: 3, // Normal
|
||||
senderEmailsString: '',
|
||||
senderClearEmailsString: '',
|
||||
isSpam: false,
|
||||
spamScore: 0,
|
||||
spamResult: '',
|
||||
size: 0,
|
||||
readReceipt: '',
|
||||
preview: null,
|
||||
|
||||
attachments: ko.observableArray(new AttachmentCollectionModel),
|
||||
threads: ko.observableArray(),
|
||||
threadUnseen: ko.observableArray(),
|
||||
unsubsribeLinks: ko.observableArray(),
|
||||
flags: ko.observableArray(),
|
||||
headers: ko.observableArray(new MimeHeaderCollectionModel)
|
||||
});
|
||||
|
||||
addObservablesTo(this, {
|
||||
subject: '',
|
||||
plain: '',
|
||||
html: '',
|
||||
preview: null,
|
||||
size: 0,
|
||||
spamScore: 0,
|
||||
spamResult: '',
|
||||
isSpam: false,
|
||||
hasVirus: null, // or boolean when scanned
|
||||
dateTimestamp: 0,
|
||||
internalTimestamp: 0,
|
||||
priority: 3, // Normal
|
||||
|
||||
senderEmailsString: '',
|
||||
senderClearEmailsString: '',
|
||||
|
||||
deleted: false,
|
||||
|
||||
|
|
@ -102,26 +114,29 @@ export class MessageModel extends AbstractModel {
|
|||
isHtml: false,
|
||||
hasImages: false,
|
||||
hasExternals: false,
|
||||
|
||||
pgpSigned: null,
|
||||
pgpVerified: null,
|
||||
hasTracking: false,
|
||||
|
||||
encrypted: false,
|
||||
|
||||
pgpSigned: null,
|
||||
pgpEncrypted: null,
|
||||
pgpDecrypted: false,
|
||||
|
||||
readReceipt: '',
|
||||
smimeSigned: null,
|
||||
smimeEncrypted: null,
|
||||
smimeDecrypted: false,
|
||||
|
||||
// rfc8621
|
||||
id: '',
|
||||
// threadId: ''
|
||||
});
|
||||
|
||||
this.attachments = ko.observableArray(new AttachmentCollectionModel);
|
||||
this.threads = ko.observableArray();
|
||||
this.threadUnseen = ko.observableArray();
|
||||
this.unsubsribeLinks = ko.observableArray();
|
||||
this.flags = ko.observableArray();
|
||||
/**
|
||||
* Basic support for Linked Data (Structured Email)
|
||||
* https://json-ld.org/
|
||||
* https://structured.email/
|
||||
**/
|
||||
linkedData: []
|
||||
});
|
||||
|
||||
addComputablesTo(this, {
|
||||
attachmentIconClass: () =>
|
||||
|
|
@ -141,17 +156,15 @@ export class MessageModel extends AbstractModel {
|
|||
|
||||
tagOptions: () => {
|
||||
const tagOptions = [];
|
||||
FolderUserStore.currentFolder().permanentFlags.forEach(value => {
|
||||
if (isAllowedKeyword(value)) {
|
||||
let lower = value.toLowerCase();
|
||||
tagOptions.push({
|
||||
css: 'msgflag-' + lower,
|
||||
value: value,
|
||||
checked: this.flags().includes(lower),
|
||||
label: i18n('MESSAGE_TAGS/'+lower, 0, value),
|
||||
toggle: (/*obj*/) => toggleTag(this, value)
|
||||
});
|
||||
}
|
||||
FolderUserStore.currentFolder().optionalTags().forEach(value => {
|
||||
let lower = value.toLowerCase();
|
||||
tagOptions.push({
|
||||
css: 'msgflag-' + lower,
|
||||
value: value,
|
||||
checked: this.flags().includes(lower),
|
||||
label: i18n('MESSAGE_TAGS/'+lower, 0, value),
|
||||
toggle: (/*obj*/) => toggleTag(this, value)
|
||||
});
|
||||
});
|
||||
return tagOptions
|
||||
},
|
||||
|
|
@ -180,6 +193,10 @@ export class MessageModel extends AbstractModel {
|
|||
return options;
|
||||
}
|
||||
});
|
||||
|
||||
this.smimeSigned.subscribe(value =>
|
||||
value?.body && MimeToMessage(value.body, this)
|
||||
);
|
||||
}
|
||||
|
||||
get requestHash() {
|
||||
|
|
@ -197,23 +214,23 @@ export class MessageModel extends AbstractModel {
|
|||
}
|
||||
|
||||
spamStatus() {
|
||||
let spam = this.spamResult();
|
||||
return spam ? i18n(this.isSpam() ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
|
||||
let spam = this.spamResult;
|
||||
return spam ? i18n(this.isSpam ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
friendlySize() {
|
||||
return FileInfo.friendlySize(this.size());
|
||||
return FileInfo.friendlySize(this.size);
|
||||
}
|
||||
|
||||
computeSenderEmail() {
|
||||
const list = this[
|
||||
[FolderUserStore.sentFolder(), FolderUserStore.draftsFolder()].includes(this.folder) ? 'to' : 'from'
|
||||
];
|
||||
this.senderEmailsString(list.toString(true));
|
||||
this.senderClearEmailsString(list.map(email => email?.email).filter(email => email).join(', '));
|
||||
this.senderEmailsString = list.toString(true);
|
||||
this.senderClearEmailsString = list.map(email => email?.email).filter(email => email).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -224,8 +241,63 @@ export class MessageModel extends AbstractModel {
|
|||
if (super.revivePropertiesFromJson(json)) {
|
||||
// this.foundCIDs = isArray(json.FoundCIDs) ? json.FoundCIDs : [];
|
||||
// this.attachments(AttachmentCollectionModel.reviveFromJson(json.attachments, this.foundCIDs));
|
||||
// this.headers(MimeHeaderCollectionModel.reviveFromJson(json.headers));
|
||||
|
||||
this.computeSenderEmail();
|
||||
|
||||
let value, headers = this.headers();
|
||||
/* // These could be by Envelope or MIME
|
||||
this.messageId = headers.valueByName('Message-Id');
|
||||
this.subject(headers.valueByName('Subject'));
|
||||
this.sender = EmailCollectionModel.fromString(headers.valueByName('Sender'));
|
||||
this.from = EmailCollectionModel.fromArray(headers.valueByName('From'));
|
||||
this.replyTo = EmailCollectionModel.fromArray(headers.valueByName('Reply-To'));
|
||||
this.to = EmailCollectionModel.fromArray(headers.valueByName('To'));
|
||||
this.cc = EmailCollectionModel.fromArray(headers.valueByName('Cc'));
|
||||
this.bcc = EmailCollectionModel.fromArray(headers.valueByName('Bcc'));
|
||||
this.inReplyTo = headers.valueByName('In-Reply-To');
|
||||
|
||||
this.deliveredTo = EmailCollectionModel.fromString(headers.valueByName('Delivered-To'));
|
||||
*/
|
||||
// Priority
|
||||
value = headers.valueByName('X-MSMail-Priority')
|
||||
|| headers.valueByName('Importance')
|
||||
|| headers.valueByName('X-Priority');
|
||||
if (value) {
|
||||
if (/[h12]/.test(value[0])) {
|
||||
this.priority = 1;
|
||||
} else if (/[l45]/.test(value[0])) {
|
||||
this.priority = 5;
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe links
|
||||
if (value = headers.valueByName('List-Unsubscribe')) {
|
||||
this.unsubsribeLinks(value.split(',').map(
|
||||
link => link.replace(/^[ <>]+|[ <>]+$/g, '')
|
||||
));
|
||||
}
|
||||
|
||||
if (headers.valueByName('X-Virus')) {
|
||||
this.hasVirus = true;
|
||||
}
|
||||
if (value = headers.valueByName('X-Virus-Status')) {
|
||||
if (value.includes('infected')) {
|
||||
this.hasVirus = true;
|
||||
} else if (value.includes('clean')) {
|
||||
this.hasVirus = false;
|
||||
}
|
||||
}
|
||||
/*
|
||||
if (value = headers.valueByName('X-Virus-Scanned')) {
|
||||
this.virusScanned(value);
|
||||
}
|
||||
|
||||
// https://autocrypt.org/level1.html#the-autocrypt-header
|
||||
headers.valuesByName('Autocrypt').forEach(value => {
|
||||
this.autocrypt.push(new MimeHeaderAutocryptModel(value));
|
||||
});
|
||||
*/
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -241,7 +313,7 @@ export class MessageModel extends AbstractModel {
|
|||
checked: this.checked(),
|
||||
unseen: this.isUnseen(),
|
||||
focused: this.focused(),
|
||||
priorityHigh: this.priority() === 1,
|
||||
priorityHigh: this.priority === 1,
|
||||
withAttachments: !!this.attachments().length,
|
||||
// hasChildrenMessage: 1 < this.threadsLen()
|
||||
}, (key, value) => value && classes.push(key));
|
||||
|
|
@ -307,8 +379,10 @@ export class MessageModel extends AbstractModel {
|
|||
let result = msgHtml(this);
|
||||
this.hasExternals(result.hasExternals);
|
||||
this.hasImages(!!result.hasExternals);
|
||||
this.hasTracking(!!result.tracking);
|
||||
this.linkedData(result.linkedData);
|
||||
body.innerHTML = result.html;
|
||||
if (!this.isSpam() && FolderUserStore.spamFolder() != this.folder) {
|
||||
if (!this.isSpam && FolderUserStore.spamFolder() != this.folder) {
|
||||
if ('always' === SettingsUserStore.viewImages()) {
|
||||
this.showExternalImages();
|
||||
}
|
||||
|
|
@ -343,6 +417,11 @@ export class MessageModel extends AbstractModel {
|
|||
return this.viewBody(false);
|
||||
}
|
||||
|
||||
swapColors() {
|
||||
const cl = this.body?.classList;
|
||||
cl && cl.toggle('swapColors');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean=} print = false
|
||||
*/
|
||||
|
|
@ -381,13 +460,6 @@ export class MessageModel extends AbstractModel {
|
|||
this.popupMessage(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
generateUid() {
|
||||
return this.folder + '/' + this.uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {MessageModel}
|
||||
*//*
|
||||
|
|
@ -438,7 +510,7 @@ export class MessageModel extends AbstractModel {
|
|||
hasImages = true;
|
||||
},
|
||||
attr = 'data-x-src',
|
||||
src, useProxy = !!SettingsGet('useLocalProxyForExternalImages');
|
||||
src, useProxy = !!SettingsGet('proxyExternalImages');
|
||||
body.querySelectorAll('img[' + attr + ']').forEach(node => {
|
||||
src = node.getAttribute(attr);
|
||||
if (isValid(src)) {
|
||||
|
|
|
|||
13
dev/Model/MimeHeader.js
Normal file
13
dev/Model/MimeHeader.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import ko from 'ko';
|
||||
|
||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
|
||||
export class MimeHeaderModel extends AbstractModel
|
||||
{
|
||||
constructor() {
|
||||
super();
|
||||
this.name = '';
|
||||
this.value = '';
|
||||
this.parameters = ko.observableArray();
|
||||
}
|
||||
}
|
||||
32
dev/Model/MimeHeaderAutocrypt.js
Normal file
32
dev/Model/MimeHeaderAutocrypt.js
Normal 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-----';
|
||||
}
|
||||
}
|
||||
38
dev/Model/MimeHeaderCollection.js
Normal file
38
dev/Model/MimeHeaderCollection.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||
import { MimeHeaderModel } from 'Model/MimeHeader';
|
||||
|
||||
'use strict';
|
||||
|
||||
export class MimeHeaderCollectionModel extends AbstractCollectionModel
|
||||
{
|
||||
/**
|
||||
* @param {?Array} json
|
||||
* @returns {MimeHeaderCollectionModel}
|
||||
*/
|
||||
static reviveFromJson(items) {
|
||||
return super.reviveFromJson(items, header => MimeHeaderModel.reviveFromJson(header));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {?MimeHeader}
|
||||
*/
|
||||
getByName(name)
|
||||
{
|
||||
name = name.toLowerCase();
|
||||
return this.find(header => header.name.toLowerCase() === name);
|
||||
}
|
||||
|
||||
valueByName(name)
|
||||
{
|
||||
const header = this.getByName(name);
|
||||
return header ? header.value : '';
|
||||
}
|
||||
|
||||
valuesByName(name)
|
||||
{
|
||||
name = name.toLowerCase();
|
||||
return this.filter(header => header.name.toLowerCase() === name).map(header => header.value);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ checkResponseError = data => {
|
|||
Notifications.DomainNotAllowed,
|
||||
Notifications.AccountNotAllowed,
|
||||
Notifications.MailServerError,
|
||||
Notifications.UnknownNotification,
|
||||
Notifications.UnknownError
|
||||
].includes(err)
|
||||
) {
|
||||
|
|
@ -132,8 +131,8 @@ export class AbstractFetchRemote
|
|||
|
||||
fetchJSON(sAction, getURL(sGetAdd),
|
||||
sGetAdd ? null : (params || {}),
|
||||
undefined === iTimeout ? 30000 : pInt(iTimeout),
|
||||
data => {
|
||||
pInt(iTimeout ?? 30000),
|
||||
async data => {
|
||||
let iError = 0;
|
||||
if (data) {
|
||||
/*
|
||||
|
|
@ -149,6 +148,10 @@ export class AbstractFetchRemote
|
|||
}
|
||||
}
|
||||
|
||||
if (111 === iError && rl.app.ask && await rl.app.ask.cryptkey()) {
|
||||
return this.request(sAction, fCallback, params, iTimeout, sGetAdd);
|
||||
}
|
||||
|
||||
fCallback && fCallback(
|
||||
iError,
|
||||
data,
|
||||
|
|
@ -170,13 +173,6 @@ export class AbstractFetchRemote
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?Function} fCallback
|
||||
*/
|
||||
getPublicKey(fCallback) {
|
||||
this.request('GetPublicKey', fCallback);
|
||||
}
|
||||
|
||||
setTrigger(trigger, value) {
|
||||
if (trigger) {
|
||||
value = !!value;
|
||||
|
|
@ -193,12 +189,16 @@ export class AbstractFetchRemote
|
|||
post(action, fTrigger, params, timeOut) {
|
||||
this.setTrigger(fTrigger, true);
|
||||
return fetchJSON(action, getURL(), params || {}, pInt(timeOut, 30000),
|
||||
data => {
|
||||
async data => {
|
||||
abort(action, 0, 1);
|
||||
|
||||
if (!data) {
|
||||
return Promise.reject(new FetchError(Notifications.JsonParse));
|
||||
}
|
||||
|
||||
if (111 === data?.ErrorCode && rl.app.ask && await rl.app.ask.cryptkey()) {
|
||||
return this.post(action, fTrigger, params, timeOut);
|
||||
}
|
||||
/*
|
||||
let isCached = false, type = '';
|
||||
if (data?.epoch) {
|
||||
|
|
|
|||
|
|
@ -64,16 +64,6 @@ class RemoteUserFetch extends AbstractFetchRemote {
|
|||
[key]: value
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
folderMove(sPrevFolderFullName, sNewFolderFullName, bSubscribe) {
|
||||
return this.post('FolderMove', FolderUserStore.foldersRenaming, {
|
||||
folder: sPrevFolderFullName,
|
||||
newFolder: sNewFolderFullName,
|
||||
subscribe: bSubscribe ? 1 : 0
|
||||
});
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
export default new RemoteUserFetch();
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export class AbstractSettingsScreen extends AbstractScreen {
|
|||
rules = {
|
||||
subname: /^(.*)$/,
|
||||
normalize_: (rquest, vals) => {
|
||||
vals.subname = null == vals.subname ? defaultRoute : pString(vals.subname);
|
||||
vals.subname = pString(vals.subname ?? defaultRoute);
|
||||
return [vals.subname];
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export class MailBoxUserScreen extends AbstractScreen {
|
|||
* @returns {void}
|
||||
*/
|
||||
onRoute(folderHash, page, search, messageUid) {
|
||||
// Only works when FolderUserStore.folderList() is loaded
|
||||
const folder = getFolderFromHashMap(folderHash.replace(/~([\d]+)$/, ''));
|
||||
if (folder) {
|
||||
FolderUserStore.currentFolder(folder);
|
||||
|
|
@ -108,7 +109,7 @@ export class MailBoxUserScreen extends AbstractScreen {
|
|||
*/
|
||||
onBuild() {
|
||||
doc.addEventListener('click', event =>
|
||||
event.target.closest('#rl-right') && moveAction(false)
|
||||
event.target.closest('#rl-right') && moveAction(0)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,40 @@ import ko from 'ko';
|
|||
|
||||
import Remote from 'Remote/Admin/Fetch';
|
||||
import { forEachObjectEntry } from 'Common/Utils';
|
||||
import { SettingsAdmin } from 'Common/Globals';
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
import { ThemeStore } from 'Stores/Theme';
|
||||
|
||||
export class AdminSettingsConfig /*extends AbstractViewSettings*/ {
|
||||
|
||||
constructor() {
|
||||
this.config = ko.observableArray();
|
||||
this.search = ko.observableArray();
|
||||
this.saved = ko.observable(false).extend({ falseTimeout: 5000 });
|
||||
|
||||
this.search.subscribe(value => {
|
||||
const v = value.toLowerCase(),
|
||||
qsa = (node, selector, fn) => node.querySelectorAll(selector).forEach(fn),
|
||||
match = node => node.textContent.toLowerCase().includes(v);
|
||||
if (v.length) {
|
||||
qsa(this.viewModelDom, 'tbody', tbody => {
|
||||
let show = match(tbody.querySelector('th'));
|
||||
if (show) {
|
||||
qsa(tbody, '[hidden]', n => n.hidden = false);
|
||||
} else {
|
||||
qsa(tbody, 'tbody td:first-child', td => {
|
||||
let hide = !match(td);
|
||||
show = show || !hide;
|
||||
// td.closest('tr').hidden = hide;
|
||||
td.parentNode.hidden = hide;
|
||||
});
|
||||
}
|
||||
tbody.hidden = !show;
|
||||
});
|
||||
} else {
|
||||
qsa(this.viewModelDom, 'table [hidden]', n => n.hidden = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beforeShow() {
|
||||
|
|
@ -28,13 +56,19 @@ export class AdminSettingsConfig /*extends AbstractViewSettings*/ {
|
|||
items: []
|
||||
};
|
||||
forEachObjectEntry(items, (skey, item) => {
|
||||
if ('language' === skey) {
|
||||
item[2] = ('webmail' === key) ? LanguageStore.languages : SettingsAdmin('languages');
|
||||
} else if ('theme' === skey) {
|
||||
item[2] = ThemeStore.themes;
|
||||
}
|
||||
'admin_password' === skey ||
|
||||
section.items.push({
|
||||
key: `config[${key}][${skey}]`,
|
||||
name: skey,
|
||||
value: item[0],
|
||||
type: getInputType(item[0], skey.includes('password')),
|
||||
comment: item[1]
|
||||
comment: item[1],
|
||||
options: item[2]
|
||||
});
|
||||
});
|
||||
cfg.push(section);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export class AdminSettingsContacts extends AbstractViewSettings {
|
|||
this.addSetting('contactsMySQLSSLVerify');
|
||||
this.addSetting('contactsMySQLSSLCiphers');
|
||||
|
||||
this.addSetting('contactsSQLiteGlobal');
|
||||
|
||||
addObservablesTo(this, {
|
||||
testing: false,
|
||||
testContactsSuccess: false,
|
||||
|
|
@ -102,7 +104,8 @@ export class AdminSettingsContacts extends AbstractViewSettings {
|
|||
PdoPassword: this.contactsPdoPassword(),
|
||||
MySQLSSLCA: this.contactsMySQLSSLCA(),
|
||||
MySQLSSLVerify: this.contactsMySQLSSLVerify(),
|
||||
MySQLSSLCiphers: this.contactsMySQLSSLCiphers()
|
||||
MySQLSSLCiphers: this.contactsMySQLSSLCiphers(),
|
||||
SQLiteGlobal: this.contactsSQLiteGlobal()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import ko from 'ko';
|
||||
|
||||
import {
|
||||
isArray
|
||||
} from 'Common/Utils';
|
||||
|
||||
import { addObservablesTo, addSubscribablesTo, addComputablesTo } from 'External/ko';
|
||||
|
||||
import { SaveSettingStatus } from 'Common/Enums';
|
||||
import { Settings, SettingsGet, SettingsCapa } from 'Common/Globals';
|
||||
import { SettingsAdmin, SettingsGet, SettingsCapa } from 'Common/Globals';
|
||||
import { translatorReload, convertLangName } from 'Common/Translator';
|
||||
|
||||
import { AbstractViewSettings } from 'Knoin/AbstractViews';
|
||||
|
|
@ -24,11 +20,7 @@ export class AdminSettingsGeneral extends AbstractViewSettings {
|
|||
super();
|
||||
|
||||
this.language = LanguageStore.language;
|
||||
this.languages = LanguageStore.languages;
|
||||
|
||||
const aLanguagesAdmin = Settings.app('languagesAdmin');
|
||||
this.languagesAdmin = ko.observableArray(isArray(aLanguagesAdmin) ? aLanguagesAdmin : []);
|
||||
this.languageAdmin = ko.observable(SettingsGet('languageAdmin'));
|
||||
this.languageAdmin = ko.observable(SettingsAdmin('language'));
|
||||
|
||||
this.theme = ThemeStore.theme;
|
||||
this.themes = ThemeStore.themes;
|
||||
|
|
@ -107,14 +99,18 @@ export class AdminSettingsGeneral extends AbstractViewSettings {
|
|||
}
|
||||
|
||||
selectLanguage() {
|
||||
showScreenPopup(LanguagesPopupView, [this.language, this.languages(), LanguageStore.userLanguage()]);
|
||||
showScreenPopup(LanguagesPopupView, [
|
||||
this.language,
|
||||
LanguageStore.languages,
|
||||
LanguageStore.userLanguage()
|
||||
]);
|
||||
}
|
||||
|
||||
selectLanguageAdmin() {
|
||||
showScreenPopup(LanguagesPopupView, [
|
||||
this.languageAdmin,
|
||||
this.languagesAdmin(),
|
||||
SettingsGet('languageUsers')
|
||||
SettingsAdmin('languages'),
|
||||
SettingsAdmin('clientLanguage')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
constructor() {
|
||||
super();
|
||||
|
||||
this.addSettings(['useLocalProxyForExternalImages']);
|
||||
this.addSettings(['proxyExternalImages', 'autoVerifySignatures']);
|
||||
|
||||
this.weakPassword = rl.app.weakPassword;
|
||||
|
||||
|
|
@ -28,6 +28,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
|
||||
viewQRCode: '',
|
||||
|
||||
capaGnuPG: SettingsCapa('GnuPG'),
|
||||
capaOpenPGP: SettingsCapa('OpenPGP')
|
||||
});
|
||||
|
||||
|
|
@ -65,7 +66,8 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
|
||||
adminPasswordNew2: reset,
|
||||
|
||||
capaOpenPGP: value => Remote.saveSetting('CapaOpenPGP', value)
|
||||
capaGnuPG: value => Remote.saveSetting('capaGnuPG', value),
|
||||
capaOpenPGP: value => Remote.saveSetting('capaOpenPGP', value)
|
||||
});
|
||||
|
||||
this.adminTOTP(SettingsGet('adminTOTP'));
|
||||
|
|
@ -75,6 +77,16 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
});
|
||||
}
|
||||
|
||||
generateTOTP() {
|
||||
let CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
|
||||
length = 16,
|
||||
secret = '';
|
||||
while (0 < length--) {
|
||||
secret += CHARS[Math.floor(Math.random() * 32)];
|
||||
}
|
||||
this.adminTOTP(secret);
|
||||
}
|
||||
|
||||
saveAdminUserCommand() {
|
||||
if (!this.adminLogin().trim()) {
|
||||
this.adminLoginError(true);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import ko from 'ko';
|
|||
|
||||
//import { koComputable } from 'External/ko';
|
||||
import { SettingsCapa, SettingsGet } from 'Common/Globals';
|
||||
import { loadAccountsAndIdentities } from 'Common/UtilsUser';
|
||||
|
||||
import { AccountUserStore } from 'Stores/User/Account';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
|
|
@ -64,7 +65,7 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
|
|||
rl.route.root();
|
||||
setTimeout(() => location.reload(), 1);
|
||||
} else {
|
||||
rl.app.accountsAndIdentities();
|
||||
loadAccountsAndIdentities();
|
||||
}
|
||||
}, {
|
||||
emailToDelete: accountToRemove.email
|
||||
|
|
@ -88,7 +89,7 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
|
|||
|
||||
accountsAndIdentitiesAfterMove() {
|
||||
Remote.request('AccountsAndIdentitiesSortOrder', null, {
|
||||
Accounts: AccountUserStore.getEmailAddresses().filter(v => v != SettingsGet('mainEmail')),
|
||||
Accounts: AccountUserStore.filter(item => item.isAdditional()).map(item => item.email),
|
||||
Identities: IdentityUserStore.map(item => (item ? item.id() : ""))
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import Remote from 'Remote/User/Fetch';
|
|||
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
|
||||
//import { FolderPopupView } from 'View/Popup/Folder';
|
||||
import { FolderCreatePopupView } from 'View/Popup/FolderCreate';
|
||||
import { FolderSystemPopupView } from 'View/Popup/FolderSystem';
|
||||
|
||||
|
|
@ -108,7 +109,7 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
|
|||
}
|
||||
|
||||
hideError() {
|
||||
this.error('');
|
||||
FolderUserStore.error('');
|
||||
}
|
||||
|
||||
toggleFolderKolabType(folder, event) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { SaveSettingStatus } from 'Common/Enums';
|
|||
import { LayoutSideView, LayoutBottomView } from 'Common/EnumsUser';
|
||||
import { setRefreshFoldersInterval } from 'Common/Folders';
|
||||
import { Settings, SettingsGet } from 'Common/Globals';
|
||||
import { WYSIWYGS } from 'Common/HtmlEditor';
|
||||
import { isArray } from 'Common/Utils';
|
||||
import { addSubscribablesTo, addComputablesTo } from 'External/ko';
|
||||
import { i18n, translateTrigger, translatorReload, convertLangName } from 'Common/Translator';
|
||||
|
|
@ -14,6 +15,7 @@ import { showScreenPopup } from 'Knoin/Knoin';
|
|||
|
||||
import { AppUserStore } from 'Stores/User/App';
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
import { NotificationUserStore } from 'Stores/User/Notification';
|
||||
|
|
@ -40,11 +42,18 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
this.isDesktopNotificationAllowed = NotificationUserStore.allowed;
|
||||
|
||||
this.threadsAllowed = AppUserStore.threadsAllowed;
|
||||
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
|
||||
this.threadAlgorithms = ko.observableArray();
|
||||
FolderUserStore.capabilities.forEach(capa =>
|
||||
capa.startsWith('THREAD=') && this.threadAlgorithms.push(capa.slice(7))
|
||||
);
|
||||
this.threadAlgorithms.sort((a, b) => a.length - b.length);
|
||||
this.threadAlgorithm = SettingsUserStore.threadAlgorithm;
|
||||
|
||||
['useThreads',
|
||||
['useThreads', 'threadAlgorithm',
|
||||
// These use addSetting()
|
||||
'layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
|
||||
'editorDefaultType', 'msgDefaultAction', 'maxBlockquotesLevel',
|
||||
'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel',
|
||||
// These are in addSettings()
|
||||
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
||||
'viewHTML', 'viewImages', 'viewImagesWhitelist', 'removeColors', 'allowStyles', 'allowDraftAutosave',
|
||||
|
|
@ -59,6 +68,8 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
|
||||
this.identities = IdentityUserStore;
|
||||
|
||||
this.wysiwygs = WYSIWYGS;
|
||||
|
||||
addComputablesTo(this, {
|
||||
languageFullName: () => convertLangName(this.language()),
|
||||
|
||||
|
|
@ -80,6 +91,8 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
];
|
||||
},
|
||||
|
||||
hasWysiwygs: () => 1 < WYSIWYGS().length,
|
||||
|
||||
msgDefaultActions: () => {
|
||||
translateTrigger();
|
||||
return [
|
||||
|
|
@ -99,6 +112,7 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
});
|
||||
|
||||
this.addSetting('EditorDefaultType');
|
||||
this.addSetting('editorWysiwyg');
|
||||
this.addSetting('MsgDefaultAction');
|
||||
this.addSetting('MessageReadDelay');
|
||||
this.addSetting('MessagesPerPage');
|
||||
|
|
@ -140,6 +154,11 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
Remote.saveSetting('UseThreads', value);
|
||||
},
|
||||
|
||||
threadAlgorithm: value => {
|
||||
MessagelistUserStore([]);
|
||||
Remote.saveSetting('threadAlgorithm', value);
|
||||
},
|
||||
|
||||
checkMailInterval: () => {
|
||||
setRefreshFoldersInterval(SettingsUserStore.checkMailInterval());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import { showScreenPopup } from 'Knoin/Knoin';
|
|||
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
|
||||
import { OpenPgpGeneratePopupView } from 'View/Popup/OpenPgpGenerate';
|
||||
|
||||
//import Remote from 'Remote/User/Fetch';
|
||||
import { SMimeUserStore } from 'Stores/User/SMime';
|
||||
import { SMimeImportPopupView } from 'View/Popup/SMimeImport';
|
||||
|
||||
export class UserSettingsSecurity extends AbstractViewSettings {
|
||||
constructor() {
|
||||
|
|
@ -43,6 +44,8 @@ export class UserSettingsSecurity extends AbstractViewSettings {
|
|||
this.openpgpkeysPublic = OpenPGPUserStore.publicKeys;
|
||||
this.openpgpkeysPrivate = OpenPGPUserStore.privateKeys;
|
||||
|
||||
this.smimeCertificates = SMimeUserStore;
|
||||
|
||||
this.canOpenPGP = SettingsCapa('OpenPGP');
|
||||
this.canGnuPG = GnuPGUserStore.isSupported();
|
||||
this.canMailvelope = !!window.mailvelope;
|
||||
|
|
@ -56,6 +59,14 @@ export class UserSettingsSecurity extends AbstractViewSettings {
|
|||
showScreenPopup(OpenPgpGeneratePopupView);
|
||||
}
|
||||
|
||||
importToOpenPGP() {
|
||||
OpenPGPUserStore.loadBackupKeys();
|
||||
}
|
||||
|
||||
importToSMime() {
|
||||
showScreenPopup(SMimeImportPopupView);
|
||||
}
|
||||
|
||||
onBuild() {
|
||||
/**
|
||||
* Create an iframe to display the Mailvelope keyring settings.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export class AbstractModel {
|
|||
constructor() {
|
||||
/*
|
||||
if (new.target === AbstractModel) {
|
||||
throw new Error("Can't instantiate AbstractModel!");
|
||||
throw Error("Can't instantiate AbstractModel!");
|
||||
}
|
||||
*/
|
||||
Object.defineProperty(this, 'disposables', {value: []});
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ const
|
|||
sDeepPrefix = '\u00A0\u00A0\u00A0',
|
||||
showUnsubscribed = true/*!SettingsUserStore.hideUnsubscribed()*/,
|
||||
|
||||
disabled = rl.settings.get('sieveAllowFileintoInbox') ? '' : 'INBOX',
|
||||
|
||||
foldersWalk = folders => {
|
||||
folders.forEach(oItem => {
|
||||
if (showUnsubscribed || oItem.hasSubscriptions() || !oItem.exists) {
|
||||
|
|
@ -39,7 +37,7 @@ const
|
|||
id: oItem.fullName,
|
||||
name: sDeepPrefix.repeat(oItem.deep) + oItem.detailedName(),
|
||||
system: false,
|
||||
disabled: !oItem.selectable() || disabled == oItem.fullName
|
||||
disabled: !oItem.selectable()
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ try {
|
|||
data = data ? decodeURIComponent(data[2]) : null;
|
||||
data = data ? JSON.parse(data) : {};
|
||||
win[sName] = {
|
||||
getItem: key => data[key] == null ? null : data[key],
|
||||
getItem: key => data[key] ?? null,
|
||||
setItem: (key, value) => {
|
||||
data[key] = ''+value; // forces the value to a string
|
||||
document.cookie = sName+'='+encodeURIComponent(JSON.stringify(data))
|
||||
|
|
|
|||
|
|
@ -1 +1,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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ DomainAdminStore.fetch = () => {
|
|||
if (!iError) {
|
||||
DomainAdminStore(
|
||||
data.Result.map(item => {
|
||||
item.name = IDN.toUnicode(item.name);
|
||||
item.disabled = ko.observable(item.disabled);
|
||||
item.askDelete = ko.observable(false);
|
||||
return item;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export const LanguageStore = {
|
|||
const aLanguages = Settings.app('languages');
|
||||
this.languages(isArray(aLanguages) ? aLanguages : []);
|
||||
this.language(SettingsGet('language'));
|
||||
this.userLanguage(SettingsGet('userLanguage'));
|
||||
this.userLanguage(SettingsGet('clientLanguage'));
|
||||
this.hourCycle(SettingsGet('hourCycle'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ import { addObservablesTo, koArrayWithDestroy } from 'External/ko';
|
|||
|
||||
export const AccountUserStore = koArrayWithDestroy();
|
||||
|
||||
AccountUserStore.getEmailAddresses = () => AccountUserStore.map(item => item.email);
|
||||
|
||||
addObservablesTo(AccountUserStore, {
|
||||
email: '',
|
||||
loading: false
|
||||
|
|
|
|||
|
|
@ -9,25 +9,17 @@ import Remote from 'Remote/User/Fetch';
|
|||
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey';
|
||||
import { AskPopupView } from 'View/Popup/Ask';
|
||||
|
||||
import { Passphrases } from 'Storage/Passphrases';
|
||||
|
||||
const
|
||||
askPassphrase = async (privateKey, btnTxt = 'LABEL_SIGN') => {
|
||||
const key = privateKey.id,
|
||||
pass = Passphrases.has(key)
|
||||
? {password:Passphrases.get(key), remember:false}
|
||||
: await AskPopupView.password('GnuPG key<br>' + key + ' ' + privateKey.emails[0], 'OPENPGP/'+btnTxt);
|
||||
pass && pass.remember && Passphrases.set(key, pass.password);
|
||||
return pass.password;
|
||||
},
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
|
||||
const
|
||||
findGnuPGKey = (keys, query/*, sign*/) =>
|
||||
keys.find(key =>
|
||||
// key[sign ? 'can_sign' : 'can_decrypt']
|
||||
(key.can_sign || key.can_decrypt)
|
||||
&& (key.emails.includes(query) || key.subkeys.find(key => query == key.keyid || query == key.fingerprint))
|
||||
&& (key.for(query) || key.subkeys.find(key => query == key.keyid || query == key.fingerprint))
|
||||
);
|
||||
|
||||
export const GnuPGUserStore = new class {
|
||||
|
|
@ -45,7 +37,8 @@ export const GnuPGUserStore = new class {
|
|||
this.keyring = null;
|
||||
this.publicKeys([]);
|
||||
this.privateKeys([]);
|
||||
Remote.request('GnupgGetKeys',
|
||||
SettingsCapa('GnuPG')
|
||||
&& Remote.request('GnupgGetKeys',
|
||||
(iError, oData) => {
|
||||
if (oData?.Result) {
|
||||
this.keyring = oData.Result;
|
||||
|
|
@ -55,6 +48,7 @@ export const GnuPGUserStore = new class {
|
|||
key.fingerprint = key.subkeys[0].fingerprint;
|
||||
key.uids.forEach(uid => uid.email && aEmails.push(uid.email));
|
||||
key.emails = aEmails;
|
||||
key.for = email => aEmails.includes(IDN.toASCII(email));
|
||||
key.askDelete = ko.observable(false);
|
||||
key.openForDeletion = ko.observable(null).askDeleteHelper();
|
||||
key.remove = () => {
|
||||
|
|
@ -77,33 +71,49 @@ export const GnuPGUserStore = new class {
|
|||
);
|
||||
}
|
||||
};
|
||||
key.view = () => {
|
||||
const fetch = pass => Remote.request('GnupgExportKey',
|
||||
(iError, oData) => {
|
||||
if (oData?.Result) {
|
||||
key.armor = oData.Result;
|
||||
showScreenPopup(OpenPgpKeyPopupView, [key]);
|
||||
} else {
|
||||
Passphrases.delete(key.id);
|
||||
}
|
||||
}, {
|
||||
keyId: key.id,
|
||||
isPrivate: isPrivate,
|
||||
passphrase: pass
|
||||
}
|
||||
if (isPrivate) {
|
||||
key.password = async btnTxt => {
|
||||
const pass = await Passphrases.ask(key,
|
||||
'GnuPG key<br>' + key.id + ' ' + key.emails[0],
|
||||
btnTxt
|
||||
);
|
||||
if (isPrivate) {
|
||||
askPassphrase(key, 'POPUP_VIEW_TITLE').then(passphrase => {
|
||||
(null !== passphrase) && fetch(passphrase);
|
||||
});
|
||||
pass && pass.remember && Passphrases.handle(key, pass.password);
|
||||
return pass?.password;
|
||||
};
|
||||
}
|
||||
key.fetch = async callback => {
|
||||
if (key.armor) {
|
||||
callback && callback();
|
||||
} else {
|
||||
fetch('');
|
||||
let pass = isPrivate ? await key.password('OPENPGP/POPUP_VIEW_TITLE') : '';
|
||||
if (null != pass) try {
|
||||
const result = await Remote.post('GnupgExportKey', null, {
|
||||
keyId: key.id,
|
||||
isPrivate: isPrivate,
|
||||
passphrase: pass
|
||||
});
|
||||
if (result?.Result) {
|
||||
key.armor = result.Result;
|
||||
callback && callback();
|
||||
} else {
|
||||
Passphrases.delete(key);
|
||||
}
|
||||
} catch (e) {
|
||||
Passphrases.delete(key);
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
return key.armor;
|
||||
};
|
||||
key.view = () => key.fetch(() => showScreenPopup(OpenPgpKeyPopupView, [key]));
|
||||
return key;
|
||||
};
|
||||
this.publicKeys(oData.Result.public.map(key => initKey(key, 0)));
|
||||
this.privateKeys(oData.Result.private.map(key => initKey(key, 1)));
|
||||
},
|
||||
collator = baseCollator(),
|
||||
sort = keys => keys.sort(
|
||||
(a, b) => collator.compare(a.emails[0], b.emails[0]) || collator.compare(a.id, b.id)
|
||||
);
|
||||
this.publicKeys(sort(oData.Result.public.map(key => initKey(key, 0))));
|
||||
this.privateKeys(sort(oData.Result.private.map(key => initKey(key, 1))));
|
||||
console.log('gnupg ready');
|
||||
}
|
||||
}
|
||||
|
|
@ -117,19 +127,6 @@ export const GnuPGUserStore = new class {
|
|||
return SettingsCapa('GnuPG');
|
||||
}
|
||||
|
||||
importKey(key, callback) {
|
||||
Remote.request('GnupgImportKey',
|
||||
(iError, oData) => {
|
||||
if (oData?.Result/* && (oData.Result.imported || oData.Result.secretimported)*/) {
|
||||
this.loadKeyrings();
|
||||
}
|
||||
callback?.(iError, oData);
|
||||
}, {
|
||||
key: key
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
keyPair.privateKey
|
||||
keyPair.publicKey
|
||||
|
|
@ -155,7 +152,7 @@ export const GnuPGUserStore = new class {
|
|||
const count = recipients.length,
|
||||
length = count ? recipients.filter(email =>
|
||||
// (key.can_verify || key.can_encrypt) &&
|
||||
this.publicKeys.find(key => key.emails.includes(email))
|
||||
this.publicKeys.find(key => key.for(email))
|
||||
).length : 0;
|
||||
return length && length === count;
|
||||
}
|
||||
|
|
@ -163,13 +160,13 @@ export const GnuPGUserStore = new class {
|
|||
getPublicKeyFingerprints(recipients) {
|
||||
const fingerprints = [];
|
||||
recipients.forEach(email => {
|
||||
fingerprints.push(this.publicKeys.find(key => key.emails.includes(email)).fingerprint);
|
||||
fingerprints.push(this.publicKeys.find(key => key.for(email)).fingerprint);
|
||||
});
|
||||
return fingerprints;
|
||||
}
|
||||
|
||||
getPrivateKeyFor(query, sign) {
|
||||
return findGnuPGKey(this.privateKeys, query, sign);
|
||||
getPrivateKeyFor(query/*, sign*/) {
|
||||
return findGnuPGKey(this.privateKeys, query/*, sign*/);
|
||||
}
|
||||
|
||||
async decrypt(message) {
|
||||
|
|
@ -191,22 +188,27 @@ export const GnuPGUserStore = new class {
|
|||
uid: message.uid,
|
||||
partId: pgpInfo.partId,
|
||||
keyId: key.id,
|
||||
passphrase: await askPassphrase(key, 'BUTTON_DECRYPT'),
|
||||
passphrase: await key.password('CRYPTO/DECRYPT'),
|
||||
data: '' // message.plain() optional
|
||||
}
|
||||
if (null !== params.passphrase) {
|
||||
const result = await Remote.post('GnupgDecrypt', null, params);
|
||||
if (result?.Result && false !== result.Result.data) {
|
||||
return result.Result;
|
||||
if (null != params.passphrase) {
|
||||
try {
|
||||
const response = await Remote.post('GnupgDecrypt', null, params);
|
||||
if (response?.Result?.data) {
|
||||
return response.Result;
|
||||
}
|
||||
throw response;
|
||||
} catch (e) {
|
||||
Passphrases.delete(key);
|
||||
throw e;
|
||||
}
|
||||
Passphrases.delete(key.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async verify(message) {
|
||||
let data = message.pgpSigned(); // { bodyPartId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
|
||||
let data = message.pgpSigned(); // { partId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
|
||||
if (data) {
|
||||
data = { ...data }; // clone
|
||||
// const sender = message.from[0].email;
|
||||
|
|
@ -217,18 +219,19 @@ export const GnuPGUserStore = new class {
|
|||
data.bodyPart = data.bodyPart.raw;
|
||||
data.sigPart = data.sigPart.body;
|
||||
}
|
||||
let response = await Remote.post('MessagePgpVerify', null, data);
|
||||
let response = await Remote.post('PgpVerifyMessage', null, data);
|
||||
if (response?.Result) {
|
||||
return {
|
||||
fingerprint: response.Result.fingerprint,
|
||||
success: 0 == response.Result.status // GOODSIG
|
||||
success: 0 == response.Result.status, // GOODSIG
|
||||
error: response.Result.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sign(privateKey) {
|
||||
return await askPassphrase(privateKey);
|
||||
return await privateKey.password('CRYPTO/SIGN');
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
|||
124
dev/Stores/User/Mailvelope.js
Normal file
124
dev/Stores/User/Mailvelope.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { SettingsGet } from 'Common/Globals';
|
||||
|
||||
// https://mailvelope.github.io/mailvelope/Keyring.html
|
||||
let mailvelopeKeyring = null;
|
||||
|
||||
export const MailvelopeUserStore = {
|
||||
keyring: null,
|
||||
|
||||
loadKeyring(identifier) {
|
||||
identifier = identifier || SettingsGet('Email');
|
||||
if (window.mailvelope) {
|
||||
const fn = keyring => {
|
||||
mailvelopeKeyring = keyring;
|
||||
console.log('mailvelope ready');
|
||||
};
|
||||
mailvelope.getKeyring().then(fn, err => {
|
||||
if (identifier) {
|
||||
// attempt to create a new keyring for this app/user
|
||||
mailvelope.createKeyring(identifier).then(fn, err => console.error(err));
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
addEventListener('mailvelope-disconnect', event => {
|
||||
alert('Mailvelope is updated to version ' + event.detail.version + '. Reload page');
|
||||
}, false);
|
||||
} else {
|
||||
addEventListener('mailvelope', () => this.loadKeyring(identifier));
|
||||
}
|
||||
},
|
||||
|
||||
async hasPublicKeyForEmails(recipients) {
|
||||
const
|
||||
mailvelope = mailvelopeKeyring && await mailvelopeKeyring.validKeyForAddress(recipients)
|
||||
/*.then(LookupResult => Object.entries(LookupResult))*/,
|
||||
entries = mailvelope && Object.entries(mailvelope);
|
||||
return entries && entries.filter(value => value[1]).length === recipients.length;
|
||||
},
|
||||
|
||||
async getPrivateKeyFor(email/*, sign*/) {
|
||||
if (mailvelopeKeyring && await mailvelopeKeyring.hasPrivateKey({email:email})) {
|
||||
return ['mailvelope', email];
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
async decrypt(message) {
|
||||
// Try Mailvelope (does not support inline images)
|
||||
if (mailvelopeKeyring) {
|
||||
const sender = message.from[0].email,
|
||||
armoredText = message.plain();
|
||||
try {
|
||||
let emails = [...message.from,...message.to,...message.cc].validUnique(),
|
||||
i = emails.length;
|
||||
while (i--) {
|
||||
if (await this.getPrivateKeyFor(emails[i].email)) {
|
||||
/**
|
||||
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createEncryptedFormContainer
|
||||
* Creates an iframe to display an encrypted form
|
||||
*/
|
||||
// mailvelope.createEncryptedFormContainer('#mailvelope-form');
|
||||
/**
|
||||
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createDisplayContainer
|
||||
* Creates an iframe to display the decrypted content of the encrypted mail.
|
||||
*/
|
||||
const body = message.body;
|
||||
body.textContent = '';
|
||||
let result = await mailvelope.createDisplayContainer(
|
||||
'#'+body.id,
|
||||
armoredText,
|
||||
mailvelopeKeyring,
|
||||
{
|
||||
senderAddress: sender
|
||||
// emails[i].email
|
||||
}
|
||||
);
|
||||
if (result) {
|
||||
if (result.error?.message) {
|
||||
if ('PWD_DIALOG_CANCEL' !== result.error.code) {
|
||||
alert(result.error.code + ': ' + result.error.message);
|
||||
}
|
||||
} else {
|
||||
body.classList.add('mailvelope');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns headers that should be added to an outgoing email.
|
||||
* So far this is only the autocrypt header.
|
||||
*/
|
||||
/*
|
||||
mailvelopeKeyring.additionalHeadersForOutgoingEmail(from: 'abc@web.de')
|
||||
.then(function(additional) {
|
||||
console.log('additionalHeadersForOutgoingEmail', additional);
|
||||
// logs: {autocrypt: "addr=abc@web.de; prefer-encrypt=mutual; keydata=..."}
|
||||
});
|
||||
|
||||
mailvelopeKeyring.addSyncHandler(syncHandlerObj)
|
||||
mailvelopeKeyring.createKeyBackupContainer(selector, options)
|
||||
mailvelopeKeyring.createKeyGenContainer(selector, {
|
||||
// userIds: [],
|
||||
keySize: 4096
|
||||
})
|
||||
|
||||
mailvelopeKeyring.exportOwnPublicKey(emailAddr).then(<AsciiArmored, Error>)
|
||||
mailvelopeKeyring.importPublicKey(armored)
|
||||
|
||||
// https://mailvelope.github.io/mailvelope/global.html#SyncHandlerObject
|
||||
mailvelopeKeyring.addSyncHandler({
|
||||
uploadSync
|
||||
downloadSync
|
||||
backup
|
||||
restore
|
||||
});
|
||||
*/
|
||||
};
|
||||
|
|
@ -32,6 +32,8 @@ import { SettingsGet } from 'Common/Globals';
|
|||
import { SUB_QUERY_PREFIX } from 'Common/Links';
|
||||
import { AppUserStore } from 'Stores/User/App';
|
||||
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
|
||||
const
|
||||
isChecked = item => item.checked(),
|
||||
replaceHash = hash => {
|
||||
|
|
@ -254,17 +256,14 @@ MessagelistUserStore.reload = (bDropPagePosition = false, bDropCurrentFolderCach
|
|||
|
||||
let flags = folderInfo.permanentFlags || [];
|
||||
if (flags.includes('\\*')) {
|
||||
/** Add Thunderbird labels */
|
||||
let i = 6;
|
||||
while (--i) {
|
||||
flags.includes('$label'+i) || flags.push('$label'+i);
|
||||
}
|
||||
/** TODO: add others by default? */
|
||||
}
|
||||
flags.sort((a, b) => {
|
||||
a = a.toUpperCase();
|
||||
b = b.toUpperCase();
|
||||
return (a < b) ? -1 : ((a > b) ? 1 : 0);
|
||||
});
|
||||
folder.permanentFlags(flags);
|
||||
folder.permanentFlags(flags.sort(baseCollator().compare));
|
||||
|
||||
MessagelistUserStore.notifyNewMessages(folder.fullName, collection.newMessages);
|
||||
}
|
||||
|
|
@ -314,6 +313,7 @@ MessagelistUserStore.reload = (bDropPagePosition = false, bDropCurrentFolderCach
|
|||
|
||||
if (AppUserStore.threadsAllowed() && SettingsUserStore.useThreads()) {
|
||||
params.useThreads = 1;
|
||||
params.threadAlgorithm = SettingsUserStore.threadAlgorithm();
|
||||
params.threadUid = MessagelistUserStore.threadUid();
|
||||
} else {
|
||||
params.threadUid = 0;
|
||||
|
|
|
|||
|
|
@ -10,38 +10,48 @@ import Remote from 'Remote/User/Fetch';
|
|||
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey';
|
||||
import { AskPopupView } from 'View/Popup/Ask';
|
||||
|
||||
import { Passphrases } from 'Storage/Passphrases';
|
||||
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
|
||||
const
|
||||
loaded = () => !!window.openpgp,
|
||||
|
||||
findOpenPGPKey = (keys, query/*, sign*/) =>
|
||||
keys.find(key =>
|
||||
key.emails.includes(query) || query == key.id || query == key.fingerprint
|
||||
key.for(query) || query == key.id || query == key.fingerprint
|
||||
),
|
||||
|
||||
decryptKey = async (privateKey, btnTxt = 'LABEL_SIGN') => {
|
||||
decryptKey = async (privateKey, btnTxt = 'SIGN') => {
|
||||
if (privateKey.key.isDecrypted()) {
|
||||
return privateKey.key;
|
||||
}
|
||||
const key = privateKey.id,
|
||||
pass = Passphrases.has(key)
|
||||
? {password:Passphrases.get(key), remember:false}
|
||||
: await AskPopupView.password(
|
||||
'OpenPGP.js key<br>' + key + ' ' + privateKey.emails[0],
|
||||
'OPENPGP/'+btnTxt
|
||||
);
|
||||
pass = await Passphrases.ask(privateKey,
|
||||
'OpenPGP.js key<br>' + key + ' ' + privateKey.emails[0],
|
||||
'CRYPTO/'+btnTxt
|
||||
);
|
||||
if (pass) {
|
||||
const passphrase = pass.password,
|
||||
result = await openpgp.decryptKey({
|
||||
privateKey: privateKey.key,
|
||||
passphrase
|
||||
});
|
||||
result && pass.remember && Passphrases.set(key, passphrase);
|
||||
result && pass.remember && Passphrases.handle(privateKey, passphrase);
|
||||
return result;
|
||||
}
|
||||
},
|
||||
|
||||
collator = baseCollator(),
|
||||
sort = keys => keys.sort(
|
||||
// (a, b) => collator.compare(a.emails[0], b.emails[0]) || collator.compare(a.fingerprint, b.fingerprint)
|
||||
(a, b) => collator.compare(a.emails[0], b.emails[0]) || collator.compare(a.id, b.id)
|
||||
),
|
||||
dedup = keys => sort((keys || [])
|
||||
.filter((v, i, a) => a.findIndex(entry => entry.fingerprint == v.fingerprint) === i)
|
||||
),
|
||||
|
||||
/**
|
||||
* OpenPGP.js v5 removed the localStorage (keyring)
|
||||
* This should be compatible with the old OpenPGP.js v2
|
||||
|
|
@ -53,9 +63,11 @@ const
|
|||
let keys = [], key,
|
||||
armoredKeys = JSON.parse(storage.getItem(itemname)),
|
||||
i = arrayLength(armoredKeys);
|
||||
while (i--) {
|
||||
while (i--) try {
|
||||
key = await openpgp.readKey({armoredKey:armoredKeys[i]});
|
||||
key.err || keys.push(new OpenPgpKeyModel(armoredKeys[i], key));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return keys;
|
||||
},
|
||||
|
|
@ -71,15 +83,11 @@ const
|
|||
class OpenPgpKeyModel {
|
||||
constructor(armor, key) {
|
||||
this.key = key;
|
||||
const aEmails = [];
|
||||
if (key.users) {
|
||||
key.users.forEach(user => user.userID.email && aEmails.push(user.userID.email));
|
||||
}
|
||||
this.id = key.getKeyID().toHex().toUpperCase();
|
||||
this.fingerprint = key.getFingerprint();
|
||||
this.can_encrypt = !!key.getEncryptionKey();
|
||||
this.can_sign = !!key.getSigningKey();
|
||||
this.emails = aEmails;
|
||||
this.emails = key.users.map(user => IDN.toASCII(user.userID.email)).filter(email => email);
|
||||
this.armor = armor;
|
||||
this.askDelete = ko.observable(false);
|
||||
this.openForDeletion = ko.observable(null).askDeleteHelper();
|
||||
|
|
@ -87,6 +95,18 @@ class OpenPgpKeyModel {
|
|||
// key.getPrimaryUser()
|
||||
}
|
||||
|
||||
/*
|
||||
get id() { return this.key.getKeyID().toHex().toUpperCase(); }
|
||||
get fingerprint() { return this.key.getFingerprint(); }
|
||||
get can_encrypt() { return !!this.key.getEncryptionKey(); }
|
||||
get can_sign() { return !!this.key.getSigningKey(); }
|
||||
get emails() { return this.key.users.map(user => IDN.toASCII(user.userID.email)).filter(email => email); }
|
||||
get armor() { return this.key.armor(); }
|
||||
*/
|
||||
for(email) {
|
||||
return this.emails.includes(IDN.toASCII(email));
|
||||
}
|
||||
|
||||
view() {
|
||||
showScreenPopup(OpenPgpKeyPopupView, [this]);
|
||||
}
|
||||
|
|
@ -116,37 +136,62 @@ export const OpenPGPUserStore = new class {
|
|||
}
|
||||
|
||||
loadKeyrings() {
|
||||
if (window.openpgp) {
|
||||
loadOpenPgpKeys(publicKeysItem).then(keys => {
|
||||
this.publicKeys(keys || []);
|
||||
if (loaded()) {
|
||||
loadOpenPgpKeys(publicKeysItem)
|
||||
.then(keys => {
|
||||
this.publicKeys(dedup(keys));
|
||||
console.log('openpgp.js public keys loaded');
|
||||
});
|
||||
loadOpenPgpKeys(privateKeysItem).then(keys => {
|
||||
this.privateKeys(keys || [])
|
||||
console.log('openpgp.js private keys loaded');
|
||||
})
|
||||
.finally(() => {
|
||||
loadOpenPgpKeys(privateKeysItem)
|
||||
.then(keys => {
|
||||
this.privateKeys(dedup(keys));
|
||||
console.log('openpgp.js private keys loaded');
|
||||
})
|
||||
.finally(() => {
|
||||
/*SettingsGet('loadBackupKeys') && */this.loadBackupKeys();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadBackupKeys() {
|
||||
Remote.request('GetPGPKeys',
|
||||
(iError, oData) => !iError && oData.Result && this.importKeys(oData.Result)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSupported() {
|
||||
return !!window.openpgp;
|
||||
return loaded();
|
||||
}
|
||||
|
||||
importKey(armoredKey) {
|
||||
window.openpgp && openpgp.readKey({armoredKey:armoredKey}).then(key => {
|
||||
if (!key.err) {
|
||||
if (key.isPrivate()) {
|
||||
this.privateKeys.push(new OpenPgpKeyModel(armoredKey, key));
|
||||
storeOpenPgpKeys(this.privateKeys, privateKeysItem);
|
||||
} else {
|
||||
this.publicKeys.push(new OpenPgpKeyModel(armoredKey, key));
|
||||
storeOpenPgpKeys(this.publicKeys, publicKeysItem);
|
||||
this.importKeys([armoredKey]);
|
||||
}
|
||||
|
||||
async importKeys(keys) {
|
||||
if (loaded()) {
|
||||
const privateKeys = this.privateKeys(),
|
||||
publicKeys = this.publicKeys();
|
||||
for (const armoredKey of keys) try {
|
||||
let key = await openpgp.readKey({armoredKey:armoredKey});
|
||||
if (!key.err) {
|
||||
key = new OpenPgpKeyModel(armoredKey, key);
|
||||
const keys = key.key.isPrivate() ? privateKeys : publicKeys;
|
||||
keys.find(entry => entry.fingerprint == key.fingerprint)
|
||||
|| keys.push(key);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e, armoredKey);
|
||||
}
|
||||
});
|
||||
this.privateKeys(sort(privateKeys));
|
||||
this.publicKeys(sort(publicKeys));
|
||||
storeOpenPgpKeys(privateKeys, privateKeysItem);
|
||||
storeOpenPgpKeys(publicKeys, publicKeysItem);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -155,7 +200,7 @@ export const OpenPGPUserStore = new class {
|
|||
keyPair.revocationCertificate
|
||||
*/
|
||||
storeKeyPair(keyPair) {
|
||||
if (window.openpgp) {
|
||||
if (loaded()) {
|
||||
openpgp.readKey({armoredKey:keyPair.publicKey}).then(key => {
|
||||
this.publicKeys.push(new OpenPgpKeyModel(keyPair.publicKey, key));
|
||||
storeOpenPgpKeys(this.publicKeys, publicKeysItem);
|
||||
|
|
@ -173,7 +218,7 @@ export const OpenPGPUserStore = new class {
|
|||
hasPublicKeyForEmails(recipients) {
|
||||
const count = recipients.length,
|
||||
length = count ? recipients.filter(email =>
|
||||
this.publicKeys().find(key => key.emails.includes(email))
|
||||
this.publicKeys().find(key => key.for(email))
|
||||
).length : 0;
|
||||
return length && length === count;
|
||||
}
|
||||
|
|
@ -201,7 +246,7 @@ export const OpenPGPUserStore = new class {
|
|||
}
|
||||
}
|
||||
if (privateKey) try {
|
||||
const decryptedKey = await decryptKey(privateKey, 'BUTTON_DECRYPT');
|
||||
const decryptedKey = await decryptKey(privateKey, 'DECRYPT');
|
||||
if (decryptedKey) {
|
||||
const publicKey = findOpenPGPKey(this.publicKeys, sender/*, sign*/);
|
||||
return await openpgp.decrypt({
|
||||
|
|
@ -222,15 +267,15 @@ export const OpenPGPUserStore = new class {
|
|||
* https://docs.openpgpjs.org/#sign-and-verify-cleartext-messages
|
||||
*/
|
||||
async verify(message) {
|
||||
const data = message.pgpSigned(), // { bodyPartId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
|
||||
publicKey = this.publicKeys().find(key => key.emails.includes(message.from[0].email));
|
||||
const data = message.pgpSigned(), // { partId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
|
||||
publicKey = this.publicKeys().find(key => key.for(message.from[0].email));
|
||||
if (data && publicKey) {
|
||||
data.folder = message.folder;
|
||||
data.uid = message.uid;
|
||||
data.tryGnuPG = 0;
|
||||
let response;
|
||||
if (data.sigPartId) {
|
||||
response = await Remote.post('MessagePgpVerify', null, data);
|
||||
response = await Remote.post('PgpVerifyMessage', null, data);
|
||||
} else if (data.bodyPart) {
|
||||
// MimePart
|
||||
response = { Result: { text: data.bodyPart.raw, signature: data.sigPart.body } };
|
||||
|
|
@ -282,7 +327,7 @@ export const OpenPGPUserStore = new class {
|
|||
*/
|
||||
async encrypt(text, recipients, signPrivateKey) {
|
||||
const count = recipients.length;
|
||||
recipients = recipients.map(email => this.publicKeys().find(key => key.emails.includes(email))).filter(key => key);
|
||||
recipients = recipients.map(email => this.publicKeys().find(key => key.for(email))).filter(key => key);
|
||||
if (count === recipients.length) {
|
||||
if (signPrivateKey) {
|
||||
signPrivateKey = await decryptKey(signPrivateKey);
|
||||
|
|
|
|||
|
|
@ -8,14 +8,16 @@ import { SettingsCapa, SettingsGet } from 'Common/Globals';
|
|||
|
||||
import { GnuPGUserStore } from 'Stores/User/GnuPG';
|
||||
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
|
||||
import { MailvelopeUserStore } from 'Stores/User/Mailvelope';
|
||||
|
||||
// https://mailvelope.github.io/mailvelope/Keyring.html
|
||||
let mailvelopeKeyring = null;
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
export const
|
||||
BEGIN_PGP_MESSAGE = '-----BEGIN PGP MESSAGE-----',
|
||||
// BEGIN_PGP_SIGNATURE = '-----BEGIN PGP SIGNATURE-----',
|
||||
// BEGIN_PGP_SIGNED = '-----BEGIN PGP SIGNED MESSAGE-----',
|
||||
// BEGIN_PGP_PUBLIC_KEY = '-----BEGIN PGP PUBLIC KEY BLOCK-----',
|
||||
// END_PGP_PUBLIC_KEY = '-----END PGP PUBLIC KEY BLOCK-----',
|
||||
|
||||
PgpUserStore = new class {
|
||||
init() {
|
||||
|
|
@ -33,32 +35,9 @@ export const
|
|||
}
|
||||
|
||||
loadKeyrings(identifier) {
|
||||
identifier = identifier || SettingsGet('Email');
|
||||
if (window.mailvelope) {
|
||||
const fn = keyring => {
|
||||
mailvelopeKeyring = keyring;
|
||||
console.log('mailvelope ready');
|
||||
};
|
||||
mailvelope.getKeyring().then(fn, err => {
|
||||
if (identifier) {
|
||||
// attempt to create a new keyring for this app/user
|
||||
mailvelope.createKeyring(identifier).then(fn, err => console.error(err));
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
addEventListener('mailvelope-disconnect', event => {
|
||||
alert('Mailvelope is updated to version ' + event.detail.version + '. Reload page');
|
||||
}, false);
|
||||
} else {
|
||||
addEventListener('mailvelope', () => this.loadKeyrings(identifier));
|
||||
}
|
||||
|
||||
MailvelopeUserStore.loadKeyring(identifier);
|
||||
OpenPGPUserStore.loadKeyrings();
|
||||
|
||||
if (SettingsCapa('GnuPG')) {
|
||||
GnuPGUserStore.loadKeyrings();
|
||||
}
|
||||
GnuPGUserStore.loadKeyrings();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -75,21 +54,28 @@ export const
|
|||
return 0 === text.trim().indexOf(BEGIN_PGP_MESSAGE);
|
||||
}
|
||||
|
||||
async mailvelopeHasPublicKeyForEmails(recipients) {
|
||||
const
|
||||
mailvelope = mailvelopeKeyring && await mailvelopeKeyring.validKeyForAddress(recipients)
|
||||
/*.then(LookupResult => Object.entries(LookupResult))*/,
|
||||
entries = mailvelope && Object.entries(mailvelope);
|
||||
return entries && entries.filter(value => value[1]).length === recipients.length;
|
||||
importKey(key, gnuPG, backup) {
|
||||
if (gnuPG || backup) {
|
||||
Remote.request('PgpImportKey',
|
||||
(iError, oData) => {
|
||||
if (gnuPG && oData?.Result/* && (oData.Result.imported || oData.Result.secretimported)*/) {
|
||||
GnuPGUserStore.loadKeyrings();
|
||||
}
|
||||
iError && alert(oData.ErrorMessage);
|
||||
}, {
|
||||
key, gnuPG, backup
|
||||
}
|
||||
);
|
||||
}
|
||||
OpenPGPUserStore.importKey(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if verifying/encrypting a message is possible with given email addresses.
|
||||
* Returns the first library that can.
|
||||
*/
|
||||
async hasPublicKeyForEmails(recipients) {
|
||||
const count = recipients.length;
|
||||
if (count) {
|
||||
hasPublicKeyForEmails(recipients) {
|
||||
if (recipients.length) {
|
||||
if (GnuPGUserStore.hasPublicKeyForEmails(recipients)) {
|
||||
return 'gnupg';
|
||||
}
|
||||
|
|
@ -100,40 +86,15 @@ export const
|
|||
return false;
|
||||
}
|
||||
|
||||
async getMailvelopePrivateKeyFor(email/*, sign*/) {
|
||||
if (mailvelopeKeyring && await mailvelopeKeyring.hasPrivateKey({email:email})) {
|
||||
return ['mailvelope', email];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if signing a message is possible with given email address.
|
||||
* Returns the first library that can.
|
||||
*/
|
||||
async getKeyForSigning(email) {
|
||||
let key = OpenPGPUserStore.getPrivateKeyFor(email, 1);
|
||||
if (key) {
|
||||
return ['openpgp', key];
|
||||
}
|
||||
|
||||
key = GnuPGUserStore.getPrivateKeyFor(email, 1);
|
||||
if (key) {
|
||||
return ['gnupg', key];
|
||||
}
|
||||
|
||||
// return await this.getMailvelopePrivateKeyFor(email, 1);
|
||||
}
|
||||
|
||||
async decrypt(message) {
|
||||
const sender = message.from[0].email,
|
||||
armoredText = message.plain();
|
||||
const armoredText = message.plain();
|
||||
if (!this.isEncrypted(armoredText)) {
|
||||
throw Error('Not armored text');
|
||||
}
|
||||
|
||||
// Try OpenPGP.js
|
||||
if (OpenPGPUserStore.isSupported()) {
|
||||
const sender = message.from[0].email;
|
||||
let result = await OpenPGPUserStore.decrypt(armoredText, sender);
|
||||
if (result) {
|
||||
return result;
|
||||
|
|
@ -141,68 +102,20 @@ export const
|
|||
}
|
||||
|
||||
// Try Mailvelope (does not support inline images)
|
||||
if (mailvelopeKeyring) {
|
||||
try {
|
||||
let emails = [...message.from,...message.to,...message.cc].validUnique(),
|
||||
i = emails.length;
|
||||
while (i--) {
|
||||
if (await this.getMailvelopePrivateKeyFor(emails[i].email)) {
|
||||
/**
|
||||
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createEncryptedFormContainer
|
||||
* Creates an iframe to display an encrypted form
|
||||
*/
|
||||
// mailvelope.createEncryptedFormContainer('#mailvelope-form');
|
||||
/**
|
||||
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createDisplayContainer
|
||||
* Creates an iframe to display the decrypted content of the encrypted mail.
|
||||
*/
|
||||
const body = message.body;
|
||||
body.textContent = '';
|
||||
let result = await mailvelope.createDisplayContainer(
|
||||
'#'+body.id,
|
||||
armoredText,
|
||||
mailvelopeKeyring,
|
||||
{
|
||||
senderAddress: sender
|
||||
// emails[i].email
|
||||
}
|
||||
);
|
||||
if (result) {
|
||||
if (result.error?.message) {
|
||||
if ('PWD_DIALOG_CANCEL' !== result.error.code) {
|
||||
alert(result.error.code + ': ' + result.error.message);
|
||||
}
|
||||
} else {
|
||||
body.classList.add('mailvelope');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Now try GnuPG
|
||||
return GnuPGUserStore.decrypt(message);
|
||||
return (await MailvelopeUserStore.decrypt(message))
|
||||
// Or try GnuPG
|
||||
|| GnuPGUserStore.decrypt(message);
|
||||
}
|
||||
|
||||
async verify(message) {
|
||||
const signed = message.pgpSigned();
|
||||
const signed = message.pgpSigned(),
|
||||
sender = message.from[0].email;
|
||||
if (signed) {
|
||||
const sender = message.from[0].email,
|
||||
gnupg = GnuPGUserStore.hasPublicKeyForEmails([sender]),
|
||||
openpgp = OpenPGPUserStore.hasPublicKeyForEmails([sender]);
|
||||
// Detached signature use GnuPG first, else we must download whole message
|
||||
if (gnupg && signed.sigPartId) {
|
||||
return GnuPGUserStore.verify(message);
|
||||
}
|
||||
if (openpgp) {
|
||||
// OpenPGP only when inline, else we must download the whole message
|
||||
if (!signed.sigPartId && OpenPGPUserStore.hasPublicKeyForEmails([sender])) {
|
||||
return OpenPGPUserStore.verify(message);
|
||||
}
|
||||
if (gnupg) {
|
||||
if (GnuPGUserStore.hasPublicKeyForEmails([sender])) {
|
||||
return GnuPGUserStore.verify(message);
|
||||
}
|
||||
// Mailvelope can't
|
||||
|
|
@ -210,29 +123,23 @@ export const
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns headers that should be added to an outgoing email.
|
||||
* So far this is only the autocrypt header.
|
||||
*/
|
||||
/*
|
||||
mailvelopeKeyring.additionalHeadersForOutgoingEmail(headers)
|
||||
mailvelopeKeyring.addSyncHandler(syncHandlerObj)
|
||||
mailvelopeKeyring.createKeyBackupContainer(selector, options)
|
||||
mailvelopeKeyring.createKeyGenContainer(selector, {
|
||||
// userIds: [],
|
||||
keySize: 4096
|
||||
})
|
||||
|
||||
mailvelopeKeyring.exportOwnPublicKey(emailAddr).then(<AsciiArmored, Error>)
|
||||
mailvelopeKeyring.importPublicKey(armored)
|
||||
|
||||
// https://mailvelope.github.io/mailvelope/global.html#SyncHandlerObject
|
||||
mailvelopeKeyring.addSyncHandler({
|
||||
uploadSync
|
||||
downloadSync
|
||||
backup
|
||||
restore
|
||||
});
|
||||
*/
|
||||
|
||||
getPublicKeyOfEmails(recipients) {
|
||||
if (recipients.length) {
|
||||
let result = {};
|
||||
recipients.forEach(email => {
|
||||
OpenPGPUserStore.publicKeys().forEach(key => {
|
||||
if (key.for(email)) {
|
||||
result[email] = key.armor;
|
||||
}
|
||||
});
|
||||
GnuPGUserStore.publicKeys.map(async key => {
|
||||
if (!result[email] && key.for(email)) {
|
||||
result[email] = await key.fetch();
|
||||
}
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
21
dev/Stores/User/SMime.js
Normal file
21
dev/Stores/User/SMime.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { addObservablesTo, koArrayWithDestroy } from 'External/ko';
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
export const SMimeUserStore = koArrayWithDestroy();
|
||||
|
||||
addObservablesTo(SMimeUserStore, {
|
||||
loading: false
|
||||
});
|
||||
|
||||
SMimeUserStore.loadCertificates = () => {
|
||||
SMimeUserStore([]);
|
||||
SMimeUserStore.loading(true);
|
||||
Remote.request('SMimeGetCertificates', (iError, oData) => {
|
||||
SMimeUserStore.loading(false);
|
||||
const collator = baseCollator();
|
||||
iError || SMimeUserStore(oData.Result.sort(
|
||||
(a, b) => collator.compare(a.emailAddress, b.emailAddress) || (b.validTo_time_t - a.validTo_time_t)
|
||||
));
|
||||
});
|
||||
};
|
||||
|
|
@ -29,6 +29,7 @@ export const SettingsUserStore = new class {
|
|||
showNextMessage: 0,
|
||||
allowDraftAutosave: 1,
|
||||
useThreads: 0,
|
||||
threadAlgorithm: '',
|
||||
replySameFolder: 0,
|
||||
hideUnsubscribed: 0,
|
||||
hideDeleted: 1,
|
||||
|
|
@ -47,6 +48,7 @@ export const SettingsUserStore = new class {
|
|||
|
||||
layout: 1,
|
||||
editorDefaultType: 'Html',
|
||||
editorWysiwyg: 'Squire',
|
||||
msgDefaultAction: 1
|
||||
});
|
||||
|
||||
|
|
@ -78,9 +80,10 @@ export const SettingsUserStore = new class {
|
|||
|
||||
init() {
|
||||
const self = this;
|
||||
self.editorDefaultType(SettingsGet('EditorDefaultType'));
|
||||
|
||||
[
|
||||
'EditorDefaultType',
|
||||
'editorWysiwyg',
|
||||
'messageNewWindow',
|
||||
'messageReadAuto',
|
||||
'MsgDefaultAction',
|
||||
|
|
@ -97,7 +100,8 @@ export const SettingsUserStore = new class {
|
|||
'listGrouped',
|
||||
'showNextMessage',
|
||||
'AllowDraftAutosave',
|
||||
'UseThreads',
|
||||
'useThreads',
|
||||
'threadAlgorithm',
|
||||
'ReplySameFolder',
|
||||
'HideUnsubscribed',
|
||||
'HideDeleted',
|
||||
|
|
@ -109,6 +113,39 @@ export const SettingsUserStore = new class {
|
|||
'pgpSign',
|
||||
'pgpEncrypt',
|
||||
'allowSpellcheck'
|
||||
/*
|
||||
'MessagesPerPage',
|
||||
'MessageReadDelay',
|
||||
'SoundNotification',
|
||||
'NotificationSound',
|
||||
'DesktopNotifications',
|
||||
'Layout',
|
||||
'AutoLogout',
|
||||
'ContactsAutosave',
|
||||
'contactsAllowed',
|
||||
'CheckMailInterval',
|
||||
'SentFolder',
|
||||
'DraftsFolder',
|
||||
'JunkFolder',
|
||||
'TrashFolder',
|
||||
'ArchiveFolder',
|
||||
'hourCycle',
|
||||
'Resizer4Width',
|
||||
'Resizer5Width',
|
||||
'Resizer5Height',
|
||||
'fontSansSerif',
|
||||
'fontSerif',
|
||||
'fontMono',
|
||||
'userBackgroundName',
|
||||
'userBackgroundHash',
|
||||
'autoVerifySignatures',
|
||||
'allowLanguagesOnSettings',
|
||||
'attachmentLimit',
|
||||
'Theme',
|
||||
'language',
|
||||
'clientLanguage',
|
||||
'StaticLibsJs',
|
||||
*/
|
||||
].forEach(name => {
|
||||
let value = SettingsGet(name);
|
||||
name = name[0].toLowerCase() + name.slice(1);
|
||||
|
|
|
|||
|
|
@ -7,16 +7,12 @@
|
|||
max-width: 810px;
|
||||
|
||||
.domain-desc {
|
||||
color: #666;
|
||||
line-height: 20px;
|
||||
background-color: #f9f9f9;
|
||||
padding: 8px;
|
||||
border: 1px solid #eee;
|
||||
border: 1px solid;
|
||||
border-radius: var(--input-border-radius, 3px);
|
||||
margin: -5px 0;
|
||||
i {
|
||||
font-style: normal;
|
||||
color: red;
|
||||
strong {
|
||||
color: var(--error-clr, #b94a48);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,15 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
/* https://github.com/the-djmaze/snappymail/issues/1427 */
|
||||
#V-MailFolderList .e-checkbox {
|
||||
margin: 0 4px;
|
||||
width: calc(100% - 8px);
|
||||
}
|
||||
#V-MailFolderList .unreadOnly li a:not(.unread-sub):not([data-unread]) {
|
||||
display:none;
|
||||
}
|
||||
|
||||
.b-folders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -56,6 +65,7 @@
|
|||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
min-width: 100px;
|
||||
color: var(--folders-color, #333);
|
||||
}
|
||||
|
||||
hr {
|
||||
|
|
@ -68,22 +78,18 @@
|
|||
white-space: nowrap;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 34px;
|
||||
background-color: transparent;
|
||||
vertical-align: middle;
|
||||
color: var(--folders-disabled-color, #666);
|
||||
border-left: 3px solid transparent;
|
||||
|
||||
color: var(--folders-color, #333);
|
||||
display: block;
|
||||
line-height: 34px;
|
||||
padding: 0 2em 0 @folderItemPadding;
|
||||
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
z-index: 1;
|
||||
|
||||
&.selectable {
|
||||
|
||||
color: var(--folders-color, #333);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
|
|
@ -106,6 +112,7 @@
|
|||
}
|
||||
}
|
||||
&:not(.selectable) {
|
||||
color: var(--folders-disabled-color, #666);
|
||||
cursor: default;
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
@ -185,27 +192,6 @@
|
|||
li li li li li a {
|
||||
text-indent: 4em;
|
||||
}
|
||||
|
||||
/**/
|
||||
&.single-root-inbox .b-folders-user > li > a {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&.single-root-inbox {
|
||||
li li a {
|
||||
text-indent: 0;
|
||||
}
|
||||
li li li a {
|
||||
text-indent: 1em;
|
||||
}
|
||||
li li li li a {
|
||||
text-indent: 2em;
|
||||
}
|
||||
li li li li li a {
|
||||
text-indent: 3em;
|
||||
}
|
||||
}
|
||||
/**/
|
||||
}
|
||||
|
||||
#rl-left .buttonCompose {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@
|
|||
|
||||
max-width: 770px;
|
||||
|
||||
.tab-content {
|
||||
grid-column-end: 5;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.e-signature-place {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: var(--input-border-radius, 3px);
|
||||
margin-bottom: 1.43em;
|
||||
}
|
||||
|
||||
.textEmail {
|
||||
|
|
|
|||
|
|
@ -327,6 +327,7 @@
|
|||
.attachmentParent,
|
||||
.flagParent {
|
||||
margin: 0 10px 0 5px;
|
||||
min-width: 1em;
|
||||
}
|
||||
.flagParent::after {
|
||||
content: '☆'; /*⚐*/
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.openpgp-control {
|
||||
.crypto-control {
|
||||
padding: 0.5em;
|
||||
|
||||
span {
|
||||
|
|
@ -331,6 +331,11 @@
|
|||
padding: 10px;
|
||||
position: relative;
|
||||
|
||||
&.swapColors {
|
||||
background-color: var(--main-color, currentcolor);
|
||||
color: var(--main-bg-color);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: unset;
|
||||
/* unicode-bidi: plaintext;*/
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@
|
|||
margin-top: 1em;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
td + td {
|
||||
white-space: nowrap;
|
||||
width: 1em;
|
||||
|
|
@ -24,10 +28,6 @@
|
|||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.folder-name.can-be-edited {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
width: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@
|
|||
color: red;
|
||||
content: attr(data-rainloopErrorTip);
|
||||
font-size: 90%;
|
||||
left: 100%;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
padding: 0.25em;
|
||||
position : absolute;
|
||||
text-align: var(--left, left);
|
||||
top: 100%;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
white-space: nowrap;
|
||||
white-space: pre;
|
||||
z-index: 2001;
|
||||
}
|
||||
@media screen and (max-width: @maxMobileWidth) {
|
||||
|
|
|
|||
|
|
@ -123,4 +123,8 @@ html[dir="rtl"] {
|
|||
border-right: 6px solid #eee;
|
||||
}
|
||||
}
|
||||
|
||||
[data-rainloopErrorTip]::before {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { addObservablesTo } from 'External/ko';
|
||||
import { getNotification } from 'Common/Translator';
|
||||
import { loadAccountsAndIdentities } from 'Common/UtilsUser';
|
||||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ export class AccountPopupView extends AbstractViewPopup {
|
|||
this.submitError(getNotification(iError));
|
||||
this.submitErrorAdditional(data?.ErrorMessageAdditional);
|
||||
} else {
|
||||
rl.app.accountsAndIdentities();
|
||||
loadAccountsAndIdentities();
|
||||
this.close();
|
||||
}
|
||||
}, data
|
||||
|
|
|
|||
|
|
@ -32,14 +32,12 @@ export class AdvancedSearchPopupView extends AbstractViewPopup {
|
|||
// Almost the same as MessageModel.tagOptions
|
||||
keywords: () => {
|
||||
const keywords = [{value:'',label:''}];
|
||||
FolderUserStore.currentFolder().permanentFlags.forEach(value => {
|
||||
if (isAllowedKeyword(value)) {
|
||||
let lower = value.toLowerCase();
|
||||
keywords.push({
|
||||
value: value,
|
||||
label: i18n('MESSAGE_TAGS/'+lower, 0, lower)
|
||||
});
|
||||
}
|
||||
FolderUserStore.currentFolder().optionalTags().forEach(value => {
|
||||
let lower = value.toLowerCase();
|
||||
keywords.push({
|
||||
value: value,
|
||||
label: i18n('MESSAGE_TAGS/'+lower, 0, lower)
|
||||
});
|
||||
});
|
||||
return keywords
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import {
|
|||
} from 'Common/EnumsUser';
|
||||
|
||||
import { pInt, isArray, arrayLength, b64Encode } from 'Common/Utils';
|
||||
import { encodeHtml, HtmlEditor, htmlToPlain } from 'Common/Html';
|
||||
import { encodeHtml, htmlToPlain } from 'Common/Html';
|
||||
import { HtmlEditor } from 'Common/HtmlEditor';
|
||||
import { koArrayWithDestroy, addObservablesTo, addComputablesTo, addSubscribablesTo } from 'External/ko';
|
||||
|
||||
import { UNUSED_OPTION_VALUE } from 'Common/Consts';
|
||||
|
|
@ -27,16 +28,24 @@ import { SettingsUserStore } from 'Stores/User/Settings';
|
|||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
import { AccountUserStore } from 'Stores/User/Account';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
|
||||
import { PgpUserStore } from 'Stores/User/Pgp';
|
||||
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
|
||||
import { GnuPGUserStore } from 'Stores/User/GnuPG';
|
||||
import { MailvelopeUserStore } from 'Stores/User/Mailvelope';
|
||||
//import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
|
||||
import { SMimeUserStore } from 'Stores/User/SMime';
|
||||
import { Passphrases } from 'Storage/Passphrases';
|
||||
|
||||
import { MessageUserStore } from 'Stores/User/Message';
|
||||
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
||||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { ComposeAttachmentModel } from 'Model/ComposeAttachment';
|
||||
import { EmailModel, addressparser } from 'Model/Email';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
|
||||
import { addressparser } from 'Mime/Address';
|
||||
|
||||
import { decorateKoCommands, showScreenPopup } from 'Knoin/Knoin';
|
||||
import { AbstractViewPopup } from 'Knoin/AbstractViews';
|
||||
|
|
@ -44,6 +53,7 @@ import { AbstractViewPopup } from 'Knoin/AbstractViews';
|
|||
import { FolderSystemPopupView } from 'View/Popup/FolderSystem';
|
||||
import { AskPopupView } from 'View/Popup/Ask';
|
||||
import { ContactsPopupView } from 'View/Popup/Contacts';
|
||||
|
||||
/*
|
||||
import { ThemeStore } from 'Stores/Theme';
|
||||
|
||||
|
|
@ -56,7 +66,7 @@ const
|
|||
|
||||
tpl = createElement('template'),
|
||||
|
||||
base64_encode = text => b64Encode(text).match(/.{1,76}/g).join('\r\n'),
|
||||
base64_encode = text => text ? b64Encode(text).match(/.{1,76}/g).join('\r\n') : '',
|
||||
|
||||
getEmail = value => addressparser(value)[0]?.email || false,
|
||||
|
||||
|
|
@ -243,11 +253,8 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
showBcc: false,
|
||||
showReplyTo: false,
|
||||
|
||||
pgpSign: false,
|
||||
canPgpSign: false,
|
||||
pgpEncrypt: false,
|
||||
canPgpEncrypt: false,
|
||||
canMailvelope: false,
|
||||
doSign: false,
|
||||
doEncrypt: false,
|
||||
|
||||
draftsFolder: '',
|
||||
draftUid: 0,
|
||||
|
|
@ -271,6 +278,8 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
});
|
||||
|
||||
this.attachments = koArrayWithDestroy();
|
||||
this.encryptOptions = koArrayWithDestroy();
|
||||
this.signOptions = koArrayWithDestroy();
|
||||
|
||||
this.dragAndDropOver = ko.observable(false).extend({ debounce: 1 });
|
||||
this.dragAndDropVisible = ko.observable(false).extend({ debounce: 1 });
|
||||
|
|
@ -323,6 +332,13 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
attachmentsInProcessCount: () => this.attachmentsInProcess.length,
|
||||
isDraft: () => this.draftsFolder() && this.draftUid(),
|
||||
|
||||
canEncrypt: () => this.encryptOptions().length,
|
||||
canMailvelope: () => this.encryptOptions.includes('Mailvelope'),
|
||||
canSign: () => this.signOptions().length,
|
||||
|
||||
encryptOptionsText: () => this.encryptOptions().join(', '),
|
||||
signOptionsText: () => this.signOptions().map(o => o[0]).join(', '),
|
||||
|
||||
identitiesOptions: () =>
|
||||
IdentityUserStore.map(item => ({
|
||||
item: item,
|
||||
|
|
@ -340,32 +356,31 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
|
||||
sendSuccessButSaveError: value => !value && this.savedErrorDesc(''),
|
||||
|
||||
currentIdentity: value => value && this.from(value.formattedName()),
|
||||
currentIdentity: value => {
|
||||
if (value) {
|
||||
this.from(value.formattedName());
|
||||
this.doEncrypt(value.pgpEncrypt() || SettingsUserStore.pgpEncrypt());
|
||||
this.doSign(value.pgpSign() || SettingsUserStore.pgpSign());
|
||||
}
|
||||
},
|
||||
|
||||
from: value => {
|
||||
this.canPgpSign(false);
|
||||
value = getEmail(value);
|
||||
value && PgpUserStore.getKeyForSigning(value).then(result => {
|
||||
console.log({
|
||||
email: value,
|
||||
canPgpSign:result
|
||||
});
|
||||
this.canPgpSign(result)
|
||||
});
|
||||
from: () => {
|
||||
this.initSign();
|
||||
this.initEncrypt();
|
||||
},
|
||||
|
||||
cc: value => {
|
||||
if (false === this.showCc() && value.length) {
|
||||
this.showCc(true);
|
||||
}
|
||||
this.initPgpEncrypt();
|
||||
this.initEncrypt();
|
||||
},
|
||||
|
||||
bcc: value => {
|
||||
if (false === this.showBcc() && value.length) {
|
||||
this.showBcc(true);
|
||||
}
|
||||
this.initPgpEncrypt();
|
||||
this.initEncrypt();
|
||||
},
|
||||
|
||||
replyTo: value => {
|
||||
|
|
@ -384,7 +399,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
if (this.emptyToError() && value.length) {
|
||||
this.emptyToError(false);
|
||||
}
|
||||
this.initPgpEncrypt();
|
||||
this.initEncrypt();
|
||||
},
|
||||
|
||||
attachmentsInProcess: value => {
|
||||
|
|
@ -412,7 +427,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
case 'K': quota *= 1024;
|
||||
}
|
||||
// Issue: can't select signing key
|
||||
// this.pgpSign(this.pgpSign() || confirm('Sign this message?'));
|
||||
// this.doSign(this.doSign() || confirm('Sign this message?'));
|
||||
mailvelope.createEditorContainer('#mailvelope-editor', PgpUserStore.mailvelopeKeyring, {
|
||||
// https://mailvelope.github.io/mailvelope/global.html#EditorContainerOptions
|
||||
quota: Math.max(2048, (quota / 1024)) - 48, // (text + attachments) limit in kilobytes
|
||||
|
|
@ -424,7 +439,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
quotedMailHeader: '', // header to be added before the quoted mail
|
||||
keepAttachments: false, // add attachments of quotedMail to editor (default: false)
|
||||
// Issue: can't select signing key
|
||||
signMsg: this.pgpSign()
|
||||
signMsg: this.doSign()
|
||||
*/
|
||||
}).then(editor => this.mailvelope = editor);
|
||||
}
|
||||
|
|
@ -443,7 +458,8 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
}
|
||||
|
||||
sendCommand() {
|
||||
let sSentFolder = FolderUserStore.sentFolder();
|
||||
const identity = this.currentIdentity();
|
||||
let sSentFolder = identity?.sentFolder?.() || FolderUserStore.sentFolder();
|
||||
|
||||
this.attachmentsInProcessError(false);
|
||||
this.attachmentsInErrorError(false);
|
||||
|
|
@ -465,8 +481,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
if (SettingsUserStore.replySameFolder()) {
|
||||
if (
|
||||
3 === arrayLength(this.aDraftInfo) &&
|
||||
null != this.aDraftInfo[2] &&
|
||||
this.aDraftInfo[2].length
|
||||
this.aDraftInfo[2]?.length
|
||||
) {
|
||||
sSentFolder = this.aDraftInfo[2];
|
||||
}
|
||||
|
|
@ -474,63 +489,91 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
|
||||
if (!sSentFolder) {
|
||||
showScreenPopup(FolderSystemPopupView, [FolderType.Sent]);
|
||||
} else try {
|
||||
this.sendError(false);
|
||||
this.sending(true);
|
||||
|
||||
sSentFolder = UNUSED_OPTION_VALUE === sSentFolder ? '' : sSentFolder;
|
||||
|
||||
this.getMessageRequestParams(sSentFolder).then(params => {
|
||||
Remote.request('SendMessage',
|
||||
(iError, data) => {
|
||||
this.sending(false);
|
||||
if (iError) {
|
||||
if (Notifications.CantSaveMessage === iError) {
|
||||
this.sendSuccessButSaveError(true);
|
||||
this.savedErrorDesc(i18n('COMPOSE/SAVED_ERROR_ON_SEND').trim());
|
||||
} else {
|
||||
this.sendError(true);
|
||||
this.sendErrorDesc(getNotification(iError, data?.ErrorMessage)
|
||||
|| getNotification(Notifications.CantSendMessage));
|
||||
}
|
||||
} else {
|
||||
if (arrayLength(this.aDraftInfo) > 0) {
|
||||
const flag = {
|
||||
'reply': '\\answered',
|
||||
'forward': '$forwarded'
|
||||
}[this.aDraftInfo[0]];
|
||||
if (flag) {
|
||||
const aFlags = oLastMessage.flags();
|
||||
if (aFlags.indexOf(flag) === -1) {
|
||||
aFlags.push(flag);
|
||||
oLastMessage.flags(aFlags);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
setFolderETag(this.draftsFolder(), '');
|
||||
setFolderETag(sSentFolder, '');
|
||||
if (3 === arrayLength(this.aDraftInfo)) {
|
||||
const folder = this.aDraftInfo[2];
|
||||
setFolderETag(folder, '');
|
||||
}
|
||||
reloadDraftFolder();
|
||||
},
|
||||
params,
|
||||
30000
|
||||
);
|
||||
}).catch(e => {
|
||||
} else {
|
||||
const sendError = e => {
|
||||
console.error(e);
|
||||
this.sendError(true);
|
||||
this.sendErrorDesc(e);
|
||||
this.sending(false);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.sendError(true);
|
||||
this.sendErrorDesc(e);
|
||||
this.sending(false);
|
||||
};
|
||||
const sendFailed = (iError, data) => {
|
||||
this.sendError(true);
|
||||
this.sendErrorDesc(
|
||||
getNotification(iError, data?.ErrorMessage, Notifications.CantSendMessage)
|
||||
+ "\n" + data?.ErrorMessageAdditional
|
||||
);
|
||||
};
|
||||
try {
|
||||
this.sendError(false);
|
||||
this.sending(true);
|
||||
|
||||
sSentFolder = UNUSED_OPTION_VALUE === sSentFolder ? '' : sSentFolder;
|
||||
|
||||
const sendMessage = params => {
|
||||
Remote.request('SendMessage',
|
||||
(iError, data) => {
|
||||
this.sending(false);
|
||||
if (iError) {
|
||||
/*
|
||||
if (Notifications.AuthError === iError && !params.auth) {
|
||||
AskPopupView.password('SMTP login', 'retry', 3).then(result => {
|
||||
if (result) {
|
||||
this.sending(true);
|
||||
params.auth = result;
|
||||
sendMessage(params);
|
||||
} else {
|
||||
sendFailed(iError, data);
|
||||
}
|
||||
});
|
||||
} else
|
||||
*/
|
||||
if (Notifications.CantSaveMessage === iError) {
|
||||
this.sendSuccessButSaveError(true);
|
||||
let msg = i18n('COMPOSE/SAVED_ERROR_ON_SEND');
|
||||
if (data?.ErrorMessageAdditional) {
|
||||
msg = msg + "\n" + data?.ErrorMessageAdditional;
|
||||
}
|
||||
this.savedErrorDesc(msg);
|
||||
} else {
|
||||
params.signPassphrase && Passphrases.delete(identity);
|
||||
this.sendError(true);
|
||||
sendFailed(iError, data);
|
||||
}
|
||||
} else {
|
||||
if (arrayLength(this.aDraftInfo) > 0) {
|
||||
const flag = {
|
||||
'reply': '\\answered',
|
||||
'forward': '$forwarded'
|
||||
}[this.aDraftInfo[0]];
|
||||
if (flag) {
|
||||
const aFlags = oLastMessage.flags();
|
||||
if (aFlags.indexOf(flag) === -1) {
|
||||
aFlags.push(flag);
|
||||
oLastMessage.flags(aFlags);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
setFolderETag(this.draftsFolder(), '');
|
||||
setFolderETag(sSentFolder, '');
|
||||
if (3 === arrayLength(this.aDraftInfo)) {
|
||||
const folder = this.aDraftInfo[2];
|
||||
setFolderETag(folder, '');
|
||||
}
|
||||
reloadDraftFolder();
|
||||
},
|
||||
params,
|
||||
30000
|
||||
);
|
||||
};
|
||||
|
||||
this.getMessageRequestParams(sSentFolder)
|
||||
.then(sendMessage)
|
||||
.catch(sendError);
|
||||
} catch (e) {
|
||||
sendError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -700,7 +743,6 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
// setTimeout(() => {
|
||||
this.oEditor = new HtmlEditor(
|
||||
this.editorArea(),
|
||||
null,
|
||||
() => fOnInit(this.oEditor),
|
||||
bHtml => this.isHtml(!!bHtml)
|
||||
);
|
||||
|
|
@ -853,7 +895,10 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
|
||||
switch (options.mode) {
|
||||
case ComposeType.Reply:
|
||||
case ComposeType.ReplyAll:
|
||||
case ComposeType.ReplyAll: {
|
||||
// if (1 == oLastMessage.to.length) {
|
||||
// setTimeout(() => this.from(emailArrayToStringLineHelper(oLastMessage.to)), 1);
|
||||
// }
|
||||
if (ComposeType.Reply === options.mode) {
|
||||
this.to(emailArrayToStringLineHelper(oLastMessage.replyEmails(excludeEmail)));
|
||||
} else {
|
||||
|
|
@ -866,9 +911,15 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
this.aDraftInfo = ['reply', oLastMessage.uid, oLastMessage.folder];
|
||||
this.sInReplyTo = oLastMessage.messageId;
|
||||
this.sReferences = (oLastMessage.references + ' ' + oLastMessage.messageId).trim();
|
||||
// OpenPGP “Transferable Public Key”
|
||||
// oLastMessage.autocrypt?.keydata
|
||||
break;
|
||||
oLastMessage.headers().valuesByName('autocrypt').forEach(value => {
|
||||
let autocrypt = new MimeHeaderAutocryptModel(value);
|
||||
if (autocrypt.addr && autocrypt.keydata) {
|
||||
PgpUserStore.hasPublicKeyForEmails([autocrypt.addr])
|
||||
|| PgpUserStore.importKey(autocrypt.pem(), true, true)
|
||||
// || showScreenPopup(OpenPgpImportPopupView, [autocrypt.pem()])
|
||||
}
|
||||
});
|
||||
} break;
|
||||
|
||||
case ComposeType.Forward:
|
||||
case ComposeType.ForwardAsAttachment:
|
||||
|
|
@ -1190,7 +1241,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
const attachment = new ComposeAttachmentModel(
|
||||
message.requestHash,
|
||||
message.subject() /*+ '-' + Jua.randomId()*/ + '.eml',
|
||||
message.size()
|
||||
message.size
|
||||
);
|
||||
attachment.fromMessage = true;
|
||||
attachment.complete(true);
|
||||
|
|
@ -1301,8 +1352,8 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
this.showBcc(false);
|
||||
this.showReplyTo(false);
|
||||
|
||||
this.pgpSign(SettingsUserStore.pgpSign());
|
||||
this.pgpEncrypt(SettingsUserStore.pgpEncrypt());
|
||||
this.doSign(SettingsUserStore.pgpSign());
|
||||
this.doEncrypt(SettingsUserStore.pgpEncrypt());
|
||||
|
||||
this.attachments([]);
|
||||
|
||||
|
|
@ -1338,28 +1389,55 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
].join(',').split(',').map(value => getEmail(value.trim())).validUnique();
|
||||
}
|
||||
|
||||
initPgpEncrypt() {
|
||||
const recipients = this.allRecipients();
|
||||
PgpUserStore.hasPublicKeyForEmails(recipients).then(result => {
|
||||
console.log({canPgpEncrypt:result});
|
||||
this.canPgpEncrypt(result);
|
||||
});
|
||||
PgpUserStore.mailvelopeHasPublicKeyForEmails(recipients).then(result => {
|
||||
console.log({canMailvelope:result});
|
||||
this.canMailvelope(result);
|
||||
if (!result) {
|
||||
/**
|
||||
* Checks if signing a message is possible with from email address.
|
||||
* And sets all that can.
|
||||
*/
|
||||
initSign() {
|
||||
let options = [],
|
||||
identity = this.currentIdentity(),
|
||||
email = getEmail(this.from()),
|
||||
key = OpenPGPUserStore.getPrivateKeyFor(email, 1);
|
||||
key && options.push(['OpenPGP', key]);
|
||||
key = GnuPGUserStore.getPrivateKeyFor(email, 1);
|
||||
key && options.push(['GnuPG', key]);
|
||||
identity.smimeKeyValid() && identity.smimeCertificateValid() && identity.email() === email
|
||||
&& options.push(['S/MIME']);
|
||||
console.dir({signOptions: options});
|
||||
this.signOptions(options);
|
||||
}
|
||||
|
||||
async initEncrypt() {
|
||||
const recipients = this.allRecipients(),
|
||||
options = [];
|
||||
|
||||
if (recipients.length) {
|
||||
GnuPGUserStore.hasPublicKeyForEmails(recipients)
|
||||
&& options.push('GnuPG');
|
||||
|
||||
OpenPGPUserStore.hasPublicKeyForEmails(recipients)
|
||||
&& options.push('OpenPGP');
|
||||
|
||||
const count = recipients.length,
|
||||
identity = this.currentIdentity(),
|
||||
from = (identity.smimeKey() && identity.smimeCertificate()) ? identity.email() : null;
|
||||
count
|
||||
&& count === recipients.filter(email =>
|
||||
email == from
|
||||
|| SMimeUserStore.find(certificate => email == certificate.emailAddress && certificate.smimeencrypt)
|
||||
).length
|
||||
&& options.push('S/MIME');
|
||||
|
||||
if (await MailvelopeUserStore.hasPublicKeyForEmails(recipients)) {
|
||||
options.push('Mailvelope');
|
||||
} else {
|
||||
'mailvelope' === this.viewArea() && this.bodyArea();
|
||||
// this.dropMailvelope();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
togglePgpSign() {
|
||||
this.pgpSign(!this.pgpSign()/* && this.canPgpSign()*/);
|
||||
}
|
||||
|
||||
togglePgpEncrypt() {
|
||||
this.pgpEncrypt(!this.pgpEncrypt()/* && this.canPgpEncrypt()*/);
|
||||
console.dir({encryptOptions:options});
|
||||
this.encryptOptions(options);
|
||||
}
|
||||
|
||||
async getMessageRequestParams(sSaveFolder, draft)
|
||||
|
|
@ -1410,11 +1488,18 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
// Only used at send, not at save:
|
||||
dsn: this.requestDsn() ? 1 : 0,
|
||||
requireTLS: this.requireTLS() ? 1 : 0,
|
||||
readReceiptRequest: this.requestReadReceipt() ? 1 : 0
|
||||
readReceiptRequest: this.requestReadReceipt() ? 1 : 0,
|
||||
autocrypt: [],
|
||||
/**
|
||||
* Basic support for Linked Data (Structured Email)
|
||||
* https://json-ld.org/
|
||||
* https://structured.email/
|
||||
**/
|
||||
linkedData: []
|
||||
},
|
||||
recipients = draft ? [identity.email()] : this.allRecipients(),
|
||||
sign = !draft && this.pgpSign() && this.canPgpSign(),
|
||||
encrypt = this.pgpEncrypt() && this.canPgpEncrypt(),
|
||||
signOptions = !draft && this.doSign() && this.signOptions(),
|
||||
encryptOptions = this.doEncrypt() && this.encryptOptions(),
|
||||
isHtml = this.oEditor.isHtml();
|
||||
|
||||
if (isHtml) {
|
||||
|
|
@ -1436,7 +1521,12 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
params.encrypted = draft
|
||||
? await this.mailvelope.createDraft()
|
||||
: await this.mailvelope.encrypt(recipients);
|
||||
} else if (sign || encrypt) {
|
||||
/*
|
||||
Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) =>
|
||||
params.autocrypt.push({addr:k, keydata:v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g).trim()})
|
||||
);
|
||||
*/
|
||||
} else if (signOptions.length || encryptOptions.length) {
|
||||
if (!draft && !hasAttachments && !Text.length) {
|
||||
throw i18n('COMPOSE/ERROR_EMPTY_BODY');
|
||||
}
|
||||
|
|
@ -1456,45 +1546,108 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
alternative.children.push(data);
|
||||
data = alternative;
|
||||
}
|
||||
if (!draft && sign?.[1]) {
|
||||
if ('openpgp' == sign[0]) {
|
||||
// Doesn't sign attachments
|
||||
params.html = params.plain = '';
|
||||
let signed = new MimePart;
|
||||
signed.headers['Content-Type'] =
|
||||
'multipart/signed; micalg="pgp-sha256"; protocol="application/pgp-signature"';
|
||||
signed.headers['Content-Transfer-Encoding'] = '7Bit';
|
||||
signed.children.push(data);
|
||||
let signature = new MimePart;
|
||||
signature.headers['Content-Type'] = 'application/pgp-signature; name="signature.asc"';
|
||||
signature.headers['Content-Transfer-Encoding'] = '7Bit';
|
||||
signature.body = await OpenPGPUserStore.sign(data.toString(), sign[1], 1);
|
||||
signed.children.push(signature);
|
||||
params.signed = signed.toString();
|
||||
params.boundary = signed.boundary;
|
||||
data = signed;
|
||||
} else if ('gnupg' == sign[0]) {
|
||||
let sign = true;
|
||||
for (let i = 0; i < signOptions.length; ++i) {
|
||||
if ('OpenPGP' == signOptions[i][0]) {
|
||||
try {
|
||||
// Doesn't sign attachments
|
||||
params.html = params.plain = '';
|
||||
let signed = new MimePart;
|
||||
signed.headers['Content-Type'] =
|
||||
'multipart/signed; micalg="pgp-sha256"; protocol="application/pgp-signature"';
|
||||
signed.headers['Content-Transfer-Encoding'] = '7Bit';
|
||||
signed.children.push(data);
|
||||
let signature = new MimePart;
|
||||
signature.headers['Content-Type'] = 'application/pgp-signature; name="signature.asc"';
|
||||
signature.headers['Content-Transfer-Encoding'] = '7Bit';
|
||||
signature.body = await OpenPGPUserStore.sign(data.toString(), signOptions[i][1], 1);
|
||||
signed.children.push(signature);
|
||||
params.signed = signed.toString();
|
||||
params.boundary = signed.boundary;
|
||||
data = signed;
|
||||
/*
|
||||
Object.entries(PgpUserStore.getPublicKeyOfEmails([getEmail(this.from())]) || {})
|
||||
.forEach(([k,v]) => params.publicKey = v);
|
||||
*/
|
||||
} catch (e) {
|
||||
sign = false;
|
||||
console.error(e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if ('GnuPG' == signOptions[i][0]) {
|
||||
// TODO: sign in PHP fails
|
||||
// params.signData = data.toString();
|
||||
params.signFingerprint = sign[1].fingerprint;
|
||||
params.signPassphrase = await GnuPGUserStore.sign(sign[1]);
|
||||
} else {
|
||||
throw 'Signing with ' + sign[0] + ' not yet implemented';
|
||||
let pass = await GnuPGUserStore.sign(signOptions[i][1]);
|
||||
if (null != pass) {
|
||||
// params.signData = data.toString();
|
||||
params.signFingerprint = signOptions[i][1].fingerprint;
|
||||
params.signPassphrase = pass;
|
||||
// params.attachPublicKey = false;
|
||||
} else {
|
||||
sign = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if ('S/MIME' == signOptions[i][0]) {
|
||||
// TODO: sign in PHP fails
|
||||
params.sign = 'S/MIME';
|
||||
// params.signCertificate = identity.smimeCertificate();
|
||||
// params.signPrivateKey = identity.smimeKey();
|
||||
// params.attachCertificate = false;
|
||||
if (identity.smimeKeyEncrypted()) {
|
||||
const pass = await Passphrases.ask(identity,
|
||||
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}),
|
||||
'CRYPTO/SIGN'
|
||||
);
|
||||
if (null != pass) {
|
||||
params.signPassphrase = pass.password;
|
||||
// pass.remember && Passphrases.handle(identity, pass.password);
|
||||
} else {
|
||||
sign = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (encrypt) {
|
||||
if ('openpgp' == encrypt) {
|
||||
// Doesn't encrypt attachments
|
||||
params.encrypted = await OpenPGPUserStore.encrypt(data.toString(), recipients);
|
||||
params.signed = '';
|
||||
} else if ('gnupg' == encrypt) {
|
||||
// Does encrypt attachments
|
||||
params.encryptFingerprints = JSON.stringify(GnuPGUserStore.getPublicKeyFingerprints(recipients));
|
||||
} else {
|
||||
throw 'Encryption with ' + encrypt + ' not yet implemented';
|
||||
if (signOptions.length && !sign) {
|
||||
throw 'Signing failed';
|
||||
}
|
||||
|
||||
if (encryptOptions.length) {
|
||||
const autocrypt = () =>
|
||||
Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) =>
|
||||
params.autocrypt.push({
|
||||
addr: k,
|
||||
keydata: v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '').trim()
|
||||
})
|
||||
);
|
||||
for (let i = 0; i < encryptOptions.length; ++i) {
|
||||
if ('OpenPGP' == encryptOptions[i]) {
|
||||
// Doesn't encrypt attachments
|
||||
params.encrypted = await OpenPGPUserStore.encrypt(data.toString(), recipients);
|
||||
params.signed = '';
|
||||
autocrypt();
|
||||
break;
|
||||
}
|
||||
if ('GnuPG' == encryptOptions[i]) {
|
||||
// Does encrypt attachments
|
||||
params.encryptFingerprints = JSON.stringify(GnuPGUserStore.getPublicKeyFingerprints(recipients));
|
||||
autocrypt();
|
||||
break;
|
||||
}
|
||||
if ('S/MIME' == encryptOptions[i]) {
|
||||
params.encryptCertificates = [identity.smimeCertificate()];
|
||||
SMimeUserStore.forEach(certificate => {
|
||||
certificate.emailAddress != identity.email()
|
||||
&& recipients.includes(certificate.emailAddress)
|
||||
&& params.encryptCertificates.push(certificate.id)
|
||||
});
|
||||
break;
|
||||
}
|
||||
// We skip Mailvelope as it has its own window
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export class ContactsPopupView extends AbstractViewPopup {
|
|||
|
||||
this.selector.on('ItemSelect', contact => this.populateViewContact(contact));
|
||||
|
||||
this.selector.on('ItemGetUid', contact => contact ? contact.generateUid() : '');
|
||||
this.selector.on('ItemGetUid', contact => contact ? contact.id() : '');
|
||||
|
||||
addComputablesTo(this, {
|
||||
contactsPaginator: computedPaginatorHelper(
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const
|
|||
imapType: 0,
|
||||
imapTimeout: 300,
|
||||
imapShortLogin: false,
|
||||
imapLowerLogin: true,
|
||||
// SSL
|
||||
imapSslVerify_peer: false,
|
||||
imapSslAllow_self_signed: false,
|
||||
|
|
@ -44,12 +45,14 @@ const
|
|||
sievePort: 4190,
|
||||
sieveType: 0,
|
||||
sieveTimeout: 10,
|
||||
sieveAuthLiteral: true,
|
||||
|
||||
smtpHost: '',
|
||||
smtpPort: 25,
|
||||
smtpType: 0,
|
||||
smtpTimeout: 60,
|
||||
smtpShortLogin: false,
|
||||
smtpLowerLogin: true,
|
||||
smtpUseAuth: true,
|
||||
smtpSetSender: false,
|
||||
smtpAuthPlainLine: false,
|
||||
|
|
@ -69,6 +72,7 @@ const
|
|||
secure: pInt(oDomain.imapType()),
|
||||
timeout: oDomain.imapTimeout,
|
||||
shortLogin: !!oDomain.imapShortLogin(),
|
||||
lowerLogin: !!oDomain.imapLowerLogin(),
|
||||
ssl: {
|
||||
verify_peer: !!oDomain.imapSslVerify_peer(),
|
||||
verify_peer_name: !!oDomain.imapSslVerify_peer(),
|
||||
|
|
@ -92,6 +96,7 @@ const
|
|||
secure: pInt(oDomain.smtpType()),
|
||||
timeout: oDomain.smtpTimeout,
|
||||
shortLogin: !!oDomain.smtpShortLogin(),
|
||||
lowerLogin: !!oDomain.smtpLowerLogin(),
|
||||
ssl: {
|
||||
verify_peer: !!oDomain.smtpSslVerify_peer(),
|
||||
verify_peer_name: !!oDomain.smtpSslVerify_peer(),
|
||||
|
|
@ -104,11 +109,13 @@ const
|
|||
},
|
||||
Sieve: {
|
||||
enabled: !!oDomain.sieveEnabled(),
|
||||
authLiteral: !!oDomain.sieveAuthLiteral(),
|
||||
host: oDomain.sieveHost,
|
||||
port: oDomain.sievePort,
|
||||
secure: pInt(oDomain.sieveType()),
|
||||
timeout: oDomain.sieveTimeout,
|
||||
shortLogin: !!oDomain.imapShortLogin(),
|
||||
lowerLogin: !!oDomain.imapLowerLogin(),
|
||||
ssl: {
|
||||
verify_peer: !!oDomain.imapSslVerify_peer(),
|
||||
verify_peer_name: !!oDomain.imapSslVerify_peer(),
|
||||
|
|
@ -306,21 +313,43 @@ export class DomainPopupView extends AbstractViewPopup {
|
|||
this.testingSmtpError(false);
|
||||
}
|
||||
|
||||
autoconfig() {
|
||||
let domain = this.name();
|
||||
Remote.request('AdminDomainAutoconfig', (iError, oData) => {
|
||||
if (oData?.Result?.config) {
|
||||
let server = oData.Result.config.incomingServer[0];
|
||||
this.imapHost(server.hostname);
|
||||
this.imapPort(server.port);
|
||||
this.imapType('STARTTLS' === server.socketType ? 2 : ('SSL' === server.socketType ? 1 : 0));
|
||||
this.imapShortLogin('%EMAILADDRESS%' !== server.username);
|
||||
|
||||
server = oData.Result.config.outgoingServer[0];
|
||||
this.smtpHost(server.hostname);
|
||||
this.smtpPort(server.port);
|
||||
this.smtpType('STARTTLS' === server.socketType ? 2 : ('SSL' === server.socketType ? 1 : 0));
|
||||
this.smtpShortLogin('%EMAILADDRESS%' !== server.username);
|
||||
this.smtpUseAuth(!!server.authentication);
|
||||
this.smtpUsePhpMail(false);
|
||||
}
|
||||
}, {domain});
|
||||
}
|
||||
|
||||
onShow(oDomain) {
|
||||
this.saving(false);
|
||||
this.clearTesting();
|
||||
this.edit(false);
|
||||
this.imapCapabilities([
|
||||
'BINARY',
|
||||
'LIST-STATUS',
|
||||
'METADATA',
|
||||
'MOVE',
|
||||
'NAMESPACE',
|
||||
'PREVIEW',
|
||||
'SORT',
|
||||
'THREAD',
|
||||
'BINARY',
|
||||
'STATUS=SIZE',
|
||||
'PREVIEW'
|
||||
'THREAD'
|
||||
]);
|
||||
this.imapDisabled_capabilities(['PREVIEW','STATUS=SIZE']);
|
||||
this.imapDisabled_capabilities(['METADATA','PREVIEW','STATUS=SIZE']);
|
||||
forEachObjectEntry(domainDefaults, (key, value) => this[key](value));
|
||||
this.enableSmartPorts(true);
|
||||
if (oDomain) {
|
||||
|
|
@ -343,6 +372,8 @@ export class DomainPopupView extends AbstractViewPopup {
|
|||
this[key]?.(value);
|
||||
}
|
||||
});
|
||||
this.name(IDN.toUnicode(this.name()));
|
||||
this.aliasName(IDN.toUnicode(this.aliasName()));
|
||||
this.imapCapabilities(this.imapCapabilities.concat(this.imapDisabled_capabilities()).unique());
|
||||
this.enableSmartPorts(true);
|
||||
}
|
||||
|
|
|
|||
152
dev/View/Popup/Folder.js
Normal file
152
dev/View/Popup/Folder.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { koComputable, addObservablesTo } from 'External/ko';
|
|||
import { Notifications } from 'Common/Enums';
|
||||
import { defaultOptionsAfterRender } from 'Common/Utils';
|
||||
import { folderListOptionsBuilder, sortFolders } from 'Common/Folders';
|
||||
import { getNotification } from 'Common/Translator';
|
||||
import { getNotification/*, baseCollator*/ } from 'Common/Translator';
|
||||
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ export class FolderCreatePopupView extends AbstractViewPopup {
|
|||
folders.push(subFolder);
|
||||
sortFolders(folders);
|
||||
/*
|
||||
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
|
||||
var collator = baseCollator(true);
|
||||
console.log((folder ? folder.subFolders : FolderUserStore.folderList).sort(collator.compare));
|
||||
*/
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
|
||||
import { addObservablesTo } from 'External/ko';
|
||||
import { addObservablesTo, koComputable } from 'External/ko';
|
||||
|
||||
import { getNotification } from 'Common/Translator';
|
||||
import { loadAccountsAndIdentities } from 'Common/UtilsUser';
|
||||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
|
|
@ -9,29 +10,21 @@ import { AbstractViewPopup } from 'Knoin/AbstractViews';
|
|||
|
||||
import { IdentityModel } from 'Model/Identity';
|
||||
|
||||
import { folderListOptionsBuilder } from 'Common/Folders';
|
||||
import { i18n } from 'Common/Translator';
|
||||
import { defaultOptionsAfterRender } from 'Common/Utils';
|
||||
|
||||
import { AskPopupView } from 'View/Popup/Ask';
|
||||
|
||||
export class IdentityPopupView extends AbstractViewPopup {
|
||||
constructor() {
|
||||
super('Identity');
|
||||
|
||||
addObservablesTo(this, {
|
||||
id: '',
|
||||
identity: null,
|
||||
edit: false,
|
||||
|
||||
email: '',
|
||||
emailFocused: false,
|
||||
|
||||
name: '',
|
||||
labelFocused: false,
|
||||
nameFocused: false,
|
||||
|
||||
replyTo: '',
|
||||
showReplyTo: false,
|
||||
|
||||
bcc: '',
|
||||
showBcc: false,
|
||||
|
||||
signature: '',
|
||||
signatureInsertBefore: false,
|
||||
|
||||
submitRequest: false,
|
||||
submitError: ''
|
||||
});
|
||||
|
|
@ -40,21 +33,79 @@ export class IdentityPopupView extends AbstractViewPopup {
|
|||
this.replyTo.valueHasMutated();
|
||||
this.bcc.valueHasMutated();
|
||||
*/
|
||||
this.folderSelectList = koComputable(() =>
|
||||
folderListOptionsBuilder(
|
||||
[],
|
||||
[['', '('+i18n('GLOBAL/DEFAULT')+')']]
|
||||
)
|
||||
);
|
||||
this.defaultOptionsAfterRender = defaultOptionsAfterRender;
|
||||
|
||||
this.createSelfSigned = this.createSelfSigned.bind(this);
|
||||
this.setSMimeKeyPass = this.setSMimeKeyPass.bind(this);
|
||||
}
|
||||
|
||||
createSelfSigned() {
|
||||
AskPopupView.password('', 'CRYPTO/CREATE_SELF_SIGNED').then(result => {
|
||||
if (result) {
|
||||
const identity = this.identity();
|
||||
Remote.request('SMimeCreateCertificate', (iError, oData) => {
|
||||
if (oData.Result.x509) {
|
||||
identity.smimeKey(oData.Result.pkey);
|
||||
identity.smimeCertificate(oData.Result.x509);
|
||||
} else {
|
||||
this.submitError(oData.ErrorMessage);
|
||||
}
|
||||
}, {
|
||||
name: identity.name(),
|
||||
email: identity.email(),
|
||||
privateKey: identity.smimeKey(),
|
||||
passphrase: result.password
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setSMimeKeyPass() {
|
||||
const identity = this.identity();
|
||||
let old = null
|
||||
if (identity.smimeKeyEncrypted()) {
|
||||
old = await AskPopupView.password(i18n('CRYPTO/CURRENT_PASS'), 'CRYPTO/DECRYPT');
|
||||
if (!old) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
AskPopupView.password(i18n('CRYPTO/NEW_PASS'), 'GLOBAL/SAVE').then(result => {
|
||||
if (result) {
|
||||
Remote.request('SMimeExportPrivateKey', (iError, oData) => {
|
||||
if (oData.Result) {
|
||||
identity.smimeKey(oData.Result);
|
||||
} else {
|
||||
this.submitError(oData.ErrorMessage);
|
||||
}
|
||||
}, {
|
||||
privateKey: identity.smimeKey(),
|
||||
oldPassphrase: old?.password,
|
||||
newPassphrase: result.password
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submitForm(form) {
|
||||
if (!this.submitRequest() && form.reportValidity()) {
|
||||
this.signature?.__fetchEditorValue?.();
|
||||
let identity = this.identity();
|
||||
identity.signature?.__fetchEditorValue?.();
|
||||
this.submitRequest(true);
|
||||
const data = new FormData(form);
|
||||
data.set('Id', this.id());
|
||||
data.set('Signature', this.signature());
|
||||
data.set('Id', identity.id());
|
||||
data.set('Signature', identity.signature());
|
||||
Remote.request('IdentityUpdate', iError => {
|
||||
this.submitRequest(false);
|
||||
if (iError) {
|
||||
this.submitError(getNotification(iError));
|
||||
} else {
|
||||
rl.app.accountsAndIdentities();
|
||||
loadAccountsAndIdentities();
|
||||
this.close();
|
||||
}
|
||||
}, data
|
||||
|
|
@ -66,12 +117,8 @@ export class IdentityPopupView extends AbstractViewPopup {
|
|||
* @param {?IdentityModel} oIdentity
|
||||
*/
|
||||
onShow(identity) {
|
||||
this.showBcc(false);
|
||||
this.showReplyTo(false);
|
||||
|
||||
this.submitRequest(false);
|
||||
this.submitError('');
|
||||
|
||||
if (identity) {
|
||||
this.edit(true);
|
||||
} else {
|
||||
|
|
@ -79,18 +126,10 @@ export class IdentityPopupView extends AbstractViewPopup {
|
|||
identity = new IdentityModel;
|
||||
identity.id(Jua.randomId());
|
||||
}
|
||||
this.id(identity.id() || '');
|
||||
this.name(identity.name());
|
||||
this.email(identity.email());
|
||||
this.replyTo(identity.replyTo());
|
||||
this.showReplyTo(0 < identity.replyTo().length);
|
||||
this.bcc(identity.bcc());
|
||||
this.showBcc(0 < identity.bcc().length);
|
||||
this.signature(identity.signature());
|
||||
this.signatureInsertBefore(identity.signatureInsertBefore());
|
||||
this.identity(identity);
|
||||
}
|
||||
|
||||
afterShow() {
|
||||
this.id() ? this.emailFocused(true) : this.nameFocused(true);
|
||||
this.identity().id() ? this.labelFocused(true) : this.nameFocused(true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export class OpenPgpGeneratePopupView extends AbstractViewPopup {
|
|||
const type = this.keyType().toLowerCase(),
|
||||
userId = {
|
||||
name: this.name(),
|
||||
email: this.email()
|
||||
email: IDN.toASCII(this.email())
|
||||
},
|
||||
cfg = {
|
||||
type: type,
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
import { addObservablesTo } from 'External/ko';
|
||||
import { GnuPGUserStore } from 'Stores/User/GnuPG';
|
||||
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
|
||||
import { PgpUserStore } from 'Stores/User/Pgp';
|
||||
|
||||
import { AbstractViewPopup } from 'Knoin/AbstractViews';
|
||||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
import { i18n } from 'Common/Translator';
|
||||
|
||||
export class OpenPgpImportPopupView extends AbstractViewPopup {
|
||||
constructor() {
|
||||
super('OpenPgpImport');
|
||||
|
||||
addObservablesTo(this, {
|
||||
search: '',
|
||||
|
||||
key: '',
|
||||
keyError: false,
|
||||
keyErrorMessage: '',
|
||||
|
||||
saveGnuPG: true,
|
||||
saveServer: false
|
||||
saveServer: true
|
||||
});
|
||||
|
||||
this.canGnuPG = GnuPGUserStore.isSupported();
|
||||
|
|
@ -25,6 +30,44 @@ export class OpenPgpImportPopupView extends AbstractViewPopup {
|
|||
});
|
||||
}
|
||||
|
||||
searchPGP() {
|
||||
this.key(i18n('SUGGESTIONS/SEARCHING_DESC'));
|
||||
const fn = () => Remote.request('PgpSearchKey',
|
||||
(iError, oData) => {
|
||||
if (iError) {
|
||||
this.key(oData.ErrorMessage);
|
||||
} else {
|
||||
this.key(oData.Result);
|
||||
}
|
||||
}, {
|
||||
query: this.search()
|
||||
}
|
||||
);
|
||||
fetch(
|
||||
`https://keys.openpgp.org/pks/lookup?op=get&options=mr&search=${this.search()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
credentials: 'omit'
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
if ('application/pgp-keys' == response.headers.get('Content-Type')) {
|
||||
response.text().then(body => this.key(body));
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
this.key('keys.openpgp.org: ' + e?.message + '\nTrying local...');
|
||||
fn();
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
submitForm() {
|
||||
let keyTrimmed = this.key().trim();
|
||||
|
||||
|
|
@ -35,38 +78,30 @@ export class OpenPgpImportPopupView extends AbstractViewPopup {
|
|||
this.keyError(!keyTrimmed);
|
||||
this.keyErrorMessage('');
|
||||
|
||||
if (!keyTrimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let match = null,
|
||||
count = 30,
|
||||
done = false;
|
||||
// eslint-disable-next-line max-len
|
||||
const reg = /[-]{3,6}BEGIN[\s]PGP[\s](PRIVATE|PUBLIC)[\s]KEY[\s]BLOCK[-]{3,6}[\s\S]+?[-]{3,6}END[\s]PGP[\s](PRIVATE|PUBLIC)[\s]KEY[\s]BLOCK[-]{3,6}/gi;
|
||||
|
||||
do {
|
||||
match = reg.exec(keyTrimmed);
|
||||
if (match && 0 < count) {
|
||||
if (match[0] && match[1] && match[2] && match[1] === match[2]) {
|
||||
this.saveGnuPG() && GnuPGUserStore.isSupported() && GnuPGUserStore.importKey(this.key(), (iError, oData) => {
|
||||
iError && alert(oData.ErrorMessage);
|
||||
});
|
||||
OpenPGPUserStore.isSupported() && OpenPGPUserStore.importKey(this.key());
|
||||
}
|
||||
|
||||
--count;
|
||||
if (keyTrimmed) {
|
||||
let match = null,
|
||||
count = 30,
|
||||
done = false;
|
||||
} else {
|
||||
done = true;
|
||||
}
|
||||
} while (!done);
|
||||
const GnuPG = this.saveGnuPG() && GnuPGUserStore.isSupported(),
|
||||
backup = this.saveServer(),
|
||||
// eslint-disable-next-line max-len
|
||||
reg = /[-]{3,6}BEGIN[\s]PGP[\s](PRIVATE|PUBLIC)[\s]KEY[\s]BLOCK[-]{3,6}[\s\S]+?[-]{3,6}END[\s]PGP[\s](PRIVATE|PUBLIC)[\s]KEY[\s]BLOCK[-]{3,6}/gi;
|
||||
|
||||
if (this.keyError()) {
|
||||
return;
|
||||
do {
|
||||
match = reg.exec(keyTrimmed);
|
||||
if (match && 0 < count) {
|
||||
if (match[0] && match[1] && match[2] && match[1] === match[2]) {
|
||||
PgpUserStore.importKey(this.key(), GnuPG, backup);
|
||||
}
|
||||
--count;
|
||||
done = false;
|
||||
} else {
|
||||
done = true;
|
||||
}
|
||||
} while (!done);
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
onShow(key) {
|
||||
|
|
|
|||
50
dev/View/Popup/SMimeImport.js
Normal file
50
dev/View/Popup/SMimeImport.js
Normal 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('');
|
||||
}
|
||||
}
|
||||
|
|
@ -92,10 +92,6 @@ export class LoginUserView extends AbstractViewLogin {
|
|||
this.submitError('');
|
||||
}
|
||||
|
||||
toggleSignMe() {
|
||||
this.signMe(!this.signMe());
|
||||
}
|
||||
|
||||
submitCommand(self, event) {
|
||||
const email = this.email().trim();
|
||||
this.email(email);
|
||||
|
|
@ -124,7 +120,7 @@ export class LoginUserView extends AbstractViewLogin {
|
|||
iError = Notifications.AuthError;
|
||||
}
|
||||
this.submitError(getNotification(iError, oData?.ErrorMessage,
|
||||
Notifications.UnknownNotification));
|
||||
Notifications.UnknownError));
|
||||
this.submitErrorAdditional(oData?.ErrorMessageAdditional);
|
||||
} else {
|
||||
rl.setData(oData.Result);
|
||||
|
|
@ -142,25 +138,20 @@ export class LoginUserView extends AbstractViewLogin {
|
|||
onBuild(dom) {
|
||||
super.onBuild(dom);
|
||||
|
||||
const signMe = (SettingsGet('signMe') || '').toLowerCase();
|
||||
|
||||
let signMe = SettingsGet('signMe');
|
||||
switch (signMe) {
|
||||
case 'defaultoff':
|
||||
case 'defaulton':
|
||||
this.signMeType(
|
||||
'defaulton' === signMe ? SignMeOn : SignMeOff
|
||||
);
|
||||
|
||||
case SignMeOff:
|
||||
case SignMeOn:
|
||||
switch (Local.get(ClientSideKeyNameLastSignMe)) {
|
||||
case '-1-':
|
||||
this.signMeType(SignMeOn);
|
||||
signMe = SignMeOn;
|
||||
break;
|
||||
case '-0-':
|
||||
this.signMeType(SignMeOff);
|
||||
signMe = SignMeOff;
|
||||
break;
|
||||
// no default
|
||||
}
|
||||
|
||||
this.signMeType(signMe);
|
||||
break;
|
||||
default:
|
||||
this.signMeType(SignMeUnused);
|
||||
|
|
|
|||
|
|
@ -35,31 +35,18 @@ export class MailFolderList extends AbstractViewLeft {
|
|||
|
||||
this.moveAction = moveAction;
|
||||
|
||||
this.foldersListWithSingleInboxRootFolder = ko.observable(false);
|
||||
|
||||
this.allowContacts = AppUserStore.allowContacts();
|
||||
|
||||
this.foldersFilter = foldersFilter;
|
||||
|
||||
this.filterUnseen = ko.observable(false);
|
||||
|
||||
addComputablesTo(this, {
|
||||
foldersFilterVisible: () => 20 < FolderUserStore.folderList().CountRec,
|
||||
|
||||
folderListVisible: () => {
|
||||
let multiple = false,
|
||||
inbox, visible,
|
||||
result = FolderUserStore.folderList().filter(folder => {
|
||||
if (folder.isInbox()) {
|
||||
inbox = folder;
|
||||
}
|
||||
visible = folder.visible();
|
||||
multiple |= visible && !folder.isInbox();
|
||||
return visible;
|
||||
});
|
||||
if (inbox && !multiple) {
|
||||
inbox.collapsed(false);
|
||||
}
|
||||
this.foldersListWithSingleInboxRootFolder(!multiple);
|
||||
return result;
|
||||
let result = FolderUserStore.folderList().visible();
|
||||
return 1 === result.length && result[0].isInbox() ? result[0].visibleSubfolders() : result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -90,13 +77,14 @@ export class MailFolderList extends AbstractViewLeft {
|
|||
const folder = ko.dataFor(el);
|
||||
if (folder) {
|
||||
if (moveAction()) {
|
||||
moveAction(false);
|
||||
let messages = MessagelistUserStore.listCheckedOrSelectedUidsWithSubMails();
|
||||
const copy = event.ctrlKey || 2 === moveAction(),
|
||||
messages = MessagelistUserStore.listCheckedOrSelectedUidsWithSubMails();
|
||||
moveAction(0);
|
||||
messages.size && MessagelistUserStore.moveMessages(
|
||||
messages.folder,
|
||||
messages,
|
||||
folder.fullName,
|
||||
event.ctrlKey
|
||||
copy
|
||||
);
|
||||
} else {
|
||||
if (!SettingsUserStore.usePreviewPane()) {
|
||||
|
|
@ -173,7 +161,7 @@ export class MailFolderList extends AbstractViewLeft {
|
|||
// addShortcut('tab', 'shift', ScopeFolderList, () => {
|
||||
addShortcut('escape,tab,arrowright', '', ScopeFolderList, () => {
|
||||
AppUserStore.focusedState(ScopeMessageList);
|
||||
moveAction(false);
|
||||
moveAction(0);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,11 +162,11 @@ export class MailMessageList extends AbstractViewRight {
|
|||
let list = [], current, sort = FolderUserStore.sortMode() || 'DATE';
|
||||
if (sort.includes('FROM')) {
|
||||
MessagelistUserStore.forEach(msg => {
|
||||
let email = msg.from[0].email;
|
||||
let email = msg.from[0]?.email;
|
||||
if (!current || email != current.id) {
|
||||
current = {
|
||||
id: email,
|
||||
label: msg.from[0].toLine(),
|
||||
label: msg.from[0]?.toLine(),
|
||||
search: 'from=' + email,
|
||||
messages: []
|
||||
};
|
||||
|
|
@ -243,7 +243,7 @@ export class MailMessageList extends AbstractViewRight {
|
|||
|
||||
this.selector.on('MiddleClick', message => populateMessageBody(message, true));
|
||||
|
||||
this.selector.on('ItemGetUid', message => (message ? message.generateUid() : ''));
|
||||
this.selector.on('ItemGetUid', message => (message ? message.folder + '/' + message.uid : ''));
|
||||
|
||||
this.selector.on('canSelect', () => MessagelistUserStore.canSelect());
|
||||
|
||||
|
|
@ -331,6 +331,7 @@ export class MailMessageList extends AbstractViewRight {
|
|||
spamCommand: canBeMovedHelper,
|
||||
notSpamCommand: canBeMovedHelper,
|
||||
moveCommand: canBeMovedHelper,
|
||||
copyCommand: canBeMovedHelper
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -415,18 +416,26 @@ export class MailMessageList extends AbstractViewRight {
|
|||
moveMessagesToFolderType(FolderType.Inbox);
|
||||
}
|
||||
|
||||
moveCommand(vm, event) {
|
||||
moveOrCopy(vm, event, mode) {
|
||||
if (canBeMovedHelper()) {
|
||||
if (vm && event?.preventDefault) {
|
||||
stopEvent(event);
|
||||
}
|
||||
|
||||
let b = moveAction();
|
||||
AppUserStore.focusedState(b ? ScopeMessageList : ScopeFolderList);
|
||||
moveAction(!b);
|
||||
let i = moveAction();
|
||||
AppUserStore.focusedState(i ? ScopeMessageList : ScopeFolderList);
|
||||
moveAction(i ? 0 : mode);
|
||||
}
|
||||
}
|
||||
|
||||
moveCommand(vm, event) {
|
||||
this.moveOrCopy(vm, event, 1);
|
||||
}
|
||||
|
||||
copyCommand(vm, event) {
|
||||
this.moveOrCopy(vm, event, 2);
|
||||
}
|
||||
|
||||
composeClick() {
|
||||
showMessageComposer();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ import { showScreenPopup } from 'Knoin/Knoin';
|
|||
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
|
||||
import { GnuPGUserStore } from 'Stores/User/GnuPG';
|
||||
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
|
||||
import { Passphrases } from 'Storage/Passphrases';
|
||||
|
||||
const
|
||||
oMessageScrollerDom = () => elementById('messageItem') || {},
|
||||
|
|
@ -109,7 +112,8 @@ export class MailMessageView extends AbstractViewRight {
|
|||
actionsMenu: null,
|
||||
// viewer
|
||||
viewFromShort: '',
|
||||
dkimData: ['none', '', '']
|
||||
dkimData: ['none', '', ''],
|
||||
nowTracking: false
|
||||
});
|
||||
|
||||
this.moveAction = moveAction;
|
||||
|
|
@ -142,7 +146,7 @@ export class MailMessageView extends AbstractViewRight {
|
|||
|
||||
downloadAsZipAllowed: () => this.attachmentsActions.includes('zip')
|
||||
&& (currentMessage()?.attachments || [])
|
||||
.filter(item => item?.download /*&& !item?.isLinked()*/ && item?.checked())
|
||||
.filter(item => item?.checked() && item?.download /*&& !item?.isLinked()*/)
|
||||
.length,
|
||||
|
||||
tagsAllowed: () => FolderUserStore.currentFolder()?.tagsAllowed(),
|
||||
|
|
@ -155,10 +159,10 @@ export class MailMessageView extends AbstractViewRight {
|
|||
: ''
|
||||
).join(' '),
|
||||
|
||||
askReadReceipt: () =>
|
||||
(MessagelistUserStore.isDraftFolder() || MessagelistUserStore.isSentFolder())
|
||||
&& currentMessage()?.readReceipt()
|
||||
&& currentMessage()?.flags().includes('$mdnsent'),
|
||||
askReadReceipt: () => currentMessage()?.readReceipt
|
||||
&& !(MessagelistUserStore.isDraftFolder() || MessagelistUserStore.isSentFolder())
|
||||
&& !currentMessage()?.flags().includes('$mdnsent')
|
||||
&& !currentMessage()?.flags().includes('\\answered'),
|
||||
|
||||
listAttachments: () => currentMessage()?.attachments()
|
||||
.filter(item => SettingsUserStore.listInlineAttachments() || !item.isLinked()),
|
||||
|
|
@ -170,18 +174,17 @@ export class MailMessageView extends AbstractViewRight {
|
|||
|
||||
canBeRepliedOrForwarded: () => !MessagelistUserStore.isDraftFolder() && this.messageVisible(),
|
||||
|
||||
viewDkimIcon: () => 'none' !== this.dkimData()[0],
|
||||
|
||||
dkimIconClass:() => {
|
||||
dkimIcon: () => {
|
||||
switch (this.dkimData()[0]) {
|
||||
case 'none':
|
||||
return '';
|
||||
case 'pass':
|
||||
return 'icon-ok iconcolor-green'; // ✔️
|
||||
return '✔';
|
||||
default:
|
||||
return 'icon-cross iconcolor-red'; // ✖ ❌
|
||||
return '✖';
|
||||
}
|
||||
},
|
||||
dkimIconClass: () => 'pass' === this.dkimData()[0] ? 'iconcolor-green' : 'iconcolor-red',
|
||||
|
||||
dkimTitle:() => {
|
||||
const dkim = this.dkimData();
|
||||
|
|
@ -208,6 +211,7 @@ export class MailMessageView extends AbstractViewRight {
|
|||
// TODO: make first param a user setting #683
|
||||
this.viewFromShort(message.from.toString(false, true));
|
||||
this.dkimData(message.dkim[0] || ['none', '', '']);
|
||||
this.nowTracking(false);
|
||||
} else {
|
||||
MessagelistUserStore.selectedMessage(null);
|
||||
|
||||
|
|
@ -236,6 +240,7 @@ export class MailMessageView extends AbstractViewRight {
|
|||
decorateKoCommands(this, {
|
||||
editCommand: self => self.messageVisible(),
|
||||
moveCommand: self => self.messageVisible(),
|
||||
copyCommand: self => self.messageVisible(),
|
||||
goUpCommand: self => !self.messageListOrViewLoading(),
|
||||
goDownCommand: self => !self.messageListOrViewLoading()
|
||||
});
|
||||
|
|
@ -253,13 +258,21 @@ export class MailMessageView extends AbstractViewRight {
|
|||
currentMessage() && showMessageComposer([ComposeType.Draft, currentMessage()]);
|
||||
}
|
||||
|
||||
moveCommand(vm, event) {
|
||||
moveOrCopy(vm, event, mode) {
|
||||
if (vm && event?.preventDefault) {
|
||||
stopEvent(event);
|
||||
}
|
||||
this.actionsMenu().ddBtn.hide();
|
||||
AppUserStore.focusedState(ScopeFolderList);
|
||||
moveAction(true);
|
||||
moveAction(mode);
|
||||
}
|
||||
|
||||
moveCommand(vm, event) {
|
||||
this.moveOrCopy(vm, event, 1);
|
||||
}
|
||||
|
||||
copyCommand(vm, event) {
|
||||
this.moveOrCopy(vm, event, 2);
|
||||
}
|
||||
|
||||
setUnseen() {
|
||||
|
|
@ -500,6 +513,17 @@ export class MailMessageView extends AbstractViewRight {
|
|||
currentMessage().showExternalImages();
|
||||
}
|
||||
|
||||
showTracking() {
|
||||
const msg = currentMessage(), body = msg?.body;
|
||||
if (body && msg.hasTracking()) {
|
||||
let attr = 'data-x-href-tracking';
|
||||
body.querySelectorAll('a['+attr+']').forEach(node => node.href = node.getAttribute(attr));
|
||||
// attr = 'data-x-src-tracking';
|
||||
// body.querySelectorAll('img['+attr+']').forEach(node => node.src = node.getAttribute(attr));
|
||||
this.nowTracking(true);
|
||||
}
|
||||
}
|
||||
|
||||
whitelistText(txt) {
|
||||
let value = (SettingsUserStore.viewImagesWhitelist().trim() + '\n' + txt).trim();
|
||||
/*
|
||||
|
|
@ -525,20 +549,19 @@ export class MailMessageView extends AbstractViewRight {
|
|||
* @returns {void}
|
||||
*/
|
||||
readReceipt() {
|
||||
let oMessage = currentMessage()
|
||||
if (oMessage.readReceipt()) {
|
||||
Remote.request('SendReadReceiptMessage', iError => {
|
||||
if (!iError) {
|
||||
oMessage.flags.push('$mdnsent');
|
||||
// oMessage.flags.valueHasMutated();
|
||||
let oMessage = currentMessage();
|
||||
if (oMessage.readReceipt) {
|
||||
oMessage.flags.push('$mdnsent');
|
||||
Remote.request('SendReadReceiptMessage',
|
||||
iError => iError && oMessage.flags.remove('$mdnsent'),
|
||||
{
|
||||
messageFolder: oMessage.folder,
|
||||
messageUid: oMessage.uid,
|
||||
readReceipt: oMessage.readReceipt,
|
||||
subject: i18n('READ_RECEIPT/SUBJECT', { SUBJECT: oMessage.subject() }),
|
||||
plain: i18n('READ_RECEIPT/BODY', { 'READ-RECEIPT': AccountUserStore.email() })
|
||||
}
|
||||
}, {
|
||||
messageFolder: oMessage.folder,
|
||||
messageUid: oMessage.uid,
|
||||
readReceipt: oMessage.readReceipt(),
|
||||
subject: i18n('READ_RECEIPT/SUBJECT', { SUBJECT: oMessage.subject() }),
|
||||
plain: i18n('READ_RECEIPT/BODY', { 'READ-RECEIPT': AccountUserStore.email() })
|
||||
});
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -554,40 +577,45 @@ export class MailMessageView extends AbstractViewRight {
|
|||
}
|
||||
|
||||
pgpDecrypt() {
|
||||
const oMessage = currentMessage();
|
||||
const oMessage = currentMessage(),
|
||||
data = oMessage.pgpEncrypted();
|
||||
delete data.error;
|
||||
PgpUserStore.decrypt(oMessage).then(result => {
|
||||
if (result) {
|
||||
oMessage.pgpDecrypted(true);
|
||||
if (result.data) {
|
||||
MimeToMessage(result.data, oMessage);
|
||||
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
|
||||
if (result.signatures?.length) {
|
||||
oMessage.pgpSigned(true);
|
||||
oMessage.pgpVerified({
|
||||
signatures: result.signatures,
|
||||
success: !!result.signatures.length
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!result) {
|
||||
// TODO: translate
|
||||
alert('Decryption failed, canceled or not possible');
|
||||
throw Error('Decryption failed, canceled or not possible');
|
||||
}
|
||||
oMessage.pgpDecrypted(true);
|
||||
if (result.data) {
|
||||
MimeToMessage(result.data, oMessage);
|
||||
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
|
||||
if (result.signatures?.length) {
|
||||
oMessage.pgpSigned({
|
||||
signatures: result.signatures,
|
||||
success: !!result.signatures.length
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(e => console.error(e));
|
||||
.catch(e => {
|
||||
data.error = e.message;
|
||||
})
|
||||
.finally(() => {
|
||||
oMessage.pgpEncrypted(data);
|
||||
});
|
||||
}
|
||||
|
||||
pgpVerify(/*self, event*/) {
|
||||
const oMessage = currentMessage()/*, ctrl = event.target.closest('.openpgp-control')*/;
|
||||
PgpUserStore.verify(oMessage).then(result => {
|
||||
if (result) {
|
||||
oMessage.pgpVerified(result);
|
||||
oMessage.pgpSigned(result);
|
||||
} else {
|
||||
alert('Verification failed or no valid public key found');
|
||||
}
|
||||
/*
|
||||
if (result?.success) {
|
||||
i18n('OPENPGP/GOOD_SIGNATURE', {
|
||||
i18n('CRYPTO/GOOD_SIGNATURE', {
|
||||
USER: validKey.user + ' (' + validKey.id + ')'
|
||||
});
|
||||
message.getText()
|
||||
|
|
@ -597,7 +625,8 @@ export class MailMessageView extends AbstractViewRight {
|
|||
? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ')
|
||||
: '';
|
||||
|
||||
i18n('OPENPGP/ERROR', {
|
||||
i18n('CRYPTO/ERROR', {
|
||||
TYPE: 'OpenPGP',
|
||||
ERROR: 'message'
|
||||
}) + (additional ? ' (' + additional + ')' : '');
|
||||
}
|
||||
|
|
@ -605,4 +634,68 @@ export class MailMessageView extends AbstractViewRight {
|
|||
});
|
||||
}
|
||||
|
||||
async smimeDecrypt() {
|
||||
const message = currentMessage();
|
||||
const addresses = message.from.concat(message.to, message.cc, message.bcc).map(item => item.email),
|
||||
identity = IdentityUserStore.find(item => addresses.includes(item.email())),
|
||||
data = message.smimeEncrypted(); // { partId: "1" }
|
||||
if (data && identity) {
|
||||
delete data.error;
|
||||
let pass, params = { ...data }; // clone
|
||||
params.folder = message.folder;
|
||||
params.uid = message.uid;
|
||||
// params.bodyPart = params.bodyPart?.raw;
|
||||
params.certificate = identity.smimeCertificate();
|
||||
params.privateKey = identity.smimeKey();
|
||||
if (identity.smimeKeyEncrypted()) {
|
||||
pass = await Passphrases.ask(identity,
|
||||
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}),
|
||||
'CRYPTO/DECRYPT'
|
||||
);
|
||||
if (!pass) {
|
||||
return;
|
||||
}
|
||||
params.passphrase = pass?.password;
|
||||
}
|
||||
Remote.post('SMimeDecryptMessage', null, params).then(response => {
|
||||
if (response?.Result?.data) {
|
||||
message.smimeDecrypted(true);
|
||||
MimeToMessage(response.Result.data, message);
|
||||
message.html() ? message.viewHtml() : message.viewPlain();
|
||||
pass && pass.remember && Passphrases.handle(identity, pass.password);
|
||||
if ('signed' in response.Result) {
|
||||
message.smimeSigned(response.Result.signed);
|
||||
}
|
||||
}
|
||||
}).catch(e => {
|
||||
data.error = e.message
|
||||
})
|
||||
.finally(() => {
|
||||
message.smimeEncrypted(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
smimeVerify(/*self, event*/) {
|
||||
const message = currentMessage(),
|
||||
data = message.smimeSigned(); // { partId: "1", micAlg: "pgp-sha256" }
|
||||
if (data) {
|
||||
const params = { ...data }; // clone
|
||||
params.folder = message.folder;
|
||||
params.uid = message.uid;
|
||||
params.bodyPart = data.bodyPart?.raw;
|
||||
params.sigPart = data.sigPart?.bodyRaw;
|
||||
Remote.post('SMimeVerifyMessage', null, params).then(response => {
|
||||
if (response?.Result) {
|
||||
if (response.Result.body) {
|
||||
MimeToMessage(response.Result.body, message);
|
||||
message.html() ? message.viewHtml() : message.viewPlain();
|
||||
}
|
||||
data.success = response.Result.success;
|
||||
message.smimeSigned(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,9 +87,8 @@ export class SystemDropDownUserView extends AbstractViewRight {
|
|||
}
|
||||
|
||||
accountName() {
|
||||
let email = AccountUserStore.email(),
|
||||
account = AccountUserStore.find(account => account.email == email);
|
||||
return account?.name || email;
|
||||
const email = AccountUserStore.email();
|
||||
return AccountUserStore.find(account => account.email == email)?.label() || IDN.toUnicode(email);
|
||||
}
|
||||
|
||||
settingsClick() {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
const
|
||||
qUri = path => doc.location.pathname.replace(/\/+$/,'') + '/?/' + path,
|
||||
eId = id => doc.getElementById('rl-'+id),
|
||||
admin = '1' == eId('app')?.dataset?.admin,
|
||||
admin = '1' == eId('app').dataset.admin,
|
||||
|
||||
toggle = div => {
|
||||
eId('loading').hidden = true;
|
||||
|
|
@ -69,7 +69,7 @@ window.rl = {
|
|||
headers: {}
|
||||
}, init);
|
||||
let asJSON = 1,
|
||||
XToken = RL_APP_DATA.System?.token,
|
||||
XToken = (RL_APP_DATA.System || {}).token,
|
||||
object = {};
|
||||
if (postData) {
|
||||
init.method = 'POST';
|
||||
|
|
|
|||
|
|
@ -75,8 +75,8 @@
|
|||
// create drag image from custom element or drag source
|
||||
img = src.cloneNode(true);
|
||||
copyStyle(src, img);
|
||||
img._x = xOffset == null ? src.clientWidth / 2 : xOffset;
|
||||
img._y = yOffset == null ? src.clientHeight / 2 : yOffset;
|
||||
img._x = xOffset ?? src.clientWidth / 2;
|
||||
img._y = yOffset ?? src.clientHeight / 2;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
7
dev/prototype.js
vendored
7
dev/prototype.js
vendored
|
|
@ -44,6 +44,13 @@
|
|||
return template.content.firstChild;
|
||||
};
|
||||
|
||||
/**
|
||||
* https://github.com/tc39/proposal-regex-escaping
|
||||
*/
|
||||
if (!RegExp.escape){
|
||||
RegExp.escape = s => String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every time the function is executed,
|
||||
* it will delay the execution with the given amount of milliseconds.
|
||||
|
|
|
|||
106
examples/crypto/3140898548F06E97-private.asc
Normal file
106
examples/crypto/3140898548F06E97-private.asc
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
-----BEGIN PGP PRIVATE KEY BLOCK-----
|
||||
|
||||
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
|
||||
YXYPCv6njzAv1+fTgemAZYyomQ1qhVg=
|
||||
=a9os
|
||||
-----END PGP PRIVATE KEY BLOCK-----
|
||||
50
examples/crypto/3140898548F06E97-public.asc
Normal file
50
examples/crypto/3140898548F06E97-public.asc
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
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
|
||||
=0ljX
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
16
examples/crypto/5F3A5CDC09AD8AE3-private.asc
Normal file
16
examples/crypto/5F3A5CDC09AD8AE3-private.asc
Normal 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
Loading…
Add table
Add a link
Reference in a new issue