mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-10-24 22:25:09 +03:00
Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
208f1c7298 |
244 changed files with 6057 additions and 8646 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM alpine:3.24.3 AS builder
|
||||
RUN apk add --no-cache php85 php85-json php-phar php-zip
|
||||
FROM alpine:3.18.5 AS builder
|
||||
RUN apk add --no-cache php82 php82-json php-phar php-zip
|
||||
RUN apk add --no-cache npm
|
||||
RUN npm install -g gulp yarn
|
||||
WORKDIR /source
|
||||
|
|
@ -23,7 +23,7 @@ RUN set -eux; \
|
|||
rm -v /snappymail/README.md /snappymail/_include.php
|
||||
|
||||
# Inspired by the original Rainloop dockerfile from youtous on GitLab
|
||||
FROM php:8.5-fpm-alpine AS final
|
||||
FROM php:8.2-fpm-alpine AS final
|
||||
|
||||
LABEL org.label-schema.description="SnappyMail webmail client image using nginx, php-fpm on Alpine"
|
||||
|
||||
|
|
@ -99,6 +99,9 @@ RUN set -eux; \
|
|||
# mysql
|
||||
RUN docker-php-ext-install pdo_mysql
|
||||
|
||||
# opcache
|
||||
RUN docker-php-ext-install opcache
|
||||
|
||||
# postgres
|
||||
RUN set -eux; \
|
||||
apk add --no-cache postgresql-libs; \
|
||||
|
|
@ -119,13 +122,6 @@ 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,12 +9,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ if (isset($options['plugins'])) {
|
|||
require(ROOT_DIR . '/build/plugins.php');
|
||||
}
|
||||
|
||||
$gulp = trim(shell_exec('which gulp'));
|
||||
$gulp = trim(`which gulp`);
|
||||
if (!$gulp) {
|
||||
exit('gulp not installed, run as root: npm install --global gulp-cli');
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ $options['aur'] = isset($options['aur']);
|
|||
|
||||
// Docker build
|
||||
$options['docker'] = isset($options['docker']);
|
||||
if ($options['docker'] && !($docker = trim(shell_exec('which docker')))) {
|
||||
if ($options['docker'] && !($docker = trim(`which docker`))) {
|
||||
exit('docker not found');
|
||||
}
|
||||
if ($options['docker'] && $options['aur']) {
|
||||
|
|
@ -60,7 +60,7 @@ if (!isset($options['skip-gulp'])) {
|
|||
|
||||
$cmddir = escapeshellcmd(ROOT_DIR) . '/snappymail/v/0.0.0/static';
|
||||
|
||||
if ($gzip = trim(shell_exec('which gzip'))) {
|
||||
if ($gzip = trim(`which gzip`)) {
|
||||
echo "\x1b[33;1m === Gzip *.js and *.css === \x1b[0m\n";
|
||||
passthru("{$gzip} -k --best {$cmddir}/js/*.js");
|
||||
passthru("{$gzip} -k --best {$cmddir}/js/min/*.js");
|
||||
|
|
@ -70,7 +70,7 @@ if (!isset($options['skip-gulp'])) {
|
|||
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/min/boot.min.js.gz');
|
||||
}
|
||||
|
||||
if ($brotli = trim(shell_exec('which brotli'))) {
|
||||
if ($brotli = trim(`which brotli`)) {
|
||||
echo "\x1b[33;1m === Brotli *.js and *.css === \x1b[0m\n";
|
||||
passthru("{$brotli} -k --best {$cmddir}/js/*.js");
|
||||
passthru("{$brotli} -k --best {$cmddir}/js/min/*.js");
|
||||
|
|
@ -188,7 +188,7 @@ file_put_contents("{$destPath}core.json", '{
|
|||
// Arch User Repository
|
||||
if ($options['aur']) {
|
||||
// extension_loaded('blake2')
|
||||
if (!function_exists('b2sum') && $b2sum = trim(shell_exec('which b2sum'))) {
|
||||
if (!function_exists('b2sum') && $b2sum = trim(`which b2sum`)) {
|
||||
function b2sum($file) {
|
||||
$file = escapeshellarg($file);
|
||||
exec("b2sum --binary {$file} 2>&1", $output, $exitcode);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'External/User/ko';
|
||||
|
||||
import { SMAudio } from 'Common/Audio';
|
||||
import { arrayLength } from 'Common/Utils';
|
||||
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser';
|
||||
|
||||
import {
|
||||
|
|
@ -241,15 +240,7 @@ export class AppUser extends AbstractApp {
|
|||
|
||||
showMessageComposer(params = [])
|
||||
{
|
||||
let msg = params[1];
|
||||
if (1 == arrayLength(msg)) {
|
||||
msg = msg[0];
|
||||
}
|
||||
if (msg) {
|
||||
msg.decrypt().then((/*success*/)=>showScreenPopup(ComposePopupView, params));
|
||||
} else {
|
||||
showScreenPopup(ComposePopupView, params);
|
||||
}
|
||||
showScreenPopup(ComposePopupView, params);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -260,8 +251,7 @@ AskPopupView.password = function(sAskDesc, btnText, ask) {
|
|||
view => resolve({
|
||||
password:view.passphrase(),
|
||||
username:/*ask & 2 ? */view.username(),
|
||||
remember:/*ask & 4 ? */view.remember(),
|
||||
rememberPermanent: view.rememberPermanent(),
|
||||
remember:/*ask & 4 ? */view.remember()
|
||||
}),
|
||||
() => resolve(null),
|
||||
true,
|
||||
|
|
|
|||
|
|
@ -232,7 +232,6 @@ export const
|
|||
detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'),
|
||||
|
||||
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
|
||||
minFontSize = parseInt(SettingsUserStore.minFontSize()),
|
||||
|
||||
result = {
|
||||
hasExternals: false,
|
||||
|
|
@ -258,15 +257,6 @@ export const
|
|||
rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px'
|
||||
},
|
||||
allowedAttributes = [
|
||||
// SnappyMail
|
||||
// 'data-x-href-broken',
|
||||
// 'data-x-href-tracking',
|
||||
'data-x-src',
|
||||
// 'data-x-src-hidden',
|
||||
// 'data-x-src-tracking',
|
||||
// 'data-x-src-broken',
|
||||
// 'data-x-style-url',
|
||||
// 'data-x-style-broken-urls',
|
||||
// defaults
|
||||
'name',
|
||||
'dir', 'lang', 'style', 'title',
|
||||
|
|
@ -309,8 +299,6 @@ export const
|
|||
// Not supported by <template> element
|
||||
// .replace(/<!doctype[^>]*>/gi, '')
|
||||
// .replace(/<\?xml[^>]*\?>/gi, '')
|
||||
// https://github.com/the-djmaze/snappymail/issues/1860
|
||||
.replace(/<(\/?)x-html(\s[^>]*)?>/gi, '')
|
||||
.replace(/<(\/?)head(\s[^>]*)?>/gi, '')
|
||||
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
|
||||
// .replace(/<\/?(html|head)[^>]*>/gi, '')
|
||||
|
|
@ -505,12 +493,12 @@ export const
|
|||
let attachment;
|
||||
if (value.startsWith('cid:'))
|
||||
{
|
||||
setAttribute('data-x-src', value);
|
||||
value = value.slice(4);
|
||||
setAttribute('data-x-src-cid', value);
|
||||
attachment = findAttachmentByCid(value);
|
||||
if (attachment?.download) {
|
||||
oElement.src = attachment.linkPreview();
|
||||
// oElement.title || (oElement.title = '+attachment.fileName);
|
||||
oElement.title += ' ('+attachment.fileName+')';
|
||||
attachment.isInline(true);
|
||||
attachment.isLinked(true);
|
||||
}
|
||||
|
|
@ -626,13 +614,12 @@ export const
|
|||
if (urls_broken.length) {
|
||||
setAttribute('data-x-style-broken-urls', JSON.stringify(urls_broken));
|
||||
}
|
||||
|
||||
/*
|
||||
// https://github.com/the-djmaze/snappymail/issues/1082
|
||||
if (minFontSize > pInt(oStyle.fontSize)) {
|
||||
oStyle.fontSize = minFontSize + "px";
|
||||
// oStyle.removeProperty('font-size');
|
||||
if (11 > pInt(oStyle.fontSize)) {
|
||||
oStyle.removeProperty('font-size');
|
||||
}
|
||||
|
||||
*/
|
||||
// Removes background and color
|
||||
// Many e-mails incorrectly only define one, not both
|
||||
// And in dark theme mode this kills the readability
|
||||
|
|
|
|||
2
dev/External/User/ko.js
vendored
2
dev/External/User/ko.js
vendored
|
|
@ -97,7 +97,7 @@ Object.assign(ko.bindingHandlers, {
|
|||
|
||||
fValue.subscribe(fUpdateEditorValue);
|
||||
|
||||
// ko.addDisposeCallback(element, () => {
|
||||
// ko.utils.domNodeDisposal.addDisposeCallback(element, () => {
|
||||
// });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
dev/External/ko.js
vendored
7
dev/External/ko.js
vendored
|
|
@ -15,7 +15,6 @@ export const
|
|||
* based solely on the values of other observables in the application
|
||||
*/
|
||||
koComputable = fn => ko.computed(fn, {'pure':true}),
|
||||
// koObservable = value => ko.observable(value),
|
||||
|
||||
addObservablesTo = (target, observables) =>
|
||||
forEachObjectEntry(observables, (key, value) =>
|
||||
|
|
@ -31,7 +30,7 @@ export const
|
|||
|
||||
onEvent = (element, event, fn) => {
|
||||
element.addEventListener(event, fn);
|
||||
ko.addDisposeCallback(element, () => element.removeEventListener(event, fn));
|
||||
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener(event, fn));
|
||||
},
|
||||
|
||||
onKey = (key, element, fValueAccessor, fAllBindings, model) => {
|
||||
|
|
@ -176,6 +175,6 @@ ko.extenders.falseTimeout = (target, option) => {
|
|||
|
||||
// functions
|
||||
|
||||
ko.observable.fn.askDeleteHelper = function(prop='askDelete') {
|
||||
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, prop] });
|
||||
ko.observable.fn.askDeleteHelper = function() {
|
||||
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, 'askDelete'] });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -73,9 +73,4 @@ export class EmailModel extends AbstractModel {
|
|||
}
|
||||
return result || name;
|
||||
}
|
||||
|
||||
get domain() {
|
||||
return this.email.replace(/^.+@([^@]+)$/, '$1');
|
||||
// return this.email.slice(this.email.lastIndexOf('@'))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
//import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
import { addObservablesTo, addComputablesTo } from 'External/ko';
|
||||
|
||||
export class IdentityModel extends EmailModel /*AbstractModel*/ {
|
||||
export class IdentityModel extends AbstractModel {
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {string} email
|
||||
*/
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
addObservablesTo(this, {
|
||||
id: '',
|
||||
label: '',
|
||||
// email: '',
|
||||
// name: '',
|
||||
email: '',
|
||||
name: '',
|
||||
|
||||
replyTo: '',
|
||||
bcc: '',
|
||||
|
|
@ -24,7 +27,6 @@ export class IdentityModel extends EmailModel /*AbstractModel*/ {
|
|||
|
||||
smimeKey: '',
|
||||
smimeCertificate: '',
|
||||
smimeCertificateChain: '',
|
||||
|
||||
askDelete: false,
|
||||
|
||||
|
|
@ -34,18 +36,16 @@ 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()),
|
||||
smimeCertificateChainValid: () => !this.smimeCertificateChain()
|
||||
|| /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificateChain())
|
||||
smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate())
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
toString() {
|
||||
const name = this.name,
|
||||
email = this.email,
|
||||
formattedName() {
|
||||
const name = this.name(),
|
||||
email = this.email(),
|
||||
label = this.label();
|
||||
return (name ? `${name} ` : '') + `<${email}>` + (label ? ` (${label})` : '');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,10 +24,6 @@ import Remote from 'Remote/User/Fetch';
|
|||
|
||||
import { MimeToMessage } from 'Mime/Utils';
|
||||
|
||||
import { PgpUserStore } from 'Stores/User/Pgp';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
import { Passphrases } from 'Storage/Passphrases';
|
||||
|
||||
const
|
||||
PreviewHTML = `<html>
|
||||
<head>
|
||||
|
|
@ -602,140 +598,4 @@ export class MessageModel extends AbstractModel {
|
|||
return result.html || plainToHtml(this.plain());
|
||||
}
|
||||
|
||||
async decrypt() {
|
||||
const msg = this;
|
||||
if (msg.pgpEncrypted() && !msg.pgpDecrypted()) {
|
||||
return await msg.pgpDecrypt().then(()=>msg.pgpDecrypted());
|
||||
}
|
||||
if (msg.smimeEncrypted() && !msg.smimeDecrypted()) {
|
||||
return await msg.smimeDecrypt().then(()=>msg.smimeDecrypted());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/*
|
||||
async decrypted() {
|
||||
return msg.pgpDecrypted() || msg.smimeDecrypted());
|
||||
}
|
||||
*/
|
||||
async pgpDecrypt() {
|
||||
const oMessage = this,
|
||||
data = oMessage.pgpEncrypted();
|
||||
delete data.error;
|
||||
await PgpUserStore.decrypt(oMessage).then(result => {
|
||||
if (!result) {
|
||||
// TODO: translate
|
||||
throw Error('Decryption failed, canceled or not possible');
|
||||
}
|
||||
oMessage.pgpDecrypted(true);
|
||||
if (result.data) {
|
||||
MimeToMessage(result.data, oMessage);
|
||||
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
|
||||
if (result.signatures?.length) {
|
||||
oMessage.pgpSigned({
|
||||
signatures: result.signatures,
|
||||
success: !!result.signatures.length
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
data.error = e.message;
|
||||
})
|
||||
.finally(() => {
|
||||
oMessage.pgpEncrypted(data);
|
||||
});
|
||||
}
|
||||
|
||||
pgpVerify(/*self, event*/) {
|
||||
const oMessage = this;
|
||||
PgpUserStore.verify(oMessage).then(result => {
|
||||
if (result) {
|
||||
oMessage.pgpSigned(result);
|
||||
} else {
|
||||
alert('Verification failed or no valid public key found');
|
||||
}
|
||||
/*
|
||||
if (result?.success) {
|
||||
i18n('CRYPTO/GOOD_SIGNATURE', {
|
||||
USER: validKey.user + ' (' + validKey.id + ')'
|
||||
});
|
||||
message.getText()
|
||||
} else {
|
||||
const keyIds = arrayLength(signingKeyIds) ? signingKeyIds : null,
|
||||
additional = keyIds
|
||||
? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ')
|
||||
: '';
|
||||
|
||||
i18n('CRYPTO/ERROR', {
|
||||
TYPE: 'OpenPGP',
|
||||
ERROR: 'message'
|
||||
}) + (additional ? ' (' + additional + ')' : '');
|
||||
}
|
||||
*/
|
||||
});
|
||||
}
|
||||
|
||||
async smimeDecrypt() {
|
||||
const message = this;
|
||||
const addresses = message.from.concat(message.to, message.cc, message.bcc).map(item => item.email),
|
||||
identity = IdentityUserStore.find(item => addresses.includes(item.email)),
|
||||
data = message.smimeEncrypted(); // { partId: "1" }
|
||||
if (data && identity) {
|
||||
delete data.error;
|
||||
let pass, params = { ...data }; // clone
|
||||
params.folder = message.folder;
|
||||
params.uid = message.uid;
|
||||
// params.bodyPart = params.bodyPart?.raw;
|
||||
params.certificate = identity.smimeCertificate();
|
||||
params.privateKey = identity.smimeKey();
|
||||
if (identity.smimeKeyEncrypted()) {
|
||||
pass = await Passphrases.ask(identity,
|
||||
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email}),
|
||||
'CRYPTO/DECRYPT'
|
||||
);
|
||||
if (!pass) {
|
||||
return;
|
||||
}
|
||||
params.passphrase = pass?.password;
|
||||
}
|
||||
await Remote.post('SMimeDecryptMessage', null, params).then(response => {
|
||||
if (response?.Result?.data) {
|
||||
message.smimeDecrypted(true);
|
||||
MimeToMessage(response.Result.data, message);
|
||||
message.html() ? message.viewHtml() : message.viewPlain();
|
||||
pass && pass.remember && Passphrases.handle(identity, pass.password);
|
||||
if ('signed' in response.Result) {
|
||||
message.smimeSigned(response.Result.signed);
|
||||
}
|
||||
}
|
||||
}).catch(e => {
|
||||
data.error = e.message
|
||||
})
|
||||
.finally(() => {
|
||||
message.smimeEncrypted(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
smimeVerify(/*self, event*/) {
|
||||
const message = this,
|
||||
data = message.smimeSigned(); // { partId: "1", micAlg: "pgp-sha256" }
|
||||
if (data) {
|
||||
const params = { ...data }; // clone
|
||||
params.folder = message.folder;
|
||||
params.uid = message.uid;
|
||||
params.bodyPart = data.bodyPart?.raw;
|
||||
params.sigPart = data.sigPart?.bodyRaw;
|
||||
Remote.post('SMimeVerifyMessage', null, params).then(response => {
|
||||
if (response?.Result) {
|
||||
if (response.Result.body) {
|
||||
MimeToMessage(response.Result.body, message);
|
||||
message.html() ? message.viewHtml() : message.viewPlain();
|
||||
}
|
||||
data.success = response.Result.success;
|
||||
message.smimeSigned(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ export class AdminSettingsDomains /*extends AbstractViewSettings*/ {
|
|||
testUsername() {
|
||||
Remote.request('AdminDomainMatch',
|
||||
(iError, oData) => {
|
||||
if (oData?.Result?.name) {
|
||||
alert(`${oData.Result.email} matched domain: ${oData.Result.name}`);
|
||||
if (oData?.Result?.domain) {
|
||||
alert(`${oData.Result.email} matched domain: ${oData.Result.domain.name}`);
|
||||
} else {
|
||||
alert('No domain match');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import ko from 'ko';
|
|||
import { koComputable } from 'External/ko';
|
||||
|
||||
import { SettingsGet } from 'Common/Globals';
|
||||
import { i18n, translateTrigger, getErrorMessage } from 'Common/Translator';
|
||||
import { i18n, translateTrigger } from 'Common/Translator';
|
||||
import { ContactUserStore } from 'Stores/User/Contact';
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
|
|
@ -15,7 +15,6 @@ export class UserSettingsContacts /*extends AbstractViewSettings*/ {
|
|||
this.syncUrl = ContactUserStore.syncUrl;
|
||||
this.syncUser = ContactUserStore.syncUser;
|
||||
this.syncPass = ContactUserStore.syncPass;
|
||||
this.syncError = ko.observable('');
|
||||
|
||||
this.syncModeOptions = koComputable(() => {
|
||||
translateTrigger();
|
||||
|
|
@ -49,15 +48,4 @@ export class UserSettingsContacts /*extends AbstractViewSettings*/ {
|
|||
})
|
||||
);
|
||||
}
|
||||
|
||||
test() {
|
||||
this.syncError('');
|
||||
Remote.request('TestContactsSyncData', (iError, data) => {
|
||||
iError && this.syncError(data.messageAdditional || data.message || getErrorMessage(iError, data));
|
||||
}, {
|
||||
Url: ContactUserStore.syncUrl(),
|
||||
User: ContactUserStore.syncUser(),
|
||||
Password: ContactUserStore.syncPass()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,51 +44,6 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
this.desktopNotifications = NotificationUserStore.enabled;
|
||||
this.isDesktopNotificationAllowed = NotificationUserStore.allowed;
|
||||
|
||||
const i18nSort = s => i18n('MESSAGE_LIST/SORT_'+s);
|
||||
this.sortSupported = FolderUserStore.hasCapability('SORT');
|
||||
this.sortOptions = [
|
||||
{
|
||||
value: '',
|
||||
text: i18nSort('DATE_DESC')
|
||||
},
|
||||
{
|
||||
value: 'DATE',
|
||||
text: i18nSort('DATE_ASC')
|
||||
},
|
||||
{
|
||||
value: 'FROM',
|
||||
text: i18nSort('FROM_ASC')
|
||||
},
|
||||
{
|
||||
value: 'REVERSE FROM',
|
||||
text: i18nSort('FROM_DESC')
|
||||
},
|
||||
{
|
||||
value: 'REVERSE SIZE',
|
||||
text: i18nSort('SIZE_DESC')
|
||||
},
|
||||
{
|
||||
value: 'SIZE',
|
||||
text: i18nSort('SIZE_ASC')
|
||||
},
|
||||
{
|
||||
value: 'SUBJECT',
|
||||
text: i18nSort('SUBJECT_ASC')
|
||||
},
|
||||
{
|
||||
value: 'REVERSE SUBJECT',
|
||||
text: i18nSort('SUBJECT_DESC')
|
||||
},
|
||||
{
|
||||
value: 'REVERSE ARRIVAL',
|
||||
text: i18nSort('ARRIVAL_DESC')
|
||||
},
|
||||
{
|
||||
value: 'ARRIVAL',
|
||||
text: i18nSort('ARRIVAL_ASC')
|
||||
}
|
||||
];
|
||||
|
||||
this.threadsAllowed = AppUserStore.threadsAllowed;
|
||||
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
|
||||
this.threadAlgorithms = ko.observableArray();
|
||||
|
|
@ -96,11 +51,12 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
capa.startsWith('THREAD=') && this.threadAlgorithms.push(capa.slice(7))
|
||||
);
|
||||
this.threadAlgorithms.sort((a, b) => a.length - b.length);
|
||||
this.threadAlgorithm = SettingsUserStore.threadAlgorithm;
|
||||
|
||||
['defaultSort', 'useThreads', 'threadAlgorithm',
|
||||
['useThreads', 'threadAlgorithm',
|
||||
// These use addSetting()
|
||||
'layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
|
||||
'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel', 'minFontSize',
|
||||
'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel',
|
||||
// These are in addSettings()
|
||||
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
||||
'viewHTML', 'viewImages', 'viewImagesWhitelist', 'removeColors', 'allowStyles', 'allowDraftAutosave',
|
||||
|
|
@ -122,7 +78,7 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
|
||||
identityMainDesc: () => {
|
||||
const identity = IdentityUserStore.main();
|
||||
return identity ? identity.toString() : '---';
|
||||
return identity ? identity.formattedName() : '---';
|
||||
},
|
||||
|
||||
editorDefaultTypes: () => {
|
||||
|
|
@ -161,8 +117,6 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
this.addSetting('CheckMailInterval');
|
||||
this.addSetting('Layout');
|
||||
this.addSetting('MaxBlockquotesLevel');
|
||||
this.addSetting('minFontSize');
|
||||
this.addSetting('defaultSort');
|
||||
|
||||
this.addSettings([
|
||||
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
||||
|
|
|
|||
|
|
@ -1,155 +1,21 @@
|
|||
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) => {
|
||||
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;
|
||||
Passphrases.ask = async (key, sAskDesc, btnText) =>
|
||||
Passphrases.has(key)
|
||||
? {password:Passphrases.handle(key)/*, remember:false*/}
|
||||
: await AskPopupView.password(sAskDesc, btnText, 5);
|
||||
|
||||
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._deleteFromSession(key)).debounce(timeout * 60 * 1000);
|
||||
timeouts[key] = (()=>Passphrases.delete(key)).debounce(timeout * 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,13 +51,6 @@ 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',
|
||||
|
|
@ -76,7 +69,6 @@ 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, getNotification } from 'Common/Translator';
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
|
||||
const
|
||||
loaded = () => !!window.openpgp,
|
||||
|
|
@ -58,9 +58,10 @@ const
|
|||
*/
|
||||
publicKeysItem = 'openpgp-public-keys',
|
||||
privateKeysItem = 'openpgp-private-keys',
|
||||
storage = window.localStorage,
|
||||
loadOpenPgpKeys = async itemname => {
|
||||
let keys = [], key,
|
||||
armoredKeys = JSON.parse(localStorage.getItem(itemname)),
|
||||
armoredKeys = JSON.parse(storage.getItem(itemname)),
|
||||
i = arrayLength(armoredKeys);
|
||||
while (i--) try {
|
||||
key = await openpgp.readKey({armoredKey:armoredKeys[i]});
|
||||
|
|
@ -73,9 +74,9 @@ const
|
|||
storeOpenPgpKeys = (keys, section) => {
|
||||
let armoredKeys = keys.map(item => item.armor);
|
||||
if (armoredKeys.length) {
|
||||
localStorage.setItem(section, JSON.stringify(armoredKeys));
|
||||
storage.setItem(section, JSON.stringify(armoredKeys));
|
||||
} else {
|
||||
localStorage.removeItem(section);
|
||||
storage.removeItem(section);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -90,9 +91,6 @@ 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()
|
||||
}
|
||||
|
|
@ -118,29 +116,12 @@ 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;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { LayoutSideView, LayoutBottomView } from 'Common/EnumsUser';
|
|||
import { pInt } from 'Common/Utils';
|
||||
import { $htmlCL, SettingsGet, fireEvent } from 'Common/Globals';
|
||||
import { ThemeStore } from 'Stores/Theme';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
|
||||
export const SettingsUserStore = new class {
|
||||
constructor() {
|
||||
|
|
@ -23,7 +22,6 @@ export const SettingsUserStore = new class {
|
|||
allowStyles: 0,
|
||||
collapseBlockquotes: 1,
|
||||
maxBlockquotesLevel: 0,
|
||||
minFontSize: 0,
|
||||
listInlineAttachments: 0,
|
||||
simpleAttachmentsList: 0,
|
||||
useCheckboxesInList: 1,
|
||||
|
|
@ -31,7 +29,6 @@ export const SettingsUserStore = new class {
|
|||
showNextMessage: 0,
|
||||
allowDraftAutosave: 1,
|
||||
useThreads: 0,
|
||||
defaultSort: '',
|
||||
threadAlgorithm: '',
|
||||
replySameFolder: 0,
|
||||
hideUnsubscribed: 0,
|
||||
|
|
@ -99,7 +96,6 @@ export const SettingsUserStore = new class {
|
|||
'AllowStyles',
|
||||
'CollapseBlockquotes',
|
||||
'MaxBlockquotesLevel',
|
||||
'minFontSize',
|
||||
'ListInlineAttachments',
|
||||
'simpleAttachmentsList',
|
||||
'UseCheckboxesInList',
|
||||
|
|
@ -107,7 +103,6 @@ export const SettingsUserStore = new class {
|
|||
'showNextMessage',
|
||||
'AllowDraftAutosave',
|
||||
'useThreads',
|
||||
'defaultSort',
|
||||
'threadAlgorithm',
|
||||
'ReplySameFolder',
|
||||
'HideUnsubscribed',
|
||||
|
|
@ -166,7 +161,5 @@ export const SettingsUserStore = new class {
|
|||
self.messageReadDelay(pInt(SettingsGet('MessageReadDelay')));
|
||||
self.autoLogout(pInt(SettingsGet('AutoLogout')));
|
||||
self.keyPassForget(pInt(SettingsGet('keyPassForget')));
|
||||
|
||||
FolderUserStore.sortMode(self.defaultSort());
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#V-PopupsLanguages {
|
||||
|
||||
max-width: 720px;
|
||||
max-width: 710px;
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
|
|
|
|||
|
|
@ -125,9 +125,8 @@
|
|||
/* Right */
|
||||
|
||||
.right {
|
||||
border-left: 1px solid var(--border-color, #ddd);
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
border-left: 1px solid var(--border-color, #ddd);
|
||||
}
|
||||
|
||||
.b-view-content-toolbar {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
border-left: 3px solid transparent;
|
||||
color: var(--folders-color, #333);
|
||||
display: block;
|
||||
line-height: 2.2em;
|
||||
line-height: 34px;
|
||||
padding: 0 2em 0 @folderItemPadding;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
|
@ -151,7 +151,7 @@
|
|||
color: var(--unread-count-color, #fff);
|
||||
font-size: 80%;
|
||||
line-height: 1.5em;
|
||||
margin-top: 0.5em;
|
||||
margin-top: 7px;
|
||||
min-width: 1.7em;
|
||||
padding: 1px 4px;
|
||||
text-align: center;
|
||||
|
|
|
|||
|
|
@ -45,8 +45,4 @@
|
|||
content: ')';
|
||||
}
|
||||
|
||||
.identities-list tr + tr .identity-default {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,10 @@ export class AskPopupView extends AbstractViewPopup {
|
|||
askUsername: false,
|
||||
passphrase: '',
|
||||
askPass: false,
|
||||
remember: true, // remember for session
|
||||
askRemeber: false,
|
||||
rememberPermanent: false,
|
||||
askRememberPermanent: false,
|
||||
remember: true,
|
||||
askRemeber: false
|
||||
});
|
||||
|
||||
this.rememberPermanent.subscribe(value => value && this.remember(true));
|
||||
|
||||
this.fYesAction = null;
|
||||
this.fNoAction = null;
|
||||
|
||||
|
|
@ -52,12 +48,10 @@ export class AskPopupView extends AbstractViewPopup {
|
|||
this.askDesc(sAskDesc || '');
|
||||
this.askUsername(ask & 2);
|
||||
this.askPass(ask & 1);
|
||||
this.askRemeber(ask & 4); // 0b0100
|
||||
this.askRememberPermanent(ask & 0b1000)
|
||||
this.askRemeber(ask & 4);
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ import Remote from 'Remote/User/Fetch';
|
|||
|
||||
import { ComposeAttachmentModel } from 'Model/ComposeAttachment';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { IdentityModel } from 'Model/Identity';
|
||||
import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
|
||||
import { addressparser } from 'Mime/Address';
|
||||
|
||||
|
|
@ -93,7 +92,7 @@ const
|
|||
|
||||
findIdentity = addresses => {
|
||||
addresses = addresses.map(item => item.email);
|
||||
return IdentityUserStore.find(item => addresses.includes(item.email));
|
||||
return IdentityUserStore.find(item => addresses.includes(item.email()));
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -344,7 +343,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
IdentityUserStore.map(item => ({
|
||||
item: item,
|
||||
optValue: item.id(),
|
||||
optText: item
|
||||
optText: item.formattedName()
|
||||
})),
|
||||
|
||||
canBeSentOrSaved: () => !this.sending() && !this.saving()
|
||||
|
|
@ -359,7 +358,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
|
||||
currentIdentity: value => {
|
||||
if (value) {
|
||||
this.from(value.toString());
|
||||
this.from(value.formattedName());
|
||||
this.doEncrypt(value.pgpEncrypt() || SettingsUserStore.pgpEncrypt());
|
||||
this.doSign(value.pgpSign() || SettingsUserStore.pgpSign());
|
||||
}
|
||||
|
|
@ -455,7 +454,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
contactsCommand: self => self.allowContacts
|
||||
});
|
||||
|
||||
this.from(IdentityUserStore()[0].toString());
|
||||
this.from(IdentityUserStore()[0].formattedName());
|
||||
}
|
||||
|
||||
sentFolder()
|
||||
|
|
@ -863,7 +862,6 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
case ComposeType.Forward:
|
||||
case ComposeType.ForwardAsAttachment:
|
||||
identity = findIdentity(oLastMessage.to.concat(oLastMessage.cc, oLastMessage.bcc))
|
||||
|| findIdentity(oLastMessage.from)
|
||||
/* || findIdentity(oLastMessage.deliveredTo)*/;
|
||||
break;
|
||||
case ComposeType.Draft:
|
||||
|
|
@ -873,20 +871,10 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
// case ComposeType.Empty:
|
||||
}
|
||||
}
|
||||
// Set from custom email
|
||||
if (!identity
|
||||
&& oLastMessage && (ComposeType.Reply === options.mode || ComposeType.ReplyAll === options.mode)
|
||||
&& 1 === oLastMessage.to.length
|
||||
// && mEmail.includes(oLastMessage.to[0].domain)
|
||||
) {
|
||||
identity = new IdentityModel;
|
||||
identity.name = oLastMessage.to[0].name;
|
||||
identity.email = oLastMessage.to[0].email;
|
||||
}
|
||||
identity = identity || IdentityUserStore()[0];
|
||||
if (identity) {
|
||||
// excludeEmail.add(identity.email);
|
||||
excludeEmail[identity.email] = true;
|
||||
// excludeEmail.add(identity.email());
|
||||
excludeEmail[identity.email()] = true;
|
||||
}
|
||||
|
||||
if (arrayLength(options.to)) {
|
||||
|
|
@ -971,7 +959,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
// https://github.com/the-djmaze/snappymail/issues/491
|
||||
tpl.innerHTML = oLastMessage.bodyAsHTML();
|
||||
tpl.content.querySelectorAll('img').forEach(img => {
|
||||
img.src || img.dataset.xSrc || img.replaceWith(img.alt || img.title)
|
||||
img.src || img.dataset.xSrcCid || img.dataset.xSrc || img.replaceWith(img.alt || img.title)
|
||||
});
|
||||
sText = tpl.innerHTML.trim();
|
||||
|
||||
|
|
@ -1393,7 +1381,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
allRecipients() {
|
||||
return [
|
||||
// From/sender is also recipient (Sent mailbox)
|
||||
// this.currentIdentity().email,
|
||||
// this.currentIdentity().email(),
|
||||
this.from(),
|
||||
this.to(),
|
||||
this.cc(),
|
||||
|
|
@ -1413,8 +1401,7 @@ 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.smimeCertificateChainValid() && identity.email === email
|
||||
identity.smimeKeyValid() && identity.smimeCertificateValid() && identity.email() === email
|
||||
&& options.push(['S/MIME']);
|
||||
console.dir({signOptions: options});
|
||||
this.signOptions(options);
|
||||
|
|
@ -1433,7 +1420,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
|
||||
const count = recipients.length,
|
||||
identity = this.currentIdentity(),
|
||||
from = (identity.smimeKey() && identity.smimeCertificate()) ? identity.email : null;
|
||||
from = (identity.smimeKey() && identity.smimeCertificate()) ? identity.email() : null;
|
||||
count
|
||||
&& count === recipients.filter(email =>
|
||||
email == from
|
||||
|
|
@ -1510,21 +1497,12 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
**/
|
||||
linkedData: []
|
||||
},
|
||||
recipients = draft ? [identity.email] : this.allRecipients(),
|
||||
recipients = draft ? [identity.email()] : this.allRecipients(),
|
||||
signOptions = !draft && this.doSign() && this.signOptions(),
|
||||
encryptOptions = this.doEncrypt() && this.encryptOptions(),
|
||||
isHtml = this.oEditor.isHtml();
|
||||
|
||||
if (isHtml) {
|
||||
tpl.innerHTML = Text;
|
||||
tpl.content.querySelectorAll('img').forEach(img => {
|
||||
if (img.dataset.xSrc) {
|
||||
img.src = img.dataset.xSrc;
|
||||
img.removeAttribute('data-x-src')
|
||||
}
|
||||
});
|
||||
Text = tpl.innerHTML.trim();
|
||||
|
||||
do {
|
||||
l = Text.length;
|
||||
Text = Text
|
||||
|
|
@ -1594,7 +1572,6 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
*/
|
||||
break;
|
||||
} catch (e) {
|
||||
Passphrases.delete(signOptions[i][1])
|
||||
console.error(e);
|
||||
}
|
||||
} else if ('GnuPG' == signOptions[i][0]) {
|
||||
|
|
@ -1612,12 +1589,11 @@ 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()) {
|
||||
const pass = await Passphrases.ask(identity,
|
||||
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email}),
|
||||
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}),
|
||||
'CRYPTO/SIGN'
|
||||
);
|
||||
if (null != pass) {
|
||||
|
|
@ -1637,9 +1613,7 @@ 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, '')
|
||||
.replace(/\s+/g, '') // Single base64 string, let the server do compliance formatting
|
||||
keydata: v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '').trim()
|
||||
})
|
||||
);
|
||||
for (let i = 0; i < encryptOptions.length; ++i) {
|
||||
|
|
@ -1659,7 +1633,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
if ('S/MIME' == encryptOptions[i]) {
|
||||
params.encryptCertificates = [identity.smimeCertificate()];
|
||||
SMimeUserStore.forEach(certificate => {
|
||||
certificate.emailAddress != identity.email
|
||||
certificate.emailAddress != identity.email()
|
||||
&& recipients.includes(certificate.emailAddress)
|
||||
&& params.encryptCertificates.push(certificate.id)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ export class IdentityPopupView extends AbstractViewPopup {
|
|||
this.submitError(oData.message);
|
||||
}
|
||||
}, {
|
||||
name: identity.name,
|
||||
email: identity.email,
|
||||
name: identity.name(),
|
||||
email: identity.email(),
|
||||
privateKey: identity.smimeKey(),
|
||||
passphrase: result.password
|
||||
});
|
||||
|
|
|
|||
|
|
@ -105,9 +105,9 @@ export class OpenPgpGeneratePopupView extends AbstractViewPopup {
|
|||
}
|
||||
|
||||
onShow() {
|
||||
this.name(''/*IdentityUserStore()[0].name*/);
|
||||
this.name(''/*IdentityUserStore()[0].name()*/);
|
||||
this.password('');
|
||||
this.email(''/*IdentityUserStore()[0].email*/);
|
||||
this.email(''/*IdentityUserStore()[0].email()*/);
|
||||
this.emailError(false);
|
||||
this.submitError('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,19 +213,15 @@ export class MailMessageList extends AbstractViewRight {
|
|||
|
||||
sortText: () => {
|
||||
let mode = FolderUserStore.sortMode(),
|
||||
has = w => mode.includes(w),
|
||||
desc = '' === mode || has('REVERSE');
|
||||
desc = '' === mode || mode.includes('REVERSE');
|
||||
mode = mode.split(/\s+/);
|
||||
if (has('FROM')) {
|
||||
if (mode.includes('FROM')) {
|
||||
return '@' + (desc ? '⬆' : '⬇');
|
||||
}
|
||||
if (has('SUBJECT')) {
|
||||
if (mode.includes('SUBJECT')) {
|
||||
return '𝐒' + (desc ? '⬆' : '⬇');
|
||||
}
|
||||
if (has('SIZE')) {
|
||||
return '✉' + (desc ? '⬇' : '⬆');
|
||||
}
|
||||
return (has('ARRIVAL') ? '📨' : '📅') + (desc ? '⬇' : '⬆');
|
||||
return (mode.includes('SIZE') ? '✉' : '📅') + (desc ? '⬇' : '⬆');
|
||||
},
|
||||
|
||||
downloadAsZipAllowed: () => this.attachmentsActions.includes('zip')
|
||||
|
|
@ -303,14 +299,14 @@ export class MailMessageList extends AbstractViewRight {
|
|||
item => sFolder === item?.folder && iUid == item?.uid
|
||||
);
|
||||
|
||||
if ('INBOX' === sFolder && 'INBOX' !== FolderUserStore.currentFolderFullName()) {
|
||||
if ('INBOX' === sFolder) {
|
||||
hasher.setHash(mailBox(sFolder));
|
||||
}
|
||||
|
||||
if (message) {
|
||||
this.selector.selectMessageItem(message);
|
||||
} else {
|
||||
if ('INBOX' !== sFolder && sFolder !== FolderUserStore.currentFolderFullName()) {
|
||||
if ('INBOX' !== sFolder) {
|
||||
hasher.setHash(mailBox(sFolder));
|
||||
}
|
||||
if (sFolder && iUid) {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ import { showScreenPopup } from 'Knoin/Knoin';
|
|||
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
|
||||
import { GnuPGUserStore } from 'Stores/User/GnuPG';
|
||||
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
|
||||
import { Passphrases } from 'Storage/Passphrases';
|
||||
|
||||
const
|
||||
oMessageScrollerDom = () => elementById('messageItem') || {},
|
||||
|
|
@ -130,8 +133,6 @@ export class MailMessageView extends AbstractViewRight {
|
|||
// viewer
|
||||
viewFromShort: '',
|
||||
dkimData: ['none', '', ''],
|
||||
spfData: ['none', '', ''],
|
||||
dmarcData: ['none', '', ''],
|
||||
nowTracking: false
|
||||
});
|
||||
|
||||
|
|
@ -196,7 +197,7 @@ export class MailMessageView extends AbstractViewRight {
|
|||
dkimIcon: () => {
|
||||
switch (this.dkimData()[0]) {
|
||||
case 'none':
|
||||
return '🚫︎';
|
||||
return '';
|
||||
case 'pass':
|
||||
return '✔';
|
||||
default:
|
||||
|
|
@ -204,43 +205,12 @@ export class MailMessageView extends AbstractViewRight {
|
|||
}
|
||||
},
|
||||
dkimIconClass: () => 'pass' === this.dkimData()[0] ? 'iconcolor-green' : 'iconcolor-red',
|
||||
|
||||
dkimTitle:() => {
|
||||
const dkim = this.dkimData();
|
||||
return dkim[0] ? dkim[2] || 'DKIM: ' + dkim[0] : '';
|
||||
},
|
||||
|
||||
spfIcon: () => {
|
||||
switch (this.spfData()[0]) {
|
||||
case 'none':
|
||||
return '🚫︎';
|
||||
case 'pass':
|
||||
return '✔';
|
||||
default:
|
||||
return '✖';
|
||||
}
|
||||
},
|
||||
spfIconClass: () => 'pass' === this.spfData()[0] ? 'iconcolor-green' : 'iconcolor-red',
|
||||
spfTitle:() => {
|
||||
const spf = this.spfData();
|
||||
return spf[0] ? spf[2] || 'SPF: ' + spf[0] : '';
|
||||
},
|
||||
|
||||
dmarcIcon: () => {
|
||||
switch (this.dmarcData()[0]) {
|
||||
case 'none':
|
||||
return '🚫︎';
|
||||
case 'pass':
|
||||
return '✔';
|
||||
default:
|
||||
return '✖';
|
||||
}
|
||||
},
|
||||
dmarcIconClass: () => 'pass' === this.dmarcData()[0] ? 'iconcolor-green' : 'iconcolor-red',
|
||||
dmarcTitle:() => {
|
||||
const dmarc = this.dmarcData();
|
||||
return dmarc[0] ? dmarc[2] || 'DMARC: ' + dmarc[0] : '';
|
||||
},
|
||||
|
||||
showWhitelistOptions: () => 'match' === SettingsUserStore.viewImages(),
|
||||
|
||||
firstUnsubsribeLink: () => currentMessage()?.unsubsribeLinks()[0] || '',
|
||||
|
|
@ -263,8 +233,6 @@ export class MailMessageView extends AbstractViewRight {
|
|||
// TODO: make first param a user setting #683
|
||||
this.viewFromShort(message.from.toString(false, true));
|
||||
this.dkimData(message.dkim[0] || ['none', '', '']);
|
||||
this.spfData(message.spf[0] || ['none', '', '']);
|
||||
this.dmarcData(message.dmarc[0] || ['none', '', '']);
|
||||
this.nowTracking(false);
|
||||
} else {
|
||||
MessagelistUserStore.selectedMessage(null);
|
||||
|
|
@ -642,19 +610,125 @@ export class MailMessageView extends AbstractViewRight {
|
|||
}
|
||||
|
||||
pgpDecrypt() {
|
||||
currentMessage().decrypt();
|
||||
const oMessage = currentMessage(),
|
||||
data = oMessage.pgpEncrypted();
|
||||
delete data.error;
|
||||
PgpUserStore.decrypt(oMessage).then(result => {
|
||||
if (!result) {
|
||||
// TODO: translate
|
||||
throw Error('Decryption failed, canceled or not possible');
|
||||
}
|
||||
oMessage.pgpDecrypted(true);
|
||||
if (result.data) {
|
||||
MimeToMessage(result.data, oMessage);
|
||||
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
|
||||
if (result.signatures?.length) {
|
||||
oMessage.pgpSigned({
|
||||
signatures: result.signatures,
|
||||
success: !!result.signatures.length
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
data.error = e.message;
|
||||
})
|
||||
.finally(() => {
|
||||
oMessage.pgpEncrypted(data);
|
||||
});
|
||||
}
|
||||
|
||||
pgpVerify(/*self, event*/) {
|
||||
currentMessage().pgpVerify();
|
||||
const oMessage = currentMessage()/*, ctrl = event.target.closest('.openpgp-control')*/;
|
||||
PgpUserStore.verify(oMessage).then(result => {
|
||||
if (result) {
|
||||
oMessage.pgpSigned(result);
|
||||
} else {
|
||||
alert('Verification failed or no valid public key found');
|
||||
}
|
||||
/*
|
||||
if (result?.success) {
|
||||
i18n('CRYPTO/GOOD_SIGNATURE', {
|
||||
USER: validKey.user + ' (' + validKey.id + ')'
|
||||
});
|
||||
message.getText()
|
||||
} else {
|
||||
const keyIds = arrayLength(signingKeyIds) ? signingKeyIds : null,
|
||||
additional = keyIds
|
||||
? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ')
|
||||
: '';
|
||||
|
||||
i18n('CRYPTO/ERROR', {
|
||||
TYPE: 'OpenPGP',
|
||||
ERROR: 'message'
|
||||
}) + (additional ? ' (' + additional + ')' : '');
|
||||
}
|
||||
*/
|
||||
});
|
||||
}
|
||||
|
||||
async smimeDecrypt() {
|
||||
currentMessage().decrypt();
|
||||
const message = currentMessage();
|
||||
const addresses = message.from.concat(message.to, message.cc, message.bcc).map(item => item.email),
|
||||
identity = IdentityUserStore.find(item => addresses.includes(item.email())),
|
||||
data = message.smimeEncrypted(); // { partId: "1" }
|
||||
if (data && identity) {
|
||||
delete data.error;
|
||||
let pass, params = { ...data }; // clone
|
||||
params.folder = message.folder;
|
||||
params.uid = message.uid;
|
||||
// params.bodyPart = params.bodyPart?.raw;
|
||||
params.certificate = identity.smimeCertificate();
|
||||
params.privateKey = identity.smimeKey();
|
||||
if (identity.smimeKeyEncrypted()) {
|
||||
pass = await Passphrases.ask(identity,
|
||||
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}),
|
||||
'CRYPTO/DECRYPT'
|
||||
);
|
||||
if (!pass) {
|
||||
return;
|
||||
}
|
||||
params.passphrase = pass?.password;
|
||||
}
|
||||
Remote.post('SMimeDecryptMessage', null, params).then(response => {
|
||||
if (response?.Result?.data) {
|
||||
message.smimeDecrypted(true);
|
||||
MimeToMessage(response.Result.data, message);
|
||||
message.html() ? message.viewHtml() : message.viewPlain();
|
||||
pass && pass.remember && Passphrases.handle(identity, pass.password);
|
||||
if ('signed' in response.Result) {
|
||||
message.smimeSigned(response.Result.signed);
|
||||
}
|
||||
}
|
||||
}).catch(e => {
|
||||
data.error = e.message
|
||||
})
|
||||
.finally(() => {
|
||||
message.smimeEncrypted(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
smimeVerify(/*self, event*/) {
|
||||
currentMessage().smimeVerify();
|
||||
const message = currentMessage(),
|
||||
data = message.smimeSigned(); // { partId: "1", micAlg: "pgp-sha256" }
|
||||
if (data) {
|
||||
const params = { ...data }; // clone
|
||||
params.folder = message.folder;
|
||||
params.uid = message.uid;
|
||||
params.bodyPart = data.bodyPart?.raw;
|
||||
params.sigPart = data.sigPart?.bodyRaw;
|
||||
Remote.post('SMimeVerifyMessage', null, params).then(response => {
|
||||
if (response?.Result) {
|
||||
if (response.Result.body) {
|
||||
MimeToMessage(response.Result.body, message);
|
||||
message.html() ? message.viewHtml() : message.viewPlain();
|
||||
}
|
||||
data.success = response.Result.success;
|
||||
message.smimeSigned(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=db;port=5432;dbname=snappymail`
|
||||
- Data Source Name (DSN): `host=postgres;port=5432;dbname=snappymail`
|
||||
- User `snappymail`
|
||||
- Password `snappymail`
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
This app packages SnappyMail <upstream>2.38.2</upstream>.
|
||||
This app packages SnappyMail <upstream>2.38.2.1</upstream>.
|
||||
|
||||
SnappyMail is a simple, modern, lightweight & fast web-based email client.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ RUN mkdir -p /app/code
|
|||
WORKDIR /app/code
|
||||
|
||||
# If you change the extraction below, be sure to test on scaleway
|
||||
VERSION=2.38.2
|
||||
VERSION=2.38.2.1
|
||||
RUN wget https://github.com/the-djmaze/snappymail/releases/download/v${VERSION}/snappymail-${VERSION}.zip -O /tmp/snappymail.zip && \
|
||||
unzip /tmp/snappymail.zip -d /app/code && \
|
||||
rm /tmp/snappymail.zip && \
|
||||
|
|
|
|||
|
|
@ -63,9 +63,8 @@ There, click on the link to go to the SnappyMail admin panel.
|
|||
<lib>uuid</lib>
|
||||
<lib>xxtea</lib>
|
||||
<lib>zip</lib>
|
||||
<nextcloud min-version="20" max-version="34" />
|
||||
-->
|
||||
<nextcloud min-version="20" />
|
||||
<nextcloud min-version="20" max-version="30" />
|
||||
</dependencies>
|
||||
<settings>
|
||||
<admin>OCA\SnappyMail\Settings\AdminSettings</admin>
|
||||
|
|
|
|||
|
|
@ -176,8 +176,7 @@ class SnappyMailHelper
|
|||
// If OpenID Connect (OIDC) is enabled and used for login, use this.
|
||||
if (static::isOIDCLogin()) {
|
||||
$sEmail = $config->getUserValue($sUID, 'settings', 'email');
|
||||
$pwd = new \SnappyMail\SensitiveString("oidc_login|{$sUID}");
|
||||
return [$sUID, $sEmail, $pwd];
|
||||
return [$sUID, $sEmail, "oidc_login|{$sUID}"];
|
||||
}
|
||||
|
||||
// Only use the user's password in the current session if they have
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ return "SnappyMail Webmail is a browser-based multilingual IMAP client with an a
|
|||
# script_snappymail_versions()
|
||||
sub script_snappymail_versions
|
||||
{
|
||||
return ( "2.38.2" );
|
||||
return ( "2.38.2.1" );
|
||||
}
|
||||
|
||||
sub script_snappymail_version_desc
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"title": "SnappyMail",
|
||||
"description": "Simple, modern & fast web-based email client",
|
||||
"private": true,
|
||||
"version": "2.38.2",
|
||||
"version": "2.38.2.1",
|
||||
"homepage": "https://snappymail.eu",
|
||||
"author": {
|
||||
"name": "DJ Maze",
|
||||
|
|
@ -43,11 +43,10 @@
|
|||
"dependencies": {
|
||||
"@rollup/plugin-node-resolve": "^13.1.3",
|
||||
"@rollup/plugin-replace": "^3.0.1",
|
||||
"@rollup/plugin-terser": "^1.0.2",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"del": "^6.0.0",
|
||||
"eslint": "^7.32.0",
|
||||
"gulp": "^5.0.1",
|
||||
"gulp": "^5.0.0",
|
||||
"gulp-append-prepend": "^1.0.9",
|
||||
"gulp-cached": "^1.1.1",
|
||||
"gulp-clean-css": "^4.3.0",
|
||||
|
|
@ -65,6 +64,7 @@
|
|||
"gulp-terser": "^2.1.0",
|
||||
"rollup": "^2.56.3",
|
||||
"rollup-plugin-external-globals": "^0.6.1",
|
||||
"rollup-plugin-includepaths": "^0.2.4"
|
||||
"rollup-plugin-includepaths": "^0.2.4",
|
||||
"rollup-plugin-terser": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,10 +38,7 @@
|
|||
queue = [],
|
||||
avatars = new Map,
|
||||
ncAvatars = new Map,
|
||||
identicons = new Map,
|
||||
templateId = 'MailMessageView',
|
||||
b64 = data => btoa(unescape(encodeURIComponent(data))),
|
||||
b64url = data => b64(data).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
|
||||
getBimiSelector = msg => {
|
||||
// Get 's' value out of 'v=BIMI1; s=foo;'
|
||||
let bimiSelector = msg.headers().valueByName('BIMI-Selector');
|
||||
|
|
@ -49,21 +46,9 @@
|
|||
return bimiSelector || '';
|
||||
},
|
||||
getBimiId = msg => ('pass' == msg.from[0].dkimStatus ? 1 : 0) + '-' + getBimiSelector(msg),
|
||||
email = msg => msg.from[0].email.toLowerCase(),
|
||||
getAvatarUid = msg => `${getBimiId(msg)}/${email(msg)}`,
|
||||
getAvatar = msg => ncAvatars.get(email(msg)) || avatars.get(getAvatarUid(msg)),
|
||||
getAvatarUri = msg => {
|
||||
if ('remote' === msg.avatar) {
|
||||
msg.avatar = `?Avatar/${getBimiId(msg)}/${b64url(email(msg))}`;
|
||||
}
|
||||
/*
|
||||
else if (!msg.avatar.startsWith('data:')) {
|
||||
msg.avatar = null;
|
||||
}
|
||||
*/
|
||||
return msg.avatar;
|
||||
},
|
||||
|
||||
getAvatarUrl = msg => `?Avatar/${getBimiId(msg)}/${msg.avatar}`,
|
||||
getAvatarUid = msg => `${getBimiId(msg)}/${msg.from[0].email.toLowerCase()}`,
|
||||
getAvatar = msg => ncAvatars.get(msg.from[0].email.toLowerCase()) || avatars.get(getAvatarUid(msg)),
|
||||
hash = async txt => {
|
||||
if (/^[0-9a-f]{15,}$/i.test(txt)) {
|
||||
return txt;
|
||||
|
|
@ -79,56 +64,58 @@
|
|||
(from.name?.split(/[^\p{L}]+/gu) || []).reduce((a, s) => a + (s[0] || ''), '')
|
||||
.slice(0,2)
|
||||
.toUpperCase(),
|
||||
setIdenticon = (msg, fn, cb) => {
|
||||
const from = email(msg);
|
||||
if (identicons.get(from)) {
|
||||
fn(identicons.get(from));
|
||||
} else {
|
||||
hash(from).then(hash => {
|
||||
const uri = 'data:image/svg+xml;base64,' + b64(window.identiconSvg(
|
||||
hash,
|
||||
fromChars(msg.from[0]),
|
||||
window.getComputedStyle(getEl('rl-app'), null).getPropertyValue('font-family')
|
||||
));
|
||||
fn(uri);
|
||||
identicons.set(email(msg), uri);
|
||||
cb?.(uri);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setIdenticon = (from, fn) => hash(from.email).then(hash =>
|
||||
fn('data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(window.identiconSvg(
|
||||
hash,
|
||||
fromChars(from),
|
||||
window.getComputedStyle(getEl('rl-app'), null).getPropertyValue('font-family')
|
||||
)))))
|
||||
),
|
||||
addQueue = (msg, fn) => {
|
||||
if (msg.from?.[0]) {
|
||||
if (getAvatarUri(msg)) {
|
||||
if (rl.pluginSettingsGet('avatars', 'delay')) {
|
||||
setIdenticon(msg, fn, ()=>{
|
||||
queue.push([msg, fn]);
|
||||
runQueue();
|
||||
});
|
||||
} else {
|
||||
fn(msg.avatar);
|
||||
}
|
||||
} else {
|
||||
setIdenticon(msg, fn);
|
||||
}
|
||||
msg.from?.[0] && setIdenticon(msg.from[0], fn);
|
||||
if (rl.pluginSettingsGet('avatars', 'delay')) {
|
||||
queue.push([msg, fn]);
|
||||
runQueue();
|
||||
} else if (msg.avatar) {
|
||||
fn(getAvatarUrl(msg));
|
||||
}
|
||||
},
|
||||
runQueue = (() => {
|
||||
let item = queue.shift();
|
||||
while (item) {
|
||||
let url = getAvatar(item[0]);
|
||||
if (url) {
|
||||
item[1](url);
|
||||
item = queue.shift();
|
||||
continue;
|
||||
} else if (item[0].avatar) {
|
||||
item[1](item[0].avatar);
|
||||
if (item[0].from) {
|
||||
let url = getAvatar(item[0]),
|
||||
uid = getAvatarUid(item[0]);
|
||||
if (url) {
|
||||
item[1](url);
|
||||
item = queue.shift();
|
||||
continue;
|
||||
} else if (item[0].avatar) {
|
||||
item[1](getAvatarUrl(item[0]));
|
||||
} else if (!avatars.has(uid)) {
|
||||
let from = item[0].from[0];
|
||||
rl.pluginRemoteRequest((iError, data) => {
|
||||
if (!iError && data?.Result.type) {
|
||||
url = `data:${data.Result.type};base64,${data.Result.data}`;
|
||||
avatars.set(uid, url);
|
||||
item[1](url);
|
||||
} else {
|
||||
avatars.set(uid, '');
|
||||
}
|
||||
runQueue();
|
||||
}, 'Avatar', {
|
||||
bimi: 'pass' == from.dkimStatus ? 1 : 0,
|
||||
bimiSelector: getBimiSelector(item[0]),
|
||||
email: from.email
|
||||
});
|
||||
}
|
||||
}
|
||||
runQueue();
|
||||
break;
|
||||
}
|
||||
}).debounce(1000);
|
||||
|
||||
// addEventListener('DOMContentLoaded', () => {
|
||||
/**
|
||||
* Modify templates
|
||||
*/
|
||||
|
|
@ -201,19 +188,13 @@
|
|||
fn = url=>{element.src = url};
|
||||
element.onerror = ()=>{
|
||||
element.onerror = null;
|
||||
setIdenticon(msg, fn, uri=>avatars.set(getAvatarUid(msg), uri));
|
||||
setIdenticon(msg.from[0], fn);
|
||||
};
|
||||
if (url) {
|
||||
fn(url);
|
||||
} else if (msg.avatar?.startsWith('data:')) {
|
||||
fn(msg.avatar);
|
||||
} else {
|
||||
element.onload = ()=>{
|
||||
if (!element.src.startsWith('data:')) {
|
||||
element.onload = null;
|
||||
avatars.set(getAvatarUid(msg), element.src);
|
||||
}
|
||||
};
|
||||
addQueue(msg, fn);
|
||||
}
|
||||
}
|
||||
|
|
@ -240,8 +221,11 @@
|
|||
if (url) {
|
||||
fn(url);
|
||||
} else if (msg.avatar) {
|
||||
fn(getAvatarUri(msg));
|
||||
fn(msg.avatar.startsWith('data:') ? msg.avatar : getAvatarUrl(msg));
|
||||
} else {
|
||||
// let from = msg.from[0];
|
||||
// view.viewUserPic(`?Avatar/${'pass' == from.dkimStatus ? 1 : 0}/${encodeURIComponent(from.email)}`);
|
||||
// view.viewUserPicVisible(true);
|
||||
addQueue(msg, fn);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,12 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
NAME = 'Avatars',
|
||||
AUTHOR = 'SnappyMail',
|
||||
URL = 'https://snappymail.eu/',
|
||||
VERSION = '1.22',
|
||||
RELEASE = '2025-03-10',
|
||||
VERSION = '1.20',
|
||||
RELEASE = '2024-08-26',
|
||||
REQUIRED = '2.33.0',
|
||||
CATEGORY = 'Contacts',
|
||||
LICENSE = 'MIT',
|
||||
DESCRIPTION = 'Show graphic of sender in message and messages list (supports Contacts, BIMI, Gravatar, favicon and identicon)';
|
||||
DESCRIPTION = 'Show graphic of sender in message and messages list (supports BIMI, Gravatar, favicon and identicon, Contacts is still TODO)';
|
||||
|
||||
public function Init() : void
|
||||
{
|
||||
|
|
@ -79,8 +79,11 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
|| ($this->Config()->Get('plugin', 'bimi', false) && 'pass' == $mFrom['dkimStatus'])
|
||||
|| ($this->Config()->Get('plugin', 'favicon', false) && 'pass' == $mFrom['dkimStatus'])
|
||||
)
|
||||
) {
|
||||
return 'remote';
|
||||
) try {
|
||||
// Base64Url
|
||||
return \SnappyMail\Crypt::EncryptUrlSafe($mFrom['email']);
|
||||
} catch (\Throwable $e) {
|
||||
\SnappyMail\Log::error('Crypt', $e->getMessage());
|
||||
}
|
||||
if ('pass' == $mFrom['dkimStatus'] && $this->Config()->Get('plugin', 'service', true)) {
|
||||
// 'data:image/png;base64,[a-zA-Z0-9+/=]'
|
||||
|
|
@ -109,17 +112,17 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
}
|
||||
|
||||
/**
|
||||
* GET /?Avatar/${bimi}/Encoded(${from.email})
|
||||
* Nextcloud Mail uses insecure unencoded 'index.php/apps/mail/api/avatars/url/local%40example.com'
|
||||
* GET /?Avatar/${bimi}/Encrypted(${from.email})
|
||||
* Nextcloud Mail uses insecure unencrypted 'index.php/apps/mail/api/avatars/url/local%40example.com'
|
||||
*/
|
||||
// public function ServiceAvatar(...$aParts)
|
||||
public function ServiceAvatar(string $sServiceName, string $sBimi, string $sEncodedEmail)
|
||||
public function ServiceAvatar(string $sServiceName, string $sBimi, string $sEncryptedEmail)
|
||||
{
|
||||
$maxAge = 86400;
|
||||
$sEmail = \MailSo\Base\Utils::UrlSafeBase64Decode($sEncodedEmail);
|
||||
$sEmail = \SnappyMail\Crypt::DecryptUrlSafe($sEncryptedEmail);
|
||||
$aBimi = \explode('-', $sBimi, 2);
|
||||
$sBimiSelector = isset($aBimi[1]) ? $aBimi[1] : 'default';
|
||||
// $sEmail && \MailSo\Base\Http::setETag("{$sBimiSelector}-{$sEncodedEmail}");
|
||||
// $sEmail && \MailSo\Base\Http::setETag("{$sBimiSelector}-{$sEncryptedEmail}");
|
||||
if ($sEmail && ($aResult = $this->getAvatar($sEmail, !empty($aBimi[0]), $sBimiSelector))) {
|
||||
\header("Cache-Control: max-age={$maxAge}, private");
|
||||
\header('Expires: '.\gmdate('D, j M Y H:i:s', $maxAge + \time()).' UTC');
|
||||
|
|
@ -139,10 +142,6 @@ 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)
|
||||
|
|
@ -189,8 +188,11 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
return $aResult;
|
||||
}
|
||||
|
||||
private static function getServicePng(string $sDomain) : ?string
|
||||
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid.
|
||||
private static function getServiceIcon(string $sEmail) : ?string
|
||||
{
|
||||
$sDomain = \explode('@', $sEmail);
|
||||
$sDomain = \array_pop($sDomain);
|
||||
$aServices = [
|
||||
"services/{$sDomain}",
|
||||
'services/' . static::serviceDomain($sDomain)
|
||||
|
|
@ -198,20 +200,9 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
foreach ($aServices as $service) {
|
||||
$file = __DIR__ . "/images/{$service}.png";
|
||||
if (\file_exists($file)) {
|
||||
return $file;
|
||||
return 'data:image/png;base64,' . \base64_encode(\file_get_contents($file));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid.
|
||||
private static function getServiceIcon(string $sEmail) : ?string
|
||||
{
|
||||
$aParts = \explode('@', $sEmail);
|
||||
$file = static::getServicePng(\array_pop($aParts));
|
||||
if ($file) {
|
||||
return 'data:image/png;base64,' . \base64_encode(\file_get_contents($file));
|
||||
}
|
||||
|
||||
$aResult = static::getCachedImage($sEmail);
|
||||
if ($aResult) {
|
||||
|
|
@ -234,23 +225,30 @@ 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);
|
||||
|
|
@ -270,7 +268,7 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
}
|
||||
|
||||
if ($this->Config()->Get('plugin', 'gravatar', false)) {
|
||||
$aUrls[] = 'https://gravatar.com/avatar/'.\hash('sha256', \strtolower($sAsciiEmail)).'?s=80&d=404';
|
||||
$aUrls[] = 'https://gravatar.com/avatar/'.\md5(\strtolower($sAsciiEmail)).'?s=80&d=404';
|
||||
}
|
||||
|
||||
foreach ($aUrls as $sUrl) {
|
||||
|
|
@ -286,13 +284,20 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
|
||||
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid.
|
||||
if ($bBimi && !$aResult) {
|
||||
$file = static::getServicePng($sDomain);
|
||||
if ($file) {
|
||||
\MailSo\Base\Http::setLastModified(\filemtime($file));
|
||||
$aResult = [
|
||||
'image/png',
|
||||
\file_get_contents($file)
|
||||
];
|
||||
$aServices = [
|
||||
"services/{$sDomain}",
|
||||
'services/' . static::serviceDomain($sDomain)
|
||||
];
|
||||
foreach ($aServices as $service) {
|
||||
$file = __DIR__ . "/images/{$service}.png";
|
||||
if (\file_exists($file)) {
|
||||
\MailSo\Base\Http::setLastModified(\filemtime($file));
|
||||
$aResult = [
|
||||
'image/png',
|
||||
\file_get_contents($file)
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$aResult && $this->Config()->Get('plugin', 'favicon', false)) {
|
||||
|
|
@ -313,77 +318,6 @@ 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')) {
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
[SETTINGS_CHANGE_PASSWORD]
|
||||
LEGEND_CHANGE_PASSWORD = "Змінити пароль"
|
||||
LABEL_CURRENT_PASSWORD = "Поточний пароль"
|
||||
LABEL_NEW_PASSWORD = "Новий пароль"
|
||||
LABEL_REPEAT_PASSWORD = "Підтвердьте новий пароль"
|
||||
BUTTON_UPDATE_PASSWORD = "Змінити"
|
||||
ERROR_PASSWORD_MISMATCH = "Паролі не збігаються, спробуйте ще раз"
|
||||
[NOTIFICATIONS]
|
||||
COULD_NOT_SAVE_NEW_PASSWORD = "Не вдалося зберегти новий пароль"
|
||||
CURRENT_PASSWORD_INCORRECT = "Поточний пароль не вірний"
|
||||
NEW_PASSWORD_SHORT = "Пароль закороткий"
|
||||
NEW_PASSWORD_WEAK = "Пароль надто легкий"
|
||||
NEW_PASSWORD_HIBP = "Пароль знайдено в сервісі Have I Been Pwned"
|
||||
|
|
@ -6,7 +6,7 @@ class CompactComposerPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
NAME = 'Compact Composer',
|
||||
AUTHOR = 'Sergey Mosin',
|
||||
URL = 'https://github.com/the-djmaze/snappymail/pull/1466',
|
||||
VERSION = '1.0.6',
|
||||
VERSION = '1.0.5',
|
||||
RELEASE = '2024-08-08',
|
||||
REQUIRED = '2.34.0',
|
||||
LICENSE = 'AGPL v3',
|
||||
|
|
|
|||
|
|
@ -100,6 +100,10 @@
|
|||
}
|
||||
},
|
||||
|
||||
pasteSanitizer = (event) => {
|
||||
return rl.Utils.cleanHtml(event.detail.html).html;
|
||||
},
|
||||
|
||||
pasteImageHandler = (e, squire) => {
|
||||
|
||||
const items = [...e.detail.clipboardData.items];
|
||||
|
|
@ -138,12 +142,7 @@
|
|||
toolbar.className = 'squire-toolbar btn-toolbar';
|
||||
const actions = this.makeActions(squire, toolbar);
|
||||
|
||||
this.squire.addEventListener('willPaste', (event) => {
|
||||
// https://github.com/fastmail/Squire?tab=readme-ov-file#addeventlistener
|
||||
// The content that will be inserted is available as either the fragment property, or the text property for plain text, on the detail property of the event. You can modify this text/fragment in your event handler to change what will be pasted
|
||||
tpl.innerHTML = rl.Utils.cleanHtml(event.detail.html).html;
|
||||
event.detail.fragment = tpl.content;
|
||||
});
|
||||
this.squire.addEventListener('willPaste', pasteSanitizer);
|
||||
this.squire.addEventListener('pasteImage', (e) => {
|
||||
pasteImageHandler(e, squire);
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,8 +5,8 @@ class ICSViewerPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
const
|
||||
NAME = 'ICS Viewer',
|
||||
AUTHOR = 'PhieF',
|
||||
VERSION = '1.1',
|
||||
RELEASE = '2026-10-22',
|
||||
VERSION = '1.0',
|
||||
RELEASE = '2024-09-17',
|
||||
CATEGORY = 'Messages',
|
||||
DESCRIPTION = 'Display ICS attachment using ical lib, or JSON-LD details, based on viewICS',
|
||||
REQUIRED = '2.34.0';
|
||||
|
|
|
|||
|
|
@ -86,17 +86,10 @@
|
|||
});
|
||||
}
|
||||
});
|
||||
// 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 || ''));
|
||||
// ICS attachment
|
||||
// let ics = msg.attachments.find(attachment => 'application/ics' == attachment.mimeType);
|
||||
|
||||
let ics = msg.attachments.find(attachment => 'text/calendar' == attachment.mimeType);
|
||||
if (ics && ics.download) {
|
||||
|
||||
// fetch it and parse the VEVENT
|
||||
|
|
|
|||
|
|
@ -139,41 +139,29 @@ class LdapContactsSuggestions implements \RainLoop\Providers\Suggestions\ISugges
|
|||
$sFilter .= (1 < count($aItems) ? '(|' : '').$sSubFilter.(1 < count($aItems) ? ')' : '');
|
||||
$sFilter .= ')';
|
||||
|
||||
// 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');
|
||||
$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']);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
// One unreachable branch must not silence the others.
|
||||
$this->logLdapError($oCon, 'ldap_search ('.$sOneBaseDn.')');
|
||||
$this->logLdapError($oCon, 'ldap_get_entries');
|
||||
}
|
||||
} else {
|
||||
$this->logLdapError($oCon, 'ldap_search');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ class LdapContactsSuggestionsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
{
|
||||
const
|
||||
NAME = 'Contacts suggestions (LDAP)',
|
||||
VERSION = '2.15',
|
||||
RELEASE = '2026-08-19',
|
||||
VERSION = '2.14',
|
||||
RELEASE = '2024-03-12',
|
||||
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} Several branches may be given, separated by | - useful when meeting rooms or other resources live outside the people branch.')
|
||||
->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}')
|
||||
->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 @@
|
|||
This plugins allows to authenticate using LDAP mail field, yet login field would still be used for IMAP and SMTP authentication.
|
||||
Plugin which allows you to set up LDAP username by email address (when IMAP/SMTP requires it)
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ class LDAPLoginMappingPlugin extends AbstractPlugin
|
|||
{
|
||||
const
|
||||
NAME = 'LDAP login mapping',
|
||||
VERSION = '2.4',
|
||||
VERSION = '2.3',
|
||||
AUTHOR = 'RainLoop Team, Ludovic Pouzenc<ludovic@pouzenc.fr>, ZephOne<zephone@protonmail.com>',
|
||||
RELEASE = '2025-01-09',
|
||||
RELEASE = '2024-09-20',
|
||||
REQUIRED = '2.36.1',
|
||||
CATEGORY = 'Login',
|
||||
DESCRIPTION = 'This plugins allows to authenticate using LDAP mail field, yet login field would still be used for IMAP and SMTP authentication';
|
||||
DESCRIPTION = 'Enable custom mapping using ldap field';
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
|
|
@ -104,10 +104,9 @@ 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:FilterLoginCredentials() auth try: $sIP/$sEmail, resolved as $sImapUser/$sEmail");
|
||||
syslog(LOG_WARNING, "plugins/ldap-login-mapping/index.php:FilterLoginСredentials() auth try: $sIP/$sEmail, resolved as $sImapUser/$sEmail");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<?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,91 +1,54 @@
|
|||
((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 || "";
|
||||
(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'
|
||||
}));
|
||||
};
|
||||
|
||||
// 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,
|
||||
});
|
||||
};
|
||||
if (client_id) {
|
||||
addEventListener('sm-user-login', e => {
|
||||
if (event.detail.get('Email').includes('@hotmail.com')) {
|
||||
e.preventDefault();
|
||||
login();
|
||||
}
|
||||
});
|
||||
|
||||
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 = login;
|
||||
div.append(btn);
|
||||
container && container.append(div);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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,20 +1,15 @@
|
|||
<?php
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
*
|
||||
* https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris
|
||||
* 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;
|
||||
* }
|
||||
* Azure: redirect_uri=https://{DOMAIN}/?LoginO365
|
||||
* Personal: redirect_uri=https://{DOMAIN}/LoginO365
|
||||
*/
|
||||
|
||||
use RainLoop\Model\MainAccount;
|
||||
|
|
@ -22,674 +17,232 @@ use RainLoop\Providers\Storage\Enumerations\StorageType;
|
|||
|
||||
class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
|
||||
{
|
||||
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';
|
||||
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';
|
||||
|
||||
// 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';
|
||||
// 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';
|
||||
|
||||
/**
|
||||
* 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 = [];
|
||||
private static ?array $auth = null;
|
||||
|
||||
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');
|
||||
// Used by JS to obtain an auth URL with signed state (for both login + add-account flows).
|
||||
$this->addJsonHook('LoginO365AuthUrl', 'DoLoginO365AuthUrl');
|
||||
$this->addPartHook('LoginO365', 'ServiceLoginO365');
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Cleanup: when an additional account is removed, also remove its encrypted refresh token bundle.
|
||||
$this->addHook('json.after-AccountDelete', 'afterAccountDelete');
|
||||
}
|
||||
public function httpPaths(array &$aPaths) : void
|
||||
{
|
||||
// Personal accounts workaround
|
||||
if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) {
|
||||
$aPaths = ['LoginO365'];
|
||||
}
|
||||
|
||||
public function httpPaths(array &$aPaths) : void
|
||||
{
|
||||
if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) {
|
||||
$aPaths = ['LoginO365'];
|
||||
}
|
||||
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', ';')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 ServiceLoginO365() : string
|
||||
{
|
||||
$oActions = \RainLoop\Api::Actions();
|
||||
$oHttp = $oActions->Http();
|
||||
$oHttp->ServerNoCache();
|
||||
|
||||
public function ServiceLoginO365() : string
|
||||
{
|
||||
$oActions = \RainLoop\Api::Actions();
|
||||
$oHttp = $oActions->Http();
|
||||
$oHttp->ServerNoCache();
|
||||
|
||||
try
|
||||
try
|
||||
{
|
||||
if (isset($_GET['error'])) {
|
||||
$desc = $_GET['error_description'] ?? '';
|
||||
throw new \RuntimeException("{$_GET['error']}: {$desc}");
|
||||
}
|
||||
|
||||
// Must have code + state
|
||||
if (!isset($_GET['code']) || empty($_GET['state'])) {
|
||||
$oActions->Location(\RainLoop\Utils::WebPath());
|
||||
exit;
|
||||
}
|
||||
|
||||
$oO365 = $this->o365Connector();
|
||||
if (!$oO365) {
|
||||
$oActions->Location(\RainLoop\Utils::WebPath());
|
||||
exit;
|
||||
}
|
||||
|
||||
$iNow = \time();
|
||||
|
||||
$redirectUri = $this->redirectUri();
|
||||
|
||||
$tenant = $this->Config()->Get('plugin', 'tenant', 'common');
|
||||
|
||||
$state = (string) $_GET['state'];
|
||||
$statePayload = $this->verifyAndConsumeState($state);
|
||||
if (!$statePayload) {
|
||||
$oActions->Location(\RainLoop\Utils::WebPath());
|
||||
exit;
|
||||
}
|
||||
|
||||
$aTokenWrap = $oO365->getAccessToken(
|
||||
\str_replace('{{tenant}}', $tenant, static::TOKEN_URI),
|
||||
'authorization_code',
|
||||
[
|
||||
'code' => $_GET['code'],
|
||||
'redirect_uri' => $redirectUri
|
||||
]
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
$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');
|
||||
}
|
||||
|
||||
$sAccessToken = $aResponse['access_token'];
|
||||
$iExpires += $aResponse['expires_in'];
|
||||
|
||||
$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');
|
||||
}
|
||||
|
||||
static::$auth = [
|
||||
'access_token' => $sAccessToken,
|
||||
'refresh_token' => $aResponse['refresh_token'],
|
||||
'expires_in' => $aResponse['expires_in'],
|
||||
'expires' => $iExpires
|
||||
];
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
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()
|
||||
];
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ class LoginRemotePlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
NAME = 'Login Remote',
|
||||
AUTHOR = 'SnappyMail',
|
||||
URL = 'https://snappymail.eu/',
|
||||
VERSION = '1.6',
|
||||
RELEASE = '2026-10-22',
|
||||
VERSION = '1.5',
|
||||
RELEASE = '2024-09-20',
|
||||
REQUIRED = '2.36.1',
|
||||
CATEGORY = 'Login',
|
||||
LICENSE = 'MIT',
|
||||
|
|
@ -43,7 +43,6 @@ class LoginRemotePlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
try
|
||||
{
|
||||
static::$login = true;
|
||||
$sPassword = new \SnappyMail\SensitiveString($sPassword);
|
||||
$oAccount = $oActions->LoginProcess($sEmail, $sPassword);
|
||||
}
|
||||
catch (\Throwable $oException)
|
||||
|
|
|
|||
|
|
@ -19,34 +19,33 @@ 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.
|
||||
In Dovecot 2.3, the file /etc/dovecot/conf.d/auth-master.conf.ext should contain:
|
||||
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
|
||||
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
|
||||
# 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
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -81,11 +80,3 @@ 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.7',
|
||||
RELEASE = '2026-05-17',
|
||||
VERSION = '0.5',
|
||||
RELEASE = '2024-09-20',
|
||||
REQUIRED = '2.36.1',
|
||||
CATEGORY = 'Login',
|
||||
LICENSE = 'MIT',
|
||||
|
|
@ -21,6 +21,26 @@ 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();
|
||||
|
|
@ -60,19 +80,59 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
$sMsg = "Remote User: " . $sRemoteUser;
|
||||
$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', '')));
|
||||
$sProxyIP = $this->Config()->getDecrypted('plugin', 'proxy_ip', '');
|
||||
$sMsg = "ProxyIP: " . $sProxyIP;
|
||||
$oLogger->Write($sMsg, $sLevel, $sPrefix);
|
||||
|
||||
try
|
||||
{
|
||||
static::$login = true;
|
||||
$oAccount = $oActions->LoginProcess($sEmail, $sPassword);
|
||||
$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;
|
||||
}
|
||||
catch (\Throwable $oException)
|
||||
{
|
||||
$oLogger = $oActions->Logger();
|
||||
$oLogger && $oLogger->WriteException($oException);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
\MailSo\Base\Http::Location('./');
|
||||
|
|
@ -128,6 +188,18 @@ 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.3',
|
||||
RELEASE = '2026-10-22',
|
||||
VERSION = '0.2',
|
||||
RELEASE = '2024-06-28',
|
||||
REQUIRED = '2.36.3',
|
||||
CATEGORY = 'General',
|
||||
LICENSE = 'MIT',
|
||||
|
|
@ -51,33 +51,45 @@ 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");
|
||||
|
||||
// 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);
|
||||
}
|
||||
//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
|
||||
);
|
||||
}
|
||||
|
||||
continue; // przechodzimy do następnego filtra
|
||||
}
|
||||
|
||||
// normalny search w INBOX
|
||||
$uids = $this->searchMessages($oImapClient, $searchQ, 'INBOX');
|
||||
if (!empty($uids)) {
|
||||
$this->applyActions($oImapClient, $filter, $uids, 'INBOX');
|
||||
//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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,66 +99,21 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
string $search,
|
||||
string $folder = "INBOX"
|
||||
): array {
|
||||
try {
|
||||
$bUseCache = false;
|
||||
$oSearchCriterias = \MailSo\Imap\SearchCriterias::fromString(
|
||||
$imapClient,
|
||||
$folder,
|
||||
$search,
|
||||
true,
|
||||
$bUseCache
|
||||
);
|
||||
$oParams = new \MailSo\Mail\MessageListParams();
|
||||
$oParams->sSearch = $search;
|
||||
$oParams->sFolderName = $folder;
|
||||
|
||||
$imapClient->FolderSelect($folder);
|
||||
return $imapClient->MessageSearch($oSearchCriterias, true);
|
||||
$bUseCache = false;
|
||||
$oSearchCriterias = \MailSo\Imap\SearchCriterias::fromString(
|
||||
$imapClient,
|
||||
$folder,
|
||||
$search,
|
||||
true,
|
||||
$bUseCache
|
||||
);
|
||||
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
$imapClient->FolderSelect($folder);
|
||||
return $imapClient->MessageSearch($oSearchCriterias, true);
|
||||
}
|
||||
|
||||
public function GetFilters()
|
||||
|
|
@ -206,6 +173,7 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
} else {
|
||||
$aSettings['SFilters'][$foundIndex] = $newFilter;
|
||||
}
|
||||
|
||||
return $this->jsonResponse(__FUNCTION__, $this->saveUserSettings($aSettings));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|||
private static function getQRCode(MainAccount $oAccount, string $secret) : string
|
||||
{
|
||||
$email = \rawurlencode($oAccount->Email());
|
||||
// $issuer = \rawurlencode(\RainLoop\Api::Config()->Get('webmail', 'title', 'SnappyMail'));
|
||||
// $issuer = \rawurlencode(\RainLoop\API::Config()->Get('webmail', 'title', 'SnappyMail'));
|
||||
$QR = \SnappyMail\QRCode::getMinimumQRCode(
|
||||
// "otpauth://totp/{$issuer}:{$email}?secret={$secret}&issuer={$issuer}",
|
||||
"otpauth://totp/{$email}?secret={$secret}",
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Show Secret"
|
|||
BUTTON_HIDE_SECRET = "Hide Secret"
|
||||
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
|
||||
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not configured"
|
||||
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually."
|
||||
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After you’ve used a backup code to sign in, it will become inactive."
|
||||
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually.\n"
|
||||
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After you’ve used a backup code to sign in, it will become inactive.\n"
|
||||
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
|
||||
TITLE_TEST_CODE = "2-Step verification test"
|
||||
LABEL_CODE = "Code"
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Show Secret"
|
|||
BUTTON_HIDE_SECRET = "Hide Secret"
|
||||
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
|
||||
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not configured"
|
||||
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually."
|
||||
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After you’ve used a backup code to sign in, it will become inactive."
|
||||
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually.\n"
|
||||
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After you’ve used a backup code to sign in, it will become inactive.\n"
|
||||
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
|
||||
TITLE_TEST_CODE = "2-Step verification test"
|
||||
LABEL_CODE = "Code"
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Titok megjelenítése"
|
|||
BUTTON_HIDE_SECRET = "Titok elrejtése"
|
||||
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Beállítva"
|
||||
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Nincs beállítva"
|
||||
TWO_FACTOR_SECRET_DESC = "Importáld ezt az infót a Google Authenticator kliensedbe (vagy más TOTP kliensbe) az alábbi QR kód használatával vagy a kód manuális megadatásával."
|
||||
TWO_FACTOR_BACKUP_CODES_DESC = "Ha nem kapod meg a kódokat a Google Authenticator kliensből (vagy más TOTP kliensből), akkor a bejelentkezéshez használhatod a biztonsági kódot. A biztonsági kód használata után inaktívvá válik."
|
||||
TWO_FACTOR_SECRET_DESC = "Importáld ezt az infót a Google Authenticator kliensedbe (vagy más TOTP kliensbe) az alábbi QR kód használatával vagy a kód manuális megadatásával.\n"
|
||||
TWO_FACTOR_BACKUP_CODES_DESC = "Ha nem kapod meg a kódokat a Google Authenticator kliensből (vagy más TOTP kliensből), akkor a bejelentkezéshez használhatod a biztonsági kódot. A biztonsági kód használata után inaktívvá válik.\n"
|
||||
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Tesztelés nélkül nem lehet megváltoztatni ezt a beállítást."
|
||||
TITLE_TEST_CODE = "2-lépéses hitelesítés teszt"
|
||||
LABEL_CODE = "Kód"
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Mostra la chiave segreta"
|
|||
BUTTON_HIDE_SECRET = "Nascondi la chiave segreta"
|
||||
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configurato"
|
||||
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Non configurato"
|
||||
TWO_FACTOR_SECRET_DESC = "Importa queste informazioni nella tua app Google Authenticator (o un altro programma per l'autenticazione a due fattori) utilizzando il codice QR fornito qui sotto o inserendo il codice manualmente."
|
||||
TWO_FACTOR_BACKUP_CODES_DESC = "Se non riesci a ricevere i codici tramite Google Authenticator (o un altro programma per l'autenticazione a due fattori), puoi utilizzare i codici di backup per accedere. Dopo che hai utilizzato un codice di backup per accedere, diventerà inattivo."
|
||||
TWO_FACTOR_SECRET_DESC = "Importa queste informazioni nella tua app Google Authenticator (o un altro programma per l'autenticazione a due fattori) utilizzando il codice QR fornito qui sotto o inserendo il codice manualmente.\n"
|
||||
TWO_FACTOR_BACKUP_CODES_DESC = "Se non riesci a ricevere i codici tramite Google Authenticator (o un altro programma per l'autenticazione a due fattori), puoi utilizzare i codici di backup per accedere. Dopo che hai utilizzato un codice di backup per accedere, diventerà inattivo.\n"
|
||||
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Non puoi modificare questa impostazione prima del test."
|
||||
TITLE_TEST_CODE = "Test di verifica a 2 fattori"
|
||||
LABEL_CODE = "Codice"
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
[PLUGIN_2FA]
|
||||
LEGEND_TWO_FACTOR_AUTH = "2-етапна перевірка (TOTP)"
|
||||
LABEL_ENABLE_TWO_FACTOR = "Увімкнути 2-етапну перевірку"
|
||||
LABEL_TWO_FACTOR_USER = "Ім'я користувача"
|
||||
LABEL_TWO_FACTOR_STATUS = "Статус"
|
||||
LABEL_TWO_FACTOR_SECRET = "Ключ"
|
||||
LABEL_TWO_FACTOR_BACKUP_CODES = "Резервні коди"
|
||||
BUTTON_CREATE = "Create a secret"
|
||||
BUTTON_ACTIVATE = "Activate"
|
||||
LINK_TEST = "Тестувати"
|
||||
BUTTON_SHOW_SECRET = "Показати ключ"
|
||||
BUTTON_HIDE_SECRET = "Приховати ключ"
|
||||
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Налаштовано"
|
||||
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Не налаштовано"
|
||||
TWO_FACTOR_SECRET_DESC = "Імпортуйте цю інформацію у свій клієнт Google Authenticator (або інший клієнт TOTP), використовуючи наданий нижче QR-код або ввівши код вручну"
|
||||
TWO_FACTOR_BACKUP_CODES_DESC = "Якщо ви не можете отримати код через Google Authenticator (або інший клієнт TOTP), ви можете скористатися резервними кодами для входу. Після того, як ви використаєте резервний код для входу, він стане неактивним. Закреслюйте його"
|
||||
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Ви не можете змінити це налаштування перед тестуванням."
|
||||
TITLE_TEST_CODE = "Тест 2-етапної перевірки"
|
||||
LABEL_CODE = "Код"
|
||||
LABEL_TWO_FACTOR_CODE = "Код підтвердження"
|
||||
|
|
@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "显示密钥"
|
|||
BUTTON_HIDE_SECRET = "隐藏密钥"
|
||||
TWO_FACTOR_SECRET_CONFIGURED_DESC = "已设置"
|
||||
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "未设置"
|
||||
TWO_FACTOR_SECRET_DESC = "将下面的信息导入 Google Authenticator (或其他 TOTP 客户端)。 扫描下面的二维码或手动输入密钥。"
|
||||
TWO_FACTOR_BACKUP_CODES_DESC = "如果你无法从验证器获取验证码,你可以使用备用代码登录。 当你使用过某一个备用代码后,它将会失效。"
|
||||
TWO_FACTOR_SECRET_DESC = "将下面的信息导入 Google Authenticator (或其他 TOTP 客户端)。 扫描下面的二维码或手动输入密钥。\n"
|
||||
TWO_FACTOR_BACKUP_CODES_DESC = "如果你无法从验证器获取验证码,你可以使用备用代码登录。 当你使用过某一个备用代码后,它将会失效。\n"
|
||||
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "完成测试前你无法更改此设置。"
|
||||
TITLE_TEST_CODE = "两步验证测试"
|
||||
LABEL_CODE = "代码"
|
||||
|
|
|
|||
|
|
@ -54,22 +54,11 @@ abstract class DateTimeHelper
|
|||
\DateTime::createFromFormat('d M Y H:i:s O', $sDateTime, static::GetUtcTimeZoneObject())
|
||||
// Using T (obsolete timezone abbreviation)
|
||||
?: \DateTime::createFromFormat('d M Y H:i:s T', $sDateTime, static::GetUtcTimeZoneObject());
|
||||
|
||||
try {
|
||||
$timestamp = $oDateTime->getTimestamp();
|
||||
|
||||
// 398045302 is 1982-08-13 00:08:22, the date RFC 822 was created
|
||||
if (398045302 > $timestamp) {
|
||||
\SnappyMail\Log::notice('', "Failed to parse RFC 2822 date '{$sDateTime}'");
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $timestamp;
|
||||
} catch (\Throwable $error) {
|
||||
// Catch integer overflow or other fatal errors
|
||||
\SnappyMail\Log::notice('', "Failed to parse RFC 2822 date '{$sDateTime}'. {$error->getMessage()}");
|
||||
return 0;
|
||||
// 398045302 is 1982-08-13 00:08:22 the date RFC 822 was created
|
||||
if (!$oDateTime || 398045302 > $oDateTime->getTimestamp()) {
|
||||
\SnappyMail\Log::notice('', "Failed to parse RFC 2822 date '{$sDateTime}'");
|
||||
}
|
||||
return $oDateTime ? $oDateTime->getTimestamp() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -160,20 +160,13 @@ 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)) {
|
||||
$aNames[] = $oAttr->nodeName;
|
||||
$oElement->removeAttribute($oAttr->nodeName);
|
||||
}
|
||||
}
|
||||
foreach ($aNames as $sName) {
|
||||
$oElement->removeAttribute($sName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ abstract class Utils
|
|||
return 'iso-8859-8';
|
||||
}
|
||||
|
||||
return \preg_replace('/^(cp|windows)-?(12[\d]*)/i', 'windows-$2', $sEncoding);
|
||||
return \preg_replace('/^(cp-?|windows?)(12[\d])/', 'windows-$1', $sEncoding);
|
||||
}
|
||||
|
||||
public static function NormalizeCharsetByValue(string $sCharset, string $sValue) : string
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ class Logger extends \SplFixedArray
|
|||
\E_USER_ERROR => \LOG_ERR,
|
||||
\E_USER_WARNING => \LOG_WARNING,
|
||||
\E_USER_NOTICE => \LOG_NOTICE,
|
||||
// \E_STRICT => \LOG_CRIT,
|
||||
\E_STRICT => \LOG_CRIT,
|
||||
\E_RECOVERABLE_ERROR => \LOG_ERR,
|
||||
\E_DEPRECATED => \LOG_INFO,
|
||||
\E_USER_DEPRECATED => \LOG_INFO
|
||||
|
|
@ -168,7 +168,7 @@ class Logger extends \SplFixedArray
|
|||
\E_USER_ERROR => '-USER',
|
||||
\E_USER_WARNING => '-USER',
|
||||
\E_USER_NOTICE => '-USER',
|
||||
// \E_STRICT => '-STRICT',
|
||||
\E_STRICT => '-STRICT',
|
||||
\E_RECOVERABLE_ERROR => '-RECOVERABLE',
|
||||
\E_DEPRECATED => '-DEPRECATED',
|
||||
\E_USER_DEPRECATED => '-USER_DEPRECATED'
|
||||
|
|
|
|||
|
|
@ -136,12 +136,14 @@ class HeaderCollection extends \MailSo\Base\Collection
|
|||
$sHeadersValue = \rtrim($sHeadersValue);
|
||||
}
|
||||
|
||||
// RFC 5322 unfolding removes the line break and keeps the folding
|
||||
// whitespace; RFC 2047 6.2 drops it only between two encoded words.
|
||||
if ('=?' === \substr(\ltrim($sHeadersValue), 0, 2) && '?=' === \substr($sValue, -2)) {
|
||||
$sValue .= \ltrim($sHeadersValue);
|
||||
if ('=?' === \substr(\ltrim($sHeadersValue), 0, 2)) {
|
||||
$sHeadersValue = \ltrim($sHeadersValue);
|
||||
}
|
||||
|
||||
if ('=?' === \substr($sHeadersValue, 0, 2)) {
|
||||
$sValue .= $sHeadersValue;
|
||||
} else {
|
||||
$sValue .= ' '.\ltrim($sHeadersValue);
|
||||
$sValue .= "\n".$sHeadersValue;
|
||||
}
|
||||
} else {
|
||||
if (null !== $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);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class Settings extends \MailSo\Net\ConnectSettings
|
|||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$oConfig = \RainLoop\Api::Config();
|
||||
$oConfig = \RainLoop\API::Config();
|
||||
$this->viewErrors = !!$oConfig->Get('labs', 'smtp_show_server_errors', false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -203,8 +203,7 @@ class SmtpClient extends \MailSo\Net\NetClient
|
|||
$SASL->verify($sResult);
|
||||
// Now end the authentication
|
||||
$sRequest = '';
|
||||
$this->sendRaw($sRequest, true, '');
|
||||
$this->validateResponse(235, '');
|
||||
$this->sendRequestWithCheck($sRequest, 235);
|
||||
} else switch ($type) {
|
||||
// RFC 4616
|
||||
case 'PLAIN':
|
||||
|
|
|
|||
|
|
@ -505,7 +505,7 @@ class Actions
|
|||
return $this->oPlugins;
|
||||
}
|
||||
|
||||
public function LoggerAuthHelper(?Model\Account $oAccount, string $sLogin = '', bool $admin = false): void
|
||||
protected function LoggerAuthHelper(?Model\Account $oAccount, string $sLogin = '', bool $admin = false): void
|
||||
{
|
||||
if ($sLogin) {
|
||||
$sHost = $admin ? $this->Http()->GetHost(true, true) : \MailSo\Base\Utils::getEmailAddressDomain($sLogin);
|
||||
|
|
@ -591,7 +591,6 @@ class Actions
|
|||
'ListInlineAttachments' => false,
|
||||
'CollapseBlockquotes' => $oConfig->Get('defaults', 'collapse_blockquotes', true),
|
||||
'MaxBlockquotesLevel' => 0,
|
||||
'minFontSize' => 0,
|
||||
'simpleAttachmentsList' => false,
|
||||
'listGrouped' => $oConfig->Get('defaults', 'mail_list_grouped', false),
|
||||
'MessagesPerPage' => \max(10, \intval($oConfig->Get('webmail', 'messages_per_page', 25)) ?: 25),
|
||||
|
|
@ -688,7 +687,6 @@ class Actions
|
|||
$aResult['ListInlineAttachments'] = (bool)$oSettings->GetConf('ListInlineAttachments', $aResult['ListInlineAttachments']);
|
||||
$aResult['CollapseBlockquotes'] = (bool)$oSettings->GetConf('CollapseBlockquotes', $aResult['CollapseBlockquotes']);
|
||||
$aResult['MaxBlockquotesLevel'] = (int)$oSettings->GetConf('MaxBlockquotesLevel', $aResult['MaxBlockquotesLevel']);
|
||||
$aResult['minFontSize'] = (int)$oSettings->GetConf('minFontSize', $aResult['minFontSize']);
|
||||
$aResult['simpleAttachmentsList'] = (bool)$oSettings->GetConf('simpleAttachmentsList', $aResult['simpleAttachmentsList']);
|
||||
$aResult['listGrouped'] = (bool)$oSettings->GetConf('listGrouped', $aResult['listGrouped']);
|
||||
$aResult['ContactsAutosave'] = (bool)$oSettings->GetConf('ContactsAutosave', $aResult['ContactsAutosave']);
|
||||
|
|
|
|||
|
|
@ -210,7 +210,6 @@ trait Accounts
|
|||
'mainEmail' => \RainLoop\Api::Actions()->getMainAccountFromToken()->Email(),
|
||||
'contactsAllowed' => $this->AddressBookProvider($oAccount)->IsActive(),
|
||||
'HideUnsubscribed' => false,
|
||||
'defaultSort' => '',
|
||||
'useThreads' => (bool) $oConfig->Get('defaults', 'mail_use_threads', false),
|
||||
'threadAlgorithm' => '',
|
||||
'ReplySameFolder' => (bool) $oConfig->Get('defaults', 'mail_reply_same_folder', false),
|
||||
|
|
@ -227,7 +226,6 @@ trait Accounts
|
|||
$aResult['TrashFolder'] = (string) $oSettingsLocal->GetConf('TrashFolder', '');
|
||||
$aResult['ArchiveFolder'] = (string) $oSettingsLocal->GetConf('ArchiveFolder', '');
|
||||
$aResult['HideUnsubscribed'] = (bool) $oSettingsLocal->GetConf('HideUnsubscribed', $aResult['HideUnsubscribed']);
|
||||
$aResult['defaultSort'] = (string) $oSettingsLocal->GetConf('defaultSort', $aResult['defaultSort']);
|
||||
$aResult['useThreads'] = (bool) $oSettingsLocal->GetConf('UseThreads', $aResult['useThreads']);
|
||||
$aResult['threadAlgorithm'] = (string) $oSettingsLocal->GetConf('threadAlgorithm', $aResult['threadAlgorithm']);
|
||||
$aResult['ReplySameFolder'] = (bool) $oSettingsLocal->GetConf('ReplySameFolder', $aResult['ReplySameFolder']);
|
||||
|
|
|
|||
|
|
@ -14,15 +14,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -71,8 +71,7 @@ trait AdminDomains
|
|||
return $this->DefaultResponse(array(
|
||||
'email' => $sEmail,
|
||||
'login' => $sCredentials['imapUser'],
|
||||
'name' => $sCredentials['domain'] ? $sCredentials['domain']->Name() : null,
|
||||
// 'domain' => $sCredentials['domain'],
|
||||
'domain' => $sCredentials['domain'],
|
||||
'whitelist' => $sCredentials['domain'] ? $sCredentials['domain']->ValidateWhiteList($sEmail) : null
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@
|
|||
namespace RainLoop\Actions;
|
||||
|
||||
use RainLoop\Enumerations\Capa;
|
||||
use RainLoop\Exceptions\ClientException;
|
||||
|
||||
trait Contacts
|
||||
{
|
||||
protected ?\RainLoop\Providers\AddressBook $oAddressBookProvider = null;
|
||||
/**
|
||||
* @var \RainLoop\Providers\AddressBook
|
||||
*/
|
||||
protected $oAddressBookProvider = null;
|
||||
|
||||
public function AddressBookProvider(?\RainLoop\Model\Account $oAccount = null): \RainLoop\Providers\AddressBook
|
||||
{
|
||||
|
|
@ -59,66 +61,18 @@ trait Contacts
|
|||
return $this->DefaultResponse($bResult);
|
||||
}
|
||||
|
||||
public function DoTestContactsSyncData() : array
|
||||
{
|
||||
if (!$this->GetCapa(Capa::CONTACTS)) {
|
||||
throw new ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'Disallowed');
|
||||
}
|
||||
|
||||
$oAccount = $this->getAccountFromToken();
|
||||
|
||||
$sPassword = $this->GetActionParam('Password', '');
|
||||
if (static::APP_DUMMY === $sPassword) {
|
||||
$mData = $this->getContactsSyncData($oAccount);
|
||||
$sPassword = isset($mData['Password']) ? $mData['Password'] : '';
|
||||
}
|
||||
$sPasswordHMAC = null;
|
||||
if ($sPassword) {
|
||||
$oMainAccount = $this->getMainAccountFromToken();
|
||||
$sPassword = \SnappyMail\Crypt::EncryptToJSON($sPassword, $oMainAccount->CryptKey());
|
||||
if ($sPassword) {
|
||||
$sPasswordHMAC = \hash_hmac('sha1', $sPassword, $oMainAccount->CryptKey());
|
||||
}
|
||||
}
|
||||
|
||||
$oDriver = $this->fabrica('address-book', $oAccount);
|
||||
if (!$oDriver) {
|
||||
throw new ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'No driver');
|
||||
}
|
||||
$oDriver->SetEmail($this->GetMainEmail($oAccount));
|
||||
$oDriver->setDAVClientConfig([
|
||||
'Mode' => 2, // readonly
|
||||
'User' => $this->GetActionParam('User', ''),
|
||||
'Password' => $sPassword,
|
||||
'Url' => $this->GetActionParam('Url', ''),
|
||||
'PasswordHMAC' => $sPasswordHMAC
|
||||
]);
|
||||
|
||||
$oClient = $oDriver->getDavClient();
|
||||
if (!$oClient) {
|
||||
throw new ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'No client');
|
||||
}
|
||||
$oClient->propFind($oClient->urlPath, [
|
||||
'{DAV:}getlastmodified',
|
||||
'{DAV:}resourcetype',
|
||||
'{DAV:}getetag'
|
||||
], 1);
|
||||
|
||||
return $this->TrueResponse();
|
||||
}
|
||||
|
||||
public function DoContactsSync() : array
|
||||
{
|
||||
$oAccount = $this->getAccountFromToken();
|
||||
$oAddressBookProvider = $this->AddressBookProvider($oAccount);
|
||||
if (!$oAddressBookProvider) {
|
||||
throw new ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'No AddressBookProvider');
|
||||
throw new \RainLoop\Exceptions\ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'No AddressBookProvider');
|
||||
}
|
||||
\ignore_user_abort(true);
|
||||
\SnappyMail\HTTP\Stream::start(/*$binary = false*/);
|
||||
\SnappyMail\HTTP\Stream::JSON(['messsage'=>'start']);
|
||||
if (!$oAddressBookProvider->Sync()) {
|
||||
throw new ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'AddressBookProvider->Sync() failed');
|
||||
throw new \RainLoop\Exceptions\ClientException(\RainLoop\Notifications::ContactsSyncError, null, 'AddressBookProvider->Sync() failed');
|
||||
}
|
||||
return $this->TrueResponse();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1255,7 +1255,6 @@ 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', '');
|
||||
|
|
@ -1264,7 +1263,6 @@ trait Messages
|
|||
&& ($oIdentity->Id() === $sID || $oIdentity->Email() === $oFrom->GetEmail())
|
||||
) {
|
||||
$sCertificate = $oIdentity->smimeCertificate;
|
||||
$sCertificateChain = $oIdentity->smimeCertificateChain;
|
||||
$sPrivateKey = $oIdentity->smimeKey;
|
||||
break;
|
||||
}
|
||||
|
|
@ -1305,7 +1303,6 @@ trait Messages
|
|||
|
||||
$SMIME = $this->SMIME();
|
||||
$SMIME->setCertificate($sCertificate);
|
||||
$SMIME->setCertificateChain($sCertificateChain);
|
||||
$SMIME->setPrivateKey($sPrivateKey, $oPassphrase);
|
||||
$sSignature = $SMIME->sign($tmp, $detached);
|
||||
|
||||
|
|
|
|||
|
|
@ -287,14 +287,6 @@ 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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -183,7 +183,6 @@ trait User
|
|||
$this->setSettingsFromParams($oSettings, 'ListInlineAttachments', 'bool');
|
||||
$this->setSettingsFromParams($oSettings, 'CollapseBlockquotes', 'bool');
|
||||
$this->setSettingsFromParams($oSettings, 'MaxBlockquotesLevel', 'int');
|
||||
$this->setSettingsFromParams($oSettings, 'minFontSize', 'int');
|
||||
$this->setSettingsFromParams($oSettings, 'simpleAttachmentsList', 'bool');
|
||||
$this->setSettingsFromParams($oSettings, 'listGrouped', 'bool');
|
||||
$this->setSettingsFromParams($oSettings, 'ContactsAutosave', 'bool');
|
||||
|
|
@ -205,7 +204,6 @@ trait User
|
|||
$this->setSettingsFromParams($oSettings, 'Resizer5Width', 'int');
|
||||
$this->setSettingsFromParams($oSettings, 'Resizer5Height', 'int');
|
||||
|
||||
$this->setSettingsFromParams($oSettingsLocal, 'defaultSort', 'string');
|
||||
$this->setSettingsFromParams($oSettingsLocal, 'UseThreads', 'bool');
|
||||
$this->setSettingsFromParams($oSettingsLocal, 'threadAlgorithm', 'string');
|
||||
$this->setSettingsFromParams($oSettingsLocal, 'ReplySameFolder', 'bool');
|
||||
|
|
|
|||
|
|
@ -42,12 +42,6 @@ abstract class Api
|
|||
$CSP->report = $oConfig->Get('security', 'csp_report', false);
|
||||
$CSP->report_only = $oConfig->Get('debug', 'enable', false); // || SNAPPYMAIL_DEV
|
||||
|
||||
// Allow favicon host, if present
|
||||
$parsedFaviconUrl = parse_url($oConfig->Get('webmail', 'favicon_url', ''));
|
||||
if (is_array($parsedFaviconUrl) && array_key_exists('host', $parsedFaviconUrl)) {
|
||||
$CSP->add('img-src', $parsedFaviconUrl['host']);
|
||||
}
|
||||
|
||||
// Allow https: due to remote images in e-mails or use proxy
|
||||
if (!$oConfig->Get('labs', 'use_local_proxy_for_external_images', '')) {
|
||||
$CSP->add('img-src', 'https:');
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ class Identity implements \JsonSerializable
|
|||
|
||||
private ?SensitiveString $smimeKey = null;
|
||||
private string $smimeCertificate = '';
|
||||
private string $smimeCertificateChain = '';
|
||||
|
||||
function __construct(string $sId = '', string $sEmail = '')
|
||||
{
|
||||
|
|
@ -115,7 +114,6 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -138,8 +136,7 @@ class Identity implements \JsonSerializable
|
|||
'pgpEncrypt' => $this->pgpEncrypt,
|
||||
'pgpSign' => $this->pgpSign,
|
||||
'smimeKey' => (string) $this->smimeKey,
|
||||
'smimeCertificate' => $this->smimeCertificate,
|
||||
'smimeCertificateChain' => $this->smimeCertificateChain
|
||||
'smimeCertificate' => $this->smimeCertificate
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +158,6 @@ class Identity implements \JsonSerializable
|
|||
'pgpSign' => $this->pgpSign,
|
||||
'smimeKey' => (string) $this->smimeKey,
|
||||
'smimeCertificate' => $this->smimeCertificate,
|
||||
'smimeCertificateChain' => $this->smimeCertificateChain,
|
||||
'exists' => $this->exists
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ namespace RainLoop\Plugins;
|
|||
|
||||
class Helper
|
||||
{
|
||||
static public function ValidateWildcardValues(string $sString, string $sWildcardValues, ?string &$sFoundValue = null) : bool
|
||||
static public function ValidateWildcardValues(string $sString, string $sWildcardValues, string &$sFoundValue = null) : bool
|
||||
{
|
||||
$sFoundValue = '';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ namespace RainLoop\Providers;
|
|||
|
||||
class AddressBook extends AbstractProvider
|
||||
{
|
||||
private ?AddressBook\AddressBookInterface $oDriver;
|
||||
/**
|
||||
* @var \RainLoop\Providers\AddressBook\AddressBookInterface
|
||||
*/
|
||||
private $oDriver;
|
||||
|
||||
public function __construct(?AddressBook\AddressBookInterface $oDriver)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -265,9 +265,17 @@ trait CardDAV
|
|||
*/
|
||||
private function checkContactsPath(DAVClient $oClient, string $sPath) : bool
|
||||
{
|
||||
$aResponse = $oClient->propFind($sPath, array(
|
||||
'{DAV:}resourcetype'
|
||||
), 1);
|
||||
$aResponse = null;
|
||||
try
|
||||
{
|
||||
$aResponse = $oClient->propFind($sPath, array(
|
||||
'{DAV:}resourcetype'
|
||||
), 1);
|
||||
}
|
||||
catch (\Throwable $oException)
|
||||
{
|
||||
$this->logException($oException);
|
||||
}
|
||||
|
||||
$bGood = false;
|
||||
if (\is_array($aResponse)) {
|
||||
|
|
@ -331,7 +339,7 @@ trait CardDAV
|
|||
return $oClient;
|
||||
}
|
||||
|
||||
public function getDavClient() : ?DAVClient
|
||||
protected function getDavClient() : ?DAVClient
|
||||
{
|
||||
if (!$this->aDAVConfig['Mode']) {
|
||||
return null;
|
||||
|
|
@ -397,11 +405,7 @@ trait CardDAV
|
|||
|
||||
$bGood = $sNewPath && $this->checkContactsPath($oClient, $sNewPath);
|
||||
if (!$bGood) {
|
||||
throw new \RainLoop\Exceptions\ClientException(
|
||||
\RainLoop\Notifications::ContactsSyncError,
|
||||
null,
|
||||
'Contacts path not found at: '.$sPath
|
||||
);
|
||||
$this->logWrite('Contacts path not found at: '.$sPath, \LOG_INFO, 'DAV');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -129,12 +129,7 @@ class PdoAddressBook
|
|||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$oClient = $this->getDavClient();
|
||||
} catch (\Throwable $e) {
|
||||
\SnappyMail\Log::error('DAV', $e->getMessage());
|
||||
// $this->logException($oException);
|
||||
}
|
||||
$oClient = $this->getDavClient();
|
||||
if (!$oClient) {
|
||||
\SnappyMail\Log::warning('PdoAddressBook', 'Sync() invalid DavClient');
|
||||
return false;
|
||||
|
|
@ -160,31 +155,6 @@ 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');
|
||||
|
|
@ -193,7 +163,7 @@ class PdoAddressBook
|
|||
if ($aData['deleted']) {
|
||||
++$iCount;
|
||||
unset($aLocalSyncData[$sKey]);
|
||||
if (!$bProtectRemote && isset($aRemoteSyncData[$sKey], $aRemoteSyncData[$sKey]['vcf'])) {
|
||||
if (isset($aRemoteSyncData[$sKey], $aRemoteSyncData[$sKey]['vcf'])) {
|
||||
\SnappyMail\HTTP\Stream::JSON(['messsage'=>"Delete remote {$sKey}"]);
|
||||
$this->davClientRequest($oClient, 'DELETE', $sPath.$aRemoteSyncData[$sKey]['vcf']);
|
||||
}
|
||||
|
|
@ -208,11 +178,9 @@ class PdoAddressBook
|
|||
|
||||
// Delete local
|
||||
$aIdsForDeletion = array();
|
||||
if (!$bProtectLocal) {
|
||||
foreach ($aLocalSyncData as $sKey => $aData) {
|
||||
if (!empty($aData['etag']) && !isset($aRemoteSyncData[$sKey])) {
|
||||
$aIdsForDeletion[] = $aData['id_contact'];
|
||||
}
|
||||
foreach ($aLocalSyncData as $sKey => $aData) {
|
||||
if (!empty($aData['etag']) && !isset($aRemoteSyncData[$sKey])) {
|
||||
$aIdsForDeletion[] = $aData['id_contact'];
|
||||
}
|
||||
}
|
||||
if (\count($aIdsForDeletion)) {
|
||||
|
|
@ -750,20 +718,14 @@ class PdoAddressBook
|
|||
$aParams[':search_lower'] = array($sLowerSearch, \PDO::PARAM_STR);
|
||||
}
|
||||
|
||||
// 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']);
|
||||
}
|
||||
}
|
||||
$oContact = null;
|
||||
$iIdContact = 0;
|
||||
|
||||
return null;
|
||||
$aContacts = $this->getContactsFromPDO(
|
||||
$this->prepareAndExecute($sSql, $aParams)
|
||||
);
|
||||
|
||||
return $aContacts ? $aContacts[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1030,7 +992,7 @@ class PdoAddressBook
|
|||
$sEmail = \trim($oItem->GetEmail(true));
|
||||
if (\strlen($sEmail)) {
|
||||
$aEmailsToUpdate[] = $sEmail;
|
||||
return !\in_array(\mb_strtolower($sEmail), $aExists);
|
||||
return !\in_array($sEmail, $aExists);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -279,10 +279,7 @@ class ServiceActions
|
|||
public function ServiceProxyExternal() : string
|
||||
{
|
||||
$sData = empty($this->aPaths[1]) ? '' : $this->aPaths[1];
|
||||
if ($sData
|
||||
&& $this->Config()->Get('labs', 'use_local_proxy_for_external_images', false)
|
||||
&& $this->oActions->getMainAccountFromToken()
|
||||
) {
|
||||
if ($sData && $this->Config()->Get('labs', 'use_local_proxy_for_external_images', false)) {
|
||||
$this->oActions->verifyCacheByKey($sData);
|
||||
$sUrl = \MailSo\Base\Utils::UrlSafeBase64Decode($sData);
|
||||
if (!empty($sUrl)) {
|
||||
|
|
@ -339,7 +336,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\'; object-src \'none\'');
|
||||
\header('Content-Security-Policy: script-src \'none\'; child-src \'none\'');
|
||||
|
||||
$sRawError = '';
|
||||
$this->oActions->SetActionParams(array(
|
||||
|
|
@ -542,7 +539,7 @@ class ServiceActions
|
|||
}
|
||||
|
||||
/**
|
||||
* Login with the \RainLoop\Api::CreateUserSsoHash() generated hash
|
||||
* Login with the \RainLoop\API::CreateUserSsoHash() generated hash
|
||||
*/
|
||||
public function ServiceSso() : string
|
||||
{
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ class BirthdayCalendarGenerator
|
|||
// We've seen clients (ez-vcard) putting "BDAY:" properties
|
||||
// without a value into vCards. If we come across those, we'll
|
||||
// skip them.
|
||||
if ('' === $object->BDAY->getValue()) {
|
||||
if (empty($object->BDAY->getValue())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +116,7 @@ class BirthdayCalendarGenerator
|
|||
// Skip if we can't parse the BDAY value.
|
||||
try {
|
||||
$dateParts = DateTimeParser::parseVCardDateTime($object->BDAY->getValue());
|
||||
} catch (InvalidDataException) {
|
||||
} catch (InvalidDataException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ class Cli
|
|||
// @codeCoverageIgnoreEnd
|
||||
|
||||
try {
|
||||
[$options, $positional] = $this->parseArguments($argv);
|
||||
list($options, $positional) = $this->parseArguments($argv);
|
||||
|
||||
if (isset($options['q'])) {
|
||||
$this->quiet = true;
|
||||
|
|
@ -110,10 +110,27 @@ class Cli
|
|||
|
||||
return 0;
|
||||
case 'format':
|
||||
$this->format = match ($value) {
|
||||
'jcard', 'jcal', 'vcard21', 'vcard30', 'vcard40', 'icalendar20', 'json', 'mimedir', 'icalendar', 'vcard' => $value,
|
||||
default => throw new \InvalidArgumentException('Unknown format: '.$value),
|
||||
};
|
||||
switch ($value) {
|
||||
// jcard/jcal documents
|
||||
case 'jcard':
|
||||
case 'jcal':
|
||||
// specific document versions
|
||||
case 'vcard21':
|
||||
case 'vcard30':
|
||||
case 'vcard40':
|
||||
case 'icalendar20':
|
||||
// specific formats
|
||||
case 'json':
|
||||
case 'mimedir':
|
||||
// icalendar/vcard
|
||||
case 'icalendar':
|
||||
case 'vcard':
|
||||
$this->format = $value;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new \InvalidArgumentException('Unknown format: '.$value);
|
||||
}
|
||||
break;
|
||||
case 'pretty':
|
||||
$this->pretty = true;
|
||||
|
|
@ -122,11 +139,28 @@ class Cli
|
|||
$this->forgiving = true;
|
||||
break;
|
||||
case 'inputformat':
|
||||
$this->inputFormat = match ($value) {
|
||||
'jcard', 'jcal', 'json' => 'json',
|
||||
'mimedir', 'icalendar', 'vcard', 'vcard21', 'vcard30', 'vcard40', 'icalendar20' => 'mimedir',
|
||||
default => throw new \InvalidArgumentException('Unknown format: '.$value),
|
||||
};
|
||||
switch ($value) {
|
||||
// json formats
|
||||
case 'jcard':
|
||||
case 'jcal':
|
||||
case 'json':
|
||||
$this->inputFormat = 'json';
|
||||
break;
|
||||
|
||||
// mimedir formats
|
||||
case 'mimedir':
|
||||
case 'icalendar':
|
||||
case 'vcard':
|
||||
case 'vcard21':
|
||||
case 'vcard30':
|
||||
case 'vcard40':
|
||||
case 'icalendar20':
|
||||
$this->inputFormat = 'mimedir';
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new \InvalidArgumentException('Unknown format: '.$value);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new \InvalidArgumentException('Unknown option: '.$name);
|
||||
|
|
@ -147,7 +181,7 @@ class Cli
|
|||
throw new \InvalidArgumentException('Too many arguments');
|
||||
}
|
||||
|
||||
if (!in_array($positional[0], ['validate', 'repair', 'convert', 'color'], true)) {
|
||||
if (!in_array($positional[0], ['validate', 'repair', 'convert', 'color'])) {
|
||||
throw new \InvalidArgumentException('Unknown command: '.$positional[0]);
|
||||
}
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
|
|
@ -167,14 +201,14 @@ class Cli
|
|||
}
|
||||
|
||||
if (null === $this->inputFormat) {
|
||||
if (str_ends_with($this->inputPath, '.json')) {
|
||||
if ('.json' === substr($this->inputPath, -5)) {
|
||||
$this->inputFormat = 'json';
|
||||
} else {
|
||||
$this->inputFormat = 'mimedir';
|
||||
}
|
||||
}
|
||||
if (null === $this->format) {
|
||||
if (str_ends_with($this->outputPath, '.json')) {
|
||||
if ('.json' === substr($this->outputPath, -5)) {
|
||||
$this->format = 'json';
|
||||
} else {
|
||||
$this->format = 'mimedir';
|
||||
|
|
@ -185,13 +219,12 @@ class Cli
|
|||
|
||||
try {
|
||||
while ($input = $this->readInput()) {
|
||||
// @phpstan-ignore method.dynamicName
|
||||
$returnCode = $this->$command($input);
|
||||
if (0 !== $returnCode) {
|
||||
$realCode = $returnCode;
|
||||
}
|
||||
}
|
||||
} catch (EofException) {
|
||||
} catch (EofException $e) {
|
||||
// end of file
|
||||
} catch (\Exception $e) {
|
||||
$this->log('Error: '.$e->getMessage(), 'red');
|
||||
|
|
@ -375,7 +408,7 @@ HELP
|
|||
}
|
||||
fwrite($this->stdout, json_encode($vObj->jsonSerialize(), $jsonOptions));
|
||||
} else {
|
||||
fwrite($this->stdout, (string) $vObj->serialize());
|
||||
fwrite($this->stdout, $vObj->serialize());
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
|
@ -505,7 +538,7 @@ HELP
|
|||
$this->cWrite('red', ':');
|
||||
|
||||
if ($property instanceof Property\Binary) {
|
||||
$this->cWrite('default', 'embedded binary stripped. ('.strlen((string) $property->getValue()).' bytes)');
|
||||
$this->cWrite('default', 'embedded binary stripped. ('.strlen($property->getValue()).' bytes)');
|
||||
} else {
|
||||
$parts = $property->getParts();
|
||||
$first1 = true;
|
||||
|
|
@ -560,17 +593,17 @@ HELP
|
|||
|
||||
$v = $argv[$ii];
|
||||
|
||||
if (str_starts_with((string) $v, '--')) {
|
||||
if ('--' === substr($v, 0, 2)) {
|
||||
// This is a long-form option.
|
||||
$optionName = substr((string) $v, 2);
|
||||
$optionName = substr($v, 2);
|
||||
$optionValue = true;
|
||||
if (strpos($optionName, '=')) {
|
||||
[$optionName, $optionValue] = explode('=', $optionName);
|
||||
list($optionName, $optionValue) = explode('=', $optionName);
|
||||
}
|
||||
$options[$optionName] = $optionValue;
|
||||
} elseif (str_starts_with((string) $v, '-') && strlen((string) $v) > 1) {
|
||||
} elseif ('-' === substr($v, 0, 1) && strlen($v) > 1) {
|
||||
// This is a short-form option.
|
||||
foreach (str_split(substr((string) $v, 1)) as $option) {
|
||||
foreach (str_split(substr($v, 1)) as $option) {
|
||||
$options[$option] = true;
|
||||
}
|
||||
} else {
|
||||
|
|
@ -595,9 +628,6 @@ HELP
|
|||
if (!$this->parser) {
|
||||
if ('-' !== $this->inputPath) {
|
||||
$this->stdin = fopen($this->inputPath, 'r');
|
||||
if (false === $this->stdin) {
|
||||
throw new InvalidDataException('Cannot open "'.$this->inputPath);
|
||||
}
|
||||
}
|
||||
|
||||
if ('mimedir' === $this->inputFormat) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Sabre\VObject;
|
||||
|
||||
use Sabre\VObject;
|
||||
use Sabre\Xml;
|
||||
|
||||
/**
|
||||
|
|
@ -14,7 +15,7 @@ use Sabre\Xml;
|
|||
* @author Evert Pot (http://evertpot.com/)
|
||||
* @license http://sabre.io/license/ Modified BSD License
|
||||
*
|
||||
* @property Property\FlatText UID
|
||||
* @property VObject\Property\FlatText UID
|
||||
*/
|
||||
class Component extends Node
|
||||
{
|
||||
|
|
@ -140,7 +141,7 @@ class Component extends Node
|
|||
// If there's no dot in the name, it's an exact property name,
|
||||
// we can just wipe out all those properties.
|
||||
//
|
||||
if (!str_contains($item, '.')) {
|
||||
if (false === strpos($item, '.')) {
|
||||
unset($this->children[strtoupper($item)]);
|
||||
|
||||
return;
|
||||
|
|
@ -210,8 +211,8 @@ class Component extends Node
|
|||
{
|
||||
$group = null;
|
||||
$name = strtoupper($name);
|
||||
if (str_contains($name, '.')) {
|
||||
[$group, $name] = explode('.', $name, 2);
|
||||
if (false !== strpos($name, '.')) {
|
||||
list($group, $name) = explode('.', $name, 2);
|
||||
}
|
||||
if ('' === $name) {
|
||||
$name = null;
|
||||
|
|
@ -222,14 +223,16 @@ class Component extends Node
|
|||
|
||||
if (is_null($group)) {
|
||||
return $result;
|
||||
} else {
|
||||
// If we have a group filter as well, we need to narrow it down
|
||||
// more.
|
||||
return array_filter(
|
||||
$result,
|
||||
function ($child) use ($group) {
|
||||
return $child instanceof Property && (null !== $child->group ? strtoupper($child->group) : '') === $group;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// If we have a group filter as well, we need to narrow it down
|
||||
// more.
|
||||
return array_filter(
|
||||
$result,
|
||||
fn ($child) => $child instanceof Property && (null !== $child->group ? strtoupper($child->group) : '') === $group
|
||||
);
|
||||
}
|
||||
|
||||
// If we got to this point, it means there was no 'name' specified for
|
||||
|
|
@ -366,7 +369,7 @@ class Component extends Node
|
|||
|
||||
$writer->startElement(strtolower($this->name));
|
||||
|
||||
if ([] !== $properties) {
|
||||
if (!empty($properties)) {
|
||||
$writer->startElement('properties');
|
||||
|
||||
foreach ($properties as $property) {
|
||||
|
|
@ -376,7 +379,7 @@ class Component extends Node
|
|||
$writer->endElement();
|
||||
}
|
||||
|
||||
if ([] !== $components) {
|
||||
if (!empty($components)) {
|
||||
$writer->startElement('components');
|
||||
|
||||
foreach ($components as $component) {
|
||||
|
|
@ -420,12 +423,13 @@ class Component extends Node
|
|||
$matches = $this->select($name);
|
||||
if (0 === count($matches)) {
|
||||
return null;
|
||||
}
|
||||
$firstMatch = current($matches);
|
||||
/* @var $firstMatch Property */
|
||||
$firstMatch->setIterator(new ElementList(array_values($matches)));
|
||||
} else {
|
||||
$firstMatch = current($matches);
|
||||
/* @var $firstMatch Property */
|
||||
$firstMatch->setIterator(new ElementList(array_values($matches)));
|
||||
|
||||
return $firstMatch;
|
||||
return $firstMatch;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -539,7 +543,7 @@ class Component extends Node
|
|||
$messages = [];
|
||||
|
||||
foreach ($this->children() as $child) {
|
||||
$name = strtoupper((string) $child->name);
|
||||
$name = strtoupper($child->name);
|
||||
if (!isset($propertyCounters[$name])) {
|
||||
$propertyCounters[$name] = 1;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -32,14 +32,20 @@ class VAlarm extends VObject\Component
|
|||
public function getEffectiveTriggerTime(): \DateTimeImmutable
|
||||
{
|
||||
$trigger = $this->TRIGGER;
|
||||
if (!isset($trigger['VALUE']) || ($trigger['VALUE'] && 'DURATION' === strtoupper((string) $trigger['VALUE']))) {
|
||||
if (!isset($trigger['VALUE']) || ($trigger['VALUE'] && 'DURATION' === strtoupper($trigger['VALUE']))) {
|
||||
$triggerDuration = VObject\DateTimeParser::parseDuration($this->TRIGGER);
|
||||
$related = (isset($trigger['RELATED']) && 'END' === strtoupper($trigger['RELATED'])) ? 'END' : 'START';
|
||||
$related = (isset($trigger['RELATED']) && 'END' == strtoupper($trigger['RELATED'])) ? 'END' : 'START';
|
||||
|
||||
/** @var VEvent|VTodo $parentComponent */
|
||||
$parentComponent = $this->parent;
|
||||
if ('START' === $related) {
|
||||
$effectiveTrigger = $parentComponent->DTSTART->getDateTime();
|
||||
if ('VTODO' === $parentComponent->name) {
|
||||
$propName = 'DUE';
|
||||
} else {
|
||||
$propName = 'DTSTART';
|
||||
}
|
||||
|
||||
$effectiveTrigger = $parentComponent->$propName->getDateTime();
|
||||
} else {
|
||||
if ('VTODO' === $parentComponent->name) {
|
||||
$endProp = 'DUE';
|
||||
|
|
@ -96,9 +102,9 @@ class VAlarm extends VObject\Component
|
|||
}
|
||||
|
||||
return false;
|
||||
} else {
|
||||
return $start <= $effectiveTrigger && $end > $effectiveTrigger;
|
||||
}
|
||||
|
||||
return $start <= $effectiveTrigger && $end > $effectiveTrigger;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class VAvailability extends VObject\Component
|
|||
*/
|
||||
public function isInTimeRange(\DateTimeInterface $start, \DateTimeInterface $end): bool
|
||||
{
|
||||
[$effectiveStart, $effectiveEnd] = $this->getEffectiveStartEnd();
|
||||
list($effectiveStart, $effectiveEnd) = $this->getEffectiveStartEnd();
|
||||
|
||||
return
|
||||
(is_null($effectiveStart) || $start < $effectiveEnd)
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ use Sabre\VObject\Recur\NoInstancesException;
|
|||
*
|
||||
* @property VEvent VEVENT
|
||||
* @property VJournal VJOURNAL
|
||||
* @property Property\Text ORG
|
||||
* @property Property\FlatText METHOD
|
||||
* @property VObject\Property\Text ORG
|
||||
* @property VObject\Property\FlatText METHOD
|
||||
*/
|
||||
class VCalendar extends VObject\Document
|
||||
{
|
||||
|
|
@ -51,21 +51,21 @@ class VCalendar extends VObject\Document
|
|||
* List of value-types, and which classes they map to.
|
||||
*/
|
||||
public static array $valueMap = [
|
||||
'BINARY' => Property\Binary::class,
|
||||
'BOOLEAN' => Property\Boolean::class,
|
||||
'CAL-ADDRESS' => Property\ICalendar\CalAddress::class,
|
||||
'DATE' => Property\ICalendar\Date::class,
|
||||
'DATE-TIME' => Property\ICalendar\DateTime::class,
|
||||
'DURATION' => Property\ICalendar\Duration::class,
|
||||
'FLOAT' => Property\FloatValue::class,
|
||||
'INTEGER' => Property\IntegerValue::class,
|
||||
'PERIOD' => Property\ICalendar\Period::class,
|
||||
'RECUR' => Property\ICalendar\Recur::class,
|
||||
'TEXT' => Property\Text::class,
|
||||
'TIME' => Property\Time::class,
|
||||
'UNKNOWN' => Property\Unknown::class, // jCard / jCal-only.
|
||||
'URI' => Property\Uri::class,
|
||||
'UTC-OFFSET' => Property\UtcOffset::class,
|
||||
'BINARY' => VObject\Property\Binary::class,
|
||||
'BOOLEAN' => VObject\Property\Boolean::class,
|
||||
'CAL-ADDRESS' => VObject\Property\ICalendar\CalAddress::class,
|
||||
'DATE' => VObject\Property\ICalendar\Date::class,
|
||||
'DATE-TIME' => VObject\Property\ICalendar\DateTime::class,
|
||||
'DURATION' => VObject\Property\ICalendar\Duration::class,
|
||||
'FLOAT' => VObject\Property\FloatValue::class,
|
||||
'INTEGER' => VObject\Property\IntegerValue::class,
|
||||
'PERIOD' => VObject\Property\ICalendar\Period::class,
|
||||
'RECUR' => VObject\Property\ICalendar\Recur::class,
|
||||
'TEXT' => VObject\Property\Text::class,
|
||||
'TIME' => VObject\Property\Time::class,
|
||||
'UNKNOWN' => VObject\Property\Unknown::class, // jCard / jCal-only.
|
||||
'URI' => VObject\Property\Uri::class,
|
||||
'UTC-OFFSET' => VObject\Property\UtcOffset::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -73,82 +73,78 @@ class VCalendar extends VObject\Document
|
|||
*/
|
||||
public static array $propertyMap = [
|
||||
// Calendar properties
|
||||
'CALSCALE' => Property\FlatText::class,
|
||||
'METHOD' => Property\FlatText::class,
|
||||
'PRODID' => Property\FlatText::class,
|
||||
'VERSION' => Property\FlatText::class,
|
||||
'CALSCALE' => VObject\Property\FlatText::class,
|
||||
'METHOD' => VObject\Property\FlatText::class,
|
||||
'PRODID' => VObject\Property\FlatText::class,
|
||||
'VERSION' => VObject\Property\FlatText::class,
|
||||
|
||||
// Component properties
|
||||
'ATTACH' => Property\Uri::class,
|
||||
'CATEGORIES' => Property\Text::class,
|
||||
'CLASS' => Property\FlatText::class,
|
||||
'COMMENT' => Property\FlatText::class,
|
||||
'DESCRIPTION' => Property\FlatText::class,
|
||||
'GEO' => Property\FloatValue::class,
|
||||
'LOCATION' => Property\FlatText::class,
|
||||
'PERCENT-COMPLETE' => Property\IntegerValue::class,
|
||||
'PRIORITY' => Property\IntegerValue::class,
|
||||
'RESOURCES' => Property\Text::class,
|
||||
'STATUS' => Property\FlatText::class,
|
||||
'SUMMARY' => Property\FlatText::class,
|
||||
'ATTACH' => VObject\Property\Uri::class,
|
||||
'CATEGORIES' => VObject\Property\Text::class,
|
||||
'CLASS' => VObject\Property\FlatText::class,
|
||||
'COMMENT' => VObject\Property\FlatText::class,
|
||||
'DESCRIPTION' => VObject\Property\FlatText::class,
|
||||
'GEO' => VObject\Property\FloatValue::class,
|
||||
'LOCATION' => VObject\Property\FlatText::class,
|
||||
'PERCENT-COMPLETE' => VObject\Property\IntegerValue::class,
|
||||
'PRIORITY' => VObject\Property\IntegerValue::class,
|
||||
'RESOURCES' => VObject\Property\Text::class,
|
||||
'STATUS' => VObject\Property\FlatText::class,
|
||||
'SUMMARY' => VObject\Property\FlatText::class,
|
||||
|
||||
// Date and Time Component Properties
|
||||
'COMPLETED' => Property\ICalendar\DateTime::class,
|
||||
'DTEND' => Property\ICalendar\DateTime::class,
|
||||
'DUE' => Property\ICalendar\DateTime::class,
|
||||
'DTSTART' => Property\ICalendar\DateTime::class,
|
||||
'DURATION' => Property\ICalendar\Duration::class,
|
||||
'FREEBUSY' => Property\ICalendar\Period::class,
|
||||
'TRANSP' => Property\FlatText::class,
|
||||
'COMPLETED' => VObject\Property\ICalendar\DateTime::class,
|
||||
'DTEND' => VObject\Property\ICalendar\DateTime::class,
|
||||
'DUE' => VObject\Property\ICalendar\DateTime::class,
|
||||
'DTSTART' => VObject\Property\ICalendar\DateTime::class,
|
||||
'DURATION' => VObject\Property\ICalendar\Duration::class,
|
||||
'FREEBUSY' => VObject\Property\ICalendar\Period::class,
|
||||
'TRANSP' => VObject\Property\FlatText::class,
|
||||
|
||||
// Time Zone Component Properties
|
||||
'TZID' => Property\FlatText::class,
|
||||
'TZNAME' => Property\FlatText::class,
|
||||
'TZOFFSETFROM' => Property\UtcOffset::class,
|
||||
'TZOFFSETTO' => Property\UtcOffset::class,
|
||||
'TZURL' => Property\Uri::class,
|
||||
'TZID' => VObject\Property\FlatText::class,
|
||||
'TZNAME' => VObject\Property\FlatText::class,
|
||||
'TZOFFSETFROM' => VObject\Property\UtcOffset::class,
|
||||
'TZOFFSETTO' => VObject\Property\UtcOffset::class,
|
||||
'TZURL' => VObject\Property\Uri::class,
|
||||
|
||||
// Relationship Component Properties
|
||||
'ATTENDEE' => Property\ICalendar\CalAddress::class,
|
||||
'CONTACT' => Property\FlatText::class,
|
||||
'ORGANIZER' => Property\ICalendar\CalAddress::class,
|
||||
'RECURRENCE-ID' => Property\ICalendar\DateTime::class,
|
||||
'RELATED-TO' => Property\FlatText::class,
|
||||
'URL' => Property\Uri::class,
|
||||
'UID' => Property\FlatText::class,
|
||||
'ATTENDEE' => VObject\Property\ICalendar\CalAddress::class,
|
||||
'CONTACT' => VObject\Property\FlatText::class,
|
||||
'ORGANIZER' => VObject\Property\ICalendar\CalAddress::class,
|
||||
'RECURRENCE-ID' => VObject\Property\ICalendar\DateTime::class,
|
||||
'RELATED-TO' => VObject\Property\FlatText::class,
|
||||
'URL' => VObject\Property\Uri::class,
|
||||
'UID' => VObject\Property\FlatText::class,
|
||||
|
||||
// Recurrence Component Properties
|
||||
'EXDATE' => Property\ICalendar\DateTime::class,
|
||||
'RDATE' => Property\ICalendar\DateTime::class,
|
||||
'RRULE' => Property\ICalendar\Recur::class,
|
||||
'EXRULE' => Property\ICalendar\Recur::class, // Deprecated since rfc5545
|
||||
'EXDATE' => VObject\Property\ICalendar\DateTime::class,
|
||||
'RDATE' => VObject\Property\ICalendar\DateTime::class,
|
||||
'RRULE' => VObject\Property\ICalendar\Recur::class,
|
||||
'EXRULE' => VObject\Property\ICalendar\Recur::class, // Deprecated since rfc5545
|
||||
|
||||
// Alarm Component Properties
|
||||
'ACTION' => Property\FlatText::class,
|
||||
'REPEAT' => Property\IntegerValue::class,
|
||||
'TRIGGER' => Property\ICalendar\Duration::class,
|
||||
'ACTION' => VObject\Property\FlatText::class,
|
||||
'REPEAT' => VObject\Property\IntegerValue::class,
|
||||
'TRIGGER' => VObject\Property\ICalendar\Duration::class,
|
||||
|
||||
// Change Management Component Properties
|
||||
'CREATED' => Property\ICalendar\DateTime::class,
|
||||
'DTSTAMP' => Property\ICalendar\DateTime::class,
|
||||
'LAST-MODIFIED' => Property\ICalendar\DateTime::class,
|
||||
'SEQUENCE' => Property\IntegerValue::class,
|
||||
'CREATED' => VObject\Property\ICalendar\DateTime::class,
|
||||
'DTSTAMP' => VObject\Property\ICalendar\DateTime::class,
|
||||
'LAST-MODIFIED' => VObject\Property\ICalendar\DateTime::class,
|
||||
'SEQUENCE' => VObject\Property\IntegerValue::class,
|
||||
|
||||
// Request Status
|
||||
'REQUEST-STATUS' => Property\Text::class,
|
||||
'REQUEST-STATUS' => VObject\Property\Text::class,
|
||||
|
||||
// Additions from draft-daboo-valarm-extensions-04
|
||||
'ALARM-AGENT' => Property\Text::class,
|
||||
'ACKNOWLEDGED' => Property\ICalendar\DateTime::class,
|
||||
'PROXIMITY' => Property\Text::class,
|
||||
'DEFAULT-ALARM' => Property\Boolean::class,
|
||||
'ALARM-AGENT' => VObject\Property\Text::class,
|
||||
'ACKNOWLEDGED' => VObject\Property\ICalendar\DateTime::class,
|
||||
'PROXIMITY' => VObject\Property\Text::class,
|
||||
'DEFAULT-ALARM' => VObject\Property\Boolean::class,
|
||||
|
||||
// Additions from draft-daboo-calendar-availability-05
|
||||
'BUSYTYPE' => Property\Text::class,
|
||||
|
||||
// RFC 7986 properties
|
||||
'NAME' => Property\FlatText::class,
|
||||
'COLOR' => Property\FlatText::class,
|
||||
'BUSYTYPE' => VObject\Property\Text::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -168,12 +164,12 @@ class VCalendar extends VObject\Document
|
|||
*
|
||||
* @param string|null $componentName filter by component name
|
||||
*
|
||||
* @return Component[]
|
||||
* @return VObject\Component[]
|
||||
*/
|
||||
public function getBaseComponents(?string $componentName = null): array
|
||||
public function getBaseComponents(string $componentName = null): array
|
||||
{
|
||||
$isBaseComponent = function ($component): bool {
|
||||
if (!$component instanceof Component) {
|
||||
if (!$component instanceof VObject\Component) {
|
||||
return false;
|
||||
}
|
||||
if ('VTIMEZONE' === $component->name) {
|
||||
|
|
@ -218,11 +214,13 @@ class VCalendar extends VObject\Document
|
|||
* If there is no such component, null will be returned.
|
||||
*
|
||||
* @param string|null $componentName filter by component name
|
||||
*
|
||||
* @return VObject\Component|null
|
||||
*/
|
||||
public function getBaseComponent(?string $componentName = null): ?Component
|
||||
public function getBaseComponent(string $componentName = null): ?Component
|
||||
{
|
||||
$isBaseComponent = function ($component): bool {
|
||||
if (!$component instanceof Component) {
|
||||
if (!$component instanceof VObject\Component) {
|
||||
return false;
|
||||
}
|
||||
if ('VTIMEZONE' === $component->name) {
|
||||
|
|
@ -277,7 +275,7 @@ class VCalendar extends VObject\Document
|
|||
* @throws InvalidDataException
|
||||
* @throws VObject\Recur\MaxInstancesExceededException
|
||||
*/
|
||||
public function expand(\DateTimeInterface $start, \DateTimeInterface $end, ?\DateTimeZone $timeZone = null): VCalendar
|
||||
public function expand(\DateTimeInterface $start, \DateTimeInterface $end, \DateTimeZone $timeZone = null): VCalendar
|
||||
{
|
||||
$newChildren = [];
|
||||
$recurringEvents = [];
|
||||
|
|
@ -331,7 +329,7 @@ class VCalendar extends VObject\Document
|
|||
foreach ($recurringEvents as $events) {
|
||||
try {
|
||||
$it = new EventIterator($events, null, $timeZone);
|
||||
} catch (NoInstancesException) {
|
||||
} catch (NoInstancesException $e) {
|
||||
// This event is recurring, but it doesn't have a single
|
||||
// instance. We are skipping this event from the output
|
||||
// entirely.
|
||||
|
|
@ -383,18 +381,6 @@ class VCalendar extends VObject\Document
|
|||
|
||||
'CALSCALE' => '?',
|
||||
'METHOD' => '?',
|
||||
|
||||
// RFC 7986 calendar properties
|
||||
'UID' => '?',
|
||||
'LAST-MODIFIED' => '?',
|
||||
'URL' => '?',
|
||||
'REFRESH-INTERVAL' => '?',
|
||||
'SOURCE' => '?',
|
||||
'COLOR' => '?',
|
||||
'NAME' => '*',
|
||||
'DESCRIPTION' => '*',
|
||||
'CATEGORIES' => '*',
|
||||
'IMAGE' => '*',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -440,7 +426,7 @@ class VCalendar extends VObject\Document
|
|||
if ($child instanceof Component) {
|
||||
++$componentsFound;
|
||||
|
||||
if (!in_array($child->name, ['VEVENT', 'VTODO', 'VJOURNAL'], true)) {
|
||||
if (!in_array($child->name, ['VEVENT', 'VTODO', 'VJOURNAL'])) {
|
||||
continue;
|
||||
}
|
||||
$componentTypes[] = $child->name;
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ class VCard extends VObject\Document
|
|||
'ROLE' => VObject\Property\FlatText::class,
|
||||
'LOGO' => VObject\Property\Binary::class,
|
||||
// 'AGENT' => 'Sabre\\VObject\\Property\\', // Todo: is an embedded vCard. Probably rare, so
|
||||
// not supported at the moment
|
||||
// not supported at the moment
|
||||
'ORG' => VObject\Property\Text::class,
|
||||
'NOTE' => VObject\Property\FlatText::class,
|
||||
'REV' => VObject\Property\VCard\TimeStamp::class,
|
||||
|
|
@ -132,13 +132,6 @@ class VCard extends VObject\Document
|
|||
'ORG-DIRECTORY' => VObject\Property\FlatText::class,
|
||||
|
||||
'X-CRYPTO' => VObject\Property\XCrypto::class,
|
||||
|
||||
// rfc9554 properties
|
||||
'CREATED' => VObject\Property\VCard\TimeStamp::class,
|
||||
'GRAMGENDER' => VObject\Property\FlatText::class,
|
||||
'LANGUAGE' => VObject\Property\VCard\LanguageTag::class,
|
||||
'PRONOUNS' => VObject\Property\FlatText::class,
|
||||
'SOCIALPROFILE' => VObject\Property\Uri::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -333,11 +326,6 @@ class VCard extends VObject\Document
|
|||
'CALURI' => '*',
|
||||
'CATEGORIES' => '*',
|
||||
'CLIENTPIDMAP' => '*',
|
||||
'CREATED' => '?',
|
||||
'GRAMGENDER' => '*',
|
||||
'LANGUAGE' => '?',
|
||||
'PRONOUNS' => '*',
|
||||
'SOCIALPROFILE' => '*',
|
||||
'EMAIL' => '*',
|
||||
'FBURL' => '*',
|
||||
'IMPP' => '*',
|
||||
|
|
@ -423,34 +411,6 @@ class VCard extends VObject\Document
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a property with a specific TYPE value (ADR, TEL, or EMAIL).
|
||||
*
|
||||
* This function will return null if the exact property list does not exist.
|
||||
*
|
||||
* For example to get the property of `TEL;TYPE=HOME,CELL`
|
||||
* you would call `getByTypes('TEL', ['HOME', 'CELL'])`
|
||||
*
|
||||
* @param string[] $types
|
||||
*
|
||||
* @return \ArrayAccess|array|null
|
||||
*/
|
||||
public function getByTypes(string $propertyName, array $types)
|
||||
{
|
||||
$types = array_map(strtolower(...), $types);
|
||||
foreach ($this->select($propertyName) as $field) {
|
||||
if (isset($field['TYPE'])) {
|
||||
$parts = array_map(strtolower(...), $field['TYPE']->getParts());
|
||||
|
||||
if (!array_diff($types, $parts) && !array_diff($parts, $types)) {
|
||||
return $field;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method should return a list of default property values.
|
||||
*/
|
||||
|
|
@ -493,9 +453,11 @@ class VCard extends VObject\Document
|
|||
$propertiesByGroup = [];
|
||||
|
||||
foreach ($this->children() as $property) {
|
||||
$group = $property->group ?? '';
|
||||
$group = $property->group;
|
||||
|
||||
$propertiesByGroup[$group] ??= [];
|
||||
if (!isset($propertiesByGroup[$group])) {
|
||||
$propertiesByGroup[$group] = [];
|
||||
}
|
||||
|
||||
$propertiesByGroup[$group][] = $property;
|
||||
}
|
||||
|
|
@ -503,9 +465,9 @@ class VCard extends VObject\Document
|
|||
$writer->startElement(strtolower($this->name));
|
||||
|
||||
foreach ($propertiesByGroup as $group => $properties) {
|
||||
if ('' !== $group) {
|
||||
if (!empty($group)) {
|
||||
$writer->startElement('group');
|
||||
$writer->writeAttribute('name', strtolower((string) $group));
|
||||
$writer->writeAttribute('name', strtolower($group));
|
||||
}
|
||||
|
||||
foreach ($properties as $property) {
|
||||
|
|
@ -525,7 +487,7 @@ class VCard extends VObject\Document
|
|||
}
|
||||
}
|
||||
|
||||
if ('' !== $group) {
|
||||
if (!empty($group)) {
|
||||
$writer->endElement();
|
||||
}
|
||||
}
|
||||
|
|
@ -541,7 +503,7 @@ class VCard extends VObject\Document
|
|||
$className = parent::getClassNameForPropertyName($propertyName);
|
||||
|
||||
// In vCard 4, BINARY no longer exists, and we need URI instead.
|
||||
if (VObject\Property\Binary::class === $className && self::VCARD40 === $this->getDocumentType()) {
|
||||
if (VObject\Property\Binary::class == $className && self::VCARD40 === $this->getDocumentType()) {
|
||||
return VObject\Property\Uri::class;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,10 +41,10 @@ class VEvent extends VObject\Component
|
|||
*/
|
||||
public function isInTimeRange(\DateTimeInterface $start, \DateTimeInterface $end): bool
|
||||
{
|
||||
if ($this->RRULE || $this->RDATE) {
|
||||
if ($this->RRULE) {
|
||||
try {
|
||||
$it = new EventIterator($this, null, $start->getTimezone());
|
||||
} catch (NoInstancesException) {
|
||||
} catch (NoInstancesException $e) {
|
||||
// If we've caught this exception, there are no instances
|
||||
// for the event that fall into the specified time-range.
|
||||
return false;
|
||||
|
|
@ -117,7 +117,6 @@ class VEvent extends VObject\Component
|
|||
'DTSTART' => $hasMethod ? '?' : '1',
|
||||
'CLASS' => '?',
|
||||
'CREATED' => '?',
|
||||
'COLOR' => '?',
|
||||
'DESCRIPTION' => '?',
|
||||
'GEO' => '?',
|
||||
'LAST-MODIFIED' => '?',
|
||||
|
|
@ -138,9 +137,7 @@ class VEvent extends VObject\Component
|
|||
'ATTENDEE' => '*',
|
||||
'CATEGORIES' => '*',
|
||||
'COMMENT' => '*',
|
||||
'CONFERENCE' => '*',
|
||||
'CONTACT' => '*',
|
||||
'IMAGE' => '*',
|
||||
'EXDATE' => '*',
|
||||
'REQUEST-STATUS' => '*',
|
||||
'RELATED-TO' => '*',
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class VFreeBusy extends VObject\Component
|
|||
// Every period is formatted as [start]/[end]. The start is an
|
||||
// absolute UTC time, the end may be an absolute UTC time, or
|
||||
// duration (relative) value.
|
||||
[$busyStart, $busyEnd] = explode('/', $period);
|
||||
list($busyStart, $busyEnd) = explode('/', $period);
|
||||
|
||||
$busyStart = VObject\DateTimeParser::parse($busyStart);
|
||||
$busyEnd = VObject\DateTimeParser::parse($busyEnd);
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ class VJournal extends VObject\Component
|
|||
|
||||
'CLASS' => '?',
|
||||
'CREATED' => '?',
|
||||
'COLOR' => '?',
|
||||
'DTSTART' => '?',
|
||||
'LAST-MODIFIED' => '?',
|
||||
'ORGANIZER' => '?',
|
||||
|
|
@ -79,7 +78,6 @@ class VJournal extends VObject\Component
|
|||
'CATEGORIES' => '*',
|
||||
'COMMENT' => '*',
|
||||
'CONTACT' => '*',
|
||||
'IMAGE' => '*',
|
||||
'DESCRIPTION' => '*',
|
||||
'EXDATE' => '*',
|
||||
'RELATED-TO' => '*',
|
||||
|
|
|
|||
|
|
@ -54,9 +54,9 @@ class VTodo extends VObject\Component
|
|||
return
|
||||
($start < $due || $start <= $dtstart)
|
||||
&& ($end > $dtstart || $end >= $due);
|
||||
} else {
|
||||
return $start <= $dtstart && $end > $dtstart;
|
||||
}
|
||||
|
||||
return $start <= $dtstart && $end > $dtstart;
|
||||
}
|
||||
if ($due) {
|
||||
return $start < $due && $end >= $due;
|
||||
|
|
@ -98,14 +98,13 @@ class VTodo extends VObject\Component
|
|||
'CLASS' => '?',
|
||||
'COMPLETED' => '?',
|
||||
'CREATED' => '?',
|
||||
'COLOR' => '?',
|
||||
'DESCRIPTION' => '?',
|
||||
'DTSTART' => '?',
|
||||
'GEO' => '?',
|
||||
'LAST-MODIFIED' => '?',
|
||||
'LOCATION' => '?',
|
||||
'ORGANIZER' => '?',
|
||||
'PERCENT-COMPLETE' => '?',
|
||||
'PERCENT' => '?',
|
||||
'PRIORITY' => '?',
|
||||
'RECURRENCE-ID' => '?',
|
||||
'SEQUENCE' => '?',
|
||||
|
|
@ -121,9 +120,7 @@ class VTodo extends VObject\Component
|
|||
'ATTENDEE' => '*',
|
||||
'CATEGORIES' => '*',
|
||||
'COMMENT' => '*',
|
||||
'CONFERENCE' => '*',
|
||||
'CONTACT' => '*',
|
||||
'IMAGE' => '*',
|
||||
'EXDATE' => '*',
|
||||
'REQUEST-STATUS' => '*',
|
||||
'RELATED-TO' => '*',
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class DateTimeParser
|
|||
*
|
||||
* @throws InvalidDataException
|
||||
*/
|
||||
public static function parseDateTime(string $dt, ?\DateTimeZone $tz = null): \DateTimeImmutable
|
||||
public static function parseDateTime(string $dt, \DateTimeZone $tz = null): \DateTimeImmutable
|
||||
{
|
||||
// Format is YYYYMMDD + "T" + hhmmss
|
||||
$result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])T([0-2][0-9])([0-5][0-9])([0-5][0-9])([Z]?)$/', $dt, $matches);
|
||||
|
|
@ -42,7 +42,7 @@ class DateTimeParser
|
|||
|
||||
try {
|
||||
$date = new \DateTimeImmutable($matches[1].'-'.$matches[2].'-'.$matches[3].' '.$matches[4].':'.$matches[5].':'.$matches[6], $tz);
|
||||
} catch (\Exception) {
|
||||
} catch (\Exception $e) {
|
||||
throw new InvalidDataException('The supplied iCalendar datetime value is incorrect: '.$dt);
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ class DateTimeParser
|
|||
*
|
||||
* @throws InvalidDataException
|
||||
*/
|
||||
public static function parseDate(string $date, ?\DateTimeZone $tz = null): \DateTimeImmutable
|
||||
public static function parseDate(string $date, \DateTimeZone $tz = null): \DateTimeImmutable
|
||||
{
|
||||
// Format is YYYYMMDD
|
||||
$result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])$/', $date, $matches);
|
||||
|
|
@ -63,11 +63,13 @@ class DateTimeParser
|
|||
throw new InvalidDataException('The supplied iCalendar date value is incorrect: '.$date);
|
||||
}
|
||||
|
||||
$tz ??= new \DateTimeZone('UTC');
|
||||
if (is_null($tz)) {
|
||||
$tz = new \DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
try {
|
||||
$date = new \DateTimeImmutable($matches[1].'-'.$matches[2].'-'.$matches[3], $tz);
|
||||
} catch (\Exception) {
|
||||
} catch (\Exception $e) {
|
||||
throw new InvalidDataException('The supplied iCalendar date value is incorrect: '.$date);
|
||||
}
|
||||
|
||||
|
|
@ -96,12 +98,12 @@ class DateTimeParser
|
|||
}
|
||||
|
||||
$parts = [
|
||||
'week',
|
||||
'day',
|
||||
'hour',
|
||||
'minute',
|
||||
'second',
|
||||
];
|
||||
'week',
|
||||
'day',
|
||||
'hour',
|
||||
'minute',
|
||||
'second',
|
||||
];
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$matches[$part] = isset($matches[$part]) && $matches[$part] ? (int) $matches[$part] : 0;
|
||||
|
|
@ -203,9 +205,9 @@ class DateTimeParser
|
|||
return self::parseDuration($date);
|
||||
} elseif (8 === strlen($date)) {
|
||||
return self::parseDate($date, $referenceTz);
|
||||
} else {
|
||||
return self::parseDateTime($date, $referenceTz);
|
||||
}
|
||||
|
||||
return self::parseDateTime($date, $referenceTz);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -327,7 +329,7 @@ class DateTimeParser
|
|||
|
||||
$result = [];
|
||||
foreach ($parts as $part) {
|
||||
if (!array_key_exists($part, $matches) || '' === $matches[$part]) {
|
||||
if (empty($matches[$part])) {
|
||||
$result[$part] = null;
|
||||
} elseif ('-' === $matches[$part] || '--' === $matches[$part]) {
|
||||
$result[$part] = null;
|
||||
|
|
@ -422,7 +424,7 @@ class DateTimeParser
|
|||
|
||||
$result = [];
|
||||
foreach ($parts as $part) {
|
||||
if (!array_key_exists($part, $matches) || '' === $matches[$part]) {
|
||||
if (empty($matches[$part])) {
|
||||
$result[$part] = null;
|
||||
} elseif ('-' === $matches[$part]) {
|
||||
$result[$part] = null;
|
||||
|
|
@ -539,7 +541,7 @@ class DateTimeParser
|
|||
$parts['year0'] = &$parts['year'];
|
||||
|
||||
foreach ($parts as $part => &$value) {
|
||||
if (array_key_exists($part, $matches) && '' !== $matches[$part]) {
|
||||
if (!empty($matches[$part])) {
|
||||
$value = $matches[$part];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace Sabre\VObject;
|
||||
|
||||
use Sabre\VObject;
|
||||
|
||||
/**
|
||||
* Document.
|
||||
*
|
||||
|
|
@ -16,7 +18,7 @@ namespace Sabre\VObject;
|
|||
* @author Evert Pot (http://evertpot.com/)
|
||||
* @license http://sabre.io/license/ Modified BSD License
|
||||
*
|
||||
* @property Property\FlatText VERSION
|
||||
* @property VObject\Property\FlatText VERSION
|
||||
*/
|
||||
abstract class Document extends Component
|
||||
{
|
||||
|
|
@ -118,10 +120,10 @@ abstract class Document extends Component
|
|||
public function create(string $name)
|
||||
{
|
||||
if (isset(static::$componentMap[strtoupper($name)])) {
|
||||
return $this->createComponent(...func_get_args());
|
||||
return call_user_func_array([$this, 'createComponent'], func_get_args());
|
||||
} else {
|
||||
return call_user_func_array([$this, 'createProperty'], func_get_args());
|
||||
}
|
||||
|
||||
return $this->createProperty(...func_get_args());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -138,7 +140,7 @@ abstract class Document extends Component
|
|||
* an iCalendar object, this may be something like CALSCALE:GREGORIAN. To
|
||||
* ensure that this does not happen, set $defaults to false.
|
||||
*/
|
||||
public function createComponent(string $name, ?array $children = null, bool $defaults = true): Component
|
||||
public function createComponent(string $name, array $children = null, bool $defaults = true): Component
|
||||
{
|
||||
$name = strtoupper($name);
|
||||
$class = Component::class;
|
||||
|
|
@ -146,7 +148,9 @@ abstract class Document extends Component
|
|||
if (isset(static::$componentMap[$name])) {
|
||||
$class = static::$componentMap[$name];
|
||||
}
|
||||
$children ??= [];
|
||||
if (is_null($children)) {
|
||||
$children = [];
|
||||
}
|
||||
|
||||
return new $class($this, $name, $children, $defaults);
|
||||
}
|
||||
|
|
@ -165,7 +169,7 @@ abstract class Document extends Component
|
|||
*
|
||||
* @throws InvalidDataException
|
||||
*/
|
||||
public function createProperty(string $name, $value = null, ?array $parameters = null, ?string $valueType = null, ?int $lineIndex = null, ?string $lineString = null): Property
|
||||
public function createProperty(string $name, $value = null, array $parameters = null, string $valueType = null): Property
|
||||
{
|
||||
// If there's a . in the name, it means it's prefixed by a group name.
|
||||
if (false !== ($i = strpos($name, '.'))) {
|
||||
|
|
@ -178,31 +182,28 @@ abstract class Document extends Component
|
|||
|
||||
$class = null;
|
||||
|
||||
// If a VALUE parameter is supplied, we have to use that
|
||||
// According to https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.20
|
||||
// If the property's value is the default value type, then this
|
||||
// parameter need not be specified. However, if the property's
|
||||
// default value type is overridden by some other allowable value
|
||||
// type, then this parameter MUST be specified.
|
||||
if (!$valueType) {
|
||||
$valueType = $parameters['VALUE'] ?? null;
|
||||
}
|
||||
|
||||
if ($valueType) {
|
||||
// The valueType argument comes first to figure out the correct
|
||||
// class.
|
||||
$class = $this->getClassNameForPropertyValue($valueType);
|
||||
}
|
||||
|
||||
// If the value parameter is not set or set to something we do not recognize
|
||||
// we do not attempt to interpret or parse the datass value as specified in
|
||||
// https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.20
|
||||
// So when we so far did not get a class-name, we use the default for the property
|
||||
$class ??= $this->getClassNameForPropertyName($name);
|
||||
if (is_null($class)) {
|
||||
// If a VALUE parameter is supplied, we should use that.
|
||||
if (isset($parameters['VALUE'])) {
|
||||
$class = $this->getClassNameForPropertyValue($parameters['VALUE']);
|
||||
if (is_null($class)) {
|
||||
throw new InvalidDataException('Unsupported VALUE parameter for '.$name.' property. You supplied "'.$parameters['VALUE'].'"');
|
||||
}
|
||||
} else {
|
||||
$class = $this->getClassNameForPropertyName($name);
|
||||
}
|
||||
}
|
||||
if (is_null($parameters)) {
|
||||
$parameters = [];
|
||||
}
|
||||
|
||||
$parameters ??= [];
|
||||
|
||||
return new $class($this, $name, $value, $parameters, $group, $lineIndex, $lineString);
|
||||
return new $class($this, $name, $value, $parameters, $group);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -11,19 +11,25 @@ namespace Sabre\VObject;
|
|||
*/
|
||||
class FreeBusyData
|
||||
{
|
||||
/**
|
||||
* Start timestamp.
|
||||
*/
|
||||
protected int $start;
|
||||
|
||||
/**
|
||||
* End timestamp.
|
||||
*/
|
||||
protected int $end;
|
||||
|
||||
/**
|
||||
* A list of free-busy times.
|
||||
*/
|
||||
protected array $data;
|
||||
|
||||
public function __construct(/**
|
||||
* Start timestamp.
|
||||
*/
|
||||
protected int $start, /**
|
||||
* End timestamp.
|
||||
*/
|
||||
protected int $end)
|
||||
public function __construct(int $start, int $end)
|
||||
{
|
||||
$this->start = $start;
|
||||
$this->end = $end;
|
||||
$this->data = [];
|
||||
|
||||
$this->data[] = [
|
||||
|
|
|
|||
|
|
@ -72,14 +72,16 @@ class FreeBusyGenerator
|
|||
* Check the setTimeRange and setObjects methods for details about the
|
||||
* arguments.
|
||||
*/
|
||||
public function __construct(?\DateTimeInterface $start = null, ?\DateTimeInterface $end = null, $objects = null, ?\DateTimeZone $timeZone = null)
|
||||
public function __construct(\DateTimeInterface $start = null, \DateTimeInterface $end = null, $objects = null, \DateTimeZone $timeZone = null)
|
||||
{
|
||||
$this->setTimeRange($start, $end);
|
||||
|
||||
if ($objects) {
|
||||
$this->setObjects($objects);
|
||||
}
|
||||
$timeZone ??= new \DateTimeZone('UTC');
|
||||
if (is_null($timeZone)) {
|
||||
$timeZone = new \DateTimeZone('UTC');
|
||||
}
|
||||
$this->setTimeZone($timeZone);
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +138,7 @@ class FreeBusyGenerator
|
|||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function setTimeRange(?\DateTimeInterface $start = null, ?\DateTimeInterface $end = null): void
|
||||
public function setTimeRange(\DateTimeInterface $start = null, \DateTimeInterface $end = null): void
|
||||
{
|
||||
if (!$start) {
|
||||
$start = new \DateTimeImmutable(Settings::$minDate);
|
||||
|
|
@ -215,7 +217,7 @@ class FreeBusyGenerator
|
|||
$new = [];
|
||||
|
||||
foreach ($old as $vavail) {
|
||||
[$compStart, $compEnd] = $vavail->getEffectiveStartEnd();
|
||||
list($compStart, $compEnd) = $vavail->getEffectiveStartEnd();
|
||||
|
||||
// We don't care about date-times that are earlier or later than the
|
||||
// start and end of the freebusy report, so this gets normalized
|
||||
|
|
@ -235,7 +237,7 @@ class FreeBusyGenerator
|
|||
// Going through our existing list of components to see if there's
|
||||
// a higher priority component that already fully covers this one.
|
||||
foreach ($new as $higherVavail) {
|
||||
[$higherStart, $higherEnd] = $higherVavail->getEffectiveStartEnd();
|
||||
list($higherStart, $higherEnd) = $higherVavail->getEffectiveStartEnd();
|
||||
if (
|
||||
(is_null($higherStart) || $higherStart < $compStart)
|
||||
&& (is_null($higherEnd) || $higherEnd > $compEnd)
|
||||
|
|
@ -257,7 +259,7 @@ class FreeBusyGenerator
|
|||
// priority components to override the lower ones.
|
||||
foreach (array_reverse($new) as $vavail) {
|
||||
$busyType = isset($vavail->BUSYTYPE) ? strtoupper($vavail->BUSYTYPE) : 'BUSY-UNAVAILABLE';
|
||||
[$vavailStart, $vavailEnd] = $vavail->getEffectiveStartEnd();
|
||||
list($vavailStart, $vavailEnd) = $vavail->getEffectiveStartEnd();
|
||||
|
||||
// Making the component size no larger than the requested free-busy
|
||||
// report range.
|
||||
|
|
@ -279,7 +281,7 @@ class FreeBusyGenerator
|
|||
// Looping over the AVAILABLE components.
|
||||
if (isset($vavail->AVAILABLE)) {
|
||||
foreach ($vavail->AVAILABLE as $available) {
|
||||
[$availStart, $availEnd] = $available->getEffectiveStartEnd();
|
||||
list($availStart, $availEnd) = $available->getEffectiveStartEnd();
|
||||
$fbData->add(
|
||||
$availStart->getTimeStamp(),
|
||||
$availEnd->getTimeStamp(),
|
||||
|
|
@ -360,7 +362,7 @@ class FreeBusyGenerator
|
|||
if ($component->RRULE) {
|
||||
try {
|
||||
$iterator = new EventIterator($object, (string) $component->UID, $this->timeZone);
|
||||
} catch (NoInstancesException) {
|
||||
} catch (NoInstancesException $e) {
|
||||
// This event is recurring, but it doesn't have a single
|
||||
// instance. We are skipping this event from the output
|
||||
// entirely.
|
||||
|
|
@ -435,12 +437,12 @@ class FreeBusyGenerator
|
|||
continue;
|
||||
}
|
||||
|
||||
$values = explode(',', (string) $freebusy);
|
||||
$values = explode(',', $freebusy);
|
||||
foreach ($values as $value) {
|
||||
[$startTime, $endTime] = explode('/', $value);
|
||||
list($startTime, $endTime) = explode('/', $value);
|
||||
$startTime = DateTimeParser::parseDateTime($startTime);
|
||||
|
||||
if (str_starts_with($endTime, 'P') || str_starts_with($endTime, '-P')) {
|
||||
if ('P' === substr($endTime, 0, 1) || '-P' === substr($endTime, 0, 2)) {
|
||||
$duration = DateTimeParser::parseDuration($endTime);
|
||||
$endTime = clone $startTime;
|
||||
$endTime = $endTime->add($duration);
|
||||
|
|
@ -505,7 +507,7 @@ class FreeBusyGenerator
|
|||
$vfreebusy->add($dtstamp);
|
||||
|
||||
foreach ($fbData->getData() as $busyTime) {
|
||||
$busyType = strtoupper((string) $busyTime['type']);
|
||||
$busyType = strtoupper($busyTime['type']);
|
||||
|
||||
// Ignoring all the FREE parts, because those are already assumed.
|
||||
if ('FREE' === $busyType) {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue