diff --git a/.docker/release/files/usr/local/include/application.ini b/.docker/release/files/usr/local/include/application.ini index 36d814643..317a059a4 100644 --- a/.docker/release/files/usr/local/include/application.ini +++ b/.docker/release/files/usr/local/include/application.ini @@ -289,5 +289,5 @@ dev_email = "" dev_password = "" [version] -current = "2.22.3" +current = "2.22.4" saved = "Fri, 30 Sep 2022 12:34:56 +0000" diff --git a/CHANGELOG.md b/CHANGELOG.md index d2dd2edac..26c8be96a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## 2.22.4 – 2022-11-28 + +### Changed +- Contacts dialog layout using flex +- Session token is related to the user agent string + [#713](https://github.com/the-djmaze/snappymail/pull/713) +- Better browser cache handling for avatars plugin + [#714](https://github.com/the-djmaze/snappymail/pull/714) +- Force HTML editor when set as default when replying to message + [#355](https://github.com/the-djmaze/snappymail/pull/355) + +### Fixed +- Contact Error - object Object #716 + [#716](https://github.com/the-djmaze/snappymail/pull/716) +- Unable to move messages to different folder by drag and drop + [#710](https://github.com/the-djmaze/snappymail/pull/710) +- v2.22.3 unknown error #709 + [#709https://github.com/the-djmaze/snappymail/pull/709) + + ## 2.22.3 – 2022-11-25 ### Added @@ -9,7 +29,7 @@ - Update to OpenPGP.js v5.5.0 ### Fixed -- drag & drop folder expansion #707 +- drag & drop folder expansion [#707](https://github.com/the-djmaze/snappymail/pull/707) - Save selected messages as .eml in Nextcloud failed [#704](https://github.com/the-djmaze/snappymail/pull/704) diff --git a/README.md b/README.md index ff6b4eaa0..245aff84b 100644 --- a/README.md +++ b/README.md @@ -142,25 +142,25 @@ RainLoop 1.17 vs SnappyMail |js/* |RainLoop |Snappy | |--------------- |--------: |--------: | |admin.js |2.170.153 | 82.419 | -|app.js |4.207.787 | 407.335 | -|boot.js | 868.735 | 1.989 | +|app.js |4.207.787 | 407.334 | +|boot.js | 868.735 | 2.351 | |libs.js | 658.812 | 193.075 | |sieve.js | 0 | 86.121 | |polyfills.js | 334.608 | 0 | |serviceworker.js | 0 | 285 | -|TOTAL |8.240.095 | 771.224 | +|TOTAL |8.240.095 | 771.585 | |js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli | |--------------- |--------: |--------: |------: |------: |--------: |--------: | |admin.min.js | 256.831 | 40.933 | 73.606 | 13.575 | 60.877 | 12.185 | -|app.min.js | 515.367 | 188.819 |139.456 | 62.830 |110.485 | 53.925 | -|boot.min.js | 84.659 | 1.216 | 26.998 | 761 | 23.643 | 614 | +|app.min.js | 515.367 | 188.876 |139.456 | 62.841 |110.485 | 53.957 | +|boot.min.js | 84.659 | 1.474 | 26.998 | 907 | 23.643 | 733 | |libs.min.js | 584.772 | 92.084 |180.901 | 34.119 |155.182 | 30.621 | |sieve.min.js | 0 | 41.926 | 0 | 10.484 | 0 | 9.451 | |polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 | -|TOTAL user |1.217.635 | 282.119 |358.761 | 97.710 |299.485 | 85.160 | -|TOTAL user+sieve |1.217.635 | 324.045 |358.761 |108.194 |299.485 | 94.611 | -|TOTAL admin | 959.099 | 134.233 |292.911 | 48.455 |249.877 | 43.420 | +|TOTAL user |1.217.635 | 282.434 |358.761 | 97.867 |299.485 | 85.311 | +|TOTAL user+sieve |1.217.635 | 324.360 |358.761 |108.351 |299.485 | 94.762 | +|TOTAL admin | 959.099 | 134.491 |292.911 | 48.601 |249.877 | 43.539 | For a user its around 70% smaller and faster than traditional RainLoop. @@ -189,8 +189,8 @@ For a user its around 70% smaller and faster than traditional RainLoop. |css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli | |------------ |-------: |------: |------: |------: |--------: | -|app.css | 340.331 | 82.471 | 46.946 | 17.107 | 14.645 | -|app.min.css | 274.947 | 66.548 | 39.647 | 15.117 | 13.260 | +|app.css | 340.331 | 81.910 | 46.946 | 17.050 | 14.596 | +|app.min.css | 274.947 | 66.076 | 39.647 | 15.042 | 13.205 | |boot.css | | 1.326 | | 664 | 545 | |boot.min.css | | 1.071 | | 590 | 474 | |admin.css | | 29.853 | | 6.812 | 5.933 | diff --git a/build/plugins.php b/build/plugins.php index ba6480c57..8242ef74a 100755 --- a/build/plugins.php +++ b/build/plugins.php @@ -11,6 +11,8 @@ foreach ($files as $fileinfo) { $fileinfo->isDir() || unlink($fileinfo->getRealPath()); } +$terser = ROOT_DIR . '/node_modules/terser/bin/terser'; + $manifest = []; require ROOT_DIR . '/snappymail/v/0.0.0/app/libraries/RainLoop/Plugins/AbstractPlugin.php'; $keys = [ @@ -42,6 +44,21 @@ foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) { $version = $manifest_item['version']; if (0 < floatval($version)) { echo "+ {$name} {$version}\n"; + + // Minify JavaScript + foreach (glob("{$dir}/*.js") as $file) { + if (!strpos($file,'.min')) { + $mfile = str_replace('.js', '.min.js', $file); + passthru("{$terser} {$file} --output {$mfile} --compress 'drop_console' --ecma 6 --mangle"); + } + } + foreach (glob("{$dir}/js/*.js") as $file) { + if (!strpos($file,'.min')) { + $mfile = str_replace('.js', '.min.js', $file); + passthru("{$terser} {$file} --output {$mfile} --compress 'drop_console' --ecma 6 --mangle"); + } + } + $manifest_item['type'] = 'plugin'; $manifest_item['id'] = $name; $manifest_item['file'] = "plugins/{$name}-{$version}.tgz"; diff --git a/dev/Knoin/AbstractModel.js b/dev/Knoin/AbstractModel.js index fe5ebab81..675335066 100644 --- a/dev/Knoin/AbstractModel.js +++ b/dev/Knoin/AbstractModel.js @@ -102,10 +102,10 @@ export class AbstractModel { case 'string': this[key] = typeCast(this[key], value); break; - // fall through case 'undefined': - default: this[key] = value; + // fall through + default: // console.log((typeof this[key])+' '+(model.name)+'.'+key+' not revived'); } } catch (e) { diff --git a/dev/Model/Attachment.js b/dev/Model/Attachment.js index ad5e875a1..fc1a30e00 100644 --- a/dev/Model/Attachment.js +++ b/dev/Model/Attachment.js @@ -51,6 +51,11 @@ export class AttachmentModel extends AbstractModel { return attachment; } + toggleChecked(self, event) { + event.stopPropagation(); + self.checked(!self.checked()); + } + friendlySize() { return FileInfo.friendlySize(this.estimatedSize) + (this.isLinked() ? ' πŸ”—' : ''); } diff --git a/dev/Model/Contact.js b/dev/Model/Contact.js index 5b19fdcf2..4a2f7c540 100644 --- a/dev/Model/Contact.js +++ b/dev/Model/Contact.js @@ -119,7 +119,7 @@ export class ContactModel extends AbstractModel { display: () => { let a = this.fullName(), - b = this.email()?.[0]?.value(), + b = this.email()[0]?.value(), c = this.nickname(); return a || b || c; } @@ -139,7 +139,7 @@ export class ContactModel extends AbstractModel { */ getNameAndEmailHelper() { let name = (this.givenName() + ' ' + this.surName()).trim(), - email = this.email()[0]; + email = this.email()[0]?.value(); /* // this.jCard.getOne('fn')?.notEmpty() || this.jCard.parseFullName({set:true}); diff --git a/dev/Settings/User/Folders.js b/dev/Settings/User/Folders.js index 4ff262f27..ab16e5775 100644 --- a/dev/Settings/User/Folders.js +++ b/dev/Settings/User/Folders.js @@ -151,6 +151,10 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ { } } + hideError() { + this.folderListError(''); + } + toggleFolderKolabType(folder, event) { let type = event.target.value; // TODO: append '.default' ? diff --git a/dev/Settings/User/Themes.js b/dev/Settings/User/Themes.js index 8ed6b53ed..0c6cfe1d1 100644 --- a/dev/Settings/User/Themes.js +++ b/dev/Settings/User/Themes.js @@ -60,6 +60,10 @@ export class UserSettingsThemes /*extends AbstractViewSettings*/ { }); } + setTheme(theme) { + ThemeStore.theme(theme.name); + } + onBuild() { const currentTheme = ThemeStore.theme(); diff --git a/dev/Styles/User/Contacts.less b/dev/Styles/User/Contacts.less index e5f626d7e..4b0eb97b7 100644 --- a/dev/Styles/User/Contacts.less +++ b/dev/Styles/User/Contacts.less @@ -1,14 +1,8 @@ -@contacts-popup-left-width: 25%; - #V-PopupsContacts { - bottom: 0; - width: auto; - min-width: 550px; max-width: 900px; min-height: 300px; - max-height: 700px; .control-group { label.iconsize24 { @@ -18,32 +12,32 @@ } .modal-body { + display: flex; height: calc(100vh - 49px); padding: 0; - position: relative; + } + + /* Left */ + + .left { + max-width: 30%; } .b-list-toolbar { padding: 0; height: 44px; text-align: center; - width: @contacts-popup-left-width; - border-bottom: 1px solid rgba(128,128,128,0.4); + border-bottom: 1px solid var(--border-color, #ddd); .e-search { margin: 7px 0 0; - width: calc(100% - 20px); + width: calc(100% - 10px); } } .b-list-footer-toolbar { - position: absolute; - left: 0; - bottom: 0; height: 46px; - width: @contacts-popup-left-width; - background-color: var(--message-list-toolbar-bg-color,#eee); - box-shadow: inset 0 1px 0 #ccc; + border-top: 1px solid var(--border-color, #ddd); .footer-pager { padding: 8px 10px 0 0; @@ -52,11 +46,7 @@ } .b-list-content { - position: absolute; - top: 45px; - bottom: 46px; - left: 0; - width: @contacts-popup-left-width; + height: calc(100% - 44px - 46px); overflow: hidden; overflow-y: auto; scroll-behavior: smooth; @@ -87,9 +77,8 @@ position: relative; margin: 0; - border: 0; border-left: 6px solid #eee; - border-bottom: 1px solid rgba(128,128,128,0.25); + border-bottom: 1px solid var(--border-color, #ddd); &.focused { border-left-color: #bbb; @@ -132,15 +121,17 @@ } } + /* Right */ + + .right { + width: 100%; + border-left: 1px solid var(--border-color, #ddd); + } + .b-view-content-toolbar { + border-top: 1px solid var(--border-color, #ddd); padding: 7px; - position: absolute; - bottom: 0; - right: 0; - left: @contacts-popup-left-width; - height: 30px; text-align: right; - border-top: 1px solid rgba(128,128,128,0.4); .dropdown-menu.right-edge { top: auto; @@ -149,13 +140,9 @@ } .b-view-content { - position: absolute; - top: 0; - bottom: 45px; - left: @contacts-popup-left-width; - right: 0; + height: calc(100% - 46px); + margin: 0; overflow: auto; - border-left: 1px solid rgba(128,128,128,0.25); span { height: 20px; @@ -179,21 +166,8 @@ } input, textarea { - box-shadow: none; - border-color: #fff; - font-size: 18px; width: 70vw; max-width: 400px; - - &:hover { - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - border-color: #ccc; - } - - &:focus { - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - border-color: #999; - } } } @@ -227,3 +201,9 @@ html:not(rl-mobile) { } } } + +@media screen and (max-width: 900px) { + #V-PopupsContacts { + width: 100%; + } +} diff --git a/dev/Styles/User/SettingsThemes.less b/dev/Styles/User/SettingsThemes.less index 9b26a27cd..60f3bc0fe 100644 --- a/dev/Styles/User/SettingsThemes.less +++ b/dev/Styles/User/SettingsThemes.less @@ -8,7 +8,7 @@ cursor: pointer; padding: 8px; margin: 5px; - width: 128px; + min-width: 148px; &:hover { border-color: grey; diff --git a/dev/View/Admin/Login.js b/dev/View/Admin/Login.js index 5f8cf592f..d50ee858e 100644 --- a/dev/View/Admin/Login.js +++ b/dev/View/Admin/Login.js @@ -33,6 +33,10 @@ export class AdminLoginView extends AbstractViewLogin { }); } + hideError() { + this.submitError(''); + } + submitCommand(self, event) { let form = event.target.form, data = new FormData(form), diff --git a/dev/View/Popup/Account.js b/dev/View/Popup/Account.js index 779a43737..fd58bb680 100644 --- a/dev/View/Popup/Account.js +++ b/dev/View/Popup/Account.js @@ -22,6 +22,10 @@ export class AccountPopupView extends AbstractViewPopup { }); } + hideError() { + this.submitError(''); + } + submitForm(form) { if (!this.submitRequest() && form.reportValidity()) { const data = new FormData(form); diff --git a/dev/View/Popup/OpenPgpGenerate.js b/dev/View/Popup/OpenPgpGenerate.js index a76a62dc6..cf75ecb39 100644 --- a/dev/View/Popup/OpenPgpGenerate.js +++ b/dev/View/Popup/OpenPgpGenerate.js @@ -93,6 +93,10 @@ export class OpenPgpGeneratePopupView extends AbstractViewPopup { }); } + hideError() { + this.submitError(''); + } + showError(e) { console.log(e); if (e?.message) { diff --git a/dev/View/Popup/Plugin.js b/dev/View/Popup/Plugin.js index 3296a245d..0e482c6e0 100644 --- a/dev/View/Popup/Plugin.js +++ b/dev/View/Popup/Plugin.js @@ -35,6 +35,10 @@ export class PluginPopupView extends AbstractViewPopup { }); } + hideError() { + this.saveError(''); + } + saveCommand() { const oConfig = { Id: this.id(), diff --git a/dev/View/User/Login.js b/dev/View/User/Login.js index 9004b3a49..7bf1564c4 100644 --- a/dev/View/User/Login.js +++ b/dev/View/User/Login.js @@ -88,6 +88,14 @@ export class LoginUserView extends AbstractViewLogin { }); } + hideError() { + this.submitError(''); + } + + toggleSignMe() { + this.signMe(!this.signMe()); + } + submitCommand(self, event) { const email = this.email().trim(); this.email(email); diff --git a/dev/View/User/MailBox/MessageList.js b/dev/View/User/MailBox/MessageList.js index 69fcbecce..d50494eba 100644 --- a/dev/View/User/MailBox/MessageList.js +++ b/dev/View/User/MailBox/MessageList.js @@ -11,6 +11,7 @@ import { leftPanelDisabled, toggleLeftPanel, addShortcut, registerShortcut, formFieldFocused } from 'Common/Globals'; +import { arrayLength } from 'Common/Utils'; import { computedPaginatorHelper, showMessageComposer, populateMessageBody, download, moveAction } from 'Common/UtilsUser'; import { FileInfo } from 'Common/File'; import { isFullscreen, toggleFullscreen } from 'Common/Fullscreen'; @@ -102,6 +103,9 @@ export class MailMessageList extends AbstractViewRight { this.dragOver = ko.observable(false).extend({ throttle: 1 }); this.dragOverEnter = ko.observable(false).extend({ throttle: 1 }); + const attachmentsActions = Settings.app('attachmentsActions'); + this.attachmentsActions = ko.observableArray(arrayLength(attachmentsActions) ? attachmentsActions : []); + addComputablesTo(this, { sortSupported: () => @@ -148,7 +152,9 @@ export class MailMessageList extends AbstractViewRight { return '𝐒' + (desc ? '⬆' : '⬇'); } return (mode.includes('SIZE') ? 'βœ‰' : 'πŸ“…') + (desc ? '⬇' : '⬆'); - } + }, + + downloadAsZipAllowed: () => this.attachmentsActions.includes('zip') }); this.selector = new Selector( @@ -259,7 +265,8 @@ export class MailMessageList extends AbstractViewRight { ).throttle(50)); decorateKoCommands(this, { - downloadCommand: canBeMovedHelper, + downloadAttachCommand: canBeMovedHelper, + downloadZipCommand: canBeMovedHelper, forwardCommand: canBeMovedHelper, deleteWithoutMoveCommand: canBeMovedHelper, deleteCommand: canBeMovedHelper, @@ -292,7 +299,30 @@ export class MailMessageList extends AbstractViewRight { ]); } - downloadCommand() { + downloadZipCommand() { + let hashes = []/*, uids = []*/; +// MessagelistUserStore.forEach(message => message.checked() && uids.push(message.uid)); + MessagelistUserStore.forEach(message => message.checked() && hashes.push(message.requestHash)); + if (hashes.length) { + Remote.post('AttachmentsActions', null, { + Do: 'Zip', + Folder: MessagelistUserStore().Folder, +// Uids: uids, + Hashes: hashes + }) + .then(result => { + let hash = result?.Result?.FileHash; + if (hash) { + download(attachmentDownload(hash), hash+'.zip'); + } else { + alert('Download failed'); + } + }) + .catch(() => alert('Download failed')); + } + } + + downloadAttachCommand() { let hashes = []; MessagelistUserStore.forEach(message => { if (message.checked()) { diff --git a/dev/View/User/MailBox/MessageView.js b/dev/View/User/MailBox/MessageView.js index a589de2bc..ecbb33456 100644 --- a/dev/View/User/MailBox/MessageView.js +++ b/dev/View/User/MailBox/MessageView.js @@ -225,6 +225,10 @@ export class MailMessageView extends AbstractViewRight { }); } + toggleFullInfo() { + this.showFullInfo(!this.showFullInfo()); + } + closeMessage() { currentMessage(null); } @@ -393,9 +397,7 @@ export class MailMessageView extends AbstractViewRight { // message information registerShortcut('i', 'meta', [Scope.MessageList, Scope.MessageView], () => { - if (currentMessage()) { - this.showFullInfo(!this.showFullInfo()); - } + currentMessage() && this.toggleFullInfo(); return false; }); diff --git a/integrations/cloudron/DESCRIPTION.md b/integrations/cloudron/DESCRIPTION.md index 4ca3ec11e..6cc08fc1f 100644 --- a/integrations/cloudron/DESCRIPTION.md +++ b/integrations/cloudron/DESCRIPTION.md @@ -1,4 +1,4 @@ -This app packages SnappyMail 2.22.3. +This app packages SnappyMail 2.22.4. SnappyMail is a simple, modern, lightweight & fast web-based email client. diff --git a/integrations/cloudron/Dockerfile b/integrations/cloudron/Dockerfile index da746c635..4a797b0da 100644 --- a/integrations/cloudron/Dockerfile +++ b/integrations/cloudron/Dockerfile @@ -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.22.3 +VERSION=2.22.4 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 && \ diff --git a/integrations/nextcloud/snappymail/appinfo/info.xml b/integrations/nextcloud/snappymail/appinfo/info.xml index dfb335d19..3a058def2 100644 --- a/integrations/nextcloud/snappymail/appinfo/info.xml +++ b/integrations/nextcloud/snappymail/appinfo/info.xml @@ -3,7 +3,7 @@ snappymail SnappyMail SnappyMail Webmail - 2.22.3 + 2.22.4 agpl SnappyMail, RainLoop Team, Nextgen-Networks, Tab Fitts, Nathan Kinkade, Pierre-Alain Bandinelli addHook('hook.name', 'functionName'); Happens before send/save message ### filter.message-rcpt + Called by DoSendMessage and DoSendReadReceiptMessage params: \RainLoop\Model\Account $oAccount \MailSo\Mime\EmailCollection $oRcpt @@ -359,6 +360,7 @@ $Plugin->addHook('hook.name', 'functionName'); array &$aHiddenRcpt ### filter.smtp-message-stream + Called by DoSendMessage and DoSendReadReceiptMessage params: \RainLoop\Model\Account $oAccount resource &$rMessageStream diff --git a/plugins/avatars/avatars.js b/plugins/avatars/avatars.js index 6a1ccd56f..a6a0c85c8 100644 --- a/plugins/avatars/avatars.js +++ b/plugins/avatars/avatars.js @@ -1,14 +1,16 @@ (rl => { + const queue = [], avatars = new Map, + ncAvatars = new Map, templateId = 'MailMessageView', getAvatarUid = msg => { let from = msg.from[0], bimi = 'pass' == from.dkimStatus ? 1 : 0; return `${bimi}/${from.email.toLowerCase()}`; }, - getAvatar = msg => avatars.get(getAvatarUid(msg)), + getAvatar = msg => ncAvatars.get(msg.from[0].email.toLowerCase()) || avatars.get(getAvatarUid(msg)), runQueue = (() => { let item = queue.shift(); while (item) { @@ -17,15 +19,18 @@ item[1](url); item = queue.shift(); } else { - // TODO: fetch vCard from Nextcloud contacts -// let cfg = rl.settings.get('Nextcloud'), - let from = item[0].from[0]; rl.pluginRemoteRequest((iError, data) => { if (!iError && data?.Result.type) { url = `data:${data.Result.type};base64,${data.Result.data}`; avatars.set(getAvatarUid(item[0]), url); item[1](url); + } else if (window.identiconSvg) { + // rl.pluginSettingsGet('avatars', 'identicon'); + window.identiconSvg(from.email).then(url => { + avatars.set(getAvatarUid(item[0]), url); + item[1](url); + }); } runQueue(); }, 'Avatar', { @@ -37,6 +42,58 @@ } }).debounce(1000); + /** + * Loads images from Nextcloud contacts + */ + addEventListener('DOMContentLoaded', () => { +// rl.pluginSettingsGet('avatars', 'nextcloud'); + if (parent.OC) { + const OC = () => parent.OC, + nsDAV = 'DAV:', + nsNC = 'http://nextcloud.com/ns', + nsCard = 'urn:ietf:params:xml:ns:carddav', + getElementsByTagName = (parent, namespace, localName) => parent.getElementsByTagNameNS(namespace, localName), + getElementValue = (parent, namespace, localName) => + getElementsByTagName(parent, namespace, localName)?.item(0)?.textContent, + generateUrl = path => OC().webroot + '/remote.php' + path; + if (OC().requestToken) { + fetch(generateUrl(`/dav/addressbooks/users/${OC().currentUser}/contacts/`), { + mode: 'same-origin', + cache: 'no-cache', + redirect: 'error', + credentials: 'same-origin', + method: 'REPORT', + headers: { + requesttoken: OC().requestToken, + 'Content-Type': 'application/xml; charset=utf-8', + Depth: 1 + }, + body: '' + }) + .then(response => (response.status < 400) ? response.text() : Promise.reject(new Error({ response }))) + .then(text => { + const + xmlParser = new DOMParser(), + responseList = getElementsByTagName( + xmlParser.parseFromString(text, 'application/xml').documentElement, + nsDAV, + 'response'); + for (let i = 0; i < responseList.length; ++i) { + const item = responseList.item(i); + if (1 == getElementValue(item, nsNC, 'has-photo')) { + [...getElementValue(item, nsCard, 'address-data').matchAll(/EMAIL.*?:([^@\r\n]+@[^@\r\n]+)/g)].forEach(match => { + ncAvatars.set( + match[1].toLowerCase(), + getElementValue(item, nsDAV, 'href') + '?photo' + ); + }); + } + } + }); + } + } + }); + ko.bindingHandlers.fromPic = { init: (element, self, dummy, msg) => { if (msg) { @@ -46,6 +103,10 @@ fn(url); } else if (msg.avatar) { let bimi = 'pass' == msg.from[0].dkimStatus ? 1 : 0; + if (window.identiconSvg) { + // rl.pluginSettingsGet('avatars', 'identicon'); + element.onerror = () => window.identiconSvg(msg.from[0].email).then(fn); + } fn(`?Avatar/${bimi}/${msg.avatar}`); } else { queue.push([msg, fn]); diff --git a/plugins/avatars/index.php b/plugins/avatars/index.php index fcf762763..4754a541e 100644 --- a/plugins/avatars/index.php +++ b/plugins/avatars/index.php @@ -6,8 +6,8 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin NAME = 'Avatars', AUTHOR = 'SnappyMail', URL = 'https://snappymail.eu/', - VERSION = '1.1', - RELEASE = '2022-11-27', + VERSION = '1.2', + RELEASE = '2022-11-29', REQUIRED = '2.22.4', CATEGORY = 'Contacts', LICENSE = 'MIT', @@ -19,9 +19,9 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin $this->addJs('avatars.js'); $this->addJsonHook('Avatar', 'DoAvatar'); $this->addPartHook('Avatar', 'ServiceAvatar'); + $this->Config()->Get('plugin', 'identicon', false) && $this->addJs('jdenticon.js'); // https://github.com/the-djmaze/snappymail/issues/714 - $this->Config()->Get('plugin', 'delay', true) - || $this->addHook('filter.json-response', 'FilterJsonResponse'); + $this->Config()->Get('plugin', 'delay', true) || $this->addHook('filter.json-response', 'FilterJsonResponse'); } public function FilterJsonResponse(string $sAction, array &$aResponseItem) @@ -73,24 +73,41 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin if ($sEmail && ($aResult = $this->getAvatar($sEmail, !empty($sBimi)))) { \header('Content-Type: '.$aResult[0]); echo $aResult[1]; - return true; + } else { + \MailSo\Base\Http::StatusHeader(404); } - return false; + exit; } protected function configMapping() : array { - return array( + $aResult = array( \RainLoop\Plugins\Property::NewInstance('delay')->SetLabel('Delay loading') ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetDefaultValue(true), - \RainLoop\Plugins\Property::NewInstance('bimi')->SetLabel('Use BIMI (https://bimigroup.org/)') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) - ->SetDefaultValue(false), - \RainLoop\Plugins\Property::NewInstance('gravatar')->SetLabel('Use Gravatar') + \RainLoop\Plugins\Property::NewInstance('bimi')->SetLabel('Lookup BIMI') ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetDefaultValue(false) + ->SetDescription('https://bimigroup.org/'), + \RainLoop\Plugins\Property::NewInstance('gravatar')->SetLabel('Lookup Gravatar') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) + ->SetDefaultValue(false) + ->SetDescription('https://wikipedia.org/wiki/Gravatar') ); +/* + if (\class_exists('OC') && isset(\OC::$server)) { + $aResult[] = \RainLoop\Plugins\Property::NewInstance('nextcloud')->SetLabel('Lookup Nextcloud Contacts') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) +// ->SetAllowedInJs(true) + ->SetDefaultValue(false); + } +*/ + $aResult[] = \RainLoop\Plugins\Property::NewInstance('identicon')->SetLabel('Else create Identicon') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) +// ->SetAllowedInJs(true) + ->SetDefaultValue(false) + ->SetDescription('https://wikipedia.org/wiki/Identicon'); + return $aResult; } private function getAvatar(string $sEmail, bool $bBimi) : ?array @@ -150,7 +167,7 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin if ($BIMI) { $aUrls[] = $BIMI; // $aResult = ['text/uri-list', $BIMI]; - \SnappyMail\Log::debug('Avatar', "BIMI {$sDomain} for {$sUrl}"); + \SnappyMail\Log::debug('Avatar', "BIMI {$sDomain}: {$BIMI}"); } else { \SnappyMail\Log::notice('Avatar', "BIMI 404 for {$sDomain}"); } @@ -182,9 +199,11 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin $aServices = [ "services/{$sDomain}", 'services/' . \preg_replace('/^.+\\.([^.]+\\.[^.]+)$/D', '$1', $sDomain), - 'services/' . \preg_replace('/^(.+\\.)?(paypal\\.[a-z][a-z])$/D', 'paypal.com', $sDomain), - 'empty-contact' // DATA_IMAGE_USER_DOT_PIC + 'services/' . \preg_replace('/^(.+\\.)?(paypal\\.[a-z][a-z])$/D', 'paypal.com', $sDomain) ]; + if (!$this->Config()->Get('plugin', 'identicon', false)) { + $aServices[] = 'empty-contact'; // DATA_IMAGE_USER_DOT_PIC + } foreach ($aServices as $service) { $file = __DIR__ . "/images/{$service}.png"; if (\file_exists($file)) { diff --git a/plugins/avatars/jdenticon.js b/plugins/avatars/jdenticon.js new file mode 100644 index 000000000..35847e938 --- /dev/null +++ b/plugins/avatars/jdenticon.js @@ -0,0 +1,694 @@ +/** + * Jdenticon 3.2.0 + * http://jdenticon.com + * + * Built: 2022-08-07T11:23:11.640Z + * + * MIT License + * + * Copyright (c) 2014-2021 Daniel Mester PirttijΓ€rvi + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +(()=>{ +/** + * Parses a substring of the hash as a number. + * @param {number} startPosition + * @param {number=} octets + */ + +class HSLColor +{ + constructor(h, s, l) + { + this.h = h; + this.s = s; + this.l = l; +// this.a = a; + } + + toString() + { + const h = this.h, s = this.s, l = this.l/*, a = this.a*/, + hex = v => { + v = between(0, 255, v * 255).toString(16); + return v.slice(0,2).padStart(2, "0"); + }; + let r=l, g=l, b=l; + if (0 != s) { + let q=l<0.5?l*(s+1):l+s-l*s, + p=l*2-q, + hue2rgb = h => { + h += h<0 ? 1 : (h>1 ? -1 : 0); + return (h*6<1) ? (p+(q-p)*h*6) : ((h*2<1) ? q : ((h*3<2) ? p+(q-p)*(2/3-h)*6 : p)); + }; + r = hue2rgb(h+1/3); + g = hue2rgb(h); + b = hue2rgb(h-1/3); + } + return '#' + hex(r) + hex(g) + hex(b) // isNaN(a)?100:a*100); + } +} + +const + between = (l, h, v) => Math.max(l, Math.min(h, v)), + + parseHex = (hash, startPosition, octets) => parseInt(hash.substr(startPosition, octets), 16), + + /** + * Converts an HSL color to a hexadecimal RGB color. This function will correct the lightness for the "dark" hues + * @param {number} hue Hue in range [0, 1] + * @param {number} saturation Saturation in range [0, 1] + * @param {number} lightness Lightness in range [0, 1] + * @returns {string} + */ + correctedHsl = (hue, saturation, lightness) => { + // The corrector specifies the perceived middle lightness for each hue + var correctors = [ 0.55, 0.5, 0.5, 0.46, 0.6, 0.55, 0.55 ], + corrector = correctors[(hue * 6 + 0.5) | 0]; + + // Adjust the input lightness relative to the corrector + lightness = lightness < 0.5 ? lightness * corrector * 2 : corrector + (lightness - 0.5) * (1 - corrector) * 2; + + return new HSLColor(hue, saturation, lightness).toString(); + }; + +/** + * Represents a point. + */ +class Point +{ + constructor(x, y) { + this.x = x; + this.y = y; + } +} + +/** + * Translates and rotates a point before being passed on to the canvas context. This was previously done by the canvas context itself, + * but this caused a rendering issue in Chrome on sizes > 256 where the rotation transformation of inverted paths was not done properly. + */ +class Transform +{ + constructor(x, y, size, rotation) { + this.u/*_x*/ = x; + this.v/*_y*/ = y; + this.K/*_size*/ = size; + this.Z/*_rotation*/ = rotation; + } + + /** + * Transforms the specified point based on the translation and rotation specification for this Transform. + * @param {number} x x-coordinate + * @param {number} y y-coordinate + * @param {number=} w The width of the transformed rectangle. If greater than 0, this will ensure the returned point is of the upper left corner of the transformed rectangle. + * @param {number=} h The height of the transformed rectangle. If greater than 0, this will ensure the returned point is of the upper left corner of the transformed rectangle. + */ + L/*transformIconPoint*/(x, y, w, h) { + var right = this.u/*_x*/ + this.K/*_size*/, + bottom = this.v/*_y*/ + this.K/*_size*/, + rotation = this.Z/*_rotation*/; + return rotation === 1 ? new Point(right - y - (h || 0), this.v/*_y*/ + x) : + rotation === 2 ? new Point(right - x - (w || 0), bottom - y - (h || 0)) : + rotation === 3 ? new Point(this.u/*_x*/ + y, bottom - x - (w || 0)) : + new Point(this.u/*_x*/ + x, this.v/*_y*/ + y); + } +} + +var NO_TRANSFORM = new Transform(0, 0, 0, 0); + +/** + * Provides helper functions for rendering common basic shapes. + */ +class Graphics +{ + constructor(renderer) { + /** + * @type {Renderer} + * @private + */ + this.M/*_renderer*/ = renderer; + + /** + * @type {Transform} + */ + this.A/*currentTransform*/ = NO_TRANSFORM; + } + + /** + * Adds a polygon to the underlying renderer. + * @param {Array} points The points of the polygon clockwise on the format [ x0, y0, x1, y1, ..., xn, yn ] + * @param {boolean=} invert Specifies if the polygon will be inverted. + */ + g/*addPolygon*/(points, invert) { + var this$1 = this; + + var di = invert ? -2 : 2, + transformedPoints = []; + + for (var i = invert ? points.length - 2 : 0; i < points.length && i >= 0; i += di) { + transformedPoints.push(this$1.A/*currentTransform*/.L/*transformIconPoint*/(points[i], points[i + 1])); + } + + this.M/*_renderer*/.g/*addPolygon*/(transformedPoints); + } + + /** + * Adds a polygon to the underlying renderer. + * Source: http://stackoverflow.com/a/2173084 + * @param {number} x The x-coordinate of the upper left corner of the rectangle holding the entire ellipse. + * @param {number} y The y-coordinate of the upper left corner of the rectangle holding the entire ellipse. + * @param {number} size The size of the ellipse. + * @param {boolean=} invert Specifies if the ellipse will be inverted. + */ + h/*addCircle*/(x, y, size, invert) { + var p = this.A/*currentTransform*/.L/*transformIconPoint*/(x, y, size, size); + this.M/*_renderer*/.h/*addCircle*/(p, size, invert); + } + + /** + * Adds a rectangle to the underlying renderer. + * @param {number} x The x-coordinate of the upper left corner of the rectangle. + * @param {number} y The y-coordinate of the upper left corner of the rectangle. + * @param {number} w The width of the rectangle. + * @param {number} h The height of the rectangle. + * @param {boolean=} invert Specifies if the rectangle will be inverted. + */ + i/*addRectangle*/(x, y, w, h, invert) { + this.g/*addPolygon*/([ + x, y, + x + w, y, + x + w, y + h, + x, y + h + ], invert); + } + + /** + * Adds a right triangle to the underlying renderer. + * @param {number} x The x-coordinate of the upper left corner of the rectangle holding the triangle. + * @param {number} y The y-coordinate of the upper left corner of the rectangle holding the triangle. + * @param {number} w The width of the triangle. + * @param {number} h The height of the triangle. + * @param {number} r The rotation of the triangle (clockwise). 0 = right corner of the triangle in the lower left corner of the bounding rectangle. + * @param {boolean=} invert Specifies if the triangle will be inverted. + */ + j/*addTriangle*/(x, y, w, h, r, invert) { + var points = [ + x + w, y, + x + w, y + h, + x, y + h, + x, y + ]; + points.splice(((r || 0) % 4) * 2, 2); + this.g/*addPolygon*/(points, invert); + } + + /** + * Adds a rhombus to the underlying renderer. + * @param {number} x The x-coordinate of the upper left corner of the rectangle holding the rhombus. + * @param {number} y The y-coordinate of the upper left corner of the rectangle holding the rhombus. + * @param {number} w The width of the rhombus. + * @param {number} h The height of the rhombus. + * @param {boolean=} invert Specifies if the rhombus will be inverted. + */ + N/*addRhombus*/(x, y, w, h, invert) { + this.g/*addPolygon*/([ + x + w / 2, y, + x + w, y + h / 2, + x + w / 2, y + h, + x, y + h / 2 + ], invert); + } +} + +/** + * @param {number} index + * @param {Graphics} g + * @param {number} cell + * @param {number} positionIndex + */ +function centerShape(index, g, cell, positionIndex) { + index = index % 14; + + var k, m, w, h, inner, outer; + + !index ? ( + k = cell * 0.42, + g.g/*addPolygon*/([ + 0, 0, + cell, 0, + cell, cell - k * 2, + cell - k, cell, + 0, cell + ])) : + + index == 1 ? ( + w = 0 | (cell * 0.5), + h = 0 | (cell * 0.8), + + g.j/*addTriangle*/(cell - w, 0, w, h, 2)) : + + index == 2 ? ( + w = 0 | (cell / 3), + g.i/*addRectangle*/(w, w, cell - w, cell - w)) : + + index == 3 ? ( + inner = cell * 0.1, + // Use fixed outer border widths in small icons to ensure the border is drawn + outer = + cell < 6 ? 1 : + cell < 8 ? 2 : + (0 | (cell * 0.25)), + + inner = + inner > 1 ? (0 | inner) : // large icon => truncate decimals + inner > 0.5 ? 1 : // medium size icon => fixed width + inner, // small icon => anti-aliased border + + g.i/*addRectangle*/(outer, outer, cell - inner - outer, cell - inner - outer)) : + + index == 4 ? ( + m = 0 | (cell * 0.15), + w = 0 | (cell * 0.5), + g.h/*addCircle*/(cell - w - m, cell - w - m, w)) : + + index == 5 ? ( + inner = cell * 0.1, + outer = inner * 4, + + // Align edge to nearest pixel in large icons + outer > 3 && (outer = 0 | outer), + + g.i/*addRectangle*/(0, 0, cell, cell), + g.g/*addPolygon*/([ + outer, outer, + cell - inner, outer, + outer + (cell - outer - inner) / 2, cell - inner + ], true)) : + + index == 6 ? + g.g/*addPolygon*/([ + 0, 0, + cell, 0, + cell, cell * 0.7, + cell * 0.4, cell * 0.4, + cell * 0.7, cell, + 0, cell + ]) : + + index == 7 ? + g.j/*addTriangle*/(cell / 2, cell / 2, cell / 2, cell / 2, 3) : + + index == 8 ? ( + g.i/*addRectangle*/(0, 0, cell, cell / 2), + g.i/*addRectangle*/(0, cell / 2, cell / 2, cell / 2), + g.j/*addTriangle*/(cell / 2, cell / 2, cell / 2, cell / 2, 1)) : + + index == 9 ? ( + inner = cell * 0.14, + // Use fixed outer border widths in small icons to ensure the border is drawn + outer = + cell < 4 ? 1 : + cell < 6 ? 2 : + (0 | (cell * 0.35)), + + inner = + cell < 8 ? inner : // small icon => anti-aliased border + (0 | inner), // large icon => truncate decimals + + g.i/*addRectangle*/(0, 0, cell, cell), + g.i/*addRectangle*/(outer, outer, cell - outer - inner, cell - outer - inner, true)) : + + index == 10 ? ( + inner = cell * 0.12, + outer = inner * 3, + + g.i/*addRectangle*/(0, 0, cell, cell), + g.h/*addCircle*/(outer, outer, cell - inner - outer, true)) : + + index == 11 ? + g.j/*addTriangle*/(cell / 2, cell / 2, cell / 2, cell / 2, 3) : + + index == 12 ? ( + m = cell * 0.25, + g.i/*addRectangle*/(0, 0, cell, cell), + g.N/*addRhombus*/(m, m, cell - m, cell - m, true)) : + + // 13 + ( + !positionIndex && ( + m = cell * 0.4, w = cell * 1.2, + g.h/*addCircle*/(m, m, w) + ) + ); +} + +/** + * @param {number} index + * @param {Graphics} g + * @param {number} cell + */ +function outerShape(index, g, cell) { + index = index % 4; + + var m; + + !index ? + g.j/*addTriangle*/(0, 0, cell, cell, 0) : + + index == 1 ? + g.j/*addTriangle*/(0, cell / 2, cell, cell / 2, 0) : + + index == 2 ? + g.N/*addRhombus*/(0, 0, cell, cell) : + + // 3 + ( + m = cell / 6, + g.h/*addCircle*/(m, m, cell - 2 * m) + ); +} + +/** + * Computes a SHA1 hash for any value and returns it as a hexadecimal string. + * + * This function is optimized for minimal code size and rather short messages. + * + * @param {string} message + */ +async function computeHash(message) { + const hashArray = Array.from(new Uint8Array( +// await crypto.subtle.digest('SHA-256', (new TextEncoder()).encode(message)) + await crypto.subtle.digest('SHA-1', (new TextEncoder()).encode(message)) + )); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string +} + +/** + * Prepares a measure to be used as a measure in an SVG path, by + * rounding the measure to a single decimal. This reduces the file + * size of the generated SVG with more than 50% in some cases. + */ +function svgValue(value) { + return ((value * 10 + 0.5) | 0) / 10; +} + +/** + * Represents an SVG path element. + */ +class SvgPath +{ + constructor() { + /** + * This property holds the data string (path.d) of the SVG path. + * @type {string} + */ + this.B/*dataString*/ = ""; + } + + /** + * Adds a polygon with the current fill color to the SVG path. + * @param points An array of Point objects. + */ + g/*addPolygon*/(points) { + var dataString = ""; + for (var i = 0; i < points.length; i++) { + dataString += (i ? "L" : "M") + svgValue(points[i].x) + " " + svgValue(points[i].y); + } + this.B/*dataString*/ += dataString + "Z"; + } + + /** + * Adds a circle with the current fill color to the SVG path. + * @param {Point} point The upper left corner of the circle bounding box. + * @param {number} diameter The diameter of the circle. + * @param {boolean} counterClockwise True if the circle is drawn counter-clockwise (will result in a hole if rendered on a clockwise path). + */ + h/*addCircle*/(point, diameter, counterClockwise) { + var sweepFlag = counterClockwise ? 0 : 1, + svgRadius = svgValue(diameter / 2), + svgDiameter = svgValue(diameter), + svgArc = "a" + svgRadius + "," + svgRadius + " 0 1," + sweepFlag + " "; + + this.B/*dataString*/ += + "M" + svgValue(point.x) + " " + svgValue(point.y + diameter / 2) + + svgArc + svgDiameter + ",0" + + svgArc + (-svgDiameter) + ",0"; + } +} + + +/** + * Renderer producing SVG output. + * @implements {Renderer} + */ +class SvgRenderer +{ + constructor(target) { + /** + * @type {SvgPath} + * @private + */ + this.C/*_path*/; + + /** + * @type {Object.} + * @private + */ + this.D/*_pathsByColor*/ = { }; + + /** + * @type {SvgElement|SvgWriter} + * @private + */ + this.R/*_target*/ = target; + + /** + * @type {number} + */ + this.k/*iconSize*/ = target.k/*iconSize*/; + } + + /** + * Fills the background with the specified color. + * @param {string} fillColor Fill color on the format #rrggbb[aa]. + */ + m/*setBackground*/(fillColor) { + var match = /^(#......)(..)?/.exec(fillColor), + opacity = match[2] ? parseHex(match[2], 0) / 255 : 1; + this.R/*_target*/.m/*setBackground*/(match[1], opacity); + } + + /** + * Marks the beginning of a new shape of the specified color. Should be ended with a call to endShape. + * @param {string} color Fill color on format #xxxxxx. + */ + O/*beginShape*/(color) { + this.C/*_path*/ = this.D/*_pathsByColor*/[color] || (this.D/*_pathsByColor*/[color] = new SvgPath()); + } + + /** + * Marks the end of the currently drawn shape. + */ + P/*endShape*/() { } + + /** + * Adds a polygon with the current fill color to the SVG. + * @param points An array of Point objects. + */ + g/*addPolygon*/(points) { + this.C/*_path*/.g/*addPolygon*/(points); + } + + /** + * Adds a circle with the current fill color to the SVG. + * @param {Point} point The upper left corner of the circle bounding box. + * @param {number} diameter The diameter of the circle. + * @param {boolean} counterClockwise True if the circle is drawn counter-clockwise (will result in a hole if rendered on a clockwise path). + */ + h/*addCircle*/(point, diameter, counterClockwise) { + this.C/*_path*/.h/*addCircle*/(point, diameter, counterClockwise); + } + + /** + * Called when the icon has been completely drawn. + */ + finish() { + var pathsByColor = this.D/*_pathsByColor*/; + for (var color in pathsByColor) { + // hasOwnProperty cannot be shadowed in pathsByColor + // eslint-disable-next-line no-prototype-builtins + if (pathsByColor.hasOwnProperty(color)) { + this.R/*_target*/.S/*appendPath*/(color, pathsByColor[color].B/*dataString*/); + } + } + } +} + +/** + * Renderer producing SVG output. + */ +class SvgWriter +{ + constructor(iconSize) { + /** + * @type {number} + */ + this.k/*iconSize*/ = iconSize; + + /** + * @type {string} + * @private + */ + this.F = + ''; + } + + /** + * Fills the background with the specified color. + * @param {string} fillColor Fill color on the format #rrggbb. + * @param {number} opacity Opacity in the range [0.0, 1.0]. + */ + m/*setBackground*/(fillColor, opacity) { + if (opacity) { + this.F += ''; + } + } + + /** + * Writes a path to the SVG string. + * @param {string} color Fill color on format #rrggbb. + * @param {string} dataString The SVG path data string. + */ + S/*appendPath*/(color, dataString) { + this.F += ''; + } + + /** + * Gets the rendered image as an SVG string. + */ + toString() { + return this.F + ""; + } +} + +/** + * Draws an identicon as an SVG string. + * @param {*} hashOrValue - A hexadecimal hash string or any value that will be hashed by Jdenticon. + * @returns {string} SVG string + */ +window.identiconSvg = async (hashOrValue) => { + const writer = new SvgWriter(50), + renderer = new SvgRenderer(writer), + hash = (/^[0-9a-f]{11,}$/i.test(hashOrValue) && hashOrValue) + || await computeHash(hashOrValue == null ? "" : "" + hashOrValue), + config = { + p/*colorSaturation*/: 0.5, + H/*grayscaleSaturation*/: 0, + q/*colorLightness*/: value => between(0, 1, 0.4 + value * (0.8 - 0.4)), + I/*grayscaleLightness*/: value => between(0, 1, 0.3 + value * (0.9 - 0.3)) + }; + + var index; + + // Calculate padding and round to nearest integer + var size = renderer.k/*iconSize*/; + var padding = (0.5 + size * 0.08) | 0; + size -= padding * 2; + + const graphics = new Graphics(renderer), + + // Calculate cell size and ensure it is an integer + cell = 0 | (size / 4), + + // Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon + s = 0 | (padding + size / 2 - cell * 2), + + // AVAILABLE COLORS + hue = parseHex(hash, -7) / 0xfffffff, + + // Available colors for this icon + availableColors = [ + // Dark gray + correctedHsl(hue, config.H/*grayscaleSaturation*/, config.I/*grayscaleLightness*/(0)), + // Mid color + correctedHsl(hue, config.p/*colorSaturation*/, config.q/*colorLightness*/(0.5)), + // Light gray + correctedHsl(hue, config.H/*grayscaleSaturation*/, config.I/*grayscaleLightness*/(1)), + // Light color + correctedHsl(hue, config.p/*colorSaturation*/, config.q/*colorLightness*/(1)), + // Dark color + correctedHsl(hue, config.p/*colorSaturation*/, config.q/*colorLightness*/(0)) + ], + + // The index of the selected colors + selectedColorIndexes = [], + + isDuplicate = values => { + if (values.indexOf(index) >= 0) { + for (var i = 0; i < values.length; i++) { + if (selectedColorIndexes.indexOf(values[i]) >= 0) { + return true; + } + } + } + }, + + renderShape = (colorIndex, shapes, index, rotationIndex, positions) => { + var shapeIndex = parseHex(hash, index, 1); + var r = rotationIndex ? parseHex(hash, rotationIndex, 1) : 0; + + renderer.O/*beginShape*/(availableColors[selectedColorIndexes[colorIndex]]); + + for (var i = 0; i < positions.length; i++) { + graphics.A/*currentTransform*/ + = new Transform(s + positions[i][0] * cell, s + positions[i][1] * cell, cell, r++ % 4); + shapes(shapeIndex, graphics, cell, i); + } + + renderer.P/*endShape*/(); + }; + + for (var i = 0; i < 3; i++) { + index = parseHex(hash, 8 + i, 1) % availableColors.length; + if (isDuplicate([0, 4]) || // Disallow dark gray and dark color combo + isDuplicate([2, 3])) { // Disallow light gray and light color combo + index = 1; + } + selectedColorIndexes.push(index); + } + + // ACTUAL RENDERING + // Sides + renderShape(0, outerShape, 2, 3, [[1, 0], [2, 0], [2, 3], [1, 3], [0, 1], [3, 1], [3, 2], [0, 2]]); + // Corners + renderShape(1, outerShape, 4, 5, [[0, 0], [3, 0], [3, 3], [0, 3]]); + // Center + renderShape(2, centerShape, 1, null, [[1, 1], [2, 1], [2, 2], [1, 2]]); + + renderer.finish(); + + return 'data:image/svg+xml;base64,' + btoa(writer); +}; + +})(); diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Attachments.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Attachments.php new file mode 100644 index 000000000..adcde6fc8 --- /dev/null +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Attachments.php @@ -0,0 +1,179 @@ +GetActionParam('Do', ''); + $sFolder = $this->GetActionParam('Folder', ''); + $aHashes = $this->GetActionParam('Hashes', null); + $oFilesProvider = $this->FilesProvider(); + if (empty($sAction) || !$this->GetCapa(Capa::ATTACHMENTS_ACTIONS) || !$oFilesProvider || !$oFilesProvider->IsActive()) { + return $this->FalseResponse(__FUNCTION__); + } + + $oAccount = $this->initMailClientConnection(); + + $bError = false; + $aData = []; + $mUIDs = []; + + if (\is_array($aHashes) && \count($aHashes)) { + foreach ($aHashes as $sZipHash) { + $aResult = $this->getMimeFileByHash($oAccount, $sZipHash); + if (empty($aResult['FileHash'])) { + $bError = true; + break; + } + $aData[] = $aResult; + if (!empty($aResult['MimeIndex'])) { + $mUIDs[$aResult['Uid']] = $aResult['Uid']; + } + } + } + $mUIDs = 1 < \count($mUIDs); + + if ($bError || !\count($aData)) { + return $this->FalseResponse(__FUNCTION__); + } + + $mResult = false; + switch (\strtolower($sAction)) + { + case 'zip': + + $sZipHash = \MailSo\Base\Utils::Sha1Rand(); + $sZipFileName = $oFilesProvider->GenerateLocalFullFileName($oAccount, $sZipHash); + + if (!empty($sZipFileName)) { + if (\class_exists('ZipArchive')) { + $oZip = new \ZipArchive(); + $oZip->open($sZipFileName, \ZIPARCHIVE::CREATE | \ZIPARCHIVE::OVERWRITE); + $oZip->setArchiveComment('SnappyMail/'.APP_VERSION); + foreach ($aData as $aItem) { + $sFullFileNameHash = $oFilesProvider->GetFileName($oAccount, $aItem['FileHash']); + $sFileName = ($mUIDs ? "{$aItem['Uid']}/" : ($sFolder ? "{$aItem['Uid']}-" : '')) . $aItem['FileName']; + if (!$oZip->addFile($sFullFileNameHash, $sFileName)) { + $bError = true; + } + } + + if ($bError) { + $oZip->close(); + } else { + $bError = !$oZip->close(); + } +/* + } else { + @\unlink($sZipFileName); + $oZip = new \SnappyMail\Stream\ZIP($sZipFileName); +// $oZip->setArchiveComment('SnappyMail/'.APP_VERSION); + foreach ($aData as $aItem) { + if ($aItem['FileHash']) { + $sFullFileNameHash = $oFilesProvider->GetFileName($oAccount, $aItem['FileHash']); + if (!$oZip->addFile($sFullFileNameHash, $aItem['FileName'])) { + $bError = true; + } + } + } + $oZip->close(); +*/ + } else { + @\unlink($sZipFileName); + $oZip = new \PharData($sZipFileName . '.zip', 0, null, \Phar::ZIP); + $oZip->compressFiles(\Phar::GZ); + foreach ($aData as $aItem) { + $oZip->addFile( + $oFilesProvider->GetFileName($oAccount, $aItem['FileHash']), + ($mUIDs ? "{$aItem['Uid']}/" : ($sFolder ? "{$aItem['Uid']}-" : '')) . $aItem['FileName'] + ); + } + $oZip->compressFiles(\Phar::GZ); + unset($oZip); + \rename($sZipFileName . '.zip', $sZipFileName); + } + + foreach ($aData as $aItem) { + $oFilesProvider->Clear($oAccount, $aItem['FileHash']); + } + + if (!$bError) { + $mResult = array( + 'FileHash' => Utils::EncodeKeyValuesQ(array( + 'Account' => $oAccount ? $oAccount->Hash() : '', + 'FileName' => ($sFolder ? 'messages' : 'attachments') . \date('-YmdHis') . '.zip', + 'MimeType' => 'application/zip', + 'FileHash' => $sZipHash + )) + ); + } + } + break; + + default: + $data = new \SnappyMail\AttachmentsAction; + $data->action = $sAction; + $data->items = $aData; + $data->filesProvider = $oFilesProvider; + $data->account = $oAccount; + $this->Plugins()->RunHook('json.attachments', array($data)); + $mResult = $data->result; + break; + } + +// $this->requestSleep(); + return $this->DefaultResponse(__FUNCTION__, $bError ? false : $mResult); + } + + private function getMimeFileByHash(\RainLoop\Model\Account $oAccount, string $sHash) : array + { + $aValues = $this->getDecodedRawKeyValue($sHash); + + $sFolder = isset($aValues['Folder']) ? (string) $aValues['Folder'] : ''; + $iUid = isset($aValues['Uid']) ? (int) $aValues['Uid'] : 0; + $sMimeIndex = isset($aValues['MimeIndex']) ? (string) $aValues['MimeIndex'] : ''; + + $sContentTypeIn = isset($aValues['MimeType']) ? (string) $aValues['MimeType'] : ''; + $sFileNameIn = isset($aValues['FileName']) ? (string) $aValues['FileName'] : 'file.dat'; + + $oFileProvider = $this->FilesProvider(); + + $sResultHash = ''; + + $mResult = $this->MailClient()->MessageMimeStream(function ($rResource, $sContentType, $sFileName, $sMimeIndex = '') + use ($oAccount, $oFileProvider, $sFileNameIn, $sContentTypeIn, &$sResultHash) { + + unset($sContentType, $sFileName, $sMimeIndex); + + if (\is_resource($rResource)) + { + $sHash = \MailSo\Base\Utils::Sha1Rand($sFileNameIn.'~'.$sContentTypeIn); + $rTempResource = $oFileProvider->GetFile($oAccount, $sHash, 'wb+'); + + if (\is_resource($rTempResource)) + { + if (false !== \MailSo\Base\Utils::MultipleStreamWriter($rResource, array($rTempResource))) + { + $sResultHash = $sHash; + } + + \fclose($rTempResource); + } + } + + }, $sFolder, $iUid, $sMimeIndex); + + $aValues['FileName'] = $sFileNameIn; + $aValues['FileHash'] = $mResult ? $sResultHash : ''; + + return $aValues; + } +} diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Themes.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Themes.php index b148c4aeb..b049cecfe 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Themes.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Themes.php @@ -92,7 +92,7 @@ trait Themes return \in_array($sTheme, $this->GetThemes()) ? $sTheme : $this->Config()->Get('themes', 'default', 'Default'); } - public function compileCss(string $sTheme, bool $bAdmin) : string + public function compileCss(string $sTheme, bool $bAdmin, bool $bMinified = false) : string { $bCustomTheme = '@custom' === \substr($sTheme, -7); if ($bCustomTheme) { @@ -118,7 +118,7 @@ trait Themes } } - $mResult[] = $this->Plugins()->CompileCss($bAdmin, $bLess); + $mResult[] = $this->Plugins()->CompileCss($bAdmin, $bLess, $bMinified); $mResult = \preg_replace('@(url\(["\']?)(\\./)?([a-z]+[^:a-z])@', "\$1{$sBase}\$3", diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/User.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/User.php index 39d1234f0..ba7f0c6ea 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/User.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/User.php @@ -15,6 +15,7 @@ trait User use Filters; use Folders; use Messages; + use Attachments; use Pgp; /** @@ -68,131 +69,6 @@ trait User return $this->DefaultResponse(__FUNCTION__, $this->AppData(false)); } - /** - * @throws \MailSo\RuntimeException - */ - public function DoAttachmentsActions() : array - { - $sAction = $this->GetActionParam('Do', ''); - $aHashes = $this->GetActionParam('Hashes', null); - $oFilesProvider = $this->FilesProvider(); - if (empty($sAction) || !$this->GetCapa(Capa::ATTACHMENTS_ACTIONS) || !$oFilesProvider || !$oFilesProvider->IsActive()) { - return $this->FalseResponse(__FUNCTION__); - } - - $oAccount = $this->initMailClientConnection(); - - $bError = false; - $aData = []; - $mUIDs = []; - - if (\is_array($aHashes) && \count($aHashes)) { - foreach ($aHashes as $sZipHash) { - $aResult = $this->getMimeFileByHash($oAccount, $sZipHash); - if (empty($aResult['FileHash'])) { - $bError = true; - break; - } - $aData[] = $aResult; - $mUIDs[$aResult['Uid']] = $aResult['Uid']; - } - } - $mUIDs = 1 < \count($mUIDs); - - if ($bError || !\count($aData)) { - return $this->FalseResponse(__FUNCTION__); - } - - $mResult = false; - switch (\strtolower($sAction)) - { - case 'zip': - - $sZipHash = \MailSo\Base\Utils::Sha1Rand(); - $sZipFileName = $oFilesProvider->GenerateLocalFullFileName($oAccount, $sZipHash); - - if (!empty($sZipFileName)) { - if (\class_exists('ZipArchive')) { - $oZip = new \ZipArchive(); - $oZip->open($sZipFileName, \ZIPARCHIVE::CREATE | \ZIPARCHIVE::OVERWRITE); - $oZip->setArchiveComment('SnappyMail/'.APP_VERSION); - foreach ($aData as $aItem) { - $sFullFileNameHash = $oFilesProvider->GetFileName($oAccount, $aItem['FileHash']); - $sFileName = ($mUIDs ? "{$aItem['Uid']}/" : '') . ($aItem['FileName'] ?: 'file.dat'); - if (!$oZip->addFile($sFullFileNameHash, $sFileName)) { - $bError = true; - } - } - - if ($bError) { - $oZip->close(); - } else { - $bError = !$oZip->close(); - } -/* - } else { - @\unlink($sZipFileName); - $oZip = new \SnappyMail\Stream\ZIP($sZipFileName); -// $oZip->setArchiveComment('SnappyMail/'.APP_VERSION); - foreach ($aData as $aItem) { - $sFileName = (string) (isset($aItem['FileName']) ? $aItem['FileName'] : 'file.dat'); - $sFileHash = (string) (isset($aItem['FileHash']) ? $aItem['FileHash'] : ''); - if (!empty($sFileHash)) { - $sFullFileNameHash = $oFilesProvider->GetFileName($oAccount, $sFileHash); - if (!$oZip->addFile($sFullFileNameHash, $sFileName)) { - $bError = true; - } - } - } - $oZip->close(); -*/ - } else { - @\unlink($sZipFileName); - $oZip = new \PharData($sZipFileName . '.zip', 0, null, \Phar::ZIP); - $oZip->compressFiles(\Phar::GZ); - foreach ($aData as $aItem) { - $oZip->addFile( - $oFilesProvider->GetFileName($oAccount, $aItem['FileHash']), - ($mUIDs ? "{$aItem['Uid']}/" : '') . ($aItem['FileName'] ?: 'file.dat') - ); - } - $oZip->compressFiles(\Phar::GZ); - unset($oZip); - \rename($sZipFileName . '.zip', $sZipFileName); - } - - foreach ($aData as $aItem) { - $oFilesProvider->Clear($oAccount, $aItem['FileHash']); - } - - if (!$bError) { - $mResult = array( - 'FileHash' => Utils::EncodeKeyValuesQ(array( - 'Account' => $oAccount ? $oAccount->Hash() : '', - 'FileName' => 'attachments.zip', - 'MimeType' => 'application/zip', - 'FileHash' => $sZipHash - )) - ); - } - } - break; - - default: - $data = new \SnappyMail\AttachmentsAction; - $data->action = $sAction; - $data->items = $aData; - $data->filesProvider = $oFilesProvider; - $data->account = $oAccount; - $this->Plugins()->RunHook('json.attachments', array($data)); - $mResult = $data->result; - break; - } - -// $this->requestSleep(); - return $this->DefaultResponse(__FUNCTION__, $bError ? false : $mResult); - } - public function DoLogout() : array { $bMain = true; // empty($_COOKIE[self::AUTH_ADDITIONAL_TOKEN_KEY]); @@ -468,53 +344,6 @@ trait User $this->SettingsProvider()->Save($oAccount, $oSettings) : false); } - private function getMimeFileByHash(\RainLoop\Model\Account $oAccount, string $sHash) : array - { - $aValues = $this->getDecodedRawKeyValue($sHash); - - $sFolder = isset($aValues['Folder']) ? (string) $aValues['Folder'] : ''; - $iUid = isset($aValues['Uid']) ? (int) $aValues['Uid'] : 0; - $sMimeIndex = isset($aValues['MimeIndex']) ? (string) $aValues['MimeIndex'] : ''; - - $sContentTypeIn = isset($aValues['MimeType']) ? (string) $aValues['MimeType'] : ''; - $sFileNameIn = isset($aValues['FileName']) ? (string) $aValues['FileName'] : ''; - - $oFileProvider = $this->FilesProvider(); - - $sResultHash = ''; - - $mResult = $this->MailClient()->MessageMimeStream(function ($rResource, $sContentType, $sFileName, $sMimeIndex = '') - use ($oAccount, $oFileProvider, $sFileNameIn, $sContentTypeIn, &$sResultHash) { - - unset($sContentType, $sFileName, $sMimeIndex); - - if ($oAccount && \is_resource($rResource)) - { - $sHash = \MailSo\Base\Utils::Sha1Rand($sFileNameIn.'~'.$sContentTypeIn); - $rTempResource = $oFileProvider->GetFile($oAccount, $sHash, 'wb+'); - - if (\is_resource($rTempResource)) - { - if (false !== \MailSo\Base\Utils::MultipleStreamWriter($rResource, array($rTempResource))) - { - $sResultHash = $sHash; - } - - \fclose($rTempResource); - } - } - - }, $sFolder, $iUid, $sMimeIndex); - - $aValues['FileHash'] = ''; - if ($mResult) - { - $aValues['FileHash'] = $sResultHash; - } - - return $aValues; - } - private function setSettingsFromParams(\RainLoop\Settings $oSettings, string $sConfigName, string $sType = 'string', ?callable $cCallback = null) : void { if ($this->HasActionParam($sConfigName)) diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/UserAuth.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/UserAuth.php index 6c7b858a4..cd2937b22 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/UserAuth.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/UserAuth.php @@ -334,11 +334,9 @@ trait UserAuth private function SetSignMeToken(MainAccount $oAccount): void { - $this->ClearSignMeData(); - +// $this->ClearSignMeData(); $uuid = \SnappyMail\UUID::generate(); $data = \SnappyMail\Crypt::Encrypt($oAccount); - Utils::SetCookie( self::AUTH_SIGN_ME_TOKEN_KEY, \SnappyMail\Crypt::EncryptUrlSafe([ @@ -348,13 +346,7 @@ trait UserAuth ]), \time() + 3600 * 24 * 30 // 30 days ); - - $this->StorageProvider()->Put( - $oAccount, - StorageType::SIGN_ME, - $uuid, - $data[2] - ); + $this->StorageProvider()->Put($oAccount, StorageType::SIGN_ME, $uuid, $data[2]); } public function GetAccountFromSignMeToken(): ?MainAccount @@ -368,36 +360,32 @@ trait UserAuth StorageType::SIGN_ME, $aTokenData['u'] ); - if ($sAuthToken) { - $aAccountHash = \SnappyMail\Crypt::Decrypt([ - \array_key_last($aTokenData), - \base64_decode(\end($aTokenData)), - $sAuthToken - ]); - if (\is_array($aAccountHash)) { - $oAccount = MainAccount::NewInstanceFromTokenArray($this, $aAccountHash); - if ($oAccount) { - $this->CheckMailConnection($oAccount); - // Update lifetime - $this->SetSignMeToken($oAccount); - return $oAccount; - } - \SnappyMail\Log::notice(self::AUTH_SIGN_ME_TOKEN_KEY, 'has no account'); - } else { - \SnappyMail\Log::notice(self::AUTH_SIGN_ME_TOKEN_KEY, 'decrypt failed'); - } - } else { - \SnappyMail\Log::notice(self::AUTH_SIGN_ME_TOKEN_KEY, "server token not found for {$aTokenData['e']}/.sign_me/{$aTokenData['u']}"); + if (!$sAuthToken) { + throw new \RuntimeException("server token not found for {$aTokenData['e']}/.sign_me/{$aTokenData['u']}"); } + $aAccountHash = \SnappyMail\Crypt::Decrypt([ + \array_key_last($aTokenData), + \base64_decode(\end($aTokenData)), + $sAuthToken + ]); + if (!\is_array($aAccountHash)) { + throw new \RuntimeException('token decrypt failed'); + } + $oAccount = MainAccount::NewInstanceFromTokenArray($this, $aAccountHash); + if (!$oAccount) { + throw new \RuntimeException('token has no account'); + } + $this->CheckMailConnection($oAccount); + // Update lifetime + $this->SetSignMeToken($oAccount); + return $oAccount; } catch (\Throwable $oException) { - \SnappyMail\Log::notice(self::AUTH_SIGN_ME_TOKEN_KEY, $oException->getMessage()); + \SnappyMail\Log::warning(self::AUTH_SIGN_ME_TOKEN_KEY, $oException->getMessage()); } + $this->ClearSignMeData(); } - - $this->ClearSignMeData(); - return null; } diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Plugins/Manager.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Plugins/Manager.php index 37bcbe7c5..7ee9c6ca9 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Plugins/Manager.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Plugins/Manager.php @@ -169,9 +169,13 @@ class Manager public function Hash() : string { - return \md5(\array_reduce($this->aPlugins, function($sResult, $oPlugin){ - return $sResult . "|{$oPlugin->Hash()}"; - }, APP_VERSION)); + return \md5( + \array_reduce($this->aPlugins, function($sResult, $oPlugin){ + return $sResult . "|{$oPlugin->Hash()}"; + }, APP_VERSION) + .implode('',$this->aJs[1]).implode('',$this->aJs[0]) + .implode('',$this->aCss[1]).implode('',$this->aCss[0]) + ); } public function HaveJs(bool $bAdminScope = false) : bool @@ -179,11 +183,17 @@ class Manager return $this->bIsEnabled && \count($this->aJs[$bAdminScope ? 1 : 0]); } - public function CompileCss(bool $bAdminScope, bool &$bLess) : string + public function CompileCss(bool $bAdminScope, bool &$bLess, bool $bMinified) : string { $aResult = array(); if ($this->bIsEnabled) { foreach ($this->aCss[$bAdminScope ? 1 : 0] as $sFile) { + if ($bMinified) { + $sMinFile = \str_replace('.css', '.min.css', $sFile); + if (\is_readable($sMinFile)) { + $sFile = $sMinFile; + } + } if (\is_readable($sFile)) { $aResult[] = \file_get_contents($sFile); $bLess = $bLess || \str_ends_with($sFile, '.less'); @@ -193,15 +203,18 @@ class Manager return \implode("\n", $aResult); } - public function CompileJs(bool $bAdminScope = false) : string + public function CompileJs(bool $bAdminScope = false, bool $bMinified = false) : string { $aResult = array(); - if ($this->bIsEnabled) - { - foreach ($this->aJs[$bAdminScope ? 1 : 0] as $sFile) - { - if (\is_readable($sFile)) - { + if ($this->bIsEnabled) { + foreach ($this->aJs[$bAdminScope ? 1 : 0] as $sFile) { + if ($bMinified) { + $sMinFile = \str_replace('.js', '.min.js', $sFile); + if (\is_readable($sMinFile)) { + $sFile = $sMinFile; + } + } + if (\is_readable($sFile)) { $aResult[] = \file_get_contents($sFile); } } diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/ServiceActions.php b/snappymail/v/0.0.0/app/libraries/RainLoop/ServiceActions.php index 4717c29dc..f79c7aaae 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/ServiceActions.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/ServiceActions.php @@ -506,31 +506,29 @@ class ServiceActions \header('Content-Type: application/javascript; charset=utf-8'); - $bCacheEnabled = $this->Config()->Get('labs', 'cache_system_data', true); - if ($bCacheEnabled) - { - $this->oActions->verifyCacheByKey($this->sQuery); + $bAppDebug = $this->Config()->Get('debug', 'enable', false); + $sMinify = ($bAppDebug || $this->Config()->Get('labs', 'use_app_debug_js', false)) ? '' : 'min'; + + $bCacheEnabled = !$bAppDebug && $this->Config()->Get('labs', 'cache_system_data', true); + if ($bCacheEnabled) { + $this->oActions->verifyCacheByKey($this->sQuery . $sMinify); } $sCacheFileName = ''; - if ($bCacheEnabled) - { - $sCacheFileName = KeyPathHelper::PluginsJsCache($this->oActions->Plugins()->Hash()); + if ($bCacheEnabled) { + $sCacheFileName = KeyPathHelper::PluginsJsCache($this->oActions->Plugins()->Hash()) . $sMinify; $sResult = $this->Cacher()->Get($sCacheFileName); } - if (!\strlen($sResult)) - { - $sResult = $this->Plugins()->CompileJs($bAdmin); - if ($bCacheEnabled && \strlen($sCacheFileName)) - { + if (!$sResult) { + $sResult = $this->Plugins()->CompileJs($bAdmin, !!$sMinify); + if ($sCacheFileName) { $this->Cacher()->Set($sCacheFileName, $sResult); } } - if ($bCacheEnabled) - { - $this->oActions->cacheByKey($this->sQuery); + if ($bCacheEnabled) { + $this->oActions->cacheByKey($this->sQuery . $sMinify); } return $sResult; @@ -539,45 +537,38 @@ class ServiceActions public function ServiceCss() : string { $sResult = ''; - $bAdmin = !empty($this->aPaths[2]) && 'Admin' === $this->aPaths[2]; $bJson = !empty($this->aPaths[9]) && 'Json' === $this->aPaths[9]; - if ($bJson) - { + if ($bJson) { \header('Content-Type: application/json; charset=utf-8'); - } - else - { + } else { \header('Content-Type: text/css; charset=utf-8'); } $sTheme = ''; - if (!empty($this->aPaths[4])) - { + if (!empty($this->aPaths[4])) { $sTheme = $this->oActions->ValidateTheme($this->aPaths[4]); - $bCacheEnabled = $this->Config()->Get('labs', 'cache_system_data', true); - if ($bCacheEnabled) - { - $this->oActions->verifyCacheByKey($this->sQuery); + $bAppDebug = $this->Config()->Get('debug', 'enable', false); + $sMinify = ($bAppDebug || $this->Config()->Get('labs', 'use_app_debug_css', false)) ? '' : 'min'; + + $bCacheEnabled = !$bAppDebug && $this->Config()->Get('labs', 'cache_system_data', true); + if ($bCacheEnabled) { + $this->oActions->verifyCacheByKey($this->sQuery . $sMinify); } $sCacheFileName = ''; - if ($bCacheEnabled) - { - $sCacheFileName = KeyPathHelper::CssCache($sTheme, $this->oActions->Plugins()->Hash()); + if ($bCacheEnabled) { + $sCacheFileName = KeyPathHelper::CssCache($sTheme, $this->oActions->Plugins()->Hash()) . $sMinify; $sResult = $this->Cacher()->Get($sCacheFileName); } - if (!$sResult) - { + if (!$sResult) { try { $sResult = $this->oActions->compileCss($sTheme, $bAdmin); - - if ($bCacheEnabled && $sCacheFileName) - { + if ($sCacheFileName) { $this->Cacher()->Set($sCacheFileName, $sResult); } } @@ -587,9 +578,8 @@ class ServiceActions } } - if ($bCacheEnabled) - { - $this->oActions->cacheByKey($this->sQuery); + if ($bCacheEnabled) { + $this->oActions->cacheByKey($this->sQuery . $sMinify); } } diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Utils.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Utils.php index c8c58a635..3492cfb0e 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Utils.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Utils.php @@ -195,12 +195,17 @@ class Utils */ // Set the new 4K split cookie foreach (\str_split($sValue, $iMaxSize) as $i => $sPart) { + \SnappyMail\Log::debug('COOKIE', "set {$sName}~{$i}"); static::_SetCookie($i ? "{$sName}~{$i}" : $sName, $sPart, $iExpire); } // Delete unused old 4K split cookie parts - while (($sCookieName = "{$sName}~" . ++$i) && isset($_COOKIE[$sCookieName])) { - unset($_COOKIE[$sCookieName]); - static::_SetCookie($sCookieName, '', \time() - 3600 * 24 * 30); + foreach (\array_keys($_COOKIE) as $sCookieName) { + $aSplit = \explode('~', $sCookieName); + if (isset($aSplit[1]) && $aSplit[0] == $sName && $aSplit[1] > $i) { + \SnappyMail\Log::debug('COOKIE', "unset {$sCookieName}"); + unset($_COOKIE[$sCookieName]); + static::_SetCookie($sCookieName, '', \time() - 3600 * 24 * 30); + } } } diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/crypt.php b/snappymail/v/0.0.0/app/libraries/snappymail/crypt.php index 6ec79cec0..12f221d6a 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/crypt.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/crypt.php @@ -65,11 +65,11 @@ abstract class Crypt } } } catch (\Throwable $e) { - \trigger_error(__CLASS__ . "::{$fn}(): " . $e->getMessage()); + Log::error('Crypt', "{$fn}(): {$e->getMessage()}"); } -// \trigger_error(__CLASS__ . '::Decrypt() invalid $data or $key'); + Log::warning('Crypt', 'Decrypt() invalid $data or $key'); } else { -// \trigger_error(__CLASS__ . '::Decrypt() invalid $data'); + Log::warning('Crypt', 'Decrypt() invalid $data'); } } @@ -77,7 +77,7 @@ abstract class Crypt { $data = static::jsonDecode($data); if (!\is_array($data)) { -// \trigger_error(__CLASS__ . '::DecryptFromJSON() invalid $data'); + Log::notice('Crypt', 'DecryptFromJSON() invalid $data'); return null; } return static::Decrypt(\array_map('base64_decode', $data), $key); @@ -87,7 +87,7 @@ abstract class Crypt { $data = \explode('.', $data); if (!\is_array($data)) { -// \trigger_error(__CLASS__ . '::DecryptUrlSafe() invalid $data'); + Log::notice('Crypt', 'DecryptUrlSafe() invalid $data'); return null; } return static::Decrypt(\array_map('MailSo\\Base\\Utils::UrlSafeBase64Decode', $data), $key); @@ -175,6 +175,7 @@ abstract class Crypt if (!static::$cipher) { throw new \Exception('openssl $cipher not set'); } + Log::debug('Crypt', 'openssl_decrypt() with cipher ' . static::$cipher); return \openssl_decrypt( $data, static::$cipher, @@ -195,6 +196,7 @@ abstract class Crypt if (!static::$cipher) { throw new \Exception('openssl $cipher not set'); } + Log::debug('Crypt', 'openssl_encrypt() with cipher ' . static::$cipher); $result = \openssl_encrypt( $data, static::$cipher, diff --git a/snappymail/v/0.0.0/app/localization/eu/user.json b/snappymail/v/0.0.0/app/localization/eu/user.json index 0b76a0da7..46c0c7e66 100644 --- a/snappymail/v/0.0.0/app/localization/eu/user.json +++ b/snappymail/v/0.0.0/app/localization/eu/user.json @@ -333,7 +333,7 @@ "TITLE_SYSTEM_FOLDERS": "Aukeratu sistemaren karpetak", "SELECT_CHOOSE_ONE": "Aukeratu bat", "SELECT_UNUSE_NAME": "Ez erabili", - "NOTIFICATION_SENT": "Ez duzu sistemako \"Bidalitakoak"\ karpeta hautatu mezuak bidali ondoren bertan ipintzeko.\nBidalitako mezua gorde nahi ez baduzu hautatu \"Ez erabili\" aukera.\n", + "NOTIFICATION_SENT": "Ez duzu sistemako \"Bidalitakoak\" karpeta hautatu mezuak bidali ondoren bertan ipintzeko.\nBidalitako mezua gorde nahi ez baduzu hautatu \"Ez erabili\" aukera.\n", "NOTIFICATION_DRAFTS": "Ez duzu sistemako \"Zirriborroak\" karpeta hautatu mezuak editatu bitartean gordetzeko.", "NOTIFICATION_SPAM": "Ez duzu sistemako \"Zabor-posta\" karpeta hautatu zabor-mezuak kokatzeko.\nMezu hauek betirako ezabatu nahi badituzu, aukeratu \"Ez erabili\" aukera.\n", "NOTIFICATION_TRASH": "Ez duzu sistemako \"Zakarrontzia\" karpeta hautatu zabor-mezuak kokatzeko.\nMezu hauek betirako ezabatu nahi badituzu, aukeratu \"Ez erabili\" aukera.\n", @@ -509,7 +509,7 @@ "LABEL_SWITCH_TO_LIST": "Itzuli fokua mezuen zerrendara", "LABEL_OPEN_COMPOSE_POPUP": "Ireki prestatzeko popup-a", "LABEL_MINIMIZE_COMPOSE_POPUP": "Txikitu prestatzeko popup-a", - "LABEL_OPEN_IDENTITIES_DROPDOWN": "Ireki identitateen goitibeherako menua, + "LABEL_OPEN_IDENTITIES_DROPDOWN": "Ireki identitateen goitibeherako menua", "LABEL_SAVE_MESSAGE": "Gorde mezua", "LABEL_SEND_MESSAGE": "Bidali mezua", "LABEL_CLOSE_COMPOSE": "Itxi mezua" diff --git a/snappymail/v/0.0.0/app/templates/Views/Admin/AdminLogin.html b/snappymail/v/0.0.0/app/templates/Views/Admin/AdminLogin.html index fb4e29d47..418b2e6fe 100644 --- a/snappymail/v/0.0.0/app/templates/Views/Admin/AdminLogin.html +++ b/snappymail/v/0.0.0/app/templates/Views/Admin/AdminLogin.html @@ -1,6 +1,6 @@
- Γ— + Γ—
diff --git a/snappymail/v/0.0.0/app/templates/Views/Admin/PopupsPlugin.html b/snappymail/v/0.0.0/app/templates/Views/Admin/PopupsPlugin.html index 62c5371e1..aedfcc8b0 100644 --- a/snappymail/v/0.0.0/app/templates/Views/Admin/PopupsPlugin.html +++ b/snappymail/v/0.0.0/app/templates/Views/Admin/PopupsPlugin.html @@ -10,7 +10,7 @@
- Γ— + Γ—
diff --git a/snappymail/v/0.0.0/app/templates/Views/User/Login.html b/snappymail/v/0.0.0/app/templates/Views/User/Login.html index 051d9bb6c..aed43eaa0 100644 --- a/snappymail/v/0.0.0/app/templates/Views/User/Login.html +++ b/snappymail/v/0.0.0/app/templates/Views/User/Login.html @@ -1,6 +1,6 @@
-
+
diff --git a/snappymail/v/0.0.0/app/templates/Views/User/MailMessageList.html b/snappymail/v/0.0.0/app/templates/Views/User/MailMessageList.html index a306a89ea..ee773eed0 100644 --- a/snappymail/v/0.0.0/app/templates/Views/User/MailMessageList.html +++ b/snappymail/v/0.0.0/app/templates/Views/User/MailMessageList.html @@ -43,9 +43,12 @@ -
  • +
  • +
  • + +
  • diff --git a/snappymail/v/0.0.0/app/templates/Views/User/MailMessageView.html b/snappymail/v/0.0.0/app/templates/Views/User/MailMessageView.html index 3ce790aeb..39445ef97 100644 --- a/snappymail/v/0.0.0/app/templates/Views/User/MailMessageView.html +++ b/snappymail/v/0.0.0/app/templates/Views/User/MailMessageView.html @@ -123,7 +123,7 @@
    - β„Ή + β„Ή Γ— @@ -251,8 +251,7 @@
    + data-bind="visible: download, text: checked() ? 'β˜‘' : '☐', click: toggleChecked">
    @@ -261,8 +260,7 @@
  • + data-bind="visible: download, text: checked() ? 'β˜‘' : '☐', click: toggleChecked">
  • diff --git a/snappymail/v/0.0.0/app/templates/Views/User/PopupsAccount.html b/snappymail/v/0.0.0/app/templates/Views/User/PopupsAccount.html index 987a499ff..caeb2294a 100644 --- a/snappymail/v/0.0.0/app/templates/Views/User/PopupsAccount.html +++ b/snappymail/v/0.0.0/app/templates/Views/User/PopupsAccount.html @@ -5,7 +5,7 @@
    - Γ— + Γ—
    diff --git a/snappymail/v/0.0.0/app/templates/Views/User/PopupsContacts.html b/snappymail/v/0.0.0/app/templates/Views/User/PopupsContacts.html index 5adf32717..bc6381caf 100644 --- a/snappymail/v/0.0.0/app/templates/Views/User/PopupsContacts.html +++ b/snappymail/v/0.0.0/app/templates/Views/User/PopupsContacts.html @@ -43,281 +43,285 @@