mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-10-24 22:25:09 +03:00
Compare commits
68 commits
6c9d3edc18
...
61ebcb6ed4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61ebcb6ed4 | ||
|
|
ceb9f68ded | ||
|
|
d7e7250b16 | ||
|
|
0a8e37ccc8 | ||
|
|
2cff632b2d | ||
|
|
2c658b7c31 | ||
|
|
4997853b78 | ||
|
|
6fba843d15 | ||
|
|
3659552fac | ||
|
|
3432795930 | ||
|
|
fb4a247916 | ||
|
|
a174bf7ba0 | ||
|
|
77be04d427 | ||
|
|
cf185b9ab5 | ||
|
|
30bb5c934d | ||
|
|
b9397e6e20 | ||
|
|
fd6beedc0c | ||
|
|
45b94153c1 | ||
|
|
02e96a9339 | ||
|
|
b3edb5fb0a | ||
|
|
58d553b1a2 | ||
|
|
2f26779f4a | ||
|
|
9f0e6d946a | ||
|
|
85b142f559 | ||
|
|
1be5abd122 | ||
|
|
a9ca71bae1 | ||
|
|
0805f71461 | ||
|
|
abb83e48b0 | ||
|
|
f52249b77b | ||
|
|
1bbd7f635a | ||
|
|
8a8304e81a | ||
|
|
e7a491b0f5 | ||
|
|
5dca6d4f65 | ||
|
|
2af0af173e | ||
|
|
7fdc77ce5f | ||
|
|
86dc155d1c | ||
|
|
6ef8a9a391 | ||
|
|
08ddc323f9 | ||
|
|
279cc6406e | ||
|
|
3dd6c85c87 | ||
|
|
e4ecda0e61 | ||
|
|
640c567210 | ||
|
|
8a2d8e86a5 | ||
|
|
6a7cbe070c | ||
|
|
1a0c9872b2 | ||
|
|
863f719da3 | ||
|
|
68588287ef | ||
|
|
60ee74c68a | ||
|
|
ed35c8e91e | ||
|
|
f757d58534 | ||
|
|
c1f1faca58 | ||
|
|
dc0191aaf1 | ||
|
|
a8117a9d03 | ||
|
|
f2648a54c0 | ||
|
|
1dc26f11d2 | ||
|
|
c203d15b17 | ||
|
|
2c54ac9be3 | ||
|
|
2c73a955e2 | ||
|
|
658b97f5b4 | ||
|
|
56409dcbb4 | ||
|
|
a9b89fd7b3 | ||
|
|
e37546b795 | ||
|
|
ed4b2dd4e1 | ||
|
|
fad9d5e482 | ||
|
|
dddf360db6 | ||
|
|
5973d71e2f | ||
|
|
486acde2e3 | ||
|
|
c0348893af |
48 changed files with 1581 additions and 644 deletions
|
|
@ -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; \
|
||||
|
|
|
|||
|
|
@ -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>/$UPLOAD_MAX_SIZE/g" /usr/local/etc/php-fpm.d/php-fpm.conf /etc/nginx/nginx.conf
|
||||
sed -i "s/<MEMORY_LIMIT>/$MEMORY_LIMIT/g" /usr/local/etc/php-fpm.d/php-fpm.conf
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
4
dev/External/ko.js
vendored
4
dev/External/ko.js
vendored
|
|
@ -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] });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:<base64>
|
||||
$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')) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ class LDAPLoginMappingPlugin extends AbstractPlugin
|
|||
{
|
||||
const
|
||||
NAME = 'LDAP login mapping',
|
||||
VERSION = '2.3',
|
||||
VERSION = '2.4',
|
||||
AUTHOR = 'RainLoop Team, Ludovic Pouzenc<ludovic@pouzenc.fr>, ZephOne<zephone@protonmail.com>',
|
||||
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");
|
||||
|
|
|
|||
20
plugins/login-http/LICENSE
Normal file
20
plugins/login-http/LICENSE
Normal file
|
|
@ -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.
|
||||
39
plugins/login-http/index.php
Normal file
39
plugins/login-http/index.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
class LoginHttpPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||
{
|
||||
const
|
||||
NAME = 'Login HTTP',
|
||||
AUTHOR = 'Alterative Editions',
|
||||
URL = 'https://alterative.fr/',
|
||||
VERSION = '1.0',
|
||||
RELEASE = '2026-02-09',
|
||||
REQUIRED = '2.21.0',
|
||||
CATEGORY = 'Login',
|
||||
LICENSE = 'MIT',
|
||||
DESCRIPTION = 'Automatic HTTP Login : uses PHP_AUTH_USER & PHP_AUTH_PW for IMAP connection';
|
||||
|
||||
public function Init() : void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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('<button type="button">Outlook</button>'),
|
||||
div = Element.fromHTML('<div class="controls"></div>');
|
||||
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('<button type="button">Outlook</button>'),
|
||||
div = Element.fromHTML('<div class="controls"></div>');
|
||||
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(
|
||||
'<button type="button" class="btn plugin-o365-add-account" style="margin-left: 6px;">Outlook</button>'
|
||||
);
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
<?php
|
||||
/**
|
||||
* Microsoft requires an Azure account that has an active subscription
|
||||
* I'm not going to pay, so feel free to fix this code yourself.
|
||||
* https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
|
||||
* https://answers.microsoft.com/en-us/msoffice/forum/all/configuration-for-imap-pop-and-smtp-with-oauth-in/3db47d43-25ac-4e0b-b957-22585e6caf15
|
||||
*
|
||||
* https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps
|
||||
*
|
||||
* SnappyMail login-o365 plugin
|
||||
* You need to register an app in Azure portal and add
|
||||
* a secret, redirect URIs and the following API permissions:
|
||||
* https://outlook.office.com/IMAP.AccessAsUser.All
|
||||
* https://outlook.office.com/SMTP.Send
|
||||
* openid offline_access email profile
|
||||
* https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris
|
||||
* Azure: redirect_uri=https://{DOMAIN}/?LoginO365
|
||||
* Personal: redirect_uri=https://{DOMAIN}/LoginO365
|
||||
* Query: redirect_uri=https://{DOMAIN}/?LoginO365
|
||||
* Path: redirect_uri=https://{DOMAIN}/LoginO365
|
||||
*
|
||||
* If running behind nginx reverse proxy you might
|
||||
* need to add the following to your nginx config:
|
||||
* location = /LoginO365 {
|
||||
* return 302 /?LoginO365&$args;
|
||||
* }
|
||||
*/
|
||||
|
||||
use RainLoop\Model\MainAccount;
|
||||
|
|
@ -17,232 +22,674 @@ use RainLoop\Providers\Storage\Enumerations\StorageType;
|
|||
|
||||
class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
|
||||
{
|
||||
const
|
||||
NAME = 'Office365/Outlook OAuth2',
|
||||
VERSION = '0.3',
|
||||
RELEASE = '2024-09-29',
|
||||
REQUIRED = '2.36.1',
|
||||
CATEGORY = 'Login',
|
||||
DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2';
|
||||
const
|
||||
NAME = 'Office365/Outlook OAuth2',
|
||||
VERSION = '0.4',
|
||||
RELEASE = '2025-12-22',
|
||||
REQUIRED = '2.36.1',
|
||||
CATEGORY = 'Login',
|
||||
DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2';
|
||||
|
||||
// https://login.microsoftonline.com/{{tenant}}/v2.0/.well-known/openid-configuration
|
||||
const
|
||||
LOGIN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/auth',
|
||||
TOKEN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token';
|
||||
// v2 endpoints
|
||||
const
|
||||
AUTH_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/authorize',
|
||||
TOKEN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token';
|
||||
|
||||
private static ?array $auth = null;
|
||||
/**
|
||||
* In-request cache of decrypted token bundles, keyed by lowercase email.
|
||||
* This avoids re-decrypting the same blob multiple times during a single request.
|
||||
*
|
||||
* Shape:
|
||||
* [
|
||||
* 'user@outlook.com' => ['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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
# <doc/wiki/Authentication.MasterUsers.txt>
|
||||
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "<strong>%SIZE% (%PROC%%)<\/strong> erabiliz zure <strong>%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 <style> CSS",
|
||||
"ALLOW_STYLES": "Gaitu <style> CSS",
|
||||
"MESSAGE_PER_PAGE": "Orrialdeko mezuak",
|
||||
"CHECK_MAIL_INTERVAL": "Check mail interval",
|
||||
"CHECK_MAIL_INTERVAL": "Posta begiratzeko tartea",
|
||||
"MARK_MESSAGE_READ_AFTER": "Markatu mezua irakurrita ondoren",
|
||||
"MSG_DEFAULT_ACTION": "Lehenetsitako ekintza",
|
||||
"SECONDS": "segundu",
|
||||
"MINUTES": "minutes",
|
||||
"SECONDS": "segundo",
|
||||
"MINUTES": "minutu",
|
||||
"NOTIFICATIONS": "Jakinarazpenak",
|
||||
"SOUND_NOTIFICATION": "Soinu-jakinarazpena",
|
||||
"CHROME_NOTIFICATION_DESC": "Erakutsi mezu berrien jakinarazpen popup-ak",
|
||||
"CHROME_NOTIFICATION_DESC_DENIED": "(Nabigatzaileak blokeatuta)",
|
||||
"COLLAPSE_BLOCKQUOTES": "Collapse quoted text",
|
||||
"MAX_QUOTE_LEVEL": "Maximum visible quoted text levels",
|
||||
"COLLAPSE_BLOCKQUOTES": "Bildu aipuko testua",
|
||||
"MAX_QUOTE_LEVEL": "Ageriko aipamen-maila maximoak",
|
||||
"MIN_FONT_SIZE": "Minimum Font Size",
|
||||
"LIST_INLINE_ATTACHMENTS": "Erakutsi sareko eranskinak zerrendan",
|
||||
"LIST_SIMPLE_ATTACHMENTS": "Eranskinen zerrenda arrunta",
|
||||
"LIST_MESSAGES_GROUPED": "List messages grouped (when sorting by date or from)",
|
||||
"EXTERNAL_IMAGES": "External images",
|
||||
"IMAGES_OPTION_ASK": "Always ask",
|
||||
"IMAGES_OPTION_WHITELIST": "Whitelist or ask",
|
||||
"IMAGES_OPTION_ALWAYS": "Show always",
|
||||
"IMAGES_WHITELIST": "Images whitelist",
|
||||
"MESSAGE_POPUP_WINDOW": "Popup in new window instead of tab",
|
||||
"MAILTO": "Register as 'mailto:' links handler",
|
||||
"HTML_TO_MD": "Convert HTML to Markdown instead of plain text"
|
||||
"LIST_MESSAGES_GROUPED": "Zerrendatu mezuak taldekatuak (ordena jatorria edo data denean)",
|
||||
"EXTERNAL_IMAGES": "Kanpoko irudiak",
|
||||
"IMAGES_OPTION_ASK": "Galdetu beti",
|
||||
"IMAGES_OPTION_WHITELIST": "Zerrenda zuria edo galdetu",
|
||||
"IMAGES_OPTION_ALWAYS": "Erakutsi bete",
|
||||
"IMAGES_WHITELIST": "Irudien zerrenda zuria",
|
||||
"MESSAGE_POPUP_WINDOW": "Agertu popup-ean fitxa berrian ordez",
|
||||
"MAILTO": "Erregistratu 'mailto:' esteken kudeatzaile gisa",
|
||||
"HTML_TO_MD": "Bihurtu HTML Markdown-era formaturik gabeko testuraren ordez"
|
||||
},
|
||||
"SETTINGS_CONTACTS": {
|
||||
"LABEL_CONTACTS_AUTOSAVE": "Automatikoki gehitu hartzaileak helbide-liburura",
|
||||
|
|
@ -558,12 +558,12 @@
|
|||
"BUTTON_ADD_ACCOUNT": "Gehitu kontu bat",
|
||||
"BUTTON_ADD_IDENTITY": "Gehitu identitate bat",
|
||||
"LOADING_PROCESS": "Eguneratzen...",
|
||||
"SHOW_UNREAD": "Show unread messages count"
|
||||
"SHOW_UNREAD": "Erakutsi irakurri gabeko mezuen kopurua"
|
||||
},
|
||||
"SETTINGS_OPENPGP": {
|
||||
"BUTTON_IMPORT_KEY": "Inportatu Gakoa",
|
||||
"BUTTON_GENERATE_KEY_PAIR": "Sortu Gako Parea",
|
||||
"IMPORT_FROM_SERVER": "Import from server",
|
||||
"IMPORT_FROM_SERVER": "Inportatu zerbitzaritik",
|
||||
"TITLE_PRIVATE": "Pribatua",
|
||||
"TITLE_PUBLIC": "Publikoa",
|
||||
"GET_MAILVELOPE": "Lortu Mailvelope nabigatzaileko gehigarria."
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@
|
|||
"USERNAME": "Gebruikersnaam",
|
||||
"PASSWORD": "Wachtwoord",
|
||||
"REMEMBER": "Onthouden",
|
||||
"REMEMBER_FOR_SESSION": "Onthoud voor deze sessie",
|
||||
"REMEMBER_PERMANENT": "Onthoud permanent",
|
||||
"REPLY_TO": "Antwoordadres",
|
||||
"SAVE": "Opslaan",
|
||||
"SAVE_CHANGES": "Wijzigingen opslaan?",
|
||||
|
|
@ -458,7 +460,8 @@
|
|||
"LEGEND_SECURITY": "Beveiliging",
|
||||
"LABEL_AUTOLOGOUT": "Automatisch uitloggen",
|
||||
"FORGET_KEY_PASS": "Vergeet privésleutel wachtwoord",
|
||||
"NEVER": "Nooit"
|
||||
"NEVER": "Nooit",
|
||||
"LABEL_STORED_PASS": "Wachtwoordzin opgeslagen"
|
||||
},
|
||||
"SETTINGS_GENERAL": {
|
||||
"LANGUAGE": "Taal",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@
|
|||
"USERNAME": "Имя пользователя",
|
||||
"PASSWORD": "Пароль",
|
||||
"REMEMBER": "Запомнить",
|
||||
"REMEMBER_FOR_SESSION": "Запомнить Временное",
|
||||
"REMEMBER_PERMANENT": "Запомнить Навсегда",
|
||||
"REPLY_TO": "Ответить-на",
|
||||
"SAVE": "Сохранить",
|
||||
"SAVE_CHANGES": "Сохранить изменения?",
|
||||
|
|
@ -458,7 +460,8 @@
|
|||
"LEGEND_SECURITY": "Безопасность",
|
||||
"LABEL_AUTOLOGOUT": "Автоматический выход",
|
||||
"FORGET_KEY_PASS": "Забыть парольную фразу закрытого ключа",
|
||||
"NEVER": "Никогда"
|
||||
"NEVER": "Никогда",
|
||||
"LABEL_STORED_PASS": "Remembered Passphrase"
|
||||
},
|
||||
"SETTINGS_GENERAL": {
|
||||
"LANGUAGE": "Язык",
|
||||
|
|
|
|||
|
|
@ -9,13 +9,23 @@
|
|||
<label data-i18n="GLOBAL/PASSWORD"></label>
|
||||
<input type="password" data-bind="value: passphrase">
|
||||
</div>
|
||||
<div data-bind="visible: askRemeber, component: {
|
||||
<div class="control-group" style="display: inline-flex; align-items: start;flex-direction: column;">
|
||||
<div data-bind="visible: askRemeber, component: {
|
||||
name: 'Checkbox',
|
||||
params: {
|
||||
label: 'GLOBAL/REMEMBER',
|
||||
value: remember
|
||||
label: 'GLOBAL/REMEMBER_FOR_SESSION',
|
||||
value: remember,
|
||||
enable: !rememberPermanent()
|
||||
}
|
||||
}"></div>
|
||||
<div data-bind="visible: askRememberPermanent, component: {
|
||||
name: 'Checkbox',
|
||||
params: {
|
||||
label: 'GLOBAL/REMEMBER_PERMANENT',
|
||||
value: rememberPermanent
|
||||
}
|
||||
}"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer>
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@
|
|||
<label data-i18n="SMIME/CERTIFICATE"></label>
|
||||
<textarea name="smimeCertificate" class="input-xxlarge" rows="14" autofocus="" autocomplete="off" data-bind="value: smimeCertificate"></textarea>
|
||||
</div>
|
||||
<div class="control-group" data-bind="css: {'error': smimeCertificateChain() && !smimeCertificateChainValid()}">
|
||||
<label data-i18n="SMIME/CERTIFICATECHAIN"></label>
|
||||
<textarea name="smimeCertificateChain" class="input-xxlarge" rows="14" autofocus="" autocomplete="off" data-bind="value: smimeCertificateChain"></textarea>
|
||||
</div>
|
||||
<div class="control-group" data-bind="hidden:smimeCertificate">
|
||||
<label></label>
|
||||
<button type="button" data-bind="click: $root.createSelfSigned" data-i18n="CRYPTO/CREATE_SELF_SIGNED"></button>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,20 @@
|
|||
<span class="delete-key fontastic" data-bind="visible: !askDelete(), click: openForDeletion">🗑</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-bind="visible: hasStoredPass">
|
||||
<td>
|
||||
<span class="fontastic" data-i18n="[title]SETTINGS_OPENPGP/TITLE_PRIVATE">🔒</span>
|
||||
<span class="key-id" data-bind="text: id"></span>
|
||||
<span>🔑</span><span class="key-user" data-i18n="SETTINGS_SECURITY/LABEL_STORED_PASS"></span>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-small btn-danger button-confirm-delete" data-bind="css: {'delete-access': askForgetPass()}, click: forgetPass"
|
||||
data-i18n="GLOBAL/ARE_YOU_SURE"></a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-key fontastic" data-bind="visible: !askForgetPass(), click: openForPassForget">🗑</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody><tr><th colspan="4" data-i18n="SETTINGS_OPENPGP/TITLE_PUBLIC">Public keys</th></tr></tbody>
|
||||
<tbody data-bind="foreach: gnupgPublicKeys, i18nUpdate: gnupgPublicKeys">
|
||||
|
|
@ -105,6 +119,20 @@
|
|||
<span class="delete-key fontastic" data-bind="visible: !askDelete(), click: openForDeletion">🗑</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-bind="visible: hasStoredPass">
|
||||
<td>
|
||||
<span class="fontastic" data-i18n="[title]SETTINGS_OPENPGP/TITLE_PRIVATE">🔒</span>
|
||||
<span class="key-id" data-bind="text: id"></span>
|
||||
<span>🔑</span><span class="key-user" data-i18n="SETTINGS_SECURITY/LABEL_STORED_PASS"></span>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-small btn-danger button-confirm-delete" data-bind="css: {'delete-access': askForgetPass()}, click: forgetPass"
|
||||
data-i18n="GLOBAL/ARE_YOU_SURE"></a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-key fontastic" data-bind="visible: !askForgetPass(), click: openForPassForget">🗑</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody><tr><th colspan="4" data-i18n="SETTINGS_OPENPGP/TITLE_PUBLIC">Public keys</th></tr></tbody>
|
||||
<tbody data-bind="foreach: openpgpkeysPublic, i18nUpdate: openpgpkeysPublic">
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ function getImapClient(int $host)
|
|||
|
||||
$oImapClient = new \MailSo\Imap\ImapClient;
|
||||
// $oAccount = new \RainLoop\Model\Account;
|
||||
$oImapClient->SetLogger(\RainLoop\API::Logger());
|
||||
$oImapClient->SetLogger(\RainLoop\Api::Logger());
|
||||
// $oPlugins->RunHook('imap.before-connect', array($oAccount, $oImapClient, $ImapSettings));
|
||||
$oImapClient->Connect($ImapSettings);
|
||||
// $oPlugins->RunHook('imap.after-connect', array($oAccount, $oImapClient, $ImapSettings));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue