mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-08-30 04:29:21 +03:00
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:
parent
3bd073a38b
commit
876ed221c3
218 changed files with 10266 additions and 4267 deletions
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -95,7 +95,6 @@ Notifications = {
|
|||
JsonParse: 952,
|
||||
// JsonTimeout: 953,
|
||||
|
||||
UnknownNotification: 998,
|
||||
UnknownError: 999,
|
||||
|
||||
// Admin
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
5
dev/External/ko.js
vendored
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: []});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ checkResponseError = data => {
|
|||
Notifications.DomainNotAllowed,
|
||||
Notifications.AccountNotAllowed,
|
||||
Notifications.MailServerError,
|
||||
Notifications.UnknownNotification,
|
||||
Notifications.UnknownError
|
||||
].includes(err)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
constructor() {
|
||||
super();
|
||||
|
||||
this.addSettings(['useLocalProxyForExternalImages']);
|
||||
this.addSettings(['useLocalProxyForExternalImages', 'autoVerifySignatures']);
|
||||
|
||||
this.weakPassword = rl.app.weakPassword;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: []});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
|||
124
dev/Stores/User/Mailvelope.js
Normal file
124
dev/Stores/User/Mailvelope.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { SettingsGet } from 'Common/Globals';
|
||||
|
||||
// https://mailvelope.github.io/mailvelope/Keyring.html
|
||||
let mailvelopeKeyring = null;
|
||||
|
||||
export const MailvelopeUserStore = {
|
||||
keyring: null,
|
||||
|
||||
loadKeyring(identifier) {
|
||||
identifier = identifier || SettingsGet('Email');
|
||||
if (window.mailvelope) {
|
||||
const fn = keyring => {
|
||||
mailvelopeKeyring = keyring;
|
||||
console.log('mailvelope ready');
|
||||
};
|
||||
mailvelope.getKeyring().then(fn, err => {
|
||||
if (identifier) {
|
||||
// attempt to create a new keyring for this app/user
|
||||
mailvelope.createKeyring(identifier).then(fn, err => console.error(err));
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
addEventListener('mailvelope-disconnect', event => {
|
||||
alert('Mailvelope is updated to version ' + event.detail.version + '. Reload page');
|
||||
}, false);
|
||||
} else {
|
||||
addEventListener('mailvelope', () => this.loadKeyring(identifier));
|
||||
}
|
||||
},
|
||||
|
||||
async hasPublicKeyForEmails(recipients) {
|
||||
const
|
||||
mailvelope = mailvelopeKeyring && await mailvelopeKeyring.validKeyForAddress(recipients)
|
||||
/*.then(LookupResult => Object.entries(LookupResult))*/,
|
||||
entries = mailvelope && Object.entries(mailvelope);
|
||||
return entries && entries.filter(value => value[1]).length === recipients.length;
|
||||
},
|
||||
|
||||
async getPrivateKeyFor(email/*, sign*/) {
|
||||
if (mailvelopeKeyring && await mailvelopeKeyring.hasPrivateKey({email:email})) {
|
||||
return ['mailvelope', email];
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
async decrypt(message) {
|
||||
// Try Mailvelope (does not support inline images)
|
||||
if (mailvelopeKeyring) {
|
||||
const sender = message.from[0].email,
|
||||
armoredText = message.plain();
|
||||
try {
|
||||
let emails = [...message.from,...message.to,...message.cc].validUnique(),
|
||||
i = emails.length;
|
||||
while (i--) {
|
||||
if (await this.getPrivateKeyFor(emails[i].email)) {
|
||||
/**
|
||||
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createEncryptedFormContainer
|
||||
* Creates an iframe to display an encrypted form
|
||||
*/
|
||||
// mailvelope.createEncryptedFormContainer('#mailvelope-form');
|
||||
/**
|
||||
* https://mailvelope.github.io/mailvelope/Mailvelope.html#createDisplayContainer
|
||||
* Creates an iframe to display the decrypted content of the encrypted mail.
|
||||
*/
|
||||
const body = message.body;
|
||||
body.textContent = '';
|
||||
let result = await mailvelope.createDisplayContainer(
|
||||
'#'+body.id,
|
||||
armoredText,
|
||||
mailvelopeKeyring,
|
||||
{
|
||||
senderAddress: sender
|
||||
// emails[i].email
|
||||
}
|
||||
);
|
||||
if (result) {
|
||||
if (result.error?.message) {
|
||||
if ('PWD_DIALOG_CANCEL' !== result.error.code) {
|
||||
alert(result.error.code + ': ' + result.error.message);
|
||||
}
|
||||
} else {
|
||||
body.classList.add('mailvelope');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns headers that should be added to an outgoing email.
|
||||
* So far this is only the autocrypt header.
|
||||
*/
|
||||
/*
|
||||
mailvelopeKeyring.additionalHeadersForOutgoingEmail(from: 'abc@web.de')
|
||||
.then(function(additional) {
|
||||
console.log('additionalHeadersForOutgoingEmail', additional);
|
||||
// logs: {autocrypt: "addr=abc@web.de; prefer-encrypt=mutual; keydata=..."}
|
||||
});
|
||||
|
||||
mailvelopeKeyring.addSyncHandler(syncHandlerObj)
|
||||
mailvelopeKeyring.createKeyBackupContainer(selector, options)
|
||||
mailvelopeKeyring.createKeyGenContainer(selector, {
|
||||
// userIds: [],
|
||||
keySize: 4096
|
||||
})
|
||||
|
||||
mailvelopeKeyring.exportOwnPublicKey(emailAddr).then(<AsciiArmored, Error>)
|
||||
mailvelopeKeyring.importPublicKey(armored)
|
||||
|
||||
// https://mailvelope.github.io/mailvelope/global.html#SyncHandlerObject
|
||||
mailvelopeKeyring.addSyncHandler({
|
||||
uploadSync
|
||||
downloadSync
|
||||
backup
|
||||
restore
|
||||
});
|
||||
*/
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
*/
|
||||
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
));
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
color: red;
|
||||
content: attr(data-rainloopErrorTip);
|
||||
font-size: 90%;
|
||||
left: 100%;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
padding: 0.25em;
|
||||
position : absolute;
|
||||
|
|
|
|||
|
|
@ -123,4 +123,8 @@ html[dir="rtl"] {
|
|||
border-right: 6px solid #eee;
|
||||
}
|
||||
}
|
||||
|
||||
[data-rainloopErrorTip]::before {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
50
dev/View/Popup/SMimeImport.js
Normal file
50
dev/View/Popup/SMimeImport.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { addObservablesTo } from 'External/ko';
|
||||
import { getNotification } from 'Common/Translator';
|
||||
|
||||
import { AbstractViewPopup } from 'Knoin/AbstractViews';
|
||||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
export class SMimeImportPopupView extends AbstractViewPopup {
|
||||
constructor() {
|
||||
super('SMimeImport');
|
||||
|
||||
addObservablesTo(this, {
|
||||
pem: '',
|
||||
pemError: false,
|
||||
pemErrorMessage: '',
|
||||
pemValid: false
|
||||
});
|
||||
|
||||
this.pem.subscribe(value => {
|
||||
this.pemError(false);
|
||||
this.pemErrorMessage('');
|
||||
this.pemValid(value && value.includes('-----BEGIN CERTIFICATE-----'));
|
||||
});
|
||||
}
|
||||
|
||||
submitForm() {
|
||||
if (this.pemValid()) {
|
||||
Remote.request('SMimeImportCertificate',
|
||||
(iError, oData) => {
|
||||
if (iError) {
|
||||
this.pemError(true);
|
||||
this.pemErrorMessage(getNotification(iError, oData?.ErrorMessage));
|
||||
// oData?.ErrorMessageAdditional;
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
{pem:this.pem()}
|
||||
);
|
||||
} else {
|
||||
this.pemError(true);
|
||||
}
|
||||
}
|
||||
|
||||
onShow() {
|
||||
this.pem('');
|
||||
this.pemError(false);
|
||||
this.pemErrorMessage('');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
7
dev/prototype.js
vendored
|
|
@ -44,6 +44,13 @@
|
|||
return template.content.firstChild;
|
||||
};
|
||||
|
||||
/**
|
||||
* https://github.com/tc39/proposal-regex-escaping
|
||||
*/
|
||||
if (!RegExp.escape){
|
||||
RegExp.escape = s => String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every time the function is executed,
|
||||
* it will delay the execution with the given amount of milliseconds.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
version: '2'
|
||||
services:
|
||||
snappymail:
|
||||
image: leojonathanoh/snappymail:pr-1
|
||||
image: djmaze/snappymail:latest
|
||||
ports:
|
||||
- 8888:8888
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
version: '2'
|
||||
services:
|
||||
snappymail:
|
||||
image: leojonathanoh/snappymail:pr-1
|
||||
image: djmaze/snappymail:latest
|
||||
ports:
|
||||
- 8888:8888
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
version: '2'
|
||||
services:
|
||||
snappymail:
|
||||
image: leojonathanoh/snappymail:pr-1
|
||||
image: djmaze/snappymail:latest
|
||||
ports:
|
||||
- 8888:8888
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
|
|
@ -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.**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
88
plugins/compact-composer/css/composer.css
Normal file
88
plugins/compact-composer/css/composer.css
Normal 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;
|
||||
}
|
||||
23
plugins/compact-composer/index.php
Normal file
23
plugins/compact-composer/index.php
Normal 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');
|
||||
}
|
||||
}
|
||||
990
plugins/compact-composer/js/CompactComposer.js
Normal file
990
plugins/compact-composer/js/CompactComposer.js
Normal 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);
|
||||
413
plugins/compact-composer/js/parsel.js
Normal file
413
plugins/compact-composer/js/parsel.js
Normal 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;
|
||||
|
||||
}({}));
|
||||
4102
plugins/compact-composer/js/squire-raw.js
Normal file
4102
plugins/compact-composer/js/squire-raw.js
Normal file
File diff suppressed because it is too large
Load diff
BIN
plugins/compact-composer/screenshots/Screenshot1.png
Normal file
BIN
plugins/compact-composer/screenshots/Screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
plugins/compact-composer/screenshots/Screenshot2.png
Normal file
BIN
plugins/compact-composer/screenshots/Screenshot2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
BIN
plugins/compact-composer/screenshots/Screenshot3.png
Normal file
BIN
plugins/compact-composer/screenshots/Screenshot3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
plugins/compact-composer/screenshots/Screenshot4.png
Normal file
BIN
plugins/compact-composer/screenshots/Screenshot4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
plugins/compact-composer/screenshots/Screenshot5.png
Normal file
BIN
plugins/compact-composer/screenshots/Screenshot5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
plugins/compact-composer/screenshots/Screenshot6.png
Normal file
BIN
plugins/compact-composer/screenshots/Screenshot6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
201
plugins/compact-composer/templates/PopupsCompactCompose.html
Normal file
201
plugins/compact-composer/templates/PopupsCompactCompose.html
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
22
plugins/nextcloud/storage.php
Normal file
22
plugins/nextcloud/storage.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ trait Themes
|
|||
|
||||
$oSettings->SetConf('UserBackgroundName', $sName);
|
||||
$oSettings->SetConf('UserBackgroundHash', $sHash);
|
||||
$this->SettingsProvider()->Save($oAccount, $oSettings);
|
||||
$oSettings->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace RainLoop\Enumerations;
|
||||
|
||||
class Layout
|
||||
abstract class Layout
|
||||
{
|
||||
const NO_PREVIEW = 0;
|
||||
const SIDE_PREVIEW = 1;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace RainLoop\Enumerations;
|
||||
|
||||
class PluginPropertyType
|
||||
abstract class PluginPropertyType
|
||||
{
|
||||
const
|
||||
GROUP = 7,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace RainLoop\Enumerations;
|
||||
|
||||
class TimeFormat
|
||||
{
|
||||
const F12 = '12';
|
||||
const F24 = '24';
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ class Notifications
|
|||
const MailServerError = 901;
|
||||
const ClientViewError = 902;
|
||||
const InvalidInputArgument = 903;
|
||||
// const UnknownNotification = 998;
|
||||
const UnknownError = 999;
|
||||
|
||||
// Admin
|
||||
|
|
|
|||
|
|
@ -419,7 +419,7 @@ class Manager
|
|||
$aData[$sPluginName] = $aPluginSettings;
|
||||
$oSettings->SetConf('Plugins',$aData);
|
||||
|
||||
return $this->oActions->SettingsProvider()->Save($oAccount, $oSettings);
|
||||
return $oSettings->save();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace RainLoop\Providers\AddressBook\Enumerations;
|
||||
|
||||
class PropertyType
|
||||
abstract class PropertyType
|
||||
{
|
||||
const UNKNOWN = 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ class Identities extends AbstractProvider
|
|||
});
|
||||
}
|
||||
|
||||
public function IsActive() : bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Account $account
|
||||
* @param bool $allowMultipleIdentities
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue