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
### Added

View file

@ -140,26 +140,26 @@ RainLoop 1.17 vs SnappyMail
|js/* |RainLoop |Snappy |
|--------------- |--------: |--------: |
|admin.js |2.170.153 | 81.641 |
|app.js |4.207.787 | 426.852 |
|admin.js |2.170.153 | 82.445 |
|app.js |4.207.787 | 429.565 |
|boot.js | 868.735 | 4.142 |
|libs.js | 658.812 | 192.625 |
|libs.js | 658.812 | 192.786 |
|sieve.js | 0 | 84.707 |
|polyfills.js | 334.608 | 0 |
|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 |
|--------------- |--------: |--------: |------: |------: |--------: |--------: |
|admin.min.js | 256.831 | 40.142 | 73.606 | 13.445 | 60.877 | 12.047 |
|app.min.js | 515.367 | 193.738 |139.456 | 65.570 |110.485 | 56.281 |
|admin.min.js | 256.831 | 40.573 | 73.606 | 13.585 | 60.877 | 12.188 |
|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 |
|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 |
|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+sieve |1.217.635 | 329.650 |358.761 |111.551 |299.485 | 97.493 |
|TOTAL admin | 959.099 | 134.890 |292.911 | 49.061 |249.877 | 43.900 |
|TOTAL user |1.217.635 | 289.843 |358.761 |101.789 |299.485 | 88.516 |
|TOTAL user+sieve |1.217.635 | 331.007 |358.761 |112.154 |299.485 | 97.875 |
|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.
@ -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 |
|------------ |-------: |------: |------: |------: |--------: |
|app.css | 340.331 | 84.435 | 46.946 | 17.612 | 15.091 |
|app.min.css | 274.947 | 67.825 | 39.647 | 15.526 | 13.540 |
|app.css | 340.331 | 84.472 | 46.946 | 17.622 | 15.112 |
|app.min.css | 274.947 | 67.857 | 39.647 | 15.537 | 13.549 |
|boot.css | | 1.326 | | 664 | 545 |
|boot.min.css | | 1.071 | | 590 | 474 |
|admin.css | | 30.576 | | 7.013 | 6.096 |

View file

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

View file

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

View file

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

View file

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

5
dev/External/ko.js vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -35,7 +35,8 @@ export const GnuPGUserStore = new class {
this.keyring = null;
this.publicKeys([]);
this.privateKeys([]);
Remote.request('GnupgGetKeys',
SettingsCapa('GnuPG')
&& Remote.request('GnupgGetKeys',
(iError, oData) => {
if (oData?.Result) {
this.keyring = oData.Result;
@ -68,11 +69,10 @@ export const GnuPGUserStore = new class {
}
};
if (isPrivate) {
key.password = async (btnTxt = 'SIGN') => {
const pass = await Passphrases.ask(
key,
key.password = async btnTxt => {
const pass = await Passphrases.ask(key,
'GnuPG key<br>' + key.id + ' ' + key.emails[0],
'CRYPTO/'+btnTxt
btnTxt
);
pass && pass.remember && Passphrases.set(key, pass.password);
return pass?.password;
@ -82,8 +82,8 @@ export const GnuPGUserStore = new class {
if (key.armor) {
callback && callback();
} else {
let pass = isPrivate ? await key.password('POPUP_VIEW_TITLE') : '';
if (null != pass) {
let pass = isPrivate ? await key.password('OPENPGP/POPUP_VIEW_TITLE') : '';
if (null != pass) try {
const result = await Remote.post('GnupgExportKey', null, {
keyId: key.id,
isPrivate: isPrivate,
@ -95,15 +95,22 @@ export const GnuPGUserStore = new class {
} else {
Passphrases.delete(key);
}
} catch (e) {
Passphrases.delete(key);
alert(e.message);
}
}
return key.armor;
};
key.view = () => key.fetch(() => showScreenPopup(OpenPgpKeyPopupView, [key]));
return key;
};
this.publicKeys(oData.Result.public.map(key => initKey(key, 0)));
this.privateKeys(oData.Result.private.map(key => initKey(key, 1)));
},
collator = new Intl.Collator(undefined, {sensitivity: 'base'}),
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');
}
}
@ -178,22 +185,27 @@ export const GnuPGUserStore = new class {
uid: message.uid,
partId: pgpInfo.partId,
keyId: key.id,
passphrase: await key.password('DECRYPT'),
passphrase: await key.password('CRYPTO/DECRYPT'),
data: '' // message.plain() optional
}
if (null !== params.passphrase) {
const result = await Remote.post('GnupgDecrypt', null, params);
if (result?.Result && false !== result.Result.data) {
return result.Result;
if (null != params.passphrase) {
try {
const response = await Remote.post('GnupgDecrypt', null, params);
if (response?.Result?.data) {
return response.Result;
}
throw response;
} catch (e) {
Passphrases.delete(key);
throw e;
}
Passphrases.delete(key);
}
}
}
}
async verify(message) {
let data = message.pgpSigned(); // { bodyPartId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
let data = message.pgpSigned(); // { partId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
if (data) {
data = { ...data }; // clone
// const sender = message.from[0].email;
@ -208,14 +220,15 @@ export const GnuPGUserStore = new class {
if (response?.Result) {
return {
fingerprint: response.Result.fingerprint,
success: 0 == response.Result.status // GOODSIG
success: 0 == response.Result.status, // GOODSIG
error: response.Result.message
};
}
}
}
async sign(privateKey) {
return await 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;
}
const key = privateKey.id,
pass = await Passphrases.ask(
key,
pass = await Passphrases.ask(privateKey,
'OpenPGP.js key<br>' + key + ' ' + privateKey.emails[0],
'CRYPTO/'+btnTxt
);
@ -35,11 +34,20 @@ const
privateKey: privateKey.key,
passphrase
});
result && pass.remember && Passphrases.set(key, passphrase);
result && pass.remember && Passphrases.set(privateKey, passphrase);
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)
* This should be compatible with the old OpenPGP.js v2
@ -69,15 +77,11 @@ const
class OpenPgpKeyModel {
constructor(armor, key) {
this.key = key;
const aEmails = [];
if (key.users) {
key.users.forEach(user => user.userID.email && aEmails.push(user.userID.email));
}
this.id = key.getKeyID().toHex().toUpperCase();
this.fingerprint = key.getFingerprint();
this.can_encrypt = !!key.getEncryptionKey();
this.can_sign = !!key.getSigningKey();
this.emails = aEmails;
this.emails = key.users.map(user => user.userID.email).filter(email => email);
this.armor = armor;
this.askDelete = ko.observable(false);
this.openForDeletion = ko.observable(null).askDeleteHelper();
@ -85,6 +89,15 @@ class OpenPgpKeyModel {
// key.getPrimaryUser()
}
/*
get id() { return this.key.getKeyID().toHex().toUpperCase(); }
get fingerprint() { return this.key.getFingerprint(); }
get can_encrypt() { return !!this.key.getEncryptionKey(); }
get can_sign() { return !!this.key.getSigningKey(); }
get emails() { return this.key.users.map(user => user.userID.email).filter(email => email); }
get armor() { return this.key.armor(); }
*/
view() {
showScreenPopup(OpenPgpKeyPopupView, [this]);
}
@ -115,10 +128,6 @@ export const OpenPGPUserStore = new class {
loadKeyrings() {
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 => {
this.publicKeys(dedup(keys));
console.log('openpgp.js public keys loaded');
@ -138,22 +147,29 @@ export const OpenPGPUserStore = new class {
}
importKey(armoredKey) {
window.openpgp && openpgp.readKey({armoredKey:armoredKey}).then(key => {
if (!key.err) {
key = new OpenPgpKeyModel(armoredKey, key);
if (key.key.isPrivate()) {
if (!this.privateKeys.find(entry => entry.fingerprint == key.fingerprint)) {
this.privateKeys.push(key);
storeOpenPgpKeys(this.privateKeys, privateKeysItem);
}
} else {
if (!this.publicKeys.find(entry => entry.fingerprint == key.fingerprint)) {
this.publicKeys.push(key);
storeOpenPgpKeys(this.publicKeys, publicKeysItem);
}
this.importKeys([armoredKey]);
}
async importKeys(keys) {
if (window.openpgp) {
const privateKeys = this.privateKeys(),
publicKeys = this.publicKeys();
for (const armoredKey of keys) try {
let key = await openpgp.readKey({armoredKey:armoredKey});
if (!key.err) {
key = new OpenPgpKeyModel(armoredKey, key);
const keys = key.key.isPrivate() ? privateKeys : publicKeys;
keys.find(entry => entry.fingerprint == key.fingerprint)
|| keys.push(key);
}
} catch (e) {
console.error(e, armoredKey);
}
});
this.privateKeys(sort(privateKeys));
this.publicKeys(sort(publicKeys));
storeOpenPgpKeys(privateKeys, privateKeysItem);
storeOpenPgpKeys(publicKeys, publicKeysItem);
}
}
/**
@ -229,7 +245,7 @@ export const OpenPGPUserStore = new class {
* https://docs.openpgpjs.org/#sign-and-verify-cleartext-messages
*/
async verify(message) {
const data = message.pgpSigned(), // { bodyPartId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
const data = message.pgpSigned(), // { partId: "1", sigPartId: "2", micAlg: "pgp-sha256" }
publicKey = this.publicKeys().find(key => key.emails.includes(message.from[0].email));
if (data && publicKey) {
data.folder = message.folder;

View file

@ -8,12 +8,10 @@ import { SettingsCapa, SettingsGet } from 'Common/Globals';
import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import { MailvelopeUserStore } from 'Stores/User/Mailvelope';
import Remote from 'Remote/User/Fetch';
// https://mailvelope.github.io/mailvelope/Keyring.html
let mailvelopeKeyring = null;
export const
BEGIN_PGP_MESSAGE = '-----BEGIN PGP MESSAGE-----',
// BEGIN_PGP_SIGNATURE = '-----BEGIN PGP SIGNATURE-----',
@ -37,32 +35,9 @@ export const
}
loadKeyrings(identifier) {
identifier = identifier || SettingsGet('Email');
if (window.mailvelope) {
const fn = keyring => {
mailvelopeKeyring = keyring;
console.log('mailvelope ready');
};
mailvelope.getKeyring().then(fn, err => {
if (identifier) {
// attempt to create a new keyring for this app/user
mailvelope.createKeyring(identifier).then(fn, err => console.error(err));
} else {
console.error(err);
}
});
addEventListener('mailvelope-disconnect', event => {
alert('Mailvelope is updated to version ' + event.detail.version + '. Reload page');
}, false);
} else {
addEventListener('mailvelope', () => this.loadKeyrings(identifier));
}
MailvelopeUserStore.loadKeyring(identifier);
OpenPGPUserStore.loadKeyrings();
if (SettingsCapa('GnuPG')) {
GnuPGUserStore.loadKeyrings();
}
GnuPGUserStore.loadKeyrings();
}
/**
@ -92,22 +67,14 @@ export const
}
);
}
OpenPGPUserStore.isSupported() && 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;
OpenPGPUserStore.importKey(key);
}
/**
* Checks if verifying/encrypting a message is possible with given email addresses.
* Returns the first library that can.
*/
async hasPublicKeyForEmails(recipients) {
hasPublicKeyForEmails(recipients) {
if (recipients.length) {
if (GnuPGUserStore.hasPublicKeyForEmails(recipients)) {
return 'gnupg';
@ -119,40 +86,15 @@ export const
return false;
}
async getMailvelopePrivateKeyFor(email/*, sign*/) {
if (mailvelopeKeyring && await mailvelopeKeyring.hasPrivateKey({email:email})) {
return ['mailvelope', email];
}
return false;
}
/**
* Checks if signing a message is possible with given email address.
* Returns the first library that can.
*/
async getKeyForSigning(email) {
let key = OpenPGPUserStore.getPrivateKeyFor(email, 1);
if (key) {
return ['openpgp', key];
}
key = GnuPGUserStore.getPrivateKeyFor(email, 1);
if (key) {
return ['gnupg', key];
}
// return await this.getMailvelopePrivateKeyFor(email, 1);
}
async decrypt(message) {
const sender = message.from[0].email,
armoredText = message.plain();
const armoredText = message.plain();
if (!this.isEncrypted(armoredText)) {
throw Error('Not armored text');
}
// Try OpenPGP.js
if (OpenPGPUserStore.isSupported()) {
const sender = message.from[0].email;
let result = await OpenPGPUserStore.decrypt(armoredText, sender);
if (result) {
return result;
@ -160,68 +102,20 @@ export const
}
// Try Mailvelope (does not support inline images)
if (mailvelopeKeyring) {
try {
let emails = [...message.from,...message.to,...message.cc].validUnique(),
i = emails.length;
while (i--) {
if (await this.getMailvelopePrivateKeyFor(emails[i].email)) {
/**
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createEncryptedFormContainer
* Creates an iframe to display an encrypted form
*/
// mailvelope.createEncryptedFormContainer('#mailvelope-form');
/**
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createDisplayContainer
* Creates an iframe to display the decrypted content of the encrypted mail.
*/
const body = message.body;
body.textContent = '';
let result = await mailvelope.createDisplayContainer(
'#'+body.id,
armoredText,
mailvelopeKeyring,
{
senderAddress: sender
// emails[i].email
}
);
if (result) {
if (result.error?.message) {
if ('PWD_DIALOG_CANCEL' !== result.error.code) {
alert(result.error.code + ': ' + result.error.message);
}
} else {
body.classList.add('mailvelope');
return true;
}
}
break;
}
}
} catch (err) {
console.error(err);
}
}
// Now try GnuPG
return GnuPGUserStore.decrypt(message);
return (await MailvelopeUserStore.decrypt(message))
// Or try GnuPG
|| GnuPGUserStore.decrypt(message);
}
async verify(message) {
const signed = message.pgpSigned();
const signed = message.pgpSigned(),
sender = message.from[0].email;
if (signed) {
const sender = message.from[0].email,
gnupg = GnuPGUserStore.hasPublicKeyForEmails([sender]),
openpgp = OpenPGPUserStore.hasPublicKeyForEmails([sender]);
// Detached signature use GnuPG first, else we must download whole message
if (gnupg && signed.sigPartId) {
return GnuPGUserStore.verify(message);
}
if (openpgp) {
// OpenPGP only when inline, else we must download the whole message
if (!signed.sigPartId && OpenPGPUserStore.hasPublicKeyForEmails([sender])) {
return OpenPGPUserStore.verify(message);
}
if (gnupg) {
if (GnuPGUserStore.hasPublicKeyForEmails([sender])) {
return GnuPGUserStore.verify(message);
}
// Mailvelope can't
@ -248,35 +142,4 @@ export const
}
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);
Remote.request('SMimeGetCertificates', (iError, oData) => {
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-y: auto;
min-width: 100px;
color: var(--folders-color, #333);
}
hr {
@ -77,22 +78,18 @@
white-space: nowrap;
a {
display: block;
position: relative;
z-index: 1;
line-height: 34px;
background-color: transparent;
vertical-align: middle;
color: var(--folders-disabled-color, #666);
border-left: 3px solid transparent;
color: var(--folders-color, #333);
display: block;
line-height: 34px;
padding: 0 2em 0 @folderItemPadding;
position: relative;
text-decoration: none;
vertical-align: middle;
z-index: 1;
&.selectable {
color: var(--folders-color, #333);
cursor: pointer;
&:hover {
@ -115,6 +112,7 @@
}
}
&:not(.selectable) {
color: var(--folders-disabled-color, #666);
cursor: default;
font-style: italic;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

7
dev/prototype.js vendored
View file

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

View file

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

View file

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

View file

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

View file

@ -38,7 +38,7 @@ services:
# snappymail will be available at: https://snappymail.example.com
snappymail:
image: leojonathanoh/snappymail:pr-1
image: djmaze/snappymail:latest
labels:
- "traefik.enable=true"
- "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.

View file

@ -4,7 +4,7 @@ RUN mkdir -p /app/code
WORKDIR /app/code
# 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 && \
unzip /tmp/snappymail.zip -d /app/code && \
rm /tmp/snappymail.zip && \

View file

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

View file

@ -72,7 +72,7 @@ class InstallStep implements IRepairStep
if ('12345' == $sPassword || !$sPassword) {
$output->info('Generate admin password');
$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");
$bSave = true;
}

View file

@ -91,7 +91,6 @@ class SnappyMailHelper
*/
if ($doLogin && $aCredentials[1] && $aCredentials[2]) {
try {
$oActions->Logger()->AddSecret($aCredentials[2]);
$oAccount = $oActions->LoginProcess($aCredentials[1], $aCredentials[2]);
if ($oAccount) {
// Must be here due to bug #1241
@ -202,9 +201,10 @@ class SnappyMailHelper
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();
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()
sub script_snappymail_versions
{
return ( "2.35.0" );
return ( "2.35.2" );
}
sub script_snappymail_version_desc

View file

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

View file

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

View file

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

View file

@ -132,6 +132,26 @@ trait Messages
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
*

View file

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

View file

@ -126,7 +126,6 @@ class MailClient
$this->oImapClient->FolderExamine($sFolderName);
$oBodyStructure = null;
$oMessage = null;
$aFetchItems = array(
FetchType::UID,
@ -169,11 +168,10 @@ class MailClient
}
$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 $pgpSigned = null;
public ?array $pgpSigned = null;
private ?array $pgpEncrypted = null;
private ?array $smimeSigned = null;
public ?array $smimeSigned = null;
private ?array $smimeEncrypted = null;
private ?\MailSo\Mime\EmailCollection
@ -341,7 +341,7 @@ class Message implements \JsonSerializable
$oMessage->pgpSigned = [
// /?/Raw/&q[]=/0/Download/&q[]=/...
// /?/Raw/&q[]=/0/View/&q[]=/...
'bodyPartId' => $oPart->SubParts()[0]->PartID(),
'partId' => $oPart->SubParts()[0]->PartID(),
'sigPartId' => $oPart->SubParts()[1]->PartID(),
'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.
// But Dovecot does not return it with BODY.PEEK[1], so we also use BODY.PEEK[1.MIME].
$sPgpText = \trim(
\trim($oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oMessage->pgpSigned['bodyPartId'].'.MIME]'))
\trim($oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oMessage->pgpSigned['partId'].'.MIME]'))
. "\r\n\r\n"
. \trim($oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oMessage->pgpSigned['bodyPartId'].']'))
. \trim($oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oMessage->pgpSigned['partId'].']'))
);
if ($sPgpText) {
$oMessage->pgpSigned['body'] = $sPgpText;
@ -398,7 +398,7 @@ class Message implements \JsonSerializable
// Cleartext Signature
if (!$oMessage->pgpSigned && \str_contains($sText, '-----BEGIN PGP SIGNED MESSAGE-----')) {
$oMessage->pgpSigned = [
'bodyPartId' => $oPart->PartID()
'partId' => $oPart->PartID()
];
}
@ -543,7 +543,7 @@ class Message implements \JsonSerializable
$result['html'] = $this->sHtml;
$result['plain'] = $this->sPlain;
}
// $this->GetCapa(Capa::OPEN_PGP) || $this->GetCapa(Capa::GNUPG)
// $this->GetCapa(Capa::OPENPGP) || $this->GetCapa(Capa::GNUPG)
if ($this->pgpSigned) {
$result['pgpSigned'] = $this->pgpSigned;
}

View file

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

View file

@ -27,8 +27,7 @@ abstract class ConnectionSecurityType
{
$iPort = (int) $iPort;
$iResult = (int) $iSecurityType;
if (self::AUTO_DETECT === $iSecurityType)
{
if (self::AUTO_DETECT === $iSecurityType) {
switch (true)
{
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;
}

View file

@ -184,7 +184,12 @@ class SieveClient extends \MailSo\Net\NetClient
public function Logout() : void
{
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;
}
}
@ -408,6 +413,7 @@ class SieveClient extends \MailSo\Net\NetClient
if (null === $sResponseBuffer) {
break;
}
// \MailSo\Imap\Enumerations\ResponseStatus
$bEnd = \in_array(\substr($sResponseBuffer, 0, 2), array('OK', 'NO'));
// convertEndOfLine
$sLine = \trim($sResponseBuffer);

View file

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

View file

@ -81,7 +81,7 @@ trait Accounts
$aAccounts = $this->GetAccounts($oMainAccount);
$sEmail = \trim($this->GetActionParam('email', ''));
$sPassword = $this->GetActionParam('password', '');
$oPassword = new \SnappyMail\SensitiveString($this->GetActionParam('password', ''));
$sName = \trim($this->GetActionParam('name', ''));
$bNew = !empty($this->GetActionParam('new', 1));
@ -92,8 +92,8 @@ trait Accounts
throw new ClientException(Notifications::AccountDoesNotExist);
}
if ($bNew || $sPassword) {
$oNewAccount = $this->LoginProcess($sEmail, $sPassword, false);
if ($bNew || \strlen($oPassword)) {
$oNewAccount = $this->LoginProcess($sEmail, $oPassword, false);
$aAccounts[$sEmail] = $oNewAccount->asTokenArray($oMainAccount);
} else {
$aAccounts[$sEmail] = \RainLoop\Model\AdditionalAccount::convertArray($aAccounts[$sEmail]);
@ -261,7 +261,11 @@ trait Accounts
if (!$oIdentity->FromJSON($this->GetActionParams(), true)) {
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);
return $this->TrueResponse();
}

View file

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

View file

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

View file

@ -7,6 +7,7 @@ use RainLoop\Exceptions\ClientException;
use RainLoop\Model\Account;
use RainLoop\Notifications;
use MailSo\Imap\SequenceSet;
use MailSo\Imap\Enumerations\FetchType;
use MailSo\Imap\Enumerations\MessageFlag;
use MailSo\Mime\Part as MimePart;
use MailSo\Mime\Enumerations\Header as MimeEnumHeader;
@ -76,7 +77,7 @@ trait Messages
$oParams->oCacher = $this->Cacher($oAccount);
}
// $oParams->bUseSort = $this->ImapClient->hasCapability('SORT');
// $oParams->bUseSort = $this->ImapClient()->hasCapability('SORT');
$oParams->bUseSort = true;
$oSettingsLocal = $this->SettingsProvider(true)->Load($oAccount);
@ -455,6 +456,65 @@ trait Messages
try
{
$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)
{
@ -870,14 +930,15 @@ trait Messages
$oMessage->DoesNotAddDefaultXMailer();
}
$sFrom = $this->GetActionParam('from', '');
$oMessage->SetFrom(\MailSo\Mime\Email::Parse($sFrom));
$oMessage->SetFrom(\MailSo\Mime\Email::Parse($this->GetActionParam('from', '')));
$oFrom = $oMessage->GetFrom();
/*
$oFromIdentity = $this->GetIdentityByID($oAccount, $this->GetActionParam('identityID', ''));
if ($oFromIdentity)
$oIdentity = $this->GetIdentityByID($oAccount, $this->GetActionParam('identityID', ''));
if ($oIdentity)
{
$oMessage->SetFrom(new \MailSo\Mime\Email(
$oFromIdentity->Email(), $oFromIdentity->Name()));
$oIdentity->Email(), $oIdentity->Name()));
if ($oAccount->Domain()->OutSetSender()) {
$oMessage->SetSender(\MailSo\Mime\Email::Parse($oAccount->Email()));
}
@ -887,14 +948,13 @@ trait Messages
$oMessage->SetFrom(\MailSo\Mime\Email::Parse($oAccount->Email()));
}
*/
$oFrom = $oMessage->GetFrom();
$oMessage->RegenerateMessageId($oFrom ? $oFrom->GetDomain() : '');
$oMessage->SetReplyTo(new \MailSo\Mime\EmailCollection($this->GetActionParam('replyTo', '')));
if (!empty($this->GetActionParam('readReceiptRequest', 0))) {
// Read Receipts Reference Main Account Email, Not Identities #147
// $oMessage->SetReadReceipt(($oFromIdentity ?: $oAccount)->Email());
// $oMessage->SetReadReceipt(($oIdentity ?: $oAccount)->Email());
$oMessage->SetReadReceipt($oFrom->GetEmail());
}
@ -1065,8 +1125,7 @@ trait Messages
}
}
$sPassphrase = $this->GetActionParam('signPassphrase', '');
$this->logMask($sPassphrase);
$oPassphrase = new \SnappyMail\SensitiveString($this->GetActionParam('signPassphrase', ''));
$sFingerprint = $this->GetActionParam('signFingerprint', '');
if ($sFingerprint) {
@ -1084,7 +1143,7 @@ trait Messages
$oMessage->SubParts->Clear();
$oMessage->Attachments()->Clear();
$GPG->addSignKey($sFingerprint, $sPassphrase);
$GPG->addSignKey($sFingerprint, $oPassphrase);
$GPG->setsignmode(GNUPG_SIG_MODE_DETACH);
$sSignature = $GPG->signStream($fp);
if (!$sSignature) {
@ -1110,6 +1169,18 @@ trait Messages
} else {
$sCertificate = $this->GetActionParam('signCertificate', '');
$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) {
$oBody = $oMessage->GetRootPart();
@ -1127,7 +1198,7 @@ trait Messages
$SMIME = $this->SMIME();
$SMIME->setCertificate($sCertificate);
$SMIME->setPrivateKey($sPrivateKey, $sPassphrase);
$SMIME->setPrivateKey($sPrivateKey, $oPassphrase);
$sSignature = $SMIME->sign($tmp, $detached);
if (!$sSignature) {

View file

@ -24,7 +24,7 @@ trait Pgp
$GPG = $this->GnuPG();
if ($GPG) {
$keys = $GPG->keyInfo('');
$keys = $GPG->allKeysInfo('');
foreach ($keys['public'] as $key) {
$key = $GPG->export($key['subkeys'][0]['fingerprint'] ?: $key['subkeys'][0]['keyid']);
if ($key) {
@ -47,7 +47,7 @@ trait Pgp
/**
* @throws \MailSo\RuntimeException
*/
public function GnuPG() : ?GnuPG
public function GnuPG() : ?\SnappyMail\PGP\PGPInterface
{
$oAccount = $this->getMainAccountFromToken();
if (!$oAccount) {
@ -81,7 +81,7 @@ trait Pgp
if (\is_link($link) || \symlink($homedir, $link)) {
$homedir = $link;
} else {
\error_log("symlink('{$homedir}', '{$link}') failed");
$this->logWrite("symlink('{$homedir}', '{$link}') failed", \LOG_WARNING, 'GnuPG');
}
}
// Else try ~/.gnupg/ + hash(email address)
@ -97,10 +97,6 @@ trait Pgp
$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);
@ -113,15 +109,14 @@ trait Pgp
return $this->FalseResponse();
}
$sPassphrase = $this->GetActionParam('passphrase', '');
$this->logMask($sPassphrase);
$oPassphrase = new \SnappyMail\SensitiveString($this->GetActionParam('passphrase', ''));
$GPG->addDecryptKey($this->GetActionParam('keyId', ''), $sPassphrase);
$GPG->addDecryptKey($this->GetActionParam('keyId', ''), $oPassphrase);
$sData = $this->GetActionParam('data', '');
$oPart = null;
$result = [
'data' => '',
'data' => null,
'signatures' => []
];
if ($sData) {
@ -131,11 +126,13 @@ trait Pgp
$this->initMailClientConnection();
$this->MailClient()->MessageMimeStream(
function ($rResource) use ($GPG, &$result, &$oPart) {
if (\is_resource($rResource)) {
if (\is_resource($rResource)) try {
$result['data'] = $GPG->decryptStream($rResource);
// $oPart = \MailSo\Mime\Part::FromString($result);
// $GPG->decryptStream($rResource, $rStreamHandle);
// $oPart = \MailSo\Mime\Part::FromStream($rStreamHandle);
} catch (\Throwable $e) {
$result = $e;
}
},
$this->GetActionParam('folder', ''),
@ -149,23 +146,28 @@ trait Pgp
// $result['signatures'] = $oPart->SubParts[0];
}
if ($result instanceof \Throwable) {
throw $result;
}
return $this->DefaultResponse($result);
}
public function DoGnupgGetKeys() : array
{
$GPG = $this->GnuPG();
return $this->DefaultResponse($GPG ? $GPG->keyInfo('') : false);
return $this->DefaultResponse($GPG ? $GPG->allKeysInfo('') : false);
}
public function DoGnupgExportKey() : array
{
$sPassphrase = $this->GetActionParam('passphrase', '');
$this->logMask($sPassphrase);
$oPassphrase = $this->GetActionParam('isPrivate', '')
? new \SnappyMail\SensitiveString($this->GetActionParam('passphrase', ''))
: null;
$GPG = $this->GnuPG();
return $this->DefaultResponse($GPG ? $GPG->export(
$this->GetActionParam('keyId', ''),
$sPassphrase
$oPassphrase
) : false);
}
@ -176,11 +178,10 @@ trait Pgp
if ($GPG) {
$sName = $this->GetActionParam('name', '');
$sEmail = $this->GetActionParam('email', '');
$sPassphrase = $this->GetActionParam('passphrase', '');
$this->logMask($sPassphrase);
$oPassphrase = new \SnappyMail\SensitiveString($this->GetActionParam('passphrase', ''));
$fingerprint = $GPG->generateKey(
$sName ? "{$sName} <{$sEmail}>" : $sEmail,
$sPassphrase
$oPassphrase
);
}
return $this->DefaultResponse($fingerprint);
@ -221,15 +222,11 @@ trait Pgp
}
}
$result = false;
$result = [];
if ($sKey) {
$sKey = \trim($sKey);
if ($this->GetActionParam('backup', '')) {
$result = $result || Backup::PGPKey($sKey);
}
if ($this->GetActionParam('gnuPG', '') && ($GPG = $this->GnuPG())) {
$result = $result || $GPG->import($sKey);
}
$result['backup'] = $this->GetActionParam('backup', '') && Backup::PGPKey($sKey);
$result['gnuPG'] = $this->GetActionParam('gnuPG', '') && ($GPG = $this->GnuPG()) && $GPG->import($sKey);
}
return $this->DefaultResponse($result);
@ -306,7 +303,7 @@ trait Pgp
} else {
$sFolderName = $this->GetActionParam('folder', '');
$iUid = (int) $this->GetActionParam('uid', 0);
$sBodyPartId = $this->GetActionParam('bodyPartId', '');
$sPartId = $this->GetActionParam('partId', '');
$sSigPartId = $this->GetActionParam('sigPartId', '');
// $sMicAlg = $this->GetActionParam('micAlg', '');
@ -315,10 +312,10 @@ trait Pgp
$oImapClient->FolderExamine($sFolderName);
$aParts = [
FetchType::BODY_PEEK.'['.$sBodyPartId.']',
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.'['.$sBodyPartId.'.MIME]'
FetchType::BODY_PEEK.'['.$sPartId.'.MIME]'
];
if ($sSigPartId) {
$aParts[] = FetchType::BODY_PEEK.'['.$sSigPartId.']';
@ -326,20 +323,20 @@ trait Pgp
$oFetchResponse = $oImapClient->Fetch($aParts, $iUid, true)[0];
$sBodyMime = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sBodyPartId.'.MIME]');
$sBodyMime = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sPartId.'.MIME]');
if ($sSigPartId) {
$result = [
'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.']')
)
];
} else {
// clearsigned text
$result = [
'text' => $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sBodyPartId.']'),
'text' => $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$sPartId.']'),
'signature' => ''
];
$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
{
$iErrorCode = 0;
$sErrorMessage = '';
$iErrorCode = Notifications::UnknownError;
$sErrorMessage = $oException->getMessage();
$sErrorMessageAdditional = '';
$iExceptionCode = 0;
if ($oException instanceof \RainLoop\Exceptions\ClientException) {
$iErrorCode = $oException->getCode();
@ -56,8 +57,7 @@ trait Response
}
$sErrorMessageAdditional = $oException->getAdditionalMessage();
} else {
$iErrorCode = Notifications::UnknownError;
$sErrorMessage = $oException->getCode().' - '.$oException->getMessage();
$iExceptionCode = $oException->getCode();
}
$this->logException($oException->getPrevious() ?: $oException);
@ -65,7 +65,8 @@ trait Response
return $this->DefaultResponse(false, [
'ErrorCode' => $iErrorCode,
'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
{
$oAccount = $this->getAccountFromToken();
$sName = $this->GetActionParam('name', '') ?: $oAccount->Name();
$sEmail = $this->GetActionParam('email', '') ?: $oAccount->Email();
$sPassphrase = $this->GetActionParam('passphrase', '');
$this->logMask($sPassphrase);
$oPassphrase = new \SnappyMail\SensitiveString($this->GetActionParam('passphrase', ''));
$cert = new Certificate();
$cert->distinguishedName['commonName'] = $sName;
$cert->distinguishedName['emailAddress'] = $sEmail;
$result = $cert->createSelfSigned($sPassphrase);
$cert->distinguishedName['commonName'] = $this->GetActionParam('name', '') ?: $oAccount->Name();
$cert->distinguishedName['emailAddress'] = $this->GetActionParam('email', '') ?: $oAccount->Email();
$result = $cert->createSelfSigned($oPassphrase, $this->GetActionParam('privateKey', ''));
return $this->DefaultResponse($result ?: false);
}
@ -78,8 +75,7 @@ trait SMime
$sPartId = $this->GetActionParam('partId', '');
$sCertificate = $this->GetActionParam('certificate', '');
$sPrivateKey = $this->GetActionParam('privateKey', '');
$sPassphrase = $this->GetActionParam('passphrase', '');
$this->logMask($sPassphrase);
$oPassphrase = new \SnappyMail\SensitiveString($this->GetActionParam('passphrase', ''));
$this->initMailClientConnection();
$oImapClient = $this->ImapClient();
@ -106,8 +102,16 @@ trait SMime
$SMIME = $this->SMIME();
$SMIME->setCertificate($sCertificate);
$SMIME->setPrivateKey($sPrivateKey, $sPassphrase);
$SMIME->setPrivateKey($sPrivateKey, $oPassphrase);
$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);
}
@ -118,32 +122,12 @@ trait SMime
$sPartId = $this->GetActionParam('partId', '');
$bDetached = !empty($this->GetActionParam('detached', 0));
if (!$sBody && $sPartId) {
$sFolderName = $this->GetActionParam('folder', '');
$iUid = (int) $this->GetActionParam('uid', 0);
$sMicAlg = $this->GetActionParam('micAlg', '');
// $sMicAlg = $this->GetActionParam('micAlg', '');
$this->initMailClientConnection();
$oImapClient = $this->ImapClient();
$oImapClient->FolderExamine($sFolderName);
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.']');
$oImapClient->FolderExamine($this->GetActionParam('folder', ''));
$sBody = $oImapClient->FetchMessagePart($iUid, $sPartId);
}
$result = $this->SMIME()->verify($sBody, null, !$bDetached);
@ -151,7 +135,7 @@ trait SMime
// Import the certificates automatically
$sBody = $this->GetActionParam('sigPart', '');
$sPartId = $this->GetActionParam('sigPartId', '') ?: $sPartId;
if (!$sBody && $sPartId) {
if (!$sBody && $sPartId && $oImapClient) {
$sBody = $oImapClient->Fetch(
[FetchType::BODY_PEEK.'['.$sPartId.']'],
$iUid,
@ -162,9 +146,9 @@ trait SMime
$sBody = \trim($sBody);
$certificates = [];
\openssl_pkcs7_read(
"-----BEGIN CERTIFICATE-----\n\n{$sBody}\n-----END CERTIFICATE-----",
"-----BEGIN PKCS7-----\n\n{$sBody}\n-----END PKCS7-----",
$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) {
$this->SMIME()->storeCertificate($certificate);
}
@ -173,6 +157,15 @@ trait SMime
return $this->DefaultResponse($result);
}
public function DoSMimeImportCertificate() : array
{
return $this->DefaultResponse(
$this->SMIME()->storeCertificate(
$this->GetActionParam('pem', '')
)
);
}
public function DoSMimeImportCertificatesFromMessage() : array
{
/*
@ -190,7 +183,7 @@ trait SMime
$sBody = \trim($sBody);
$certificates = [];
\openssl_pkcs7_read(
"-----BEGIN CERTIFICATE-----\n\n{$sBody}\n-----END CERTIFICATE-----",
"-----BEGIN PKCS7-----\n\n{$sBody}\n-----END PKCS7-----",
$certificates
);

View file

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

View file

@ -36,10 +36,10 @@ trait User
public function DoLogin() : array
{
$sEmail = \MailSo\Base\Utils::Trim($this->GetActionParam('Email', ''));
$sPassword = $this->GetActionParam('Password', '');
$oPassword = new \SnappyMail\SensitiveString($this->GetActionParam('Password', ''));
try {
$oAccount = $this->LoginProcess($sEmail, $sPassword);
$oAccount = $this->LoginProcess($sEmail, $oPassword);
} catch (\Throwable $oException) {
$this->loginErrorDelay();
throw $oException;
@ -61,7 +61,7 @@ trait User
if ($sCurrentLanguage !== $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, 'CheckMailInterval', 'int');
return $this->DefaultResponse($this->SettingsProvider()->Save($oAccount, $oSettings) &&
$this->SettingsProvider(true)->Save($oAccount, $oSettingsLocal));
return $this->DefaultResponse($oSettings->save() && $oSettingsLocal->save());
}
public function DoQuota() : array
{
$oAccount = $this->initMailClientConnection();
if (!$this->GetCapa(Capa::QUOTA)) {
return $this->DefaultResponse(array(0, 0, 0, 0));
}
try
{
$aQuota = $this->ImapClient()->QuotaRoot();
return $this->DefaultResponse($this->ImapClient()->QuotaRoot() ?: [0, 0, 0, 0]);
}
catch (\Throwable $oException)
{
throw new ClientException(Notifications::MailServerError, $oException);
}
return $this->DefaultResponse($aQuota);
}
public function DoSuggestions() : array
@ -263,12 +255,11 @@ trait User
public function DoClearUserBackground() : array
{
$oAccount = $this->getAccountFromToken();
if (!$this->GetCapa(Capa::USER_BACKGROUND)) {
return $this->FalseResponse();
}
$oAccount = $this->getAccountFromToken();
$oSettings = $this->SettingsProvider()->Load($oAccount);
if ($oAccount && $oSettings) {
$this->StorageProvider()->Clear($oAccount,
@ -280,8 +271,7 @@ trait User
$oSettings->SetConf('UserBackgroundHash', '');
}
return $this->DefaultResponse($oAccount && $oSettings ?
$this->SettingsProvider()->Save($oAccount, $oSettings) : false);
return $this->DefaultResponse($oAccount && $oSettings ? $oSettings->save() : false);
}
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
*/
public function resolveLoginCredentials(string &$sEmail,
#[\SensitiveParameter]
string &$sPassword,
string &$sLogin): void
public function resolveLoginCredentials(string &$sEmail, \SnappyMail\SensitiveString $oPassword, string &$sLogin): void
{
$sEmail = \MailSo\Base\Utils::Trim($sEmail);
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->logMask($sPassword);
$sLogin = $sEmail;
if ($this->Config()->Get('login', 'login_lowercase', true)) {
$sLogin = \mb_strtolower($sLogin);
}
$this->logMask($sPassword);
$this->Plugins()->RunHook('login.credentials', array(&$sEmail, &$sLogin, &$sPassword));
$this->logMask($sPassword);
$oPassword->setValue($sPassword);
}
/**
* @throws \RainLoop\Exceptions\ClientException
*/
public function LoginProcess(string &$sEmail,
#[\SensitiveParameter]
string &$sPassword,
bool $bMainAccount = true
): Account
public function LoginProcess(string &$sEmail, \SnappyMail\SensitiveString $oPassword, bool $bMainAccount = true): Account
{
$sInputEmail = $sEmail;
$this->logMask($sPassword);
$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);
}
$oAccount = null;
try {
$oAccount = $bMainAccount
? MainAccount::NewInstanceFromCredentials($this, $sEmail, $sLogin, $sPassword, true)
: AdditionalAccount::NewInstanceFromCredentials($this, $sEmail, $sLogin, $sPassword, true);
? MainAccount::NewInstanceFromCredentials($this, $sEmail, $sLogin, $oPassword, true)
: AdditionalAccount::NewInstanceFromCredentials($this, $sEmail, $sLogin, $oPassword, true);
if (!$oAccount) {
throw new ClientException(Notifications::AuthError);
}
@ -261,7 +255,7 @@ trait UserAuth
$this->oMainAuthAccount = $oMainAuthAccount;
} else {
$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 {
\SnappyMail\Log::notice('TOKENS', 'AUTH_SPEC_TOKEN_KEY invalid');

View file

@ -25,6 +25,8 @@ class ActionsAdmin extends Actions
unset($aConfig['version']);
$aConfig['logs']['time_zone'][1] = '';
$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);
}
@ -63,6 +65,7 @@ class ActionsAdmin extends Actions
});
$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, 'allowLanguagesOnLogin', 'login', 'allow_languages_on_login', 'bool');
@ -109,17 +112,15 @@ class ActionsAdmin extends Actions
public function DoAdminLogin() : array
{
$sLogin = trim($this->GetActionParam('Login', ''));
$sPassword = $this->GetActionParam('Password', '');
$this->logMask($sPassword);
$oPassword = new \SnappyMail\SensitiveString($this->GetActionParam('Password', ''));
$totp = $this->Config()->Get('security', 'admin_totp', '');
// \explode(':',`getent shadow root`)[1];
if (!\strlen($sLogin) || !\strlen($sPassword) ||
if (!\strlen($sLogin) || !\strlen($oPassword) ||
!$this->Config()->Get('security', 'allow_admin_panel', true) ||
$sLogin !== $this->Config()->Get('security', 'admin_login', '') ||
!$this->Config()->ValidatePassword($sPassword)
!$this->Config()->ValidatePassword($oPassword)
|| ($totp && !\SnappyMail\TOTP::Verify($totp, $this->GetActionParam('TOTP', ''))))
{
$this->LoggerAuthHelper(null, $this->getAdditionalLogParamsByUserLogin($sLogin, true), true);
@ -181,17 +182,13 @@ class ActionsAdmin extends Actions
$bResult = false;
$oConfig = $this->Config();
$sPassword = $this->GetActionParam('Password', '');
$this->logMask($sPassword);
$oPassword = new \SnappyMail\SensitiveString($this->GetActionParam('Password', ''));
$sNewPassword = $this->GetActionParam('newPassword', '');
if (\strlen($sNewPassword)) {
$this->logMask($sNewPassword);
}
$oNewPassword = new \SnappyMail\SensitiveString($this->GetActionParam('newPassword', ''));
$passfile = APP_PRIVATE_DATA.'admin_password.txt';
if ($oConfig->ValidatePassword($sPassword)) {
if ($oConfig->ValidatePassword($oPassword)) {
$sLogin = \trim($this->GetActionParam('Login', ''));
if (\strlen($sLogin)) {
$oConfig->Set('security', 'admin_login', $sLogin);
@ -199,9 +196,9 @@ class ActionsAdmin extends Actions
$oConfig->Set('security', 'admin_totp', $this->GetActionParam('TOTP', ''));
if (\strlen($sNewPassword)) {
$oConfig->SetPassword($sNewPassword);
if (\is_file($passfile) && \trim(\file_get_contents($passfile)) !== $sNewPassword) {
if (\strlen($oNewPassword)) {
$oConfig->SetPassword($oNewPassword);
if (\is_file($passfile) && \trim(\file_get_contents($passfile)) !== (string) $oNewPassword) {
\unlink($passfile);
}
}
@ -369,56 +366,6 @@ class ActionsAdmin extends Actions
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
{
$sRand = \MailSo\Base\Utils::Sha1Rand();

View file

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

View file

@ -120,25 +120,14 @@ class Application extends \RainLoop\Config\AbstractConfig
parent::Set($sSectionKey, $sParamKey, $mParamValue);
}
public function SetPassword(
#[\SensitiveParameter]
string $sPassword
) : void
public function SetPassword(\SnappyMail\SensitiveString $oPassword) : void
{
$this->Set('security', 'admin_password', \password_hash($sPassword, PASSWORD_DEFAULT));
$this->Set('security', 'admin_password', \password_hash($oPassword, PASSWORD_DEFAULT));
}
public function ValidatePassword(
#[\SensitiveParameter]
string $sPassword
) : bool
public function ValidatePassword(\SnappyMail\SensitiveString $oPassword) : bool
{
$sConfigPassword = (string) $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);
return \strlen($oPassword) && \password_verify($oPassword, $this->Get('security', 'admin_password', ''));
}
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'),
'openpgp' => array(true),
'auto_verify_signatures' => array(false),
'allow_admin_panel' => array(true, 'Access settings'),
'admin_login' => array('admin', 'Login and password for web admin panel'),
@ -262,7 +252,6 @@ Default is "site=same-origin;site=none"')
),
'capa' => array(
'quota' => array(true),
'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)')
),
@ -284,7 +273,7 @@ When this value is gethostname, the gethostname() value is used.
'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
once they closed the browser window.
@ -419,7 +408,7 @@ Enables caching in the system'),
'labs' => array(
'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'),
'log_ajax_response_write_limit' => array(300),
'smtp_show_server_errors' => array(false),

View file

@ -2,19 +2,17 @@
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 OPEN_PGP = 'OpenPGP';
const IDENTITIES = 'Identities';
const OPENPGP = 'OpenPGP';
const SIEVE = 'Sieve';
const THEMES = 'Themes';
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;
class Layout
abstract class Layout
{
const NO_PREVIEW = 0;
const SIDE_PREVIEW = 1;

View file

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

View file

@ -2,9 +2,9 @@
namespace RainLoop\Enumerations;
class SignMeType
abstract class SignMeType
{
const DEFAULT_OFF = 'DefaultOff';
const DEFAULT_ON = 'DefaultOn';
const UNUSED = 'Unused';
const DefaultOff = 'DefaultOff';
const DefaultOn = 'DefaultOn';
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 ON_SAVING = 1002;
const EMPTY_FILES_DATA = 1003;
const EMPTY_FILE = 1003;
private static $messages = [
\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',
\UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
\UPLOAD_ERR_NO_FILE => 'No file was uploaded',
\UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder',
\UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
\UPLOAD_ERR_EXTENSION => 'File upload stopped by extension',
/*1*/\UPLOAD_ERR_INI_SIZE => 'Filesize exceeds the upload_max_filesize directive in php.ini',
/*2*/\UPLOAD_ERR_FORM_SIZE => 'Filesize exceeds the MAX_FILE_SIZE directive that was specified in the html form',
/*3*/\UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
/*4*/\UPLOAD_ERR_NO_FILE => 'No file was uploaded',
/*6*/\UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder',
/*7*/\UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
/*8*/\UPLOAD_ERR_EXTENSION => 'File upload stopped by extension',
98 => 'Invalid file type',
99 => 'Unknown error',
1001 => 'Filesize exceeds the config setting',
@ -28,7 +28,7 @@ abstract class UploadError
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
@ -39,12 +39,12 @@ abstract class UploadError
case \UPLOAD_ERR_PARTIAL:
case \UPLOAD_ERR_NO_FILE:
case static::FILE_TYPE:
case static::EMPTY_FILE:
break;
case \UPLOAD_ERR_INI_SIZE:
case \UPLOAD_ERR_FORM_SIZE:
case static::CONFIG_SIZE:
case static::EMPTY_FILES_DATA:
return 'File is too big';
case \UPLOAD_ERR_NO_TMP_DIR:

View file

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

View file

@ -63,4 +63,11 @@ class MainAccount extends Account
{
$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 ClientViewError = 902;
const InvalidInputArgument = 903;
// const UnknownNotification = 998;
const UnknownError = 999;
// Admin

View file

@ -419,7 +419,7 @@ class Manager
$aData[$sPluginName] = $aPluginSettings;
$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;
/**
* @var \RainLoop\Model\Account
*/
protected $oAccount;
public function IsActive() : bool
{
return false;
}
public function SetAccount(\RainLoop\Model\Account $oAccount)
{
$this->oAccount = $oAccount;
}
abstract public function IsActive() : bool;
}

View file

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

View file

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

View file

@ -2,30 +2,30 @@
namespace RainLoop\Providers;
use RainLoop\Model\Account;
use RainLoop\Providers\Settings\ISettings;
class Settings extends \RainLoop\Providers\AbstractProvider
{
/**
* @var \RainLoop\Providers\Settings\ISettings
*/
private $oDriver;
private ISettings $oDriver;
public function __construct(\RainLoop\Providers\Settings\ISettings $oDriver)
public function __construct(ISettings $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);
}
public function IsActive() : bool
{
return $this->oDriver instanceof \RainLoop\Providers\Settings\ISettings;
return true;
}
}

View file

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

View file

@ -2,13 +2,18 @@
namespace RainLoop\Providers\Storage\Enumerations;
class StorageType
{
const USER = 1;
const CONFIG = 2;
const NOBODY = 3;
const SIGN_ME = 4;
const SESSION = 5;
const PGP = 6;
const ROOT = 7;
/**
* PHP 8.1
enum StorageType: int {
case USER = 1;
}
*/
abstract class StorageType
{
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
{
$sEmail = $sSubFolder = '';
if (null === $mAccount) {
$iStorageType = StorageType::NOBODY;
} else if ($mAccount instanceof \RainLoop\Model\MainAccount) {
$sEmail = $mAccount->Email();
} else if ($mAccount instanceof \RainLoop\Model\AdditionalAccount) {
$sEmail = $mAccount->ParentEmail();
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 '';
$sEmail = $sSubFolder = $sFilePath = '';
if (null === $mAccount || StorageType::NOBODY === $iStorageType) {
$sFilePath = $this->sDataPath.'/__nobody__/';
} else {
if ($mAccount instanceof \RainLoop\Model\MainAccount) {
$sEmail = $mAccount->Email();
} else if ($mAccount instanceof \RainLoop\Model\AdditionalAccount) {
$sEmail = $mAccount->ParentEmail();
if ($this->bLocal) {
$sSubFolder = $mAccount->Email();
}
if (\is_dir("{$this->sDataPath}/cfg")) {
\SnappyMail\Upgrade::FileStorage($this->sDataPath);
} else if (\is_string($mAccount)) {
$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
.'/'.\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}");
}
switch ($iStorageType)
{
case StorageType::CONFIG:
case StorageType::SIGN_ME:
case StorageType::SESSION:
case StorageType::PGP:
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))

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