Update Fork (#2)

* Cleanup OpenPgpImportPopupView code

* update polish translation

* small fix

* Added Import S/MIME certificate popup
And much better handling of the sign and encrypt options

* bugfix: store in Passphrases

* Resolve #1448

* pre-verify S/MIME opaque signed messages so we have a body to view

* Fix timestampToString() for future dates

* Move php8.php to /app/libraries/polyfill/

* Improved Settings handling to prevent bugs in outer code

* Changed AbstractProvider::IsActive() to be abstract

* Example for #1449

* bugfix: previous IsActive() commit

* OpenSSL required due to S/MIME

* Use get_debug_type() instead of gettype()

* update polish translation

* Make all Enumerations classes abstract

* Added search functionality in Admin -> Config
And removed the unused ['capa']['quota']

* Cleanup Quota handling

* OPEN_PGP should be OPENPGP as it is one word

* Improve Capa handling

* Resolve #1451

* Bugfix TypeError: b64Encode(...).match(...) is null

* Small StorageType change

* Bugfix: mailvelope editor failed

* Bugfix: undefined getMailvelopePrivateKeyFor()

* Bugfix: MIME parser RegExp didn't escape `boundary` which caused issues

* Return detailed info on PgpImportKey

* Show GnuPG verify error

* Sort PGP keys by email and id

* Sort S/MIME certificates on emailAddress else validTo

* S/MIME import from signature use `BEGIN PKCS7`

* Optionally use existing private key to generate S/MIME certificate

* Chaned some error_log() to MailSo Logger()

* Force reload of S/MIME certificates list on import

* Make better use of SnappyMail\SensitiveString

* Fix view PGP key button

* Mask all POST data that has a key which contains `pass`

* v2.35.1

* Resolve #1455

* Improved GnuPG error handling

* Update pt/pt-PT translation

* update Polish translation

* Drop support for gnupg pecl extension as it fails with "no passphrase" issues

* Resolve #1456

* Resolve #1458

* v2.35.2

* fix changelog

* Resolve #1461

* Update pt/pt-PT translation

* compact-composer plugin v1.0.0

* Resolve #1462

* Fix decrypt error message

* `new Error()` to `Error()`

* Resolve #1463

* Show url for #1466

* Simplify SignMe/Remember me code

* Simplify language Notifications

* Bugfix: SetPassword expects \SnappyMail\SensitiveString

* https://github.com/the-djmaze/snappymail/issues/1450#issuecomment-1972147950

* improve: fire the 'squire2-toolbar' event after more props are added

* improve: add dark theme support and use 'button' element as menu trigger for consistent styling

* fix: use compact template in non-destructive way (do not replace the PopupsCompose template if a different wysiwyg is used)

* Update admin.json

* Update user.json

* CSS rainloopErrorTip location

* Improved error handling on PGP and S/MIME decrypt

* KnockoutJS remove unused `beforeRemove`

* KnockoutJS drop unused `as`

* KnockoutJS simplify renderMode because only 1 option is used

* KnoutJS cleanup templating.js a bit

* KnockoutJS drop unused `bindingRewriteValidators`

* KnockoutJS drop the twoWayBindings code

* KnockoutJS simplify virtualElements binding check

* KnockoutJS simplify applyBindingsToNodeInternal

* KnockoutJS use Array.isArray

* KnockoutJS drop alias `textinput` for `textInput`

* KnockoutJS scramble `createChildContext`

* KnockoutJS scramble `controlsDescendantBindings`

* KnockoutJS scramble `exportDependencies`

* KnockoutJS drop unused `throttleEvaluation`

* KnockoutJS drop unused `valueAllowUnset`

* KnockoutJS drop unused `templateNodes`

* KnockoutJS drop unused `optionsCaption`

* KnockoutJS drop unused `dontLimitMoves`

* KnockoutJS drop unused `uniqueName`

* KnockoutJS drop IE leftovers

* KnockoutJS drop unused `preprocess`

* KnockoutJS drop unused "disposeWhenNodeIsRemoved" and "disposeWhen"

* KnockoutJS don't scramble exportDependencies. controlsDescendantBindings, createChildContext

* KnockoutJS drop unused `$parentContext` and `$parents`

* KnockoutJS drop unused `$rawData`

* Knockoutjs built latest

* KnockoutJS drop unused template options `nodes`, `if`, `ifnot`

* KnockoutJS use more Array.isArray

* KnockoutJS cleanup code a bit

* KnockoutJS primitiveTypes can just be checked with Object()

* KnockoutJS rebuilt

* Verify S/MIME signed automatically and log Exception

* Automatically verify PGP and S/MIME signed messages

* `new Error` to `Error`

* By default throw AccountNotAllowed as confused in #1478

* GPG use pinentries for decrypt, sign and export

* Better GPG error handling

* GPG show error on view/export

* OpenPGP fix handling of importing keys

* Make "verify signatures automatically" optional, as it requires more IMAP fetching

* S/MIME don't post identity key and certificate, just fetch from server

* Show error to old browsers, instead of crashing

* Automatically verify S/MIME decrypted signed message

---------

Co-authored-by: the-djmaze <>
Co-authored-by: tinola <tinola@poczta.onet.pl>
Co-authored-by: Maarten <3752035+the-djmaze@users.noreply.github.com>
Co-authored-by: lmperfis <joint.striker@gmail.com>
Co-authored-by: Sergey Mosin <sergey@srgdev.com>
Co-authored-by: hguilbert <51283484+hguilbert@users.noreply.github.com>
This commit is contained in:
S-A-L13 2024-03-04 17:00:27 +01:00 committed by GitHub
parent 3bd073a38b
commit 876ed221c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
218 changed files with 10266 additions and 4267 deletions

View file

@ -1,3 +1,50 @@
## 2.35.2 2024-02-26
### 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 ## 2.35.0 2024-02-20
### Added ### Added

View file

@ -140,26 +140,26 @@ RainLoop 1.17 vs SnappyMail
|js/* |RainLoop |Snappy | |js/* |RainLoop |Snappy |
|--------------- |--------: |--------: | |--------------- |--------: |--------: |
|admin.js |2.170.153 | 81.641 | |admin.js |2.170.153 | 82.445 |
|app.js |4.207.787 | 426.852 | |app.js |4.207.787 | 429.565 |
|boot.js | 868.735 | 4.142 | |boot.js | 868.735 | 4.142 |
|libs.js | 658.812 | 192.625 | |libs.js | 658.812 | 192.786 |
|sieve.js | 0 | 84.707 | |sieve.js | 0 | 84.707 |
|polyfills.js | 334.608 | 0 | |polyfills.js | 334.608 | 0 |
|serviceworker.js | 0 | 285 | |serviceworker.js | 0 | 285 |
|TOTAL |8.240.095 | 790.252 | |TOTAL |8.240.095 | 793.930 |
|js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli | |js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|--------------- |--------: |--------: |------: |------: |--------: |--------: | |--------------- |--------: |--------: |------: |------: |--------: |--------: |
|admin.min.js | 256.831 | 40.142 | 73.606 | 13.445 | 60.877 | 12.047 | |admin.min.js | 256.831 | 40.573 | 73.606 | 13.585 | 60.877 | 12.188 |
|app.min.js | 515.367 | 193.738 |139.456 | 65.570 |110.485 | 56.281 | |app.min.js | 515.367 | 195.013 |139.456 | 66.135 |110.485 | 56.623 |
|boot.min.js | 84.659 | 2.084 | 26.998 | 1.202 | 23.643 | 1.003 | |boot.min.js | 84.659 | 2.084 | 26.998 | 1.202 | 23.643 | 1.003 |
|libs.min.js | 584.772 | 92.664 |180.901 | 34.414 |155.182 | 30.850 | |libs.min.js | 584.772 | 92.746 |180.901 | 34.452 |155.182 | 30.890 |
|sieve.min.js | 0 | 41.164 | 0 | 10.365 | 0 | 9.359 | |sieve.min.js | 0 | 41.164 | 0 | 10.365 | 0 | 9.359 |
|polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 | |polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 |
|TOTAL user |1.217.635 | 288.486 |358.761 |101.186 |299.485 | 88.134 | |TOTAL user |1.217.635 | 289.843 |358.761 |101.789 |299.485 | 88.516 |
|TOTAL user+sieve |1.217.635 | 329.650 |358.761 |111.551 |299.485 | 97.493 | |TOTAL user+sieve |1.217.635 | 331.007 |358.761 |112.154 |299.485 | 97.875 |
|TOTAL admin | 959.099 | 134.890 |292.911 | 49.061 |249.877 | 43.900 | |TOTAL admin | 959.099 | 135.403 |292.911 | 49.239 |249.877 | 44.081 |
For a user it is around 69% smaller and faster than traditional RainLoop. For a user it is around 69% smaller and faster than traditional RainLoop.
@ -188,8 +188,8 @@ For a user it is around 69% smaller and faster than traditional RainLoop.
|css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli | |css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli |
|------------ |-------: |------: |------: |------: |--------: | |------------ |-------: |------: |------: |------: |--------: |
|app.css | 340.331 | 84.435 | 46.946 | 17.612 | 15.091 | |app.css | 340.331 | 84.472 | 46.946 | 17.622 | 15.112 |
|app.min.css | 274.947 | 67.825 | 39.647 | 15.526 | 13.540 | |app.min.css | 274.947 | 67.857 | 39.647 | 15.537 | 13.549 |
|boot.css | | 1.326 | | 664 | 545 | |boot.css | | 1.326 | | 664 | 545 |
|boot.min.css | | 1.071 | | 590 | 474 | |boot.min.css | | 1.071 | | 590 | 474 |
|admin.css | | 30.576 | | 7.013 | 6.096 | |admin.css | | 30.576 | | 7.013 | 6.096 |

View file

@ -95,7 +95,6 @@ Notifications = {
JsonParse: 952, JsonParse: 952,
// JsonTimeout: 953, // JsonTimeout: 953,
UnknownNotification: 998,
UnknownError: 999, UnknownError: 999,
// Admin // Admin

View file

@ -22,7 +22,7 @@ const
getNotificationMessage = code => { getNotificationMessage = code => {
let key = getKeyByValue(Notifications, code); let key = getKeyByValue(Notifications, code);
return key ? I18N_DATA.NOTIFICATIONS[i18nKey(key).replace('_NOTIFICATION', '_ERROR')] : ''; return key ? I18N_DATA.NOTIFICATIONS[key] : '';
}, },
fromNow = date => relativeTime(Math.round((date.getTime() - Date.now()) / 1000)); fromNow = date => relativeTime(Math.round((date.getTime() - Date.now()) / 1000));
@ -102,8 +102,7 @@ export const
timestampToString = (timeStampInUTC, formatStr) => { timestampToString = (timeStampInUTC, formatStr) => {
const now = Date.now(), const now = Date.now(),
time = 0 < timeStampInUTC ? Math.min(now, timeStampInUTC * 1000) : (0 === timeStampInUTC ? now : 0); time = 0 < timeStampInUTC ? timeStampInUTC * 1000 : (0 === timeStampInUTC ? now : 0);
if (31536000000 < time) { if (31536000000 < time) {
const m = new Date(time), h = LanguageStore.hourCycle(); const m = new Date(time), h = LanguageStore.hourCycle();
switch (formatStr) { switch (formatStr) {
@ -212,7 +211,7 @@ export const
script.remove(); script.remove();
resolve(); resolve();
}; };
script.onerror = () => reject(new Error('Language '+language+' failed')); script.onerror = () => reject(Error('Language '+language+' failed'));
script.src = langLink(language, admin); script.src = langLink(language, admin);
// script.async = true; // script.async = true;
doc.head.append(script); doc.head.append(script);

View file

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

View file

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

5
dev/External/ko.js vendored
View file

@ -66,8 +66,7 @@ Object.assign(ko.bindingHandlers, {
}, },
update: (element, fValueAccessor) => { update: (element, fValueAccessor) => {
let value = ko.unwrap(fValueAccessor()); let value = ko.unwrap(fValueAccessor());
value = isFunction(value) ? value() : value; errorTip(element, isFunction(value) ? value() : value);
errorTip(element, value);
} }
}, },
@ -107,7 +106,7 @@ Object.assign(ko.bindingHandlers, {
const command = fValueAccessor(); const command = fValueAccessor();
if (!command || !command.canExecute) { if (!command || !command.canExecute) {
throw new Error('Value should be a command'); throw Error('Value should be a command');
} }
ko.bindingHandlers['FORM'==element.nodeName ? 'submit' : 'click'].init( ko.bindingHandlers['FORM'==element.nodeName ? 'submit' : 'click'].init(

View file

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

View file

@ -175,7 +175,7 @@ export function ParseMime(text)
let boundary = headers['content-type']?.params.boundary; let boundary = headers['content-type']?.params.boundary;
if (boundary) { if (boundary) {
part.boundary = boundary; part.boundary = boundary;
let regex = new RegExp('(?:^|\r?\n)--' + boundary + '(?:--)?(?:\r?\n|$)', 'g'), let regex = new RegExp('(?:^|\r?\n)--' + RegExp.escape(boundary) + '(?:--)?(?:\r?\n|$)', 'g'),
body = mimePart.slice(head.length), body = mimePart.slice(head.length),
bodies = body.split(regex), bodies = body.split(regex),
pos = part.bodyStart; pos = part.bodyStart;

View file

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

View file

@ -32,9 +32,9 @@ export class IdentityModel extends AbstractModel {
}); });
addComputablesTo(this, { addComputablesTo(this, {
smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----') smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----'),
// smimeKeyValid: () => this.smimeKeyEncrypted() | this.smimeKey().includes('-----BEGIN PRIVATE KEY-----') smimeKeyValid: () => /^-----BEGIN (ENCRYPTED )?PRIVATE KEY-----/.test(this.smimeKey()),
// smimeCertificateValid: () => this.smimeKey().includes('-----BEGIN CERTIFICATE-----') smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate())
}); });
} }

View file

@ -24,6 +24,8 @@ import { LanguageStore } from 'Stores/Language';
import Remote from 'Remote/User/Fetch'; import Remote from 'Remote/User/Fetch';
import { MimeToMessage } from 'Mime/Utils';
const const
msgHtml = msg => cleanHtml(msg.html(), msg.attachments(), '#rl-msg-' + msg.hash), msgHtml = msg => cleanHtml(msg.html(), msg.attachments(), '#rl-msg-' + msg.hash),
@ -118,12 +120,10 @@ export class MessageModel extends AbstractModel {
encrypted: false, encrypted: false,
pgpSigned: null, pgpSigned: null,
pgpVerified: null,
pgpEncrypted: null, pgpEncrypted: null,
pgpDecrypted: false, pgpDecrypted: false,
smimeSigned: null, smimeSigned: null,
smimeVerified: null,
smimeEncrypted: null, smimeEncrypted: null,
smimeDecrypted: false, smimeDecrypted: false,
@ -196,6 +196,10 @@ export class MessageModel extends AbstractModel {
return options; return options;
} }
}); });
this.smimeSigned.subscribe(value =>
value?.body && MimeToMessage(value.body, this)
);
} }
get requestHash() { get requestHash() {

View file

@ -19,7 +19,6 @@ checkResponseError = data => {
Notifications.DomainNotAllowed, Notifications.DomainNotAllowed,
Notifications.AccountNotAllowed, Notifications.AccountNotAllowed,
Notifications.MailServerError, Notifications.MailServerError,
Notifications.UnknownNotification,
Notifications.UnknownError Notifications.UnknownError
].includes(err) ].includes(err)
) { ) {

View file

@ -7,7 +7,32 @@ export class AdminSettingsConfig /*extends AbstractViewSettings*/ {
constructor() { constructor() {
this.config = ko.observableArray(); this.config = ko.observableArray();
this.search = ko.observableArray();
this.saved = ko.observable(false).extend({ falseTimeout: 5000 }); this.saved = ko.observable(false).extend({ falseTimeout: 5000 });
this.search.subscribe(value => {
const v = value.toLowerCase(),
qsa = (node, selector, fn) => node.querySelectorAll(selector).forEach(fn),
match = node => node.textContent.toLowerCase().includes(v);
if (v.length) {
qsa(this.viewModelDom, 'tbody', tbody => {
let show = match(tbody.querySelector('th'));
if (show) {
qsa(tbody, '[hidden]', n => n.hidden = false);
} else {
qsa(tbody, 'tbody td:first-child', td => {
let hide = !match(td);
show = show || !hide;
// td.closest('tr').hidden = hide;
td.parentNode.hidden = hide;
});
}
tbody.hidden = !show;
});
} else {
qsa(this.viewModelDom, 'table [hidden]', n => n.hidden = false);
}
});
} }
beforeShow() { beforeShow() {

View file

@ -10,7 +10,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
constructor() { constructor() {
super(); super();
this.addSettings(['useLocalProxyForExternalImages']); this.addSettings(['useLocalProxyForExternalImages', 'autoVerifySignatures']);
this.weakPassword = rl.app.weakPassword; this.weakPassword = rl.app.weakPassword;

View file

@ -16,6 +16,7 @@ import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
import { OpenPgpGeneratePopupView } from 'View/Popup/OpenPgpGenerate'; import { OpenPgpGeneratePopupView } from 'View/Popup/OpenPgpGenerate';
import { SMimeUserStore } from 'Stores/User/SMime'; import { SMimeUserStore } from 'Stores/User/SMime';
import { SMimeImportPopupView } from 'View/Popup/SMimeImport';
import Remote from 'Remote/User/Fetch'; import Remote from 'Remote/User/Fetch';
@ -62,14 +63,14 @@ export class UserSettingsSecurity extends AbstractViewSettings {
importToOpenPGP() { importToOpenPGP() {
OpenPGPUserStore.isSupported() && Remote.request('GetPGPKeys', OpenPGPUserStore.isSupported() && Remote.request('GetPGPKeys',
(iError, oData) => { (iError, oData) => !iError && oData.Result && OpenPGPUserStore.importKeys(oData.Result)
if (!iError && oData.Result) {
oData.Result.forEach(key => OpenPGPUserStore.importKey(key));
}
}
); );
} }
importToSMime() {
showScreenPopup(SMimeImportPopupView);
}
onBuild() { onBuild() {
/** /**
* Create an iframe to display the Mailvelope keyring settings. * Create an iframe to display the Mailvelope keyring settings.

View file

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

View file

@ -35,7 +35,8 @@ export const GnuPGUserStore = new class {
this.keyring = null; this.keyring = null;
this.publicKeys([]); this.publicKeys([]);
this.privateKeys([]); this.privateKeys([]);
Remote.request('GnupgGetKeys', SettingsCapa('GnuPG')
&& Remote.request('GnupgGetKeys',
(iError, oData) => { (iError, oData) => {
if (oData?.Result) { if (oData?.Result) {
this.keyring = oData.Result; this.keyring = oData.Result;
@ -68,11 +69,10 @@ export const GnuPGUserStore = new class {
} }
}; };
if (isPrivate) { if (isPrivate) {
key.password = async (btnTxt = 'SIGN') => { key.password = async btnTxt => {
const pass = await Passphrases.ask( const pass = await Passphrases.ask(key,
key,
'GnuPG key<br>' + key.id + ' ' + key.emails[0], 'GnuPG key<br>' + key.id + ' ' + key.emails[0],
'CRYPTO/'+btnTxt btnTxt
); );
pass && pass.remember && Passphrases.set(key, pass.password); pass && pass.remember && Passphrases.set(key, pass.password);
return pass?.password; return pass?.password;
@ -82,8 +82,8 @@ export const GnuPGUserStore = new class {
if (key.armor) { if (key.armor) {
callback && callback(); callback && callback();
} else { } else {
let pass = isPrivate ? await key.password('POPUP_VIEW_TITLE') : ''; let pass = isPrivate ? await key.password('OPENPGP/POPUP_VIEW_TITLE') : '';
if (null != pass) { if (null != pass) try {
const result = await Remote.post('GnupgExportKey', null, { const result = await Remote.post('GnupgExportKey', null, {
keyId: key.id, keyId: key.id,
isPrivate: isPrivate, isPrivate: isPrivate,
@ -95,15 +95,22 @@ export const GnuPGUserStore = new class {
} else { } else {
Passphrases.delete(key); Passphrases.delete(key);
} }
} catch (e) {
Passphrases.delete(key);
alert(e.message);
} }
} }
return key.armor; return key.armor;
}; };
key.view = () => key.fetch(() => showScreenPopup(OpenPgpKeyPopupView, [key])); key.view = () => key.fetch(() => showScreenPopup(OpenPgpKeyPopupView, [key]));
return key; return key;
}; },
this.publicKeys(oData.Result.public.map(key => initKey(key, 0))); collator = new Intl.Collator(undefined, {sensitivity: 'base'}),
this.privateKeys(oData.Result.private.map(key => initKey(key, 1))); sort = keys => keys.sort(
(a, b) => collator.compare(a.emails[0], b.emails[0]) || collator.compare(a.id, b.id)
);
this.publicKeys(sort(oData.Result.public.map(key => initKey(key, 0))));
this.privateKeys(sort(oData.Result.private.map(key => initKey(key, 1))));
console.log('gnupg ready'); console.log('gnupg ready');
} }
} }
@ -178,22 +185,27 @@ export const GnuPGUserStore = new class {
uid: message.uid, uid: message.uid,
partId: pgpInfo.partId, partId: pgpInfo.partId,
keyId: key.id, keyId: key.id,
passphrase: await key.password('DECRYPT'), passphrase: await key.password('CRYPTO/DECRYPT'),
data: '' // message.plain() optional data: '' // message.plain() optional
} }
if (null !== params.passphrase) { if (null != params.passphrase) {
const result = await Remote.post('GnupgDecrypt', null, params); try {
if (result?.Result && false !== result.Result.data) { const response = await Remote.post('GnupgDecrypt', null, params);
return result.Result; if (response?.Result?.data) {
return response.Result;
}
throw response;
} catch (e) {
Passphrases.delete(key);
throw e;
} }
Passphrases.delete(key);
} }
} }
} }
} }
async verify(message) { async verify(message) {
let data = message.pgpSigned(); // { bodyPartId: "1", sigPartId: "2", micAlg: "pgp-sha256" } let data = message.pgpSigned(); // { partId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
if (data) { if (data) {
data = { ...data }; // clone data = { ...data }; // clone
// const sender = message.from[0].email; // const sender = message.from[0].email;
@ -208,14 +220,15 @@ export const GnuPGUserStore = new class {
if (response?.Result) { if (response?.Result) {
return { return {
fingerprint: response.Result.fingerprint, fingerprint: response.Result.fingerprint,
success: 0 == response.Result.status // GOODSIG success: 0 == response.Result.status, // GOODSIG
error: response.Result.message
}; };
} }
} }
} }
async sign(privateKey) { async sign(privateKey) {
return await privateKey.password(); return await privateKey.password('CRYPTO/SIGN');
} }
}; };

View file

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

View file

@ -24,8 +24,7 @@ const
return privateKey.key; return privateKey.key;
} }
const key = privateKey.id, const key = privateKey.id,
pass = await Passphrases.ask( pass = await Passphrases.ask(privateKey,
key,
'OpenPGP.js key<br>' + key + ' ' + privateKey.emails[0], 'OpenPGP.js key<br>' + key + ' ' + privateKey.emails[0],
'CRYPTO/'+btnTxt 'CRYPTO/'+btnTxt
); );
@ -35,11 +34,20 @@ const
privateKey: privateKey.key, privateKey: privateKey.key,
passphrase passphrase
}); });
result && pass.remember && Passphrases.set(key, passphrase); result && pass.remember && Passphrases.set(privateKey, passphrase);
return result; return result;
} }
}, },
collator = new Intl.Collator(undefined, {sensitivity: 'base'}),
sort = keys => keys.sort(
// (a, b) => collator.compare(a.emails[0], b.emails[0]) || collator.compare(a.fingerprint, b.fingerprint)
(a, b) => collator.compare(a.emails[0], b.emails[0]) || collator.compare(a.id, b.id)
),
dedup = keys => sort((keys || [])
.filter((v, i, a) => a.findIndex(entry => entry.fingerprint == v.fingerprint) === i)
),
/** /**
* OpenPGP.js v5 removed the localStorage (keyring) * OpenPGP.js v5 removed the localStorage (keyring)
* This should be compatible with the old OpenPGP.js v2 * This should be compatible with the old OpenPGP.js v2
@ -69,15 +77,11 @@ const
class OpenPgpKeyModel { class OpenPgpKeyModel {
constructor(armor, key) { constructor(armor, key) {
this.key = key; this.key = key;
const aEmails = [];
if (key.users) {
key.users.forEach(user => user.userID.email && aEmails.push(user.userID.email));
}
this.id = key.getKeyID().toHex().toUpperCase(); this.id = key.getKeyID().toHex().toUpperCase();
this.fingerprint = key.getFingerprint(); this.fingerprint = key.getFingerprint();
this.can_encrypt = !!key.getEncryptionKey(); this.can_encrypt = !!key.getEncryptionKey();
this.can_sign = !!key.getSigningKey(); this.can_sign = !!key.getSigningKey();
this.emails = aEmails; this.emails = key.users.map(user => user.userID.email).filter(email => email);
this.armor = armor; this.armor = armor;
this.askDelete = ko.observable(false); this.askDelete = ko.observable(false);
this.openForDeletion = ko.observable(null).askDeleteHelper(); this.openForDeletion = ko.observable(null).askDeleteHelper();
@ -85,6 +89,15 @@ class OpenPgpKeyModel {
// key.getPrimaryUser() // key.getPrimaryUser()
} }
/*
get id() { return this.key.getKeyID().toHex().toUpperCase(); }
get fingerprint() { return this.key.getFingerprint(); }
get can_encrypt() { return !!this.key.getEncryptionKey(); }
get can_sign() { return !!this.key.getSigningKey(); }
get emails() { return this.key.users.map(user => user.userID.email).filter(email => email); }
get armor() { return this.key.armor(); }
*/
view() { view() {
showScreenPopup(OpenPgpKeyPopupView, [this]); showScreenPopup(OpenPgpKeyPopupView, [this]);
} }
@ -115,10 +128,6 @@ export const OpenPGPUserStore = new class {
loadKeyrings() { loadKeyrings() {
if (window.openpgp) { if (window.openpgp) {
const collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}),
dedup = keys => (keys || [])
.filter((v, i, a) => a.findIndex(entry => entry.fingerprint == v.fingerprint) === i)
.sort((a, b) => collator.compare(a.emails[0], b.emails[0]));
loadOpenPgpKeys(publicKeysItem).then(keys => { loadOpenPgpKeys(publicKeysItem).then(keys => {
this.publicKeys(dedup(keys)); this.publicKeys(dedup(keys));
console.log('openpgp.js public keys loaded'); console.log('openpgp.js public keys loaded');
@ -138,22 +147,29 @@ export const OpenPGPUserStore = new class {
} }
importKey(armoredKey) { importKey(armoredKey) {
window.openpgp && openpgp.readKey({armoredKey:armoredKey}).then(key => { this.importKeys([armoredKey]);
if (!key.err) { }
key = new OpenPgpKeyModel(armoredKey, key);
if (key.key.isPrivate()) { async importKeys(keys) {
if (!this.privateKeys.find(entry => entry.fingerprint == key.fingerprint)) { if (window.openpgp) {
this.privateKeys.push(key); const privateKeys = this.privateKeys(),
storeOpenPgpKeys(this.privateKeys, privateKeysItem); publicKeys = this.publicKeys();
} for (const armoredKey of keys) try {
} else { let key = await openpgp.readKey({armoredKey:armoredKey});
if (!this.publicKeys.find(entry => entry.fingerprint == key.fingerprint)) { if (!key.err) {
this.publicKeys.push(key); key = new OpenPgpKeyModel(armoredKey, key);
storeOpenPgpKeys(this.publicKeys, publicKeysItem); 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);
}
} }
/** /**
@ -229,7 +245,7 @@ export const OpenPGPUserStore = new class {
* https://docs.openpgpjs.org/#sign-and-verify-cleartext-messages * https://docs.openpgpjs.org/#sign-and-verify-cleartext-messages
*/ */
async verify(message) { async verify(message) {
const data = message.pgpSigned(), // { bodyPartId: "1", sigPartId: "2", micAlg: "pgp-sha256" } const data = message.pgpSigned(), // { partId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
publicKey = this.publicKeys().find(key => key.emails.includes(message.from[0].email)); publicKey = this.publicKeys().find(key => key.emails.includes(message.from[0].email));
if (data && publicKey) { if (data && publicKey) {
data.folder = message.folder; data.folder = message.folder;

View file

@ -8,12 +8,10 @@ import { SettingsCapa, SettingsGet } from 'Common/Globals';
import { GnuPGUserStore } from 'Stores/User/GnuPG'; import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP'; import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import { MailvelopeUserStore } from 'Stores/User/Mailvelope';
import Remote from 'Remote/User/Fetch'; import Remote from 'Remote/User/Fetch';
// https://mailvelope.github.io/mailvelope/Keyring.html
let mailvelopeKeyring = null;
export const export const
BEGIN_PGP_MESSAGE = '-----BEGIN PGP MESSAGE-----', BEGIN_PGP_MESSAGE = '-----BEGIN PGP MESSAGE-----',
// BEGIN_PGP_SIGNATURE = '-----BEGIN PGP SIGNATURE-----', // BEGIN_PGP_SIGNATURE = '-----BEGIN PGP SIGNATURE-----',
@ -37,32 +35,9 @@ export const
} }
loadKeyrings(identifier) { loadKeyrings(identifier) {
identifier = identifier || SettingsGet('Email'); MailvelopeUserStore.loadKeyring(identifier);
if (window.mailvelope) {
const fn = keyring => {
mailvelopeKeyring = keyring;
console.log('mailvelope ready');
};
mailvelope.getKeyring().then(fn, err => {
if (identifier) {
// attempt to create a new keyring for this app/user
mailvelope.createKeyring(identifier).then(fn, err => console.error(err));
} else {
console.error(err);
}
});
addEventListener('mailvelope-disconnect', event => {
alert('Mailvelope is updated to version ' + event.detail.version + '. Reload page');
}, false);
} else {
addEventListener('mailvelope', () => this.loadKeyrings(identifier));
}
OpenPGPUserStore.loadKeyrings(); OpenPGPUserStore.loadKeyrings();
GnuPGUserStore.loadKeyrings();
if (SettingsCapa('GnuPG')) {
GnuPGUserStore.loadKeyrings();
}
} }
/** /**
@ -92,22 +67,14 @@ export const
} }
); );
} }
OpenPGPUserStore.isSupported() && OpenPGPUserStore.importKey(key); OpenPGPUserStore.importKey(key);
}
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;
} }
/** /**
* Checks if verifying/encrypting a message is possible with given email addresses. * Checks if verifying/encrypting a message is possible with given email addresses.
* Returns the first library that can. * Returns the first library that can.
*/ */
async hasPublicKeyForEmails(recipients) { hasPublicKeyForEmails(recipients) {
if (recipients.length) { if (recipients.length) {
if (GnuPGUserStore.hasPublicKeyForEmails(recipients)) { if (GnuPGUserStore.hasPublicKeyForEmails(recipients)) {
return 'gnupg'; return 'gnupg';
@ -119,40 +86,15 @@ export const
return false; return false;
} }
async getMailvelopePrivateKeyFor(email/*, sign*/) {
if (mailvelopeKeyring && await mailvelopeKeyring.hasPrivateKey({email:email})) {
return ['mailvelope', email];
}
return false;
}
/**
* Checks if signing a message is possible with given email address.
* Returns the first library that can.
*/
async getKeyForSigning(email) {
let key = OpenPGPUserStore.getPrivateKeyFor(email, 1);
if (key) {
return ['openpgp', key];
}
key = GnuPGUserStore.getPrivateKeyFor(email, 1);
if (key) {
return ['gnupg', key];
}
// return await this.getMailvelopePrivateKeyFor(email, 1);
}
async decrypt(message) { async decrypt(message) {
const sender = message.from[0].email, const armoredText = message.plain();
armoredText = message.plain();
if (!this.isEncrypted(armoredText)) { if (!this.isEncrypted(armoredText)) {
throw Error('Not armored text'); throw Error('Not armored text');
} }
// Try OpenPGP.js // Try OpenPGP.js
if (OpenPGPUserStore.isSupported()) { if (OpenPGPUserStore.isSupported()) {
const sender = message.from[0].email;
let result = await OpenPGPUserStore.decrypt(armoredText, sender); let result = await OpenPGPUserStore.decrypt(armoredText, sender);
if (result) { if (result) {
return result; return result;
@ -160,68 +102,20 @@ export const
} }
// Try Mailvelope (does not support inline images) // Try Mailvelope (does not support inline images)
if (mailvelopeKeyring) { return (await MailvelopeUserStore.decrypt(message))
try { // Or try GnuPG
let emails = [...message.from,...message.to,...message.cc].validUnique(), || GnuPGUserStore.decrypt(message);
i = emails.length;
while (i--) {
if (await this.getMailvelopePrivateKeyFor(emails[i].email)) {
/**
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createEncryptedFormContainer
* Creates an iframe to display an encrypted form
*/
// mailvelope.createEncryptedFormContainer('#mailvelope-form');
/**
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createDisplayContainer
* Creates an iframe to display the decrypted content of the encrypted mail.
*/
const body = message.body;
body.textContent = '';
let result = await mailvelope.createDisplayContainer(
'#'+body.id,
armoredText,
mailvelopeKeyring,
{
senderAddress: sender
// emails[i].email
}
);
if (result) {
if (result.error?.message) {
if ('PWD_DIALOG_CANCEL' !== result.error.code) {
alert(result.error.code + ': ' + result.error.message);
}
} else {
body.classList.add('mailvelope');
return true;
}
}
break;
}
}
} catch (err) {
console.error(err);
}
}
// Now try GnuPG
return GnuPGUserStore.decrypt(message);
} }
async verify(message) { async verify(message) {
const signed = message.pgpSigned(); const signed = message.pgpSigned(),
sender = message.from[0].email;
if (signed) { if (signed) {
const sender = message.from[0].email, // OpenPGP only when inline, else we must download the whole message
gnupg = GnuPGUserStore.hasPublicKeyForEmails([sender]), if (!signed.sigPartId && OpenPGPUserStore.hasPublicKeyForEmails([sender])) {
openpgp = OpenPGPUserStore.hasPublicKeyForEmails([sender]);
// Detached signature use GnuPG first, else we must download whole message
if (gnupg && signed.sigPartId) {
return GnuPGUserStore.verify(message);
}
if (openpgp) {
return OpenPGPUserStore.verify(message); return OpenPGPUserStore.verify(message);
} }
if (gnupg) { if (GnuPGUserStore.hasPublicKeyForEmails([sender])) {
return GnuPGUserStore.verify(message); return GnuPGUserStore.verify(message);
} }
// Mailvelope can't // Mailvelope can't
@ -248,35 +142,4 @@ export const
} }
return false; return false;
} }
/**
* Returns headers that should be added to an outgoing email.
* So far this is only the autocrypt header.
*/
/*
mailvelopeKeyring.additionalHeadersForOutgoingEmail(from: 'abc@web.de')
.then(function(additional) {
console.log('additionalHeadersForOutgoingEmail', additional);
// logs: {autocrypt: "addr=abc@web.de; prefer-encrypt=mutual; keydata=..."}
});
mailvelopeKeyring.addSyncHandler(syncHandlerObj)
mailvelopeKeyring.createKeyBackupContainer(selector, options)
mailvelopeKeyring.createKeyGenContainer(selector, {
// userIds: [],
keySize: 4096
})
mailvelopeKeyring.exportOwnPublicKey(emailAddr).then(<AsciiArmored, Error>)
mailvelopeKeyring.importPublicKey(armored)
// https://mailvelope.github.io/mailvelope/global.html#SyncHandlerObject
mailvelopeKeyring.addSyncHandler({
uploadSync
downloadSync
backup
restore
});
*/
}; };

View file

@ -12,6 +12,9 @@ SMimeUserStore.loadCertificates = () => {
SMimeUserStore.loading(true); SMimeUserStore.loading(true);
Remote.request('SMimeGetCertificates', (iError, oData) => { Remote.request('SMimeGetCertificates', (iError, oData) => {
SMimeUserStore.loading(false); SMimeUserStore.loading(false);
iError || SMimeUserStore(oData.Result); const collator = new Intl.Collator(undefined, {sensitivity: 'base'});
iError || SMimeUserStore(oData.Result.sort(
(a, b) => collator.compare(a.emailAddress, b.emailAddress) || (b.validTo_time_t - a.validTo_time_t)
));
}); });
}; };

View file

@ -65,6 +65,7 @@
overflow: hidden; overflow: hidden;
overflow-y: auto; overflow-y: auto;
min-width: 100px; min-width: 100px;
color: var(--folders-color, #333);
} }
hr { hr {
@ -77,22 +78,18 @@
white-space: nowrap; white-space: nowrap;
a { a {
display: block;
position: relative;
z-index: 1;
line-height: 34px;
background-color: transparent; background-color: transparent;
vertical-align: middle;
color: var(--folders-disabled-color, #666);
border-left: 3px solid transparent; border-left: 3px solid transparent;
color: var(--folders-color, #333);
display: block;
line-height: 34px;
padding: 0 2em 0 @folderItemPadding; padding: 0 2em 0 @folderItemPadding;
position: relative;
text-decoration: none; text-decoration: none;
vertical-align: middle;
z-index: 1;
&.selectable { &.selectable {
color: var(--folders-color, #333);
cursor: pointer; cursor: pointer;
&:hover { &:hover {
@ -115,6 +112,7 @@
} }
} }
&:not(.selectable) { &:not(.selectable) {
color: var(--folders-disabled-color, #666);
cursor: default; cursor: default;
font-style: italic; font-style: italic;
} }

View file

@ -9,7 +9,7 @@
color: red; color: red;
content: attr(data-rainloopErrorTip); content: attr(data-rainloopErrorTip);
font-size: 90%; font-size: 90%;
left: 100%; left: 0;
opacity: 1; opacity: 1;
padding: 0.25em; padding: 0.25em;
position : absolute; position : absolute;

View file

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

View file

@ -32,6 +32,7 @@ import { FolderUserStore } from 'Stores/User/Folder';
import { PgpUserStore } from 'Stores/User/Pgp'; import { PgpUserStore } from 'Stores/User/Pgp';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP'; import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import { GnuPGUserStore } from 'Stores/User/GnuPG'; import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { MailvelopeUserStore } from 'Stores/User/Mailvelope';
//import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport'; //import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
import { SMimeUserStore } from 'Stores/User/SMime'; import { SMimeUserStore } from 'Stores/User/SMime';
import { Passphrases } from 'Storage/Passphrases'; import { Passphrases } from 'Storage/Passphrases';
@ -65,7 +66,7 @@ const
tpl = createElement('template'), tpl = createElement('template'),
base64_encode = text => b64Encode(text).match(/.{1,76}/g).join('\r\n'), base64_encode = text => text ? b64Encode(text).match(/.{1,76}/g).join('\r\n') : '',
getEmail = value => addressparser(value)[0]?.email || false, getEmail = value => addressparser(value)[0]?.email || false,
@ -255,13 +256,6 @@ export class ComposePopupView extends AbstractViewPopup {
doSign: false, doSign: false,
doEncrypt: false, doEncrypt: false,
pgpSignKey: false,
canPgpEncrypt: false,
canMailvelope: false,
canSMimeSign: false,
canSMimeEncrypt: false,
draftsFolder: '', draftsFolder: '',
draftUid: 0, draftUid: 0,
sending: false, sending: false,
@ -284,6 +278,8 @@ export class ComposePopupView extends AbstractViewPopup {
}); });
this.attachments = koArrayWithDestroy(); this.attachments = koArrayWithDestroy();
this.encryptOptions = koArrayWithDestroy();
this.signOptions = koArrayWithDestroy();
this.dragAndDropOver = ko.observable(false).extend({ debounce: 1 }); this.dragAndDropOver = ko.observable(false).extend({ debounce: 1 });
this.dragAndDropVisible = ko.observable(false).extend({ debounce: 1 }); this.dragAndDropVisible = ko.observable(false).extend({ debounce: 1 });
@ -336,11 +332,9 @@ export class ComposePopupView extends AbstractViewPopup {
attachmentsInProcessCount: () => this.attachmentsInProcess.length, attachmentsInProcessCount: () => this.attachmentsInProcess.length,
isDraft: () => this.draftsFolder() && this.draftUid(), isDraft: () => this.draftsFolder() && this.draftUid(),
canSign: () => { canEncrypt: () => this.encryptOptions().length,
let s = this.canSMimeSign(); canMailvelope: () => this.encryptOptions.includes('Mailvelope'),
return this.pgpSignKey() || s; canSign: () => this.signOptions().length,
},
canEncrypt: () => this.canPgpEncrypt() | this.canSMimeEncrypt(),
identitiesOptions: () => identitiesOptions: () =>
IdentityUserStore.map(item => ({ IdentityUserStore.map(item => ({
@ -361,24 +355,14 @@ export class ComposePopupView extends AbstractViewPopup {
currentIdentity: value => { currentIdentity: value => {
if (value) { if (value) {
const smime = !!(value.smimeKey() && value.smimeCertificate());
this.from(value.formattedName()); this.from(value.formattedName());
this.doEncrypt(value.pgpEncrypt()/* || SettingsUserStore.pgpEncrypt()*/); this.doEncrypt(value.pgpEncrypt() || SettingsUserStore.pgpEncrypt());
this.doSign(smime || value.pgpSign()/* || SettingsUserStore.pgpSign()*/); this.doSign(value.pgpSign() || SettingsUserStore.pgpSign());
this.canSMimeSign(smime);
} }
}, },
from: value => { from: () => {
this.pgpSignKey(false); this.initSign();
value = getEmail(value);
value && PgpUserStore.getKeyForSigning(value).then(result => {
console.log({
email: value,
pgpSignKey:result
});
this.pgpSignKey(result)
});
this.initEncrypt(); this.initEncrypt();
}, },
@ -1381,29 +1365,55 @@ export class ComposePopupView extends AbstractViewPopup {
].join(',').split(',').map(value => getEmail(value.trim())).validUnique(); ].join(',').split(',').map(value => getEmail(value.trim())).validUnique();
} }
initEncrypt() { /**
const recipients = this.allRecipients(); * Checks if signing a message is possible with from email address.
PgpUserStore.hasPublicKeyForEmails(recipients).then(result => { * And sets all that can.
console.log({canPgpEncrypt:result}); */
this.canPgpEncrypt(result); initSign() {
}); let options = [],
PgpUserStore.mailvelopeHasPublicKeyForEmails(recipients).then(result => { identity = this.currentIdentity(),
console.log({canMailvelope:result}); email = getEmail(this.from()),
this.canMailvelope(result); key = OpenPGPUserStore.getPrivateKeyFor(email, 1);
if (!result) { key && options.push(['OpenPGP', key]);
key = GnuPGUserStore.getPrivateKeyFor(email, 1);
key && options.push(['GnuPG', key]);
identity.smimeKeyValid() && identity.smimeCertificateValid() && identity.email() === email
&& options.push(['S/MIME']);
console.dir({signOptions: options});
this.signOptions(options);
}
async initEncrypt() {
const recipients = this.allRecipients(),
options = [];
if (recipients.length) {
GnuPGUserStore.hasPublicKeyForEmails(recipients)
&& options.push('GnuPG');
OpenPGPUserStore.hasPublicKeyForEmails(recipients)
&& options.push('OpenPGP');
if (await MailvelopeUserStore.hasPublicKeyForEmails(recipients)) {
options.push('Mailvelope');
} else {
'mailvelope' === this.viewArea() && this.bodyArea(); 'mailvelope' === this.viewArea() && this.bodyArea();
// this.dropMailvelope(); // this.dropMailvelope();
} }
});
const count = recipients.length, const count = recipients.length,
identity = this.currentIdentity(), identity = this.currentIdentity(),
from = (identity.smimeKey() && identity.smimeCertificate()) ? identity.email() : null, from = (identity.smimeKey() && identity.smimeCertificate()) ? identity.email() : null;
length = count ? recipients.filter(email => count
email == from && count === recipients.filter(email =>
|| SMimeUserStore.find(certificate => email == certificate.emailAddress && certificate.smimeencrypt) email == from
).length : 0; || SMimeUserStore.find(certificate => email == certificate.emailAddress && certificate.smimeencrypt)
this.canSMimeEncrypt(length && length === count); ).length
console.log({canSMimeEncrypt:this.canSMimeEncrypt()}); && options.push('S/MIME');
}
console.dir({encryptOptions:options});
this.encryptOptions(options);
} }
async getMessageRequestParams(sSaveFolder, draft) async getMessageRequestParams(sSaveFolder, draft)
@ -1464,8 +1474,8 @@ export class ComposePopupView extends AbstractViewPopup {
linkedData: [] linkedData: []
}, },
recipients = draft ? [identity.email()] : this.allRecipients(), recipients = draft ? [identity.email()] : this.allRecipients(),
sign = !draft && this.doSign() && (this.pgpSignKey() || this.canSMimeSign()), signOptions = !draft && this.doSign() && this.signOptions(),
encrypt = this.doEncrypt() && (this.canPgpEncrypt() || this.canSMimeEncrypt()), encryptOptions = this.doEncrypt() && this.encryptOptions(),
isHtml = this.oEditor.isHtml(); isHtml = this.oEditor.isHtml();
if (isHtml) { if (isHtml) {
@ -1492,7 +1502,7 @@ export class ComposePopupView extends AbstractViewPopup {
params.autocrypt.push({addr:k, keydata:v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g).trim()}) params.autocrypt.push({addr:k, keydata:v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g).trim()})
); );
*/ */
} else if (sign || encrypt) { } else if (signOptions.length || encryptOptions.length) {
if (!draft && !hasAttachments && !Text.length) { if (!draft && !hasAttachments && !Text.length) {
throw i18n('COMPOSE/ERROR_EMPTY_BODY'); throw i18n('COMPOSE/ERROR_EMPTY_BODY');
} }
@ -1512,9 +1522,10 @@ export class ComposePopupView extends AbstractViewPopup {
alternative.children.push(data); alternative.children.push(data);
data = alternative; data = alternative;
} }
if (sign) { let sign = true;
if (sign?.[1]) { for (let i = 0; i < signOptions.length; ++i) {
if ('openpgp' == sign[0]) { if ('OpenPGP' == signOptions[i][0]) {
try {
// Doesn't sign attachments // Doesn't sign attachments
params.html = params.plain = ''; params.html = params.plain = '';
let signed = new MimePart; let signed = new MimePart;
@ -1525,34 +1536,53 @@ export class ComposePopupView extends AbstractViewPopup {
let signature = new MimePart; let signature = new MimePart;
signature.headers['Content-Type'] = 'application/pgp-signature; name="signature.asc"'; signature.headers['Content-Type'] = 'application/pgp-signature; name="signature.asc"';
signature.headers['Content-Transfer-Encoding'] = '7Bit'; signature.headers['Content-Transfer-Encoding'] = '7Bit';
signature.body = await OpenPGPUserStore.sign(data.toString(), sign[1], 1); signature.body = await OpenPGPUserStore.sign(data.toString(), signOptions[i][1], 1);
signed.children.push(signature); signed.children.push(signature);
params.signed = signed.toString(); params.signed = signed.toString();
params.boundary = signed.boundary; params.boundary = signed.boundary;
data = signed; data = signed;
} else if ('gnupg' == sign[0]) { } catch (e) {
// TODO: sign in PHP fails sign = false;
// params.signData = data.toString(); console.error(e);
params.signFingerprint = sign[1].fingerprint;
params.signPassphrase = await GnuPGUserStore.sign(sign[1]);
} else {
throw 'Signing with ' + sign[0] + ' not yet implemented';
} }
} else if (this.canSMimeSign()) { break;
params.signCertificate = identity.smimeCertificate(); }
params.signPrivateKey = identity.smimeKey(); if ('GnuPG' == signOptions[i][0]) {
// TODO: sign in PHP fails
let pass = await GnuPGUserStore.sign(signOptions[i][1]);
if (null != pass) {
// params.signData = data.toString();
params.signFingerprint = signOptions[i][1].fingerprint;
params.signPassphrase = pass;
} 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();
if (identity.smimeKeyEncrypted()) { if (identity.smimeKeyEncrypted()) {
const pass = await Passphrases.ask( const pass = await Passphrases.ask(identity,
params.signPrivateKey,
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}), i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}),
'CRYPTO/DECRYPT' 'CRYPTO/SIGN'
); );
params.signPassphrase = pass?.password; if (null != pass) {
// pass && pass.remember && Passphrases.set(identity, pass.password); params.signPassphrase = pass.password;
// pass.remember && Passphrases.set(identity, pass.password);
} else {
sign = false;
}
} }
} }
} }
if (encrypt) { if (signOptions.length && !sign) {
throw 'Signing failed';
}
if (encryptOptions.length) {
const autocrypt = () => const autocrypt = () =>
Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) => Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) =>
params.autocrypt.push({ params.autocrypt.push({
@ -1560,24 +1590,30 @@ export class ComposePopupView extends AbstractViewPopup {
keydata: v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '').trim() keydata: v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '').trim()
}) })
); );
if ('openpgp' == encrypt) { for (let i = 0; i < encryptOptions.length; ++i) {
// Doesn't encrypt attachments if ('OpenPGP' == encryptOptions[i]) {
params.encrypted = await OpenPGPUserStore.encrypt(data.toString(), recipients); // Doesn't encrypt attachments
params.signed = ''; params.encrypted = await OpenPGPUserStore.encrypt(data.toString(), recipients);
autocrypt(); params.signed = '';
} else if ('gnupg' == encrypt) { autocrypt();
// Does encrypt attachments break;
params.encryptFingerprints = JSON.stringify(GnuPGUserStore.getPublicKeyFingerprints(recipients)); }
autocrypt(); if ('GnuPG' == encryptOptions[i]) {
} else if (this.canSMimeEncrypt() && identity && identity.smimeKey() && identity.smimeCertificate()) { // Does encrypt attachments
params.encryptCertificates = [identity.smimeCertificate()]; params.encryptFingerprints = JSON.stringify(GnuPGUserStore.getPublicKeyFingerprints(recipients));
SMimeUserStore.forEach(certificate => { autocrypt();
certificate.emailAddress != identity.email() break;
&& recipients.includes(certificate.emailAddress) }
&& params.encryptCertificates.push(certificate.id) if ('S/MIME' == encryptOptions[i]) {
}); params.encryptCertificates = [identity.smimeCertificate()];
} else { SMimeUserStore.forEach(certificate => {
throw 'Encryption with ' + encrypt + ' not yet implemented'; certificate.emailAddress != identity.email()
&& recipients.includes(certificate.emailAddress)
&& params.encryptCertificates.push(certificate.id)
});
break;
}
// We skip Mailvelope as it has its own window
} }
} }
} }

View file

@ -52,10 +52,13 @@ export class IdentityPopupView extends AbstractViewPopup {
if (oData.Result.x509) { if (oData.Result.x509) {
identity.smimeKey(oData.Result.pkey); identity.smimeKey(oData.Result.pkey);
identity.smimeCertificate(oData.Result.x509); identity.smimeCertificate(oData.Result.x509);
} else {
this.submitError(oData.ErrorMessage);
} }
}, { }, {
name: identity.name(), name: identity.name(),
email: identity.email(), email: identity.email(),
privateKey: identity.smimeKey(),
passphrase: pass.password passphrase: pass.password
}); });
} }

View file

@ -78,36 +78,30 @@ export class OpenPgpImportPopupView extends AbstractViewPopup {
this.keyError(!keyTrimmed); this.keyError(!keyTrimmed);
this.keyErrorMessage(''); this.keyErrorMessage('');
if (!keyTrimmed) { if (keyTrimmed) {
return; let match = null,
} count = 30,
let match = null,
count = 30,
done = false;
// eslint-disable-next-line max-len
const reg = /[-]{3,6}BEGIN[\s]PGP[\s](PRIVATE|PUBLIC)[\s]KEY[\s]BLOCK[-]{3,6}[\s\S]+?[-]{3,6}END[\s]PGP[\s](PRIVATE|PUBLIC)[\s]KEY[\s]BLOCK[-]{3,6}/gi;
do {
match = reg.exec(keyTrimmed);
if (match && 0 < count) {
if (match[0] && match[1] && match[2] && match[1] === match[2]) {
const GnuPG = this.saveGnuPG() && GnuPGUserStore.isSupported(),
backup = this.saveServer();
PgpUserStore.importKey(this.key(), GnuPG, backup);
}
--count;
done = false; done = false;
} else { const GnuPG = this.saveGnuPG() && GnuPGUserStore.isSupported(),
done = true; backup = this.saveServer(),
} // eslint-disable-next-line max-len
} while (!done); reg = /[-]{3,6}BEGIN[\s]PGP[\s](PRIVATE|PUBLIC)[\s]KEY[\s]BLOCK[-]{3,6}[\s\S]+?[-]{3,6}END[\s]PGP[\s](PRIVATE|PUBLIC)[\s]KEY[\s]BLOCK[-]{3,6}/gi;
if (this.keyError()) { do {
return; match = reg.exec(keyTrimmed);
if (match && 0 < count) {
if (match[0] && match[1] && match[2] && match[1] === match[2]) {
PgpUserStore.importKey(this.key(), GnuPG, backup);
}
--count;
done = false;
} else {
done = true;
}
} while (!done);
this.close();
} }
this.close();
} }
onShow(key) { onShow(key) {

View file

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

View file

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

View file

@ -570,34 +570,39 @@ export class MailMessageView extends AbstractViewRight {
} }
pgpDecrypt() { pgpDecrypt() {
const oMessage = currentMessage(); const oMessage = currentMessage(),
data = oMessage.pgpEncrypted();
delete data.error;
PgpUserStore.decrypt(oMessage).then(result => { PgpUserStore.decrypt(oMessage).then(result => {
if (result) { if (!result) {
oMessage.pgpDecrypted(true);
if (result.data) {
MimeToMessage(result.data, oMessage);
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
if (result.signatures?.length) {
oMessage.pgpSigned(true);
oMessage.pgpVerified({
signatures: result.signatures,
success: !!result.signatures.length
});
}
}
} else {
// TODO: translate // TODO: translate
alert('Decryption failed, canceled or not possible'); throw Error('Decryption failed, canceled or not possible');
}
oMessage.pgpDecrypted(true);
if (result.data) {
MimeToMessage(result.data, oMessage);
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
if (result.signatures?.length) {
oMessage.pgpSigned({
signatures: result.signatures,
success: !!result.signatures.length
});
}
} }
}) })
.catch(e => console.error(e)); .catch(e => {
data.error = e.message;
})
.finally(() => {
oMessage.pgpEncrypted(data);
});
} }
pgpVerify(/*self, event*/) { pgpVerify(/*self, event*/) {
const oMessage = currentMessage()/*, ctrl = event.target.closest('.openpgp-control')*/; const oMessage = currentMessage()/*, ctrl = event.target.closest('.openpgp-control')*/;
PgpUserStore.verify(oMessage).then(result => { PgpUserStore.verify(oMessage).then(result => {
if (result) { if (result) {
oMessage.pgpVerified(result); oMessage.pgpSigned(result);
} else { } else {
alert('Verification failed or no valid public key found'); alert('Verification failed or no valid public key found');
} }
@ -624,16 +629,17 @@ export class MailMessageView extends AbstractViewRight {
async smimeDecrypt() { async smimeDecrypt() {
const message = currentMessage(); const message = currentMessage();
let pass, data = message.smimeEncrypted(); // { partId: "1" }
const addresses = message.from.concat(message.to, message.cc, message.bcc).map(item => item.email), const addresses = message.from.concat(message.to, message.cc, message.bcc).map(item => item.email),
identity = IdentityUserStore.find(item => addresses.includes(item.email())); identity = IdentityUserStore.find(item => addresses.includes(item.email())),
data = message.smimeEncrypted(); // { partId: "1" }
if (data && identity) { if (data && identity) {
data = { ...data }; // clone delete data.error;
data.folder = message.folder; let pass, params = { ...data }; // clone
data.uid = message.uid; params.folder = message.folder;
// data.bodyPart = data.bodyPart?.raw; params.uid = message.uid;
data.certificate = identity.smimeCertificate(); // params.bodyPart = params.bodyPart?.raw;
data.privateKey = identity.smimeKey(); params.certificate = identity.smimeCertificate();
params.privateKey = identity.smimeKey();
if (identity.smimeKeyEncrypted()) { if (identity.smimeKeyEncrypted()) {
pass = await Passphrases.ask(identity, pass = await Passphrases.ask(identity,
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}), i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}),
@ -642,36 +648,44 @@ export class MailMessageView extends AbstractViewRight {
if (!pass) { if (!pass) {
return; return;
} }
data.passphrase = pass?.password; params.passphrase = pass?.password;
} }
Remote.post('SMimeDecryptMessage', null, data).then(response => { Remote.post('SMimeDecryptMessage', null, params).then(response => {
if (response?.Result) { if (response?.Result?.data) {
message.smimeDecrypted(true); message.smimeDecrypted(true);
MimeToMessage(response.Result, message); MimeToMessage(response.Result.data, message);
message.html() ? message.viewHtml() : message.viewPlain(); message.html() ? message.viewHtml() : message.viewPlain();
pass && pass.remember && Passphrases.set(identity, pass.password); pass && pass.remember && Passphrases.set(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*/) { smimeVerify(/*self, event*/) {
const message = currentMessage(); const message = currentMessage(),
let data = message.smimeSigned(); // { partId: "1", micAlg: "pgp-sha256" } data = message.smimeSigned(); // { partId: "1", micAlg: "pgp-sha256" }
if (data) { if (data) {
data = { ...data }; // clone const params = { ...data }; // clone
data.folder = message.folder; params.folder = message.folder;
data.uid = message.uid; params.uid = message.uid;
data.bodyPart = data.bodyPart?.raw; params.bodyPart = data.bodyPart?.raw;
data.sigPart = data.sigPart?.bodyRaw; params.sigPart = data.sigPart?.bodyRaw;
Remote.post('SMimeVerifyMessage', null, data).then(response => { Remote.post('SMimeVerifyMessage', null, params).then(response => {
if (response?.Result) { if (response?.Result) {
if (response.Result.body) { if (response.Result.body) {
MimeToMessage(response.Result.body, message); MimeToMessage(response.Result.body, message);
message.html() ? message.viewHtml() : message.viewPlain(); message.html() ? message.viewHtml() : message.viewPlain();
response.Result.body = null;
} }
message.smimeVerified(response.Result); data.success = response.Result.success;
message.smimeSigned(data);
} }
}); });
} }

View file

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

7
dev/prototype.js vendored
View file

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

View file

@ -1,7 +1,7 @@
version: '2' version: '2'
services: services:
snappymail: snappymail:
image: leojonathanoh/snappymail:pr-1 image: djmaze/snappymail:latest
ports: ports:
- 8888:8888 - 8888:8888
environment: environment:

View file

@ -1,7 +1,7 @@
version: '2' version: '2'
services: services:
snappymail: snappymail:
image: leojonathanoh/snappymail:pr-1 image: djmaze/snappymail:latest
ports: ports:
- 8888:8888 - 8888:8888
environment: environment:

View file

@ -1,7 +1,7 @@
version: '2' version: '2'
services: services:
snappymail: snappymail:
image: leojonathanoh/snappymail:pr-1 image: djmaze/snappymail:latest
ports: ports:
- 8888:8888 - 8888:8888
environment: environment:

View file

@ -38,7 +38,7 @@ services:
# snappymail will be available at: https://snappymail.example.com # snappymail will be available at: https://snappymail.example.com
snappymail: snappymail:
image: leojonathanoh/snappymail:pr-1 image: djmaze/snappymail:latest
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=traefik-network" - "traefik.docker.network=traefik-network"

View file

@ -1,4 +1,4 @@
This app packages SnappyMail <upstream>2.35.0</upstream>. This app packages SnappyMail <upstream>2.35.2</upstream>.
SnappyMail is a simple, modern, lightweight & fast web-based email client. SnappyMail is a simple, modern, lightweight & fast web-based email client.

View file

@ -4,7 +4,7 @@ RUN mkdir -p /app/code
WORKDIR /app/code WORKDIR /app/code
# If you change the extraction below, be sure to test on scaleway # If you change the extraction below, be sure to test on scaleway
VERSION=2.35.0 VERSION=2.35.2
RUN wget https://github.com/the-djmaze/snappymail/releases/download/v${VERSION}/snappymail-${VERSION}.zip -O /tmp/snappymail.zip && \ RUN wget https://github.com/the-djmaze/snappymail/releases/download/v${VERSION}/snappymail-${VERSION}.zip -O /tmp/snappymail.zip && \
unzip /tmp/snappymail.zip -d /app/code && \ unzip /tmp/snappymail.zip -d /app/code && \
rm /tmp/snappymail.zip && \ rm /tmp/snappymail.zip && \

View file

@ -3,7 +3,7 @@
<id>snappymail</id> <id>snappymail</id>
<name>SnappyMail</name> <name>SnappyMail</name>
<summary>SnappyMail Webmail</summary> <summary>SnappyMail Webmail</summary>
<version>2.35.0</version> <version>2.35.2</version>
<licence>agpl</licence> <licence>agpl</licence>
<author>SnappyMail, RainLoop Team, Nextgen-Networks, Tab Fitts, Nathan Kinkade, Pierre-Alain Bandinelli</author> <author>SnappyMail, RainLoop Team, Nextgen-Networks, Tab Fitts, Nathan Kinkade, Pierre-Alain Bandinelli</author>
<description><![CDATA[**Simple, modern, lightweight & fast web-based email client.** <description><![CDATA[**Simple, modern, lightweight & fast web-based email client.**

View file

@ -72,7 +72,7 @@ class InstallStep implements IRepairStep
if ('12345' == $sPassword || !$sPassword) { if ('12345' == $sPassword || !$sPassword) {
$output->info('Generate admin password'); $output->info('Generate admin password');
$sPassword = \substr(\base64_encode(\random_bytes(16)), 0, 12); $sPassword = \substr(\base64_encode(\random_bytes(16)), 0, 12);
$oConfig->SetPassword($sPassword); $oConfig->SetPassword(new \SnappyMail\SensitiveString($sPassword));
\RainLoop\Utils::saveFile(APP_PRIVATE_DATA . 'admin_password.txt', $sPassword . "\n"); \RainLoop\Utils::saveFile(APP_PRIVATE_DATA . 'admin_password.txt', $sPassword . "\n");
$bSave = true; $bSave = true;
} }

View file

@ -91,7 +91,6 @@ class SnappyMailHelper
*/ */
if ($doLogin && $aCredentials[1] && $aCredentials[2]) { if ($doLogin && $aCredentials[1] && $aCredentials[2]) {
try { try {
$oActions->Logger()->AddSecret($aCredentials[2]);
$oAccount = $oActions->LoginProcess($aCredentials[1], $aCredentials[2]); $oAccount = $oActions->LoginProcess($aCredentials[1], $aCredentials[2]);
if ($oAccount) { if ($oAccount) {
// Must be here due to bug #1241 // Must be here due to bug #1241
@ -202,9 +201,10 @@ class SnappyMailHelper
return \SnappyMail\Crypt::EncryptUrlSafe($sPassword, $sSalt); return \SnappyMail\Crypt::EncryptUrlSafe($sPassword, $sSalt);
} }
public static function decodePassword(string $sPassword, string $sSalt)/* : mixed */ public static function decodePassword(string $sPassword, string $sSalt) : ?\SnappyMail\SensitiveString
{ {
static::loadApp(); static::loadApp();
return \SnappyMail\Crypt::DecryptUrlSafe($sPassword, $sSalt); $result = \SnappyMail\Crypt::DecryptUrlSafe($sPassword, $sSalt);
return $result ? new \SnappyMail\SensitiveString($result) : $result;
} }
} }

View file

@ -20,7 +20,7 @@ return "SnappyMail Webmail is a browser-based multilingual IMAP client with an a
# script_snappymail_versions() # script_snappymail_versions()
sub script_snappymail_versions sub script_snappymail_versions
{ {
return ( "2.35.0" ); return ( "2.35.2" );
} }
sub script_snappymail_version_desc sub script_snappymail_version_desc

View file

@ -3,7 +3,7 @@
"title": "SnappyMail", "title": "SnappyMail",
"description": "Simple, modern & fast web-based email client", "description": "Simple, modern & fast web-based email client",
"private": true, "private": true,
"version": "2.35.0", "version": "2.35.2",
"homepage": "https://snappymail.eu", "homepage": "https://snappymail.eu",
"author": { "author": {
"name": "DJ Maze", "name": "DJ Maze",

View file

@ -4,35 +4,31 @@ class BlackListPlugin extends \RainLoop\Plugins\AbstractPlugin
{ {
const const
NAME = 'Blacklist', NAME = 'Blacklist',
VERSION = '2.1', VERSION = '2.2',
RELEASE = '2021-04-21', RELEASE = '2024-03-04',
REQUIRED = '2.5.0', REQUIRED = '2.5.0',
CATEGORY = 'Login', CATEGORY = 'Login',
DESCRIPTION = 'Simple blacklist extension (with wildcard and exceptions functionality).'; DESCRIPTION = 'Simple blacklist extension (with wildcard and exceptions functionality).';
public function Init() : void public function Init() : void
{ {
$this->addHook('login.credentials', 'FilterLoginCredentials'); $this->addHook('login.credentials.step-1', 'FilterLoginCredentials');
} }
/** /**
* @param string $sEmail
* @param string $sLogin
* @param string $sPassword
*
* @throws \RainLoop\Exceptions\ClientException * @throws \RainLoop\Exceptions\ClientException
*/ */
public function FilterLoginCredentials(&$sEmail, &$sLogin, &$sPassword) public function FilterLoginCredentials(string &$sEmail)
{ {
$sBlackList = \trim($this->Config()->Get('plugin', 'black_list', '')); $sBlackList = \trim($this->Config()->Get('plugin', 'black_list', ''));
if (0 < \strlen($sBlackList) && \RainLoop\Plugins\Helper::ValidateWildcardValues($sEmail, $sBlackList)) if (\strlen($sBlackList) && \RainLoop\Plugins\Helper::ValidateWildcardValues($sEmail, $sBlackList)) {
{
$sExceptions = \trim($this->Config()->Get('plugin', 'exceptions', '')); $sExceptions = \trim($this->Config()->Get('plugin', 'exceptions', ''));
if (0 === \strlen($sExceptions) || !\RainLoop\Plugins\Helper::ValidateWildcardValues($sEmail, $sExceptions)) if (!\strlen($sExceptions) || !\RainLoop\Plugins\Helper::ValidateWildcardValues($sEmail, $sExceptions)) {
{
throw new \RainLoop\Exceptions\ClientException( throw new \RainLoop\Exceptions\ClientException(
$this->Config()->Get('plugin', 'auth_error', true) ? $this->Config()->Get('plugin', 'auth_error', false)
\RainLoop\Notifications::AuthError : \RainLoop\Notifications::AccountNotAllowed); ? \RainLoop\Notifications::AuthError
: \RainLoop\Notifications::AccountNotAllowed
);
} }
} }
} }
@ -46,7 +42,7 @@ class BlackListPlugin extends \RainLoop\Plugins\AbstractPlugin
\RainLoop\Plugins\Property::NewInstance('auth_error')->SetLabel('Auth Error') \RainLoop\Plugins\Property::NewInstance('auth_error')->SetLabel('Auth Error')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDescription('Throw an authentication error instead of an access error.') ->SetDescription('Throw an authentication error instead of an access error.')
->SetDefaultValue(true), ->SetDefaultValue(false),
\RainLoop\Plugins\Property::NewInstance('black_list')->SetLabel('Black List') \RainLoop\Plugins\Property::NewInstance('black_list')->SetLabel('Black List')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT)
->SetDescription('Emails black list, space as delimiter, wildcard supported.') ->SetDescription('Emails black list, space as delimiter, wildcard supported.')

View file

@ -0,0 +1,88 @@
.squire-toolbar {
padding-top: 4px;
padding-bottom: 0;
overflow: visible;
z-index: 200;
white-space: normal;
min-height: auto;
}
.squire-toolbar > .btn-group {
margin-bottom: 4px;
}
.squire-toolbar > .btn-group > a.btn,
.squire-toolbar button.btn,
.squire-toolbar select.btn {
line-height: 20px;
padding-top: 4px;
padding-bottom: 4px;
min-height: 24px;
}
.squire-toolbar-menu-item {
display: flex;
align-items: center;
gap: .25em;
margin: .1em !important;
cursor: pointer;
padding: .25em;
}
.squire-toolbar-menu-item.active {
background-color: rgba(128, 128, 128, .1);
}
.squire-toolbar-menu-item:hover {
background-color: rgba(128, 128, 128, .2);
}
.squire-toolbar-svg-icon {
display: block;
fill: var(--dialog-clr, #333);
}
.squire2-mode-wysiwyg .squire-plain,
.squire2-mode-source .squire-wysiwyg,
.squire2-mode-plain .squire-wysiwyg {
display: none;
}
.squire2-mode-source .squire-plain,
.squire2-mode-plain .squire-plain {
display: block;
}
.squire-toolbar > .squire-toolbar-menu-wrap:last-child {
float: right;
}
.squire-toolbar.mode-plain .squire-html-mode-item {
display: none;
}
#V-PopupsCompose .attachmentAreaParent.compact {
height: auto;
min-height: auto;
padding: 0;
overflow: auto;
flex: 1 0 auto;
max-height: 12em;
margin: 0;
}
#V-PopupsCompose .compact > .b-attachment-place {
position: static;
display: none;
margin: .375em;
line-height: 4em;
}
#V-PopupsCompose .compact > .b-attachment-place.dragAndDropOver {
display: block;
}
#V-PopupsCompose .compact .attachmentList {
margin: 0;
padding: 0;
}

View file

@ -0,0 +1,23 @@
<?php
class CompactComposerPlugin extends \RainLoop\Plugins\AbstractPlugin
{
const
NAME = 'Compact Composer',
AUTHOR = 'Sergey Mosin',
URL = 'https://github.com/the-djmaze/snappymail/pull/1466',
VERSION = '1.0.1',
RELEASE = '2024-02-23',
REQUIRED = '2.34.0',
LICENSE = 'AGPL v3',
DESCRIPTION = 'WYSIWYG editor with a compact toolbar';
public function Init(): void
{
$this->addTemplate('templates/PopupsCompactCompose.html');
$this->addCss('css/composer.css');
$this->addJs('js/squire-raw.js');
$this->addJs('js/parsel.js');
$this->addJs('js/CompactComposer.js');
}
}

View file

@ -0,0 +1,990 @@
/* eslint max-len: 0 */
(win => {
const rl = win.rl;
if (!rl) {
return;
}
rl.registerWYSIWYG('CompactComposer', (owner, container, onReady) => {
const editor = new CompactComposer(container);
onReady(editor);
});
const doc = win.document;
// If a user (or admin) selected the CompactComposer we need to
// replace PopupsCompose template with PopupsCompactCompose template.
// --
// This might break some plugins if they query/change PopupsCompose template
// before this code is called. They should instead listen for
// 'rl-view-model.create' to work properly.
if (rl.settings.get('editorWysiwyg') === 'CompactComposer') {
const compactTemplate = doc.getElementById('PopupsCompactCompose');
if (!compactTemplate) {
console.error('CompactComposer: PopupsCompactCompose template not found');
return;
}
const originalTemplate = doc.getElementById('PopupsCompose');
if (originalTemplate) {
originalTemplate.id = 'PopupsCompose_replaced';
} else {
console.warn('CompactComposer: PopupsCompose template not found');
}
compactTemplate.id = 'PopupsCompose';
addEventListener('rl-view-model.create', e => {
if (e.detail.viewModelTemplateID === 'PopupsCompose') {
// There is a better way to do this probably,
// but we need this for drag and drop to work
e.detail.attachmentsArea = e.detail.bodyArea;
}
});
}
const
removeElements = 'HEAD,LINK,META,NOSCRIPT,SCRIPT,TEMPLATE,TITLE',
allowedElements = 'A,B,BLOCKQUOTE,BR,DIV,EM,FONT,H1,H2,H3,H4,H5,H6,HR,I,IMG,LI,OL,P,SPAN,STRONG,TABLE,TD,TH,TR,U,UL',
allowedAttributes = 'abbr,align,background,bgcolor,border,cellpadding,cellspacing,class,color,colspan,dir,face,frame,height,href,hspace,id,lang,rowspan,rules,scope,size,src,style,target,type,usemap,valign,vspace,width'.split(','),
// TODO: labels translations
i18n = (str, def) => rl.i18n(str) || def,
ctrlKey = shortcuts.getMetaKey() + ' + ',
createElement = name => doc.createElement(name),
tpl = createElement('template'),
trimLines = html => html.trim().replace(/^(<div>\s*<br\s*\/?>\s*<\/div>)+/, '').trim(),
htmlToPlain = html => rl.Utils.htmlToPlain(html).trim(),
plainToHtml = text => rl.Utils.plainToHtml(text),
getFragmentOfChildren = parent => {
let frag = doc.createDocumentFragment();
frag.append(...parent.childNodes);
return frag;
},
/**
* @param {Array} data
* @param {String} prop
*/
getByProp = (data, prop) => {
for (let i = 0; i < data.length; i++) {
const outer = data[i];
if (outer.hasOwnProperty(prop)) {
return outer;
}
if (outer.items && Array.isArray(outer.items)) {
const item = outer.items.find(item => item.prop === prop);
if (item) {
return item;
}
}
}
throw new Error('item with prop ' + prop + ' not found');
},
SquireDefaultConfig = {
/*
addLinks: true // allow_smart_html_links
*/
sanitizeToDOMFragment: (html) => {
tpl.innerHTML = (html || '')
.replace(/<\/?(BODY|HTML)[^>]*>/gi, '')
.replace(/<!--[^>]+-->/g, '')
.replace(/<span[^>]*>\s*<\/span>/gi, '')
.trim();
tpl.querySelectorAll('a:empty,span:empty').forEach(el => el.remove());
return tpl.content;
}
},
pasteSanitizer = (event) => {
const frag = event.detail.fragment;
frag.querySelectorAll('a:empty,span:empty').forEach(el => el.remove());
frag.querySelectorAll(removeElements).forEach(el => el.remove());
frag.querySelectorAll('*').forEach(el => {
if (!el.matches(allowedElements)) {
el.replaceWith(getFragmentOfChildren(el));
} else if (el.hasAttributes()) {
[...el.attributes].forEach(attr => {
let name = attr.name.toLowerCase();
if (!allowedAttributes.includes(name)) {
el.removeAttribute(name);
}
});
}
});
},
pasteImageHandler = (e, squire) => {
const items = [...e.detail.clipboardData.items];
const imageItems = items.filter((item) => /image/.test(item.type));
if (!imageItems.length) {
return false;
}
let reader = new FileReader();
reader.onload = (loadEvent) => {
squire.insertImage(loadEvent.target.result);
};
reader.readAsDataURL(imageItems[0].getAsFile());
};
class CompactComposer {
constructor(container) {
const
plain = createElement('textarea'),
wysiwyg = createElement('div'),
toolbar = createElement('div'),
squire = new win.Squire2(wysiwyg, SquireDefaultConfig);
this.container = container;
plain.className = 'squire-plain';
wysiwyg.className = 'squire-wysiwyg';
wysiwyg.dir = 'auto';
this.mode = ''; // 'plain' | 'wysiwyg'
this.squire = squire;
this.plain = plain;
this.wysiwyg = wysiwyg;
this.toolbar = toolbar;
toolbar.className = 'squire-toolbar btn-toolbar';
const actions = this.#makeActions(squire, toolbar);
this.squire.addEventListener('willPaste', pasteSanitizer);
this.squire.addEventListener('pasteImage', (e) => {
pasteImageHandler(e, squire);
});
// squire.addEventListener('focus', () => shortcuts.off());
// squire.addEventListener('blur', () => shortcuts.on());
container.append(toolbar, wysiwyg, plain);
const fontFamilySelect = getByProp(actions, 'fontFamily').element;
const fontSizeAction = getByProp(actions, 'fontSize');
/**
* @param {string} fontName
* @return {string}
*/
const normalizeFontName = (fontName) => fontName.trim().replace(/(^["']*|["']*$)/g, '').trim().toLowerCase();
/** @type {string[]} - lower cased array of available font families*/
const fontFamiliesLowerCase = Object.values(fontFamilySelect.options).map(option => option.value.toLowerCase());
/**
* A theme might have CSS like div.squire-wysiwyg[contenteditable="true"] {
* font-family: 'Times New Roman', Times, serif; }
* so let's find the best match squire.getRoot()'s font
* it will also help to properly handle generic font names like 'sans-serif'
* @type {number}
*/
let defaultFontFamilyIndex = 0;
const squireRootFonts = getComputedStyle(squire.getRoot()).fontFamily.split(',').map(normalizeFontName);
fontFamiliesLowerCase.some((family, index) => {
const matchFound = family.split(',').some(availableFontName => {
const normalizedFontName = normalizeFontName(availableFontName);
return squireRootFonts.some(squireFontName => squireFontName === normalizedFontName);
});
if (matchFound) {
defaultFontFamilyIndex = index;
}
return matchFound;
});
/**
* Instead of comparing whole 'font-family' strings,
* we are going to look for individual font names, because we might be
* editing a Draft started in another email client for example
*
* @type {Object.<string,number>}
*/
const fontNamesMap = {};
/**
* @param {string} fontFamily
* @param {number} index
*/
const processFontFamilyString = (fontFamily, index) => {
fontFamily.split(',').forEach(fontName => {
const key = normalizeFontName(fontName);
if (fontNamesMap[key] === undefined) {
fontNamesMap[key] = index;
}
});
};
// first deal with the default font family
processFontFamilyString(fontFamiliesLowerCase[defaultFontFamilyIndex], defaultFontFamilyIndex);
// and now with the rest of the font families
fontFamiliesLowerCase.forEach((fontFamily, index) => {
if (index !== defaultFontFamilyIndex) {
processFontFamilyString(fontFamily, index);
}
});
// -----
let ignoreNextSelectEvent = false;
squire.addEventListener('pathChange', e => {
const tokensMap = this.buildTokensMap(e.detail);
if (tokensMap.has('__selection__')) {
ignoreNextSelectEvent = false;
return;
}
this.indicators.forEach((indicator) => {
indicator.element.classList.toggle('active', indicator.selectors.some(selector => tokensMap.has(selector)));
});
let familySelectedIndex = defaultFontFamilyIndex;
const fontFamily = tokensMap.get('__font_family__');
if (fontFamily) {
familySelectedIndex = -1; // show empty select if we don't know the font
const fontNames = fontFamily.split(',');
for (let i = 0; i < fontNames.length; i++) {
const index = fontNamesMap[normalizeFontName(fontNames[i])];
if (index !== undefined) {
familySelectedIndex = index;
break;
}
}
}
fontFamilySelect.selectedIndex = familySelectedIndex;
let sizeSelectedIndex = fontSizeAction.defaultValueIndex;
const fontSize = tokensMap.get('__font_size__');
if (fontSize) {
// -1 is ok because it will just show a blank <select>
sizeSelectedIndex = fontSizeAction.items.indexOf(fontSize);
}
fontSizeAction.element.selectedIndex = sizeSelectedIndex;
ignoreNextSelectEvent = true;
});
squire.addEventListener('select', e => {
if (ignoreNextSelectEvent) {
ignoreNextSelectEvent = false;
return;
}
if (e.detail.range.collapsed) {
return;
}
this.indicators.forEach((indicator) => {
indicator.element.classList.toggle('active', indicator.selectors.some(selector => squire.hasFormat(selector)));
});
});
/*
squire.addEventListener('cursor', e => {
console.dir({cursor:e.range});
});
squire.addEventListener('select', e => {
console.dir({select:e.range});
});
*/
}
/**
* @param {Squire} squire
* @param {HTMLDivElement} toolbar
* @returns {Array}
*/
#makeActions(squire, toolbar) {
const clr = this.#makeClr();
const doClr = name => input => {
// https://github.com/the-djmaze/snappymail/issues/826
clr.style.left = (input.offsetLeft + input.parentNode.offsetLeft) + 'px';
clr.style.width = input.offsetWidth + 'px';
clr.value = '';
clr.onchange = () => {
switch (name) {
case 'color':
squire.setTextColor(clr.value);
break;
case 'backgroundColor':
squire.setHighlightColor(clr.value);
break;
default:
console.error('invalid name:', name);
}
};
// Chrome 110+ https://github.com/the-djmaze/snappymail/issues/1199
// clr.oninput = () => squire.setStyle({[name]:clr.value});
setTimeout(() => clr.click(), 1);
};
toolbar.append(clr);
const browseImage = createElement('input');
browseImage.type = 'file';
browseImage.accept = 'image/*';
browseImage.style.display = 'none';
browseImage.onchange = () => {
if (browseImage.files.length) {
let reader = new FileReader();
reader.readAsDataURL(browseImage.files[0]);
reader.onloadend = () => reader.result && squire.insertImage(reader.result);
}
};
const actions = [
{
type: 'group',
items: [
{
type: 'select',
label: 'Font',
cmd: s => squire.setFontFace(s.value),
prop: 'fontFamily',
items: {
'sans-serif': {
Arial: '\'Nimbus Sans L\', \'Liberation sans\', \'Arial Unicode MS\', Arial, Helvetica, Garuda, Utkal, FreeSans, sans-serif',
Tahoma: '\'Luxi Sans\', Tahoma, Loma, Geneva, Meera, sans-serif',
Trebuchet: '\'DejaVu Sans Condensed\', Trebuchet, \'Trebuchet MS\', sans-serif',
Lucida: '\'Lucida Sans Unicode\', \'Lucida Sans\', \'DejaVu Sans\', \'Bitstream Vera Sans\', \'DejaVu LGC Sans\', sans-serif',
Verdana: '\'DejaVu Sans\', Verdana, Geneva, \'Bitstream Vera Sans\', \'DejaVu LGC Sans\', sans-serif'
},
monospace: {
Courier: '\'Liberation Mono\', \'Courier New\', FreeMono, Courier, monospace',
Lucida: '\'DejaVu Sans Mono\', \'DejaVu LGC Sans Mono\', \'Bitstream Vera Sans Mono\', \'Lucida Console\', Monaco, monospace'
},
sans: {
Times: '\'Nimbus Roman No9 L\', \'Times New Roman\', Times, FreeSerif, serif',
Palatino: '\'Bitstream Charter\', \'Palatino Linotype\', Palatino, Palladio, \'URW Palladio L\', \'Book Antiqua\', Times, serif',
Georgia: '\'URW Palladio L\', Georgia, Times, serif'
}
}
},
{
type: 'select',
label: 'Font size',
cmd: s => squire.setFontSize(s.value),
prop: 'fontSize',
items: ['11px', '13px', '16px', '20px', '24px', '30px'],
defaultValueIndex: 2
}
]
},
{
type: 'menu',
label: 'Colors',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m8 7.75c0.713 0 1.25-0.559 1.25-1.25 0-0.691-0.54-1.25-1.25-1.25-0.71 0-1.25 0.556-1.25 1.25 0 0.694 0.537 1.25 1.25 1.25zm6.5 3c0.713 0 1.25-0.559 1.25-1.25 0-0.691-0.54-1.25-1.25-1.25s-1.25 0.556-1.25 1.25c0 0.694 0.537 1.25 1.25 1.25zm-9 0c0.713 0 1.25-0.559 1.25-1.25 0-0.691-0.54-1.25-1.25-1.25s-1.25 0.556-1.25 1.25c0 0.694 0.537 1.25 1.25 1.25zm4.5 7.25c-4.47 0-8-3.63-8-8 0-4.81 3.97-8 8.17-8 4.12 0 7.83 3.02 7.83 7.21 0 2.83-2.2 4.79-4.79 4.79h-1.42c-0.277 0-0.417 0.2-0.417 0.375 0 0.208 0.104 0.382 0.312 0.521 0.208 0.139 0.312 0.507 0.312 1.1 0 1.09-0.858 2-2 2zm2-10.2c0.713 0 1.25-0.559 1.25-1.25s-0.54-1.25-1.25-1.25-1.25 0.556-1.25 1.25 0.537 1.25 1.25 1.25zm-2 8.75c0.477 0 0.737-0.739 0.188-1.08-0.226-0.142-0.312-0.514-0.312-1.04 0-1.18 0.934-1.88 1.9-1.88h1.44c2.09-0.032 3.27-1.65 3.29-3.29 0-3.19-2.79-5.71-6.33-5.71-3.74 0-6.67 2.89-6.67 6.5 0 3.49 2.68 6.45 6.5 6.5z"/></svg>',
items: [
{
type: 'menu_item',
label: 'Text Color',
cmd: doClr('color'),
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m3 18v-3h14v3zm2.35-5 3.75-10h1.79l3.75 10h-1.73l-0.896-2.56h-4.02l-0.917 2.56zm3.15-4h3l-1.46-4.04h-0.0833z"/></svg>'
},
{
type: 'menu_item',
label: 'Background Color',
cmd: doClr('backgroundColor'),
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m7.22 16.6-4.87-4.85q-0.166-0.166-0.26-0.364-0.0936-0.198-0.0936-0.427t0.0936-0.447q0.0936-0.218 0.26-0.385l4.6-4.6-2.52-2.52 1.06-1.06 8.18 8.18q0.166 0.166 0.25 0.375 0.0832 0.208 0.0832 0.437 0 0.229-0.0832 0.437-0.0832 0.208-0.25 0.375l-4.85 4.85q-0.166 0.166-0.375 0.26-0.208 0.0936-0.437 0.0936-0.229-0.0208-0.427-0.104-0.198-0.0832-0.364-0.25zm0.791-10-4.35 4.35v-0.0208 0.0208h8.7v-0.0208 0.0208zm8.18 10.3q-0.77 0-1.3-0.52-0.531-0.52-0.531-1.29 0-0.499 0.229-0.967 0.229-0.468 0.541-0.884l1.06-1.33 1.04 1.33q0.291 0.416 0.531 0.884 0.239 0.468 0.239 0.967 0 0.77-0.531 1.29-0.531 0.52-1.28 0.52z"/></svg>'
}
]
},
{
type: 'group',
items: [
{
type: 'button',
label: 'Bold',
cmd: () => this.doAction('bold', 'B'),
key: 'B',
matches: 'B,STRONT',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m5.53 16v-12h4.75q1.4 0 2.57 0.861 1.18 0.861 1.18 2.39 0 1.06-0.469 1.66t-0.885 0.865q0.542 0.249 1.17 0.895 0.625 0.646 0.625 1.9 0 1.9-1.4 2.67-1.4 0.771-2.62 0.771zm2.65-2.46h2.18q1.01 0 1.21-0.51t0.208-0.74q0-0.229-0.219-0.74-0.219-0.51-1.28-0.51h-2.1zm0-4.83h1.94q0.688 0 1.01-0.365 0.323-0.365 0.323-0.781 0-0.5-0.356-0.812-0.356-0.312-0.923-0.312h-1.99z"/></svg>'
},
{
type: 'button',
label: 'Italic',
cmd: () => this.doAction('italic', 'I'),
key: 'I',
matches: 'I,EM',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m4.5 16v-2h3.33l2.58-8h-3.42v-2h8.5v2h-3.08l-2.58 8h3.17v2z"/></svg>'
},
{
type: 'button',
label: 'Underline',
cmd: () => this.doAction('underline', 'U'),
key: 'U',
matches: 'U',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m5 17v-1.5h10v1.5zm5-3q-2 0-3.09-1.24t-1.09-3.28v-6.48h2.03v6.61q0 1.1 0.551 1.79 0.551 0.688 1.61 0.688 1.06 0 1.61-0.688 0.55-0.688 0.55-1.79v-6.61h2.02v6.48q0 2.04-1.09 3.28t-3.09 1.24z"/></svg>'
}
]
},
{
type: 'group',
items: [
{
type: 'button',
label: 'Ordered List',
cmd: () => this.doList('OL'),
key: 'Shift + 8',
matches: 'OL',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m3 17v-1h2v-0.5h-1v-1h1v-0.5h-2v-1h2.5q0.212 0 0.356 0.144t0.144 0.356v1q0 0.212-0.144 0.356t-0.356 0.144q0.212 0 0.356 0.144t0.144 0.356v1q0 0.212-0.144 0.356t-0.356 0.144zm0-5v-2q0-0.212 0.144-0.356t0.356-0.144h1.5v-0.5h-2v-1h2.5q0.212 0 0.356 0.144t0.144 0.356v1.5q0 0.212-0.144 0.356t-0.356 0.144h-1.5v0.5h2v1zm1-5v-3h-1v-1h2v4zm3.5 8v-1.5h9.5v1.5zm0-4.25v-1.5h9.5v1.5zm0-4.25v-1.5h9.5v1.5z"/></svg>'
},
{
type: 'button',
label: 'List',
cmd: () => this.doList('UL'),
key: 'Shift + 9',
matches: 'UL',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m7.5 15v-1.5h9.5v1.5zm0-4.25v-1.5h9.5v1.5zm0-4.25v-1.5h9.5v1.5zm-3 9.25q-0.621 0-1.06-0.442-0.438-0.442-0.438-1.06 0-0.621 0.442-1.06 0.442-0.438 1.06-0.438 0.621 0 1.06 0.442 0.438 0.442 0.438 1.06 0 0.621-0.442 1.06-0.442 0.438-1.06 0.438zm0-4.25q-0.621 0-1.06-0.442-0.438-0.442-0.438-1.06 0-0.621 0.442-1.06 0.442-0.438 1.06-0.438 0.621 0 1.06 0.442 0.438 0.442 0.438 1.06 0 0.621-0.442 1.06-0.442 0.438-1.06 0.438zm0-4.25q-0.621 0-1.06-0.442-0.438-0.442-0.438-1.06 0-0.621 0.442-1.06 0.442-0.438 1.06-0.438 0.621 0 1.06 0.442 0.438 0.442 0.438 1.06 0 0.621-0.442 1.06-0.442 0.438-1.06 0.438z"/></svg>'
},
{
type: 'button',
label: 'Decrease Indent',
cmd: () => this.changeLevel('decrease'),
key: ']',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m3 17v-1.5h14v1.5zm6-3.12v-1.5h8v1.5zm0-3.12v-1.5h8v1.5zm0-3.12v-1.5h8v1.5zm-6-3.12v-1.5h14v1.5zm4 8.5v-6l-4 3z"/></svg>'
},
{
type: 'button',
label: 'Increase Indent',
cmd: () => this.changeLevel('increase'),
key: '[',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m3 17v-1.5h14v1.5zm6-3.12v-1.5h8v1.5zm0-3.12v-1.5h8v1.5zm0-3.12v-1.5h8v1.5zm-6-3.12v-1.5h14v1.5zm0 8.5v-6l4 3z"/></svg>'
}
]
},
{
type: 'menu',
rightEdge: true,
label: 'Insert Image',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m15 1v2h-2v2h2v2h2v-2h2v-2h-2v-2zm-12 2c-1.09 0-2 0.909-2 2v10c0 1.09 0.909 2 2 2h14c1.09 0 2-0.909 2-2v-5h-1.75v5.25h-14.5v-10.5h7.25v-1.75zm9 6-3 4-2-3-3 4h12z"/></svg>',
items: [
{
type: 'menu_item',
label: 'Image File',
cmd: () => browseImage.click(),
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m7 11h8l-2.62-3.5-1.88 2.5-1.37-1.83zm-4.5 6q-0.604 0-1.05-0.448t-0.448-1.05v-10h1.5v10h13.5v1.5zm3-3q-0.604 0-1.05-0.448-0.448-0.448-0.448-1.05v-9q0-0.619 0.448-1.06t1.05-0.441h3.52l2 2h5.48q0.619 0 1.06 0.441 0.441 0.441 0.441 1.06v7q0 0.604-0.441 1.05-0.441 0.448-1.06 0.448zm0-1.5h11v-7h-6.08l-2-2h-2.92zm0 0v-9z"/></svg>'
},
{
type: 'menu_item',
label: 'From URL',
cmd: () => {
//TODO: check is if an IMG node is in range already
const src = prompt('Image', 'https://');
if (src) {
this.squire.insertImage(src);
}
},
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m11.9 8h-1.88c-0.692 0-1.28-0.244-1.77-0.732-0.488-0.488-0.731-1.08-0.731-1.77s0.244-1.28 0.731-1.77 1.08-0.729 1.77-0.729h1.88v0.938h-1.88c-0.434 0-0.803 0.152-1.11 0.456s-0.456 0.673-0.456 1.11 0.152 0.803 0.456 1.11 0.607 0.456 1.11 0.456h1.88zm-1.25-2.03v-0.938h3.75v0.938zm2.5 2.03v-0.938h1.88c0.434 0 0.803-0.152 1.11-0.456s0.456-0.673 0.456-1.11-0.152-0.803-0.456-1.11-0.673-0.456-1.11-0.456h-1.88v-0.938h1.88c0.692 0 1.28 0.244 1.77 0.732 0.488 0.488 0.731 1.08 0.731 1.77 0 0.692-0.244 1.28-0.731 1.77-0.488 0.486-1.08 0.729-1.77 0.729zm3.38 2v5.5c0 0.403-0.147 0.753-0.441 1.05s-0.647 0.448-1.06 0.448h-11c-0.412 0-0.766-0.149-1.06-0.448s-0.441-0.649-0.441-1.05v-9.25c0-0.403 0.147-0.753 0.441-1.05s0.649-0.407 1.06-0.448h1.5v1.5h-1.5v9.25h11v-5.5zm-11.5 4h9l-3-4-2.25 3-1.5-2z"/></svg>'
}
]
},
{
// this is a special case: we move the "attach" button group to the toolbar
// TODO: there is probably a better way of doing this in the template
// TODO: move Encrypt/Sign button group ?
type: 'move_parent',
label: 'Attach File',
id: 'composeUploadButton',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m12.5 9.5v-4.5h1.5v4.5zm-3.5 5.44c-1.02-0.272-1.5-1.15-1.5-2.02v-7.92h1.5zm0.5 3.06c-2.59 0-4.5-2.11-4.5-4.65v-8.1c0-1.95 1.55-3.25 3.25-3.25 1.85 0 3.25 1.51 3.25 3.4v6.35h-1.5v-6.5c0-1.09-0.883-1.75-1.75-1.75-1.06 0-1.75 0.906-1.75 1.79v8.21c0 2.63 3.33 4.15 5.25 1.96v1.94c-0.706 0.428-1.56 0.604-2.25 0.604zm3.75-1v-2.25h-2.25v-1.5h2.25v-2.25h1.5v2.25h2.25v1.5h-2.25v2.25z"/></svg>'
},
{
type: 'menu_more',
label: 'More',
rightEdge: true,
showInPlainMode: true,
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m2 4v2h16v-2zm0 5v2h16v-2zm0 5v2h16v-2z"/></svg>',
items: [
{
type: 'menu_item',
label: 'Undo',
cmd: () => squire.undo(),
key: 'Z',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m4.82 9.3c1.45-1.26 3.32-2.03 5.4-2.03 3.64 0 6.71 2.37 7.79 5.65l-1.85 0.61c-0.821-2.49-3.17-4.3-5.94-4.3-1.52 0-2.92 0.563-4 1.47l2.83 2.83h-7.04v-7.04z"/></svg>'
},
{
type: 'menu_item',
label: 'Redo',
cmd: () => squire.redo(),
key: 'Y',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m15.2 9.3c-1.45-1.26-3.32-2.03-5.4-2.03-3.64 0-6.71 2.37-7.79 5.65l1.85 0.61c0.821-2.49 3.17-4.3 5.94-4.3 1.52 0 2.92 0.563 4 1.47l-2.83 2.83h7.04v-7.04z"/></svg>'
},
{
type: 'menu_item',
label: 'Blockquote',
cmd: () => {
if (!['UL', 'OL', 'BLOCKQUOTE'].some(listTag => this.squire.hasFormat(listTag))) {
this.changeLevel('increase');
}
},
matches: 'BLOCKQUOTE',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m4 3c-0.554 0-1 0.446-1 1s0.446 1 1 1h12c0.554 0 1-0.446 1-1s-0.446-1-1-1h-12zm0 6c-0.554 0-1 0.446-1 1v6c0 0.554 0.446 1 1 1s1-0.446 1-1v-6c0-0.554-0.446-1-1-1zm5 0c-0.554 0-1 0.446-1 1s0.446 1 1 1h7c0.554 0 1-0.446 1-1s-0.446-1-1-1h-7zm0 6c-0.554 0-1 0.446-1 1s0.446 1 1 1h7c0.554 0 1-0.446 1-1s-0.446-1-1-1h-7z"/></svg>'
},
{
type: 'menu_item',
label: 'Link',
cmd: () => {
/** @type {Range} range */
const range = this.squire.getSelection();
let linkNode;
if (range.collapsed || range.startContainer.parentNode === range.endContainer.parentNode) {
const root = this.squire.getRoot();
for (let node = range.startContainer; node !== root; node = node.parentNode) {
if (node.tagName === 'A') {
range.selectNode(node);
linkNode = node;
break;
}
}
}
const url = prompt('Link', linkNode?.href || 'https://');
if (url != null) {
if (url.length) {
if (range.collapsed === false) {
// squire breaks the wrapping node, so if we have a <b>
// inside the selection it will create something like this:
// <a>t</a><b><a>ex</a></b><a>t</a> and we don't want that
// TODO: this could be more elegant
this.squire.removeAllFormatting(range);
}
this.squire.makeLink(url);
} else if (linkNode) {
this.squire.removeLink();
}
}
},
matches: 'A',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m15.4 16.7c-0.509 0.521-1.13 0.781-1.87 0.781-0.735 0-1.36-0.256-1.87-0.771l-1.91-1.91c-0.515-0.515-0.771-1.14-0.771-1.87 0-0.743 0.267-1.38 0.801-1.9l-0.809-0.809c-0.525 0.533-1.16 0.801-1.91 0.801-0.735 0-1.36-0.255-1.87-0.763l-1.9-1.89c-0.521-0.509-0.781-1.13-0.781-1.87-6e-7 -0.735 0.255-1.36 0.763-1.87l1.34-1.35c0.509-0.521 1.13-0.781 1.87-0.781 0.735 1.6e-4 1.36 0.256 1.87 0.771l1.91 1.91c0.515 0.515 0.772 1.14 0.772 1.87 0 0.739-0.265 1.37-0.792 1.9l0.809 0.809c0.524-0.527 1.16-0.792 1.9-0.792 0.735 0 1.36 0.255 1.87 0.763l1.9 1.89c0.521 0.509 0.781 1.13 0.781 1.87 0 0.735-0.255 1.36-0.763 1.87zm-1.25-1.24 1.34-1.35c0.165-0.178 0.248-0.385 0.248-0.624 0-0.245-0.0862-0.455-0.258-0.626l-1.9-1.89c-0.172-0.172-0.38-0.257-0.625-0.257-0.249-3.1e-5 -0.466 0.0966-0.653 0.286l0.577 0.577c0.353 0.353 0.353 0.922 0 1.28-0.353 0.353-0.922 0.353-1.28 0l-0.577-0.577c-0.184 0.182-0.278 0.401-0.278 0.654 0 0.251 0.0824 0.459 0.248 0.624l1.91 1.91c0.172 0.171 0.38 0.257 0.625 0.257 0.239 0 0.444-0.0849 0.615-0.257zm-6.43-6.5-0.577-0.577c-0.353-0.353-0.353-0.922 0-1.28 0.353-0.353 0.922-0.353 1.28 0l0.575 0.575c0.184-0.18 0.279-0.394 0.279-0.643-6e-7 -0.245-0.0849-0.454-0.257-0.625l-1.91-1.91c-0.172-0.172-0.38-0.257-0.625-0.257-0.239 0-0.444 0.0852-0.615 0.257l-1.34 1.35c-0.159 0.172-0.238 0.379-0.238 0.624 2e-7 0.251 0.0811 0.46 0.247 0.625l1.9 1.89c0.172 0.172 0.38 0.257 0.625 0.257 0.253-9e-7 0.473-0.0991 0.661-0.295z"/></svg>'
},
{
type: 'menu_item',
label: 'Strikethrough',
cmd: () => this.doAction('strikethrough', 'S'),
key: 'Shift + 7',
matches: 'S',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m10.1 15.9q-1.5 0-2.62-0.875t-1.5-2.31l1.69-0.688q0.333 1.06 0.969 1.6 0.635 0.542 1.51 0.542 0.937 0 1.49-0.448 0.552-0.448 0.552-1.2 0-0.333-0.125-0.615t-0.375-0.51h2.15q0.104 0.229 0.135 0.49t0.0312 0.615q0 1.5-1.08 2.45-1.08 0.948-2.81 0.948zm-8.12-6v-1.5h16v1.5zm8-6q1.33 0 2.21 0.562t1.42 1.79l-1.62 0.708q-0.229-0.625-0.76-1.01-0.531-0.385-1.2-0.385-0.771 0-1.28 0.375-0.51 0.375-0.552 0.958h-1.81q0.0417-1.31 1.06-2.16 1.02-0.844 2.54-0.844z"/></svg>'
},
{
type: 'menu_item',
label: 'Superscript',
cmd: () => this.doAction('superscript', 'SUP'),
key: 'Shift + 6',
matches: 'SUP',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m13.4 8v-1.99q0-0.424 0.288-0.715 0.288-0.291 0.712-0.292h1v-1h-2v-1h2q0.425 0 0.712 0.287 0.288 0.286 0.288 0.71v0.997q0 0.424-0.288 0.715-0.288 0.292-0.712 0.292h-1v1h2v1zm-9.38 8 3.31-5.21-3.08-4.79h1.89l2.21 3.56h0.0833l2.21-3.56h1.9l-3.1 4.79 3.33 5.21h-1.9l-2.44-3.88h-0.0833l-2.44 3.88z"/></svg>'
},
{
type: 'menu_item',
label: 'Subscript',
cmd: () => this.doAction('subscript', 'SUB'),
key: 'Shift + 5',
matches: 'SUB',
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m13.4 18v-1.99q0-0.424 0.288-0.715 0.288-0.292 0.712-0.292h1v-1h-2v-1h2q0.425 0 0.712 0.287 0.288 0.286 0.288 0.71v0.997q0 0.424-0.288 0.715-0.288 0.292-0.712 0.292h-1v1h2v1zm-9.38-3 3.31-5.21-3.08-4.79h1.89l2.21 3.56h0.0833l2.21-3.56h1.9l-3.1 4.79 3.33 5.21h-1.9l-2.44-3.88h-0.0833l-2.44 3.88z"/></svg>'
},
{
type: 'menu_item',
label: 'Left to Right',
cmd: () => squire.setTextDirection('ltr'),
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m8 12v-4q-1.25 0-2.12-0.875-0.875-0.875-0.875-2.12t0.875-2.12q0.875-0.875 2.11-0.875h6.01v1.5h-1.5v8.5h-1.5v-8.5h-1.5v8.5zm6 6-1.06-1.06 1.19-1.19h-11.1v-1.5h11.1l-1.19-1.19 1.06-1.06 3 3zm-6-11.5v-3q-0.625 0-1.06 0.442-0.438 0.442-0.438 1.06 0 0.621 0.441 1.06 0.441 0.437 1.06 0.437z"/></svg>'
},
{
type: 'menu_item',
label: 'Right to Left',
cmd: () => squire.setTextDirection('rtl'),
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m6 18-3-3 3-3 1.06 1.06-1.19 1.19h11.1v1.5h-11.1l1.19 1.19zm2-6v-4q-1.25 0-2.12-0.875-0.875-0.875-0.875-2.12t0.875-2.12q0.875-0.875 2.11-0.875h6.01v1.5h-1.5v8.5h-1.5v-8.5h-1.5v8.5zm0-5.5v-3q-0.625 0-1.06 0.442-0.438 0.442-0.438 1.06 0 0.621 0.441 1.06 0.441 0.437 1.06 0.437z"/></svg>'
},
{
type: 'menu_item',
label: 'HTML Mode',
id: 'menu-item-mode-wysiwyg',
cmd: () => this.setMode('wysiwyg'),
showInPlainMode: true,
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m2 2v3h1v-2h2v-1zm13 0v1h2v2h1v-3zm-9 3v10h2v-4h4v4h2v-10h-2v4h-4v-4zm-4 10v3h3v-1h-2v-2zm15 0v2h-2v1h3v-3z"/></svg>'
},
{
type: 'menu_item',
label: 'Edit Source',
id: 'menu-item-mode-source',
cmd: () => this.setMode('source'),
showInPlainMode: true,
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m12 2.83c-0.478-0.138-0.976 0.141-1.11 0.619l-3.6 12.6c-0.138 0.478 0.141 0.976 0.619 1.11 0.478 0.138 0.976-0.141 1.11-0.619l3.6-12.6c0.138-0.478-0.141-0.976-0.619-1.11zm2.27 4.65 2.51 2.51-2.51 2.51c-0.352 0.352-0.352 0.923 0 1.27 0.352 0.352 0.923 0.352 1.27 0l3.15-3.15c0.352-0.352 0.352-0.923 0-1.27l-3.15-3.15c-0.352-0.352-0.923-0.352-1.27-0.00141-0.35 0.35-0.35 0.921 0.00141 1.27zm-8.63-1.27c-0.352-0.352-0.923-0.352-1.27 0l-3.15 3.15c-0.352 0.352-0.352 0.923 0 1.27l3.15 3.15c0.352 0.352 0.923 0.352 1.27 0 0.352-0.352 0.352-0.923 0-1.27l-2.51-2.51 2.51-2.51c0.352-0.352 0.352-0.923 0-1.27z"/></svg>'
},
{
type: 'menu_item',
label: 'Plain Text Mode',
id: 'menu-item-mode-plain',
cmd: () => this.setMode('plain'),
showInPlainMode: true,
icon: '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m2 2v3h1v-2h2v-1zm13 0v1h2v2h1v-3zm-9 3v2h3v8h2v-8h3v-2zm-4 10v3h3v-1h-2v-2zm15 0v2h-2v1h3v-3z"/></svg>'
}
]
}
];
// // clear: {
// // removeStyle: {
// // html: '⎚',
// // cmd: () => squire.setStyle()
// // }
// // }
dispatchEvent(new CustomEvent('squire2-toolbar', {
detail: {
squire: this,
actions: actions
}
}));
this.indicators = this.#addActionsToParent(actions, toolbar);
return actions;
}
#makeClr() {
/**@type {HTMLInputElement} clr*/
const clr = createElement('input');
clr.type = 'color';
// Chrome https://github.com/the-djmaze/snappymail/issues/1199
let clrid = 'squire-colors',
colorlist = doc.getElementById(clrid),
add = hex => colorlist.append(new Option(hex));
if (!colorlist) {
colorlist = createElement('datalist');
colorlist.id = clrid;
// Color blind safe Tableau 10 by Maureen Stone
add('#4E79A7');
add('#F28E2B');
add('#E15759');
add('#76B7B2');
add('#59A14F');
add('#EDC948');
add('#B07AA1');
add('#FF9DA7');
add('#9C755F');
add('#BAB0AC');
doc.body.append(colorlist);
}
clr.setAttribute('list', clrid);
return clr;
}
/**
* @param {Array} items
* @param {HTMLElement} parent
*/
#addActionsToParent(items, parent) {
const indicators = [];
items.forEach(item => {
let element, event;
switch (item.type) {
case 'group':
const group = createElement('div');
group.className = 'btn-group';
if (!item.showInPlainMode) {
group.className += ' squire-html-mode-item';
}
if (item.items) {
indicators.push(...this.#addActionsToParent(item.items, group));
}
parent.append(group);
return indicators;
case 'menu':
case 'menu_more':
const menuWrap = createElement('div');
menuWrap.className = 'btn-group dropdown squire-toolbar-menu-wrap';
menuWrap.title = item.label;
if (!item.showInPlainMode) {
menuWrap.className += ' squire-html-mode-item';
}
const menuBtn = createElement('button');
menuBtn.className = 'btn dropdown-toggle';
if (item.icon !== '') {
menuBtn.innerHTML = item.icon;
menuBtn.firstElementChild.setAttribute('class', 'squire-toolbar-svg-icon');
} else {
menuBtn.className += ' fontastic';
menuBtn.textContent = '☰';
}
menuWrap.appendChild(menuBtn);
const menu = createElement('ul');
menu.className = 'dropdown-menu squire-toolbar-menu';
if (item.rightEdge) {
menu.className += ' right-edge';
}
menu.setAttribute('role', 'menu');
if (item.items) {
indicators.push(...this.#addActionsToParent(item.items, menu));
}
menuWrap.appendChild(menu);
parent.append(menuWrap);
ko.applyBindingAccessorsToNode(menuWrap, { registerBootstrapDropdown: true });
item.element = menuWrap;
return indicators;
case 'move_parent':
// we only move into main composer not the signature composer
if (this.container.className.indexOf('e-signature-place') === -1) {
element = doc.getElementById(item.id);
if (element) {
element.className = 'btn';
if (item.icon) {
element.innerHTML = item.icon;
element.firstElementChild.setAttribute('class', 'squire-toolbar-svg-icon');
}
if (item.label) {
element.title = item.label;
}
element.parentElement.className += ' ' + item.id + '-parent';
parent.append(element.parentElement);
}
}
return [];
case 'button':
element = createElement('button');
element.type = 'button';
element.className = 'btn';
element.innerHTML = item.icon;
element.firstElementChild.setAttribute('class', 'squire-toolbar-svg-icon');
event = 'click';
break;
case 'select':
element = createElement('select');
element.className = 'btn';
element.innerHTML = item.icon;
event = 'input';
if (Array.isArray(item.items)) {
item.items.forEach(value => {
value = Array.isArray(value) ? value : [value, value];
const option = new Option(value[0], value[1]);
option.style[item.prop] = value[1];
element.append(option);
});
} else {
Object.entries(item.items).forEach(([label, options]) => {
const optgroup = createElement('optgroup');
optgroup.label = label;
Object.entries(options).forEach(([text, value]) => {
const option = new Option(text, value);
option.style[item.prop] = value;
optgroup.append(option);
});
element.append(optgroup);
});
}
if (item.defaultValueIndex) {
element.selectedIndex = item.defaultValueIndex;
}
item.element = element;
break;
case 'menu_item':
element = createElement('li');
element.className = 'squire-toolbar-menu-item';
if (!item.showInPlainMode) {
element.className += ' squire-html-mode-item';
}
element.innerHTML = item.icon + '<span>' + item.label + '</span>';
element.firstElementChild.setAttribute('class', 'squire-toolbar-svg-icon squire-toolbar-menu-item-icon');
event = 'click';
break;
}
element.title = item.label + (item.key ? ' (' + ctrlKey + item.key + ')' : '');
element.tabIndex = -1;
element.addEventListener(event, () => item.cmd(element));
if (item.id) {
element.id = item.id;
}
if (item.matches) {
indicators.push({
element: element,
selectors: item.matches.split(',')
});
}
parent.append(element);
});
return indicators;
}
/**
* Plugins might add their own pathChange listeners therefore they should
* use this utility function. @see example below
* @param {Object} eventDetail detail of pathChange event
* @returns {Map<any, any>}
*/
buildTokensMap(eventDetail) {
if (!eventDetail.tokensMap) {
const tokensMap = new Map();
if (eventDetail.path !== '(selection)') {
window.parsel.tokenize(eventDetail.path).forEach(token => {
if (token.type === 'type') {
// token.name is a tag like B, I, UL, etc...
tokensMap.set(token.name, '1');
} else if (token.name === 'fontFamily') {
// token.value can be a string like '"LucidaSansUnicode","DejaVuSans","BitstreamVeraSans",sans-serif'
tokensMap.set('__font_family__', token.value);
} else if (token.name === 'fontSize') {
// token.value can be a string like '24px' or 'Large'
tokensMap.set('__font_size__', token.value);
}
});
} else {
tokensMap.set('__selection__', '1');
}
eventDetail.tokensMap = tokensMap;
}
return eventDetail.tokensMap;
}
doAction(name, tag) {
if (tag && this.squire.hasFormat(tag)) {
// ex: bold -> removeBold
name = 'remove' + name.charAt(0).toUpperCase() + name.slice(1);
}
this.squire[name]();
}
doList(type) {
if (this.squire.hasFormat(type)) {
this.squire.removeList();
return;
}
if (type === 'UL') {
this.squire.makeUnorderedList();
} else if (type === 'OL') {
this.squire.makeOrderedList();
}
}
changeLevel(incDec) {
const type = ['UL', 'OL'].some(listTag => this.squire.hasFormat(listTag))
? 'List'
: 'Quote';
this.squire[incDec + type + 'Level']();
}
/*
testPresenceinSelection(format, validation) {
return validation.test(this.squire.getPath()) || this.squire.hasFormat(format);
}
*/
setMode(mode) {
if (this.mode !== mode) {
let cl = this.container.classList,
source = 'source' === this.mode;
cl.remove('squire2-mode-' + this.mode);
if ('plain' === mode) {
this.plain.value = htmlToPlain(source ? this.plain.value : this.squire.getHTML(), true);
this.toolbar.classList.add('mode-plain');
} else if ('source' === mode) {
this.plain.value = this.squire.getHTML();
this.toolbar.classList.add('mode-plain');
} else {
this.setData(source ? this.plain.value : plainToHtml(this.plain.value, true));
mode = 'wysiwyg';
this.toolbar.classList.remove('mode-plain');
}
doc.getElementById('menu-item-mode-' + this.mode)?.classList.remove('active');
doc.getElementById('menu-item-mode-' + mode).classList.add('active');
this.mode = mode;
cl.add('squire2-mode-' + mode);
this.onModeChange?.();
setTimeout(() => this.focus(), 1);
}
}
on(type, fn) {
if ('mode' === type) {
this.onModeChange = fn;
} else {
this.squire.addEventListener(type, fn);
this.plain.addEventListener(type, fn);
}
}
execCommand(cmd, cfg) {
if ('insertSignature' === cmd) {
cfg = Object.assign({
clearCache: false,
isHtml: false,
insertBefore: false,
signature: ''
}, cfg);
if (cfg.clearCache) {
this._prev_txt_sig = null;
} else try {
const signature = cfg.isHtml ? htmlToPlain(cfg.signature) : cfg.signature;
if ('plain' === this.mode) {
let
text = this.plain.value,
prevSignature = this._prev_txt_sig;
if (prevSignature) {
text = text.replace(prevSignature, '').trim();
}
this.plain.value = cfg.insertBefore ? '\n\n' + signature + '\n\n' + text : text + '\n\n' + signature;
} else {
const squire = this.squire,
root = squire.getRoot(),
div = createElement('div');
div.className = 'rl-signature';
div.innerHTML = cfg.isHtml ? cfg.signature : plainToHtml(cfg.signature);
root.querySelectorAll('div.rl-signature').forEach(node => node.remove());
cfg.insertBefore ? root.prepend(div) : root.append(div);
// Move cursor above signature
for (let i = 0; i < 2; i++) {
const divbr = createElement('div');
divbr.append(createElement('br'));
div.before(divbr);
}
}
this._prev_txt_sig = signature;
} catch (e) {
console.error(e);
}
}
}
getData() {
return 'source' === this.mode ? this.plain.value : trimLines(this.squire.getHTML());
}
setData(html) {
// this.plain.value = html;
const squire = this.squire;
squire.setHTML(trimLines(html));
const node = squire.getRoot(),
range = squire.getSelection();
range.setStart(node, 0);
range.setEnd(node, 0);
squire.setSelection(range);
}
getPlainData() {
return this.plain.value;
}
setPlainData(text) {
this.plain.value = text;
}
blur() {
this.squire.blur();
}
focus() {
if ('wysiwyg' === this.mode) {
this.squire.focus();
} else {
this.plain.focus();
this.plain.setSelectionRange(0, 0);
}
}
}
})(window);

View file

@ -0,0 +1,413 @@
var parsel = (function (exports) {
'use strict';
const TOKENS = {
attribute: /\[\s*(?:(?<namespace>\*|[-\w\P{ASCII}]*)\|)?(?<name>[-\w\P{ASCII}]+)\s*(?:(?<operator>\W?=)\s*(?<value>.+?)\s*(\s(?<caseSensitive>[iIsS]))?\s*)?\]/gu,
id: /#(?<name>[-\w\P{ASCII}]+)/gu,
class: /\.(?<name>[-\w\P{ASCII}]+)/gu,
comma: /\s*,\s*/g,
combinator: /\s*[\s>+~]\s*/g,
'pseudo-element': /::(?<name>[-\w\P{ASCII}]+)(?:\((?<argument>¶*)\))?/gu,
'pseudo-class': /:(?<name>[-\w\P{ASCII}]+)(?:\((?<argument>¶*)\))?/gu,
universal: /(?:(?<namespace>\*|[-\w\P{ASCII}]*)\|)?\*/gu,
type: /(?:(?<namespace>\*|[-\w\P{ASCII}]*)\|)?(?<name>[-\w\P{ASCII}]+)/gu, // this must be last
};
const TRIM_TOKENS = new Set(['combinator', 'comma']);
const RECURSIVE_PSEUDO_CLASSES = new Set([
'not',
'is',
'where',
'has',
'matches',
'-moz-any',
'-webkit-any',
'nth-child',
'nth-last-child',
]);
const nthChildRegExp = /(?<index>[\dn+-]+)\s+of\s+(?<subtree>.+)/;
const RECURSIVE_PSEUDO_CLASSES_ARGS = {
'nth-child': nthChildRegExp,
'nth-last-child': nthChildRegExp,
};
const getArgumentPatternByType = (type) => {
switch (type) {
case 'pseudo-element':
case 'pseudo-class':
return new RegExp(TOKENS[type].source.replace('(?<argument>¶*)', '(?<argument>.*)'), 'gu');
default:
return TOKENS[type];
}
};
function gobbleParens(text, offset) {
let nesting = 0;
let result = '';
for (; offset < text.length; offset++) {
const char = text[offset];
switch (char) {
case '(':
++nesting;
break;
case ')':
--nesting;
break;
}
result += char;
if (nesting === 0) {
return result;
}
}
return result;
}
function tokenizeBy(text, grammar = TOKENS) {
if (!text) {
return [];
}
const tokens = [text];
for (const [type, pattern] of Object.entries(grammar)) {
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (typeof token !== 'string') {
continue;
}
pattern.lastIndex = 0;
const match = pattern.exec(token);
if (!match) {
continue;
}
const from = match.index - 1;
const args = [];
const content = match[0];
const before = token.slice(0, from + 1);
if (before) {
args.push(before);
}
args.push({
...match.groups,
type,
content,
});
const after = token.slice(from + content.length + 1);
if (after) {
args.push(after);
}
tokens.splice(i, 1, ...args);
}
}
let offset = 0;
for (const token of tokens) {
switch (typeof token) {
case 'string':
throw new Error(`Unexpected sequence ${token} found at index ${offset}`);
case 'object':
offset += token.content.length;
token.pos = [offset - token.content.length, offset];
if (TRIM_TOKENS.has(token.type)) {
token.content = token.content.trim() || ' ';
}
break;
}
}
return tokens;
}
const STRING_PATTERN = /(['"])([^\\\n]+?)\1/g;
const ESCAPE_PATTERN = /\\./g;
function tokenize(selector, grammar = TOKENS) {
// Prevent leading/trailing whitespaces from being interpreted as combinators
selector = selector.trim();
if (selector === '') {
return [];
}
const replacements = [];
// Replace escapes with placeholders.
selector = selector.replace(ESCAPE_PATTERN, (value, offset) => {
replacements.push({ value, offset });
return '\uE000'.repeat(value.length);
});
// Replace strings with placeholders.
selector = selector.replace(STRING_PATTERN, (value, quote, content, offset) => {
replacements.push({ value, offset });
return `${quote}${'\uE001'.repeat(content.length)}${quote}`;
});
// Replace parentheses with placeholders.
{
let pos = 0;
let offset;
while ((offset = selector.indexOf('(', pos)) > -1) {
const value = gobbleParens(selector, offset);
replacements.push({ value, offset });
selector = `${selector.substring(0, offset)}(${'¶'.repeat(value.length - 2)})${selector.substring(offset + value.length)}`;
pos = offset + value.length;
}
}
// Now we have no nested structures and we can parse with regexes
const tokens = tokenizeBy(selector, grammar);
// Replace placeholders in reverse order.
const changedTokens = new Set();
for (const replacement of replacements.reverse()) {
for (const token of tokens) {
const { offset, value } = replacement;
if (!(token.pos[0] <= offset &&
offset + value.length <= token.pos[1])) {
continue;
}
const { content } = token;
const tokenOffset = offset - token.pos[0];
token.content =
content.slice(0, tokenOffset) +
value +
content.slice(tokenOffset + value.length);
if (token.content !== content) {
changedTokens.add(token);
}
}
}
// Update changed tokens.
for (const token of changedTokens) {
const pattern = getArgumentPatternByType(token.type);
if (!pattern) {
throw new Error(`Unknown token type: ${token.type}`);
}
pattern.lastIndex = 0;
const match = pattern.exec(token.content);
if (!match) {
throw new Error(`Unable to parse content for ${token.type}: ${token.content}`);
}
Object.assign(token, match.groups);
}
return tokens;
}
/**
* Convert a flat list of tokens into a tree of complex & compound selectors
*/
function nestTokens(tokens, { list = true } = {}) {
if (list && tokens.find((t) => t.type === 'comma')) {
const selectors = [];
const temp = [];
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type === 'comma') {
if (temp.length === 0) {
throw new Error('Incorrect comma at ' + i);
}
selectors.push(nestTokens(temp, { list: false }));
temp.length = 0;
}
else {
temp.push(tokens[i]);
}
}
if (temp.length === 0) {
throw new Error('Trailing comma');
}
else {
selectors.push(nestTokens(temp, { list: false }));
}
return { type: 'list', list: selectors };
}
for (let i = tokens.length - 1; i >= 0; i--) {
let token = tokens[i];
if (token.type === 'combinator') {
let left = tokens.slice(0, i);
let right = tokens.slice(i + 1);
return {
type: 'complex',
combinator: token.content,
left: nestTokens(left),
right: nestTokens(right),
};
}
}
switch (tokens.length) {
case 0:
throw new Error('Could not build AST.');
case 1:
// If we're here, there are no combinators, so it's just a list.
return tokens[0];
default:
return {
type: 'compound',
list: [...tokens], // clone to avoid pointers messing up the AST
};
}
}
/**
* Traverse an AST in depth-first order
*/
function* flatten(node,
/**
* @internal
*/
parent) {
switch (node.type) {
case 'list':
for (let child of node.list) {
yield* flatten(child, node);
}
break;
case 'complex':
yield* flatten(node.left, node);
yield* flatten(node.right, node);
break;
case 'compound':
yield* node.list.map((token) => [token, node]);
break;
default:
yield [node, parent];
}
}
/**
* Traverse an AST (or part thereof), in depth-first order
*/
function walk(node, visit,
/**
* @internal
*/
parent) {
if (!node) {
return;
}
for (const [token, ast] of flatten(node, parent)) {
visit(token, ast);
}
}
/**
* Parse a CSS selector
*
* @param selector - The selector to parse
* @param options.recursive - Whether to parse the arguments of pseudo-classes like :is(), :has() etc. Defaults to true.
* @param options.list - Whether this can be a selector list (A, B, C etc). Defaults to true.
*/
function parse(selector, { recursive = true, list = true } = {}) {
const tokens = tokenize(selector);
if (!tokens) {
return;
}
const ast = nestTokens(tokens, { list });
if (!recursive) {
return ast;
}
for (const [token] of flatten(ast)) {
if (token.type !== 'pseudo-class' || !token.argument) {
continue;
}
if (!RECURSIVE_PSEUDO_CLASSES.has(token.name)) {
continue;
}
let argument = token.argument;
const childArg = RECURSIVE_PSEUDO_CLASSES_ARGS[token.name];
if (childArg) {
const match = childArg.exec(argument);
if (!match) {
continue;
}
Object.assign(token, match.groups);
argument = match.groups['subtree'];
}
if (!argument) {
continue;
}
Object.assign(token, {
subtree: parse(argument, {
recursive: true,
list: true,
}),
});
}
return ast;
}
/**
* Converts the given list or (sub)tree to a string.
*/
function stringify(listOrNode) {
let tokens;
if (Array.isArray(listOrNode)) {
tokens = listOrNode;
}
else {
tokens = [...flatten(listOrNode)].map(([token]) => token);
}
return tokens.map(token => token.content).join('');
}
/**
* To convert the specificity array to a number
*/
function specificityToNumber(specificity, base) {
base = base || Math.max(...specificity) + 1;
return (specificity[0] * (base << 1) + specificity[1] * base + specificity[2]);
}
/**
* Calculate specificity of a selector.
*
* If the selector is a list, the max specificity is returned.
*/
function specificity(selector) {
let ast = selector;
if (typeof ast === 'string') {
ast = parse(ast, { recursive: true });
}
if (!ast) {
return [];
}
if (ast.type === 'list' && 'list' in ast) {
let base = 10;
const specificities = ast.list.map((ast) => {
const sp = specificity(ast);
base = Math.max(base, ...specificity(ast));
return sp;
});
const numbers = specificities.map((ast) => specificityToNumber(ast, base));
return specificities[numbers.indexOf(Math.max(...numbers))];
}
const ret = [0, 0, 0];
for (const [token] of flatten(ast)) {
switch (token.type) {
case 'id':
ret[0]++;
break;
case 'class':
case 'attribute':
ret[1]++;
break;
case 'pseudo-element':
case 'type':
ret[2]++;
break;
case 'pseudo-class':
if (token.name === 'where') {
break;
}
if (!RECURSIVE_PSEUDO_CLASSES.has(token.name) ||
!token.subtree) {
ret[1]++;
break;
}
const sub = specificity(token.subtree);
sub.forEach((s, i) => (ret[i] += s));
// :nth-child() & :nth-last-child() add (0, 1, 0) to the specificity of their most complex selector
if (token.name === 'nth-child' ||
token.name === 'nth-last-child') {
ret[1]++;
}
}
}
return ret;
}
exports.RECURSIVE_PSEUDO_CLASSES = RECURSIVE_PSEUDO_CLASSES;
exports.RECURSIVE_PSEUDO_CLASSES_ARGS = RECURSIVE_PSEUDO_CLASSES_ARGS;
exports.TOKENS = TOKENS;
exports.TRIM_TOKENS = TRIM_TOKENS;
exports.flatten = flatten;
exports.gobbleParens = gobbleParens;
exports.parse = parse;
exports.specificity = specificity;
exports.specificityToNumber = specificityToNumber;
exports.stringify = stringify;
exports.tokenize = tokenize;
exports.tokenizeBy = tokenizeBy;
exports.walk = walk;
Object.defineProperty(exports, '__esModule', { value: true });
return exports;
}({}));

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View file

@ -0,0 +1,201 @@
<header class="g-ui-user-select-none" data-bind="css: {loading: saving() || sending()}">
<a class="btn" data-bind="command: sendCommand, tooltipErrorTip: sendErrorDesc, css: {'btn-success': sendButtonSuccess, 'btn-danger': sendError, 'btn-warning': sendSuccessButSaveError }">
<i data-bind="css: {'icon-paper-plane': !sending(), 'icon-spinner': sending()}"></i>
<span class="hide-mobile" data-i18n="COMPOSE/BUTTON_SEND"></span>
</a>
<a class="btn button-save" data-bind="command: saveCommand, tooltipErrorTip: savedErrorDesc, css: {'btn-danger': savedError }">
<i class="fontastic" data-bind="css: {'icon-spinner': saving()}">💾</i>
<span class="hide-mobile" data-i18n="GLOBAL/SAVE"></span>
</a>
<a class="btn btn-danger button-delete fontastic" data-bind="command: deleteCommand">🗑</a>
<span class="saved-text hide-mobile" data-bind="text: savedTimeText"></span>
<div class="pull-right">
<a class="btn hide-mobile" data-i18n="GLOBAL/BCC" data-bind="visible: !showBcc(), toggle: showBcc"></a>
<a class="btn hide-mobile" data-i18n="GLOBAL/CC" data-bind="visible: !showCc(), toggle: showCc"></a>
<a class="btn fontastic" data-bind="visible: allowContacts, command: contactsCommand" data-i18n="[title]GLOBAL/CONTACTS">📇</a>
<div class="btn-group dropdown" data-bind="registerBootstrapDropdown: true" style="display:inline-block;vertical-align:top">
<a class="btn dropdown-toggle fontastic"></a>
<menu class="dropdown-menu right-edge" role="menu">
<li data-bind="toggle: showBcc">
<a>
<i class="fontastic" data-bind="text: showBcc() ? '☑' : '☐'"></i>
<span data-i18n="GLOBAL/BCC"></span>
</a>
</li>
<li data-bind="toggle: showCc">
<a>
<i class="fontastic" data-bind="text: showCc() ? '☑' : '☐'"></i>
<span data-i18n="GLOBAL/CC"></span>
</a>
</li>
<li data-bind="toggle: showReplyTo">
<a>
<i class="fontastic" data-bind="text: showReplyTo() ? '☑' : '☐'"></i>
<span data-i18n="GLOBAL/REPLY_TO"></span>
</a>
</li>
<li data-bind="toggle: requestReadReceipt">
<a>
<i class="fontastic" data-bind="text: requestReadReceipt() ? '☑' : '☐'"></i>
<span data-i18n="COMPOSE/BUTTON_REQUEST_READ_RECEIPT"></span>
</a>
</li>
<li data-bind="toggle: requestDsn">
<a>
<i class="fontastic" data-bind="text: requestDsn() ? '☑' : '☐'"></i>
<span data-i18n="COMPOSE/BUTTON_REQUEST_DSN"></span>
</a>
</li>
<li data-bind="toggle: requireTLS">
<a>
<i class="fontastic" data-bind="text: requireTLS() ? '☑' : '☐'"></i>
<span data-i18n="COMPOSE/BUTTON_REQUIRE_TLS"></span>
</a>
</li>
<li data-bind="toggle: markAsImportant">
<a>
<i class="fontastic" data-bind="text: markAsImportant() ? '☑' : '☐'"></i>
<span data-i18n="COMPOSE/BUTTON_MARK_AS_IMPORTANT"></span>
</a>
</li>
<li data-bind="toggle: doSign, visible: canSign">
<a>
<i class="fontastic" data-bind="text: doSign() ? '☑' : '☐'"></i>
<span data-icon="✍" data-i18n="CRYPTO/SIGN"></span>
</a>
</li>
<li data-bind="toggle: doEncrypt, visible: canEncrypt">
<a>
<i class="fontastic" data-bind="text: doEncrypt() || 'mailvelope' == viewArea() ? '☑' : '☐'"></i>
<span data-icon="🔒" data-i18n="CRYPTO/ENCRYPT"></span>
</a>
</li>
</menu>
</div>
<a class="minimize-custom" data-bind="click: skipCommand" data-i18n="[title]COMPOSE/BUTTON_MINIMIZE"></a>
<a class="close" data-bind="click: tryToClose" data-i18n="[title]GLOBAL/CANCEL">×</a>
</div>
</header>
<div class="modal-body">
<div class="b-header g-ui-user-select-none">
<table>
<tr>
<td data-i18n="GLOBAL/FROM"></td>
<td>
<!-- ko if: allowIdentities -->
<input type="text" data-bind="textInput: from" style="width:calc(100% - 20px)">
<!-- /ko -->
<span class="e-identity" data-bind="hidden: allowIdentities, text: from"></span>
<!-- ko if: 1 < identitiesOptions().length -->
<div class="dropdown" style="display:inline-block" data-bind="registerBootstrapDropdown: true, initDom: identitiesMenu">
<a class="dropdown-toggle" href="#" tabindex="-1" id="identity-toggle" role="button"></a>
<menu class="dropdown-menu right-edge" role="menu" aria-labelledby="identity-toggle" data-bind="foreach: identitiesOptions">
<li role="presentation">
<a tabindex="-1" href="#" data-bind="click: function (oIdentity) { $root.selectIdentity(oIdentity); return true; }, text: optText"></a>
</li>
</menu>
</div>
<!-- /ko -->
</td>
</tr>
<tr>
<td>
<label data-bind="css: {'error-to': emptyToError}, tooltipErrorTip: emptyToErrorTooltip"
data-i18n="GLOBAL/TO"></label>
</td>
<td>
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-bind="emailsTags: to, autoCompleteSource: emailsSource">
</td>
</tr>
<tr class="cc-row" data-bind="visible: showCc">
<td data-i18n="GLOBAL/CC"></td>
<td>
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-bind="emailsTags: cc, autoCompleteSource: emailsSource">
</td>
</tr>
<tr class="bcc-row" data-bind="visible: showBcc">
<td data-i18n="GLOBAL/BCC"></td>
<td>
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-bind="emailsTags: bcc, autoCompleteSource: emailsSource">
</td>
</tr>
<tr class="reply-to-row" data-bind="visible: showReplyTo">
<td data-i18n="GLOBAL/REPLY_TO"></td>
<td>
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-bind="emailsTags: replyTo">
</td>
</tr>
<tr>
<td data-i18n="GLOBAL/SUBJECT"></td>
<td>
<input type="text" name="subject" autocomplete="off" data-bind="textInput: subject, attr:{spellcheck:allowSpellcheck()?'true':'false'}">
</td>
</tr>
</table>
<div style="display:flex">
<div class="btn-group" style="flex-grow:1"></div>
<div class="btn-group">
<a class="btn fontastic" data-bind="toggle: doSign, visible: canSign, css: {'btn-success': doSign()}" data-i18n="[title]CRYPTO/SIGN">
</a>
<a class="btn fontastic" data-bind="toggle: doEncrypt, visible: canEncrypt, css: {'btn-success': doEncrypt() || 'mailvelope' == viewArea()}" data-i18n="[title]CRYPTO/ENCRYPT">
🔒
</a>
</div>
<div class="btn-group">
<button class="btn fontastic" id="composeUploadButton"
data-bind="visible: addAttachmentEnabled()" data-i18n="[title]COMPOSE/ATTACH_FILES">
⁺📎
</button>
</div>
</div>
</div>
<div class="tabs">
<input type="radio" name="tabs" value="body" id="tab-body" data-bind="checked: viewArea">
<label for="tab-body"
role="tab"
aria-selected="true"
aria-controls="panel1"
data-bind="visible: canMailvelope"
tabindex="0">
<i class="icon-file-text"></i>
<span data-i18n="GLOBAL/TEXT"></span>
</label>
<div class="tab-content" role="tabpanel" aria-hidden="false" style="grid-column-end:3">
<div class="textAreaParent" data-bind="initDom: editorArea, attr:{spellcheck:allowSpellcheck()?'true':'false'}"></div>
</div>
<input type="radio" name="tabs" value="mailvelope" id="tab-mailvelope" data-bind="checked: viewArea">
<label for="tab-mailvelope"
role="tab"
aria-selected="false"
aria-controls="panel3"
tabindex="0"
data-bind="visible: canMailvelope">
<i class="mailvelope-icon"></i>
<span>Mailvelope</span>
</label>
<div class="tab-content textAreaParent" id="mailvelope-editor" role="tabpanel" aria-hidden="true" style="grid-column-end:3" data-bind="visible: canMailvelope"></div>
</div>
</div>
<div class="attachmentAreaParent compact">
<div class="b-attachment-place" data-bind="visible: addAttachmentEnabled(), css: {dragAndDropOver: dragAndDropVisible}"
data-i18n="COMPOSE/ATTACH_DROP_FILES_DESC"></div>
<ul class="attachmentList" data-bind="foreach: attachments">
<li class="attachmentItem" data-bind="attr: { title: title }, css: { waiting: waiting, error: '' !== error() }">
<div class="attachmentIcon">
<i class="iconMain" data-bind="css: iconClass, visible: !uploading() || 0 === progress()"></i>
<div class="iconProgress" data-bind="attr: { style: progressStyle }, visible: uploading"></div>
<div class="iconBG" data-bind="text: progressText, visible: uploading"></div>
</div>
<div class="attachmentNameParent">
<a href="#" class="close pull-right" style="margin-top:-4px;" data-bind="click: cancel">×</a>
<div class="attachmentName" data-bind="text: fileName"></div>
<span class="attachmentSize" data-bind="text: friendlySize"></span>
</div>
</li>
</ul>
</div>

View file

@ -328,6 +328,12 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
$this->Config()->Get('plugin', 'ignoreSystemAddressbook', true) $this->Config()->Get('plugin', 'ignoreSystemAddressbook', true)
); );
} }
/*
if ($this->Config()->Get('plugin', 'storage', false) && ('storage' === $sName || 'storage-local' === $sName)) {
require_once __DIR__ . '/storage.php';
$oDriver = new \NextcloudStorage(APP_PRIVATE_DATA.'storage', $sName === 'storage-local');
}
*/
} }
} }
@ -340,6 +346,11 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
\RainLoop\Plugins\Property::NewInstance('ignoreSystemAddressbook')->SetLabel('Ignore system addressbook') \RainLoop\Plugins\Property::NewInstance('ignoreSystemAddressbook')->SetLabel('Ignore system addressbook')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(true), ->SetDefaultValue(true),
/*
\RainLoop\Plugins\Property::NewInstance('storage')->SetLabel('Use Nextcloud user ID in config storage path')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(false)
*/
\RainLoop\Plugins\Property::NewInstance('calendar')->SetLabel('Enable "Put ICS in calendar"') \RainLoop\Plugins\Property::NewInstance('calendar')->SetLabel('Enable "Put ICS in calendar"')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(false) ->SetDefaultValue(false)

View file

@ -0,0 +1,22 @@
<?php
use RainLoop\Providers\Storage\Enumerations\StorageType;
class NextcloudStorage extends \RainLoop\Providers\Storage\FileStorage
{
/**
* @param \RainLoop\Model\Account|string|null $mAccount
*/
public function GenerateFilePath($mAccount, int $iStorageType, bool $bMkDir = false) : string
{
$sDataPath = parent::GenerateFilePath($mAccount, $iStorageType, $bMkDir);
if (StorageType::CONFIG === $iStorageType) {
$sUID = \OC::$server->getUserSession()->getUser()->getUID();
$sDataPath .= ".config/{$sUID}/";
if ($bMkDir && !\is_dir($sDataPath) && !\mkdir($sDataPath, 0700, true)) {
throw new \RainLoop\Exceptions\Exception('Can\'t make storage directory "'.$sDataPath.'"');
}
}
return $sDataPath;
}
}

View file

@ -4,35 +4,31 @@ class WhiteListPlugin extends \RainLoop\Plugins\AbstractPlugin
{ {
const const
NAME = 'Whitelist', NAME = 'Whitelist',
VERSION = '2.1', VERSION = '2.2',
RELEASE = '2021-04-21', RELEASE = '2024-03-04',
REQUIRED = '2.5.0', REQUIRED = '2.5.0',
CATEGORY = 'Login', CATEGORY = 'Login',
DESCRIPTION = 'Simple login whitelist (with wildcard and exceptions functionality).'; DESCRIPTION = 'Simple login whitelist (with wildcard and exceptions functionality).';
public function Init() : void public function Init() : void
{ {
$this->addHook('login.credentials', 'FilterLoginCredentials'); $this->addHook('login.credentials.step-1', 'FilterLoginCredentials');
} }
/** /**
* @param string $sEmail
* @param string $sLogin
* @param string $sPassword
*
* @throws \RainLoop\Exceptions\ClientException * @throws \RainLoop\Exceptions\ClientException
*/ */
public function FilterLoginCredentials(&$sEmail, &$sLogin, &$sPassword) public function FilterLoginCredentials(string &$sEmail)
{ {
$sWhiteList = \trim($this->Config()->Get('plugin', 'white_list', '')); $sWhiteList = \trim($this->Config()->Get('plugin', 'white_list', ''));
if (0 < strlen($sWhiteList) && !\RainLoop\Plugins\Helper::ValidateWildcardValues($sEmail, $sWhiteList)) if (\strlen($sWhiteList) && !\RainLoop\Plugins\Helper::ValidateWildcardValues($sEmail, $sWhiteList)) {
{
$sExceptions = \trim($this->Config()->Get('plugin', 'exceptions', '')); $sExceptions = \trim($this->Config()->Get('plugin', 'exceptions', ''));
if (0 === \strlen($sExceptions) || !\RainLoop\Plugins\Helper::ValidateWildcardValues($sEmail, $sExceptions)) if (!\strlen($sExceptions) || \RainLoop\Plugins\Helper::ValidateWildcardValues($sEmail, $sExceptions)) {
{
throw new \RainLoop\Exceptions\ClientException( throw new \RainLoop\Exceptions\ClientException(
$this->Config()->Get('plugin', 'auth_error', true) ? $this->Config()->Get('plugin', 'auth_error', false)
\RainLoop\Notifications::AuthError : \RainLoop\Notifications::AccountNotAllowed); ? \RainLoop\Notifications::AuthError
: \RainLoop\Notifications::AccountNotAllowed
);
} }
} }
} }
@ -46,7 +42,7 @@ class WhiteListPlugin extends \RainLoop\Plugins\AbstractPlugin
\RainLoop\Plugins\Property::NewInstance('auth_error')->SetLabel('Auth Error') \RainLoop\Plugins\Property::NewInstance('auth_error')->SetLabel('Auth Error')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDescription('Throw an authentication error instead of an access error.') ->SetDescription('Throw an authentication error instead of an access error.')
->SetDefaultValue(true), ->SetDefaultValue(false),
\RainLoop\Plugins\Property::NewInstance('white_list')->SetLabel('White List') \RainLoop\Plugins\Property::NewInstance('white_list')->SetLabel('White List')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT)
->SetDescription('Emails white list, space as delimiter, wildcard supported.') ->SetDescription('Emails white list, space as delimiter, wildcard supported.')

View file

@ -213,7 +213,7 @@ class BodyStructure implements \JsonSerializable
if (!$aParts) { if (!$aParts) {
$gEncryptedParts = $this->SearchByContentType('multipart/encrypted'); $gEncryptedParts = $this->SearchByContentType('multipart/encrypted');
foreach ($gEncryptedParts as $oPart) { foreach ($gEncryptedParts as $oPart) {
if ($oPart->isPgpEncrypted() && $oPart->SubParts()[1]->isInline()) { if ($oPart->isPgpEncrypted()) {
return array($oPart->SubParts()[1]); return array($oPart->SubParts()[1]);
} }
} }

View file

@ -132,6 +132,26 @@ trait Messages
return $aReturn; return $aReturn;
} }
public function FetchMessagePart(int $iUid, string $sPartId) : string
{
if ('TEXT' === $sPartId) {
$oFetchResponse = $this->Fetch([
FetchType::BODY_PEEK.'['.$sPartId.']',
FetchType::BODY_HEADER_PEEK
], $iUid, true)[0];
$sHeader = $oFetchResponse->GetFetchValue(FetchType::BODY_HEADER);
} else {
$oFetchResponse = $this->Fetch([
FetchType::BODY_PEEK.'['.$sPartId.']',
// An empty section specification refers to the entire message, including the header.
// But Dovecot does not return it with BODY.PEEK[1], so we also use BODY.PEEK[1.MIME].
FetchType::BODY_PEEK.'['.$sPartId.'.MIME]'
], $iUid, true)[0];
$sHeader = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sPartId.'.MIME]');
}
return $sHeader . $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sPartId.']');
}
/** /**
* Appends message to specified folder * Appends message to specified folder
* *

View file

@ -50,8 +50,8 @@ trait Quota
if (!$this->hasCapability('QUOTA')) { if (!$this->hasCapability('QUOTA')) {
return null; return null;
} }
$aReturn = array(0, 0, 0, 0);
$oResponseCollection = $this->SendRequest(($root?'GETQUOTAROOT':'GETQUOTA') . " {$this->EscapeFolderName($sFolderName)}"); $oResponseCollection = $this->SendRequest(($root?'GETQUOTAROOT':'GETQUOTA') . " {$this->EscapeFolderName($sFolderName)}");
$aReturn = array(0, 0);
foreach ($this->yieldUntaggedResponses() as $oResponse) { foreach ($this->yieldUntaggedResponses() as $oResponse) {
if ('QUOTA' === $oResponse->StatusOrIndex if ('QUOTA' === $oResponse->StatusOrIndex
&& isset($oResponse->ResponseList[3]) && isset($oResponse->ResponseList[3])

View file

@ -126,7 +126,6 @@ class MailClient
$this->oImapClient->FolderExamine($sFolderName); $this->oImapClient->FolderExamine($sFolderName);
$oBodyStructure = null; $oBodyStructure = null;
$oMessage = null;
$aFetchItems = array( $aFetchItems = array(
FetchType::UID, FetchType::UID,
@ -169,11 +168,10 @@ class MailClient
} }
$aFetchResponse = $this->oImapClient->Fetch($aFetchItems, $iIndex, $bIndexIsUid); $aFetchResponse = $this->oImapClient->Fetch($aFetchItems, $iIndex, $bIndexIsUid);
if (\count($aFetchResponse)) {
$oMessage = Message::fromFetchResponse($sFolderName, $aFetchResponse[0], $oBodyStructure);
}
return $oMessage; return \count($aFetchResponse)
? Message::fromFetchResponse($sFolderName, $aFetchResponse[0], $oBodyStructure)
: null;
} }
/** /**

View file

@ -66,10 +66,10 @@ class Message implements \JsonSerializable
private ?array $DraftInfo = null; private ?array $DraftInfo = null;
private ?array $pgpSigned = null; public ?array $pgpSigned = null;
private ?array $pgpEncrypted = null; private ?array $pgpEncrypted = null;
private ?array $smimeSigned = null; public ?array $smimeSigned = null;
private ?array $smimeEncrypted = null; private ?array $smimeEncrypted = null;
private ?\MailSo\Mime\EmailCollection private ?\MailSo\Mime\EmailCollection
@ -341,7 +341,7 @@ class Message implements \JsonSerializable
$oMessage->pgpSigned = [ $oMessage->pgpSigned = [
// /?/Raw/&q[]=/0/Download/&q[]=/... // /?/Raw/&q[]=/0/Download/&q[]=/...
// /?/Raw/&q[]=/0/View/&q[]=/... // /?/Raw/&q[]=/0/View/&q[]=/...
'bodyPartId' => $oPart->SubParts()[0]->PartID(), 'partId' => $oPart->SubParts()[0]->PartID(),
'sigPartId' => $oPart->SubParts()[1]->PartID(), 'sigPartId' => $oPart->SubParts()[1]->PartID(),
'micAlg' => $oHeaders ? (string) $oHeaders->ParameterValue(MimeHeader::CONTENT_TYPE, 'micalg') : '' 'micAlg' => $oHeaders ? (string) $oHeaders->ParameterValue(MimeHeader::CONTENT_TYPE, 'micalg') : ''
]; ];
@ -357,9 +357,9 @@ class Message implements \JsonSerializable
// An empty section specification refers to the entire message, including the header. // An empty section specification refers to the entire message, including the header.
// But Dovecot does not return it with BODY.PEEK[1], so we also use BODY.PEEK[1.MIME]. // But Dovecot does not return it with BODY.PEEK[1], so we also use BODY.PEEK[1.MIME].
$sPgpText = \trim( $sPgpText = \trim(
\trim($oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oMessage->pgpSigned['bodyPartId'].'.MIME]')) \trim($oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oMessage->pgpSigned['partId'].'.MIME]'))
. "\r\n\r\n" . "\r\n\r\n"
. \trim($oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oMessage->pgpSigned['bodyPartId'].']')) . \trim($oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oMessage->pgpSigned['partId'].']'))
); );
if ($sPgpText) { if ($sPgpText) {
$oMessage->pgpSigned['body'] = $sPgpText; $oMessage->pgpSigned['body'] = $sPgpText;
@ -398,7 +398,7 @@ class Message implements \JsonSerializable
// Cleartext Signature // Cleartext Signature
if (!$oMessage->pgpSigned && \str_contains($sText, '-----BEGIN PGP SIGNED MESSAGE-----')) { if (!$oMessage->pgpSigned && \str_contains($sText, '-----BEGIN PGP SIGNED MESSAGE-----')) {
$oMessage->pgpSigned = [ $oMessage->pgpSigned = [
'bodyPartId' => $oPart->PartID() 'partId' => $oPart->PartID()
]; ];
} }
@ -543,7 +543,7 @@ class Message implements \JsonSerializable
$result['html'] = $this->sHtml; $result['html'] = $this->sHtml;
$result['plain'] = $this->sPlain; $result['plain'] = $this->sPlain;
} }
// $this->GetCapa(Capa::OPEN_PGP) || $this->GetCapa(Capa::GNUPG) // $this->GetCapa(Capa::OPENPGP) || $this->GetCapa(Capa::GNUPG)
if ($this->pgpSigned) { if ($this->pgpSigned) {
$result['pgpSigned'] = $this->pgpSigned; $result['pgpSigned'] = $this->pgpSigned;
} }

View file

@ -16,6 +16,8 @@ namespace MailSo\Mime\Enumerations;
* @package Mime * @package Mime
* @subpackage Enumerations * @subpackage Enumerations
*/ */
//enum DkimStatus:string
//final class DkimStatus extends \BackedEnumString
abstract class DkimStatus abstract class DkimStatus
{ {
const NONE = 'none'; const NONE = 'none';

View file

@ -27,8 +27,7 @@ abstract class ConnectionSecurityType
{ {
$iPort = (int) $iPort; $iPort = (int) $iPort;
$iResult = (int) $iSecurityType; $iResult = (int) $iSecurityType;
if (self::AUTO_DETECT === $iSecurityType) if (self::AUTO_DETECT === $iSecurityType) {
{
switch (true) switch (true)
{ {
case 993 === $iPort: case 993 === $iPort:
@ -39,8 +38,7 @@ abstract class ConnectionSecurityType
} }
} }
if (self::SSL === $iResult && !\in_array('ssl', \stream_get_transports())) if (self::SSL === $iResult && !\in_array('ssl', \stream_get_transports())) {
{
$iResult = self::NONE; $iResult = self::NONE;
} }

View file

@ -184,7 +184,12 @@ class SieveClient extends \MailSo\Net\NetClient
public function Logout() : void public function Logout() : void
{ {
if ($this->bIsLoggined) { if ($this->bIsLoggined) {
$this->sendRequestWithCheck('LOGOUT'); try {
$this->sendRequestWithCheck('LOGOUT');
} catch (\Throwable $e) {
// https://github.com/the-djmaze/snappymail/issues/1455
$this->writeLogException($e, \LOG_WARNING, false);
}
$this->bIsLoggined = false; $this->bIsLoggined = false;
} }
} }
@ -408,6 +413,7 @@ class SieveClient extends \MailSo\Net\NetClient
if (null === $sResponseBuffer) { if (null === $sResponseBuffer) {
break; break;
} }
// \MailSo\Imap\Enumerations\ResponseStatus
$bEnd = \in_array(\substr($sResponseBuffer, 0, 2), array('OK', 'NO')); $bEnd = \in_array(\substr($sResponseBuffer, 0, 2), array('OK', 'NO'));
// convertEndOfLine // convertEndOfLine
$sLine = \trim($sResponseBuffer); $sLine = \trim($sResponseBuffer);

View file

@ -2,6 +2,9 @@
namespace RainLoop; namespace RainLoop;
use RainLoop\Enumerations\Capa;
use RainLoop\Enumerations\SignMeType;
class Actions class Actions
{ {
use Actions\Admin; use Actions\Admin;
@ -471,7 +474,7 @@ class Actions
$oDriver = null; $oDriver = null;
try { try {
// if ($this->oConfig->Get('contacts', 'enable', false)) { // if ($this->oConfig->Get('contacts', 'enable', false)) {
if ($this->GetCapa(Enumerations\Capa::CONTACTS)) { if ($this->GetCapa(Capa::CONTACTS)) {
$oDriver = $this->fabrica('address-book', $oAccount); $oDriver = $this->fabrica('address-book', $oAccount);
} }
if ($oAccount && $oDriver) { if ($oAccount && $oDriver) {
@ -666,7 +669,7 @@ class Actions
$sPassword = \substr(\base64_encode(\random_bytes(16)), 0, 12); $sPassword = \substr(\base64_encode(\random_bytes(16)), 0, 12);
Utils::saveFile($passfile, $sPassword . "\n"); Utils::saveFile($passfile, $sPassword . "\n");
// \chmod($passfile, 0600); // \chmod($passfile, 0600);
$oConfig->SetPassword($sPassword); $oConfig->SetPassword(new \SnappyMail\SensitiveString($sPassword));
$oConfig->Save(); $oConfig->Save();
} }
} }
@ -712,7 +715,7 @@ class Actions
]); ]);
$aAttachmentsActions = array(); $aAttachmentsActions = array();
if ($this->GetCapa(Enumerations\Capa::ATTACHMENTS_ACTIONS)) { if ($this->GetCapa(Capa::ATTACHMENTS_ACTIONS)) {
if (\class_exists('PharData') || \class_exists('ZipArchive')) { if (\class_exists('PharData') || \class_exists('ZipArchive')) {
$aAttachmentsActions[] = 'zip'; $aAttachmentsActions[] = 'zip';
} }
@ -809,7 +812,7 @@ class Actions
$aResult['fontSerif'] = $oSettings->GetConf('fontSerif', ''); $aResult['fontSerif'] = $oSettings->GetConf('fontSerif', '');
$aResult['fontMono'] = $oSettings->GetConf('fontMono', ''); $aResult['fontMono'] = $oSettings->GetConf('fontMono', '');
if ($this->GetCapa(Enumerations\Capa::USER_BACKGROUND)) { if ($this->GetCapa(Capa::USER_BACKGROUND)) {
$aResult['userBackgroundName'] = (string)$oSettings->GetConf('UserBackgroundName', ''); $aResult['userBackgroundName'] = (string)$oSettings->GetConf('UserBackgroundName', '');
$aResult['userBackgroundHash'] = (string)$oSettings->GetConf('UserBackgroundHash', ''); $aResult['userBackgroundHash'] = (string)$oSettings->GetConf('UserBackgroundHash', '');
} }
@ -832,12 +835,17 @@ class Actions
$aResult['DevPassword'] = ''; $aResult['DevPassword'] = '';
} }
$aResult['signMe'] = (string) $oConfig->Get('login', 'sign_me_auto', Enumerations\SignMeType::DEFAULT_OFF); $aResult['signMe'] = [
SignMeType::DefaultOff => 0,
SignMeType::DefaultOn => 1,
SignMeType::Unused => 2
][(string) $oConfig->Get('login', 'sign_me_auto', SignMeType::DefaultOff)];
} }
} }
if ($aResult['Auth']) { if ($aResult['Auth']) {
$aResult['useLocalProxyForExternalImages'] = (bool)$oConfig->Get('labs', 'use_local_proxy_for_external_images', false); $aResult['useLocalProxyForExternalImages'] = (bool)$oConfig->Get('labs', 'use_local_proxy_for_external_images', false);
$aResult['autoVerifySignatures'] = (bool)$oConfig->Get('security', 'auto_verify_signatures', false);
$aResult['allowLanguagesOnSettings'] = (bool) $oConfig->Get('webmail', 'allow_languages_on_settings', true); $aResult['allowLanguagesOnSettings'] = (bool) $oConfig->Get('webmail', 'allow_languages_on_settings', true);
$aResult['Capa'] = $this->Capa($bAdmin, $oAccount); $aResult['Capa'] = $this->Capa($bAdmin, $oAccount);
$value = \ini_get('upload_max_filesize'); $value = \ini_get('upload_max_filesize');
@ -971,27 +979,26 @@ class Actions
if (!$aResult) { if (!$aResult) {
$oConfig = $this->oConfig; $oConfig = $this->oConfig;
$aResult = array( $aResult = array(
'AdditionalAccounts' => (bool) $oConfig->Get('webmail', 'allow_additional_accounts', false), Capa::ADDITIONAL_ACCOUNTS => (bool) $oConfig->Get('webmail', 'allow_additional_accounts', false),
'AttachmentThumbnails' => (bool) $oConfig->Get('interface', 'show_attachment_thumbnail', true) Capa::ATTACHMENT_THUMBNAILS => (bool) $oConfig->Get('interface', 'show_attachment_thumbnail', true)
&& ($bAdmin && ($bAdmin
|| \extension_loaded('gd') || \extension_loaded('gd')
|| \extension_loaded('gmagick') || \extension_loaded('gmagick')
|| \extension_loaded('imagick') || \extension_loaded('imagick')
), ),
'AttachmentsActions' => (bool) $oConfig->Get('capa', 'attachments_actions', false), Capa::ATTACHMENTS_ACTIONS => (bool) $oConfig->Get('capa', 'attachments_actions', false),
'Contacts' => (bool) $oConfig->Get('contacts', 'enable', false), Capa::CONTACTS => (bool) $oConfig->Get('contacts', 'enable', false),
'DangerousActions' => (bool) $oConfig->Get('capa', 'dangerous_actions', true), Capa::DANGEROUS_ACTIONS => (bool) $oConfig->Get('capa', 'dangerous_actions', true),
'GnuPG' => (bool) $oConfig->Get('security', 'openpgp', false) && \SnappyMail\PGP\GnuPG::isSupported(), Capa::GNUPG => (bool) $oConfig->Get('security', 'openpgp', false) && \SnappyMail\PGP\GnuPG::isSupported(),
'Identities' => (bool) $oConfig->Get('webmail', 'allow_additional_identities', false), Capa::IDENTITIES => (bool) $oConfig->Get('webmail', 'allow_additional_identities', false),
'Kolab' => false, // See Kolab plugin Capa::OPENPGP => (bool) $oConfig->Get('security', 'openpgp', false),
'OpenPGP' => (bool) $oConfig->Get('security', 'openpgp', false), Capa::SIEVE => false,
'Quota' => (bool) $oConfig->Get('capa', 'quota', true), Capa::THEMES => (bool) $oConfig->Get('webmail', 'allow_themes', false),
'Sieve' => false, Capa::USER_BACKGROUND => (bool) $oConfig->Get('webmail', 'allow_user_background', false),
'Themes' => (bool) $oConfig->Get('webmail', 'allow_themes', false), 'Kolab' => false, // See Kolab plugin
'UserBackground' => (bool) $oConfig->Get('webmail', 'allow_user_background', false)
); );
} }
$aResult['Sieve'] = $bAdmin || ($oAccount && $oAccount->Domain()->UseSieve()); $aResult[Capa::SIEVE] = $bAdmin || ($oAccount && $oAccount->Domain()->UseSieve());
return $aResult; return $aResult;
} }

View file

@ -81,7 +81,7 @@ trait Accounts
$aAccounts = $this->GetAccounts($oMainAccount); $aAccounts = $this->GetAccounts($oMainAccount);
$sEmail = \trim($this->GetActionParam('email', '')); $sEmail = \trim($this->GetActionParam('email', ''));
$sPassword = $this->GetActionParam('password', ''); $oPassword = new \SnappyMail\SensitiveString($this->GetActionParam('password', ''));
$sName = \trim($this->GetActionParam('name', '')); $sName = \trim($this->GetActionParam('name', ''));
$bNew = !empty($this->GetActionParam('new', 1)); $bNew = !empty($this->GetActionParam('new', 1));
@ -92,8 +92,8 @@ trait Accounts
throw new ClientException(Notifications::AccountDoesNotExist); throw new ClientException(Notifications::AccountDoesNotExist);
} }
if ($bNew || $sPassword) { if ($bNew || \strlen($oPassword)) {
$oNewAccount = $this->LoginProcess($sEmail, $sPassword, false); $oNewAccount = $this->LoginProcess($sEmail, $oPassword, false);
$aAccounts[$sEmail] = $oNewAccount->asTokenArray($oMainAccount); $aAccounts[$sEmail] = $oNewAccount->asTokenArray($oMainAccount);
} else { } else {
$aAccounts[$sEmail] = \RainLoop\Model\AdditionalAccount::convertArray($aAccounts[$sEmail]); $aAccounts[$sEmail] = \RainLoop\Model\AdditionalAccount::convertArray($aAccounts[$sEmail]);
@ -261,7 +261,11 @@ trait Accounts
if (!$oIdentity->FromJSON($this->GetActionParams(), true)) { if (!$oIdentity->FromJSON($this->GetActionParams(), true)) {
throw new ClientException(Notifications::InvalidInputArgument); throw new ClientException(Notifications::InvalidInputArgument);
} }
/* // TODO: verify private key for certificate?
if ($oIdentity->smimeCertificate && $oIdentity->smimeKey) {
new \SnappyMail\SMime\Certificate($oIdentity->smimeCertificate, $oIdentity->smimeKey);
}
*/
$this->IdentitiesProvider()->UpdateIdentity($oAccount, $oIdentity); $this->IdentitiesProvider()->UpdateIdentity($oAccount, $oIdentity);
return $this->TrueResponse(); return $this->TrueResponse();
} }

View file

@ -60,9 +60,9 @@ trait AdminDomains
public function DoAdminDomainMatch() : array public function DoAdminDomainMatch() : array
{ {
$sEmail = $this->GetActionParam('username'); $sEmail = $this->GetActionParam('username');
$sPassword = '********'; $oPassword = new \SnappyMail\SensitiveString('********');
$sLogin = ''; $sLogin = '';
$this->resolveLoginCredentials($sEmail, $sPassword, $sLogin); $this->resolveLoginCredentials($sEmail, $oPassword, $sLogin);
$oDomain = \str_contains($sEmail, '@') $oDomain = \str_contains($sEmail, '@')
? $this->DomainProvider()->Load(\MailSo\Base\Utils::GetDomainFromEmail($sEmail), true) ? $this->DomainProvider()->Load(\MailSo\Base\Utils::GetDomainFromEmail($sEmail), true)
: null; : null;

View file

@ -86,14 +86,11 @@ trait Folders
} }
if ($oFolderCollection) { if ($oFolderCollection) {
$aQuota = null; try {
if ($this->GetCapa(Capa::QUOTA)) { // $aQuota = $this->ImapClient()->Quota();
try { $aQuota = $this->ImapClient()->QuotaRoot();
// $aQuota = $this->ImapClient()->Quota(); } catch (\Throwable $oException) {
$aQuota = $this->ImapClient()->QuotaRoot(); // ignore
} catch (\Throwable $oException) {
// ignore
}
} }
$aCapabilities = \array_values(\array_filter($this->ImapClient()->Capability() ?: [], function ($item) { $aCapabilities = \array_values(\array_filter($this->ImapClient()->Capability() ?: [], function ($item) {
@ -192,7 +189,7 @@ trait Folders
$oSettingsLocal->SetConf('CheckableFolder', \json_encode(\array_unique($aCheckableFolder))); $oSettingsLocal->SetConf('CheckableFolder', \json_encode(\array_unique($aCheckableFolder)));
return $this->DefaultResponse($this->SettingsProvider(true)->Save($oAccount, $oSettingsLocal)); return $this->DefaultResponse($oSettingsLocal->save());
} }
/** /**
@ -320,7 +317,7 @@ trait Folders
$oSettingsLocal->SetConf('TrashFolder', $this->GetActionParam('trash', '')); $oSettingsLocal->SetConf('TrashFolder', $this->GetActionParam('trash', ''));
$oSettingsLocal->SetConf('ArchiveFolder', $this->GetActionParam('archive', '')); $oSettingsLocal->SetConf('ArchiveFolder', $this->GetActionParam('archive', ''));
return $this->DefaultResponse($this->SettingsProvider(true)->Save($oAccount, $oSettingsLocal)); return $this->DefaultResponse($oSettingsLocal->save());
} }
public function DoFolderACL() : array public function DoFolderACL() : array

View file

@ -7,6 +7,7 @@ use RainLoop\Exceptions\ClientException;
use RainLoop\Model\Account; use RainLoop\Model\Account;
use RainLoop\Notifications; use RainLoop\Notifications;
use MailSo\Imap\SequenceSet; use MailSo\Imap\SequenceSet;
use MailSo\Imap\Enumerations\FetchType;
use MailSo\Imap\Enumerations\MessageFlag; use MailSo\Imap\Enumerations\MessageFlag;
use MailSo\Mime\Part as MimePart; use MailSo\Mime\Part as MimePart;
use MailSo\Mime\Enumerations\Header as MimeEnumHeader; use MailSo\Mime\Enumerations\Header as MimeEnumHeader;
@ -76,7 +77,7 @@ trait Messages
$oParams->oCacher = $this->Cacher($oAccount); $oParams->oCacher = $this->Cacher($oAccount);
} }
// $oParams->bUseSort = $this->ImapClient->hasCapability('SORT'); // $oParams->bUseSort = $this->ImapClient()->hasCapability('SORT');
$oParams->bUseSort = true; $oParams->bUseSort = true;
$oSettingsLocal = $this->SettingsProvider(true)->Load($oAccount); $oSettingsLocal = $this->SettingsProvider(true)->Load($oAccount);
@ -455,6 +456,65 @@ trait Messages
try try
{ {
$oMessage = $this->MailClient()->Message($sFolder, $iUid, true, $this->Cacher($oAccount)); $oMessage = $this->MailClient()->Message($sFolder, $iUid, true, $this->Cacher($oAccount));
$bAutoVerify = $this->Config()->Get('security', 'auto_verify_signatures', false);
// S/MIME signed. Verify it, so we have the raw mime body to show
if ($oMessage->smimeSigned && ($bAutoVerify || !$oMessage->smimeSigned['detached'])) try {
$bOpaque = !$oMessage->smimeSigned['detached'];
$sBody = $this->ImapClient()->FetchMessagePart(
$oMessage->Uid,
$oMessage->smimeSigned['partId']
);
$result = (new \SnappyMail\SMime\OpenSSL(''))->verify($sBody, null, $bOpaque);
if ($result) {
if ($bOpaque) {
$oMessage->smimeSigned['body'] = $result['body'];
}
$oMessage->smimeSigned['success'] = $result['success'];
}
} catch (\Throwable $e) {
$this->logException($e);
}
if ($bAutoVerify && $oMessage->pgpSigned) try {
$GPG = $this->GnuPG();
if ($GPG) {
if ($oMessage->pgpSigned['sigPartId']) {
$sPartId = $oMessage->pgpSigned['partId'];
$sSigPartId = $oMessage->pgpSigned['sigPartId'];
$aParts = [
FetchType::BODY_PEEK.'['.$sPartId.']',
// An empty section specification refers to the entire message, including the header.
// But Dovecot does not return it with BODY.PEEK[1], so we also use BODY.PEEK[1.MIME].
FetchType::BODY_PEEK.'['.$sPartId.'.MIME]',
FetchType::BODY_PEEK.'['.$sSigPartId.']'
];
$oFetchResponse = $this->ImapClient()->Fetch($aParts, $oMessage->Uid, true)[0];
$sBodyMime = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sPartId.'.MIME]');
$info = $this->GnuPG()->verify(
\preg_replace('/\\r?\\n/su', "\r\n",
$sBodyMime . $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sPartId.']')
),
\preg_replace('/[^\x00-\x7F]/', '',
$oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sSigPartId.']')
)
);
} else {
// clearsigned text
$info = $this->GnuPG()->verify($oMessage->sPlain, '');
}
if (!empty($info[0]) && 0 == $info[0]['status']) {
$info = $info[0];
$oMessage->pgpSigned = [
'fingerprint' => $info['fingerprint'],
'success' => true
];
}
}
} catch (\Throwable $e) {
$this->logException($e);
}
} }
catch (\Throwable $oException) catch (\Throwable $oException)
{ {
@ -870,14 +930,15 @@ trait Messages
$oMessage->DoesNotAddDefaultXMailer(); $oMessage->DoesNotAddDefaultXMailer();
} }
$sFrom = $this->GetActionParam('from', ''); $oMessage->SetFrom(\MailSo\Mime\Email::Parse($this->GetActionParam('from', '')));
$oMessage->SetFrom(\MailSo\Mime\Email::Parse($sFrom)); $oFrom = $oMessage->GetFrom();
/* /*
$oFromIdentity = $this->GetIdentityByID($oAccount, $this->GetActionParam('identityID', '')); $oIdentity = $this->GetIdentityByID($oAccount, $this->GetActionParam('identityID', ''));
if ($oFromIdentity) if ($oIdentity)
{ {
$oMessage->SetFrom(new \MailSo\Mime\Email( $oMessage->SetFrom(new \MailSo\Mime\Email(
$oFromIdentity->Email(), $oFromIdentity->Name())); $oIdentity->Email(), $oIdentity->Name()));
if ($oAccount->Domain()->OutSetSender()) { if ($oAccount->Domain()->OutSetSender()) {
$oMessage->SetSender(\MailSo\Mime\Email::Parse($oAccount->Email())); $oMessage->SetSender(\MailSo\Mime\Email::Parse($oAccount->Email()));
} }
@ -887,14 +948,13 @@ trait Messages
$oMessage->SetFrom(\MailSo\Mime\Email::Parse($oAccount->Email())); $oMessage->SetFrom(\MailSo\Mime\Email::Parse($oAccount->Email()));
} }
*/ */
$oFrom = $oMessage->GetFrom();
$oMessage->RegenerateMessageId($oFrom ? $oFrom->GetDomain() : ''); $oMessage->RegenerateMessageId($oFrom ? $oFrom->GetDomain() : '');
$oMessage->SetReplyTo(new \MailSo\Mime\EmailCollection($this->GetActionParam('replyTo', ''))); $oMessage->SetReplyTo(new \MailSo\Mime\EmailCollection($this->GetActionParam('replyTo', '')));
if (!empty($this->GetActionParam('readReceiptRequest', 0))) { if (!empty($this->GetActionParam('readReceiptRequest', 0))) {
// Read Receipts Reference Main Account Email, Not Identities #147 // Read Receipts Reference Main Account Email, Not Identities #147
// $oMessage->SetReadReceipt(($oFromIdentity ?: $oAccount)->Email()); // $oMessage->SetReadReceipt(($oIdentity ?: $oAccount)->Email());
$oMessage->SetReadReceipt($oFrom->GetEmail()); $oMessage->SetReadReceipt($oFrom->GetEmail());
} }
@ -1065,8 +1125,7 @@ trait Messages
} }
} }
$sPassphrase = $this->GetActionParam('signPassphrase', ''); $oPassphrase = new \SnappyMail\SensitiveString($this->GetActionParam('signPassphrase', ''));
$this->logMask($sPassphrase);
$sFingerprint = $this->GetActionParam('signFingerprint', ''); $sFingerprint = $this->GetActionParam('signFingerprint', '');
if ($sFingerprint) { if ($sFingerprint) {
@ -1084,7 +1143,7 @@ trait Messages
$oMessage->SubParts->Clear(); $oMessage->SubParts->Clear();
$oMessage->Attachments()->Clear(); $oMessage->Attachments()->Clear();
$GPG->addSignKey($sFingerprint, $sPassphrase); $GPG->addSignKey($sFingerprint, $oPassphrase);
$GPG->setsignmode(GNUPG_SIG_MODE_DETACH); $GPG->setsignmode(GNUPG_SIG_MODE_DETACH);
$sSignature = $GPG->signStream($fp); $sSignature = $GPG->signStream($fp);
if (!$sSignature) { if (!$sSignature) {
@ -1110,6 +1169,18 @@ trait Messages
} else { } else {
$sCertificate = $this->GetActionParam('signCertificate', ''); $sCertificate = $this->GetActionParam('signCertificate', '');
$sPrivateKey = $this->GetActionParam('signPrivateKey', ''); $sPrivateKey = $this->GetActionParam('signPrivateKey', '');
if ('S/MIME' === $this->GetActionParam('sign', '')) {
$sID = $this->GetActionParam('identityID', '');
foreach ($this->GetIdentities($oAccount) as $oIdentity) {
if ($oIdentity && $oIdentity->smimeCertificate && $oIdentity->smimeKey
&& ($oIdentity->Id() === $sID || $oIdentity->Email() === $oFrom->GetEmail())
) {
$sCertificate = $oIdentity->smimeCertificate;
$sPrivateKey = $oIdentity->smimeKey;
break;
}
}
}
if ($sCertificate && $sPrivateKey) { if ($sCertificate && $sPrivateKey) {
$oBody = $oMessage->GetRootPart(); $oBody = $oMessage->GetRootPart();
@ -1127,7 +1198,7 @@ trait Messages
$SMIME = $this->SMIME(); $SMIME = $this->SMIME();
$SMIME->setCertificate($sCertificate); $SMIME->setCertificate($sCertificate);
$SMIME->setPrivateKey($sPrivateKey, $sPassphrase); $SMIME->setPrivateKey($sPrivateKey, $oPassphrase);
$sSignature = $SMIME->sign($tmp, $detached); $sSignature = $SMIME->sign($tmp, $detached);
if (!$sSignature) { if (!$sSignature) {

View file

@ -24,7 +24,7 @@ trait Pgp
$GPG = $this->GnuPG(); $GPG = $this->GnuPG();
if ($GPG) { if ($GPG) {
$keys = $GPG->keyInfo(''); $keys = $GPG->allKeysInfo('');
foreach ($keys['public'] as $key) { foreach ($keys['public'] as $key) {
$key = $GPG->export($key['subkeys'][0]['fingerprint'] ?: $key['subkeys'][0]['keyid']); $key = $GPG->export($key['subkeys'][0]['fingerprint'] ?: $key['subkeys'][0]['keyid']);
if ($key) { if ($key) {
@ -47,7 +47,7 @@ trait Pgp
/** /**
* @throws \MailSo\RuntimeException * @throws \MailSo\RuntimeException
*/ */
public function GnuPG() : ?GnuPG public function GnuPG() : ?\SnappyMail\PGP\PGPInterface
{ {
$oAccount = $this->getMainAccountFromToken(); $oAccount = $this->getMainAccountFromToken();
if (!$oAccount) { if (!$oAccount) {
@ -81,7 +81,7 @@ trait Pgp
if (\is_link($link) || \symlink($homedir, $link)) { if (\is_link($link) || \symlink($homedir, $link)) {
$homedir = $link; $homedir = $link;
} else { } else {
\error_log("symlink('{$homedir}', '{$link}') failed"); $this->logWrite("symlink('{$homedir}', '{$link}') failed", \LOG_WARNING, 'GnuPG');
} }
} }
// Else try ~/.gnupg/ + hash(email address) // Else try ~/.gnupg/ + hash(email address)
@ -97,10 +97,6 @@ trait Pgp
$homedir = $tmpdir; $homedir = $tmpdir;
} }
} }
if (104 <= \strlen($homedir . '/S.gpg-agent.extra')) {
throw new \Exception("socket name for '{$homedir}/S.gpg-agent.extra' is too long");
}
} }
return GnuPG::getInstance($homedir); return GnuPG::getInstance($homedir);
@ -113,15 +109,14 @@ trait Pgp
return $this->FalseResponse(); return $this->FalseResponse();
} }
$sPassphrase = $this->GetActionParam('passphrase', ''); $oPassphrase = new \SnappyMail\SensitiveString($this->GetActionParam('passphrase', ''));
$this->logMask($sPassphrase);
$GPG->addDecryptKey($this->GetActionParam('keyId', ''), $sPassphrase); $GPG->addDecryptKey($this->GetActionParam('keyId', ''), $oPassphrase);
$sData = $this->GetActionParam('data', ''); $sData = $this->GetActionParam('data', '');
$oPart = null; $oPart = null;
$result = [ $result = [
'data' => '', 'data' => null,
'signatures' => [] 'signatures' => []
]; ];
if ($sData) { if ($sData) {
@ -131,11 +126,13 @@ trait Pgp
$this->initMailClientConnection(); $this->initMailClientConnection();
$this->MailClient()->MessageMimeStream( $this->MailClient()->MessageMimeStream(
function ($rResource) use ($GPG, &$result, &$oPart) { function ($rResource) use ($GPG, &$result, &$oPart) {
if (\is_resource($rResource)) { if (\is_resource($rResource)) try {
$result['data'] = $GPG->decryptStream($rResource); $result['data'] = $GPG->decryptStream($rResource);
// $oPart = \MailSo\Mime\Part::FromString($result); // $oPart = \MailSo\Mime\Part::FromString($result);
// $GPG->decryptStream($rResource, $rStreamHandle); // $GPG->decryptStream($rResource, $rStreamHandle);
// $oPart = \MailSo\Mime\Part::FromStream($rStreamHandle); // $oPart = \MailSo\Mime\Part::FromStream($rStreamHandle);
} catch (\Throwable $e) {
$result = $e;
} }
}, },
$this->GetActionParam('folder', ''), $this->GetActionParam('folder', ''),
@ -149,23 +146,28 @@ trait Pgp
// $result['signatures'] = $oPart->SubParts[0]; // $result['signatures'] = $oPart->SubParts[0];
} }
if ($result instanceof \Throwable) {
throw $result;
}
return $this->DefaultResponse($result); return $this->DefaultResponse($result);
} }
public function DoGnupgGetKeys() : array public function DoGnupgGetKeys() : array
{ {
$GPG = $this->GnuPG(); $GPG = $this->GnuPG();
return $this->DefaultResponse($GPG ? $GPG->keyInfo('') : false); return $this->DefaultResponse($GPG ? $GPG->allKeysInfo('') : false);
} }
public function DoGnupgExportKey() : array public function DoGnupgExportKey() : array
{ {
$sPassphrase = $this->GetActionParam('passphrase', ''); $oPassphrase = $this->GetActionParam('isPrivate', '')
$this->logMask($sPassphrase); ? new \SnappyMail\SensitiveString($this->GetActionParam('passphrase', ''))
: null;
$GPG = $this->GnuPG(); $GPG = $this->GnuPG();
return $this->DefaultResponse($GPG ? $GPG->export( return $this->DefaultResponse($GPG ? $GPG->export(
$this->GetActionParam('keyId', ''), $this->GetActionParam('keyId', ''),
$sPassphrase $oPassphrase
) : false); ) : false);
} }
@ -176,11 +178,10 @@ trait Pgp
if ($GPG) { if ($GPG) {
$sName = $this->GetActionParam('name', ''); $sName = $this->GetActionParam('name', '');
$sEmail = $this->GetActionParam('email', ''); $sEmail = $this->GetActionParam('email', '');
$sPassphrase = $this->GetActionParam('passphrase', ''); $oPassphrase = new \SnappyMail\SensitiveString($this->GetActionParam('passphrase', ''));
$this->logMask($sPassphrase);
$fingerprint = $GPG->generateKey( $fingerprint = $GPG->generateKey(
$sName ? "{$sName} <{$sEmail}>" : $sEmail, $sName ? "{$sName} <{$sEmail}>" : $sEmail,
$sPassphrase $oPassphrase
); );
} }
return $this->DefaultResponse($fingerprint); return $this->DefaultResponse($fingerprint);
@ -221,15 +222,11 @@ trait Pgp
} }
} }
$result = false; $result = [];
if ($sKey) { if ($sKey) {
$sKey = \trim($sKey); $sKey = \trim($sKey);
if ($this->GetActionParam('backup', '')) { $result['backup'] = $this->GetActionParam('backup', '') && Backup::PGPKey($sKey);
$result = $result || Backup::PGPKey($sKey); $result['gnuPG'] = $this->GetActionParam('gnuPG', '') && ($GPG = $this->GnuPG()) && $GPG->import($sKey);
}
if ($this->GetActionParam('gnuPG', '') && ($GPG = $this->GnuPG())) {
$result = $result || $GPG->import($sKey);
}
} }
return $this->DefaultResponse($result); return $this->DefaultResponse($result);
@ -306,7 +303,7 @@ trait Pgp
} else { } else {
$sFolderName = $this->GetActionParam('folder', ''); $sFolderName = $this->GetActionParam('folder', '');
$iUid = (int) $this->GetActionParam('uid', 0); $iUid = (int) $this->GetActionParam('uid', 0);
$sBodyPartId = $this->GetActionParam('bodyPartId', ''); $sPartId = $this->GetActionParam('partId', '');
$sSigPartId = $this->GetActionParam('sigPartId', ''); $sSigPartId = $this->GetActionParam('sigPartId', '');
// $sMicAlg = $this->GetActionParam('micAlg', ''); // $sMicAlg = $this->GetActionParam('micAlg', '');
@ -315,10 +312,10 @@ trait Pgp
$oImapClient->FolderExamine($sFolderName); $oImapClient->FolderExamine($sFolderName);
$aParts = [ $aParts = [
FetchType::BODY_PEEK.'['.$sBodyPartId.']', FetchType::BODY_PEEK.'['.$sPartId.']',
// An empty section specification refers to the entire message, including the header. // An empty section specification refers to the entire message, including the header.
// But Dovecot does not return it with BODY.PEEK[1], so we also use BODY.PEEK[1.MIME]. // But Dovecot does not return it with BODY.PEEK[1], so we also use BODY.PEEK[1.MIME].
FetchType::BODY_PEEK.'['.$sBodyPartId.'.MIME]' FetchType::BODY_PEEK.'['.$sPartId.'.MIME]'
]; ];
if ($sSigPartId) { if ($sSigPartId) {
$aParts[] = FetchType::BODY_PEEK.'['.$sSigPartId.']'; $aParts[] = FetchType::BODY_PEEK.'['.$sSigPartId.']';
@ -326,20 +323,20 @@ trait Pgp
$oFetchResponse = $oImapClient->Fetch($aParts, $iUid, true)[0]; $oFetchResponse = $oImapClient->Fetch($aParts, $iUid, true)[0];
$sBodyMime = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sBodyPartId.'.MIME]'); $sBodyMime = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sPartId.'.MIME]');
if ($sSigPartId) { if ($sSigPartId) {
$result = [ $result = [
'text' => \preg_replace('/\\r?\\n/su', "\r\n", 'text' => \preg_replace('/\\r?\\n/su', "\r\n",
$sBodyMime . $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sBodyPartId.']') $sBodyMime . $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sPartId.']')
), ),
'signature' => preg_replace('/[^\x00-\x7F]/', '', 'signature' => \preg_replace('/[^\x00-\x7F]/', '',
$oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sSigPartId.']') $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sSigPartId.']')
) )
]; ];
} else { } else {
// clearsigned text // clearsigned text
$result = [ $result = [
'text' => $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sBodyPartId.']'), 'text' => $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sPartId.']'),
'signature' => '' 'signature' => ''
]; ];
$decode = (new \MailSo\Mime\HeaderCollection($sBodyMime))->ValueByName(MimeEnumHeader::CONTENT_TRANSFER_ENCODING); $decode = (new \MailSo\Mime\HeaderCollection($sBodyMime))->ValueByName(MimeEnumHeader::CONTENT_TRANSFER_ENCODING);

View file

@ -45,9 +45,10 @@ trait Response
public function ExceptionResponse(\Throwable $oException) : array public function ExceptionResponse(\Throwable $oException) : array
{ {
$iErrorCode = 0; $iErrorCode = Notifications::UnknownError;
$sErrorMessage = ''; $sErrorMessage = $oException->getMessage();
$sErrorMessageAdditional = ''; $sErrorMessageAdditional = '';
$iExceptionCode = 0;
if ($oException instanceof \RainLoop\Exceptions\ClientException) { if ($oException instanceof \RainLoop\Exceptions\ClientException) {
$iErrorCode = $oException->getCode(); $iErrorCode = $oException->getCode();
@ -56,8 +57,7 @@ trait Response
} }
$sErrorMessageAdditional = $oException->getAdditionalMessage(); $sErrorMessageAdditional = $oException->getAdditionalMessage();
} else { } else {
$iErrorCode = Notifications::UnknownError; $iExceptionCode = $oException->getCode();
$sErrorMessage = $oException->getCode().' - '.$oException->getMessage();
} }
$this->logException($oException->getPrevious() ?: $oException); $this->logException($oException->getPrevious() ?: $oException);
@ -65,7 +65,8 @@ trait Response
return $this->DefaultResponse(false, [ return $this->DefaultResponse(false, [
'ErrorCode' => $iErrorCode, 'ErrorCode' => $iErrorCode,
'ErrorMessage' => $sErrorMessage, 'ErrorMessage' => $sErrorMessage,
'ErrorMessageAdditional' => $sErrorMessageAdditional 'ErrorMessageAdditional' => $sErrorMessageAdditional,
'ExceptionCode' => $iExceptionCode
]); ]);
} }

View file

@ -53,21 +53,18 @@ trait SMime
} }
/** /**
* Can be use by Identity * Can be used by Identity
*/ */
public function DoSMimeCreateCertificate() : array public function DoSMimeCreateCertificate() : array
{ {
$oAccount = $this->getAccountFromToken(); $oAccount = $this->getAccountFromToken();
$sName = $this->GetActionParam('name', '') ?: $oAccount->Name(); $oPassphrase = new \SnappyMail\SensitiveString($this->GetActionParam('passphrase', ''));
$sEmail = $this->GetActionParam('email', '') ?: $oAccount->Email();
$sPassphrase = $this->GetActionParam('passphrase', '');
$this->logMask($sPassphrase);
$cert = new Certificate(); $cert = new Certificate();
$cert->distinguishedName['commonName'] = $sName; $cert->distinguishedName['commonName'] = $this->GetActionParam('name', '') ?: $oAccount->Name();
$cert->distinguishedName['emailAddress'] = $sEmail; $cert->distinguishedName['emailAddress'] = $this->GetActionParam('email', '') ?: $oAccount->Email();
$result = $cert->createSelfSigned($sPassphrase); $result = $cert->createSelfSigned($oPassphrase, $this->GetActionParam('privateKey', ''));
return $this->DefaultResponse($result ?: false); return $this->DefaultResponse($result ?: false);
} }
@ -78,8 +75,7 @@ trait SMime
$sPartId = $this->GetActionParam('partId', ''); $sPartId = $this->GetActionParam('partId', '');
$sCertificate = $this->GetActionParam('certificate', ''); $sCertificate = $this->GetActionParam('certificate', '');
$sPrivateKey = $this->GetActionParam('privateKey', ''); $sPrivateKey = $this->GetActionParam('privateKey', '');
$sPassphrase = $this->GetActionParam('passphrase', ''); $oPassphrase = new \SnappyMail\SensitiveString($this->GetActionParam('passphrase', ''));
$this->logMask($sPassphrase);
$this->initMailClientConnection(); $this->initMailClientConnection();
$oImapClient = $this->ImapClient(); $oImapClient = $this->ImapClient();
@ -106,8 +102,16 @@ trait SMime
$SMIME = $this->SMIME(); $SMIME = $this->SMIME();
$SMIME->setCertificate($sCertificate); $SMIME->setCertificate($sCertificate);
$SMIME->setPrivateKey($sPrivateKey, $sPassphrase); $SMIME->setPrivateKey($sPrivateKey, $oPassphrase);
$result = $SMIME->decrypt($sBody); $result = $SMIME->decrypt($sBody);
if ($result) {
$result = ['data' => $result];
if (\str_contains($result['data'], 'multipart/signed')) {
$result['signed'] = [
'success' => !empty($SMIME->verify($result['data'], null, true)['success'])
];
}
}
return $this->DefaultResponse($result ?: false); return $this->DefaultResponse($result ?: false);
} }
@ -118,32 +122,12 @@ trait SMime
$sPartId = $this->GetActionParam('partId', ''); $sPartId = $this->GetActionParam('partId', '');
$bDetached = !empty($this->GetActionParam('detached', 0)); $bDetached = !empty($this->GetActionParam('detached', 0));
if (!$sBody && $sPartId) { if (!$sBody && $sPartId) {
$sFolderName = $this->GetActionParam('folder', '');
$iUid = (int) $this->GetActionParam('uid', 0); $iUid = (int) $this->GetActionParam('uid', 0);
$sMicAlg = $this->GetActionParam('micAlg', ''); // $sMicAlg = $this->GetActionParam('micAlg', '');
$this->initMailClientConnection(); $this->initMailClientConnection();
$oImapClient = $this->ImapClient(); $oImapClient = $this->ImapClient();
$oImapClient->FolderExamine($sFolderName); $oImapClient->FolderExamine($this->GetActionParam('folder', ''));
$sBody = $oImapClient->FetchMessagePart($iUid, $sPartId);
if ('TEXT' === $sPartId) {
$oFetchResponse = $oImapClient->Fetch([
FetchType::BODY_PEEK.'['.$sPartId.']',
// An empty section specification refers to the entire message, including the header.
// But Dovecot does not return it with BODY.PEEK[1], so we also use BODY.PEEK[1.MIME].
FetchType::BODY_HEADER_PEEK
], $iUid, true)[0];
$sBody = $oFetchResponse->GetFetchValue(FetchType::BODY_HEADER);
} else {
$oFetchResponse = $oImapClient->Fetch([
FetchType::BODY_PEEK.'['.$sPartId.']',
// An empty section specification refers to the entire message, including the header.
// But Dovecot does not return it with BODY.PEEK[1], so we also use BODY.PEEK[1.MIME].
FetchType::BODY_PEEK.'['.$sPartId.'.MIME]'
], $iUid, true)[0];
$sBody = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sPartId.'.MIME]');
}
$sBody .= $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sPartId.']');
} }
$result = $this->SMIME()->verify($sBody, null, !$bDetached); $result = $this->SMIME()->verify($sBody, null, !$bDetached);
@ -151,7 +135,7 @@ trait SMime
// Import the certificates automatically // Import the certificates automatically
$sBody = $this->GetActionParam('sigPart', ''); $sBody = $this->GetActionParam('sigPart', '');
$sPartId = $this->GetActionParam('sigPartId', '') ?: $sPartId; $sPartId = $this->GetActionParam('sigPartId', '') ?: $sPartId;
if (!$sBody && $sPartId) { if (!$sBody && $sPartId && $oImapClient) {
$sBody = $oImapClient->Fetch( $sBody = $oImapClient->Fetch(
[FetchType::BODY_PEEK.'['.$sPartId.']'], [FetchType::BODY_PEEK.'['.$sPartId.']'],
$iUid, $iUid,
@ -162,9 +146,9 @@ trait SMime
$sBody = \trim($sBody); $sBody = \trim($sBody);
$certificates = []; $certificates = [];
\openssl_pkcs7_read( \openssl_pkcs7_read(
"-----BEGIN CERTIFICATE-----\n\n{$sBody}\n-----END CERTIFICATE-----", "-----BEGIN PKCS7-----\n\n{$sBody}\n-----END PKCS7-----",
$certificates $certificates
) || \error_log("OpenSSL openssl_pkcs7_read: " . \openssl_error_string()); ) || $this->logWrite("openssl_pkcs7_read: " . \openssl_error_string(), \LOG_ERR, 'OpenSSL');
foreach ($certificates as $certificate) { foreach ($certificates as $certificate) {
$this->SMIME()->storeCertificate($certificate); $this->SMIME()->storeCertificate($certificate);
} }
@ -173,6 +157,15 @@ trait SMime
return $this->DefaultResponse($result); return $this->DefaultResponse($result);
} }
public function DoSMimeImportCertificate() : array
{
return $this->DefaultResponse(
$this->SMIME()->storeCertificate(
$this->GetActionParam('pem', '')
)
);
}
public function DoSMimeImportCertificatesFromMessage() : array public function DoSMimeImportCertificatesFromMessage() : array
{ {
/* /*
@ -190,7 +183,7 @@ trait SMime
$sBody = \trim($sBody); $sBody = \trim($sBody);
$certificates = []; $certificates = [];
\openssl_pkcs7_read( \openssl_pkcs7_read(
"-----BEGIN CERTIFICATE-----\n\n{$sBody}\n-----END CERTIFICATE-----", "-----BEGIN PKCS7-----\n\n{$sBody}\n-----END PKCS7-----",
$certificates $certificates
); );

View file

@ -187,7 +187,7 @@ trait Themes
$oSettings->SetConf('UserBackgroundName', $sName); $oSettings->SetConf('UserBackgroundName', $sName);
$oSettings->SetConf('UserBackgroundHash', $sHash); $oSettings->SetConf('UserBackgroundHash', $sHash);
$this->SettingsProvider()->Save($oAccount, $oSettings); $oSettings->save();
} }
} }
} }

View file

@ -36,10 +36,10 @@ trait User
public function DoLogin() : array public function DoLogin() : array
{ {
$sEmail = \MailSo\Base\Utils::Trim($this->GetActionParam('Email', '')); $sEmail = \MailSo\Base\Utils::Trim($this->GetActionParam('Email', ''));
$sPassword = $this->GetActionParam('Password', ''); $oPassword = new \SnappyMail\SensitiveString($this->GetActionParam('Password', ''));
try { try {
$oAccount = $this->LoginProcess($sEmail, $sPassword); $oAccount = $this->LoginProcess($sEmail, $oPassword);
} catch (\Throwable $oException) { } catch (\Throwable $oException) {
$this->loginErrorDelay(); $this->loginErrorDelay();
throw $oException; throw $oException;
@ -61,7 +61,7 @@ trait User
if ($sCurrentLanguage !== $sLanguage) { if ($sCurrentLanguage !== $sLanguage) {
$oSettings->SetConf('language', $sLanguage); $oSettings->SetConf('language', $sLanguage);
$this->SettingsProvider()->Save($oAccount, $oSettings); $oSettings->save();
} }
} }
} }
@ -219,28 +219,20 @@ trait User
$this->setSettingsFromParams($oSettingsLocal, 'ShowUnreadCount', 'bool'); $this->setSettingsFromParams($oSettingsLocal, 'ShowUnreadCount', 'bool');
$this->setSettingsFromParams($oSettingsLocal, 'CheckMailInterval', 'int'); $this->setSettingsFromParams($oSettingsLocal, 'CheckMailInterval', 'int');
return $this->DefaultResponse($this->SettingsProvider()->Save($oAccount, $oSettings) && return $this->DefaultResponse($oSettings->save() && $oSettingsLocal->save());
$this->SettingsProvider(true)->Save($oAccount, $oSettingsLocal));
} }
public function DoQuota() : array public function DoQuota() : array
{ {
$oAccount = $this->initMailClientConnection(); $oAccount = $this->initMailClientConnection();
if (!$this->GetCapa(Capa::QUOTA)) {
return $this->DefaultResponse(array(0, 0, 0, 0));
}
try try
{ {
$aQuota = $this->ImapClient()->QuotaRoot(); return $this->DefaultResponse($this->ImapClient()->QuotaRoot() ?: [0, 0, 0, 0]);
} }
catch (\Throwable $oException) catch (\Throwable $oException)
{ {
throw new ClientException(Notifications::MailServerError, $oException); throw new ClientException(Notifications::MailServerError, $oException);
} }
return $this->DefaultResponse($aQuota);
} }
public function DoSuggestions() : array public function DoSuggestions() : array
@ -263,12 +255,11 @@ trait User
public function DoClearUserBackground() : array public function DoClearUserBackground() : array
{ {
$oAccount = $this->getAccountFromToken();
if (!$this->GetCapa(Capa::USER_BACKGROUND)) { if (!$this->GetCapa(Capa::USER_BACKGROUND)) {
return $this->FalseResponse(); return $this->FalseResponse();
} }
$oAccount = $this->getAccountFromToken();
$oSettings = $this->SettingsProvider()->Load($oAccount); $oSettings = $this->SettingsProvider()->Load($oAccount);
if ($oAccount && $oSettings) { if ($oAccount && $oSettings) {
$this->StorageProvider()->Clear($oAccount, $this->StorageProvider()->Clear($oAccount,
@ -280,8 +271,7 @@ trait User
$oSettings->SetConf('UserBackgroundHash', ''); $oSettings->SetConf('UserBackgroundHash', '');
} }
return $this->DefaultResponse($oAccount && $oSettings ? return $this->DefaultResponse($oAccount && $oSettings ? $oSettings->save() : false);
$this->SettingsProvider()->Save($oAccount, $oSettings) : false);
} }
private function setSettingsFromParams(\RainLoop\Settings $oSettings, string $sConfigName, string $sType = 'string', ?callable $cCallback = null) : void private function setSettingsFromParams(\RainLoop\Settings $oSettings, string $sConfigName, string $sType = 'string', ?callable $cCallback = null) : void

View file

@ -22,10 +22,7 @@ trait UserAuth
/** /**
* @throws \RainLoop\Exceptions\ClientException * @throws \RainLoop\Exceptions\ClientException
*/ */
public function resolveLoginCredentials(string &$sEmail, public function resolveLoginCredentials(string &$sEmail, \SnappyMail\SensitiveString $oPassword, string &$sLogin): void
#[\SensitiveParameter]
string &$sPassword,
string &$sLogin): void
{ {
$sEmail = \MailSo\Base\Utils::Trim($sEmail); $sEmail = \MailSo\Base\Utils::Trim($sEmail);
if ($this->Config()->Get('login', 'login_lowercase', true)) { if ($this->Config()->Get('login', 'login_lowercase', true)) {
@ -101,43 +98,40 @@ trait UserAuth
} }
} }
$sPassword = (string) $oPassword;
$this->Plugins()->RunHook('login.credentials.step-2', array(&$sEmail, &$sPassword)); $this->Plugins()->RunHook('login.credentials.step-2', array(&$sEmail, &$sPassword));
$this->logMask($sPassword);
$sLogin = $sEmail; $sLogin = $sEmail;
if ($this->Config()->Get('login', 'login_lowercase', true)) { if ($this->Config()->Get('login', 'login_lowercase', true)) {
$sLogin = \mb_strtolower($sLogin); $sLogin = \mb_strtolower($sLogin);
} }
$this->logMask($sPassword);
$this->Plugins()->RunHook('login.credentials', array(&$sEmail, &$sLogin, &$sPassword)); $this->Plugins()->RunHook('login.credentials', array(&$sEmail, &$sLogin, &$sPassword));
$this->logMask($sPassword); $this->logMask($sPassword);
$oPassword->setValue($sPassword);
} }
/** /**
* @throws \RainLoop\Exceptions\ClientException * @throws \RainLoop\Exceptions\ClientException
*/ */
public function LoginProcess(string &$sEmail, public function LoginProcess(string &$sEmail, \SnappyMail\SensitiveString $oPassword, bool $bMainAccount = true): Account
#[\SensitiveParameter]
string &$sPassword,
bool $bMainAccount = true
): Account
{ {
$sInputEmail = $sEmail; $sInputEmail = $sEmail;
$this->logMask($sPassword);
$sLogin = ''; $sLogin = '';
$this->resolveLoginCredentials($sEmail, $sPassword, $sLogin); $this->resolveLoginCredentials($sEmail, $oPassword, $sLogin);
if (!\str_contains($sEmail, '@') || !\strlen($sPassword)) { if (!\str_contains($sEmail, '@') || !\strlen($oPassword)) {
throw new ClientException(Notifications::InvalidInputArgument); throw new ClientException(Notifications::InvalidInputArgument);
} }
$oAccount = null; $oAccount = null;
try { try {
$oAccount = $bMainAccount $oAccount = $bMainAccount
? MainAccount::NewInstanceFromCredentials($this, $sEmail, $sLogin, $sPassword, true) ? MainAccount::NewInstanceFromCredentials($this, $sEmail, $sLogin, $oPassword, true)
: AdditionalAccount::NewInstanceFromCredentials($this, $sEmail, $sLogin, $sPassword, true); : AdditionalAccount::NewInstanceFromCredentials($this, $sEmail, $sLogin, $oPassword, true);
if (!$oAccount) { if (!$oAccount) {
throw new ClientException(Notifications::AuthError); throw new ClientException(Notifications::AuthError);
} }
@ -261,7 +255,7 @@ trait UserAuth
$this->oMainAuthAccount = $oMainAuthAccount; $this->oMainAuthAccount = $oMainAuthAccount;
} else { } else {
$this->StorageProvider()->Clear($oMainAuthAccount, StorageType::SESSION, $sToken); $this->StorageProvider()->Clear($oMainAuthAccount, StorageType::SESSION, $sToken);
\SnappyMail\Log::notice('TOKENS', 'SESSION_TOKEN value invalid: ' . \gettype($sTokenValue)); \SnappyMail\Log::notice('TOKENS', 'SESSION_TOKEN value invalid: ' . \get_debug_type($sTokenValue));
} }
} else { } else {
\SnappyMail\Log::notice('TOKENS', 'AUTH_SPEC_TOKEN_KEY invalid'); \SnappyMail\Log::notice('TOKENS', 'AUTH_SPEC_TOKEN_KEY invalid');

View file

@ -25,6 +25,8 @@ class ActionsAdmin extends Actions
unset($aConfig['version']); unset($aConfig['version']);
$aConfig['logs']['time_zone'][1] = ''; $aConfig['logs']['time_zone'][1] = '';
$aConfig['logs']['time_zone'][2] = \DateTimeZone::listIdentifiers(); $aConfig['logs']['time_zone'][2] = \DateTimeZone::listIdentifiers();
$aConfig['login']['sign_me_auto'][2] = ['DefaultOff','DefaultOn','Unused'];
$aConfig['defaults']['view_images'][2] = ['ask','match','always'];
return $this->DefaultResponse($aConfig); return $this->DefaultResponse($aConfig);
} }
@ -63,6 +65,7 @@ class ActionsAdmin extends Actions
}); });
$this->setConfigFromParams($oConfig, 'useLocalProxyForExternalImages', 'labs', 'use_local_proxy_for_external_images', 'bool'); $this->setConfigFromParams($oConfig, 'useLocalProxyForExternalImages', 'labs', 'use_local_proxy_for_external_images', 'bool');
$this->setConfigFromParams($oConfig, 'autoVerifySignatures', 'security', 'auto_verify_signatures', 'bool');
$this->setConfigFromParams($oConfig, 'allowLanguagesOnSettings', 'webmail', 'allow_languages_on_settings', 'bool'); $this->setConfigFromParams($oConfig, 'allowLanguagesOnSettings', 'webmail', 'allow_languages_on_settings', 'bool');
$this->setConfigFromParams($oConfig, 'allowLanguagesOnLogin', 'login', 'allow_languages_on_login', 'bool'); $this->setConfigFromParams($oConfig, 'allowLanguagesOnLogin', 'login', 'allow_languages_on_login', 'bool');
@ -109,17 +112,15 @@ class ActionsAdmin extends Actions
public function DoAdminLogin() : array public function DoAdminLogin() : array
{ {
$sLogin = trim($this->GetActionParam('Login', '')); $sLogin = trim($this->GetActionParam('Login', ''));
$sPassword = $this->GetActionParam('Password', ''); $oPassword = new \SnappyMail\SensitiveString($this->GetActionParam('Password', ''));
$this->logMask($sPassword);
$totp = $this->Config()->Get('security', 'admin_totp', ''); $totp = $this->Config()->Get('security', 'admin_totp', '');
// \explode(':',`getent shadow root`)[1]; // \explode(':',`getent shadow root`)[1];
if (!\strlen($sLogin) || !\strlen($sPassword) || if (!\strlen($sLogin) || !\strlen($oPassword) ||
!$this->Config()->Get('security', 'allow_admin_panel', true) || !$this->Config()->Get('security', 'allow_admin_panel', true) ||
$sLogin !== $this->Config()->Get('security', 'admin_login', '') || $sLogin !== $this->Config()->Get('security', 'admin_login', '') ||
!$this->Config()->ValidatePassword($sPassword) !$this->Config()->ValidatePassword($oPassword)
|| ($totp && !\SnappyMail\TOTP::Verify($totp, $this->GetActionParam('TOTP', '')))) || ($totp && !\SnappyMail\TOTP::Verify($totp, $this->GetActionParam('TOTP', ''))))
{ {
$this->LoggerAuthHelper(null, $this->getAdditionalLogParamsByUserLogin($sLogin, true), true); $this->LoggerAuthHelper(null, $this->getAdditionalLogParamsByUserLogin($sLogin, true), true);
@ -181,17 +182,13 @@ class ActionsAdmin extends Actions
$bResult = false; $bResult = false;
$oConfig = $this->Config(); $oConfig = $this->Config();
$sPassword = $this->GetActionParam('Password', ''); $oPassword = new \SnappyMail\SensitiveString($this->GetActionParam('Password', ''));
$this->logMask($sPassword);
$sNewPassword = $this->GetActionParam('newPassword', ''); $oNewPassword = new \SnappyMail\SensitiveString($this->GetActionParam('newPassword', ''));
if (\strlen($sNewPassword)) {
$this->logMask($sNewPassword);
}
$passfile = APP_PRIVATE_DATA.'admin_password.txt'; $passfile = APP_PRIVATE_DATA.'admin_password.txt';
if ($oConfig->ValidatePassword($sPassword)) { if ($oConfig->ValidatePassword($oPassword)) {
$sLogin = \trim($this->GetActionParam('Login', '')); $sLogin = \trim($this->GetActionParam('Login', ''));
if (\strlen($sLogin)) { if (\strlen($sLogin)) {
$oConfig->Set('security', 'admin_login', $sLogin); $oConfig->Set('security', 'admin_login', $sLogin);
@ -199,9 +196,9 @@ class ActionsAdmin extends Actions
$oConfig->Set('security', 'admin_totp', $this->GetActionParam('TOTP', '')); $oConfig->Set('security', 'admin_totp', $this->GetActionParam('TOTP', ''));
if (\strlen($sNewPassword)) { if (\strlen($oNewPassword)) {
$oConfig->SetPassword($sNewPassword); $oConfig->SetPassword($oNewPassword);
if (\is_file($passfile) && \trim(\file_get_contents($passfile)) !== $sNewPassword) { if (\is_file($passfile) && \trim(\file_get_contents($passfile)) !== (string) $oNewPassword) {
\unlink($passfile); \unlink($passfile);
} }
} }
@ -369,56 +366,6 @@ class ActionsAdmin extends Actions
return $this->DefaultResponse($QR->__toString()); return $this->DefaultResponse($QR->__toString());
} }
/*
public function AdminAppData(array &$aResult): void
{
$oConfig = $this->oConfig;
$aResult['Auth'] = $this->IsAdminLoggined(false);
if ($aResult['Auth']) {
$aResult['adminLogin'] = (string)$oConfig->Get('security', 'admin_login', '');
$aResult['adminTOTP'] = (string)$oConfig->Get('security', 'admin_totp', '');
$aResult['pluginsEnable'] = (bool)$oConfig->Get('plugins', 'enable', false);
$aResult['loginDefaultDomain'] = $oConfig->Get('login', 'default_domain', '');
$aResult['determineUserLanguage'] = (bool)$oConfig->Get('login', 'determine_user_language', true);
$aResult['determineUserDomain'] = (bool)$oConfig->Get('login', 'determine_user_domain', false);
$aResult['supportedPdoDrivers'] = \RainLoop\Pdo\Base::getAvailableDrivers();
$aResult['contactsEnable'] = (bool)$oConfig->Get('contacts', 'enable', false);
$aResult['contactsSync'] = (bool)$oConfig->Get('contacts', 'allow_sync', false);
$aResult['contactsPdoType'] = Providers\AddressBook\PdoAddressBook::validPdoType($oConfig->Get('contacts', 'type', 'sqlite'));
$aResult['contactsPdoDsn'] = (string)$oConfig->Get('contacts', 'pdo_dsn', '');
$aResult['contactsPdoType'] = (string)$oConfig->Get('contacts', 'type', '');
$aResult['contactsPdoUser'] = (string)$oConfig->Get('contacts', 'pdo_user', '');
$aResult['contactsPdoPassword'] = static::APP_DUMMY;
$aResult['contactsMySQLSSLCA'] = (string) $oConfig->Get('contacts', 'mysql_ssl_ca', '');
$aResult['contactsMySQLSSLVerify'] = !!$oConfig->Get('contacts', 'mysql_ssl_verify', true);
$aResult['contactsMySQLSSLCiphers'] = (string) $oConfig->Get('contacts', 'mysql_ssl_ciphers', '');
$aResult['contactsSQLiteGlobal'] = !!$oConfig->Get('contacts', 'sqlite_global', \is_file(APP_PRIVATE_DATA . '/AddressBook.sqlite'));
$aResult['contactsSuggestionsLimit'] = (int)$oConfig->Get('contacts', 'suggestions_limit', 20);
$aResult['faviconUrl'] = $oConfig->Get('webmail', 'favicon_url', '');
$aResult['weakPassword'] = \is_file(APP_PRIVATE_DATA.'admin_password.txt');
$aResult['System']['languagesAdmin'] = \SnappyMail\L10n::getLanguages(true);
$aResult['languageAdmin'] = $this->ValidateLanguage($oConfig->Get('webmail', 'language_admin', 'en'), '', true);
$aResult['languageUsers'] = $this->ValidateLanguage($this->detectUserLanguage(true), '', true, true);
} else {
$passfile = APP_PRIVATE_DATA.'admin_password.txt';
$sPassword = $oConfig->Get('security', 'admin_password', '');
if (!$sPassword) {
$sPassword = \substr(\base64_encode(\random_bytes(16)), 0, 12);
Utils::saveFile($passfile, $sPassword . "\n");
// \chmod($passfile, 0600);
$oConfig->SetPassword($sPassword);
$oConfig->Save();
}
}
}
*/
private function setAdminAuthToken() : string private function setAdminAuthToken() : string
{ {
$sRand = \MailSo\Base\Utils::Sha1Rand(); $sRand = \MailSo\Base\Utils::Sha1Rand();

View file

@ -88,15 +88,15 @@ abstract class AbstractConfig implements \ArrayAccess, \JsonSerializable
if (!\is_scalar($mParamValue)) { if (!\is_scalar($mParamValue)) {
$mParamValue = null; $mParamValue = null;
} }
switch (\gettype($this->aData[$sSectionKey][$sParamKey][0])) switch (\get_debug_type($this->aData[$sSectionKey][$sParamKey][0]))
{ {
case 'boolean': case 'bool':
$this->aData[$sSectionKey][$sParamKey][0] = (bool) $mParamValue; $this->aData[$sSectionKey][$sParamKey][0] = (bool) $mParamValue;
break; break;
case 'double': case 'float':
$this->aData[$sSectionKey][$sParamKey][0] = (float) $mParamValue; $this->aData[$sSectionKey][$sParamKey][0] = (float) $mParamValue;
break; break;
case 'integer': case 'int':
$this->aData[$sSectionKey][$sParamKey][0] = (int) $mParamValue; $this->aData[$sSectionKey][$sParamKey][0] = (int) $mParamValue;
break; break;
case 'string': case 'string':
@ -289,13 +289,13 @@ abstract class AbstractConfig implements \ArrayAccess, \JsonSerializable
$bFirst = false; $bFirst = false;
$sValue = '""'; $sValue = '""';
switch (\gettype($mParamValue[0])) switch (\get_debug_type($mParamValue[0]))
{ {
case 'boolean': case 'bool':
$sValue = $mParamValue[0] ? 'On' : 'Off'; $sValue = $mParamValue[0] ? 'On' : 'Off';
break; break;
case 'double': case 'float':
case 'integer': case 'int':
$sValue = $mParamValue[0]; $sValue = $mParamValue[0];
break; break;
case 'string': case 'string':

View file

@ -120,25 +120,14 @@ class Application extends \RainLoop\Config\AbstractConfig
parent::Set($sSectionKey, $sParamKey, $mParamValue); parent::Set($sSectionKey, $sParamKey, $mParamValue);
} }
public function SetPassword( public function SetPassword(\SnappyMail\SensitiveString $oPassword) : void
#[\SensitiveParameter]
string $sPassword
) : void
{ {
$this->Set('security', 'admin_password', \password_hash($sPassword, PASSWORD_DEFAULT)); $this->Set('security', 'admin_password', \password_hash($oPassword, PASSWORD_DEFAULT));
} }
public function ValidatePassword( public function ValidatePassword(\SnappyMail\SensitiveString $oPassword) : bool
#[\SensitiveParameter]
string $sPassword
) : bool
{ {
$sConfigPassword = (string) $this->Get('security', 'admin_password', ''); return \strlen($oPassword) && \password_verify($oPassword, $this->Get('security', 'admin_password', ''));
if (32 == \strlen($sPassword) && \md5(APP_SALT.$sPassword.APP_SALT) === $sConfigPassword) {
$this->SetPassword($sPassword);
return true;
}
return \strlen($sPassword) && \password_verify($sPassword, $sConfigPassword);
} }
public function Save() : bool public function Save() : bool
@ -214,6 +203,7 @@ Warning: only enable when server does not do this, else double compression error
'x_xss_protection_header' => array('1; mode=block'), 'x_xss_protection_header' => array('1; mode=block'),
'openpgp' => array(true), 'openpgp' => array(true),
'auto_verify_signatures' => array(false),
'allow_admin_panel' => array(true, 'Access settings'), 'allow_admin_panel' => array(true, 'Access settings'),
'admin_login' => array('admin', 'Login and password for web admin panel'), 'admin_login' => array('admin', 'Login and password for web admin panel'),
@ -262,7 +252,6 @@ Default is "site=same-origin;site=none"')
), ),
'capa' => array( 'capa' => array(
'quota' => array(true),
'dangerous_actions' => array(true, 'Allow clear folder and delete messages without moving to trash'), 'dangerous_actions' => array(true, 'Allow clear folder and delete messages without moving to trash'),
'attachments_actions' => array(true, 'Allow download attachments as Zip (and optionally others)') 'attachments_actions' => array(true, 'Allow download attachments as Zip (and optionally others)')
), ),
@ -284,7 +273,7 @@ When this value is gethostname, the gethostname() value is used.
'login_lowercase' => array(true), 'login_lowercase' => array(true),
'sign_me_auto' => array(\RainLoop\Enumerations\SignMeType::DEFAULT_OFF, 'sign_me_auto' => array(\RainLoop\Enumerations\SignMeType::DefaultOff,
'This option allows webmail to remember the logged in user 'This option allows webmail to remember the logged in user
once they closed the browser window. once they closed the browser window.
@ -419,7 +408,7 @@ Enables caching in the system'),
'labs' => array( 'labs' => array(
'date_from_headers' => array(true, 'Display message RFC 2822 date and time header, instead of the arrival internal date.'), 'date_from_headers' => array(true, 'Display message RFC 2822 date and time header, instead of the arrival internal date.'),
'allow_message_append' => array(false), 'allow_message_append' => array(false, 'Allow drag & drop .eml files from system into messages list'),
'login_fault_delay' => array(5, 'When login fails, wait N seconds before responding'), 'login_fault_delay' => array(5, 'When login fails, wait N seconds before responding'),
'log_ajax_response_write_limit' => array(300), 'log_ajax_response_write_limit' => array(300),
'smtp_show_server_errors' => array(false), 'smtp_show_server_errors' => array(false),

View file

@ -2,19 +2,17 @@
namespace RainLoop\Enumerations; namespace RainLoop\Enumerations;
class Capa abstract class Capa
{ {
const ADDITIONAL_ACCOUNTS = 'AdditionalAccounts';
const ATTACHMENTS_ACTIONS = 'AttachmentsActions';
const ATTACHMENT_THUMBNAILS = 'AttachmentThumbnails';
const CONTACTS = 'Contacts';
const DANGEROUS_ACTIONS = 'DangerousActions';
const GNUPG = 'GnuPG'; const GNUPG = 'GnuPG';
const OPEN_PGP = 'OpenPGP'; const IDENTITIES = 'Identities';
const OPENPGP = 'OpenPGP';
const SIEVE = 'Sieve';
const THEMES = 'Themes'; const THEMES = 'Themes';
const USER_BACKGROUND = 'UserBackground'; const USER_BACKGROUND = 'UserBackground';
const SIEVE = 'Sieve';
const ATTACHMENT_THUMBNAILS = 'AttachmentThumbnails';
const ADDITIONAL_ACCOUNTS = 'AdditionalAccounts';
const IDENTITIES = 'Identities';
const CONTACTS = 'Contacts';
const QUOTA = 'Quota';
const ATTACHMENTS_ACTIONS = 'AttachmentsActions';
const DANGEROUS_ACTIONS = 'DangerousActions';
const AUTOLOGOUT = ''; // Obsolete
} }

View file

@ -2,7 +2,7 @@
namespace RainLoop\Enumerations; namespace RainLoop\Enumerations;
class Layout abstract class Layout
{ {
const NO_PREVIEW = 0; const NO_PREVIEW = 0;
const SIDE_PREVIEW = 1; const SIDE_PREVIEW = 1;

View file

@ -2,7 +2,7 @@
namespace RainLoop\Enumerations; namespace RainLoop\Enumerations;
class PluginPropertyType abstract class PluginPropertyType
{ {
const const
GROUP = 7, GROUP = 7,

View file

@ -2,9 +2,9 @@
namespace RainLoop\Enumerations; namespace RainLoop\Enumerations;
class SignMeType abstract class SignMeType
{ {
const DEFAULT_OFF = 'DefaultOff'; const DefaultOff = 'DefaultOff';
const DEFAULT_ON = 'DefaultOn'; const DefaultOn = 'DefaultOn';
const UNUSED = 'Unused'; const Unused = 'Unused';
} }

View file

@ -1,9 +0,0 @@
<?php
namespace RainLoop\Enumerations;
class TimeFormat
{
const F12 = '12';
const F24 = '24';
}

View file

@ -9,16 +9,16 @@ abstract class UploadError
const CONFIG_SIZE = 1001; const CONFIG_SIZE = 1001;
const ON_SAVING = 1002; const ON_SAVING = 1002;
const EMPTY_FILES_DATA = 1003; const EMPTY_FILE = 1003;
private static $messages = [ private static $messages = [
\UPLOAD_ERR_INI_SIZE => 'Filesize exceeds the upload_max_filesize directive in php.ini', /*1*/\UPLOAD_ERR_INI_SIZE => 'Filesize exceeds the upload_max_filesize directive in php.ini',
\UPLOAD_ERR_FORM_SIZE => 'Filesize exceeds the MAX_FILE_SIZE directive that was specified in the html form', /*2*/\UPLOAD_ERR_FORM_SIZE => 'Filesize exceeds the MAX_FILE_SIZE directive that was specified in the html form',
\UPLOAD_ERR_PARTIAL => 'File was only partially uploaded', /*3*/\UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
\UPLOAD_ERR_NO_FILE => 'No file was uploaded', /*4*/\UPLOAD_ERR_NO_FILE => 'No file was uploaded',
\UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder', /*6*/\UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder',
\UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk', /*7*/\UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
\UPLOAD_ERR_EXTENSION => 'File upload stopped by extension', /*8*/\UPLOAD_ERR_EXTENSION => 'File upload stopped by extension',
98 => 'Invalid file type', 98 => 'Invalid file type',
99 => 'Unknown error', 99 => 'Unknown error',
1001 => 'Filesize exceeds the config setting', 1001 => 'Filesize exceeds the config setting',
@ -28,7 +28,7 @@ abstract class UploadError
public static function getMessage(int $code): string public static function getMessage(int $code): string
{ {
return isset(static::$messages[$code]) ? static::$messages[$code] : 0; return isset(static::$messages[$code]) ? static::$messages[$code] : '';
} }
public static function getUserMessage(int $iError, int &$iClientError): string public static function getUserMessage(int $iError, int &$iClientError): string
@ -39,12 +39,12 @@ abstract class UploadError
case \UPLOAD_ERR_PARTIAL: case \UPLOAD_ERR_PARTIAL:
case \UPLOAD_ERR_NO_FILE: case \UPLOAD_ERR_NO_FILE:
case static::FILE_TYPE: case static::FILE_TYPE:
case static::EMPTY_FILE:
break; break;
case \UPLOAD_ERR_INI_SIZE: case \UPLOAD_ERR_INI_SIZE:
case \UPLOAD_ERR_FORM_SIZE: case \UPLOAD_ERR_FORM_SIZE:
case static::CONFIG_SIZE: case static::CONFIG_SIZE:
case static::EMPTY_FILES_DATA:
return 'File is too big'; return 'File is too big';
case \UPLOAD_ERR_NO_TMP_DIR: case \UPLOAD_ERR_NO_TMP_DIR:

View file

@ -15,7 +15,7 @@ abstract class Account implements \JsonSerializable
private string $sLogin = ''; private string $sLogin = '';
private ?SensitiveString $sPassword = null; private ?SensitiveString $oPassword = null;
private string $sSmtpLogin = ''; private string $sSmtpLogin = '';
@ -46,7 +46,7 @@ abstract class Account implements \JsonSerializable
public function IncPassword() : string public function IncPassword() : string
{ {
return $this->sPassword ? $this->sPassword->getValue() : ''; return $this->oPassword ? $this->oPassword->getValue() : '';
} }
public function OutLogin() : string public function OutLogin() : string
@ -66,16 +66,13 @@ abstract class Account implements \JsonSerializable
$this->sEmail, $this->sEmail,
$this->sLogin, $this->sLogin,
// \json_encode($this->Domain()), // \json_encode($this->Domain()),
// $this->sPassword // $this->oPassword
])); ]));
} }
public function SetPassword( public function SetPassword(SensitiveString $oPassword) : void
#[\SensitiveParameter]
string $sPassword
) : void
{ {
$this->sPassword = new SensitiveString($sPassword); $this->oPassword = $oPassword;
} }
public function SetSmtpPassword( public function SetSmtpPassword(
@ -125,12 +122,11 @@ abstract class Account implements \JsonSerializable
public static function NewInstanceFromCredentials(\RainLoop\Actions $oActions, public static function NewInstanceFromCredentials(\RainLoop\Actions $oActions,
string $sEmail, string $sLogin, string $sEmail, string $sLogin,
#[\SensitiveParameter] SensitiveString $oPassword,
string $sPassword,
bool $bThrowException = false): ?self bool $bThrowException = false): ?self
{ {
$oAccount = null; $oAccount = null;
if ($sEmail && $sLogin && $sPassword) { if ($sEmail && $sLogin && \strlen($oPassword)) {
$oDomain = $oActions->DomainProvider()->Load(\MailSo\Base\Utils::GetDomainFromEmail($sEmail), true); $oDomain = $oActions->DomainProvider()->Load(\MailSo\Base\Utils::GetDomainFromEmail($sEmail), true);
if ($oDomain) { if ($oDomain) {
if ($oDomain->ValidateWhiteList($sEmail, $sLogin)) { if ($oDomain->ValidateWhiteList($sEmail, $sLogin)) {
@ -138,7 +134,7 @@ abstract class Account implements \JsonSerializable
$oAccount->sEmail = \MailSo\Base\Utils::IdnToAscii($sEmail, true); $oAccount->sEmail = \MailSo\Base\Utils::IdnToAscii($sEmail, true);
$oAccount->sLogin = \MailSo\Base\Utils::IdnToAscii($sLogin); $oAccount->sLogin = \MailSo\Base\Utils::IdnToAscii($sLogin);
$oAccount->SetPassword($sPassword); $oAccount->SetPassword($oPassword);
$oAccount->oDomain = $oDomain; $oAccount->oDomain = $oDomain;
$oActions->Plugins()->RunHook('filter.account', array($oAccount)); $oActions->Plugins()->RunHook('filter.account', array($oAccount));
@ -194,25 +190,22 @@ abstract class Account implements \JsonSerializable
$oActions, $oActions,
$aAccountHash['email'], $aAccountHash['email'],
$aAccountHash['login'], $aAccountHash['login'],
$aAccountHash['pass'], new SensitiveString($aAccountHash['pass']),
$bThrowExceptionOnFalse $bThrowExceptionOnFalse
); );
if ($oAccount) { if ($oAccount) {
if (isset($aAccountHash['name'])) { if (isset($aAccountHash['name'])) {
$oAccount->sName = $aAccountHash['name']; $oAccount->sName = $aAccountHash['name'];
} }
$oActions->logMask($oAccount->IncPassword());
// init smtp user/password // init smtp user/password
if (isset($aAccountHash['smtp'])) { if (isset($aAccountHash['smtp'])) {
$oAccount->sSmtpLogin = $aAccountHash['smtp']['user']; $oAccount->sSmtpLogin = $aAccountHash['smtp']['user'];
$oAccount->SetSmtpPassword($aAccountHash['smtp']['pass']); $oAccount->SetSmtpPassword($aAccountHash['smtp']['pass']);
$oActions->logMask($oAccount->sSmtpPassword);
} }
// init proxy user/password // init proxy user/password
if (isset($aAccountHash['proxy'])) { if (isset($aAccountHash['proxy'])) {
$oAccount->sProxyAuthUser = $aAccountHash['proxy']['user']; $oAccount->sProxyAuthUser = $aAccountHash['proxy']['user'];
$oAccount->SetProxyAuthPassword($aAccountHash['proxy']['pass']); $oAccount->SetProxyAuthPassword($aAccountHash['proxy']['pass']);
$oActions->logMask($oAccount->sProxyAuthPassword);
} }
} }
} }
@ -240,7 +233,7 @@ abstract class Account implements \JsonSerializable
$oImapClient->Connect($oSettings); $oImapClient->Connect($oSettings);
$oPlugins->RunHook('imap.after-connect', array($this, $oImapClient, $oSettings)); $oPlugins->RunHook('imap.after-connect', array($this, $oImapClient, $oSettings));
$oSettings->Password = $this->sPassword; $oSettings->Password = $this->oPassword;
return $this->netClientLogin($oImapClient, $oPlugins); return $this->netClientLogin($oImapClient, $oPlugins);
} }
@ -264,7 +257,7 @@ abstract class Account implements \JsonSerializable
throw new RequireCredentialsException throw new RequireCredentialsException
} }
*/ */
$oSettings->Password = $this->sSmtpPassword ?: $this->sPassword; $oSettings->Password = $this->sSmtpPassword ?: $this->oPassword;
return $this->netClientLogin($oSmtpClient, $oPlugins); return $this->netClientLogin($oSmtpClient, $oPlugins);
} }
@ -279,7 +272,7 @@ abstract class Account implements \JsonSerializable
$oSieveClient->Connect($oSettings); $oSieveClient->Connect($oSettings);
$oPlugins->RunHook('sieve.after-connect', array($this, $oSieveClient, $oSettings)); $oPlugins->RunHook('sieve.after-connect', array($this, $oSieveClient, $oSettings));
$oSettings->Password = $this->sPassword; $oSettings->Password = $this->oPassword;
return $this->netClientLogin($oSieveClient, $oPlugins); return $this->netClientLogin($oSieveClient, $oPlugins);
} }
@ -306,4 +299,11 @@ abstract class Account implements \JsonSerializable
return $bResult; return $bResult;
} }
/*
// Stores settings in AdditionalAccount else MainAccount
public function settingsLocal() : \RainLoop\Settings
{
return \RainLoop\Api::Actions()->SettingsProvider(true)->Load($this);
}
*/
} }

View file

@ -37,6 +37,16 @@ class Identity implements \JsonSerializable
$this->sEmail = $sEmail; $this->sEmail = $sEmail;
} }
function __get(string $name)
{
if (!\property_exists($this, $name)) {
$name = \substr($name, 1);
}
if (\property_exists($this, $name)) {
return $this->$name;
}
}
function toMime() : \MailSo\Mime\Email function toMime() : \MailSo\Mime\Email
{ {
return new \MailSo\Mime\Email($this->sEmail, $this->sName); return new \MailSo\Mime\Email($this->sEmail, $this->sName);

View file

@ -63,4 +63,11 @@ class MainAccount extends Account
{ {
$this->sCryptKey = new SensitiveString($sKey); $this->sCryptKey = new SensitiveString($sKey);
} }
/*
// Stores settings in MainAccount
public function settings() : \RainLoop\Settings
{
return \RainLoop\Api::Actions()->SettingsProvider()->Load($this);
}
*/
} }

View file

@ -58,7 +58,6 @@ class Notifications
const MailServerError = 901; const MailServerError = 901;
const ClientViewError = 902; const ClientViewError = 902;
const InvalidInputArgument = 903; const InvalidInputArgument = 903;
// const UnknownNotification = 998;
const UnknownError = 999; const UnknownError = 999;
// Admin // Admin

View file

@ -419,7 +419,7 @@ class Manager
$aData[$sPluginName] = $aPluginSettings; $aData[$sPluginName] = $aPluginSettings;
$oSettings->SetConf('Plugins',$aData); $oSettings->SetConf('Plugins',$aData);
return $this->oActions->SettingsProvider()->Save($oAccount, $oSettings); return $oSettings->save();
} }
} }

View file

@ -6,18 +6,5 @@ abstract class AbstractProvider
{ {
use \MailSo\Log\Inherit; use \MailSo\Log\Inherit;
/** abstract public function IsActive() : bool;
* @var \RainLoop\Model\Account
*/
protected $oAccount;
public function IsActive() : bool
{
return false;
}
public function SetAccount(\RainLoop\Model\Account $oAccount)
{
$this->oAccount = $oAccount;
}
} }

View file

@ -2,7 +2,7 @@
namespace RainLoop\Providers\AddressBook\Enumerations; namespace RainLoop\Providers\AddressBook\Enumerations;
class PropertyType abstract class PropertyType
{ {
const UNKNOWN = 0; const UNKNOWN = 0;

View file

@ -27,6 +27,11 @@ class Identities extends AbstractProvider
}); });
} }
public function IsActive() : bool
{
return true;
}
/** /**
* @param Account $account * @param Account $account
* @param bool $allowMultipleIdentities * @param bool $allowMultipleIdentities

View file

@ -2,30 +2,30 @@
namespace RainLoop\Providers; namespace RainLoop\Providers;
use RainLoop\Model\Account;
use RainLoop\Providers\Settings\ISettings;
class Settings extends \RainLoop\Providers\AbstractProvider class Settings extends \RainLoop\Providers\AbstractProvider
{ {
/** private ISettings $oDriver;
* @var \RainLoop\Providers\Settings\ISettings
*/
private $oDriver;
public function __construct(\RainLoop\Providers\Settings\ISettings $oDriver) public function __construct(ISettings $oDriver)
{ {
$this->oDriver = $oDriver; $this->oDriver = $oDriver;
} }
public function Load(\RainLoop\Model\Account $oAccount) : \RainLoop\Settings public function Load(Account $oAccount) : \RainLoop\Settings
{ {
return new \RainLoop\Settings($this->oDriver->Load($oAccount)); return new \RainLoop\Settings($this, $oAccount, $this->oDriver->Load($oAccount));
} }
public function Save(\RainLoop\Model\Account $oAccount, \RainLoop\Settings $oSettings) : bool public function Save(Account $oAccount, \RainLoop\Settings $oSettings) : bool
{ {
return $this->oDriver->Save($oAccount, $oSettings); return $this->oDriver->Save($oAccount, $oSettings);
} }
public function IsActive() : bool public function IsActive() : bool
{ {
return $this->oDriver instanceof \RainLoop\Providers\Settings\ISettings; return true;
} }
} }

View file

@ -2,25 +2,26 @@
namespace RainLoop\Providers\Settings; namespace RainLoop\Providers\Settings;
use RainLoop\Model\Account;
use RainLoop\Providers\Storage;
use RainLoop\Providers\Storage\Enumerations\StorageType;
class DefaultSettings implements ISettings class DefaultSettings implements ISettings
{ {
const FILE_NAME = 'settings'; const FILE_NAME = 'settings';
const FILE_NAME_LOCAL = 'settings_local'; const FILE_NAME_LOCAL = 'settings_local';
/** private Storage $oStorageProvider;
* @var \RainLoop\Providers\Storage
*/
private $oStorageProvider;
public function __construct(\RainLoop\Providers\Storage $oStorageProvider) public function __construct(Storage $oStorageProvider)
{ {
$this->oStorageProvider = $oStorageProvider; $this->oStorageProvider = $oStorageProvider;
} }
public function Load(\RainLoop\Model\Account $oAccount) : array public function Load(Account $oAccount) : array
{ {
$sValue = $this->oStorageProvider->Get($oAccount, $sValue = $this->oStorageProvider->Get($oAccount,
\RainLoop\Providers\Storage\Enumerations\StorageType::CONFIG, StorageType::CONFIG,
$this->oStorageProvider->IsLocal() ? $this->oStorageProvider->IsLocal() ?
self::FILE_NAME_LOCAL : self::FILE_NAME_LOCAL :
self::FILE_NAME self::FILE_NAME
@ -36,20 +37,20 @@ class DefaultSettings implements ISettings
return array(); return array();
} }
public function Save(\RainLoop\Model\Account $oAccount, \RainLoop\Settings $oSettings) : bool public function Save(Account $oAccount, \RainLoop\Settings $oSettings) : bool
{ {
return $this->oStorageProvider->Put($oAccount, return $this->oStorageProvider->Put($oAccount,
\RainLoop\Providers\Storage\Enumerations\StorageType::CONFIG, StorageType::CONFIG,
$this->oStorageProvider->IsLocal() ? $this->oStorageProvider->IsLocal() ?
self::FILE_NAME_LOCAL : self::FILE_NAME_LOCAL :
self::FILE_NAME, self::FILE_NAME,
\json_encode($oSettings)); \json_encode($oSettings));
} }
public function Delete(\RainLoop\Model\Account $oAccount) : bool public function Delete(Account $oAccount) : bool
{ {
return $this->oStorageProvider->Clear($oAccount, return $this->oStorageProvider->Clear($oAccount,
\RainLoop\Providers\Storage\Enumerations\StorageType::CONFIG, StorageType::CONFIG,
$this->oStorageProvider->IsLocal() ? $this->oStorageProvider->IsLocal() ?
self::FILE_NAME_LOCAL : self::FILE_NAME_LOCAL :
self::FILE_NAME); self::FILE_NAME);

View file

@ -2,13 +2,18 @@
namespace RainLoop\Providers\Storage\Enumerations; namespace RainLoop\Providers\Storage\Enumerations;
class StorageType /**
{ * PHP 8.1
const USER = 1; enum StorageType: int {
const CONFIG = 2; case USER = 1;
const NOBODY = 3; }
const SIGN_ME = 4; */
const SESSION = 5; abstract class StorageType
const PGP = 6; {
const ROOT = 7; const CONFIG = 1;
const NOBODY = 2;
const SIGN_ME = 3;
const SESSION = 4;
const PGP = 5;
const ROOT = 6;
} }

View file

@ -90,58 +90,57 @@ class FileStorage implements \RainLoop\Providers\Storage\IStorage
*/ */
public function GenerateFilePath($mAccount, int $iStorageType, bool $bMkDir = false) : string public function GenerateFilePath($mAccount, int $iStorageType, bool $bMkDir = false) : string
{ {
$sEmail = $sSubFolder = ''; $sEmail = $sSubFolder = $sFilePath = '';
if (null === $mAccount) { if (null === $mAccount || StorageType::NOBODY === $iStorageType) {
$iStorageType = StorageType::NOBODY; $sFilePath = $this->sDataPath.'/__nobody__/';
} else if ($mAccount instanceof \RainLoop\Model\MainAccount) { } else {
$sEmail = $mAccount->Email(); if ($mAccount instanceof \RainLoop\Model\MainAccount) {
} else if ($mAccount instanceof \RainLoop\Model\AdditionalAccount) { $sEmail = $mAccount->Email();
$sEmail = $mAccount->ParentEmail(); } else if ($mAccount instanceof \RainLoop\Model\AdditionalAccount) {
if ($this->bLocal) { $sEmail = $mAccount->ParentEmail();
$sSubFolder = $mAccount->Email(); if ($this->bLocal) {
} $sSubFolder = $mAccount->Email();
} else if (\is_string($mAccount)) {
$sEmail = $mAccount;
}
if ($sEmail) {
if (StorageType::SIGN_ME === $iStorageType) {
$sSubFolder = '.sign_me';
} else if (StorageType::SESSION === $iStorageType) {
$sSubFolder = '.sessions';
} else if (StorageType::PGP === $iStorageType) {
$sSubFolder = '.pgp';
} else if (StorageType::ROOT === $iStorageType) {
$sSubFolder = '';
}
}
$sFilePath = '';
switch ($iStorageType)
{
case StorageType::NOBODY:
$sFilePath = $this->sDataPath.'/__nobody__/';
break;
case StorageType::SIGN_ME:
case StorageType::SESSION:
case StorageType::CONFIG:
case StorageType::PGP:
case StorageType::ROOT:
if (empty($sEmail)) {
return '';
} }
if (\is_dir("{$this->sDataPath}/cfg")) { } else if (\is_string($mAccount)) {
\SnappyMail\Upgrade::FileStorage($this->sDataPath); $sEmail = $mAccount;
}
if ($sEmail) {
// these are never local
if (StorageType::SIGN_ME === $iStorageType) {
$sSubFolder = '.sign_me';
} else if (StorageType::SESSION === $iStorageType) {
$sSubFolder = '.sessions';
} else if (StorageType::PGP === $iStorageType) {
$sSubFolder = '.pgp';
} else if (StorageType::ROOT === $iStorageType) {
$sSubFolder = '';
} }
$aEmail = \explode('@', $sEmail ?: 'nobody@unknown.tld'); }
$sDomain = \trim(1 < \count($aEmail) ? \array_pop($aEmail) : '');
$sFilePath = $this->sDataPath switch ($iStorageType)
.'/'.\MailSo\Base\Utils::SecureFileName($sDomain ?: 'unknown.tld') {
.'/'.\MailSo\Base\Utils::SecureFileName(\implode('@', $aEmail) ?: '.unknown') case StorageType::CONFIG:
.'/'.($sSubFolder ? \MailSo\Base\Utils::SecureFileName($sSubFolder).'/' : ''); case StorageType::SIGN_ME:
break; case StorageType::SESSION:
default: case StorageType::PGP:
throw new \Exception("Invalid storage type {$iStorageType}"); case StorageType::ROOT:
if (empty($sEmail)) {
return '';
}
if (\is_dir("{$this->sDataPath}/cfg")) {
\SnappyMail\Upgrade::FileStorage($this->sDataPath);
}
$aEmail = \explode('@', $sEmail ?: 'nobody@unknown.tld');
$sDomain = \trim(1 < \count($aEmail) ? \array_pop($aEmail) : '');
$sFilePath = $this->sDataPath
.'/'.\MailSo\Base\Utils::SecureFileName($sDomain ?: 'unknown.tld')
.'/'.\MailSo\Base\Utils::SecureFileName(\implode('@', $aEmail) ?: '.unknown')
.'/'.($sSubFolder ? \MailSo\Base\Utils::SecureFileName($sSubFolder).'/' : '');
break;
default:
throw new \Exception("Invalid storage type {$iStorageType}");
}
} }
if ($bMkDir && !empty($sFilePath) && !\is_dir($sFilePath) && !\mkdir($sFilePath, 0700, true)) if ($bMkDir && !empty($sFilePath) && !\is_dir($sFilePath) && !\mkdir($sFilePath, 0700, true))

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