diff --git a/.docker/release/Dockerfile b/.docker/release/Dockerfile index 2bfc647d5..cee484719 100644 --- a/.docker/release/Dockerfile +++ b/.docker/release/Dockerfile @@ -122,6 +122,13 @@ RUN set -eux; \ apk del .build-dependencies; \ apk del .deps +# sqlite +RUN set -eux; \ + apk add --no-cache sqlite-libs; \ + apk add --no-cache --virtual .deps sqlite-dev; \ + docker-php-ext-install pdo_sqlite; \ + apk del .deps + # tidy RUN set -eux; \ apk add --no-cache tidyhtml; \ diff --git a/.docker/release/files/entrypoint.sh b/.docker/release/files/entrypoint.sh index 75e82d729..c0ed7df0c 100755 --- a/.docker/release/files/entrypoint.sh +++ b/.docker/release/files/entrypoint.sh @@ -9,6 +9,12 @@ UPLOAD_MAX_SIZE=${UPLOAD_MAX_SIZE:-25M} MEMORY_LIMIT=${MEMORY_LIMIT:-128M} SECURE_COOKIES=${SECURE_COOKIES:-true} +# Disable listening on ipv6 for ipv4-only environments, or nginx will fail to start +if ! ip a | grep 'inet6 ' > /dev/null; then + echo "[INFO] ipv6 is disabled. Configuring nginx to not listen on ipv6" + sed -i '/listen \[::\]:/d' /etc/nginx/nginx.conf +fi + # Set attachment size limit sed -i "s//$UPLOAD_MAX_SIZE/g" /usr/local/etc/php-fpm.d/php-fpm.conf /etc/nginx/nginx.conf sed -i "s//$MEMORY_LIMIT/g" /usr/local/etc/php-fpm.d/php-fpm.conf diff --git a/SECURITY.md b/SECURITY.md index faaba8d68..11d1ddada 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,5 +36,5 @@ YMXQVq4SJt7UpWABAORudfJxsBqCRKtPlZMgGTJLjcOkyFJ9C2Fx7DeN0J4I ## Publishing and Credits I will analyze and fix the reported issue as fast as possible. -Together with the reporter I plan the disclosure of the found and fixed vulnerability. +Together with the reporter, I plan the disclosure of the found and fixed vulnerability. Credits to the reporter are granted and can be included in all public communication if desired. diff --git a/dev/App/User.js b/dev/App/User.js index 7e87ebffb..5e28164ac 100644 --- a/dev/App/User.js +++ b/dev/App/User.js @@ -260,7 +260,8 @@ AskPopupView.password = function(sAskDesc, btnText, ask) { view => resolve({ password:view.passphrase(), username:/*ask & 2 ? */view.username(), - remember:/*ask & 4 ? */view.remember() + remember:/*ask & 4 ? */view.remember(), + rememberPermanent: view.rememberPermanent(), }), () => resolve(null), true, diff --git a/dev/External/ko.js b/dev/External/ko.js index d5837d639..d783ae1df 100644 --- a/dev/External/ko.js +++ b/dev/External/ko.js @@ -176,6 +176,6 @@ ko.extenders.falseTimeout = (target, option) => { // functions -ko.observable.fn.askDeleteHelper = function() { - return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, 'askDelete'] }); +ko.observable.fn.askDeleteHelper = function(prop='askDelete') { + return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, prop] }); }; diff --git a/dev/Model/Identity.js b/dev/Model/Identity.js index 098a11ba4..4a93b2d81 100644 --- a/dev/Model/Identity.js +++ b/dev/Model/Identity.js @@ -24,6 +24,7 @@ export class IdentityModel extends EmailModel /*AbstractModel*/ { smimeKey: '', smimeCertificate: '', + smimeCertificateChain: '', askDelete: false, @@ -33,7 +34,9 @@ export class IdentityModel extends EmailModel /*AbstractModel*/ { addComputablesTo(this, { smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----'), smimeKeyValid: () => /^-----BEGIN (ENCRYPTED |RSA )?PRIVATE KEY-----/.test(this.smimeKey()), - smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate()) + smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate()), + smimeCertificateChainValid: () => !this.smimeCertificateChain() + || /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificateChain()) }); } diff --git a/dev/Storage/Passphrases.js b/dev/Storage/Passphrases.js index db43925a0..0242ddd45 100644 --- a/dev/Storage/Passphrases.js +++ b/dev/Storage/Passphrases.js @@ -1,21 +1,155 @@ import { AskPopupView } from 'View/Popup/Ask'; import { SettingsUserStore } from 'Stores/User/Settings'; +import { SettingsGet } from '../Common/Globals'; +import { isArray } from '../Common/Utils'; export const Passphrases = new WeakMap(); -Passphrases.ask = async (key, sAskDesc, btnText) => - Passphrases.has(key) - ? {password:Passphrases.handle(key)/*, remember:false*/} - : await AskPopupView.password(sAskDesc, btnText, 5); +Passphrases.ask = async (key, sAskDesc, btnText) => { + if (Passphrases.has(key)) { + return { password: Passphrases.handle(key)/*, remember:false*/ }; + } else if (Passphrases.hasInLocalStorage(key)) { + return { password: await getFromLocalStorage(key) }; + } else { + const pass = await AskPopupView.password(sAskDesc, btnText, + window.crypto.subtle && canUseLocalStorage(key) ? 0b1101 : 0b0101); + pass.rememberPermanent && await saveToLocalStorage(key, pass.password); + return pass; + } +}; + +Passphrases._deleteFromSession = Passphrases.delete; const timeouts = {}; // get/set accessor to control deletion after N minutes of inactivity Passphrases.handle = (key, pass) => { const timeout = SettingsUserStore.keyPassForget(); if (timeout && !timeouts[key]) { - timeouts[key] = (()=>Passphrases.delete(key)).debounce(timeout * 1000); + timeouts[key] = (() => Passphrases._deleteFromSession(key)).debounce(timeout * 60 * 1000); } pass && Passphrases.set(key, pass); timeout && timeouts[key](); return Passphrases.get(key); }; + +const deleteFromLocalStorage = (key) => { + const keyId = getKeyId(key); + if (keyId) { + localStorage.removeItem(keyId); + } +}; +Passphrases.delete = (key) => { + deleteFromLocalStorage(key); + return Passphrases._deleteFromSession(key); +}; + +Passphrases.hasInLocalStorage = (key) => { + const keyId = getKeyId(key); + return keyId && localStorage.getItem(keyId) !== null; +}; + +const saveToLocalStorage = async (key, pass) => { + const keyId = getKeyId(key); + if (!keyId) { + return; + } + + if (!pass) { + localStorage.removeItem(keyId); + return; + } + + try { + const salt = window.crypto.getRandomValues(new Uint8Array(16)); + const derivedKey = await deriveKeyFromHash(SettingsGet('accountHash'), salt); + + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + derivedKey, + new TextEncoder().encode(pass) + ); + localStorage.setItem(keyId, JSON.stringify([ + btoa(String.fromCharCode.apply(null, salt)), + btoa(String.fromCharCode.apply(null, iv)), + btoa(String.fromCharCode.apply(null, new Uint8Array(encrypted))) + ])); + } catch (e) { + console.error('Passphrases.saveToLocalStorage failed', e); + } +}; + +const getFromLocalStorage = async (key) => { + const keyId = getKeyId(key); + if (!keyId) { + return undefined; + } + + const jsonData = localStorage.getItem(keyId); + if (!jsonData) { + console.error('Passphrases.getFromLocalStorage failed: no data found'); + return undefined; + } + + try { + const saltIvData = JSON.parse(jsonData); + if (!saltIvData || !isArray(saltIvData) || saltIvData.length !== 3) { + // noinspection ExceptionCaughtLocallyJS + throw new Error('invalid passphrase data'); + } + const toUint8 = (str) => new Uint8Array(atob(str).split('').map(c => c.charCodeAt(0))); + + const derivedKey = await deriveKeyFromHash(SettingsGet('accountHash'), toUint8(saltIvData[0])); + const decrypted = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: toUint8(saltIvData[1]) + }, + derivedKey, + toUint8(saltIvData[2]) + ); + return String.fromCharCode.apply(null, new Uint8Array(decrypted)); + } catch (e) { + localStorage.removeItem(keyId); + console.error('Passphrases.getFromLocalStorage failed', e); + return undefined; + } +}; + +const canUseLocalStorage = (key) => getKeyId(key) !== undefined; + +const getKeyId = (key) => { + if (key && typeof key.id === 'string' && key.id.length > 4 && typeof key.forgetPass === 'function') { + // only deal with keys that we can forget (OpenPGB, GnuPG) + return key.id + '_local_key'; + } else { + console.info('Passphrases.getKeyId: unsupported key type'); + return undefined; + } +}; + +const deriveKeyFromHash = async (hash, salt) => { + if (!hash) { + throw new Error('empty accountHash'); + } + return window.crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(hash), + { 'name': 'PBKDF2' }, + false, + ['deriveKey'] + ).then(keyMaterial => { + return window.crypto.subtle.deriveKey( + { + 'name': 'PBKDF2', + 'salt': salt, + 'iterations': 512, + 'hash': 'SHA-256' + }, + keyMaterial, + { 'name': 'AES-GCM', 'length': 256 }, + false, + ['encrypt', 'decrypt'] + ); + }); +}; diff --git a/dev/Stores/User/GnuPG.js b/dev/Stores/User/GnuPG.js index 3b9c85785..f70b9a3c3 100644 --- a/dev/Stores/User/GnuPG.js +++ b/dev/Stores/User/GnuPG.js @@ -51,6 +51,13 @@ export const GnuPGUserStore = new class { key.for = email => aEmails.includes(IDN.toASCII(email)); key.askDelete = ko.observable(false); key.openForDeletion = ko.observable(null).askDeleteHelper(); + key.askForgetPass = ko.observable(false); + key.openForPassForget = ko.observable(null).askDeleteHelper('askForgetPass'); + key.forgetPass = () => { + Passphrases.delete(key); + key.hasStoredPass(false); + }; + key.hasStoredPass = ko.observable(Passphrases.hasInLocalStorage(key)); key.remove = () => { if (key.askDelete()) { Remote.request('GnupgDeleteKey', @@ -69,6 +76,7 @@ export const GnuPGUserStore = new class { isPrivate: isPrivate } ); + isPrivate && key.forgetPass(); } }; if (isPrivate) { diff --git a/dev/Stores/User/OpenPGP.js b/dev/Stores/User/OpenPGP.js index 3fce91d45..5c521d214 100644 --- a/dev/Stores/User/OpenPGP.js +++ b/dev/Stores/User/OpenPGP.js @@ -13,7 +13,7 @@ import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey'; import { Passphrases } from 'Storage/Passphrases'; -import { baseCollator } from 'Common/Translator'; +import { baseCollator, getNotification } from 'Common/Translator'; const loaded = () => !!window.openpgp, @@ -90,6 +90,9 @@ class OpenPgpKeyModel { this.armor = armor; this.askDelete = ko.observable(false); this.openForDeletion = ko.observable(null).askDeleteHelper(); + this.hasStoredPass = ko.observable(Passphrases.hasInLocalStorage(this)); + this.askForgetPass = ko.observable(false); + this.openForPassForget = ko.observable(null).askDeleteHelper('askForgetPass'); // key.getUserIDs() // key.getPrimaryUser() } @@ -115,12 +118,29 @@ class OpenPgpKeyModel { if (this.key.isPrivate()) { OpenPGPUserStore.privateKeys.remove(this); storeOpenPgpKeys(OpenPGPUserStore.privateKeys, privateKeysItem); + this.forgetPass() } else { OpenPGPUserStore.publicKeys.remove(this); storeOpenPgpKeys(OpenPGPUserStore.publicKeys, publicKeysItem); } + Remote.request('DeletePGPKey', + (iError, oData) => { + if (oData) { + if (iError || oData.Result === false) { + alert(oData.message || getNotification(iError)); + } + } + }, { + key: this.armor + } + ); } } + + forgetPass() { + Passphrases.delete(this); + this.hasStoredPass(false); + } /* toJSON() { return this.armor; diff --git a/dev/View/Popup/Ask.js b/dev/View/Popup/Ask.js index 8d7c7bca0..8392dfcbd 100644 --- a/dev/View/Popup/Ask.js +++ b/dev/View/Popup/Ask.js @@ -15,10 +15,14 @@ export class AskPopupView extends AbstractViewPopup { askUsername: false, passphrase: '', askPass: false, - remember: true, - askRemeber: false + remember: true, // remember for session + askRemeber: false, + rememberPermanent: false, + askRememberPermanent: false, }); + this.rememberPermanent.subscribe(value => value && this.remember(true)); + this.fYesAction = null; this.fNoAction = null; @@ -48,10 +52,12 @@ export class AskPopupView extends AbstractViewPopup { this.askDesc(sAskDesc || ''); this.askUsername(ask & 2); this.askPass(ask & 1); - this.askRemeber(ask & 4); + this.askRemeber(ask & 4); // 0b0100 + this.askRememberPermanent(ask & 0b1000) this.username(''); this.passphrase(''); this.remember(true); + this.rememberPermanent(false); this.yesButton(i18n(btnText || 'GLOBAL/YES')); this.noButton(i18n(ask ? 'GLOBAL/CANCEL' : 'GLOBAL/NO')); this.fYesAction = fYesFunc; diff --git a/dev/View/Popup/Compose.js b/dev/View/Popup/Compose.js index d768ef88f..ffa49a2a4 100644 --- a/dev/View/Popup/Compose.js +++ b/dev/View/Popup/Compose.js @@ -1413,7 +1413,8 @@ export class ComposePopupView extends AbstractViewPopup { key && options.push(['OpenPGP', key]); key = GnuPGUserStore.getPrivateKeyFor(email, 1); key && options.push(['GnuPG', key]); - identity.smimeKeyValid() && identity.smimeCertificateValid() && identity.email === email + identity.smimeKeyValid() && identity.smimeCertificateValid() + && identity.smimeCertificateChainValid() && identity.email === email && options.push(['S/MIME']); console.dir({signOptions: options}); this.signOptions(options); @@ -1593,6 +1594,7 @@ export class ComposePopupView extends AbstractViewPopup { */ break; } catch (e) { + Passphrases.delete(signOptions[i][1]) console.error(e); } } else if ('GnuPG' == signOptions[i][0]) { @@ -1610,6 +1612,7 @@ export class ComposePopupView extends AbstractViewPopup { // TODO: sign in PHP fails params.sign = 'S/MIME'; // params.signCertificate = identity.smimeCertificate(); +// params.signCertificateChain = identity.smimeCertificateChain(); // params.signPrivateKey = identity.smimeKey(); // params.attachCertificate = false; if (identity.smimeKeyEncrypted()) { @@ -1634,7 +1637,9 @@ export class ComposePopupView extends AbstractViewPopup { Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) => params.autocrypt.push({ addr: k, - keydata: v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '').trim() + keydata: v + .replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '') + .replace(/\s+/g, '') // Single base64 string, let the server do compliance formatting }) ); for (let i = 0; i < encryptOptions.length; ++i) { diff --git a/dev/View/User/MailBox/MessageList.js b/dev/View/User/MailBox/MessageList.js index 2517fe903..e273e3069 100644 --- a/dev/View/User/MailBox/MessageList.js +++ b/dev/View/User/MailBox/MessageList.js @@ -303,14 +303,14 @@ export class MailMessageList extends AbstractViewRight { item => sFolder === item?.folder && iUid == item?.uid ); - if ('INBOX' === sFolder) { + if ('INBOX' === sFolder && 'INBOX' !== FolderUserStore.currentFolderFullName()) { hasher.setHash(mailBox(sFolder)); } if (message) { this.selector.selectMessageItem(message); } else { - if ('INBOX' !== sFolder) { + if ('INBOX' !== sFolder && sFolder !== FolderUserStore.currentFolderFullName()) { hasher.setHash(mailBox(sFolder)); } if (sFolder && iUid) { diff --git a/examples/docker/Docker.md b/examples/docker/Docker.md index 28a17a9c3..9d5ec834f 100644 --- a/examples/docker/Docker.md +++ b/examples/docker/Docker.md @@ -77,7 +77,7 @@ Now, login to [http://localhost:8888/?admin](http://localhost:8888/?admin) with To use PostgreSQL as the DB, in Admin Panel, click `Contacts`, check `Enable contacts` and , and under `Storage (PDO)` choose the following: - Type: `PostgresSQL` -- Data Source Name (DSN): `host=postgres;port=5432;dbname=snappymail` +- Data Source Name (DSN): `host=db;port=5432;dbname=snappymail` - User `snappymail` - Password `snappymail` diff --git a/integrations/nextcloud/snappymail/lib/Util/SnappyMailHelper.php b/integrations/nextcloud/snappymail/lib/Util/SnappyMailHelper.php index 80a93d061..deb7fb465 100644 --- a/integrations/nextcloud/snappymail/lib/Util/SnappyMailHelper.php +++ b/integrations/nextcloud/snappymail/lib/Util/SnappyMailHelper.php @@ -176,7 +176,8 @@ class SnappyMailHelper // If OpenID Connect (OIDC) is enabled and used for login, use this. if (static::isOIDCLogin()) { $sEmail = $config->getUserValue($sUID, 'settings', 'email'); - return [$sUID, $sEmail, "oidc_login|{$sUID}"]; + $pwd = new \SnappyMail\SensitiveString("oidc_login|{$sUID}"); + return [$sUID, $sEmail, $pwd]; } // Only use the user's password in the current session if they have diff --git a/plugins/avatars/index.php b/plugins/avatars/index.php index 417fdfd1b..459172ebe 100644 --- a/plugins/avatars/index.php +++ b/plugins/avatars/index.php @@ -15,7 +15,7 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin REQUIRED = '2.33.0', CATEGORY = 'Contacts', LICENSE = 'MIT', - DESCRIPTION = 'Show graphic of sender in message and messages list (supports BIMI, Gravatar, favicon and identicon, Contacts is still TODO)'; + DESCRIPTION = 'Show graphic of sender in message and messages list (supports Contacts, BIMI, Gravatar, favicon and identicon)'; public function Init() : void { @@ -139,6 +139,10 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetAllowedInJs(true) ->SetDefaultValue(true), + \RainLoop\Plugins\Property::NewInstance('contacts')->SetLabel('Contacts') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) + ->SetDescription('Use the PHOTO stored on a matching address book contact') + ->SetDefaultValue(true), \RainLoop\Plugins\Property::NewInstance('bimi')->SetLabel('BIMI') ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetDefaultValue(false) @@ -230,30 +234,23 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin \header('Cache-Control: private'); // \header('Expires: '.\gmdate('D, j M Y H:i:s', \time() + 86400).' UTC'); + // A photo stored on the contact is authoritative, so it is consulted + // before the cache and before any network lookup: it beats a guessed + // avatar and replaces up to five remote round-trips. Deliberately not + // written to the avatars cache, which has no expiry - a cached copy + // would shadow later edits to the contact. + if ($this->Config()->Get('plugin', 'contacts', true)) { + $aResult = static::getContactPhoto($sAsciiEmail); + if ($aResult) { + return $aResult; + } + } + $aResult = static::getCachedImage($sEmail); if ($aResult) { return $aResult; } - // TODO: lookup contacts vCard and return PHOTO value - /* - if (!$aResult) { - $oActions = \RainLoop\Api::Actions(); - $oAccount = $oActions->getAccountFromToken(); - if ($oAccount) { - $oAddressBookProvider = $oActions->AddressBookProvider($oAccount); - if ($oAddressBookProvider) { - $oContact = $oAddressBookProvider->GetContactByEmail($sEmail); - if ($oContact && $oContact->vCard && $oContact->vCard['PHOTO']) { - $aResult = [ - 'text/vcard', - $oContact->vCard - ]; - } - } - } - } - */ if (!$aResult) { $sDomain = \explode('@', $sEmail); @@ -316,6 +313,77 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin return $sDomain; } + /** + * Return the PHOTO of the address book contact owning this address. + * + * vCard 4.0 stores it as a data: URI, vCard 3.0 as ENCODING=b with a TYPE + * parameter, and either version may point at an external URL instead. + */ + private static function getContactPhoto(string $sEmail) : ?array + { + try { + $oActions = \RainLoop\Api::Actions(); + $oAccount = $oActions->getAccountFromToken(false); + if (!$oAccount) { + return null; + } + $oProvider = $oActions->AddressBookProvider($oAccount); + if (!$oProvider || !$oProvider->IsActive()) { + return null; + } + // Several contacts can share an address - a synced card, a manual + // entry, an auto-collected one - and only one of them may carry the + // photo. GetContactByEmail() returns whichever matches first, so + // fall back to scanning the matches for one that actually has it. + $oPhoto = null; + $oContact = $oProvider->GetContactByEmail($sEmail); + if ($oContact && $oContact->vCard && isset($oContact->vCard->PHOTO)) { + $oPhoto = $oContact->vCard->PHOTO; + } else { + $iCount = 0; + foreach ($oProvider->GetContacts(0, 20, $sEmail, $iCount) as $oCandidate) { + if ($oCandidate->vCard && isset($oCandidate->vCard->PHOTO)) { + $oPhoto = $oCandidate->vCard->PHOTO; + break; + } + } + } + if (!$oPhoto) { + return null; + } + + $sValue = \trim((string) $oPhoto); + if (!\strlen($sValue)) { + return null; + } + + // vCard 4.0: data:image/jpeg;base64,... + if (\preg_match('#^data:(image/[a-z0-9.+-]+);base64,(.+)$#si', $sValue, $aMatch)) { + $sBinary = \base64_decode(\preg_replace('#\s+#', '', $aMatch[2]), true); + return $sBinary ? [\strtolower($aMatch[1]), $sBinary] : null; + } + + // vCard 3.0: PHOTO;ENCODING=b;TYPE=JPEG: + $sEncoding = \strtolower((string) ($oPhoto['ENCODING'] ?? '')); + if ('b' === $sEncoding || 'base64' === $sEncoding) { + $sBinary = \base64_decode(\preg_replace('#\s+#', '', $sValue), true); + if ($sBinary) { + $sType = \strtolower((string) ($oPhoto['TYPE'] ?? '')) ?: 'jpeg'; + return [\str_starts_with($sType, 'image/') ? $sType : "image/{$sType}", $sBinary]; + } + return null; + } + + // Either version may reference the image instead of embedding it. + if (\preg_match('#^https?://#i', $sValue)) { + return static::getUrl($sValue); + } + } catch (\Throwable $oException) { + \SnappyMail\Log::notice('Avatar', 'contact photo lookup failed: ' . $oException->getMessage()); + } + return null; + } + private static function cacheImage(string $sEmail, array $aResult) : void { if (!\is_dir(\APP_PRIVATE_DATA . 'avatars')) { diff --git a/plugins/ics-viewer/index.php b/plugins/ics-viewer/index.php index 591288392..4100593b2 100644 --- a/plugins/ics-viewer/index.php +++ b/plugins/ics-viewer/index.php @@ -5,8 +5,8 @@ class ICSViewerPlugin extends \RainLoop\Plugins\AbstractPlugin const NAME = 'ICS Viewer', AUTHOR = 'PhieF', - VERSION = '1.0', - RELEASE = '2024-09-17', + VERSION = '1.1', + RELEASE = '2026-10-22', CATEGORY = 'Messages', DESCRIPTION = 'Display ICS attachment using ical lib, or JSON-LD details, based on viewICS', REQUIRED = '2.34.0'; diff --git a/plugins/ics-viewer/message.js b/plugins/ics-viewer/message.js index 380b732bb..b0adc469d 100644 --- a/plugins/ics-viewer/message.js +++ b/plugins/ics-viewer/message.js @@ -86,10 +86,17 @@ }); } }); - // ICS attachment -// let ics = msg.attachments.find(attachment => 'application/ics' == attachment.mimeType); - - let ics = msg.attachments.find(attachment => 'text/calendar' == attachment.mimeType); + // ICS attachment. + // attachments is a ko.observableArray, so it must be + // unwrapped before array methods are used on it. + // A scheduling mail does not always carry a text/calendar + // attachment either: the part is often inline inside + // multipart/alternative and repeated as application/ics. + let ics = (msg.attachments() || []).find(attachment => + 'text/calendar' == attachment.mimeType + || 'application/ics' == attachment.mimeType + || 'text/x-vcalendar' == attachment.mimeType + || /\.ics$/i.test(attachment.fileName || '')); if (ics && ics.download) { // fetch it and parse the VEVENT diff --git a/plugins/ldap-contacts-suggestions/LdapContactsSuggestions.php b/plugins/ldap-contacts-suggestions/LdapContactsSuggestions.php index d7eb3eb9a..aa5907753 100644 --- a/plugins/ldap-contacts-suggestions/LdapContactsSuggestions.php +++ b/plugins/ldap-contacts-suggestions/LdapContactsSuggestions.php @@ -139,29 +139,41 @@ class LdapContactsSuggestions implements \RainLoop\Providers\Suggestions\ISugges $sFilter .= (1 < count($aItems) ? '(|' : '').$sSubFilter.(1 < count($aItems) ? ')' : ''); $sFilter .= ')'; - $this->logWrite('ldap_search: start: '.$sBaseDn.' / '.$sFilter, \LOG_INFO, 'LDAP'); - $oS = @\ldap_search($oCon, $sBaseDn, $sFilter, $aItems, 0, 30, 30); - if ($oS) { - $aEntries = @\ldap_get_entries($oCon, $oS); - if (is_array($aEntries)) { - if (isset($aEntries['count'])) { - unset($aEntries['count']); - } + // A directory rarely keeps everything worth suggesting in one branch: + // meeting rooms and other bookable resources commonly live outside + // the people branch. Searching a single subtree either misses them, + // or - if the base is widened to the domain root - drags every + // service account into the suggestion list. Base DNs are therefore + // separated by '|', which cannot appear unescaped in a DN, so an + // existing single-branch configuration keeps working unchanged. + $aBaseDns = \array_filter(\array_map('trim', \explode('|', $sBaseDn)), 'strlen'); - foreach ($aEntries as $aItem) { - if ($aItem) { - $sName = $sEmail = ''; - list ($sEmail, $sName) = $this->findNameAndEmail($aItem, $aEmails, $aNames, $aUIDs); - if (!empty($sEmail)) { - $aResult[] = array($sEmail, $sName); + foreach ($aBaseDns as $sOneBaseDn) { + $this->logWrite('ldap_search: start: '.$sOneBaseDn.' / '.$sFilter, \LOG_INFO, 'LDAP'); + $oS = @\ldap_search($oCon, $sOneBaseDn, $sFilter, $aItems, 0, 30, 30); + if ($oS) { + $aEntries = @\ldap_get_entries($oCon, $oS); + if (is_array($aEntries)) { + if (isset($aEntries['count'])) { + unset($aEntries['count']); + } + + foreach ($aEntries as $aItem) { + if ($aItem) { + $sName = $sEmail = ''; + list ($sEmail, $sName) = $this->findNameAndEmail($aItem, $aEmails, $aNames, $aUIDs); + if (!empty($sEmail)) { + $aResult[] = array($sEmail, $sName); + } } } + } else { + $this->logLdapError($oCon, 'ldap_get_entries'); } } else { - $this->logLdapError($oCon, 'ldap_get_entries'); + // One unreachable branch must not silence the others. + $this->logLdapError($oCon, 'ldap_search ('.$sOneBaseDn.')'); } - } else { - $this->logLdapError($oCon, 'ldap_search'); } } diff --git a/plugins/ldap-contacts-suggestions/index.php b/plugins/ldap-contacts-suggestions/index.php index dbbf2d515..5ef78593c 100644 --- a/plugins/ldap-contacts-suggestions/index.php +++ b/plugins/ldap-contacts-suggestions/index.php @@ -4,8 +4,8 @@ class LdapContactsSuggestionsPlugin extends \RainLoop\Plugins\AbstractPlugin { const NAME = 'Contacts suggestions (LDAP)', - VERSION = '2.14', - RELEASE = '2024-03-12', + VERSION = '2.15', + RELEASE = '2026-08-19', REQUIRED = '2.35.3', CATEGORY = 'Contacts', DESCRIPTION = 'Get contacts suggestions from LDAP.'; @@ -79,7 +79,7 @@ class LdapContactsSuggestionsPlugin extends \RainLoop\Plugins\AbstractPlugin ->SetType(\RainLoop\Enumerations\PluginPropertyType::PASSWORD) ->SetDefaultValue(''), \RainLoop\Plugins\Property::NewInstance('base_dn')->SetLabel('Search base DN') - ->SetDescription('DN to use as the search base. Supported tokens: {domain}, {domain:dc}, {email}, {email:user}, {email:domain}, {login}, {imap:login}, {imap:host}, {imap:port}') + ->SetDescription('DN to use as the search base. Supported tokens: {domain}, {domain:dc}, {email}, {email:user}, {email:domain}, {login}, {imap:login}, {imap:host}, {imap:port} Several branches may be given, separated by | - useful when meeting rooms or other resources live outside the people branch.') ->SetDefaultValue('ou=People,dc=example,dc=com'), \RainLoop\Plugins\Property::NewInstance('object_classes')->SetLabel('objectClasses') ->SetDescription('LDAP objectClasses to search for, comma separated list') diff --git a/plugins/ldap-login-mapping/README b/plugins/ldap-login-mapping/README index b12d3a77e..59e065d6b 100644 --- a/plugins/ldap-login-mapping/README +++ b/plugins/ldap-login-mapping/README @@ -1 +1 @@ -Plugin which allows you to set up LDAP username by email address (when IMAP/SMTP requires it) +This plugins allows to authenticate using LDAP mail field, yet login field would still be used for IMAP and SMTP authentication. diff --git a/plugins/ldap-login-mapping/index.php b/plugins/ldap-login-mapping/index.php index 73d8fae9c..64b983696 100644 --- a/plugins/ldap-login-mapping/index.php +++ b/plugins/ldap-login-mapping/index.php @@ -8,12 +8,12 @@ class LDAPLoginMappingPlugin extends AbstractPlugin { const NAME = 'LDAP login mapping', - VERSION = '2.3', + VERSION = '2.4', AUTHOR = 'RainLoop Team, Ludovic Pouzenc, ZephOne', - RELEASE = '2024-09-20', + RELEASE = '2025-01-09', REQUIRED = '2.36.1', CATEGORY = 'Login', - DESCRIPTION = 'Enable custom mapping using ldap field'; + DESCRIPTION = 'This plugins allows to authenticate using LDAP mail field, yet login field would still be used for IMAP and SMTP authentication'; /** * @var array */ @@ -104,6 +104,7 @@ class LDAPLoginMappingPlugin extends AbstractPlugin $sResult = $this->ldapSearch($sEmail); if ( is_array($sResult) ) { $sImapUser = $sResult['login']; + $sSmtpUser = $sResult['login']; $sEmail = $sResult['email']; } syslog(LOG_WARNING, "plugins/ldap-login-mapping/index.php:FilterLoginСredentials() auth try: $sIP/$sEmail, resolved as $sImapUser/$sEmail"); diff --git a/plugins/login-http/LICENSE b/plugins/login-http/LICENSE new file mode 100644 index 000000000..f709b02e2 --- /dev/null +++ b/plugins/login-http/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2016 RainLoop Team + +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. diff --git a/plugins/login-http/index.php b/plugins/login-http/index.php new file mode 100644 index 000000000..9b53c0cb5 --- /dev/null +++ b/plugins/login-http/index.php @@ -0,0 +1,39 @@ +addPartHook('ExternalHTTP', 'ServiceExternalHTTP'); + } + + public function ServiceExternalHTTP() : string + { + $oActions = \RainLoop\Api::Actions(); + $oActions->Http()->ServerNoCache(); + $sUser=$_SERVER['PHP_AUTH_USER'] ?? ''; + $sPassword=$_SERVER['PHP_AUTH_PW'] ?? ''; + try { + $oAccount = $oActions->LoginProcess($sUser, new \SnappyMail\SensitiveString($sPassword)); + if ($oAccount) { + \MailSo\Base\Http::Location('./'); + return true; + } + } catch (\Throwable $e) { + // Log l'erreur si besoin + } + \MailSo\Base\Http::Location('./?ExternalHTTP'); + return true; + } +} diff --git a/plugins/login-o365/LoginOAuth2.js b/plugins/login-o365/LoginOAuth2.js index dd8c843ed..392c0e7b1 100644 --- a/plugins/login-o365/LoginOAuth2.js +++ b/plugins/login-o365/LoginOAuth2.js @@ -1,54 +1,91 @@ -(rl => { - const client_id = rl.pluginSettingsGet('login-o365', 'client_id'), - // https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris - query = rl.pluginSettingsGet('login-o365', 'personal') ? '' : '?', - tenant = rl.pluginSettingsGet('login-o365', 'tenant'), - login = () => { - document.location = 'https://login.microsoftonline.com/'+tenant+'/oauth2/v2.0/authorize?' + (new URLSearchParams({ - response_type: 'code', - client_id: client_id, - redirect_uri: document.location.href.replace(/\/$/, '') + '/' + query + 'LoginO365', - scope: [ - // Associate personal info - 'openid', - 'offline_access', - 'email', - 'profile', - // Access IMAP and SMTP through OAUTH - 'https://graph.microsoft.com/IMAP.AccessAsUser.All', -// 'https://graph.microsoft.com/Mail.ReadWrite' - 'https://graph.microsoft.com/Mail.Send' -/* // Legacy: - 'https://outlook.office.com/SMTP.Send', - 'https://outlook.office.com/IMAP.AccessAsUser.All' -*/ - ].join(' '), - state: 'o365', // + rl.settings.app('token') + localStorage.getItem('smctoken') - // Force authorize screen, so we always get a refresh_token - access_type: 'offline', - prompt: 'consent' - })); - }; +((rl) => { + const client_id = rl.pluginSettingsGet("login-o365", "client_id"), + allowAnyDomain = !!rl.pluginSettingsGet("login-o365", "allow_any_domain"), + isSupportedEmail = (email) => { + email = (email || "").toLowerCase(); + if (!email.includes("@")) return false; + if (allowAnyDomain) return true; + return /@(outlook\.com|hotmail\.com|live\.com)$/.test(email); + }, + startOAuth = (op, opts = {}) => { + const email = (opts.email || "").toLowerCase(); + const name = opts.name || ""; + const returnHash = opts.return || ""; - if (client_id) { - addEventListener('sm-user-login', e => { - if (event.detail.get('Email').includes('@hotmail.com')) { - e.preventDefault(); - login(); - } - }); + // Server mints a signed state + correct redirect_uri and returns full authUrl. + rl.pluginRemoteRequest((iError, data) => { + const url = data?.Result?.authUrl; + if (!iError && url) { + document.location = url; + } + }, "LoginO365AuthUrl", { + op: op, + email: email, + name: name, + return: returnHash, + }); + }; - addEventListener('rl-view-model', e => { - if ('Login' === e.detail.viewModelTemplateID) { - const - container = e.detail.viewModelDom.querySelector('#plugin-Login-BottomControlGroup'), - btn = Element.fromHTML(''), - div = Element.fromHTML('
'); - btn.onclick = login; - div.append(btn); - container && container.append(div); - } - }); - } + if (client_id) { + addEventListener("sm-user-login", (e) => { + const email = (e.detail.get("Email") || "").toLowerCase(); + if (isSupportedEmail(email)) { + e.preventDefault(); + startOAuth("login", { email }); + } + }); + addEventListener("rl-view-model", (e) => { + if ("Login" === e.detail.viewModelTemplateID) { + const + container = e.detail.viewModelDom.querySelector("#plugin-Login-BottomControlGroup"), + btn = Element.fromHTML(''), + div = Element.fromHTML('
'); + btn.onclick = () => { + // Best-effort: try to read the email field if present. + const input = e.detail.viewModelDom.querySelector('input[type="email"], input[name="Email"], input[name="email"], input'); + const email = (input?.value || "").toLowerCase(); + if (!email || isSupportedEmail(email)) { + startOAuth("login", { email }); + } + }; + div.append(btn); + container && container.append(div); + } + + // "Add account" popup (Settings → Accounts → Add account) + if ("PopupsAccount" === e.detail.viewModelTemplateID) { + // Only for the "Add account" mode, not "Edit account". + if (typeof e.detail.isNew === "function" && !e.detail.isNew()) { + return; + } + const root = e.detail.viewModelDom; + if (!root) return; + + const footer = root.querySelector("footer"); + const form = root.querySelector("#accountform"); + const addButton = root.querySelector("button.buttonAddAccount"); + if (!footer || !form || !addButton) return; + + // Avoid inserting duplicates when view model is re-rendered. + if (root.querySelector(".plugin-o365-add-account")) return; + + const btn = Element.fromHTML( + '' + ); + + btn.onclick = () => { + const email = (form.querySelector('input[name="email"]')?.value || "").trim().toLowerCase(); + const name = (form.querySelector('input[name="name"]')?.value || "").trim(); + if (!email || !isSupportedEmail(email)) { + return; + } + startOAuth("add", { email, name, return: document.location.hash || "#/settings/accounts" }); + }; + + // Put the button next to the default Add Account submit button. + footer.insertBefore(btn, addButton.nextSibling); + } + }); + } })(window.rl); diff --git a/plugins/login-o365/index.php b/plugins/login-o365/index.php index 62050f429..bb8ae7b69 100644 --- a/plugins/login-o365/index.php +++ b/plugins/login-o365/index.php @@ -1,15 +1,20 @@ ['access_token'=>..., 'refresh_token'=>..., 'expires'=>..., 'expires_in'=>...], + * ... + * ] + */ + private static array $auth = []; - public function Init() : void - { - $this->UseLangs(true); - $this->addJs('LoginOAuth2.js'); - $this->addHook('imap.before-login', 'clientLogin'); - $this->addHook('smtp.before-login', 'clientLogin'); - $this->addHook('sieve.before-login', 'clientLogin'); + public function Init() : void + { + $this->UseLangs(true); + $this->addJs('LoginOAuth2.js'); + $this->addHook('imap.before-login', 'clientLogin'); + $this->addHook('smtp.before-login', 'clientLogin'); + $this->addHook('sieve.before-login', 'clientLogin'); - $this->addPartHook('LoginO365', 'ServiceLoginO365'); + $this->addPartHook('LoginO365', 'ServiceLoginO365'); + // Used by JS to obtain an auth URL with signed state (for both login + add-account flows). + $this->addJsonHook('LoginO365AuthUrl', 'DoLoginO365AuthUrl'); - // Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true - $this->addHook('filter.http-paths', 'httpPaths'); - } + // Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true + $this->addHook('filter.http-paths', 'httpPaths'); - public function httpPaths(array &$aPaths) : void - { - // Personal accounts workaround - if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) { - $aPaths = ['LoginO365']; - } + // Cleanup: when an additional account is removed, also remove its encrypted refresh token bundle. + $this->addHook('json.after-AccountDelete', 'afterAccountDelete'); + } - if (!empty($aPaths[0]) && 'LoginO365' === $aPaths[0]) { - $oConfig = \RainLoop\Api::Config(); - $oConfig->Set('security', 'secfetch_allow', - \trim($oConfig->Get('security', 'secfetch_allow', '') . ';site=cross-site', ';') - ); - } - } + public function httpPaths(array &$aPaths) : void + { + if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) { + $aPaths = ['LoginO365']; + } - public function ServiceLoginO365() : string - { - $oActions = \RainLoop\Api::Actions(); - $oHttp = $oActions->Http(); - $oHttp->ServerNoCache(); + if (!empty($aPaths[0]) && 'LoginO365' === $aPaths[0]) { + $oConfig = \RainLoop\Api::Config(); + $oConfig->Set('security', 'secfetch_allow', + \trim($oConfig->Get('security', 'secfetch_allow', '') . ';site=cross-site', ';') + ); + } + } - try + public function ServiceLoginO365() : string + { + $oActions = \RainLoop\Api::Actions(); + $oHttp = $oActions->Http(); + $oHttp->ServerNoCache(); + + try { - if (isset($_GET['error'])) { - throw new \RuntimeException("{$_GET['error']}: {$_GET['error_description']}"); - } - if (!isset($_GET['code']) || empty($_GET['state']) || 'o365' !== $_GET['state']) { - $oActions->Location(\RainLoop\Utils::WebPath()); - exit; - } - $oO365 = $this->o365Connector(); - if (!$oO365) { - $oActions->Location(\RainLoop\Utils::WebPath()); - exit; - } + if (isset($_GET['error'])) { + $desc = $_GET['error_description'] ?? ''; + throw new \RuntimeException("{$_GET['error']}: {$desc}"); + } - $iExpires = \time(); - $aResponse = $oO365->getAccessToken( - \str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI), - 'authorization_code', - array( - 'code' => $_GET['code'], - 'redirect_uri' => $oHttp->GetFullUrl().'?LoginO365' - ) - ); - if (200 != $aResponse['code']) { - if (isset($aResponse['result']['error'])) { - throw new \RuntimeException( - $aResponse['code'] - . ': ' - . $aResponse['result']['error'] - . ' / ' - . $aResponse['result']['error_description'] - ); - } - throw new \RuntimeException("HTTP: {$aResponse['code']}"); - } - $aResponse = $aResponse['result']; - if (empty($aResponse['access_token'])) { - throw new \RuntimeException('access_token missing'); - } - if (empty($aResponse['refresh_token'])) { - throw new \RuntimeException('refresh_token missing'); - } + // Must have code + state + if (!isset($_GET['code']) || empty($_GET['state'])) { + $oActions->Location(\RainLoop\Utils::WebPath()); + exit; + } - $sAccessToken = $aResponse['access_token']; - $iExpires += $aResponse['expires_in']; + $oO365 = $this->o365Connector(); + if (!$oO365) { + $oActions->Location(\RainLoop\Utils::WebPath()); + exit; + } - $oO365->setAccessToken($sAccessToken); - $aUserInfo = $oO365->fetch('https://graph.microsoft.com/oidc/userinfo'); - if (200 != $aUserInfo['code']) { - throw new \RuntimeException("HTTP: {$aResponse['code']}"); - } - $aUserInfo = $aUserInfo['result']; - if (empty($aUserInfo['id'])) { - throw new \RuntimeException('unknown id'); - } - if (empty($aUserInfo['email'])) { - throw new \RuntimeException('unknown email address'); - } + $iNow = \time(); - static::$auth = [ - 'access_token' => $sAccessToken, - 'refresh_token' => $aResponse['refresh_token'], - 'expires_in' => $aResponse['expires_in'], - 'expires' => $iExpires - ]; + $redirectUri = $this->redirectUri(); - $oPassword = new \SnappyMail\SensitiveString($aUserInfo['id']); - $oAccount = $oActions->LoginProcess($aUserInfo['email'], $oPassword); -// $oAccount = MainAccount::NewInstanceFromCredentials($oActions, $aUserInfo['email'], $aUserInfo['email'], $oPassword, true); - if ($oAccount) { -// $oActions->SetMainAuthAccount($oAccount); -// $oActions->SetAuthToken($oAccount); - $oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(), - \SnappyMail\Crypt::EncryptToJSON(static::$auth, $oAccount->CryptKey()) - ); - } - } - catch (\Exception $oException) - { - $oActions->Logger()->WriteException($oException, \LOG_ERR); - } - $oActions->Location(\RainLoop\Utils::WebPath()); - exit; - } + $tenant = $this->Config()->Get('plugin', 'tenant', 'common'); - public function configMapping() : array - { - return [ - \RainLoop\Plugins\Property::NewInstance('personal') - ->SetLabel('Use with personal accounts') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) - ->SetDefaultValue(true) - ->SetAllowedInJs() - ->SetDescription('Sign in users with personal Microsoft accounts such as Outlook.com (Hotmail)'), - \RainLoop\Plugins\Property::NewInstance('client_id') - ->SetLabel('Client ID') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING) - ->SetAllowedInJs() - ->SetDescription('https://github.com/the-djmaze/snappymail/wiki/FAQ#o365'), - \RainLoop\Plugins\Property::NewInstance('client_secret') - ->SetLabel('Client Secret') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING) - ->SetEncrypted(), - \RainLoop\Plugins\Property::NewInstance('tenant_id') - ->SetLabel('Tenant ID') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING), - \RainLoop\Plugins\Property::NewInstance('tenant')->SetLabel('Tenant') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION) - ->SetDefaultValue(['common','consumers','organizations']) - ->SetAllowedInJs() - ]; - } + $state = (string) $_GET['state']; + $statePayload = $this->verifyAndConsumeState($state); + if (!$statePayload) { + $oActions->Location(\RainLoop\Utils::WebPath()); + exit; + } - public function clientLogin(\RainLoop\Model\Account $oAccount, \MailSo\Net\NetClient $oClient, \MailSo\Net\ConnectSettings $oSettings) : void - { - if ($oAccount instanceof MainAccount && \str_ends_with($oAccount->Email(), '@hotmail.com')) { - $oActions = \RainLoop\Api::Actions(); - try { - $aData = static::$auth ?: \SnappyMail\Crypt::DecryptFromJSON( - $oActions->StorageProvider()->Get($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken()), - $oAccount->CryptKey() - ); - } catch (\Throwable $oException) { -// $oActions->Logger()->WriteException($oException, \LOG_ERR); - return; - } - if (!empty($aData['expires']) && !empty($aData['access_token']) && !empty($aData['refresh_token'])) { - if (\time() >= $aData['expires']) { - $iExpires = \time(); - $oO365 = $this->o365Connector(); - if ($oO365) { - $aRefreshTokenResponse = $oO365->getAccessToken( - \str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI), - 'refresh_token', - array('refresh_token' => $aData['refresh_token']) - ); - if (!empty($aRefreshTokenResponse['result']['access_token'])) { - $aData['access_token'] = $aRefreshTokenResponse['result']['access_token']; - $aResponse['expires'] = $iExpires + $aResponse['expires_in']; - $oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(), - \SnappyMail\Crypt::EncryptToJSON($aData, $oAccount->CryptKey()) - ); - } - } - } - $oSettings->passphrase = $aData['access_token']; - \array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2'); - } - } - } + $aTokenWrap = $oO365->getAccessToken( + \str_replace('{{tenant}}', $tenant, static::TOKEN_URI), + 'authorization_code', + [ + 'code' => $_GET['code'], + 'redirect_uri' => $redirectUri + ] + ); - protected function o365Connector() : ?\OAuth2\Client - { - $client_id = \trim($this->Config()->Get('plugin', 'client_id', '')); - $client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', '')); - if ($client_id && $client_secret) { - try - { - $oO365 = new \OAuth2\Client($client_id, $client_secret); - $oActions = \RainLoop\Api::Actions(); - $sProxy = $oActions->Config()->Get('labs', 'curl_proxy', ''); - if (\strlen($sProxy)) { - $oO365->setCurlOption(CURLOPT_PROXY, $sProxy); - $sProxyAuth = $oActions->Config()->Get('labs', 'curl_proxy_auth', ''); - if (\strlen($sProxyAuth)) { - $oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth); - } - } - return $oO365; - } - catch (\Exception $oException) - { - $oActions->Logger()->WriteException($oException, \LOG_ERR); - } - } - return null; - } -} + if (!\is_array($aTokenWrap) || !isset($aTokenWrap['code'])) { + throw new \RuntimeException('Token request failed: ' . \json_encode($aTokenWrap)); + } + if (200 !== (int)$aTokenWrap['code']) { + $err = $aTokenWrap['result']['error'] ?? ''; + $desc = $aTokenWrap['result']['error_description'] ?? ''; + throw new \RuntimeException("Token HTTP {$aTokenWrap['code']}: {$err} / {$desc}"); + } + + $aToken = $aTokenWrap['result'] ?? []; + $accessToken = $aToken['access_token'] ?? ''; + $refreshToken = $aToken['refresh_token'] ?? ''; + $expiresIn = (int)($aToken['expires_in'] ?? 0); + $idToken = $aToken['id_token'] ?? ''; + + if ($accessToken === '') { + throw new \RuntimeException('access_token missing'); + } + + if ($refreshToken === '') { + throw new \RuntimeException('refresh_token missing'); + } + if ($idToken === '') { + // We rely on id_token to get email/sub without Graph. + throw new \RuntimeException('id_token missing (add openid email profile scopes)'); + } + + // Parse id_token (JWT) to get identity (sub + email) + $claims = $this->decodeJwtPayload($idToken); + if (!\is_array($claims)) { + throw new \RuntimeException('Cannot decode id_token payload'); + } + + $email = $claims['email'] ?? ($claims['preferred_username'] ?? ($claims['upn'] ?? '')); + $sub = $claims['sub'] ?? ''; + + if ($sub === '') { + throw new \RuntimeException('unknown id from id_token'); + } + if ($email === '') { + throw new \RuntimeException('unknown email address from id_token'); + } + + if (!$this->isSupportedEmail(\strtolower($email))) { + throw new \RuntimeException('Unsupported email domain for this plugin'); + } + + $tokenBundle = [ + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'expires_in' => $expiresIn, + 'expires' => $iNow + $expiresIn + ]; + + $op = $statePayload['op'] ?? 'login'; + if ('add' === $op) { + $oMainAccount = $oActions->getMainAccountFromToken(false); + if (!$oMainAccount) { + throw new \RuntimeException('Add-account flow requires logged in main account'); + } + if (!empty($statePayload['main']) && $statePayload['main'] !== $oMainAccount->Email()) { + throw new \RuntimeException('Add-account state does not match current main account'); + } + + // Store token bundle encrypted with MAIN account crypt key (never store refresh_token unencrypted). + // This is later used by imap/smtp/sieve.before-login for *additional* accounts. + $this->storeAccountTokens($oMainAccount, $email, $tokenBundle); + + // Create/validate an AdditionalAccount entry exactly like SnappyMail expects in "additionalaccounts". + // We set the "password" to the OAuth subject (sub) as an opaque secret; the plugin will inject XOAUTH2. + $oPassword = new \SnappyMail\SensitiveString($sub); + $oAdditional = $oActions->LoginProcess($email, $oPassword, false); + if (!$oAdditional instanceof \RainLoop\Model\AdditionalAccount) { + throw new \RuntimeException('Failed to create additional account'); + } + + $asciiEmail = \SnappyMail\IDN::emailToAscii($oAdditional->Email()); + if ($asciiEmail === $oMainAccount->Email()) { + throw new \RuntimeException('Cannot add main account as additional'); + } + + $aAccounts = $oActions->GetAccounts($oMainAccount); + $aEntry = $oAdditional->asTokenArray($oMainAccount); + if (!empty($statePayload['name']) && \is_string($statePayload['name'])) { + $aEntry['name'] = \trim($statePayload['name']); + } else if (isset($aAccounts[$asciiEmail]['name'])) { + // Preserve previous custom label if re-adding/updating. + $aEntry['name'] = (string) $aAccounts[$asciiEmail]['name']; + } + $aAccounts[$asciiEmail] = $aEntry; + $oActions->SetAccounts($oMainAccount, $aAccounts); + + // Cache for this request (used during LoginProcess() above and any subsequent logins). + static::$auth[\strtolower($asciiEmail)] = $tokenBundle; + + $returnHash = ''; + if (!empty($statePayload['return']) && \is_string($statePayload['return']) && \str_starts_with($statePayload['return'], '#')) { + $returnHash = $statePayload['return']; + } + $oActions->Location(\RainLoop\Utils::WebPath() . $returnHash); + exit; + } + + // Default: "login" flow (preserve existing behavior) + static::$auth[\strtolower($email)] = $tokenBundle; + + // SnappyMail uses password as opaque string; plugin injects XOAUTH2 later. + $oPassword = new \SnappyMail\SensitiveString($sub); + $oAccount = $oActions->LoginProcess($email, $oPassword); + + if ($oAccount) { + $oActions->StorageProvider()->Put( + $oAccount, + StorageType::SESSION, + \RainLoop\Utils::GetSessionToken(), + \SnappyMail\Crypt::EncryptToJSON($tokenBundle, $oAccount->CryptKey()) + ); + } + } + catch (\Throwable $e) { + $oActions->Logger()->WriteException($e, \LOG_ERR); + } + + $oActions->Location(\RainLoop\Utils::WebPath()); + exit; + } + + public function configMapping() : array + { + return [ + \RainLoop\Plugins\Property::NewInstance('client_id') + ->SetLabel('Client ID') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING) + ->SetAllowedInJs(), + \RainLoop\Plugins\Property::NewInstance('client_secret') + ->SetLabel('Client Secret') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING) + ->SetEncrypted(), + \RainLoop\Plugins\Property::NewInstance('tenant') + ->SetLabel('Tenant') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION) + ->SetDefaultValue(['common','consumers','organizations']) + ->SetAllowedInJs(), + \RainLoop\Plugins\Property::NewInstance('personal') + // When true: redirect URI uses query parameter form "/?LoginO365" (Azure supports it). + // When false: redirect URI uses path form "/LoginO365" (useful behind reverse proxies). + ->SetLabel('Use "/?LoginO365" redirect URI') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) + ->SetDefaultValue(false) + ->SetAllowedInJs(), + \RainLoop\Plugins\Property::NewInstance('allow_any_domain') + ->SetLabel('Allow any domain (not only outlook.com/hotmail.com/live.com)') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) + ->SetDefaultValue(false) + ->SetAllowedInJs() + ]; + } + + public function clientLogin(\RainLoop\Model\Account $oAccount, \MailSo\Net\NetClient $oClient, \MailSo\Net\ConnectSettings $oSettings) : void + { + $email = \strtolower($oAccount->Email()); + + if (!$this->isSupportedEmail($email)) { + return; + } + + $oActions = \RainLoop\Api::Actions(); + + $aData = static::$auth[$email] ?? null; + if (!$aData) { + try { + if ($oAccount instanceof MainAccount) { + $blob = $oActions->StorageProvider()->Get( + $oAccount, + StorageType::SESSION, + \RainLoop\Utils::GetSessionToken() + ); + $aData = \SnappyMail\Crypt::DecryptFromJSON($blob, $oAccount->CryptKey()); + } else if ($oAccount instanceof \RainLoop\Model\AdditionalAccount) { + $oMain = $oActions->getMainAccountFromToken(false); + if (!$oMain) { + return; + } + $blob = $oActions->StorageProvider()->Get( + $oMain, + StorageType::CONFIG, + $this->tokenStorageKey($email) + ); + $aData = \SnappyMail\Crypt::DecryptFromJSON($blob, $oMain->CryptKey()); + } + } catch (\Throwable $e) { + return; + } + } + + if (empty($aData['access_token']) || empty($aData['refresh_token']) || empty($aData['expires'])) { + return; + } + + // Refresh if expired (or close to expiry) + if (\time() >= ((int)$aData['expires'] - 30)) { + $oO365 = $this->o365Connector(); + if ($oO365) { + $tenant = $this->Config()->Get('plugin', 'tenant', 'common'); + $aRefreshWrap = $oO365->getAccessToken( + \str_replace('{{tenant}}', $tenant, static::TOKEN_URI), + 'refresh_token', + ['refresh_token' => $aData['refresh_token']] + ); + + if (\is_array($aRefreshWrap) && isset($aRefreshWrap['code']) && 200 === (int)$aRefreshWrap['code']) { + $r = $aRefreshWrap['result'] ?? []; + if (!empty($r['access_token'])) { + $aData['access_token'] = $r['access_token']; + } + if (!empty($r['refresh_token'])) { + $aData['refresh_token'] = $r['refresh_token']; + } + $expiresIn = (int)($r['expires_in'] ?? 0); + if ($expiresIn > 0) { + $aData['expires'] = \time() + $expiresIn; + $aData['expires_in'] = $expiresIn; + } + + // Persist updated bundle (encrypted). + if ($oAccount instanceof MainAccount) { + $oActions->StorageProvider()->Put( + $oAccount, + StorageType::SESSION, + \RainLoop\Utils::GetSessionToken(), + \SnappyMail\Crypt::EncryptToJSON($aData, $oAccount->CryptKey()) + ); + } else if ($oAccount instanceof \RainLoop\Model\AdditionalAccount) { + $oMain = $oActions->getMainAccountFromToken(false); + if ($oMain) { + $oActions->StorageProvider()->Put( + $oMain, + StorageType::CONFIG, + $this->tokenStorageKey($email), + \SnappyMail\Crypt::EncryptToJSON($aData, $oMain->CryptKey()) + ); + } + } + } + } + } + + static::$auth[$email] = $aData; + + // Inject XOAUTH2/OAUTHBEARER + $oSettings->passphrase = $aData['access_token']; + \array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2'); + } + + /** + * Server-side cleanup hook: after a successful AccountDelete, remove stored token bundle for that email. + * This prevents leaving encrypted refresh tokens behind when an additional account is removed. + */ + public function afterAccountDelete(array &$aResponse) : void + { + if (empty($aResponse['Result'])) { + return; + } + $oActions = \RainLoop\Api::Actions(); + $oMain = $oActions->getMainAccountFromToken(false); + if (!$oMain) { + return; + } + $email = \strtolower(\SnappyMail\IDN::emailToAscii(\trim((string) $oActions->GetActionParam('emailToDelete', '')))); + if ($email && $this->isSupportedEmail($email)) { + $oActions->StorageProvider()->Clear($oMain, StorageType::CONFIG, $this->tokenStorageKey($email)); + } + } + + protected function o365Connector() : ?\OAuth2\Client + { + $client_id = \trim($this->Config()->Get('plugin', 'client_id', '')); + $client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', '')); + + if ($client_id && $client_secret) { + try { + $oO365 = new \OAuth2\Client($client_id, $client_secret); + + $oActions = \RainLoop\Api::Actions(); + $sProxy = $oActions->Config()->Get('labs', 'curl_proxy', ''); + if (\strlen($sProxy)) { + $oO365->setCurlOption(CURLOPT_PROXY, $sProxy); + $sProxyAuth = $oActions->Config()->Get('labs', 'curl_proxy_auth', ''); + if (\strlen($sProxyAuth)) { + $oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth); + } + } + + return $oO365; + } catch (\Throwable $e) { + \RainLoop\Api::Actions()->Logger()->WriteException($e, \LOG_ERR); + } + } + + return null; + } + + private function decodeJwtPayload(string $jwt) : ?array + { + $parts = \explode('.', $jwt); + if (\count($parts) < 2) { + return null; + } + $payload = $parts[1]; + $payload .= \str_repeat('=', (4 - (\strlen($payload) % 4)) % 4); + $json = \base64_decode(\strtr($payload, '-_', '+/')); + if ($json === false) { + return null; + } + $data = \json_decode($json, true); + return \is_array($data) ? $data : null; + } + + /** + * JSON action called by JS to obtain an MS authorize URL with signed state. + * This avoids exposing any signing secret to JS and keeps redirect_uri consistent with server logic. + */ + public function DoLoginO365AuthUrl() : array + { + $oActions = \RainLoop\Api::Actions(); + + $op = (string) $this->jsonParam('op', 'login'); + if (!\in_array($op, ['login', 'add'], true)) { + return $this->jsonResponse(__FUNCTION__, false); + } + + $email = \strtolower(\trim((string) $this->jsonParam('email', ''))); + $name = \trim((string) $this->jsonParam('name', '')); + $returnHash = (string) $this->jsonParam('return', ''); + + if ($returnHash && !\str_starts_with($returnHash, '#')) { + $returnHash = ''; + } + + // For add-account flow, require a logged-in main account (we must write to its additionalaccounts storage). + $oMainAccount = null; + if ('add' === $op) { + $oMainAccount = $oActions->getMainAccountFromToken(false); + if (!$oMainAccount) { + return $this->jsonResponse(__FUNCTION__, false); + } + } + + // Optional server-side guard: only permit supported consumer domains unless configured otherwise. + if ($email && !$this->isSupportedEmail($email)) { + return $this->jsonResponse(__FUNCTION__, false); + } + + $oConfig = $this->Config(); + $client_id = \trim($oConfig->Get('plugin', 'client_id', '')); + if (!$client_id) { + return $this->jsonResponse(__FUNCTION__, false); + } + + $nonce = $this->b64url(\random_bytes(16)); + // Store nonce server-side to prevent replay; consumed on callback. + $oActions->StorageProvider()->Put( + null, + StorageType::NOBODY, + $this->stateNonceKey($nonce), + (string) \time() + ); + + $payload = [ + 'v' => 1, + 'op' => $op, + 'csrf' => \RainLoop\Utils::GetCsrfToken(), + 'nonce' => $nonce, + 'ts' => \time() + ]; + if ('add' === $op && $oMainAccount) { + $payload['main'] = $oMainAccount->Email(); + if ($name) { + $payload['name'] = \substr($name, 0, 100); + } + if ($returnHash) { + $payload['return'] = \substr($returnHash, 0, 200); + } + } + + $state = $this->signState($payload); + $tenant = $oConfig->Get('plugin', 'tenant', 'common'); + $redirectUri = $this->redirectUri(); + + $params = [ + 'response_type' => 'code', + 'client_id' => $client_id, + 'redirect_uri' => $redirectUri, + 'scope' => \implode(' ', [ + 'openid', + 'offline_access', + 'email', + 'profile', + 'https://outlook.office.com/IMAP.AccessAsUser.All', + 'https://outlook.office.com/SMTP.Send', + ]), + 'state' => $state, + // Helps MS UI prefill, but does not change server-side validation. + ]; + if ($email) { + $params['login_hint'] = $email; + } + $authUrl = \str_replace('{{tenant}}', $tenant, static::AUTH_URI) + . '?' + . \http_build_query($params, '', '&', PHP_QUERY_RFC3986); + + return $this->jsonResponse(__FUNCTION__, [ + 'authUrl' => $authUrl + ]); + } + + private function isSupportedEmail(string $email) : bool + { + if ((bool)$this->Config()->Get('plugin', 'allow_any_domain', false)) { + return \str_contains($email, '@'); + } + return \str_ends_with($email, '@hotmail.com') + || \str_ends_with($email, '@outlook.com') + || \str_ends_with($email, '@live.com'); + } + + /** + * Build absolute base URL (works behind nginx reverse proxy). + */ + private function baseUrl() : string + { + $scheme = (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) + ? $_SERVER['HTTP_X_FORWARDED_PROTO'] + : ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'); + + $host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? ''; + if (!$host) { + throw new \RuntimeException('Cannot determine HTTP_HOST'); + } + return $scheme . '://' . $host; + } + + /** + * Redirect URI used for the Azure app registration. + * When plugin.personal=true -> "/?LoginO365" + * When plugin.personal=false -> "/LoginO365" + */ + private function redirectUri() : string + { + $base = \rtrim($this->baseUrl(), '/'); + $useQuery = (bool)$this->Config()->Get('plugin', 'personal', false); + return $useQuery ? ($base . '/?LoginO365') : ($base . '/LoginO365'); + } + + private function tokenStorageKey(string $emailLower) : string + { + // Stored under MAIN account StorageType::CONFIG (encrypted with main CryptKey). + // Email is hashed to avoid path/encoding issues across storage backends. + return 'login-o365.tokens.' . \sha1($emailLower); + } + + private function storeAccountTokens(MainAccount $oMainAccount, string $email, array $tokenBundle) : void + { + $emailLower = \strtolower(\SnappyMail\IDN::emailToAscii($email)); + \RainLoop\Api::Actions()->StorageProvider()->Put( + $oMainAccount, + StorageType::CONFIG, + $this->tokenStorageKey($emailLower), + \SnappyMail\Crypt::EncryptToJSON($tokenBundle, $oMainAccount->CryptKey()) + ); + } + + private function stateNonceKey(string $nonce) : string + { + return 'login-o365.state.' . $nonce; + } + + private function b64url(string $bin) : string + { + return \rtrim(\strtr(\base64_encode($bin), '+/', '-_'), '='); + } + + private function b64urlDecode(string $b64url) /*: string|false*/ + { + $pad = (4 - (\strlen($b64url) % 4)) % 4; + return \base64_decode(\strtr($b64url . \str_repeat('=', $pad), '-_', '+/'), true); + } + + private function stateHmacKey() : string + { + // Uses the plugin client_secret (server-side only) as HMAC key. + // This prevents any user-controlled tampering of the state payload. + $key = \trim($this->Config()->getDecrypted('plugin', 'client_secret', '')); + if (!$key) { + // Fallback for misconfiguration; keeps behavior deterministic. + $key = 'login-o365'; + } + return $key; + } + + private function signState(array $payload) : string + { + $json = \json_encode($payload); + if (!$json) { + $json = '{}'; + } + $payloadB64 = $this->b64url($json); + $sig = \hash_hmac('sha256', $payloadB64, $this->stateHmacKey(), true); + return $payloadB64 . '.' . $this->b64url($sig); + } + + /** + * Verify signature + CSRF + nonce, then consumes nonce to prevent replay. + * Returns decoded payload on success, null on failure. + */ + private function verifyAndConsumeState(string $state) : ?array + { + $parts = \explode('.', $state, 2); + if (2 !== \count($parts)) { + return null; + } + [$payloadB64, $sigB64] = $parts; + $sig = $this->b64urlDecode($sigB64); + if ($sig === false) { + return null; + } + + $expected = \hash_hmac('sha256', $payloadB64, $this->stateHmacKey(), true); + if (!\hash_equals($expected, $sig)) { + return null; + } + + $payloadJson = $this->b64urlDecode($payloadB64); + if ($payloadJson === false) { + return null; + } + $payload = \json_decode($payloadJson, true); + if (!\is_array($payload) || empty($payload['csrf']) || empty($payload['nonce']) || empty($payload['op'])) { + return null; + } + + // Must match the current browser session. + if ($payload['csrf'] !== \RainLoop\Utils::GetCsrfToken()) { + return null; + } + + // Replay protection: nonce must exist server-side and is consumed once. + $oActions = \RainLoop\Api::Actions(); + $key = $this->stateNonceKey((string) $payload['nonce']); + $seen = $oActions->StorageProvider()->Get(null, StorageType::NOBODY, $key); + if (!$seen) { + return null; + } + + $ts = (int) ($payload['ts'] ?? 0); + if ($ts && \abs(\time() - $ts) > 900) { // 15 minutes + // Expired: clear nonce to avoid accumulating stale entries. + $oActions->StorageProvider()->Clear(null, StorageType::NOBODY, $key); + return null; + } + + $oActions->StorageProvider()->Clear(null, StorageType::NOBODY, $key); + + return $payload; + } +} \ No newline at end of file diff --git a/plugins/login-remote/index.php b/plugins/login-remote/index.php index ab1550571..fe5bb7282 100644 --- a/plugins/login-remote/index.php +++ b/plugins/login-remote/index.php @@ -6,8 +6,8 @@ class LoginRemotePlugin extends \RainLoop\Plugins\AbstractPlugin NAME = 'Login Remote', AUTHOR = 'SnappyMail', URL = 'https://snappymail.eu/', - VERSION = '1.5', - RELEASE = '2024-09-20', + VERSION = '1.6', + RELEASE = '2026-10-22', REQUIRED = '2.36.1', CATEGORY = 'Login', LICENSE = 'MIT', @@ -43,6 +43,7 @@ class LoginRemotePlugin extends \RainLoop\Plugins\AbstractPlugin try { static::$login = true; + $sPassword = new \SnappyMail\SensitiveString($sPassword); $oAccount = $oActions->LoginProcess($sEmail, $sPassword); } catch (\Throwable $oException) diff --git a/plugins/proxy-auth/README.md b/plugins/proxy-auth/README.md index 7a3004d70..c9db401e6 100644 --- a/plugins/proxy-auth/README.md +++ b/plugins/proxy-auth/README.md @@ -19,36 +19,37 @@ The following steps are require in SnappyMail: - Master User is dependent on Dovecot config (see below) - Master User Password is dependent on Dovecot config (see below) - Header Name is dependent on authentication solution. This is the header containing the name of currently logged in user. In case of Authelia, this is "Remote-User". - - Check Proxy: Since this plugin partially bypasses authentication, it is important to only allow this access from well-defined hosts. It is highly recommended to activate this option! - - When checking for reverse proxy, it is required to set the IP filter to either an IP address or a subnet. - Automatic Login: Automatically logs in the user of user header is present (see below) +> **Security note** +> +> This plugin trusts the configured request header as proof of identity. Anyone who can reach the SnappyMail container directly and set that header can log in as any user. You **must** ensure that SnappyMail is only reachable through your reverse proxy / SSO chain (e.g. via the docker network, a firewall, or by binding SnappyMail to a non-public interface) and that the upstream proxy strips any client-supplied value of the header before forwarding. The plugin itself does no source-IP validation — earlier versions had a `check_proxy` option, but it inspected the forwarded client IP (the end user's IP) and therefore did not actually verify that the request came from the proxy. It has been removed; gate access at the network layer instead. + This concludes the setup of SnappyMail. ### Dovecot In Dovecot, you need to enable Master User. Enable ```!include auth-master.conf.ext``` in /etc/dovecot/conf.d/10-auth.conf. -The file /etc/dovecot/conf.d/auth-master.conf.ext should contain: +In Dovecot 2.3, the file /etc/dovecot/conf.d/auth-master.conf.ext should contain: ``` -# Authentication for master users. Included from auth.conf. - -# By adding master=yes setting inside a passdb you make the passdb a list -# of "master users", who can log in as anyone else. -# - -# Example master user passdb using passwd-file. You can use any passdb though. passdb { driver = passwd-file master = yes args = /etc/dovecot/master-users - - # Unless you're using PAM, you probably still want the destination user to - # be looked up from passdb that it really exists. pass=yes does that. pass = yes } ``` +In Dovecot 2.4, the file /etc/dovecot/conf.d/auth-master.conf.ext should contain: +``` +passdb passwd-file { + master = yes + passwd_file_path = /etc/dovecot/master-users + result_success = continue +} +``` + You then need to create a master user in /etc/dovecot/master-users: ``` admin:PASSWORD::::::allow_nets=local,172.17.0.0/16 @@ -80,3 +81,11 @@ The user is always considered logged in, as authentication is handled through re Auto login can be disabled in the plugin settings. You can also change the logout link in admin panel -> Config -> custom_logout_link to the one of your authentication system, e.g., ```https://auth.yourdomain.com/logout```. In this case, you can log out from your overall system via SnappyMail. + +## Troubleshooting + +### IMAP `AUTHENTICATIONFAILED` after a container rebuild / upgrade + +The master user/password fields are encrypted at rest using SnappyMail's `APP_SALT`. If that salt is regenerated (e.g., the data volume was reset, the container was rebuilt without persisting `_data_`, or the salt file was rotated), the values in `plugin-proxy-auth.json` can no longer be decrypted. `getDecrypted()` then silently returns `null`, an empty password is passed to IMAP, and Dovecot rejects the login with `AUTHENTICATIONFAILED`. + +Fix: open admin panel -> Extensions -> Proxy Auth, re-enter the Master User and Master Password (and any other previously-set encrypted fields), and save. The values will be re-encrypted under the current salt. diff --git a/plugins/proxy-auth/index.php b/plugins/proxy-auth/index.php index ad2fea0f4..36db5d9c2 100644 --- a/plugins/proxy-auth/index.php +++ b/plugins/proxy-auth/index.php @@ -6,8 +6,8 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin NAME = 'Proxy Auth', AUTHOR = 'Philipp', URL = 'https://www.mundhenk.org/', - VERSION = '0.5', - RELEASE = '2024-09-20', + VERSION = '0.7', + RELEASE = '2026-05-17', REQUIRED = '2.36.1', CATEGORY = 'Login', LICENSE = 'MIT', @@ -21,26 +21,6 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin $this->addHook('login.credentials', 'MapEmailAddress'); } - /* by https://gist.github.com/tott/7684443 */ - /** - * Check if a given ip is in a network - * @param string $ip IP to check in IPV4 format eg. 127.0.0.1 - * @param string $range IP/CIDR netmask eg. 127.0.0.0/24, also 127.0.0.1 is accepted and /32 assumed - * @return boolean true if the ip is in this range / false if not. - */ - private function ip_in_range( $ip, $range ) { - if ( strpos( $range, '/' ) == false ) { - $range .= '/32'; - } - // $range is in IP/CIDR format eg 127.0.0.1/24 - list( $range, $netmask ) = explode( '/', $range, 2 ); - $range_decimal = ip2long( $range ); - $ip_decimal = ip2long( $ip ); - $wildcard_decimal = pow( 2, ( 32 - $netmask ) ) - 1; - $netmask_decimal = ~ $wildcard_decimal; - return ( ( $ip_decimal & $netmask_decimal ) == ( $range_decimal & $netmask_decimal ) ); - } - public function MapEmailAddress(string &$sEmail, string &$sImapUser, string &$sPassword, string &$sSmtpUser) { $oActions = \RainLoop\Api::Actions(); @@ -80,59 +60,19 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin $sMsg = "Remote User: " . $sRemoteUser; $oLogger->Write($sMsg, $sLevel, $sPrefix); - $sProxyIP = $this->Config()->getDecrypted('plugin', 'proxy_ip', ''); - $sMsg = "ProxyIP: " . $sProxyIP; - $oLogger->Write($sMsg, $sLevel, $sPrefix); + /* create master user login from remote user header and settings */ + $sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser; + $sPassword = new \SnappyMail\SensitiveString(\trim($this->Config()->getDecrypted('plugin', 'master_password', ''))); - $sProxyCheck = $this->Config()->getDecrypted('plugin', 'proxy_check', ''); - $sClientIPs = $this->Manager()->Actions()->Http()->GetClientIP(true); - - /* make sure that remote user is only set by authorized proxy to avoid security risks */ - if ($sProxyCheck) { - $sProxyRequest = false; - $sMsg = "checking client IPs: " . $sClientIPs; - $oLogger->Write($sMsg, $sLevel, $sPrefix); - - $sClientIPs = explode(", ", $sClientIPs); - if (is_array($sClientIPs)) { - foreach ($sClientIPs as &$sIP) { - $sMsg = "checking client IP: " . $sIP; - $oLogger->Write($sMsg, $sLevel, $sPrefix); - - if ($this->ip_in_range($sIP, $sProxyIP)) { - $sProxyRequest = true; - } - } - } else { - $sMsg = "checking client IP: " . $sClientIPs; - $oLogger->Write($sMsg, $sLevel, $sPrefix); - - if ($this->ip_in_range($sClientIPs, $sProxyIP)) { - $sProxyRequest = true; - } - } - } else { - $sProxyRequest = true; + try + { + static::$login = true; + $oAccount = $oActions->LoginProcess($sEmail, $sPassword); } - - if ($sProxyRequest) { - /* create master user login from remote user header and settings */ - $sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser; - $sPassword = new \SnappyMail\SensitiveString(\trim($this->Config()->getDecrypted('plugin', 'master_password', ''))); - - try - { - static::$login = true; - $oAccount = $oActions->LoginProcess($sEmail, $sPassword); - } - catch (\Throwable $oException) - { - $oLogger = $oActions->Logger(); - $oLogger && $oLogger->WriteException($oException); - } - - \MailSo\Base\Http::Location('./'); - return true; + catch (\Throwable $oException) + { + $oLogger = $oActions->Logger(); + $oLogger && $oLogger->WriteException($oException); } \MailSo\Base\Http::Location('./'); @@ -188,18 +128,6 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin ->SetDescription('Name of header containing username') ->SetDefaultValue('Remote-User') ->SetEncrypted(), - \RainLoop\Plugins\Property::NewInstance('check_proxy') - ->SetLabel('Check Proxy') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) - ->SetDescription('Activates check if proxy is connecting') - ->SetDefaultValue(true) - ->SetEncrypted(), - \RainLoop\Plugins\Property::NewInstance('proxy_ip') - ->SetLabel('Proxy IPNet') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) - ->SetDescription('IP or Subnet of proxy, auth header will only be accepted from this address') - ->SetDefaultValue('10.1.0.0/24') - ->SetEncrypted(), \RainLoop\Plugins\Property::NewInstance('auto_login') ->SetAllowedInJs(true) ->SetLabel('Activate automatic login') diff --git a/plugins/search-filters/index.php b/plugins/search-filters/index.php index 090f31141..f48cae1ae 100644 --- a/plugins/search-filters/index.php +++ b/plugins/search-filters/index.php @@ -6,8 +6,8 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin NAME = 'Search Filters', AUTHOR = 'AbdoBnHesham', URL = 'https://github.com/the-djmaze/snappymail/pull/1673', - VERSION = '0.2', - RELEASE = '2024-06-28', + VERSION = '0.3', + RELEASE = '2026-10-22', REQUIRED = '2.36.3', CATEGORY = 'General', LICENSE = 'MIT', @@ -51,45 +51,33 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin $Filters = $aSettings['SFilters']; foreach ($Filters as $filter) { - $this->Manager()->logWrite(json_encode([ - 'filter' => $filter, - ]), LOG_WARNING); + $this->Manager()->logWrite(json_encode(['filter' => $filter]), LOG_WARNING); - $folder = 'INBOX'; $searchQ = $filter['searchQ']; - $uids = $this->searchMessages($oImapClient, $searchQ, "INBOX"); - //Mark as read/seen - if ($filter['fSeen']) { - foreach ($uids as $uid) { - $oRange = new MailSo\Imap\SequenceSet([$uid]); - $this->Manager()->Actions()->MailClient()->MessageSetFlag( - $folder, - $oRange, - MailSo\Imap\Enumerations\MessageFlag::SEEN - ); + // obsługa keyword (multi-folder) + if (stripos($searchQ, 'keyword=') !== false) { + $oMailClient = $this->Manager()->Actions()->MailClient(); + + // Pobieramy wszystkie foldery top-level + $folders = $oMailClient->Folders('', false, ''); + foreach ($folders as $f) { + $folder = $f->FullName ?? null; + if (!$folder) continue; + + $uids = $this->searchMessages($oImapClient, $searchQ, $folder); + if (!empty($uids)) { + $this->applyActions($oImapClient, $filter, $uids, $folder); + } } + + continue; // przechodzimy do następnego filtra } - //Flag/Star message - if ($filter['fFlag']) { - foreach ($uids as $uid) { - $oRange = new MailSo\Imap\SequenceSet([$uid]); - $this->Manager()->Actions()->MailClient()->MessageSetFlag( - $folder, - $oRange, - MailSo\Imap\Enumerations\MessageFlag::FLAGGED - ); - } - } - - // Move to folder - if ($filter['fFolder']) { - $folder = $filter['fFolder']; - foreach ($uids as $uid) { - $oRange = new MailSo\Imap\SequenceSet([$uid]); - $oImapClient->MessageMove("INBOX", $folder, $oRange); - } + // normalny search w INBOX + $uids = $this->searchMessages($oImapClient, $searchQ, 'INBOX'); + if (!empty($uids)) { + $this->applyActions($oImapClient, $filter, $uids, 'INBOX'); } } } @@ -99,21 +87,66 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin string $search, string $folder = "INBOX" ): array { - $oParams = new \MailSo\Mail\MessageListParams(); - $oParams->sSearch = $search; - $oParams->sFolderName = $folder; + try { + $bUseCache = false; + $oSearchCriterias = \MailSo\Imap\SearchCriterias::fromString( + $imapClient, + $folder, + $search, + true, + $bUseCache + ); - $bUseCache = false; - $oSearchCriterias = \MailSo\Imap\SearchCriterias::fromString( - $imapClient, - $folder, - $search, - true, - $bUseCache - ); + $imapClient->FolderSelect($folder); + return $imapClient->MessageSearch($oSearchCriterias, true); - $imapClient->FolderSelect($folder); - return $imapClient->MessageSearch($oSearchCriterias, true); + } catch (\Throwable $e) { + $this->Manager()->logWrite( + 'SearchFilters IMAP error in folder "' . $folder . + '" for search "' . $search . '": ' . $e->getMessage(), + LOG_ERR + ); + return []; + } + } + + private function applyActions( + \MailSo\Imap\ImapClient $imapClient, + array $filter, + array $uids, + string $folder + ) { + // Mark as read/seen + if (!empty($filter['fSeen'])) { + foreach ($uids as $uid) { + $oRange = new \MailSo\Imap\SequenceSet([$uid]); + $this->Manager()->Actions()->MailClient()->MessageSetFlag( + $folder, + $oRange, + \MailSo\Imap\Enumerations\MessageFlag::SEEN + ); + } + } + + // Flag/Star message + if (!empty($filter['fFlag'])) { + foreach ($uids as $uid) { + $oRange = new \MailSo\Imap\SequenceSet([$uid]); + $this->Manager()->Actions()->MailClient()->MessageSetFlag( + $folder, + $oRange, + \MailSo\Imap\Enumerations\MessageFlag::FLAGGED + ); + } + } + + // Move to folder + if (!empty($filter['fFolder']) && $filter['fFolder'] !== -1) { + foreach ($uids as $uid) { + $oRange = new \MailSo\Imap\SequenceSet([$uid]); + $imapClient->MessageMove('INBOX', $filter['fFolder'], $oRange); + } + } } public function GetFilters() @@ -173,7 +206,6 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin } else { $aSettings['SFilters'][$foundIndex] = $newFilter; } - return $this->jsonResponse(__FUNCTION__, $this->saveUserSettings($aSettings)); } diff --git a/snappymail/v/0.0.0/app/libraries/MailSo/Base/HtmlUtils.php b/snappymail/v/0.0.0/app/libraries/MailSo/Base/HtmlUtils.php index e073363b3..0300d6c48 100644 --- a/snappymail/v/0.0.0/app/libraries/MailSo/Base/HtmlUtils.php +++ b/snappymail/v/0.0.0/app/libraries/MailSo/Base/HtmlUtils.php @@ -160,13 +160,20 @@ abstract class HtmlUtils // Remove all remaining data-* attributes foreach ($xpath->query('//*[@*[starts-with(name(), "data-")]]') as $oElement) { - $sTagNameLower = \strtolower($oElement->nodeName); if ($oElement->hasAttributes()) { + // DOMNamedNodeMap is live: removing the attribute the foreach is + // currently on ends the iteration, so exactly one data-* was + // removed per element however many it carried. Collect the + // names first, then remove. + $aNames = []; foreach ($oElement->attributes as $oAttr) { if ('data-' === \substr(\strtolower($oAttr->nodeName), 0, 5)) { - $oElement->removeAttribute($oAttr->nodeName); + $aNames[] = $oAttr->nodeName; } } + foreach ($aNames as $sName) { + $oElement->removeAttribute($sName); + } } } diff --git a/snappymail/v/0.0.0/app/libraries/MailSo/Net/SSLContext.php b/snappymail/v/0.0.0/app/libraries/MailSo/Net/SSLContext.php index e34972061..efbb46d1b 100644 --- a/snappymail/v/0.0.0/app/libraries/MailSo/Net/SSLContext.php +++ b/snappymail/v/0.0.0/app/libraries/MailSo/Net/SSLContext.php @@ -39,7 +39,7 @@ class SSLContext implements \JsonSerializable public function __construct() { - $oConfig = \RainLoop\API::Config(); + $oConfig = \RainLoop\Api::Config(); $this->verify_peer = !!$oConfig->Get('ssl', 'verify_certificate', true); $this->verify_peer_name = !!$oConfig->Get('ssl', 'verify_certificate', true); $this->allow_self_signed = !!$oConfig->Get('ssl', 'allow_self_signed', false); diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Admin.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Admin.php index 970703a77..c2f4db681 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Admin.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Admin.php @@ -14,6 +14,15 @@ trait Admin public function IsAdminLoggined(bool $bThrowExceptionOnFalse = true) : bool { if ($this->Config()->Get('security', 'allow_admin_panel', true)) { + // [PATCH ronzz.org] OIDC bridge: nginx auth_request (webmail-admin.ronzz.org) + // sets X-NC-Admin after validating the Nextcloud session. Honored only on the + // dedicated admin host (admin_panel.host); the header is stripped/overridden + // at nginx for any other path. + if (!empty($_SERVER['HTTP_X_NC_ADMIN']) + && \strtolower((string) $this->Config()->Get('admin_panel', 'host', '')) === \strtolower($this->Http()->GetHost())) + { + return true; + } $sAdminKey = $this->getAdminAuthKey(); if ($sAdminKey && $this->Cacher(null, true)->Get(KeyPathHelper::SessionAdminKey($sAdminKey))) { return true; diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Messages.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Messages.php index 8b04bbdad..1153e9bee 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Messages.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Messages.php @@ -1255,6 +1255,7 @@ trait Messages $oPart->SubParts->append($oSignaturePart); } else { $sCertificate = $this->GetActionParam('signCertificate', ''); + $sCertificateChain = $this->GetActionParam('signCertificateChain', ''); $sPrivateKey = $this->GetActionParam('signPrivateKey', ''); if ('S/MIME' === $this->GetActionParam('sign', '')) { $sID = $this->GetActionParam('identityID', ''); @@ -1263,6 +1264,7 @@ trait Messages && ($oIdentity->Id() === $sID || $oIdentity->Email() === $oFrom->GetEmail()) ) { $sCertificate = $oIdentity->smimeCertificate; + $sCertificateChain = $oIdentity->smimeCertificateChain; $sPrivateKey = $oIdentity->smimeKey; break; } @@ -1303,6 +1305,7 @@ trait Messages $SMIME = $this->SMIME(); $SMIME->setCertificate($sCertificate); + $SMIME->setCertificateChain($sCertificateChain); $SMIME->setPrivateKey($sPrivateKey, $oPassphrase); $sSignature = $SMIME->sign($tmp, $detached); diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Pgp.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Pgp.php index 939610695..a87b6d1da 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Pgp.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Pgp.php @@ -287,6 +287,14 @@ trait Pgp return $this->DefaultResponse(($key && $keyId && Backup::PGPKey($key, $keyId))); } + public function DoDeletePGPKey(): array + { + $key = $this->GetActionParam('key', ''); + $keyId = $this->GetActionParam('keyId', ''); + $success = $key && Backup::PGPKey($key, $keyId, true); + return $this->DefaultResponse($success, ['code' => (int)!$success]); + } + /** * https://datatracker.ietf.org/doc/html/rfc3156#section-5 */ diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Model/Identity.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Model/Identity.php index f31e77a9c..414efae6b 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Model/Identity.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Model/Identity.php @@ -31,6 +31,7 @@ class Identity implements \JsonSerializable private ?SensitiveString $smimeKey = null; private string $smimeCertificate = ''; + private string $smimeCertificateChain = ''; function __construct(string $sId = '', string $sEmail = '') { @@ -114,6 +115,7 @@ class Identity implements \JsonSerializable $this->pgpSign = !empty($aData['pgpSign']); $this->smimeKey = new SensitiveString(isset($aData['smimeKey']) ? $aData['smimeKey'] : ''); $this->smimeCertificate = isset($aData['smimeCertificate']) ? $aData['smimeCertificate'] : ''; + $this->smimeCertificateChain = isset($aData['smimeCertificateChain']) ? $aData['smimeCertificateChain'] : ''; return true; } @@ -136,7 +138,8 @@ class Identity implements \JsonSerializable 'pgpEncrypt' => $this->pgpEncrypt, 'pgpSign' => $this->pgpSign, 'smimeKey' => (string) $this->smimeKey, - 'smimeCertificate' => $this->smimeCertificate + 'smimeCertificate' => $this->smimeCertificate, + 'smimeCertificateChain' => $this->smimeCertificateChain ); } @@ -158,6 +161,7 @@ class Identity implements \JsonSerializable 'pgpSign' => $this->pgpSign, 'smimeKey' => (string) $this->smimeKey, 'smimeCertificate' => $this->smimeCertificate, + 'smimeCertificateChain' => $this->smimeCertificateChain, 'exists' => $this->exists ); } diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Providers/AddressBook/PdoAddressBook.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Providers/AddressBook/PdoAddressBook.php index 155ea781d..4df85133a 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Providers/AddressBook/PdoAddressBook.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Providers/AddressBook/PdoAddressBook.php @@ -160,6 +160,31 @@ class PdoAddressBook $bReadWrite = $this->isDAVReadWrite(); + /** + * An empty list on either side is far more often a fault than a real + * "delete everything": a fresh or rebuilt local store, a DAV listing + * that failed to parse, a stale server-side index. Acting on it + * destroys data the other side still holds, so skip the deletions in + * that direction and let the following import/export reconcile. + */ + $iLocalLive = 0; + foreach ($aLocalSyncData as $aGuardData) { + if (empty($aGuardData['deleted'])) { + ++$iLocalLive; + } + } + $iRemoteCount = \count($aRemoteSyncData); + $bProtectRemote = (0 === $iLocalLive && 0 < $iRemoteCount); + $bProtectLocal = (0 === $iRemoteCount && 0 < $iLocalLive); + if ($bProtectRemote) { + \SnappyMail\Log::warning('PdoAddressBook', "Sync() local store is empty while remote holds" + . " {$iRemoteCount} contacts: importing only, no remote deletions"); + } + if ($bProtectLocal) { + \SnappyMail\Log::warning('PdoAddressBook', "Sync() remote listing is empty while local holds" + . " {$iLocalLive} contacts: keeping local, no local deletions"); + } + // Delete remote when Mode = read + write if ($bReadWrite) { \SnappyMail\Log::info('PdoAddressBook', 'Sync() is import and export'); @@ -168,7 +193,7 @@ class PdoAddressBook if ($aData['deleted']) { ++$iCount; unset($aLocalSyncData[$sKey]); - if (isset($aRemoteSyncData[$sKey], $aRemoteSyncData[$sKey]['vcf'])) { + if (!$bProtectRemote && isset($aRemoteSyncData[$sKey], $aRemoteSyncData[$sKey]['vcf'])) { \SnappyMail\HTTP\Stream::JSON(['messsage'=>"Delete remote {$sKey}"]); $this->davClientRequest($oClient, 'DELETE', $sPath.$aRemoteSyncData[$sKey]['vcf']); } @@ -183,9 +208,11 @@ class PdoAddressBook // Delete local $aIdsForDeletion = array(); - foreach ($aLocalSyncData as $sKey => $aData) { - if (!empty($aData['etag']) && !isset($aRemoteSyncData[$sKey])) { - $aIdsForDeletion[] = $aData['id_contact']; + if (!$bProtectLocal) { + foreach ($aLocalSyncData as $sKey => $aData) { + if (!empty($aData['etag']) && !isset($aRemoteSyncData[$sKey])) { + $aIdsForDeletion[] = $aData['id_contact']; + } } } if (\count($aIdsForDeletion)) { @@ -723,14 +750,20 @@ class PdoAddressBook $aParams[':search_lower'] = array($sLowerSearch, \PDO::PARAM_STR); } - $oContact = null; - $iIdContact = 0; + // This query selects id_contact only, so it must not be handed to + // getContactsFromPDO(), which unconditionally reads id_contact_str, + // changed, etag and jcard from every row. Look the contact up by id + // instead: GetContactByID() selects those columns, so the Contact it + // returns carries a real etag and changed value rather than 0. + $oStmt = $this->prepareAndExecute($sSql, $aParams); + if ($oStmt) { + $aFetch = $oStmt->fetchAll(\PDO::FETCH_ASSOC); + if (\is_array($aFetch) && \count($aFetch) && !empty($aFetch[0]['id_contact'])) { + return $this->GetContactByID((int) $aFetch[0]['id_contact']); + } + } - $aContacts = $this->getContactsFromPDO( - $this->prepareAndExecute($sSql, $aParams) - ); - - return $aContacts ? $aContacts[0] : null; + return null; } /** @@ -997,7 +1030,7 @@ class PdoAddressBook $sEmail = \trim($oItem->GetEmail(true)); if (\strlen($sEmail)) { $aEmailsToUpdate[] = $sEmail; - return !\in_array($sEmail, $aExists); + return !\in_array(\mb_strtolower($sEmail), $aExists); } } return false; 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 e46b95375..372c0b462 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/ServiceActions.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/ServiceActions.php @@ -339,7 +339,7 @@ class ServiceActions $sMethodName = 'Raw'.$sAction; if (\method_exists($this->oActions, $sMethodName)) { \header('X-Raw-Action: '.$sMethodName); - \header('Content-Security-Policy: script-src \'none\'; child-src \'none\''); + \header('Content-Security-Policy: script-src \'none\'; child-src \'none\'; object-src \'none\''); $sRawError = ''; $this->oActions->SetActionParams(array( diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/pgp/backup.php b/snappymail/v/0.0.0/app/libraries/snappymail/pgp/backup.php index 18b18cb1f..f16acbd67 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/pgp/backup.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/pgp/backup.php @@ -9,7 +9,7 @@ namespace SnappyMail\PGP; class Backup { - public static function PGPKey(string $key, string $keyId = '') : bool + public static function PGPKey(string $key, string $keyId = '', $doDelete = false) : bool { $oActions = \RainLoop\Api::Actions(); $oAccount = $oActions->getMainAccountFromToken(); @@ -21,15 +21,23 @@ class Backup true ); if (\str_contains($key, 'PGP PRIVATE KEY')) { + $fileName = "{$dir}{$keyId}.key"; + if ($doDelete) { + return !file_exists($fileName) || unlink($fileName); + } $hash = $oAccount->CryptKey(); $key = \SnappyMail\Crypt::Encrypt($key, $hash); $key[1] = \base64_encode($key[1]); $key[2] = \base64_encode($key[2]); $key[3] = \hash_hmac('sha1', $key[2], $hash); - return !!\file_put_contents("{$dir}{$keyId}.key", \json_encode($key)); + return !!\file_put_contents($fileName, \json_encode($key)); } if (\str_contains($key, 'PGP PUBLIC KEY')) { - return !!\file_put_contents("{$dir}{$keyId}_public.asc", $key); + $fileName = "{$dir}{$keyId}_public.asc"; + if ($doDelete) { + return !file_exists($fileName) || unlink($fileName); + } + return !!\file_put_contents($fileName, $key); } } return false; diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/smime/openssl.php b/snappymail/v/0.0.0/app/libraries/snappymail/smime/openssl.php index 1bb35c2d9..3af3b7f25 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/smime/openssl.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/smime/openssl.php @@ -19,6 +19,7 @@ class OpenSSL // Used for sign and decrypt private $certificate; // OpenSSLCertificate|array|string private $privateKey; // OpenSSLAsymmetricKey|OpenSSLCertificate|array|string + private ?string $certificateChain = null; function __construct(string $homedir) { @@ -131,6 +132,15 @@ class OpenSSL } } + public function setCertificateChain(/*string*/$certificateChain) + { + if ($certificateChain === "") { + $this->certificateChain = null; + } else { + $this->certificateChain = $certificateChain; + } + } + public function setPrivateKey(/*OpenSSLAsymmetricKey|string*/$privateKey, ?\SnappyMail\SensitiveString $passphrase = null ) : void @@ -244,6 +254,13 @@ class OpenSSL } $input = $tmp; } + if (\is_string($this->certificateChain)) { + $tmp = new Temporary('smimechain-'); + if (!$tmp->putContents($this->certificateChain)) { + return null; + } + $certificateChain = $tmp; + } $output = new Temporary('smimeout-'); if (!\openssl_pkcs7_sign( $input->filename(), @@ -252,7 +269,7 @@ class OpenSSL $this->privateKey, $this->headers, $detached ? \PKCS7_DETACHED | \PKCS7_BINARY : 0, // | PKCS7_NOCERTS | PKCS7_NOATTR - $this->untrusted_certificates_filename + $certificateChain ?? null )) { throw new \RuntimeException('OpenSSL sign: ' . \openssl_error_string()); } diff --git a/snappymail/v/0.0.0/app/localization/de/user.json b/snappymail/v/0.0.0/app/localization/de/user.json index 204311cf2..6a3ff1a12 100644 --- a/snappymail/v/0.0.0/app/localization/de/user.json +++ b/snappymail/v/0.0.0/app/localization/de/user.json @@ -27,6 +27,8 @@ "USERNAME": "Nutzername", "PASSWORD": "Passwort", "REMEMBER": "Zugangsdaten merken", + "REMEMBER_FOR_SESSION": "Aktuelle Sitzung merken", + "REMEMBER_PERMANENT": "Dauerhaft merken", "REPLY_TO": "Antwort an", "SAVE": "Speichern", "SAVE_CHANGES": "Änderungen speichern?", @@ -331,6 +333,7 @@ "SMIME": { "POPUP_IMPORT_TITLE": "S\/MIME-Zertifikat importieren", "CERTIFICATE": "Zertifikat", + "CERTIFICATECHAIN": "Zwischenzertifikat(e)", "CERTIFICATES": "S\/MIME-Zertifikate", "SIGNED_MESSAGE": "S\/MIME-signierte Nachricht", "ENCRYPTED_MESSAGE": "S\/MIME-verschlüsselte Nachricht", @@ -458,7 +461,8 @@ "LEGEND_SECURITY": "Sicherheit", "LABEL_AUTOLOGOUT": "Automatische Abmeldung", "FORGET_KEY_PASS": "Passphrase für privaten Schlüssel vergessen", - "NEVER": "Nie" + "NEVER": "Nie", + "LABEL_STORED_PASS": "Passphrase gespeichert" }, "SETTINGS_GENERAL": { "LANGUAGE": "Sprache", diff --git a/snappymail/v/0.0.0/app/localization/en/user.json b/snappymail/v/0.0.0/app/localization/en/user.json index 26b8af858..7c53b9d39 100644 --- a/snappymail/v/0.0.0/app/localization/en/user.json +++ b/snappymail/v/0.0.0/app/localization/en/user.json @@ -27,6 +27,8 @@ "USERNAME": "Username", "PASSWORD": "Passphrase", "REMEMBER": "Remember", + "REMEMBER_FOR_SESSION": "Remember for this Session", + "REMEMBER_PERMANENT": "Remember Permanently", "REPLY_TO": "Reply-To", "SAVE": "Save", "SAVE_CHANGES": "Save changes?", @@ -331,6 +333,7 @@ "SMIME": { "POPUP_IMPORT_TITLE": "Import S\/MIME certificate", "CERTIFICATE": "Certificate", + "CERTIFICATECHAIN": "Intermediate Certificate(s)", "CERTIFICATES": "S\/MIME Certificates", "SIGNED_MESSAGE": "S\/MIME signed message", "ENCRYPTED_MESSAGE": "S\/MIME encrypted message", @@ -458,7 +461,8 @@ "LEGEND_SECURITY": "Security", "LABEL_AUTOLOGOUT": "Auto Logout", "FORGET_KEY_PASS": "Forget private key passphrase", - "NEVER": "Never" + "NEVER": "Never", + "LABEL_STORED_PASS": "Remembered Passphrase" }, "SETTINGS_GENERAL": { "LANGUAGE": "Language", 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 c1ea1e13e..54abcd1f4 100644 --- a/snappymail/v/0.0.0/app/localization/eu/user.json +++ b/snappymail/v/0.0.0/app/localization/eu/user.json @@ -11,9 +11,9 @@ "CLOSE": "Itxi", "CONTACTS": "Kontaktuak", "DELETE": "Ezabatu", - "UNDELETE": "Undelete", + "UNDELETE": "Berreskuratu", "DONE": "Eginda", - "EMAIL": "Email", + "EMAIL": "E-mail", "FROM": "Nork", "LOADING": "Kargatzen", "LOGOUT": "Saioa itxi", @@ -22,11 +22,11 @@ "COPY_TO": "Kopiatu hona", "NAME": "Izena", "NONE": "Bat ere ez", - "TO_SPAM": "Move message(s) to Spam", + "TO_SPAM": "Eraman mezua(k) Spam-era", "NOT_SPAM": "Ez da spama", "USERNAME": "Erabiltzaile-izena", "PASSWORD": "Pasahitza", - "REMEMBER": "Remember", + "REMEMBER": "Gogoratu", "REPLY_TO": "Erantzun", "SAVE": "Gorde", "SAVE_CHANGES": "Aldaketak gorde?", @@ -45,7 +45,7 @@ "ATTACHMENTS": "Eranskinak" }, "LOGIN": { - "LABEL_LOGIN": "Login", + "LABEL_LOGIN": "Hasi saioa", "LABEL_SIGN_ME": "Gogora nazazu", "BUTTON_SIGN_IN": "Sartu" }, @@ -60,12 +60,12 @@ "UNSEEN": "Irakurri gabe", "DATE": "Data", "DATE_ALL": "Denak", - "BEFORE_3_DAYS": "Older then 3 days", - "BEFORE_7_DAYS": "Older then 1 week", - "BEFORE_MONTH": "Older then 1 month", - "BEFORE_3_MONTHS": "Older then 3 months", - "BEFORE_6_MONTHS": "Older then 6 months", - "BEFORE_YEAR": "Older then 1 year", + "BEFORE_3_DAYS": "3 egun baino zaharragoa", + "BEFORE_7_DAYS": "Aste 1 baino zaharragoa", + "BEFORE_MONTH": "Hilabete 1 baino zaharragoa", + "BEFORE_3_MONTHS": "3 hilabete baino zaharragoa", + "BEFORE_6_MONTHS": "6 hilabete baino zaharragoa", + "BEFORE_YEAR": "Urte 1 baino zaharragoa", "SINCE_3_DAYS": "3 egun arterakoak", "SINCE_7_DAYS": "Astebetako arterakoak", "SINCE_MONTH": "Hilabete arterakoak", @@ -89,26 +89,26 @@ "ARCHIVE_NAME": "Artxiboa" }, "IMAP": { - "ACL_RIGHTS": "Rights", - "ACL_A": "Administer", - "ACL_E": "Expunge", - "ACL_I": "Insert messages", - "ACL_K": "Create sub-folders", - "ACL_L": "Show messages", - "ACL_P": "Send mail", - "ACL_R": "Select folder", - "ACL_S": "\\SEEN tag", - "ACL_T": "\\DELETED tag", - "ACL_W": "Other tags", - "ACL_X": "Delete\/Rename folder" + "ACL_RIGHTS": "Baimenak", + "ACL_A": "Administratu", + "ACL_E": "Ezabatu", + "ACL_I": "Txertatu mezuak", + "ACL_K": "Sortu azpi-karpetak", + "ACL_L": "Erakutsi mezuak", + "ACL_P": "Bidali posta", + "ACL_R": "Hautatu karpeta", + "ACL_S": "\\IKUSITAKO etiketa", + "ACL_T": "\\EZABATUTAKO etiketa", + "ACL_W": "Beste etiketak", + "ACL_X": "Ezabatu/Berrizendatu karpeta" }, "MESSAGE_LIST": { "BUTTON_RELOAD": "Eguneratu mezuen zerrenda", "BUTTON_EMPTY_FOLDER": "Hustu karpeta", - "BUTTON_MULTY_FORWARD": "Erantsi bezala bidali", - "BUTTON_DELETE_WITHOUT_MOVE": "Beteriako ezabatu", - "MENU_SET_SEEN": "Irakurri bezala markatu", - "MENU_SET_ALL_SEEN": "Dena irakurri bezala markatu", + "BUTTON_MULTY_FORWARD": "Bidali eranskin gisa", + "BUTTON_DELETE_WITHOUT_MOVE": "Ezabatu betirako", + "MENU_SET_SEEN": "Irakurritako gisa markatu", + "MENU_SET_ALL_SEEN": "Dena irakurritako gisa markatu", "MENU_UNSET_SEEN": "Irakurri gabe bezala markatu", "MENU_SET_FLAG": "Markatu", "MENU_UNSET_FLAG": "Desmarkatu", @@ -122,16 +122,16 @@ "EMPTY_LIST": "Hustu zerrenda.", "EMPTY_SEARCH_LIST": "Zure bilaketarekin bat datorren mezurik ez.", "SEARCH_RESULT_FOR": "\"%SEARCH%\"-erako bilaketa emaitzak", - "BACK_TO_MESSAGE_LIST": "itzuli mezuen zerrendara", + "BACK_TO_MESSAGE_LIST": "Itzuli mezuen zerrendara", "PUT_MESSAGE_HERE": "Bota mezua hemen zerrendan ikusteko", "TODAY_AT": "gaur %TIME%-etan", "YESTERDAY_AT": "atzo %TIME%-etan", "NEW_MESSAGE_NOTIFICATION": "%COUNT% mezu berri dituzu!", "QUOTA_SIZE": "%SIZE% (%PROC%%)<\/strong> erabiliz zure %LIMIT%<\/strong>-tik", "SORT": "Ordenatu", - "NO_SORT": "Mail server does not support sorting", - "SORT_ARRIVAL_ASC": "Arrived ascending", - "SORT_ARRIVAL_DESC": "Arrived descending", + "NO_SORT": "Posta zerbitzariak ez du ordenatzen", + "SORT_ARRIVAL_ASC": "Zaharrenetik berrienera", + "SORT_ARRIVAL_DESC": "Berrienetik zaharrenera", "SORT_DATE_ASC": "Data goraka", "SORT_DATE_DESC": "Data beheraka", "SORT_SIZE_ASC": "Tamaina goraka", @@ -160,7 +160,7 @@ "MENU_VIEW_ORIGINAL": "Erakutsi jatorrizkoa", "HTML_VIEW": "Ikusi HTML mezua", "PLAIN_VIEW": "Ikusi testu soil mezua", - "SWAP_COLORS": "Swap default (background)color", + "SWAP_COLORS": "Aldatu atzeko planoaren kolorea", "MENU_DOWNLOAD_ORIGINAL": "Deskargatu .eml fitxategi bezala", "MENU_FILTER_SIMILAR": "Iragazi hau bezalako mezuak", "MENU_PRINT": "Inprimatu", @@ -172,12 +172,12 @@ "MESSAGE_VIEW_DESC": "Aukeratu zerrendako edozein mezua hemen ikusteko.", "MESSAGE_VIEW_MOVE_DESC": "Klikatu karpetaren izena ezkerreko panelean helburua aukeratzeko.", "LINK_DOWNLOAD_AS_ZIP": "Deskargatu zip gisa", - "SPAM_SCORE": "Spam Puntuzioa", + "SPAM_SCORE": "Spam puntuzioa", "HAS_VIRUS_WARNING": "KONTUZ: virusa detektatu da", "TAGS": "Etiketak", "NEW_TAG": "Etiketa berria", - "ENABLE_TRACKING_LINKS": "Enable tracking links", - "TRACKING_ENABLED": "Links are now tracked when clicked!" + "ENABLE_TRACKING_LINKS": "Gaitu jarraipen-estekak", + "TRACKING_ENABLED": "Estekak orain jarraipena dute klikatzean!" }, "MESSAGE_TAGS": { "$important": "Garrantzitsua", @@ -221,14 +221,14 @@ "DISPLAY_NAME": "Erakusteko izena", "LAST_NAME": "Abizenak", "FIRST_NAME": "Izena", - "MIDDLE_NAME": "Middle name", - "NAME_PREFIX": "Name prefix", - "NAME_SUFFIX": "Name suffix", + "MIDDLE_NAME": "Bigarren izena", + "NAME_PREFIX": "Aurrizkia", + "NAME_SUFFIX": "Atzizkia", "NICK_NAME": "Ezizena", "LABEL_READ_ONLY": "Irakurtzeko bakarrik", "ADD_MENU_LABEL": "Gehitu", "ADD_MENU_NICKNAME": "Ezizena", - "ADD_MENU_NOTE": "Ezizena", + "ADD_MENU_NOTE": "Iruzkina", "ADD_MENU_PHONE": "Telefonoa", "ADD_MENU_URL": "URL", "ADD_MENU_ADDRESS": "Helbidea", @@ -236,13 +236,13 @@ "ADD_MENU_TAGS": "Etiketak", "BUTTON_SHARE_ALL": "Denak", "BUTTON_SYNC": "Sinkronizazioa (CardDAV)", - "SEND_TO_ALL_CONTACT_EMAILS": "Send to all contact emails" + "SEND_TO_ALL_CONTACT_EMAILS": "Bidali kontaktu guztiei" }, "COMPOSE": { - "LINK_SHOW_INPUTS": "erakutsi eremu guztiak", + "LINK_SHOW_INPUTS": "Erakutsi eremu guztiak", "BUTTON_SEND": "Bidali", "BUTTON_MINIMIZE": "Minimizatu", - "SAVED_TIME": "%TIME%-n gordea", + "SAVED_TIME": "%TIME%-etan gordea", "SAVED_ERROR_ON_SEND": "Mezua bidali da baina ez da gorde bidalitako elementuen karpetan", "DISCARD_UNSAVED_DATA": "Baztertu gorde gabeko datuak?", "ATTACH_FILES": "Erantsi fitxategiak", @@ -250,20 +250,20 @@ "REPLY_MESSAGE_TITLE": "%DATETIME%, %EMAIL% idatzia", "FORWARD_MESSAGE_TOP_TITLE": "-------- Birbidalitako mezua -------", "FORWARD_MESSAGE_TOP_SENT": "Bidalita", - "EMPTY_TO_ERROR_DESC": "Mesedez, gehitu gutxienez jasotzaile bat", + "EMPTY_TO_ERROR_DESC": "Gehitu jasotzaile bat gutxienez", "NO_ATTACHMENTS_HERE_DESC": "Ez dago eranskinik hemen.", "ATTACHMENTS_ERROR_DESC": "Kontuz! Eranskin guztiak ez dira kargatu.", "ATTACHMENTS_UPLOAD_ERROR_DESC": "Eranskin guztiak ez dira kargatu oraindik.", "ALLOW_SPELLCHECK": "Gaitu nabigatzaileko hizkuntza-zuzentzailea", - "ALLOW_DRAFT_AUTOSAVE": "Automatikoi gorde zirriborroa", + "ALLOW_DRAFT_AUTOSAVE": "Automatikoki gorde zirriborroa", "BUTTON_REQUEST_READ_RECEIPT": "Eskatu irakurketa-agiria", - "BUTTON_MARK_AS_IMPORTANT": "MArkatu garrantzitsu bezala", + "BUTTON_MARK_AS_IMPORTANT": "Markatu garrantzitsu gisa", "BUTTON_REQUEST_DSN": "Eskatu jasotze-agiria", - "BUTTON_REQUIRE_TLS": "Require secure delivery", - "ERROR_EMPTY_BODY": "Message body is empty" + "BUTTON_REQUIRE_TLS": "Eskatu entrega segurua", + "ERROR_EMPTY_BODY": "Mezua hutsik dago" }, "POPUPS_ASK": { - "EXIT_ARE_YOU_SURE": "Are you sure you want to exit?", + "EXIT_ARE_YOU_SURE": "Ziur zaude irten nahi duzula?", "DESC_WANT_CLOSE_THIS_WINDOW": "Ziur zaude leiho hau itxi nahi duzula?", "DESC_WANT_DELETE_MESSAGES": "Ziur zaude mezua(k) ezabatu nahi d(it)uzula?" }, @@ -271,20 +271,20 @@ "TITLE_LANGUAGES": "Aukeratu zure hizkuntza" }, "POPUPS_ADD_ACCOUNT": { - "TITLE_ADD_ACCOUNT": "Kontua gehitu", + "TITLE_ADD_ACCOUNT": "Gehitu kontua", "BUTTON_ADD_ACCOUNT": "Gehitu", - "TITLE_UPDATE_ACCOUNT": "Kontua eguneratu" + "TITLE_UPDATE_ACCOUNT": "Eguneratu kontua" }, "POPUPS_IDENTITY": { - "TITLE_ADD_IDENTITY": "Nortasuna gehitu", - "TITLE_UPDATE_IDENTITY": "Nortasuna eguneratu", - "LABEL": "Label", + "TITLE_ADD_IDENTITY": "Gehitu nortasuna", + "TITLE_UPDATE_IDENTITY": "Eguneratu nortasuna", + "LABEL": "Etiketa", "BUTTON_ADD_IDENTITY": "Gehitu", - "SIGNATURE": "Signature", - "LABEL_SIGNATURE_INSERT_BEFORE": "Txertatu sinadura hau erantzuteko testuaren aurretik" + "SIGNATURE": "Sinadura", + "LABEL_SIGNATURE_INSERT_BEFORE": "Txertatu sinadura hau erantzunaren aurretik" }, "POPUPS_CREATE_FOLDER": { - "TITLE_CREATE_FOLDER": "Karpeta sortu", + "TITLE_CREATE_FOLDER": "Sortu karpeta", "LABEL_PARENT": "Karpeta gurasoa", "BUTTON_CREATE": "Sortu", "TITLE_CREATING_PROCESS": "Karpeta sortzen" @@ -303,13 +303,13 @@ "VERIFY": "Egiaztatu", "GOOD_SIGNATURE": "%USER%-en sinadura egokia", "ERROR": "%TYPE% errorea: %ERROR%", - "CREATE_SELF_SIGNED": "Create self-signed", - "VALID_UNTIL": "Valid until", - "PRIVATE_KEY": "Private key", - "CHANGE_PASS": "Change passphrase", - "CURRENT_PASS": "Current passphrase", - "NEW_PASS": "New passphrase", - "ASK_CRYPTKEY_PASS": "Your login passphrase has changed. Please fill in your old login password to decrypt data" + "CREATE_SELF_SIGNED": "Sortu sinadura automatikoa", + "VALID_UNTIL": "Noiz arte balio du", + "PRIVATE_KEY": "Gako pribatua", + "CHANGE_PASS": "Aldatu pasaesaldia", + "CURRENT_PASS": "Uneko pasaesaldia", + "NEW_PASS": "Pasaesaldi berria", + "ASK_CRYPTKEY_PASS": "Saioaren pasahitza aldatu egin da. Bete zure saio-hasierako pasahitz zaharrean deszifratzeko" }, "OPENPGP": { "POPUP_IMPORT_TITLE": "Inportatu OpenPGP gakoa", @@ -322,19 +322,19 @@ "SIGNED_MESSAGE": "OpenPGP sinatutako mezua", "ENCRYPTED_MESSAGE": "OpenPGP zifratutako mezua", "STORE_IN_GNUPG": "Zerbitzarian gorden GnuPG-n", - "STORE_PUBLIC_KEY_IN_GNUPG": "Store public key on server in GnuPG", - "STORE_PRIVATE_KEY_IN_GNUPG": "Store private key on server in GnuPG", - "BACKUP_ON_SERVER": "Zerbitzarian gorde (zifratuta)", - "BACKUP_PUBLIC_KEY_ON_SERVER": "Backup public key on server", - "BACKUP_PRIVATE_KEY_ON_SERVER": "Backup private key on server" + "STORE_PUBLIC_KEY_IN_GNUPG": "Gorde gako publikoa GnuPGko zerbitzarian", + "STORE_PRIVATE_KEY_IN_GNUPG": "Gorde gako pribatua GnuPGko zerbitzarian", + "BACKUP_ON_SERVER": "Gorde zerbitzarian (zifratuta)", + "BACKUP_PUBLIC_KEY_ON_SERVER": "Gorde gako publikoa zerbitzarian", + "BACKUP_PRIVATE_KEY_ON_SERVER": "Gorde gako pribatua zerbitzarian" }, "SMIME": { - "POPUP_IMPORT_TITLE": "Import S\/MIME certificate", - "CERTIFICATE": "Certificate", - "CERTIFICATES": "S\/MIME Certificates", + "POPUP_IMPORT_TITLE": "Inportatu S\/MIME ziurtagiria", + "CERTIFICATE": "Ziurtagiria", + "CERTIFICATES": "S\/MIME ziurtagiria", "SIGNED_MESSAGE": "S\/MIME sinatutako mezua", "ENCRYPTED_MESSAGE": "S\/MIME zifratutako mezua", - "PRIVATE_KEY_OF": "S\/MIME private key of %EMAIL%" + "PRIVATE_KEY_OF": "S\/MIME %EMAIL%-ren gako pribatua" }, "POPUPS_FILTER": { "TITLE_CREATE_FILTER": "Sortu iragazkia", @@ -362,13 +362,13 @@ "SELECT_TYPE_OVER": "Gainean", "SELECT_TYPE_UNDER": "Azpian", "SELECT_TYPE_TEXT": "Testua", - "SELECT_TYPE_RAW": "Raw", + "SELECT_TYPE_RAW": "Gordina", "SELECT_MATCH_ANY": "Bat etortzeak hurrengo EDOZEIN araurekin", "SELECT_MATCH_ALL": "Bat etortzeak hurrengo arau GUZTIEKIN", "MARK_AS_READ_LABEL": "Markatu irakurri bezala", "REPLY_INTERVAL_LABEL": "Erantzuteko tartea (egunak)", - "KEEP_LABEL": "Mantenu", - "STOP_LABEL": "Stop further processing of rules", + "KEEP_LABEL": "Mantenua", + "STOP_LABEL": "Utzi arauak gehiago izapidetzeari", "VACATION_SUBJECT_LABEL": "Gaia (hautazkoa)", "VACATION_MESSAGE_LABEL": "Mezua", "VACATION_RECIPIENTS_LABEL": "Hartzaileak (komaz bananduta)", @@ -376,8 +376,8 @@ "ALL_INCOMING_MESSAGES_DESC": "Iristen diren mezu guztiak" }, "POPUPS_SIEVE_SCRIPT": { - "TITLE_CREATE": "Sortu Scripta", - "TITLE_EDIT": "Editatu Scripta", + "TITLE_CREATE": "Sortu scripta", + "TITLE_EDIT": "Editatu scripta", "BUTTON_ADD_FILTER": "Gehitu iragazkia", "BUTTON_RAW_SCRIPT": "Erabili erabiltzailearen script pertsonal bat", "CAPABILITY_LABEL": "Gaitasunak", @@ -403,37 +403,37 @@ "ERROR_NO_FILE_UPLOADED": "Ez da fitxategirik kargatu", "ERROR_MISSING_TEMP_FOLDER": "Fitxategi tenporala falta da", "ERROR_ON_SAVING_FILE": "Fitxategi-kargako errore ezezagun bat gertatu da", - "ERROR_FILE_TYPE": "Ez baliozko fitxategi-mota", + "ERROR_FILE_TYPE": "Fitxategi-mota ez baliozkoa", "ERROR_UNKNOWN": "Fitxategi-kargako errore ezezagun bat gertatu da" }, "EDITOR": { "PLAIN": "HTML <-> TESTUA", "SOURCE": "HTML <-> JATORRIA", - "FONTFAMILY": "Font family", - "FONTSIZE": "Font size", - "DIR": "Text direction", - "DIR_LTR": "Left to right", - "DIR_RTL": "Right to left", - "DIR_AUTO": "Automatic", - "TEXTCOLOR": "Text color", - "BACKGROUNDCOLOR": "Background color", - "BOLD": "Bold", - "ITALIC": "Italic", - "UNDERLINE": "Underline", - "STRIKE": "Strikethrough", - "SUB": "Subscript", - "SUP": "Superscript", - "OL": "Ordered list", - "UL": "Unordered list", - "QUOTE": "Blockquote", - "INDENTDECREASE": "Decrease indent", - "INDENTINCREASE": "Increase indent", - "LINK": "Link", - "IMAGEURL": "Image URL", - "IMAGEUPLOAD": "Image select", - "UNDO": "Undo", - "REDO": "Redo", - "REMOVESTYLE": "Remove style" + "FONTFAMILY": "Letra mota", + "FONTSIZE": "Letra tamaina", + "DIR": "Testuaren noranzkoa", + "DIR_LTR": "Eskerretik eskuinera", + "DIR_RTL": "Eskuinetik eskerrera", + "DIR_AUTO": "Automatikoa", + "TEXTCOLOR": "Testuaren kolorea", + "BACKGROUNDCOLOR": "Atzeko planoaren kolorea", + "BOLD": "Lodia", + "ITALIC": "Etzana", + "UNDERLINE": "Azpimarratua", + "STRIKE": "Marratua", + "SUB": "Azpi-indizea", + "SUP": "Goi-indizea", + "OL": "Zerrenda ordenatua", + "UL": "Ordenik gabeko zerrenda", + "QUOTE": "Aipua", + "INDENTDECREASE": "Txikitu koska", + "INDENTINCREASE": "Handitu koska", + "LINK": "Esteka", + "IMAGEURL": "Irudiaren URLa", + "IMAGEUPLOAD": "Aukeratu irudia", + "UNDO": "Desegin", + "REDO": "Berregin", + "REMOVESTYLE": "Kendu estiloa" }, "SETTINGS_LABELS": { "GENERAL": "Orokorra", @@ -447,7 +447,7 @@ "SETTINGS_FILTERS": { "SIMPLE": "Arrunta", "ADVANCED": "Aurretatua", - "BUTTON_ADD_SCRIPT": "Gehitu Scripta", + "BUTTON_ADD_SCRIPT": "Gehitu scripta", "SUBNAME_MOVE_TO": "Mugitu \"%FOLDER%\"-era", "SUBNAME_FORWARD_TO": "Birbidali \"%EMAIL%\"-era", "SUBNAME_REJECT": "Errefusatu", @@ -457,18 +457,18 @@ "SETTINGS_SECURITY": { "LEGEND_SECURITY": "Segurtasuna", "LABEL_AUTOLOGOUT": "Saioa automatikoki itxi", - "FORGET_KEY_PASS": "Forget private key passphrase", - "NEVER": "Inoiz" + "FORGET_KEY_PASS": "Ahaztu gako pribatuaren pasaesaldia", + "NEVER": "Inoiz ez" }, "SETTINGS_GENERAL": { "LANGUAGE": "Hizkuntza", - "HOUR_FORMAT": "Hour format", + "HOUR_FORMAT": "Ordu-formatua", "IDENTITY": "Nortasuna", "LAYOUT": "Diseinua", "LAYOUT_NO_SPLIT": "Zatitu gabe", "LAYOUT_VERTICAL_SPLIT": "Zatiketa bertikala", "LAYOUT_HORIZONTAL_SPLIT": "Zatiketa horizontala", - "SHOW_NEXT_MESSAGE": "Show next message when (re)move current message", + "SHOW_NEXT_MESSAGE": "Erakutsi hurrengo mezua unekoa mugitu edo ezabatzean", "EDITOR": "Berezko testu editorea", "EDITOR_HTML": "Html", "EDITOR_PLAIN": "Formaturik gabea", @@ -477,36 +477,36 @@ "HIDE_DELETED_MESSAGES": "Ezkutatu ezabatzeko markatutako mezuak", "USE_CHECKBOXES_IN_LIST": "Bistaratu kontrol laukiak zerrendan", "USE_THREADS": "Erabili hariak", - "THREAD_ALGORITHM": "Threads algorithm", + "THREAD_ALGORITHM": "Harien algoritmoa", "REPLY_SAME_FOLDER": "Kokatu erantzunak erantzundako mezuaren karpeta berean", "PREFER_HTML": "Lehenetsi HTMLa testu arruntari", "PREFER_HTML_INFO": "Mezuak batzuetan bi formatuetan datoz. Aukera honekin HTML edo testu arrunt partea erakustea aukera dezakezu.", "REMOVE_COLORS": "Kendu atzealdeko irudia eta testuaren kolorean mezuaren gorputzetik", - "ALLOW_STYLES": "Allow