mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-10-30 00:53:33 +03:00
Compare commits
205 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8afb1049f7 | ||
|
|
b5315e4044 | ||
|
|
b792fd461d | ||
|
|
6d0b2121b8 | ||
|
|
7d861eb92f | ||
|
|
f14544992d | ||
|
|
78aa114d21 | ||
|
|
97e646fda4 | ||
|
|
2d25109aa3 | ||
|
|
afece6e85c | ||
|
|
03151d72a0 | ||
|
|
08bc097dd4 | ||
|
|
28fc6f5e0f | ||
|
|
f455aea5ff | ||
|
|
8f645388fc | ||
|
|
7bce091177 | ||
|
|
eb47ce41a3 | ||
|
|
49f94a5650 | ||
|
|
870df2d861 | ||
|
|
343ef172d1 | ||
|
|
bca01170a4 | ||
|
|
ddf28c299c | ||
|
|
b5c1be34c2 | ||
|
|
0204ca48ee | ||
|
|
0358fdf971 | ||
|
|
a0e093d527 | ||
|
|
19de853729 | ||
|
|
66725386b0 | ||
|
|
e1e94c815d | ||
|
|
7c432e392b | ||
|
|
37fcdbe534 | ||
|
|
c9f7d27551 | ||
|
|
843b69bfa8 | ||
|
|
61ebcb6ed4 | ||
|
|
ceb9f68ded | ||
|
|
d7e7250b16 | ||
|
|
0a8e37ccc8 | ||
|
|
2cff632b2d | ||
|
|
2c658b7c31 | ||
|
|
4997853b78 | ||
|
|
6fba843d15 | ||
|
|
3659552fac | ||
|
|
3432795930 | ||
|
|
fb4a247916 | ||
|
|
a174bf7ba0 | ||
|
|
77be04d427 | ||
|
|
cf185b9ab5 | ||
|
|
30bb5c934d | ||
|
|
b9397e6e20 | ||
|
|
fd6beedc0c | ||
|
|
45b94153c1 | ||
|
|
02e96a9339 | ||
|
|
b3edb5fb0a | ||
|
|
58d553b1a2 | ||
|
|
2f26779f4a | ||
|
|
9f0e6d946a | ||
|
|
85b142f559 | ||
|
|
1be5abd122 | ||
|
|
a9ca71bae1 | ||
|
|
0805f71461 | ||
|
|
abb83e48b0 | ||
|
|
f52249b77b | ||
|
|
1bbd7f635a | ||
|
|
8a8304e81a | ||
|
|
e7a491b0f5 | ||
|
|
6c9d3edc18 | ||
|
|
5dca6d4f65 | ||
|
|
2af0af173e | ||
|
|
7fdc77ce5f | ||
|
|
86dc155d1c | ||
|
|
5de44c77c2 | ||
|
|
dfc9420385 | ||
|
|
e66f325aa3 | ||
|
|
aad651f2a1 | ||
|
|
67a68915db | ||
|
|
71287022ca | ||
|
|
3da27c3bc8 | ||
|
|
020927cc56 | ||
|
|
8667af700d | ||
|
|
4e65e7ee84 | ||
|
|
aca14765c9 | ||
|
|
be91e4eb80 | ||
|
|
24d7dee9b9 | ||
|
|
6ef8a9a391 | ||
|
|
08ddc323f9 | ||
|
|
279cc6406e | ||
|
|
3dd6c85c87 | ||
|
|
e4ecda0e61 | ||
|
|
640c567210 | ||
|
|
8a2d8e86a5 | ||
|
|
6a7cbe070c | ||
|
|
1a0c9872b2 | ||
|
|
863f719da3 | ||
|
|
68588287ef | ||
|
|
60ee74c68a | ||
|
|
ed35c8e91e | ||
|
|
f757d58534 | ||
|
|
c154d23cfe | ||
|
|
c1f1faca58 | ||
|
|
dc0191aaf1 | ||
|
|
a8117a9d03 | ||
|
|
f2648a54c0 | ||
|
|
1dc26f11d2 | ||
|
|
c203d15b17 | ||
|
|
2c54ac9be3 | ||
|
|
2c73a955e2 | ||
|
|
658b97f5b4 | ||
|
|
ba496919dd | ||
|
|
24bb509b96 | ||
|
|
56409dcbb4 | ||
|
|
b4a6068d5b | ||
|
|
40cf2b950e | ||
|
|
7397a95a7c | ||
|
|
9dd7c021fd | ||
|
|
9f6d2e4cb7 | ||
|
|
93e3c431ec | ||
|
|
a9b89fd7b3 | ||
|
|
da96945f39 | ||
|
|
0bc9950b42 | ||
|
|
c3660bee57 | ||
|
|
dea7f4d1d8 | ||
|
|
00128e837f | ||
|
|
d9eaad24ad | ||
|
|
9565559213 | ||
|
|
1c9c9f0d6b | ||
|
|
d3b120bbe7 | ||
|
|
a024d34f6d | ||
|
|
33fbaa9598 | ||
|
|
86da00eb50 | ||
|
|
45c8f71af0 | ||
|
|
70aebb4981 | ||
|
|
0233c83719 | ||
|
|
e37546b795 | ||
|
|
2741762199 | ||
|
|
ecbd13f099 | ||
|
|
5bba3dac82 | ||
|
|
ed4b2dd4e1 | ||
|
|
fad9d5e482 | ||
|
|
1660c62aa3 | ||
|
|
dddf360db6 | ||
|
|
7268dafffd | ||
|
|
8bf4727555 | ||
|
|
208b1fab62 | ||
|
|
149cf5a120 | ||
|
|
5973d71e2f | ||
|
|
486acde2e3 | ||
|
|
e16d0b52ae | ||
|
|
c0348893af | ||
|
|
0ffd97a183 | ||
|
|
2143051eaf | ||
|
|
7eaa065d76 | ||
|
|
7a465606bc | ||
|
|
cd3398db20 | ||
|
|
18da535643 | ||
|
|
d301b93b0d | ||
|
|
66553a36da | ||
|
|
95af4f60ea | ||
|
|
3ee978484a | ||
|
|
8b5547104d | ||
|
|
0d5d403fb8 | ||
|
|
91ff43e6ab | ||
|
|
bc6a2b67c0 | ||
|
|
4cd5774257 | ||
|
|
c744b83480 | ||
|
|
3d55bdaf5a | ||
|
|
2809e75307 | ||
|
|
a83c3aacf8 | ||
|
|
8645b0ab1e | ||
|
|
490d808cdb | ||
|
|
f12de9a3fd | ||
|
|
1b4bf3b8c3 | ||
|
|
64e780e500 | ||
|
|
709d67226c | ||
|
|
cab3b2b78a | ||
|
|
c50edb826b | ||
|
|
332f9a79e3 | ||
|
|
534b71e5a4 | ||
|
|
f0b3d798bc | ||
|
|
b01072e2e3 | ||
|
|
bce9758338 | ||
|
|
3a589e240c | ||
|
|
6bdd509b48 | ||
|
|
4f6783a991 | ||
|
|
012c1102f9 | ||
|
|
78547e8c68 | ||
|
|
fdc1a1c924 | ||
|
|
f877b849eb | ||
|
|
a1f890e3b4 | ||
|
|
7aa87da449 | ||
|
|
64f74d0511 | ||
|
|
5d679b5be5 | ||
|
|
6bc658b5e0 | ||
|
|
bc6de6c336 | ||
|
|
583528c448 | ||
|
|
6307470ac0 | ||
|
|
35914c0406 | ||
|
|
5861124c2a | ||
|
|
0ef2987911 | ||
|
|
3d970e4706 | ||
|
|
838e73fa94 | ||
|
|
42c98d42e0 | ||
|
|
055c0d3bf4 | ||
|
|
16106a28d5 | ||
|
|
9eb7f38485 | ||
|
|
82ab4c4c6d |
306 changed files with 9897 additions and 7044 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM alpine:3.18.5 AS builder
|
FROM alpine:3.24.3 AS builder
|
||||||
RUN apk add --no-cache php82 php82-json php-phar php-zip
|
RUN apk add --no-cache php85 php85-json php-phar php-zip
|
||||||
RUN apk add --no-cache npm
|
RUN apk add --no-cache npm
|
||||||
RUN npm install -g gulp yarn
|
RUN npm install -g gulp yarn
|
||||||
WORKDIR /source
|
WORKDIR /source
|
||||||
|
|
@ -23,7 +23,7 @@ RUN set -eux; \
|
||||||
rm -v /snappymail/README.md /snappymail/_include.php
|
rm -v /snappymail/README.md /snappymail/_include.php
|
||||||
|
|
||||||
# Inspired by the original Rainloop dockerfile from youtous on GitLab
|
# Inspired by the original Rainloop dockerfile from youtous on GitLab
|
||||||
FROM php:8.2-fpm-alpine AS final
|
FROM php:8.5-fpm-alpine AS final
|
||||||
|
|
||||||
LABEL org.label-schema.description="SnappyMail webmail client image using nginx, php-fpm on Alpine"
|
LABEL org.label-schema.description="SnappyMail webmail client image using nginx, php-fpm on Alpine"
|
||||||
|
|
||||||
|
|
@ -99,9 +99,6 @@ RUN set -eux; \
|
||||||
# mysql
|
# mysql
|
||||||
RUN docker-php-ext-install pdo_mysql
|
RUN docker-php-ext-install pdo_mysql
|
||||||
|
|
||||||
# opcache
|
|
||||||
RUN docker-php-ext-install opcache
|
|
||||||
|
|
||||||
# postgres
|
# postgres
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
apk add --no-cache postgresql-libs; \
|
apk add --no-cache postgresql-libs; \
|
||||||
|
|
@ -122,6 +119,13 @@ RUN set -eux; \
|
||||||
apk del .build-dependencies; \
|
apk del .build-dependencies; \
|
||||||
apk del .deps
|
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
|
# tidy
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
apk add --no-cache tidyhtml; \
|
apk add --no-cache tidyhtml; \
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ UPLOAD_MAX_SIZE=${UPLOAD_MAX_SIZE:-25M}
|
||||||
MEMORY_LIMIT=${MEMORY_LIMIT:-128M}
|
MEMORY_LIMIT=${MEMORY_LIMIT:-128M}
|
||||||
SECURE_COOKIES=${SECURE_COOKIES:-true}
|
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
|
# 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/<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
|
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
|
## Publishing and Credits
|
||||||
|
|
||||||
I will analyze and fix the reported issue as fast as possible.
|
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.
|
Credits to the reporter are granted and can be included in all public communication if desired.
|
||||||
|
|
|
||||||
247
cli/compile.php
Executable file
247
cli/compile.php
Executable file
|
|
@ -0,0 +1,247 @@
|
||||||
|
#!/usr/bin/php
|
||||||
|
<?php
|
||||||
|
define('ROOT_DIR', dirname(__DIR__));
|
||||||
|
chdir(ROOT_DIR);
|
||||||
|
|
||||||
|
$options = getopt('', ['readme','source','knockout','openpgp','squire']);
|
||||||
|
$return_var = '';
|
||||||
|
|
||||||
|
function execCmd($cmd)
|
||||||
|
{
|
||||||
|
passthru($cmd, $return_var);
|
||||||
|
if ($return_var) {
|
||||||
|
exit("`{$cmd}` failed with error code {$return_var}\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($options['knockout'])) {
|
||||||
|
chdir(ROOT_DIR . '/vendors/knockout');
|
||||||
|
if ($grunt = trim(`which grunt`)) {
|
||||||
|
execCmd($grunt, $return_var);
|
||||||
|
} else {
|
||||||
|
exit('grunt not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = ROOT_DIR . '/vendors/knockout/build/output/knockout-latest.js';
|
||||||
|
$data = file_get_contents($file);
|
||||||
|
/*
|
||||||
|
Strip start: (()=>{'use strict';var aa="function"==typeof Object.defineProperties?Object.defineProperty:function(C,F,E){if(C==Array.prototype||C==Object.prototype)return C;C[F]=E.value;return C};function ba(C){C=["object"==typeof globalThis&&globalThis,C,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var F=0;F<C.length;++F){var E=C[F];if(E&&E.Math==Math)return E}throw Error("Cannot find global object");}var ca=ba(this);
|
||||||
|
function Z(C,F){if(F)a:{var E=ca;C=C.split(".");for(var J=0;J<C.length-1;J++){var S=C[J];if(!(S in E))break a;E=E[S]}C=C[C.length-1];J=E[C];F=F(J);F!=J&&null!=F&&aa(E,C,{configurable:!0,writable:!0,value:F})}}Z("Object.entries",function(C){return C?C:function(F){var E=[],J;for(J in F)Object.prototype.hasOwnProperty.call(F,J)&&E.push([J,F[J]]);return E}});
|
||||||
|
Z("Array.prototype.includes",function(C){return C?C:function(F,E){var J=this;J instanceof String&&(J=String(J));var S=J.length;E=E||0;for(0>E&&(E=Math.max(E+S,0));E<S;E++){var V=J[E];if(V===F||Object.is(V,F))return!0}return!1}});
|
||||||
|
Strip end: })();
|
||||||
|
*/
|
||||||
|
|
||||||
|
$p1 = strpos($data, '(()=>');
|
||||||
|
// ECMASCRIPT_202
|
||||||
|
$data = substr($data, 0, $p1) . substr($data, $p1 + 6, -6) . "\n";
|
||||||
|
// ECMASCRIPT_2018
|
||||||
|
// $data = substr($data, 0, $p1) . substr($data, $p1 + 19, -6) . "\n";
|
||||||
|
// ECMASCRIPT6
|
||||||
|
// $data = substr($data, 0, $p1) . substr($data, strpos($data, 'return!0}return!1}});') + 22, -6) . "\n";
|
||||||
|
|
||||||
|
file_put_contents($file, $data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($options['openpgp'])) {
|
||||||
|
chdir(ROOT_DIR . '/vendors/openpgp-5');
|
||||||
|
execCmd('git pull', $return_var);
|
||||||
|
execCmd('./build.sh', $return_var);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($options['squire'])) {
|
||||||
|
chdir(ROOT_DIR . '/vendors/squire2');
|
||||||
|
// execCmd('git pull', $return_var);
|
||||||
|
// execCmd('git checkout snappymail', $return_var);
|
||||||
|
execCmd('./build.sh', $return_var);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($options['source'])) {
|
||||||
|
#cd ROOT_DIR . '/vendors/openpgp-2.6.2/';
|
||||||
|
#rollup -c
|
||||||
|
#npm cache clean --force
|
||||||
|
|
||||||
|
if ($gulp = trim(`which gulp`)) {
|
||||||
|
execCmd($gulp, $return_var);
|
||||||
|
} else {
|
||||||
|
exit('gulp not installed, run as root: npm install --global gulp-cli');
|
||||||
|
}
|
||||||
|
|
||||||
|
#rollup -c
|
||||||
|
|
||||||
|
$cmddir = escapeshellcmd(ROOT_DIR) . '/snappymail/v/0.0.0/static';
|
||||||
|
|
||||||
|
if ($gzip = trim(`which gzip`)) {
|
||||||
|
echo "\x1b[33;1m === Gzip *.js and *.css === \x1b[0m\n";
|
||||||
|
// execCmd("{$gzip} -k --best {$cmddir}/js/*.js");
|
||||||
|
execCmd("{$gzip} -k --best {$cmddir}/js/min/*.js");
|
||||||
|
execCmd("{$gzip} -k --best {$cmddir}/css/admin*.css");
|
||||||
|
execCmd("{$gzip} -k --best {$cmddir}/css/app*.css");
|
||||||
|
// unlink($cmddir . '/js/boot.js.gz');
|
||||||
|
// unlink($cmddir . '/js/min/boot.min.js.gz');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($brotli = trim(`which brotli`)) {
|
||||||
|
echo "\x1b[33;1m === Brotli *.js and *.css === \x1b[0m\n";
|
||||||
|
// execCmd("{$brotli} -k --best {$cmddir}/js/*.js");
|
||||||
|
execCmd("{$brotli} -k --best {$cmddir}/js/min/*.js");
|
||||||
|
execCmd("{$brotli} -k --best {$cmddir}/css/admin*.css");
|
||||||
|
execCmd("{$brotli} -k --best {$cmddir}/css/app*.css");
|
||||||
|
// unlink($cmddir . '/js/boot.js.br');
|
||||||
|
// unlink($cmddir . '/js/min/boot.min.js.br');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($options['readme'])) {
|
||||||
|
|
||||||
|
$basedir = escapeshellcmd(ROOT_DIR) . '/snappymail/v/0.0.0';
|
||||||
|
|
||||||
|
$readme = file_get_contents(ROOT_DIR . '/README.md');
|
||||||
|
|
||||||
|
$total = filesize("{$basedir}/static/js/serviceworker.js");
|
||||||
|
foreach (['admin','app','boot','libs','sieve'] as $name) {
|
||||||
|
$size = filesize("{$basedir}/static/js/{$name}.js");
|
||||||
|
$total += $size;
|
||||||
|
$readme = preg_replace("/(\\|{$name}\\.js\\s+\\|[\\s0-9.]+\\|)[\\s0-9.]+\\|/Ds",
|
||||||
|
'$1' . str_pad(number_format($size, 0, '', '.'), 9, ' ', STR_PAD_LEFT) . ' |',
|
||||||
|
$readme
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$readme = preg_replace("/(\\|TOTAL\\s+\\|8[\\s0-9.]+\\|)[\\s0-9.]+\\|/Ds",
|
||||||
|
'$1' . str_pad(number_format($total, 0, '', '.'), 9, ' ', STR_PAD_LEFT) . ' |',
|
||||||
|
$readme
|
||||||
|
);
|
||||||
|
|
||||||
|
$total = [];
|
||||||
|
$total_br = [];
|
||||||
|
$total_gz = [];
|
||||||
|
foreach (['admin','app','boot','libs','sieve'] as $name) {
|
||||||
|
$size = filesize("{$basedir}/static/js/min/{$name}.min.js");
|
||||||
|
$size_br = filesize("{$basedir}/static/js/min/{$name}.min.js.br");
|
||||||
|
$size_gz = filesize("{$basedir}/static/js/min/{$name}.min.js.gz");
|
||||||
|
$total[$name] = $size;
|
||||||
|
$total_br[$name] = $size_br;
|
||||||
|
$total_gz[$name] = $size_gz;
|
||||||
|
$readme = preg_replace(
|
||||||
|
"/(\\|{$name}\\.min\\.js\\s+\\|[\\s0-9.]+)\\|[\\s0-9.]+(\\|[\\s0-9.]+)\\|[\\s0-9.]+(\\|[\\s0-9.]+)\\|[\\s0-9.]+\\|/Ds",
|
||||||
|
'$1|'
|
||||||
|
. str_pad(number_format($size, 0, '', '.'), 9, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '$2|'
|
||||||
|
. str_pad(number_format($size_gz, 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '$3|'
|
||||||
|
. str_pad(number_format($size_br, 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '|'
|
||||||
|
,
|
||||||
|
$readme
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$readme = preg_replace(
|
||||||
|
"/(TOTAL user\+sieve\\s+\\|[\\s0-9.]+)\\|[\\s0-9.]+(\\|[\\s0-9.]+)\\|[\\s0-9.]+(\\|[\\s0-9.]+)\\|[\\s0-9.]+\\|/Ds",
|
||||||
|
'$1|'
|
||||||
|
. str_pad(number_format(array_sum($total) - $total['admin'], 0, '', '.'), 9, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '$2|'
|
||||||
|
. str_pad(number_format(array_sum($total_gz) - $total_gz['admin'], 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '$3|'
|
||||||
|
. str_pad(number_format(array_sum($total_br) - $total_br['admin'], 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '|'
|
||||||
|
,
|
||||||
|
$readme
|
||||||
|
);
|
||||||
|
|
||||||
|
//$smaller = 1217635 / (array_sum($total) - $total['admin']);
|
||||||
|
$smaller = 358761 / (array_sum($total_gz) - $total_gz['admin']);
|
||||||
|
//$smaller = 299485 / (array_sum($total_br) - $total_br['admin']);
|
||||||
|
$readme = preg_replace(
|
||||||
|
'/(For a user it is around) [0-9]+/',
|
||||||
|
'$1 ' . round(100 - 100 / $smaller),
|
||||||
|
$readme
|
||||||
|
);
|
||||||
|
|
||||||
|
unset($total['sieve']);
|
||||||
|
unset($total_br['sieve']);
|
||||||
|
unset($total_gz['sieve']);
|
||||||
|
$readme = preg_replace(
|
||||||
|
"/(TOTAL user\\s+\\|[\\s0-9.]+)\\|[\\s0-9.]+(\\|[\\s0-9.]+)\\|[\\s0-9.]+(\\|[\\s0-9.]+)\\|[\\s0-9.]+\\|/Ds",
|
||||||
|
'$1|'
|
||||||
|
. str_pad(number_format(array_sum($total) - $total['admin'], 0, '', '.'), 9, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '$2|'
|
||||||
|
. str_pad(number_format(array_sum($total_gz) - $total_gz['admin'], 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '$3|'
|
||||||
|
. str_pad(number_format(array_sum($total_br) - $total_br['admin'], 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '|'
|
||||||
|
,
|
||||||
|
$readme
|
||||||
|
);
|
||||||
|
$readme = preg_replace(
|
||||||
|
"/(TOTAL admin\\s+\\|[\\s0-9.]+)\\|[\\s0-9.]+(\\|[\\s0-9.]+)\\|[\\s0-9.]+(\\|[\\s0-9.]+)\\|[\\s0-9.]+\\|/Ds",
|
||||||
|
'$1|'
|
||||||
|
. str_pad(number_format(array_sum($total) - $total['app'], 0, '', '.'), 9, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '$2|'
|
||||||
|
. str_pad(number_format(array_sum($total_gz) - $total_gz['app'], 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '$3|'
|
||||||
|
. str_pad(number_format(array_sum($total_br) - $total_br['app'], 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '|'
|
||||||
|
,
|
||||||
|
$readme
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (['admin','app'] as $name) {
|
||||||
|
$readme = preg_replace(
|
||||||
|
"/(\\|{$name}\\.css\\s+\\|[\\s0-9.]+)\\|[\\s0-9.]+(\\|[\\s0-9.]+)\\|[\\s0-9.]+\\|[\\s0-9.]+\\|/Ds",
|
||||||
|
'$1|'
|
||||||
|
. str_pad(number_format(filesize("{$basedir}/static/css/{$name}.css"), 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '$2|'
|
||||||
|
. str_pad(number_format(filesize("{$basedir}/static/css/{$name}.css.gz"), 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' |'
|
||||||
|
. str_pad(number_format(filesize("{$basedir}/static/css/{$name}.css.br"), 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '|'
|
||||||
|
,
|
||||||
|
$readme
|
||||||
|
);
|
||||||
|
$readme = preg_replace(
|
||||||
|
"/(\\|{$name}\\.min\\.css\\s+\\|[\\s0-9.]+)\\|[\\s0-9.]+(\\|[\\s0-9.]+)\\|[\\s0-9.]+\\|[\\s0-9.]+\\|/Ds",
|
||||||
|
'$1|'
|
||||||
|
. str_pad(number_format(filesize("{$basedir}/static/css/{$name}.min.css"), 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '$2|'
|
||||||
|
. str_pad(number_format(filesize("{$basedir}/static/css/{$name}.min.css.gz"), 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' |'
|
||||||
|
. str_pad(number_format(filesize("{$basedir}/static/css/{$name}.min.css.br"), 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '|'
|
||||||
|
,
|
||||||
|
$readme
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$sizes = [
|
||||||
|
filesize("{$basedir}/static/js/min/openpgp.min.js"),
|
||||||
|
filesize("{$basedir}/static/js/min/openpgp.min.js.gz"),
|
||||||
|
filesize("{$basedir}/static/js/min/openpgp.min.js.br")
|
||||||
|
];
|
||||||
|
//|openpgp.min.js | 330.742 | 540.542 |102.388 | 167.397 | 84.241 | 137.502 |
|
||||||
|
$readme = preg_replace(
|
||||||
|
"/(openpgp\.min\.js\\s+\\|[\\s0-9.]+)\\|[\\s0-9.]+(\\|[\\s0-9.]+)\\|[\\s0-9.]+(\\|[\\s0-9.]+)\\|[\\s0-9.]+\\|/Ds",
|
||||||
|
'$1|'
|
||||||
|
. str_pad(number_format($sizes[0], 0, '', '.'), 9, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '$2| '
|
||||||
|
. str_pad(number_format($sizes[1], 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '$3| '
|
||||||
|
. str_pad(number_format($sizes[2], 0, '', '.'), 7, ' ', STR_PAD_LEFT) . ' '
|
||||||
|
. '|'
|
||||||
|
,
|
||||||
|
$readme
|
||||||
|
);
|
||||||
|
|
||||||
|
//file://
|
||||||
|
|
||||||
|
file_put_contents(ROOT_DIR . '/README.md', $readme);
|
||||||
|
|
||||||
|
$data = file_put_contents(
|
||||||
|
"{$basedir}/app/templates/Views/Admin/AdminSettingsAbout.html",
|
||||||
|
preg_replace(
|
||||||
|
'/20[0-9][0-9] ©/',
|
||||||
|
date('Y').' ©',
|
||||||
|
file_get_contents("{$basedir}/app/templates/Views/Admin/AdminSettingsAbout.html")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ if (isset($options['plugins'])) {
|
||||||
require(ROOT_DIR . '/build/plugins.php');
|
require(ROOT_DIR . '/build/plugins.php');
|
||||||
}
|
}
|
||||||
|
|
||||||
$gulp = trim(`which gulp`);
|
$gulp = trim(shell_exec('which gulp'));
|
||||||
if (!$gulp) {
|
if (!$gulp) {
|
||||||
exit('gulp not installed, run as root: npm install --global gulp-cli');
|
exit('gulp not installed, run as root: npm install --global gulp-cli');
|
||||||
}
|
}
|
||||||
|
|
@ -34,7 +34,7 @@ $options['aur'] = isset($options['aur']);
|
||||||
|
|
||||||
// Docker build
|
// Docker build
|
||||||
$options['docker'] = isset($options['docker']);
|
$options['docker'] = isset($options['docker']);
|
||||||
if ($options['docker'] && !($docker = trim(`which docker`))) {
|
if ($options['docker'] && !($docker = trim(shell_exec('which docker')))) {
|
||||||
exit('docker not found');
|
exit('docker not found');
|
||||||
}
|
}
|
||||||
if ($options['docker'] && $options['aur']) {
|
if ($options['docker'] && $options['aur']) {
|
||||||
|
|
@ -60,7 +60,7 @@ if (!isset($options['skip-gulp'])) {
|
||||||
|
|
||||||
$cmddir = escapeshellcmd(ROOT_DIR) . '/snappymail/v/0.0.0/static';
|
$cmddir = escapeshellcmd(ROOT_DIR) . '/snappymail/v/0.0.0/static';
|
||||||
|
|
||||||
if ($gzip = trim(`which gzip`)) {
|
if ($gzip = trim(shell_exec('which gzip'))) {
|
||||||
echo "\x1b[33;1m === Gzip *.js and *.css === \x1b[0m\n";
|
echo "\x1b[33;1m === Gzip *.js and *.css === \x1b[0m\n";
|
||||||
passthru("{$gzip} -k --best {$cmddir}/js/*.js");
|
passthru("{$gzip} -k --best {$cmddir}/js/*.js");
|
||||||
passthru("{$gzip} -k --best {$cmddir}/js/min/*.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');
|
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/min/boot.min.js.gz');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($brotli = trim(`which brotli`)) {
|
if ($brotli = trim(shell_exec('which brotli'))) {
|
||||||
echo "\x1b[33;1m === Brotli *.js and *.css === \x1b[0m\n";
|
echo "\x1b[33;1m === Brotli *.js and *.css === \x1b[0m\n";
|
||||||
passthru("{$brotli} -k --best {$cmddir}/js/*.js");
|
passthru("{$brotli} -k --best {$cmddir}/js/*.js");
|
||||||
passthru("{$brotli} -k --best {$cmddir}/js/min/*.js");
|
passthru("{$brotli} -k --best {$cmddir}/js/min/*.js");
|
||||||
|
|
@ -188,7 +188,7 @@ file_put_contents("{$destPath}core.json", '{
|
||||||
// Arch User Repository
|
// Arch User Repository
|
||||||
if ($options['aur']) {
|
if ($options['aur']) {
|
||||||
// extension_loaded('blake2')
|
// extension_loaded('blake2')
|
||||||
if (!function_exists('b2sum') && $b2sum = trim(`which b2sum`)) {
|
if (!function_exists('b2sum') && $b2sum = trim(shell_exec('which b2sum'))) {
|
||||||
function b2sum($file) {
|
function b2sum($file) {
|
||||||
$file = escapeshellarg($file);
|
$file = escapeshellarg($file);
|
||||||
exec("b2sum --binary {$file} 2>&1", $output, $exitcode);
|
exec("b2sum --binary {$file} 2>&1", $output, $exitcode);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'External/User/ko';
|
import 'External/User/ko';
|
||||||
|
|
||||||
import { SMAudio } from 'Common/Audio';
|
import { SMAudio } from 'Common/Audio';
|
||||||
|
import { arrayLength } from 'Common/Utils';
|
||||||
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser';
|
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -240,9 +241,17 @@ export class AppUser extends AbstractApp {
|
||||||
|
|
||||||
showMessageComposer(params = [])
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AskPopupView.password = function(sAskDesc, btnText, ask) {
|
AskPopupView.password = function(sAskDesc, btnText, ask) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
|
|
@ -251,7 +260,8 @@ AskPopupView.password = function(sAskDesc, btnText, ask) {
|
||||||
view => resolve({
|
view => resolve({
|
||||||
password:view.passphrase(),
|
password:view.passphrase(),
|
||||||
username:/*ask & 2 ? */view.username(),
|
username:/*ask & 2 ? */view.username(),
|
||||||
remember:/*ask & 4 ? */view.remember()
|
remember:/*ask & 4 ? */view.remember(),
|
||||||
|
rememberPermanent: view.rememberPermanent(),
|
||||||
}),
|
}),
|
||||||
() => resolve(null),
|
() => resolve(null),
|
||||||
true,
|
true,
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,7 @@ export const
|
||||||
detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'),
|
detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'),
|
||||||
|
|
||||||
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
|
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
|
||||||
|
minFontSize = parseInt(SettingsUserStore.minFontSize()),
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
hasExternals: false,
|
hasExternals: false,
|
||||||
|
|
@ -257,6 +258,15 @@ export const
|
||||||
rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px'
|
rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px'
|
||||||
},
|
},
|
||||||
allowedAttributes = [
|
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
|
// defaults
|
||||||
'name',
|
'name',
|
||||||
'dir', 'lang', 'style', 'title',
|
'dir', 'lang', 'style', 'title',
|
||||||
|
|
@ -299,8 +309,11 @@ export const
|
||||||
// Not supported by <template> element
|
// Not supported by <template> element
|
||||||
// .replace(/<!doctype[^>]*>/gi, '')
|
// .replace(/<!doctype[^>]*>/gi, '')
|
||||||
// .replace(/<\?xml[^>]*\?>/gi, '')
|
// .replace(/<\?xml[^>]*\?>/gi, '')
|
||||||
|
// https://github.com/the-djmaze/snappymail/issues/1860
|
||||||
|
.replace(/<(\/?)x-html(\s[^>]*)?>/gi, '')
|
||||||
.replace(/<(\/?)head(\s[^>]*)?>/gi, '')
|
.replace(/<(\/?)head(\s[^>]*)?>/gi, '')
|
||||||
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
|
.replace(/<body(\s[^>]*)?>/i, '<div class="mail-body"$1>')
|
||||||
|
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div$2>')
|
||||||
// .replace(/<\/?(html|head)[^>]*>/gi, '')
|
// .replace(/<\/?(html|head)[^>]*>/gi, '')
|
||||||
// Fix Reddit https://github.com/the-djmaze/snappymail/issues/540
|
// Fix Reddit https://github.com/the-djmaze/snappymail/issues/540
|
||||||
.replace(/<span class="preview-text"[\s\S]+?<\/span>/, '')
|
.replace(/<span class="preview-text"[\s\S]+?<\/span>/, '')
|
||||||
|
|
@ -493,12 +506,12 @@ export const
|
||||||
let attachment;
|
let attachment;
|
||||||
if (value.startsWith('cid:'))
|
if (value.startsWith('cid:'))
|
||||||
{
|
{
|
||||||
|
setAttribute('data-x-src', value);
|
||||||
value = value.slice(4);
|
value = value.slice(4);
|
||||||
setAttribute('data-x-src-cid', value);
|
|
||||||
attachment = findAttachmentByCid(value);
|
attachment = findAttachmentByCid(value);
|
||||||
if (attachment?.download) {
|
if (attachment?.download) {
|
||||||
oElement.src = attachment.linkPreview();
|
oElement.src = attachment.linkPreview();
|
||||||
oElement.title += ' ('+attachment.fileName+')';
|
// oElement.title || (oElement.title = '+attachment.fileName);
|
||||||
attachment.isInline(true);
|
attachment.isInline(true);
|
||||||
attachment.isLinked(true);
|
attachment.isLinked(true);
|
||||||
}
|
}
|
||||||
|
|
@ -614,12 +627,14 @@ export const
|
||||||
if (urls_broken.length) {
|
if (urls_broken.length) {
|
||||||
setAttribute('data-x-style-broken-urls', JSON.stringify(urls_broken));
|
setAttribute('data-x-style-broken-urls', JSON.stringify(urls_broken));
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
// https://github.com/the-djmaze/snappymail/issues/1082
|
// https://github.com/the-djmaze/snappymail/issues/1082
|
||||||
if (11 > pInt(oStyle.fontSize)) {
|
// Use getComputedStyle?
|
||||||
oStyle.removeProperty('font-size');
|
if (oStyle.fontSize && minFontSize > pInt(oStyle.fontSize)) {
|
||||||
|
oStyle.fontSize = minFontSize + "px";
|
||||||
|
// oStyle.setProperty('font-size', minFontSize + "px");
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
// Removes background and color
|
// Removes background and color
|
||||||
// Many e-mails incorrectly only define one, not both
|
// Many e-mails incorrectly only define one, not both
|
||||||
// And in dark theme mode this kills the readability
|
// 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);
|
fValue.subscribe(fUpdateEditorValue);
|
||||||
|
|
||||||
// ko.utils.domNodeDisposal.addDisposeCallback(element, () => {
|
// ko.addDisposeCallback(element, () => {
|
||||||
// });
|
// });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
dev/External/ko.js
vendored
7
dev/External/ko.js
vendored
|
|
@ -15,6 +15,7 @@ export const
|
||||||
* based solely on the values of other observables in the application
|
* based solely on the values of other observables in the application
|
||||||
*/
|
*/
|
||||||
koComputable = fn => ko.computed(fn, {'pure':true}),
|
koComputable = fn => ko.computed(fn, {'pure':true}),
|
||||||
|
// koObservable = value => ko.observable(value),
|
||||||
|
|
||||||
addObservablesTo = (target, observables) =>
|
addObservablesTo = (target, observables) =>
|
||||||
forEachObjectEntry(observables, (key, value) =>
|
forEachObjectEntry(observables, (key, value) =>
|
||||||
|
|
@ -30,7 +31,7 @@ export const
|
||||||
|
|
||||||
onEvent = (element, event, fn) => {
|
onEvent = (element, event, fn) => {
|
||||||
element.addEventListener(event, fn);
|
element.addEventListener(event, fn);
|
||||||
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener(event, fn));
|
ko.addDisposeCallback(element, () => element.removeEventListener(event, fn));
|
||||||
},
|
},
|
||||||
|
|
||||||
onKey = (key, element, fValueAccessor, fAllBindings, model) => {
|
onKey = (key, element, fValueAccessor, fAllBindings, model) => {
|
||||||
|
|
@ -175,6 +176,6 @@ ko.extenders.falseTimeout = (target, option) => {
|
||||||
|
|
||||||
// functions
|
// functions
|
||||||
|
|
||||||
ko.observable.fn.askDeleteHelper = function() {
|
ko.observable.fn.askDeleteHelper = function(prop='askDelete') {
|
||||||
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, 'askDelete'] });
|
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, prop] });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -73,4 +73,9 @@ export class EmailModel extends AbstractModel {
|
||||||
}
|
}
|
||||||
return result || name;
|
return result || name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get domain() {
|
||||||
|
return this.email.replace(/^.+@([^@]+)$/, '$1');
|
||||||
|
// return this.email.slice(this.email.lastIndexOf('@'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
//import { AbstractModel } from 'Knoin/AbstractModel';
|
||||||
|
import { EmailModel } from 'Model/Email';
|
||||||
import { addObservablesTo, addComputablesTo } from 'External/ko';
|
import { addObservablesTo, addComputablesTo } from 'External/ko';
|
||||||
|
|
||||||
export class IdentityModel extends AbstractModel {
|
export class IdentityModel extends EmailModel /*AbstractModel*/ {
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
* @param {string} email
|
|
||||||
*/
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
addObservablesTo(this, {
|
addObservablesTo(this, {
|
||||||
id: '',
|
id: '',
|
||||||
label: '',
|
label: '',
|
||||||
email: '',
|
// email: '',
|
||||||
name: '',
|
// name: '',
|
||||||
|
|
||||||
replyTo: '',
|
replyTo: '',
|
||||||
bcc: '',
|
bcc: '',
|
||||||
|
|
@ -27,6 +24,7 @@ export class IdentityModel extends AbstractModel {
|
||||||
|
|
||||||
smimeKey: '',
|
smimeKey: '',
|
||||||
smimeCertificate: '',
|
smimeCertificate: '',
|
||||||
|
smimeCertificateChain: '',
|
||||||
|
|
||||||
askDelete: false,
|
askDelete: false,
|
||||||
|
|
||||||
|
|
@ -36,16 +34,18 @@ export class IdentityModel extends AbstractModel {
|
||||||
addComputablesTo(this, {
|
addComputablesTo(this, {
|
||||||
smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----'),
|
smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----'),
|
||||||
smimeKeyValid: () => /^-----BEGIN (ENCRYPTED |RSA )?PRIVATE KEY-----/.test(this.smimeKey()),
|
smimeKeyValid: () => /^-----BEGIN (ENCRYPTED |RSA )?PRIVATE KEY-----/.test(this.smimeKey()),
|
||||||
smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate())
|
smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate()),
|
||||||
|
smimeCertificateChainValid: () => !this.smimeCertificateChain()
|
||||||
|
|| /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificateChain())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
formattedName() {
|
toString() {
|
||||||
const name = this.name(),
|
const name = this.name,
|
||||||
email = this.email(),
|
email = this.email,
|
||||||
label = this.label();
|
label = this.label();
|
||||||
return (name ? `${name} ` : '') + `<${email}>` + (label ? ` (${label})` : '');
|
return (name ? `${name} ` : '') + `<${email}>` + (label ? ` (${label})` : '');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ import Remote from 'Remote/User/Fetch';
|
||||||
|
|
||||||
import { MimeToMessage } from 'Mime/Utils';
|
import { MimeToMessage } from 'Mime/Utils';
|
||||||
|
|
||||||
|
import { PgpUserStore } from 'Stores/User/Pgp';
|
||||||
|
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||||
|
import { Passphrases } from 'Storage/Passphrases';
|
||||||
|
|
||||||
const
|
const
|
||||||
PreviewHTML = `<html>
|
PreviewHTML = `<html>
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -598,4 +602,140 @@ export class MessageModel extends AbstractModel {
|
||||||
return result.html || plainToHtml(this.plain());
|
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() {
|
testUsername() {
|
||||||
Remote.request('AdminDomainMatch',
|
Remote.request('AdminDomainMatch',
|
||||||
(iError, oData) => {
|
(iError, oData) => {
|
||||||
if (oData?.Result?.domain) {
|
if (oData?.Result?.name) {
|
||||||
alert(`${oData.Result.email} matched domain: ${oData.Result.domain.name}`);
|
alert(`${oData.Result.email} matched domain: ${oData.Result.name}`);
|
||||||
} else {
|
} else {
|
||||||
alert('No domain match');
|
alert('No domain match');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import ko from 'ko';
|
||||||
import { koComputable } from 'External/ko';
|
import { koComputable } from 'External/ko';
|
||||||
|
|
||||||
import { SettingsGet } from 'Common/Globals';
|
import { SettingsGet } from 'Common/Globals';
|
||||||
import { i18n, translateTrigger } from 'Common/Translator';
|
import { i18n, translateTrigger, getErrorMessage } from 'Common/Translator';
|
||||||
import { ContactUserStore } from 'Stores/User/Contact';
|
import { ContactUserStore } from 'Stores/User/Contact';
|
||||||
import Remote from 'Remote/User/Fetch';
|
import Remote from 'Remote/User/Fetch';
|
||||||
|
|
||||||
|
|
@ -15,6 +15,7 @@ export class UserSettingsContacts /*extends AbstractViewSettings*/ {
|
||||||
this.syncUrl = ContactUserStore.syncUrl;
|
this.syncUrl = ContactUserStore.syncUrl;
|
||||||
this.syncUser = ContactUserStore.syncUser;
|
this.syncUser = ContactUserStore.syncUser;
|
||||||
this.syncPass = ContactUserStore.syncPass;
|
this.syncPass = ContactUserStore.syncPass;
|
||||||
|
this.syncError = ko.observable('');
|
||||||
|
|
||||||
this.syncModeOptions = koComputable(() => {
|
this.syncModeOptions = koComputable(() => {
|
||||||
translateTrigger();
|
translateTrigger();
|
||||||
|
|
@ -48,4 +49,15 @@ 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,6 +44,51 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
||||||
this.desktopNotifications = NotificationUserStore.enabled;
|
this.desktopNotifications = NotificationUserStore.enabled;
|
||||||
this.isDesktopNotificationAllowed = NotificationUserStore.allowed;
|
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;
|
this.threadsAllowed = AppUserStore.threadsAllowed;
|
||||||
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
|
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
|
||||||
this.threadAlgorithms = ko.observableArray();
|
this.threadAlgorithms = ko.observableArray();
|
||||||
|
|
@ -51,12 +96,11 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
||||||
capa.startsWith('THREAD=') && this.threadAlgorithms.push(capa.slice(7))
|
capa.startsWith('THREAD=') && this.threadAlgorithms.push(capa.slice(7))
|
||||||
);
|
);
|
||||||
this.threadAlgorithms.sort((a, b) => a.length - b.length);
|
this.threadAlgorithms.sort((a, b) => a.length - b.length);
|
||||||
this.threadAlgorithm = SettingsUserStore.threadAlgorithm;
|
|
||||||
|
|
||||||
['useThreads', 'threadAlgorithm',
|
['defaultSort', 'useThreads', 'threadAlgorithm',
|
||||||
// These use addSetting()
|
// These use addSetting()
|
||||||
'layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
|
'layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
|
||||||
'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel',
|
'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel', 'minFontSize',
|
||||||
// These are in addSettings()
|
// These are in addSettings()
|
||||||
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
||||||
'viewHTML', 'viewImages', 'viewImagesWhitelist', 'removeColors', 'allowStyles', 'allowDraftAutosave',
|
'viewHTML', 'viewImages', 'viewImagesWhitelist', 'removeColors', 'allowStyles', 'allowDraftAutosave',
|
||||||
|
|
@ -78,7 +122,7 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
||||||
|
|
||||||
identityMainDesc: () => {
|
identityMainDesc: () => {
|
||||||
const identity = IdentityUserStore.main();
|
const identity = IdentityUserStore.main();
|
||||||
return identity ? identity.formattedName() : '---';
|
return identity ? identity.toString() : '---';
|
||||||
},
|
},
|
||||||
|
|
||||||
editorDefaultTypes: () => {
|
editorDefaultTypes: () => {
|
||||||
|
|
@ -117,6 +161,8 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
||||||
this.addSetting('CheckMailInterval');
|
this.addSetting('CheckMailInterval');
|
||||||
this.addSetting('Layout');
|
this.addSetting('Layout');
|
||||||
this.addSetting('MaxBlockquotesLevel');
|
this.addSetting('MaxBlockquotesLevel');
|
||||||
|
this.addSetting('minFontSize');
|
||||||
|
this.addSetting('defaultSort');
|
||||||
|
|
||||||
this.addSettings([
|
this.addSettings([
|
||||||
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,155 @@
|
||||||
import { AskPopupView } from 'View/Popup/Ask';
|
import { AskPopupView } from 'View/Popup/Ask';
|
||||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||||
|
import { SettingsGet } from '../Common/Globals';
|
||||||
|
import { isArray } from '../Common/Utils';
|
||||||
|
|
||||||
export const Passphrases = new WeakMap();
|
export const Passphrases = new WeakMap();
|
||||||
|
|
||||||
Passphrases.ask = async (key, sAskDesc, btnText) =>
|
Passphrases.ask = async (key, sAskDesc, btnText) => {
|
||||||
Passphrases.has(key)
|
if (Passphrases.has(key)) {
|
||||||
? {password:Passphrases.handle(key)/*, remember:false*/}
|
return { password: Passphrases.handle(key)/*, remember:false*/ };
|
||||||
: await AskPopupView.password(sAskDesc, btnText, 5);
|
} else if (Passphrases.hasInLocalStorage(key)) {
|
||||||
|
return { password: await getFromLocalStorage(key) };
|
||||||
|
} else {
|
||||||
|
const pass = await AskPopupView.password(sAskDesc, btnText,
|
||||||
|
window.crypto.subtle && canUseLocalStorage(key) ? 0b1101 : 0b0101);
|
||||||
|
pass.rememberPermanent && await saveToLocalStorage(key, pass.password);
|
||||||
|
return pass;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Passphrases._deleteFromSession = Passphrases.delete;
|
||||||
|
|
||||||
const timeouts = {};
|
const timeouts = {};
|
||||||
// get/set accessor to control deletion after N minutes of inactivity
|
// get/set accessor to control deletion after N minutes of inactivity
|
||||||
Passphrases.handle = (key, pass) => {
|
Passphrases.handle = (key, pass) => {
|
||||||
const timeout = SettingsUserStore.keyPassForget();
|
const timeout = SettingsUserStore.keyPassForget();
|
||||||
if (timeout && !timeouts[key]) {
|
if (timeout && !timeouts[key]) {
|
||||||
timeouts[key] = (()=>Passphrases.delete(key)).debounce(timeout * 1000);
|
timeouts[key] = (() => Passphrases._deleteFromSession(key)).debounce(timeout * 60 * 1000);
|
||||||
}
|
}
|
||||||
pass && Passphrases.set(key, pass);
|
pass && Passphrases.set(key, pass);
|
||||||
timeout && timeouts[key]();
|
timeout && timeouts[key]();
|
||||||
return Passphrases.get(key);
|
return Passphrases.get(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteFromLocalStorage = (key) => {
|
||||||
|
const keyId = getKeyId(key);
|
||||||
|
if (keyId) {
|
||||||
|
localStorage.removeItem(keyId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Passphrases.delete = (key) => {
|
||||||
|
deleteFromLocalStorage(key);
|
||||||
|
return Passphrases._deleteFromSession(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
Passphrases.hasInLocalStorage = (key) => {
|
||||||
|
const keyId = getKeyId(key);
|
||||||
|
return keyId && localStorage.getItem(keyId) !== null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveToLocalStorage = async (key, pass) => {
|
||||||
|
const keyId = getKeyId(key);
|
||||||
|
if (!keyId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pass) {
|
||||||
|
localStorage.removeItem(keyId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const derivedKey = await deriveKeyFromHash(SettingsGet('accountHash'), salt);
|
||||||
|
|
||||||
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv: iv },
|
||||||
|
derivedKey,
|
||||||
|
new TextEncoder().encode(pass)
|
||||||
|
);
|
||||||
|
localStorage.setItem(keyId, JSON.stringify([
|
||||||
|
btoa(String.fromCharCode.apply(null, salt)),
|
||||||
|
btoa(String.fromCharCode.apply(null, iv)),
|
||||||
|
btoa(String.fromCharCode.apply(null, new Uint8Array(encrypted)))
|
||||||
|
]));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Passphrases.saveToLocalStorage failed', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFromLocalStorage = async (key) => {
|
||||||
|
const keyId = getKeyId(key);
|
||||||
|
if (!keyId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = localStorage.getItem(keyId);
|
||||||
|
if (!jsonData) {
|
||||||
|
console.error('Passphrases.getFromLocalStorage failed: no data found');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saltIvData = JSON.parse(jsonData);
|
||||||
|
if (!saltIvData || !isArray(saltIvData) || saltIvData.length !== 3) {
|
||||||
|
// noinspection ExceptionCaughtLocallyJS
|
||||||
|
throw new Error('invalid passphrase data');
|
||||||
|
}
|
||||||
|
const toUint8 = (str) => new Uint8Array(atob(str).split('').map(c => c.charCodeAt(0)));
|
||||||
|
|
||||||
|
const derivedKey = await deriveKeyFromHash(SettingsGet('accountHash'), toUint8(saltIvData[0]));
|
||||||
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: toUint8(saltIvData[1])
|
||||||
|
},
|
||||||
|
derivedKey,
|
||||||
|
toUint8(saltIvData[2])
|
||||||
|
);
|
||||||
|
return String.fromCharCode.apply(null, new Uint8Array(decrypted));
|
||||||
|
} catch (e) {
|
||||||
|
localStorage.removeItem(keyId);
|
||||||
|
console.error('Passphrases.getFromLocalStorage failed', e);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canUseLocalStorage = (key) => getKeyId(key) !== undefined;
|
||||||
|
|
||||||
|
const getKeyId = (key) => {
|
||||||
|
if (key && typeof key.id === 'string' && key.id.length > 4 && typeof key.forgetPass === 'function') {
|
||||||
|
// only deal with keys that we can forget (OpenPGB, GnuPG)
|
||||||
|
return key.id + '_local_key';
|
||||||
|
} else {
|
||||||
|
console.info('Passphrases.getKeyId: unsupported key type');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deriveKeyFromHash = async (hash, salt) => {
|
||||||
|
if (!hash) {
|
||||||
|
throw new Error('empty accountHash');
|
||||||
|
}
|
||||||
|
return window.crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(hash),
|
||||||
|
{ 'name': 'PBKDF2' },
|
||||||
|
false,
|
||||||
|
['deriveKey']
|
||||||
|
).then(keyMaterial => {
|
||||||
|
return window.crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
'name': 'PBKDF2',
|
||||||
|
'salt': salt,
|
||||||
|
'iterations': 512,
|
||||||
|
'hash': 'SHA-256'
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{ 'name': 'AES-GCM', 'length': 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,13 @@ export const GnuPGUserStore = new class {
|
||||||
key.for = email => aEmails.includes(IDN.toASCII(email));
|
key.for = email => aEmails.includes(IDN.toASCII(email));
|
||||||
key.askDelete = ko.observable(false);
|
key.askDelete = ko.observable(false);
|
||||||
key.openForDeletion = ko.observable(null).askDeleteHelper();
|
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 = () => {
|
key.remove = () => {
|
||||||
if (key.askDelete()) {
|
if (key.askDelete()) {
|
||||||
Remote.request('GnupgDeleteKey',
|
Remote.request('GnupgDeleteKey',
|
||||||
|
|
@ -69,6 +76,7 @@ export const GnuPGUserStore = new class {
|
||||||
isPrivate: isPrivate
|
isPrivate: isPrivate
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
isPrivate && key.forgetPass();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (isPrivate) {
|
if (isPrivate) {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey';
|
||||||
|
|
||||||
import { Passphrases } from 'Storage/Passphrases';
|
import { Passphrases } from 'Storage/Passphrases';
|
||||||
|
|
||||||
import { baseCollator } from 'Common/Translator';
|
import { baseCollator, getNotification } from 'Common/Translator';
|
||||||
|
|
||||||
const
|
const
|
||||||
loaded = () => !!window.openpgp,
|
loaded = () => !!window.openpgp,
|
||||||
|
|
@ -58,10 +58,9 @@ const
|
||||||
*/
|
*/
|
||||||
publicKeysItem = 'openpgp-public-keys',
|
publicKeysItem = 'openpgp-public-keys',
|
||||||
privateKeysItem = 'openpgp-private-keys',
|
privateKeysItem = 'openpgp-private-keys',
|
||||||
storage = window.localStorage,
|
|
||||||
loadOpenPgpKeys = async itemname => {
|
loadOpenPgpKeys = async itemname => {
|
||||||
let keys = [], key,
|
let keys = [], key,
|
||||||
armoredKeys = JSON.parse(storage.getItem(itemname)),
|
armoredKeys = JSON.parse(localStorage.getItem(itemname)),
|
||||||
i = arrayLength(armoredKeys);
|
i = arrayLength(armoredKeys);
|
||||||
while (i--) try {
|
while (i--) try {
|
||||||
key = await openpgp.readKey({armoredKey:armoredKeys[i]});
|
key = await openpgp.readKey({armoredKey:armoredKeys[i]});
|
||||||
|
|
@ -74,9 +73,9 @@ const
|
||||||
storeOpenPgpKeys = (keys, section) => {
|
storeOpenPgpKeys = (keys, section) => {
|
||||||
let armoredKeys = keys.map(item => item.armor);
|
let armoredKeys = keys.map(item => item.armor);
|
||||||
if (armoredKeys.length) {
|
if (armoredKeys.length) {
|
||||||
storage.setItem(section, JSON.stringify(armoredKeys));
|
localStorage.setItem(section, JSON.stringify(armoredKeys));
|
||||||
} else {
|
} else {
|
||||||
storage.removeItem(section);
|
localStorage.removeItem(section);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -91,6 +90,9 @@ class OpenPgpKeyModel {
|
||||||
this.armor = armor;
|
this.armor = armor;
|
||||||
this.askDelete = ko.observable(false);
|
this.askDelete = ko.observable(false);
|
||||||
this.openForDeletion = ko.observable(null).askDeleteHelper();
|
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.getUserIDs()
|
||||||
// key.getPrimaryUser()
|
// key.getPrimaryUser()
|
||||||
}
|
}
|
||||||
|
|
@ -116,12 +118,29 @@ class OpenPgpKeyModel {
|
||||||
if (this.key.isPrivate()) {
|
if (this.key.isPrivate()) {
|
||||||
OpenPGPUserStore.privateKeys.remove(this);
|
OpenPGPUserStore.privateKeys.remove(this);
|
||||||
storeOpenPgpKeys(OpenPGPUserStore.privateKeys, privateKeysItem);
|
storeOpenPgpKeys(OpenPGPUserStore.privateKeys, privateKeysItem);
|
||||||
|
this.forgetPass()
|
||||||
} else {
|
} else {
|
||||||
OpenPGPUserStore.publicKeys.remove(this);
|
OpenPGPUserStore.publicKeys.remove(this);
|
||||||
storeOpenPgpKeys(OpenPGPUserStore.publicKeys, publicKeysItem);
|
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() {
|
toJSON() {
|
||||||
return this.armor;
|
return this.armor;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { LayoutSideView, LayoutBottomView } from 'Common/EnumsUser';
|
||||||
import { pInt } from 'Common/Utils';
|
import { pInt } from 'Common/Utils';
|
||||||
import { $htmlCL, SettingsGet, fireEvent } from 'Common/Globals';
|
import { $htmlCL, SettingsGet, fireEvent } from 'Common/Globals';
|
||||||
import { ThemeStore } from 'Stores/Theme';
|
import { ThemeStore } from 'Stores/Theme';
|
||||||
|
import { FolderUserStore } from 'Stores/User/Folder';
|
||||||
|
|
||||||
export const SettingsUserStore = new class {
|
export const SettingsUserStore = new class {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -22,6 +23,7 @@ export const SettingsUserStore = new class {
|
||||||
allowStyles: 0,
|
allowStyles: 0,
|
||||||
collapseBlockquotes: 1,
|
collapseBlockquotes: 1,
|
||||||
maxBlockquotesLevel: 0,
|
maxBlockquotesLevel: 0,
|
||||||
|
minFontSize: 0,
|
||||||
listInlineAttachments: 0,
|
listInlineAttachments: 0,
|
||||||
simpleAttachmentsList: 0,
|
simpleAttachmentsList: 0,
|
||||||
useCheckboxesInList: 1,
|
useCheckboxesInList: 1,
|
||||||
|
|
@ -29,6 +31,7 @@ export const SettingsUserStore = new class {
|
||||||
showNextMessage: 0,
|
showNextMessage: 0,
|
||||||
allowDraftAutosave: 1,
|
allowDraftAutosave: 1,
|
||||||
useThreads: 0,
|
useThreads: 0,
|
||||||
|
defaultSort: '',
|
||||||
threadAlgorithm: '',
|
threadAlgorithm: '',
|
||||||
replySameFolder: 0,
|
replySameFolder: 0,
|
||||||
hideUnsubscribed: 0,
|
hideUnsubscribed: 0,
|
||||||
|
|
@ -96,6 +99,7 @@ export const SettingsUserStore = new class {
|
||||||
'AllowStyles',
|
'AllowStyles',
|
||||||
'CollapseBlockquotes',
|
'CollapseBlockquotes',
|
||||||
'MaxBlockquotesLevel',
|
'MaxBlockquotesLevel',
|
||||||
|
'minFontSize',
|
||||||
'ListInlineAttachments',
|
'ListInlineAttachments',
|
||||||
'simpleAttachmentsList',
|
'simpleAttachmentsList',
|
||||||
'UseCheckboxesInList',
|
'UseCheckboxesInList',
|
||||||
|
|
@ -103,6 +107,7 @@ export const SettingsUserStore = new class {
|
||||||
'showNextMessage',
|
'showNextMessage',
|
||||||
'AllowDraftAutosave',
|
'AllowDraftAutosave',
|
||||||
'useThreads',
|
'useThreads',
|
||||||
|
'defaultSort',
|
||||||
'threadAlgorithm',
|
'threadAlgorithm',
|
||||||
'ReplySameFolder',
|
'ReplySameFolder',
|
||||||
'HideUnsubscribed',
|
'HideUnsubscribed',
|
||||||
|
|
@ -161,5 +166,7 @@ export const SettingsUserStore = new class {
|
||||||
self.messageReadDelay(pInt(SettingsGet('MessageReadDelay')));
|
self.messageReadDelay(pInt(SettingsGet('MessageReadDelay')));
|
||||||
self.autoLogout(pInt(SettingsGet('AutoLogout')));
|
self.autoLogout(pInt(SettingsGet('AutoLogout')));
|
||||||
self.keyPassForget(pInt(SettingsGet('keyPassForget')));
|
self.keyPassForget(pInt(SettingsGet('keyPassForget')));
|
||||||
|
|
||||||
|
FolderUserStore.sortMode(self.defaultSort());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#V-PopupsLanguages {
|
#V-PopupsLanguages {
|
||||||
|
|
||||||
max-width: 710px;
|
max-width: 720px;
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
||||||
|
|
@ -125,8 +125,9 @@
|
||||||
/* Right */
|
/* Right */
|
||||||
|
|
||||||
.right {
|
.right {
|
||||||
width: 100%;
|
|
||||||
border-left: 1px solid var(--border-color, #ddd);
|
border-left: 1px solid var(--border-color, #ddd);
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-view-content-toolbar {
|
.b-view-content-toolbar {
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
color: var(--folders-color, #333);
|
color: var(--folders-color, #333);
|
||||||
display: block;
|
display: block;
|
||||||
line-height: 34px;
|
line-height: 2.2em;
|
||||||
padding: 0 2em 0 @folderItemPadding;
|
padding: 0 2em 0 @folderItemPadding;
|
||||||
position: relative;
|
position: relative;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
@ -151,7 +151,7 @@
|
||||||
color: var(--unread-count-color, #fff);
|
color: var(--unread-count-color, #fff);
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
line-height: 1.5em;
|
line-height: 1.5em;
|
||||||
margin-top: 7px;
|
margin-top: 0.5em;
|
||||||
min-width: 1.7em;
|
min-width: 1.7em;
|
||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
|
|
@ -45,4 +45,8 @@
|
||||||
content: ')';
|
content: ')';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.identities-list tr + tr .identity-default {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,14 @@ export class AskPopupView extends AbstractViewPopup {
|
||||||
askUsername: false,
|
askUsername: false,
|
||||||
passphrase: '',
|
passphrase: '',
|
||||||
askPass: false,
|
askPass: false,
|
||||||
remember: true,
|
remember: true, // remember for session
|
||||||
askRemeber: false
|
askRemeber: false,
|
||||||
|
rememberPermanent: false,
|
||||||
|
askRememberPermanent: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.rememberPermanent.subscribe(value => value && this.remember(true));
|
||||||
|
|
||||||
this.fYesAction = null;
|
this.fYesAction = null;
|
||||||
this.fNoAction = null;
|
this.fNoAction = null;
|
||||||
|
|
||||||
|
|
@ -48,10 +52,12 @@ export class AskPopupView extends AbstractViewPopup {
|
||||||
this.askDesc(sAskDesc || '');
|
this.askDesc(sAskDesc || '');
|
||||||
this.askUsername(ask & 2);
|
this.askUsername(ask & 2);
|
||||||
this.askPass(ask & 1);
|
this.askPass(ask & 1);
|
||||||
this.askRemeber(ask & 4);
|
this.askRemeber(ask & 4); // 0b0100
|
||||||
|
this.askRememberPermanent(ask & 0b1000)
|
||||||
this.username('');
|
this.username('');
|
||||||
this.passphrase('');
|
this.passphrase('');
|
||||||
this.remember(true);
|
this.remember(true);
|
||||||
|
this.rememberPermanent(false);
|
||||||
this.yesButton(i18n(btnText || 'GLOBAL/YES'));
|
this.yesButton(i18n(btnText || 'GLOBAL/YES'));
|
||||||
this.noButton(i18n(ask ? 'GLOBAL/CANCEL' : 'GLOBAL/NO'));
|
this.noButton(i18n(ask ? 'GLOBAL/CANCEL' : 'GLOBAL/NO'));
|
||||||
this.fYesAction = fYesFunc;
|
this.fYesAction = fYesFunc;
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import Remote from 'Remote/User/Fetch';
|
||||||
|
|
||||||
import { ComposeAttachmentModel } from 'Model/ComposeAttachment';
|
import { ComposeAttachmentModel } from 'Model/ComposeAttachment';
|
||||||
import { EmailModel } from 'Model/Email';
|
import { EmailModel } from 'Model/Email';
|
||||||
|
import { IdentityModel } from 'Model/Identity';
|
||||||
import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
|
import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
|
||||||
import { addressparser } from 'Mime/Address';
|
import { addressparser } from 'Mime/Address';
|
||||||
|
|
||||||
|
|
@ -92,7 +93,7 @@ const
|
||||||
|
|
||||||
findIdentity = addresses => {
|
findIdentity = addresses => {
|
||||||
addresses = addresses.map(item => item.email);
|
addresses = addresses.map(item => item.email);
|
||||||
return IdentityUserStore.find(item => addresses.includes(item.email()));
|
return IdentityUserStore.find(item => addresses.includes(item.email));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -343,7 +344,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
IdentityUserStore.map(item => ({
|
IdentityUserStore.map(item => ({
|
||||||
item: item,
|
item: item,
|
||||||
optValue: item.id(),
|
optValue: item.id(),
|
||||||
optText: item.formattedName()
|
optText: item
|
||||||
})),
|
})),
|
||||||
|
|
||||||
canBeSentOrSaved: () => !this.sending() && !this.saving()
|
canBeSentOrSaved: () => !this.sending() && !this.saving()
|
||||||
|
|
@ -358,7 +359,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
|
|
||||||
currentIdentity: value => {
|
currentIdentity: value => {
|
||||||
if (value) {
|
if (value) {
|
||||||
this.from(value.formattedName());
|
this.from(value.toString());
|
||||||
this.doEncrypt(value.pgpEncrypt() || SettingsUserStore.pgpEncrypt());
|
this.doEncrypt(value.pgpEncrypt() || SettingsUserStore.pgpEncrypt());
|
||||||
this.doSign(value.pgpSign() || SettingsUserStore.pgpSign());
|
this.doSign(value.pgpSign() || SettingsUserStore.pgpSign());
|
||||||
}
|
}
|
||||||
|
|
@ -454,7 +455,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
contactsCommand: self => self.allowContacts
|
contactsCommand: self => self.allowContacts
|
||||||
});
|
});
|
||||||
|
|
||||||
this.from(IdentityUserStore()[0].formattedName());
|
this.from(IdentityUserStore()[0].toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
sentFolder()
|
sentFolder()
|
||||||
|
|
@ -862,6 +863,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
case ComposeType.Forward:
|
case ComposeType.Forward:
|
||||||
case ComposeType.ForwardAsAttachment:
|
case ComposeType.ForwardAsAttachment:
|
||||||
identity = findIdentity(oLastMessage.to.concat(oLastMessage.cc, oLastMessage.bcc))
|
identity = findIdentity(oLastMessage.to.concat(oLastMessage.cc, oLastMessage.bcc))
|
||||||
|
|| findIdentity(oLastMessage.from)
|
||||||
/* || findIdentity(oLastMessage.deliveredTo)*/;
|
/* || findIdentity(oLastMessage.deliveredTo)*/;
|
||||||
break;
|
break;
|
||||||
case ComposeType.Draft:
|
case ComposeType.Draft:
|
||||||
|
|
@ -871,10 +873,20 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
// case ComposeType.Empty:
|
// 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];
|
identity = identity || IdentityUserStore()[0];
|
||||||
if (identity) {
|
if (identity) {
|
||||||
// excludeEmail.add(identity.email());
|
// excludeEmail.add(identity.email);
|
||||||
excludeEmail[identity.email()] = true;
|
excludeEmail[identity.email] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arrayLength(options.to)) {
|
if (arrayLength(options.to)) {
|
||||||
|
|
@ -959,7 +971,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
// https://github.com/the-djmaze/snappymail/issues/491
|
// https://github.com/the-djmaze/snappymail/issues/491
|
||||||
tpl.innerHTML = oLastMessage.bodyAsHTML();
|
tpl.innerHTML = oLastMessage.bodyAsHTML();
|
||||||
tpl.content.querySelectorAll('img').forEach(img => {
|
tpl.content.querySelectorAll('img').forEach(img => {
|
||||||
img.src || img.dataset.xSrcCid || img.dataset.xSrc || img.replaceWith(img.alt || img.title)
|
img.src || img.dataset.xSrc || img.replaceWith(img.alt || img.title)
|
||||||
});
|
});
|
||||||
sText = tpl.innerHTML.trim();
|
sText = tpl.innerHTML.trim();
|
||||||
|
|
||||||
|
|
@ -1381,7 +1393,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
allRecipients() {
|
allRecipients() {
|
||||||
return [
|
return [
|
||||||
// From/sender is also recipient (Sent mailbox)
|
// From/sender is also recipient (Sent mailbox)
|
||||||
// this.currentIdentity().email(),
|
// this.currentIdentity().email,
|
||||||
this.from(),
|
this.from(),
|
||||||
this.to(),
|
this.to(),
|
||||||
this.cc(),
|
this.cc(),
|
||||||
|
|
@ -1401,7 +1413,8 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
key && options.push(['OpenPGP', key]);
|
key && options.push(['OpenPGP', key]);
|
||||||
key = GnuPGUserStore.getPrivateKeyFor(email, 1);
|
key = GnuPGUserStore.getPrivateKeyFor(email, 1);
|
||||||
key && options.push(['GnuPG', key]);
|
key && options.push(['GnuPG', key]);
|
||||||
identity.smimeKeyValid() && identity.smimeCertificateValid() && identity.email() === email
|
identity.smimeKeyValid() && identity.smimeCertificateValid()
|
||||||
|
&& identity.smimeCertificateChainValid() && identity.email === email
|
||||||
&& options.push(['S/MIME']);
|
&& options.push(['S/MIME']);
|
||||||
console.dir({signOptions: options});
|
console.dir({signOptions: options});
|
||||||
this.signOptions(options);
|
this.signOptions(options);
|
||||||
|
|
@ -1420,7 +1433,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
|
|
||||||
const count = recipients.length,
|
const count = recipients.length,
|
||||||
identity = this.currentIdentity(),
|
identity = this.currentIdentity(),
|
||||||
from = (identity.smimeKey() && identity.smimeCertificate()) ? identity.email() : null;
|
from = (identity.smimeKey() && identity.smimeCertificate()) ? identity.email : null;
|
||||||
count
|
count
|
||||||
&& count === recipients.filter(email =>
|
&& count === recipients.filter(email =>
|
||||||
email == from
|
email == from
|
||||||
|
|
@ -1497,12 +1510,21 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
**/
|
**/
|
||||||
linkedData: []
|
linkedData: []
|
||||||
},
|
},
|
||||||
recipients = draft ? [identity.email()] : this.allRecipients(),
|
recipients = draft ? [identity.email] : this.allRecipients(),
|
||||||
signOptions = !draft && this.doSign() && this.signOptions(),
|
signOptions = !draft && this.doSign() && this.signOptions(),
|
||||||
encryptOptions = this.doEncrypt() && this.encryptOptions(),
|
encryptOptions = this.doEncrypt() && this.encryptOptions(),
|
||||||
isHtml = this.oEditor.isHtml();
|
isHtml = this.oEditor.isHtml();
|
||||||
|
|
||||||
if (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 {
|
do {
|
||||||
l = Text.length;
|
l = Text.length;
|
||||||
Text = Text
|
Text = Text
|
||||||
|
|
@ -1572,6 +1594,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
*/
|
*/
|
||||||
break;
|
break;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
Passphrases.delete(signOptions[i][1])
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
} else if ('GnuPG' == signOptions[i][0]) {
|
} else if ('GnuPG' == signOptions[i][0]) {
|
||||||
|
|
@ -1589,11 +1612,12 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
// TODO: sign in PHP fails
|
// TODO: sign in PHP fails
|
||||||
params.sign = 'S/MIME';
|
params.sign = 'S/MIME';
|
||||||
// params.signCertificate = identity.smimeCertificate();
|
// params.signCertificate = identity.smimeCertificate();
|
||||||
|
// params.signCertificateChain = identity.smimeCertificateChain();
|
||||||
// params.signPrivateKey = identity.smimeKey();
|
// params.signPrivateKey = identity.smimeKey();
|
||||||
// params.attachCertificate = false;
|
// params.attachCertificate = false;
|
||||||
if (identity.smimeKeyEncrypted()) {
|
if (identity.smimeKeyEncrypted()) {
|
||||||
const pass = await Passphrases.ask(identity,
|
const pass = await Passphrases.ask(identity,
|
||||||
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}),
|
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email}),
|
||||||
'CRYPTO/SIGN'
|
'CRYPTO/SIGN'
|
||||||
);
|
);
|
||||||
if (null != pass) {
|
if (null != pass) {
|
||||||
|
|
@ -1613,7 +1637,9 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) =>
|
Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) =>
|
||||||
params.autocrypt.push({
|
params.autocrypt.push({
|
||||||
addr: k,
|
addr: k,
|
||||||
keydata: v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '').trim()
|
keydata: v
|
||||||
|
.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '')
|
||||||
|
.replace(/\s+/g, '') // Single base64 string, let the server do compliance formatting
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
for (let i = 0; i < encryptOptions.length; ++i) {
|
for (let i = 0; i < encryptOptions.length; ++i) {
|
||||||
|
|
@ -1633,7 +1659,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
||||||
if ('S/MIME' == encryptOptions[i]) {
|
if ('S/MIME' == encryptOptions[i]) {
|
||||||
params.encryptCertificates = [identity.smimeCertificate()];
|
params.encryptCertificates = [identity.smimeCertificate()];
|
||||||
SMimeUserStore.forEach(certificate => {
|
SMimeUserStore.forEach(certificate => {
|
||||||
certificate.emailAddress != identity.email()
|
certificate.emailAddress != identity.email
|
||||||
&& recipients.includes(certificate.emailAddress)
|
&& recipients.includes(certificate.emailAddress)
|
||||||
&& params.encryptCertificates.push(certificate.id)
|
&& params.encryptCertificates.push(certificate.id)
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,8 @@ export class IdentityPopupView extends AbstractViewPopup {
|
||||||
this.submitError(oData.message);
|
this.submitError(oData.message);
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
name: identity.name(),
|
name: identity.name,
|
||||||
email: identity.email(),
|
email: identity.email,
|
||||||
privateKey: identity.smimeKey(),
|
privateKey: identity.smimeKey(),
|
||||||
passphrase: result.password
|
passphrase: result.password
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -105,9 +105,9 @@ export class OpenPgpGeneratePopupView extends AbstractViewPopup {
|
||||||
}
|
}
|
||||||
|
|
||||||
onShow() {
|
onShow() {
|
||||||
this.name(''/*IdentityUserStore()[0].name()*/);
|
this.name(''/*IdentityUserStore()[0].name*/);
|
||||||
this.password('');
|
this.password('');
|
||||||
this.email(''/*IdentityUserStore()[0].email()*/);
|
this.email(''/*IdentityUserStore()[0].email*/);
|
||||||
this.emailError(false);
|
this.emailError(false);
|
||||||
this.submitError('');
|
this.submitError('');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,15 +213,19 @@ export class MailMessageList extends AbstractViewRight {
|
||||||
|
|
||||||
sortText: () => {
|
sortText: () => {
|
||||||
let mode = FolderUserStore.sortMode(),
|
let mode = FolderUserStore.sortMode(),
|
||||||
desc = '' === mode || mode.includes('REVERSE');
|
has = w => mode.includes(w),
|
||||||
|
desc = '' === mode || has('REVERSE');
|
||||||
mode = mode.split(/\s+/);
|
mode = mode.split(/\s+/);
|
||||||
if (mode.includes('FROM')) {
|
if (has('FROM')) {
|
||||||
return '@' + (desc ? '⬆' : '⬇');
|
return '@' + (desc ? '⬆' : '⬇');
|
||||||
}
|
}
|
||||||
if (mode.includes('SUBJECT')) {
|
if (has('SUBJECT')) {
|
||||||
return '𝐒' + (desc ? '⬆' : '⬇');
|
return '𝐒' + (desc ? '⬆' : '⬇');
|
||||||
}
|
}
|
||||||
return (mode.includes('SIZE') ? '✉' : '📅') + (desc ? '⬇' : '⬆');
|
if (has('SIZE')) {
|
||||||
|
return '✉' + (desc ? '⬇' : '⬆');
|
||||||
|
}
|
||||||
|
return (has('ARRIVAL') ? '📨' : '📅') + (desc ? '⬇' : '⬆');
|
||||||
},
|
},
|
||||||
|
|
||||||
downloadAsZipAllowed: () => this.attachmentsActions.includes('zip')
|
downloadAsZipAllowed: () => this.attachmentsActions.includes('zip')
|
||||||
|
|
@ -299,14 +303,14 @@ export class MailMessageList extends AbstractViewRight {
|
||||||
item => sFolder === item?.folder && iUid == item?.uid
|
item => sFolder === item?.folder && iUid == item?.uid
|
||||||
);
|
);
|
||||||
|
|
||||||
if ('INBOX' === sFolder) {
|
if ('INBOX' === sFolder && 'INBOX' !== FolderUserStore.currentFolderFullName()) {
|
||||||
hasher.setHash(mailBox(sFolder));
|
hasher.setHash(mailBox(sFolder));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
this.selector.selectMessageItem(message);
|
this.selector.selectMessageItem(message);
|
||||||
} else {
|
} else {
|
||||||
if ('INBOX' !== sFolder) {
|
if ('INBOX' !== sFolder && sFolder !== FolderUserStore.currentFolderFullName()) {
|
||||||
hasher.setHash(mailBox(sFolder));
|
hasher.setHash(mailBox(sFolder));
|
||||||
}
|
}
|
||||||
if (sFolder && iUid) {
|
if (sFolder && iUid) {
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,6 @@ import { showScreenPopup } from 'Knoin/Knoin';
|
||||||
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
|
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
|
||||||
import { GnuPGUserStore } from 'Stores/User/GnuPG';
|
import { GnuPGUserStore } from 'Stores/User/GnuPG';
|
||||||
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
|
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
|
||||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
|
||||||
|
|
||||||
import { Passphrases } from 'Storage/Passphrases';
|
|
||||||
|
|
||||||
const
|
const
|
||||||
oMessageScrollerDom = () => elementById('messageItem') || {},
|
oMessageScrollerDom = () => elementById('messageItem') || {},
|
||||||
|
|
@ -133,6 +130,8 @@ export class MailMessageView extends AbstractViewRight {
|
||||||
// viewer
|
// viewer
|
||||||
viewFromShort: '',
|
viewFromShort: '',
|
||||||
dkimData: ['none', '', ''],
|
dkimData: ['none', '', ''],
|
||||||
|
spfData: ['none', '', ''],
|
||||||
|
dmarcData: ['none', '', ''],
|
||||||
nowTracking: false
|
nowTracking: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -197,7 +196,7 @@ export class MailMessageView extends AbstractViewRight {
|
||||||
dkimIcon: () => {
|
dkimIcon: () => {
|
||||||
switch (this.dkimData()[0]) {
|
switch (this.dkimData()[0]) {
|
||||||
case 'none':
|
case 'none':
|
||||||
return '';
|
return '🚫︎';
|
||||||
case 'pass':
|
case 'pass':
|
||||||
return '✔';
|
return '✔';
|
||||||
default:
|
default:
|
||||||
|
|
@ -205,12 +204,43 @@ export class MailMessageView extends AbstractViewRight {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dkimIconClass: () => 'pass' === this.dkimData()[0] ? 'iconcolor-green' : 'iconcolor-red',
|
dkimIconClass: () => 'pass' === this.dkimData()[0] ? 'iconcolor-green' : 'iconcolor-red',
|
||||||
|
|
||||||
dkimTitle:() => {
|
dkimTitle:() => {
|
||||||
const dkim = this.dkimData();
|
const dkim = this.dkimData();
|
||||||
return dkim[0] ? dkim[2] || 'DKIM: ' + dkim[0] : '';
|
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(),
|
showWhitelistOptions: () => 'match' === SettingsUserStore.viewImages(),
|
||||||
|
|
||||||
firstUnsubsribeLink: () => currentMessage()?.unsubsribeLinks()[0] || '',
|
firstUnsubsribeLink: () => currentMessage()?.unsubsribeLinks()[0] || '',
|
||||||
|
|
@ -233,6 +263,8 @@ export class MailMessageView extends AbstractViewRight {
|
||||||
// TODO: make first param a user setting #683
|
// TODO: make first param a user setting #683
|
||||||
this.viewFromShort(message.from.toString(false, true));
|
this.viewFromShort(message.from.toString(false, true));
|
||||||
this.dkimData(message.dkim[0] || ['none', '', '']);
|
this.dkimData(message.dkim[0] || ['none', '', '']);
|
||||||
|
this.spfData(message.spf[0] || ['none', '', '']);
|
||||||
|
this.dmarcData(message.dmarc[0] || ['none', '', '']);
|
||||||
this.nowTracking(false);
|
this.nowTracking(false);
|
||||||
} else {
|
} else {
|
||||||
MessagelistUserStore.selectedMessage(null);
|
MessagelistUserStore.selectedMessage(null);
|
||||||
|
|
@ -610,125 +642,19 @@ export class MailMessageView extends AbstractViewRight {
|
||||||
}
|
}
|
||||||
|
|
||||||
pgpDecrypt() {
|
pgpDecrypt() {
|
||||||
const oMessage = currentMessage(),
|
currentMessage().decrypt();
|
||||||
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*/) {
|
pgpVerify(/*self, event*/) {
|
||||||
const oMessage = currentMessage()/*, ctrl = event.target.closest('.openpgp-control')*/;
|
currentMessage().pgpVerify();
|
||||||
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() {
|
async smimeDecrypt() {
|
||||||
const message = currentMessage();
|
currentMessage().decrypt();
|
||||||
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*/) {
|
smimeVerify(/*self, event*/) {
|
||||||
const message = currentMessage(),
|
currentMessage().smimeVerify();
|
||||||
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:
|
To use PostgreSQL as the DB, in Admin Panel, click `Contacts`, check `Enable contacts` and , and under `Storage (PDO)` choose the following:
|
||||||
|
|
||||||
- Type: `PostgresSQL`
|
- Type: `PostgresSQL`
|
||||||
- Data Source Name (DSN): `host=postgres;port=5432;dbname=snappymail`
|
- Data Source Name (DSN): `host=db;port=5432;dbname=snappymail`
|
||||||
- User `snappymail`
|
- User `snappymail`
|
||||||
- Password `snappymail`
|
- Password `snappymail`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
This app packages SnappyMail <upstream>2.38.2.1</upstream>.
|
This app packages SnappyMail <upstream>2.38.2</upstream>.
|
||||||
|
|
||||||
SnappyMail is a simple, modern, lightweight & fast web-based email client.
|
SnappyMail is a simple, modern, lightweight & fast web-based email client.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ RUN mkdir -p /app/code
|
||||||
WORKDIR /app/code
|
WORKDIR /app/code
|
||||||
|
|
||||||
# If you change the extraction below, be sure to test on scaleway
|
# If you change the extraction below, be sure to test on scaleway
|
||||||
VERSION=2.38.2.1
|
VERSION=2.38.2
|
||||||
RUN wget https://github.com/the-djmaze/snappymail/releases/download/v${VERSION}/snappymail-${VERSION}.zip -O /tmp/snappymail.zip && \
|
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 && \
|
unzip /tmp/snappymail.zip -d /app/code && \
|
||||||
rm /tmp/snappymail.zip && \
|
rm /tmp/snappymail.zip && \
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,12 @@
|
||||||
* Custom 'data' folder path
|
* Custom 'data' folder path
|
||||||
*/
|
*/
|
||||||
if (class_exists('OC')) {
|
if (class_exists('OC')) {
|
||||||
define('APP_DATA_FOLDER_PATH', \rtrim(\trim(\OC::$server->getSystemConfig()->getValue('datadirectory', '')), '\\/').'/appdata_snappymail/');
|
define('APP_DATA_FOLDER_PATH', \rtrim(\trim(
|
||||||
|
\method_exists(\OC::$server, 'getSystemConfig')
|
||||||
|
? \OC::$server->getSystemConfig()->getValue('datadirectory', '')
|
||||||
|
// Nextcloud 34+
|
||||||
|
: \OC::$server->get(\OCP\IConfig::class)->getSystemValue('datadirectory', '')
|
||||||
|
), '\\/') . '/appdata_snappymail/');
|
||||||
} else {
|
} else {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
header($_SERVER['SERVER_PROTOCOL'].' 400 Bad Request', true, 400);
|
header($_SERVER['SERVER_PROTOCOL'].' 400 Bad Request', true, 400);
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,9 @@ There, click on the link to go to the SnappyMail admin panel.
|
||||||
<lib>uuid</lib>
|
<lib>uuid</lib>
|
||||||
<lib>xxtea</lib>
|
<lib>xxtea</lib>
|
||||||
<lib>zip</lib>
|
<lib>zip</lib>
|
||||||
|
<nextcloud min-version="20" max-version="34" />
|
||||||
-->
|
-->
|
||||||
<nextcloud min-version="20" max-version="30" />
|
<nextcloud min-version="20" />
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<settings>
|
<settings>
|
||||||
<admin>OCA\SnappyMail\Settings\AdminSettings</admin>
|
<admin>OCA\SnappyMail\Settings\AdminSettings</admin>
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,11 @@ class ContentSecurityPolicy extends \OCP\AppFramework\Http\ContentSecurityPolicy
|
||||||
public function getSnappyMailNonce() {
|
public function getSnappyMailNonce() {
|
||||||
static $sNonce;
|
static $sNonce;
|
||||||
if (!$sNonce) {
|
if (!$sNonce) {
|
||||||
$cspManager = \OC::$server->getContentSecurityPolicyNonceManager();
|
$cspManager = (\method_exists(\OC::$server, 'getContentSecurityPolicyNonceManager')
|
||||||
|
? \OC::$server->getContentSecurityPolicyNonceManager()
|
||||||
|
// Nextcloud 34+
|
||||||
|
: \OC::$server->get(\OC\Security\CSP\ContentSecurityPolicyNonceManager::class)
|
||||||
|
);
|
||||||
$sNonce = $cspManager->getNonce() ?: \SnappyMail\UUID::generate();
|
$sNonce = $cspManager->getNonce() ?: \SnappyMail\UUID::generate();
|
||||||
if (\method_exists($cspManager, 'browserSupportsCspV3') && !$cspManager->browserSupportsCspV3()) {
|
if (\method_exists($cspManager, 'browserSupportsCspV3') && !$cspManager->browserSupportsCspV3()) {
|
||||||
$this->addAllowedScriptDomain("'nonce-{$sNonce}'");
|
$this->addAllowedScriptDomain("'nonce-{$sNonce}'");
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,8 @@ class SnappyMailHelper
|
||||||
// If OpenID Connect (OIDC) is enabled and used for login, use this.
|
// If OpenID Connect (OIDC) is enabled and used for login, use this.
|
||||||
if (static::isOIDCLogin()) {
|
if (static::isOIDCLogin()) {
|
||||||
$sEmail = $config->getUserValue($sUID, 'settings', 'email');
|
$sEmail = $config->getUserValue($sUID, 'settings', 'email');
|
||||||
return [$sUID, $sEmail, "oidc_login|{$sUID}"];
|
$pwd = new \SnappyMail\SensitiveString("oidc_login|{$sUID}");
|
||||||
|
return [$sUID, $sEmail, $pwd];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only use the user's password in the current session if they have
|
// 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()
|
# script_snappymail_versions()
|
||||||
sub script_snappymail_versions
|
sub script_snappymail_versions
|
||||||
{
|
{
|
||||||
return ( "2.38.2.1" );
|
return ( "2.38.2" );
|
||||||
}
|
}
|
||||||
|
|
||||||
sub script_snappymail_version_desc
|
sub script_snappymail_version_desc
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"title": "SnappyMail",
|
"title": "SnappyMail",
|
||||||
"description": "Simple, modern & fast web-based email client",
|
"description": "Simple, modern & fast web-based email client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.38.2.1",
|
"version": "2.38.2",
|
||||||
"homepage": "https://snappymail.eu",
|
"homepage": "https://snappymail.eu",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "DJ Maze",
|
"name": "DJ Maze",
|
||||||
|
|
@ -43,10 +43,11 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rollup/plugin-node-resolve": "^13.1.3",
|
"@rollup/plugin-node-resolve": "^13.1.3",
|
||||||
"@rollup/plugin-replace": "^3.0.1",
|
"@rollup/plugin-replace": "^3.0.1",
|
||||||
|
"@rollup/plugin-terser": "^1.0.2",
|
||||||
"babel-eslint": "^10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
"del": "^6.0.0",
|
"del": "^6.0.0",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
"gulp": "^5.0.0",
|
"gulp": "^5.0.1",
|
||||||
"gulp-append-prepend": "^1.0.9",
|
"gulp-append-prepend": "^1.0.9",
|
||||||
"gulp-cached": "^1.1.1",
|
"gulp-cached": "^1.1.1",
|
||||||
"gulp-clean-css": "^4.3.0",
|
"gulp-clean-css": "^4.3.0",
|
||||||
|
|
@ -64,7 +65,6 @@
|
||||||
"gulp-terser": "^2.1.0",
|
"gulp-terser": "^2.1.0",
|
||||||
"rollup": "^2.56.3",
|
"rollup": "^2.56.3",
|
||||||
"rollup-plugin-external-globals": "^0.6.1",
|
"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,7 +38,10 @@
|
||||||
queue = [],
|
queue = [],
|
||||||
avatars = new Map,
|
avatars = new Map,
|
||||||
ncAvatars = new Map,
|
ncAvatars = new Map,
|
||||||
|
identicons = new Map,
|
||||||
templateId = 'MailMessageView',
|
templateId = 'MailMessageView',
|
||||||
|
b64 = data => btoa(unescape(encodeURIComponent(data))),
|
||||||
|
b64url = data => b64(data).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
|
||||||
getBimiSelector = msg => {
|
getBimiSelector = msg => {
|
||||||
// Get 's' value out of 'v=BIMI1; s=foo;'
|
// Get 's' value out of 'v=BIMI1; s=foo;'
|
||||||
let bimiSelector = msg.headers().valueByName('BIMI-Selector');
|
let bimiSelector = msg.headers().valueByName('BIMI-Selector');
|
||||||
|
|
@ -46,9 +49,21 @@
|
||||||
return bimiSelector || '';
|
return bimiSelector || '';
|
||||||
},
|
},
|
||||||
getBimiId = msg => ('pass' == msg.from[0].dkimStatus ? 1 : 0) + '-' + getBimiSelector(msg),
|
getBimiId = msg => ('pass' == msg.from[0].dkimStatus ? 1 : 0) + '-' + getBimiSelector(msg),
|
||||||
getAvatarUrl = msg => `?Avatar/${getBimiId(msg)}/${msg.avatar}`,
|
email = msg => msg.from[0].email.toLowerCase(),
|
||||||
getAvatarUid = msg => `${getBimiId(msg)}/${msg.from[0].email.toLowerCase()}`,
|
getAvatarUid = msg => `${getBimiId(msg)}/${email(msg)}`,
|
||||||
getAvatar = msg => ncAvatars.get(msg.from[0].email.toLowerCase()) || avatars.get(getAvatarUid(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;
|
||||||
|
},
|
||||||
|
|
||||||
hash = async txt => {
|
hash = async txt => {
|
||||||
if (/^[0-9a-f]{15,}$/i.test(txt)) {
|
if (/^[0-9a-f]{15,}$/i.test(txt)) {
|
||||||
return txt;
|
return txt;
|
||||||
|
|
@ -64,58 +79,56 @@
|
||||||
(from.name?.split(/[^\p{L}]+/gu) || []).reduce((a, s) => a + (s[0] || ''), '')
|
(from.name?.split(/[^\p{L}]+/gu) || []).reduce((a, s) => a + (s[0] || ''), '')
|
||||||
.slice(0,2)
|
.slice(0,2)
|
||||||
.toUpperCase(),
|
.toUpperCase(),
|
||||||
setIdenticon = (from, fn) => hash(from.email).then(hash =>
|
setIdenticon = (msg, fn, cb) => {
|
||||||
fn('data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(window.identiconSvg(
|
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,
|
hash,
|
||||||
fromChars(from),
|
fromChars(msg.from[0]),
|
||||||
window.getComputedStyle(getEl('rl-app'), null).getPropertyValue('font-family')
|
window.getComputedStyle(getEl('rl-app'), null).getPropertyValue('font-family')
|
||||||
)))))
|
));
|
||||||
),
|
fn(uri);
|
||||||
|
identicons.set(email(msg), uri);
|
||||||
|
cb?.(uri);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
addQueue = (msg, fn) => {
|
addQueue = (msg, fn) => {
|
||||||
msg.from?.[0] && setIdenticon(msg.from[0], fn);
|
if (msg.from?.[0]) {
|
||||||
|
if (getAvatarUri(msg)) {
|
||||||
if (rl.pluginSettingsGet('avatars', 'delay')) {
|
if (rl.pluginSettingsGet('avatars', 'delay')) {
|
||||||
|
setIdenticon(msg, fn, ()=>{
|
||||||
queue.push([msg, fn]);
|
queue.push([msg, fn]);
|
||||||
runQueue();
|
runQueue();
|
||||||
} else if (msg.avatar) {
|
});
|
||||||
fn(getAvatarUrl(msg));
|
} else {
|
||||||
|
fn(msg.avatar);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIdenticon(msg, fn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
runQueue = (() => {
|
runQueue = (() => {
|
||||||
let item = queue.shift();
|
let item = queue.shift();
|
||||||
while (item) {
|
while (item) {
|
||||||
if (item[0].from) {
|
let url = getAvatar(item[0]);
|
||||||
let url = getAvatar(item[0]),
|
|
||||||
uid = getAvatarUid(item[0]);
|
|
||||||
if (url) {
|
if (url) {
|
||||||
item[1](url);
|
item[1](url);
|
||||||
item = queue.shift();
|
item = queue.shift();
|
||||||
continue;
|
continue;
|
||||||
} else if (item[0].avatar) {
|
} else if (item[0].avatar) {
|
||||||
item[1](getAvatarUrl(item[0]));
|
item[1](item[0].avatar);
|
||||||
} 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();
|
runQueue();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}).debounce(1000);
|
}).debounce(1000);
|
||||||
|
|
||||||
// addEventListener('DOMContentLoaded', () => {
|
|
||||||
/**
|
/**
|
||||||
* Modify templates
|
* Modify templates
|
||||||
*/
|
*/
|
||||||
|
|
@ -188,13 +201,19 @@
|
||||||
fn = url=>{element.src = url};
|
fn = url=>{element.src = url};
|
||||||
element.onerror = ()=>{
|
element.onerror = ()=>{
|
||||||
element.onerror = null;
|
element.onerror = null;
|
||||||
setIdenticon(msg.from[0], fn);
|
setIdenticon(msg, fn, uri=>avatars.set(getAvatarUid(msg), uri));
|
||||||
};
|
};
|
||||||
if (url) {
|
if (url) {
|
||||||
fn(url);
|
fn(url);
|
||||||
} else if (msg.avatar?.startsWith('data:')) {
|
} else if (msg.avatar?.startsWith('data:')) {
|
||||||
fn(msg.avatar);
|
fn(msg.avatar);
|
||||||
} else {
|
} else {
|
||||||
|
element.onload = ()=>{
|
||||||
|
if (!element.src.startsWith('data:')) {
|
||||||
|
element.onload = null;
|
||||||
|
avatars.set(getAvatarUid(msg), element.src);
|
||||||
|
}
|
||||||
|
};
|
||||||
addQueue(msg, fn);
|
addQueue(msg, fn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -221,11 +240,8 @@
|
||||||
if (url) {
|
if (url) {
|
||||||
fn(url);
|
fn(url);
|
||||||
} else if (msg.avatar) {
|
} else if (msg.avatar) {
|
||||||
fn(msg.avatar.startsWith('data:') ? msg.avatar : getAvatarUrl(msg));
|
fn(getAvatarUri(msg));
|
||||||
} else {
|
} else {
|
||||||
// let from = msg.from[0];
|
|
||||||
// view.viewUserPic(`?Avatar/${'pass' == from.dkimStatus ? 1 : 0}/${encodeURIComponent(from.email)}`);
|
|
||||||
// view.viewUserPicVisible(true);
|
|
||||||
addQueue(msg, fn);
|
addQueue(msg, fn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,12 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
NAME = 'Avatars',
|
NAME = 'Avatars',
|
||||||
AUTHOR = 'SnappyMail',
|
AUTHOR = 'SnappyMail',
|
||||||
URL = 'https://snappymail.eu/',
|
URL = 'https://snappymail.eu/',
|
||||||
VERSION = '1.20',
|
VERSION = '1.22',
|
||||||
RELEASE = '2024-08-26',
|
RELEASE = '2025-03-10',
|
||||||
REQUIRED = '2.33.0',
|
REQUIRED = '2.33.0',
|
||||||
CATEGORY = 'Contacts',
|
CATEGORY = 'Contacts',
|
||||||
LICENSE = 'MIT',
|
LICENSE = 'MIT',
|
||||||
DESCRIPTION = 'Show graphic of sender in message and messages list (supports BIMI, Gravatar, favicon and identicon, Contacts is still TODO)';
|
DESCRIPTION = 'Show graphic of sender in message and messages list (supports Contacts, BIMI, Gravatar, favicon and identicon)';
|
||||||
|
|
||||||
public function Init() : void
|
public function Init() : void
|
||||||
{
|
{
|
||||||
|
|
@ -79,11 +79,8 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
|| ($this->Config()->Get('plugin', 'bimi', false) && 'pass' == $mFrom['dkimStatus'])
|
|| ($this->Config()->Get('plugin', 'bimi', false) && 'pass' == $mFrom['dkimStatus'])
|
||||||
|| ($this->Config()->Get('plugin', 'favicon', false) && 'pass' == $mFrom['dkimStatus'])
|
|| ($this->Config()->Get('plugin', 'favicon', false) && 'pass' == $mFrom['dkimStatus'])
|
||||||
)
|
)
|
||||||
) try {
|
) {
|
||||||
// Base64Url
|
return 'remote';
|
||||||
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)) {
|
if ('pass' == $mFrom['dkimStatus'] && $this->Config()->Get('plugin', 'service', true)) {
|
||||||
// 'data:image/png;base64,[a-zA-Z0-9+/=]'
|
// 'data:image/png;base64,[a-zA-Z0-9+/=]'
|
||||||
|
|
@ -112,17 +109,17 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /?Avatar/${bimi}/Encrypted(${from.email})
|
* GET /?Avatar/${bimi}/Encoded(${from.email})
|
||||||
* Nextcloud Mail uses insecure unencrypted 'index.php/apps/mail/api/avatars/url/local%40example.com'
|
* Nextcloud Mail uses insecure unencoded 'index.php/apps/mail/api/avatars/url/local%40example.com'
|
||||||
*/
|
*/
|
||||||
// public function ServiceAvatar(...$aParts)
|
// public function ServiceAvatar(...$aParts)
|
||||||
public function ServiceAvatar(string $sServiceName, string $sBimi, string $sEncryptedEmail)
|
public function ServiceAvatar(string $sServiceName, string $sBimi, string $sEncodedEmail)
|
||||||
{
|
{
|
||||||
$maxAge = 86400;
|
$maxAge = 86400;
|
||||||
$sEmail = \SnappyMail\Crypt::DecryptUrlSafe($sEncryptedEmail);
|
$sEmail = \MailSo\Base\Utils::UrlSafeBase64Decode($sEncodedEmail);
|
||||||
$aBimi = \explode('-', $sBimi, 2);
|
$aBimi = \explode('-', $sBimi, 2);
|
||||||
$sBimiSelector = isset($aBimi[1]) ? $aBimi[1] : 'default';
|
$sBimiSelector = isset($aBimi[1]) ? $aBimi[1] : 'default';
|
||||||
// $sEmail && \MailSo\Base\Http::setETag("{$sBimiSelector}-{$sEncryptedEmail}");
|
// $sEmail && \MailSo\Base\Http::setETag("{$sBimiSelector}-{$sEncodedEmail}");
|
||||||
if ($sEmail && ($aResult = $this->getAvatar($sEmail, !empty($aBimi[0]), $sBimiSelector))) {
|
if ($sEmail && ($aResult = $this->getAvatar($sEmail, !empty($aBimi[0]), $sBimiSelector))) {
|
||||||
\header("Cache-Control: max-age={$maxAge}, private");
|
\header("Cache-Control: max-age={$maxAge}, private");
|
||||||
\header('Expires: '.\gmdate('D, j M Y H:i:s', $maxAge + \time()).' UTC');
|
\header('Expires: '.\gmdate('D, j M Y H:i:s', $maxAge + \time()).' UTC');
|
||||||
|
|
@ -142,6 +139,10 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
|
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
|
||||||
->SetAllowedInJs(true)
|
->SetAllowedInJs(true)
|
||||||
->SetDefaultValue(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')
|
\RainLoop\Plugins\Property::NewInstance('bimi')->SetLabel('BIMI')
|
||||||
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
|
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
|
||||||
->SetDefaultValue(false)
|
->SetDefaultValue(false)
|
||||||
|
|
@ -188,11 +189,8 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
return $aResult;
|
return $aResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid.
|
private static function getServicePng(string $sDomain) : ?string
|
||||||
private static function getServiceIcon(string $sEmail) : ?string
|
|
||||||
{
|
{
|
||||||
$sDomain = \explode('@', $sEmail);
|
|
||||||
$sDomain = \array_pop($sDomain);
|
|
||||||
$aServices = [
|
$aServices = [
|
||||||
"services/{$sDomain}",
|
"services/{$sDomain}",
|
||||||
'services/' . static::serviceDomain($sDomain)
|
'services/' . static::serviceDomain($sDomain)
|
||||||
|
|
@ -200,9 +198,20 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
foreach ($aServices as $service) {
|
foreach ($aServices as $service) {
|
||||||
$file = __DIR__ . "/images/{$service}.png";
|
$file = __DIR__ . "/images/{$service}.png";
|
||||||
if (\file_exists($file)) {
|
if (\file_exists($file)) {
|
||||||
return 'data:image/png;base64,' . \base64_encode(\file_get_contents($file));
|
return $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);
|
$aResult = static::getCachedImage($sEmail);
|
||||||
if ($aResult) {
|
if ($aResult) {
|
||||||
|
|
@ -225,30 +234,23 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
\header('Cache-Control: private');
|
\header('Cache-Control: private');
|
||||||
// \header('Expires: '.\gmdate('D, j M Y H:i:s', \time() + 86400).' UTC');
|
// \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);
|
$aResult = static::getCachedImage($sEmail);
|
||||||
if ($aResult) {
|
if ($aResult) {
|
||||||
return $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) {
|
if (!$aResult) {
|
||||||
$sDomain = \explode('@', $sEmail);
|
$sDomain = \explode('@', $sEmail);
|
||||||
|
|
@ -268,7 +270,7 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->Config()->Get('plugin', 'gravatar', false)) {
|
if ($this->Config()->Get('plugin', 'gravatar', false)) {
|
||||||
$aUrls[] = 'https://gravatar.com/avatar/'.\md5(\strtolower($sAsciiEmail)).'?s=80&d=404';
|
$aUrls[] = 'https://gravatar.com/avatar/'.\hash('sha256', \strtolower($sAsciiEmail)).'?s=80&d=404';
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($aUrls as $sUrl) {
|
foreach ($aUrls as $sUrl) {
|
||||||
|
|
@ -284,20 +286,13 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
|
|
||||||
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid.
|
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid.
|
||||||
if ($bBimi && !$aResult) {
|
if ($bBimi && !$aResult) {
|
||||||
$aServices = [
|
$file = static::getServicePng($sDomain);
|
||||||
"services/{$sDomain}",
|
if ($file) {
|
||||||
'services/' . static::serviceDomain($sDomain)
|
|
||||||
];
|
|
||||||
foreach ($aServices as $service) {
|
|
||||||
$file = __DIR__ . "/images/{$service}.png";
|
|
||||||
if (\file_exists($file)) {
|
|
||||||
\MailSo\Base\Http::setLastModified(\filemtime($file));
|
\MailSo\Base\Http::setLastModified(\filemtime($file));
|
||||||
$aResult = [
|
$aResult = [
|
||||||
'image/png',
|
'image/png',
|
||||||
\file_get_contents($file)
|
\file_get_contents($file)
|
||||||
];
|
];
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$aResult && $this->Config()->Get('plugin', 'favicon', false)) {
|
if (!$aResult && $this->Config()->Get('plugin', 'favicon', false)) {
|
||||||
|
|
@ -318,6 +313,77 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
return $sDomain;
|
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
|
private static function cacheImage(string $sEmail, array $aResult) : void
|
||||||
{
|
{
|
||||||
if (!\is_dir(\APP_PRIVATE_DATA . 'avatars')) {
|
if (!\is_dir(\APP_PRIVATE_DATA . 'avatars')) {
|
||||||
|
|
|
||||||
13
plugins/change-password/langs/uk.ini
Normal file
13
plugins/change-password/langs/uk.ini
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[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',
|
NAME = 'Compact Composer',
|
||||||
AUTHOR = 'Sergey Mosin',
|
AUTHOR = 'Sergey Mosin',
|
||||||
URL = 'https://github.com/the-djmaze/snappymail/pull/1466',
|
URL = 'https://github.com/the-djmaze/snappymail/pull/1466',
|
||||||
VERSION = '1.0.5',
|
VERSION = '1.0.6',
|
||||||
RELEASE = '2024-08-08',
|
RELEASE = '2024-08-08',
|
||||||
REQUIRED = '2.34.0',
|
REQUIRED = '2.34.0',
|
||||||
LICENSE = 'AGPL v3',
|
LICENSE = 'AGPL v3',
|
||||||
|
|
|
||||||
|
|
@ -100,10 +100,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
pasteSanitizer = (event) => {
|
|
||||||
return rl.Utils.cleanHtml(event.detail.html).html;
|
|
||||||
},
|
|
||||||
|
|
||||||
pasteImageHandler = (e, squire) => {
|
pasteImageHandler = (e, squire) => {
|
||||||
|
|
||||||
const items = [...e.detail.clipboardData.items];
|
const items = [...e.detail.clipboardData.items];
|
||||||
|
|
@ -142,7 +138,12 @@
|
||||||
toolbar.className = 'squire-toolbar btn-toolbar';
|
toolbar.className = 'squire-toolbar btn-toolbar';
|
||||||
const actions = this.makeActions(squire, toolbar);
|
const actions = this.makeActions(squire, toolbar);
|
||||||
|
|
||||||
this.squire.addEventListener('willPaste', pasteSanitizer);
|
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('pasteImage', (e) => {
|
this.squire.addEventListener('pasteImage', (e) => {
|
||||||
pasteImageHandler(e, squire);
|
pasteImageHandler(e, squire);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -5,8 +5,8 @@ class ICSViewerPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
const
|
const
|
||||||
NAME = 'ICS Viewer',
|
NAME = 'ICS Viewer',
|
||||||
AUTHOR = 'PhieF',
|
AUTHOR = 'PhieF',
|
||||||
VERSION = '1.0',
|
VERSION = '1.1',
|
||||||
RELEASE = '2024-09-17',
|
RELEASE = '2026-10-22',
|
||||||
CATEGORY = 'Messages',
|
CATEGORY = 'Messages',
|
||||||
DESCRIPTION = 'Display ICS attachment using ical lib, or JSON-LD details, based on viewICS',
|
DESCRIPTION = 'Display ICS attachment using ical lib, or JSON-LD details, based on viewICS',
|
||||||
REQUIRED = '2.34.0';
|
REQUIRED = '2.34.0';
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,17 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// ICS attachment
|
// ICS attachment.
|
||||||
// let ics = msg.attachments.find(attachment => 'application/ics' == attachment.mimeType);
|
// attachments is a ko.observableArray, so it must be
|
||||||
|
// unwrapped before array methods are used on it.
|
||||||
let ics = msg.attachments.find(attachment => 'text/calendar' == attachment.mimeType);
|
// A scheduling mail does not always carry a text/calendar
|
||||||
|
// attachment either: the part is often inline inside
|
||||||
|
// multipart/alternative and repeated as application/ics.
|
||||||
|
let ics = (msg.attachments() || []).find(attachment =>
|
||||||
|
'text/calendar' == attachment.mimeType
|
||||||
|
|| 'application/ics' == attachment.mimeType
|
||||||
|
|| 'text/x-vcalendar' == attachment.mimeType
|
||||||
|
|| /\.ics$/i.test(attachment.fileName || ''));
|
||||||
if (ics && ics.download) {
|
if (ics && ics.download) {
|
||||||
|
|
||||||
// fetch it and parse the VEVENT
|
// fetch it and parse the VEVENT
|
||||||
|
|
|
||||||
|
|
@ -139,8 +139,18 @@ class LdapContactsSuggestions implements \RainLoop\Providers\Suggestions\ISugges
|
||||||
$sFilter .= (1 < count($aItems) ? '(|' : '').$sSubFilter.(1 < count($aItems) ? ')' : '');
|
$sFilter .= (1 < count($aItems) ? '(|' : '').$sSubFilter.(1 < count($aItems) ? ')' : '');
|
||||||
$sFilter .= ')';
|
$sFilter .= ')';
|
||||||
|
|
||||||
$this->logWrite('ldap_search: start: '.$sBaseDn.' / '.$sFilter, \LOG_INFO, 'LDAP');
|
// A directory rarely keeps everything worth suggesting in one branch:
|
||||||
$oS = @\ldap_search($oCon, $sBaseDn, $sFilter, $aItems, 0, 30, 30);
|
// meeting rooms and other bookable resources commonly live outside
|
||||||
|
// the people branch. Searching a single subtree either misses them,
|
||||||
|
// or - if the base is widened to the domain root - drags every
|
||||||
|
// service account into the suggestion list. Base DNs are therefore
|
||||||
|
// separated by '|', which cannot appear unescaped in a DN, so an
|
||||||
|
// existing single-branch configuration keeps working unchanged.
|
||||||
|
$aBaseDns = \array_filter(\array_map('trim', \explode('|', $sBaseDn)), 'strlen');
|
||||||
|
|
||||||
|
foreach ($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) {
|
if ($oS) {
|
||||||
$aEntries = @\ldap_get_entries($oCon, $oS);
|
$aEntries = @\ldap_get_entries($oCon, $oS);
|
||||||
if (is_array($aEntries)) {
|
if (is_array($aEntries)) {
|
||||||
|
|
@ -161,7 +171,9 @@ class LdapContactsSuggestions implements \RainLoop\Providers\Suggestions\ISugges
|
||||||
$this->logLdapError($oCon, 'ldap_get_entries');
|
$this->logLdapError($oCon, 'ldap_get_entries');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$this->logLdapError($oCon, 'ldap_search');
|
// One unreachable branch must not silence the others.
|
||||||
|
$this->logLdapError($oCon, 'ldap_search ('.$sOneBaseDn.')');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ class LdapContactsSuggestionsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
{
|
{
|
||||||
const
|
const
|
||||||
NAME = 'Contacts suggestions (LDAP)',
|
NAME = 'Contacts suggestions (LDAP)',
|
||||||
VERSION = '2.14',
|
VERSION = '2.15',
|
||||||
RELEASE = '2024-03-12',
|
RELEASE = '2026-08-19',
|
||||||
REQUIRED = '2.35.3',
|
REQUIRED = '2.35.3',
|
||||||
CATEGORY = 'Contacts',
|
CATEGORY = 'Contacts',
|
||||||
DESCRIPTION = 'Get contacts suggestions from LDAP.';
|
DESCRIPTION = 'Get contacts suggestions from LDAP.';
|
||||||
|
|
@ -79,7 +79,7 @@ class LdapContactsSuggestionsPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
->SetType(\RainLoop\Enumerations\PluginPropertyType::PASSWORD)
|
->SetType(\RainLoop\Enumerations\PluginPropertyType::PASSWORD)
|
||||||
->SetDefaultValue(''),
|
->SetDefaultValue(''),
|
||||||
\RainLoop\Plugins\Property::NewInstance('base_dn')->SetLabel('Search base DN')
|
\RainLoop\Plugins\Property::NewInstance('base_dn')->SetLabel('Search base DN')
|
||||||
->SetDescription('DN to use as the search base. Supported tokens: {domain}, {domain:dc}, {email}, {email:user}, {email:domain}, {login}, {imap:login}, {imap:host}, {imap:port}')
|
->SetDescription('DN to use as the search base. Supported tokens: {domain}, {domain:dc}, {email}, {email:user}, {email:domain}, {login}, {imap:login}, {imap:host}, {imap:port} Several branches may be given, separated by | - useful when meeting rooms or other resources live outside the people branch.')
|
||||||
->SetDefaultValue('ou=People,dc=example,dc=com'),
|
->SetDefaultValue('ou=People,dc=example,dc=com'),
|
||||||
\RainLoop\Plugins\Property::NewInstance('object_classes')->SetLabel('objectClasses')
|
\RainLoop\Plugins\Property::NewInstance('object_classes')->SetLabel('objectClasses')
|
||||||
->SetDescription('LDAP objectClasses to search for, comma separated list')
|
->SetDescription('LDAP objectClasses to search for, comma separated list')
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Plugin which allows you to set up LDAP username by email address (when IMAP/SMTP requires it)
|
This plugins allows to authenticate using LDAP mail field, yet login field would still be used for IMAP and SMTP authentication.
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@ class LDAPLoginMappingPlugin extends AbstractPlugin
|
||||||
{
|
{
|
||||||
const
|
const
|
||||||
NAME = 'LDAP login mapping',
|
NAME = 'LDAP login mapping',
|
||||||
VERSION = '2.3',
|
VERSION = '2.4',
|
||||||
AUTHOR = 'RainLoop Team, Ludovic Pouzenc<ludovic@pouzenc.fr>, ZephOne<zephone@protonmail.com>',
|
AUTHOR = 'RainLoop Team, Ludovic Pouzenc<ludovic@pouzenc.fr>, ZephOne<zephone@protonmail.com>',
|
||||||
RELEASE = '2024-09-20',
|
RELEASE = '2025-01-09',
|
||||||
REQUIRED = '2.36.1',
|
REQUIRED = '2.36.1',
|
||||||
CATEGORY = 'Login',
|
CATEGORY = 'Login',
|
||||||
DESCRIPTION = 'Enable custom mapping using ldap field';
|
DESCRIPTION = 'This plugins allows to authenticate using LDAP mail field, yet login field would still be used for IMAP and SMTP authentication';
|
||||||
/**
|
/**
|
||||||
* @var array
|
* @var array
|
||||||
*/
|
*/
|
||||||
|
|
@ -104,9 +104,10 @@ class LDAPLoginMappingPlugin extends AbstractPlugin
|
||||||
$sResult = $this->ldapSearch($sEmail);
|
$sResult = $this->ldapSearch($sEmail);
|
||||||
if ( is_array($sResult) ) {
|
if ( is_array($sResult) ) {
|
||||||
$sImapUser = $sResult['login'];
|
$sImapUser = $sResult['login'];
|
||||||
|
$sSmtpUser = $sResult['login'];
|
||||||
$sEmail = $sResult['email'];
|
$sEmail = $sResult['email'];
|
||||||
}
|
}
|
||||||
syslog(LOG_WARNING, "plugins/ldap-login-mapping/index.php:FilterLoginСredentials() auth try: $sIP/$sEmail, resolved as $sImapUser/$sEmail");
|
syslog(LOG_WARNING, "plugins/ldap-login-mapping/index.php:FilterLoginCredentials() auth try: $sIP/$sEmail, resolved as $sImapUser/$sEmail");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
20
plugins/login-http/LICENSE
Normal file
20
plugins/login-http/LICENSE
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2016 RainLoop Team
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
39
plugins/login-http/index.php
Normal file
39
plugins/login-http/index.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class LoginHttpPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
|
{
|
||||||
|
const
|
||||||
|
NAME = 'Login HTTP',
|
||||||
|
AUTHOR = 'Alterative Editions',
|
||||||
|
URL = 'https://alterative.fr/',
|
||||||
|
VERSION = '1.0',
|
||||||
|
RELEASE = '2026-02-09',
|
||||||
|
REQUIRED = '2.21.0',
|
||||||
|
CATEGORY = 'Login',
|
||||||
|
LICENSE = 'MIT',
|
||||||
|
DESCRIPTION = 'Automatic HTTP Login : uses PHP_AUTH_USER & PHP_AUTH_PW for IMAP connection';
|
||||||
|
|
||||||
|
public function Init() : void
|
||||||
|
{
|
||||||
|
$this->addPartHook('ExternalHTTP', 'ServiceExternalHTTP');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ServiceExternalHTTP() : string
|
||||||
|
{
|
||||||
|
$oActions = \RainLoop\Api::Actions();
|
||||||
|
$oActions->Http()->ServerNoCache();
|
||||||
|
$sUser=$_SERVER['PHP_AUTH_USER'] ?? '';
|
||||||
|
$sPassword=$_SERVER['PHP_AUTH_PW'] ?? '';
|
||||||
|
try {
|
||||||
|
$oAccount = $oActions->LoginProcess($sUser, new \SnappyMail\SensitiveString($sPassword));
|
||||||
|
if ($oAccount) {
|
||||||
|
\MailSo\Base\Http::Location('./');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Log l'erreur si besoin
|
||||||
|
}
|
||||||
|
\MailSo\Base\Http::Location('./?ExternalHTTP');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,54 +1,91 @@
|
||||||
(rl => {
|
((rl) => {
|
||||||
const client_id = rl.pluginSettingsGet('login-o365', 'client_id'),
|
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
|
allowAnyDomain = !!rl.pluginSettingsGet("login-o365", "allow_any_domain"),
|
||||||
query = rl.pluginSettingsGet('login-o365', 'personal') ? '' : '?',
|
isSupportedEmail = (email) => {
|
||||||
tenant = rl.pluginSettingsGet('login-o365', 'tenant'),
|
email = (email || "").toLowerCase();
|
||||||
login = () => {
|
if (!email.includes("@")) return false;
|
||||||
document.location = 'https://login.microsoftonline.com/'+tenant+'/oauth2/v2.0/authorize?' + (new URLSearchParams({
|
if (allowAnyDomain) return true;
|
||||||
response_type: 'code',
|
return /@(outlook\.com|hotmail\.com|live\.com)$/.test(email);
|
||||||
client_id: client_id,
|
},
|
||||||
redirect_uri: document.location.href.replace(/\/$/, '') + '/' + query + 'LoginO365',
|
startOAuth = (op, opts = {}) => {
|
||||||
scope: [
|
const email = (opts.email || "").toLowerCase();
|
||||||
// Associate personal info
|
const name = opts.name || "";
|
||||||
'openid',
|
const returnHash = opts.return || "";
|
||||||
'offline_access',
|
|
||||||
'email',
|
// Server mints a signed state + correct redirect_uri and returns full authUrl.
|
||||||
'profile',
|
rl.pluginRemoteRequest((iError, data) => {
|
||||||
// Access IMAP and SMTP through OAUTH
|
const url = data?.Result?.authUrl;
|
||||||
'https://graph.microsoft.com/IMAP.AccessAsUser.All',
|
if (!iError && url) {
|
||||||
// 'https://graph.microsoft.com/Mail.ReadWrite'
|
document.location = url;
|
||||||
'https://graph.microsoft.com/Mail.Send'
|
}
|
||||||
/* // Legacy:
|
}, "LoginO365AuthUrl", {
|
||||||
'https://outlook.office.com/SMTP.Send',
|
op: op,
|
||||||
'https://outlook.office.com/IMAP.AccessAsUser.All'
|
email: email,
|
||||||
*/
|
name: name,
|
||||||
].join(' '),
|
return: returnHash,
|
||||||
state: 'o365', // + rl.settings.app('token') + localStorage.getItem('smctoken')
|
});
|
||||||
// Force authorize screen, so we always get a refresh_token
|
|
||||||
access_type: 'offline',
|
|
||||||
prompt: 'consent'
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (client_id) {
|
if (client_id) {
|
||||||
addEventListener('sm-user-login', e => {
|
addEventListener("sm-user-login", (e) => {
|
||||||
if (event.detail.get('Email').includes('@hotmail.com')) {
|
const email = (e.detail.get("Email") || "").toLowerCase();
|
||||||
|
if (isSupportedEmail(email)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
login();
|
startOAuth("login", { email });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
addEventListener('rl-view-model', e => {
|
addEventListener("rl-view-model", (e) => {
|
||||||
if ('Login' === e.detail.viewModelTemplateID) {
|
if ("Login" === e.detail.viewModelTemplateID) {
|
||||||
const
|
const
|
||||||
container = e.detail.viewModelDom.querySelector('#plugin-Login-BottomControlGroup'),
|
container = e.detail.viewModelDom.querySelector("#plugin-Login-BottomControlGroup"),
|
||||||
btn = Element.fromHTML('<button type="button">Outlook</button>'),
|
btn = Element.fromHTML('<button type="button">Outlook</button>'),
|
||||||
div = Element.fromHTML('<div class="controls"></div>');
|
div = Element.fromHTML('<div class="controls"></div>');
|
||||||
btn.onclick = login;
|
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);
|
div.append(btn);
|
||||||
container && container.append(div);
|
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);
|
})(window.rl);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Microsoft requires an Azure account that has an active subscription
|
* SnappyMail login-o365 plugin
|
||||||
* I'm not going to pay, so feel free to fix this code yourself.
|
* You need to register an app in Azure portal and add
|
||||||
* https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
|
* a secret, redirect URIs and the following API permissions:
|
||||||
* https://answers.microsoft.com/en-us/msoffice/forum/all/configuration-for-imap-pop-and-smtp-with-oauth-in/3db47d43-25ac-4e0b-b957-22585e6caf15
|
* https://outlook.office.com/IMAP.AccessAsUser.All
|
||||||
*
|
* https://outlook.office.com/SMTP.Send
|
||||||
* https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps
|
* openid offline_access email profile
|
||||||
*
|
|
||||||
* https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris
|
* https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris
|
||||||
* Azure: redirect_uri=https://{DOMAIN}/?LoginO365
|
* Query: redirect_uri=https://{DOMAIN}/?LoginO365
|
||||||
* Personal: redirect_uri=https://{DOMAIN}/LoginO365
|
* Path: redirect_uri=https://{DOMAIN}/LoginO365
|
||||||
|
*
|
||||||
|
* If running behind nginx reverse proxy you might
|
||||||
|
* need to add the following to your nginx config:
|
||||||
|
* location = /LoginO365 {
|
||||||
|
* return 302 /?LoginO365&$args;
|
||||||
|
* }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use RainLoop\Model\MainAccount;
|
use RainLoop\Model\MainAccount;
|
||||||
|
|
@ -19,18 +24,28 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
{
|
{
|
||||||
const
|
const
|
||||||
NAME = 'Office365/Outlook OAuth2',
|
NAME = 'Office365/Outlook OAuth2',
|
||||||
VERSION = '0.3',
|
VERSION = '0.4',
|
||||||
RELEASE = '2024-09-29',
|
RELEASE = '2025-12-22',
|
||||||
REQUIRED = '2.36.1',
|
REQUIRED = '2.36.1',
|
||||||
CATEGORY = 'Login',
|
CATEGORY = 'Login',
|
||||||
DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2';
|
DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2';
|
||||||
|
|
||||||
// https://login.microsoftonline.com/{{tenant}}/v2.0/.well-known/openid-configuration
|
// v2 endpoints
|
||||||
const
|
const
|
||||||
LOGIN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/auth',
|
AUTH_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/authorize',
|
||||||
TOKEN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token';
|
TOKEN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token';
|
||||||
|
|
||||||
private static ?array $auth = null;
|
/**
|
||||||
|
* In-request cache of decrypted token bundles, keyed by lowercase email.
|
||||||
|
* This avoids re-decrypting the same blob multiple times during a single request.
|
||||||
|
*
|
||||||
|
* Shape:
|
||||||
|
* [
|
||||||
|
* 'user@outlook.com' => ['access_token'=>..., 'refresh_token'=>..., 'expires'=>..., 'expires_in'=>...],
|
||||||
|
* ...
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
private static array $auth = [];
|
||||||
|
|
||||||
public function Init() : void
|
public function Init() : void
|
||||||
{
|
{
|
||||||
|
|
@ -41,14 +56,18 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
$this->addHook('sieve.before-login', 'clientLogin');
|
$this->addHook('sieve.before-login', 'clientLogin');
|
||||||
|
|
||||||
$this->addPartHook('LoginO365', 'ServiceLoginO365');
|
$this->addPartHook('LoginO365', 'ServiceLoginO365');
|
||||||
|
// Used by JS to obtain an auth URL with signed state (for both login + add-account flows).
|
||||||
|
$this->addJsonHook('LoginO365AuthUrl', 'DoLoginO365AuthUrl');
|
||||||
|
|
||||||
// Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true
|
// Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true
|
||||||
$this->addHook('filter.http-paths', 'httpPaths');
|
$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
|
public function httpPaths(array &$aPaths) : void
|
||||||
{
|
{
|
||||||
// Personal accounts workaround
|
|
||||||
if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) {
|
if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) {
|
||||||
$aPaths = ['LoginO365'];
|
$aPaths = ['LoginO365'];
|
||||||
}
|
}
|
||||||
|
|
@ -70,85 +89,167 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (isset($_GET['error'])) {
|
if (isset($_GET['error'])) {
|
||||||
throw new \RuntimeException("{$_GET['error']}: {$_GET['error_description']}");
|
$desc = $_GET['error_description'] ?? '';
|
||||||
|
throw new \RuntimeException("{$_GET['error']}: {$desc}");
|
||||||
}
|
}
|
||||||
if (!isset($_GET['code']) || empty($_GET['state']) || 'o365' !== $_GET['state']) {
|
|
||||||
|
// Must have code + state
|
||||||
|
if (!isset($_GET['code']) || empty($_GET['state'])) {
|
||||||
$oActions->Location(\RainLoop\Utils::WebPath());
|
$oActions->Location(\RainLoop\Utils::WebPath());
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$oO365 = $this->o365Connector();
|
$oO365 = $this->o365Connector();
|
||||||
if (!$oO365) {
|
if (!$oO365) {
|
||||||
$oActions->Location(\RainLoop\Utils::WebPath());
|
$oActions->Location(\RainLoop\Utils::WebPath());
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$iExpires = \time();
|
$iNow = \time();
|
||||||
$aResponse = $oO365->getAccessToken(
|
|
||||||
\str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI),
|
$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',
|
'authorization_code',
|
||||||
array(
|
[
|
||||||
'code' => $_GET['code'],
|
'code' => $_GET['code'],
|
||||||
'redirect_uri' => $oHttp->GetFullUrl().'?LoginO365'
|
'redirect_uri' => $redirectUri
|
||||||
)
|
]
|
||||||
);
|
|
||||||
if (200 != $aResponse['code']) {
|
|
||||||
if (isset($aResponse['result']['error'])) {
|
|
||||||
throw new \RuntimeException(
|
|
||||||
$aResponse['code']
|
|
||||||
. ': '
|
|
||||||
. $aResponse['result']['error']
|
|
||||||
. ' / '
|
|
||||||
. $aResponse['result']['error_description']
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!\is_array($aTokenWrap) || !isset($aTokenWrap['code'])) {
|
||||||
|
throw new \RuntimeException('Token request failed: ' . \json_encode($aTokenWrap));
|
||||||
}
|
}
|
||||||
throw new \RuntimeException("HTTP: {$aResponse['code']}");
|
if (200 !== (int)$aTokenWrap['code']) {
|
||||||
|
$err = $aTokenWrap['result']['error'] ?? '';
|
||||||
|
$desc = $aTokenWrap['result']['error_description'] ?? '';
|
||||||
|
throw new \RuntimeException("Token HTTP {$aTokenWrap['code']}: {$err} / {$desc}");
|
||||||
}
|
}
|
||||||
$aResponse = $aResponse['result'];
|
|
||||||
if (empty($aResponse['access_token'])) {
|
$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');
|
throw new \RuntimeException('access_token missing');
|
||||||
}
|
}
|
||||||
if (empty($aResponse['refresh_token'])) {
|
|
||||||
|
if ($refreshToken === '') {
|
||||||
throw new \RuntimeException('refresh_token missing');
|
throw new \RuntimeException('refresh_token missing');
|
||||||
}
|
}
|
||||||
|
if ($idToken === '') {
|
||||||
$sAccessToken = $aResponse['access_token'];
|
// We rely on id_token to get email/sub without Graph.
|
||||||
$iExpires += $aResponse['expires_in'];
|
throw new \RuntimeException('id_token missing (add openid email profile scopes)');
|
||||||
|
|
||||||
$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 = [
|
// Parse id_token (JWT) to get identity (sub + email)
|
||||||
'access_token' => $sAccessToken,
|
$claims = $this->decodeJwtPayload($idToken);
|
||||||
'refresh_token' => $aResponse['refresh_token'],
|
if (!\is_array($claims)) {
|
||||||
'expires_in' => $aResponse['expires_in'],
|
throw new \RuntimeException('Cannot decode id_token payload');
|
||||||
'expires' => $iExpires
|
}
|
||||||
|
|
||||||
|
$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
|
||||||
];
|
];
|
||||||
|
|
||||||
$oPassword = new \SnappyMail\SensitiveString($aUserInfo['id']);
|
$op = $statePayload['op'] ?? 'login';
|
||||||
$oAccount = $oActions->LoginProcess($aUserInfo['email'], $oPassword);
|
if ('add' === $op) {
|
||||||
// $oAccount = MainAccount::NewInstanceFromCredentials($oActions, $aUserInfo['email'], $aUserInfo['email'], $oPassword, true);
|
$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) {
|
if ($oAccount) {
|
||||||
// $oActions->SetMainAuthAccount($oAccount);
|
$oActions->StorageProvider()->Put(
|
||||||
// $oActions->SetAuthToken($oAccount);
|
$oAccount,
|
||||||
$oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(),
|
StorageType::SESSION,
|
||||||
\SnappyMail\Crypt::EncryptToJSON(static::$auth, $oAccount->CryptKey())
|
\RainLoop\Utils::GetSessionToken(),
|
||||||
|
\SnappyMail\Crypt::EncryptToJSON($tokenBundle, $oAccount->CryptKey())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (\Exception $oException)
|
catch (\Throwable $e) {
|
||||||
{
|
$oActions->Logger()->WriteException($e, \LOG_ERR);
|
||||||
$oActions->Logger()->WriteException($oException, \LOG_ERR);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$oActions->Location(\RainLoop\Utils::WebPath());
|
$oActions->Location(\RainLoop\Utils::WebPath());
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
@ -156,66 +257,147 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
public function configMapping() : array
|
public function configMapping() : array
|
||||||
{
|
{
|
||||||
return [
|
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')
|
\RainLoop\Plugins\Property::NewInstance('client_id')
|
||||||
->SetLabel('Client ID')
|
->SetLabel('Client ID')
|
||||||
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
|
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
|
||||||
->SetAllowedInJs()
|
->SetAllowedInJs(),
|
||||||
->SetDescription('https://github.com/the-djmaze/snappymail/wiki/FAQ#o365'),
|
|
||||||
\RainLoop\Plugins\Property::NewInstance('client_secret')
|
\RainLoop\Plugins\Property::NewInstance('client_secret')
|
||||||
->SetLabel('Client Secret')
|
->SetLabel('Client Secret')
|
||||||
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
|
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
|
||||||
->SetEncrypted(),
|
->SetEncrypted(),
|
||||||
\RainLoop\Plugins\Property::NewInstance('tenant_id')
|
\RainLoop\Plugins\Property::NewInstance('tenant')
|
||||||
->SetLabel('Tenant ID')
|
->SetLabel('Tenant')
|
||||||
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING),
|
|
||||||
\RainLoop\Plugins\Property::NewInstance('tenant')->SetLabel('Tenant')
|
|
||||||
->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION)
|
->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION)
|
||||||
->SetDefaultValue(['common','consumers','organizations'])
|
->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()
|
->SetAllowedInJs()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clientLogin(\RainLoop\Model\Account $oAccount, \MailSo\Net\NetClient $oClient, \MailSo\Net\ConnectSettings $oSettings) : void
|
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')) {
|
$email = \strtolower($oAccount->Email());
|
||||||
$oActions = \RainLoop\Api::Actions();
|
|
||||||
try {
|
if (!$this->isSupportedEmail($email)) {
|
||||||
$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;
|
return;
|
||||||
}
|
}
|
||||||
if (!empty($aData['expires']) && !empty($aData['access_token']) && !empty($aData['refresh_token'])) {
|
|
||||||
if (\time() >= $aData['expires']) {
|
$oActions = \RainLoop\Api::Actions();
|
||||||
$iExpires = \time();
|
|
||||||
|
$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();
|
$oO365 = $this->o365Connector();
|
||||||
if ($oO365) {
|
if ($oO365) {
|
||||||
$aRefreshTokenResponse = $oO365->getAccessToken(
|
$tenant = $this->Config()->Get('plugin', 'tenant', 'common');
|
||||||
\str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI),
|
$aRefreshWrap = $oO365->getAccessToken(
|
||||||
|
\str_replace('{{tenant}}', $tenant, static::TOKEN_URI),
|
||||||
'refresh_token',
|
'refresh_token',
|
||||||
array('refresh_token' => $aData['refresh_token'])
|
['refresh_token' => $aData['refresh_token']]
|
||||||
);
|
);
|
||||||
if (!empty($aRefreshTokenResponse['result']['access_token'])) {
|
|
||||||
$aData['access_token'] = $aRefreshTokenResponse['result']['access_token'];
|
if (\is_array($aRefreshWrap) && isset($aRefreshWrap['code']) && 200 === (int)$aRefreshWrap['code']) {
|
||||||
$aResponse['expires'] = $iExpires + $aResponse['expires_in'];
|
$r = $aRefreshWrap['result'] ?? [];
|
||||||
$oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(),
|
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())
|
\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'];
|
$oSettings->passphrase = $aData['access_token'];
|
||||||
\array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2');
|
\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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,10 +405,11 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
{
|
{
|
||||||
$client_id = \trim($this->Config()->Get('plugin', 'client_id', ''));
|
$client_id = \trim($this->Config()->Get('plugin', 'client_id', ''));
|
||||||
$client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', ''));
|
$client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', ''));
|
||||||
|
|
||||||
if ($client_id && $client_secret) {
|
if ($client_id && $client_secret) {
|
||||||
try
|
try {
|
||||||
{
|
|
||||||
$oO365 = new \OAuth2\Client($client_id, $client_secret);
|
$oO365 = new \OAuth2\Client($client_id, $client_secret);
|
||||||
|
|
||||||
$oActions = \RainLoop\Api::Actions();
|
$oActions = \RainLoop\Api::Actions();
|
||||||
$sProxy = $oActions->Config()->Get('labs', 'curl_proxy', '');
|
$sProxy = $oActions->Config()->Get('labs', 'curl_proxy', '');
|
||||||
if (\strlen($sProxy)) {
|
if (\strlen($sProxy)) {
|
||||||
|
|
@ -236,13 +419,277 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
$oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth);
|
$oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $oO365;
|
return $oO365;
|
||||||
}
|
} catch (\Throwable $e) {
|
||||||
catch (\Exception $oException)
|
\RainLoop\Api::Actions()->Logger()->WriteException($e, \LOG_ERR);
|
||||||
{
|
|
||||||
$oActions->Logger()->WriteException($oException, \LOG_ERR);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function decodeJwtPayload(string $jwt) : ?array
|
||||||
|
{
|
||||||
|
$parts = \explode('.', $jwt);
|
||||||
|
if (\count($parts) < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$payload = $parts[1];
|
||||||
|
$payload .= \str_repeat('=', (4 - (\strlen($payload) % 4)) % 4);
|
||||||
|
$json = \base64_decode(\strtr($payload, '-_', '+/'));
|
||||||
|
if ($json === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data = \json_decode($json, true);
|
||||||
|
return \is_array($data) ? $data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON action called by JS to obtain an MS authorize URL with signed state.
|
||||||
|
* This avoids exposing any signing secret to JS and keeps redirect_uri consistent with server logic.
|
||||||
|
*/
|
||||||
|
public function DoLoginO365AuthUrl() : array
|
||||||
|
{
|
||||||
|
$oActions = \RainLoop\Api::Actions();
|
||||||
|
|
||||||
|
$op = (string) $this->jsonParam('op', 'login');
|
||||||
|
if (!\in_array($op, ['login', 'add'], true)) {
|
||||||
|
return $this->jsonResponse(__FUNCTION__, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = \strtolower(\trim((string) $this->jsonParam('email', '')));
|
||||||
|
$name = \trim((string) $this->jsonParam('name', ''));
|
||||||
|
$returnHash = (string) $this->jsonParam('return', '');
|
||||||
|
|
||||||
|
if ($returnHash && !\str_starts_with($returnHash, '#')) {
|
||||||
|
$returnHash = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For add-account flow, require a logged-in main account (we must write to its additionalaccounts storage).
|
||||||
|
$oMainAccount = null;
|
||||||
|
if ('add' === $op) {
|
||||||
|
$oMainAccount = $oActions->getMainAccountFromToken(false);
|
||||||
|
if (!$oMainAccount) {
|
||||||
|
return $this->jsonResponse(__FUNCTION__, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional server-side guard: only permit supported consumer domains unless configured otherwise.
|
||||||
|
if ($email && !$this->isSupportedEmail($email)) {
|
||||||
|
return $this->jsonResponse(__FUNCTION__, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$oConfig = $this->Config();
|
||||||
|
$client_id = \trim($oConfig->Get('plugin', 'client_id', ''));
|
||||||
|
if (!$client_id) {
|
||||||
|
return $this->jsonResponse(__FUNCTION__, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nonce = $this->b64url(\random_bytes(16));
|
||||||
|
// Store nonce server-side to prevent replay; consumed on callback.
|
||||||
|
$oActions->StorageProvider()->Put(
|
||||||
|
null,
|
||||||
|
StorageType::NOBODY,
|
||||||
|
$this->stateNonceKey($nonce),
|
||||||
|
(string) \time()
|
||||||
|
);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'v' => 1,
|
||||||
|
'op' => $op,
|
||||||
|
'csrf' => \RainLoop\Utils::GetCsrfToken(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'ts' => \time()
|
||||||
|
];
|
||||||
|
if ('add' === $op && $oMainAccount) {
|
||||||
|
$payload['main'] = $oMainAccount->Email();
|
||||||
|
if ($name) {
|
||||||
|
$payload['name'] = \substr($name, 0, 100);
|
||||||
|
}
|
||||||
|
if ($returnHash) {
|
||||||
|
$payload['return'] = \substr($returnHash, 0, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $this->signState($payload);
|
||||||
|
$tenant = $oConfig->Get('plugin', 'tenant', 'common');
|
||||||
|
$redirectUri = $this->redirectUri();
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'response_type' => 'code',
|
||||||
|
'client_id' => $client_id,
|
||||||
|
'redirect_uri' => $redirectUri,
|
||||||
|
'scope' => \implode(' ', [
|
||||||
|
'openid',
|
||||||
|
'offline_access',
|
||||||
|
'email',
|
||||||
|
'profile',
|
||||||
|
'https://outlook.office.com/IMAP.AccessAsUser.All',
|
||||||
|
'https://outlook.office.com/SMTP.Send',
|
||||||
|
]),
|
||||||
|
'state' => $state,
|
||||||
|
// Helps MS UI prefill, but does not change server-side validation.
|
||||||
|
];
|
||||||
|
if ($email) {
|
||||||
|
$params['login_hint'] = $email;
|
||||||
|
}
|
||||||
|
$authUrl = \str_replace('{{tenant}}', $tenant, static::AUTH_URI)
|
||||||
|
. '?'
|
||||||
|
. \http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
|
||||||
|
return $this->jsonResponse(__FUNCTION__, [
|
||||||
|
'authUrl' => $authUrl
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSupportedEmail(string $email) : bool
|
||||||
|
{
|
||||||
|
if ((bool)$this->Config()->Get('plugin', 'allow_any_domain', false)) {
|
||||||
|
return \str_contains($email, '@');
|
||||||
|
}
|
||||||
|
return \str_ends_with($email, '@hotmail.com')
|
||||||
|
|| \str_ends_with($email, '@outlook.com')
|
||||||
|
|| \str_ends_with($email, '@live.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build absolute base URL (works behind nginx reverse proxy).
|
||||||
|
*/
|
||||||
|
private function baseUrl() : string
|
||||||
|
{
|
||||||
|
$scheme = (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']))
|
||||||
|
? $_SERVER['HTTP_X_FORWARDED_PROTO']
|
||||||
|
: ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http');
|
||||||
|
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
|
||||||
|
if (!$host) {
|
||||||
|
throw new \RuntimeException('Cannot determine HTTP_HOST');
|
||||||
|
}
|
||||||
|
return $scheme . '://' . $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect URI used for the Azure app registration.
|
||||||
|
* When plugin.personal=true -> "/?LoginO365"
|
||||||
|
* When plugin.personal=false -> "/LoginO365"
|
||||||
|
*/
|
||||||
|
private function redirectUri() : string
|
||||||
|
{
|
||||||
|
$base = \rtrim($this->baseUrl(), '/');
|
||||||
|
$useQuery = (bool)$this->Config()->Get('plugin', 'personal', false);
|
||||||
|
return $useQuery ? ($base . '/?LoginO365') : ($base . '/LoginO365');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tokenStorageKey(string $emailLower) : string
|
||||||
|
{
|
||||||
|
// Stored under MAIN account StorageType::CONFIG (encrypted with main CryptKey).
|
||||||
|
// Email is hashed to avoid path/encoding issues across storage backends.
|
||||||
|
return 'login-o365.tokens.' . \sha1($emailLower);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeAccountTokens(MainAccount $oMainAccount, string $email, array $tokenBundle) : void
|
||||||
|
{
|
||||||
|
$emailLower = \strtolower(\SnappyMail\IDN::emailToAscii($email));
|
||||||
|
\RainLoop\Api::Actions()->StorageProvider()->Put(
|
||||||
|
$oMainAccount,
|
||||||
|
StorageType::CONFIG,
|
||||||
|
$this->tokenStorageKey($emailLower),
|
||||||
|
\SnappyMail\Crypt::EncryptToJSON($tokenBundle, $oMainAccount->CryptKey())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stateNonceKey(string $nonce) : string
|
||||||
|
{
|
||||||
|
return 'login-o365.state.' . $nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function b64url(string $bin) : string
|
||||||
|
{
|
||||||
|
return \rtrim(\strtr(\base64_encode($bin), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function b64urlDecode(string $b64url) /*: string|false*/
|
||||||
|
{
|
||||||
|
$pad = (4 - (\strlen($b64url) % 4)) % 4;
|
||||||
|
return \base64_decode(\strtr($b64url . \str_repeat('=', $pad), '-_', '+/'), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stateHmacKey() : string
|
||||||
|
{
|
||||||
|
// Uses the plugin client_secret (server-side only) as HMAC key.
|
||||||
|
// This prevents any user-controlled tampering of the state payload.
|
||||||
|
$key = \trim($this->Config()->getDecrypted('plugin', 'client_secret', ''));
|
||||||
|
if (!$key) {
|
||||||
|
// Fallback for misconfiguration; keeps behavior deterministic.
|
||||||
|
$key = 'login-o365';
|
||||||
|
}
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function signState(array $payload) : string
|
||||||
|
{
|
||||||
|
$json = \json_encode($payload);
|
||||||
|
if (!$json) {
|
||||||
|
$json = '{}';
|
||||||
|
}
|
||||||
|
$payloadB64 = $this->b64url($json);
|
||||||
|
$sig = \hash_hmac('sha256', $payloadB64, $this->stateHmacKey(), true);
|
||||||
|
return $payloadB64 . '.' . $this->b64url($sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify signature + CSRF + nonce, then consumes nonce to prevent replay.
|
||||||
|
* Returns decoded payload on success, null on failure.
|
||||||
|
*/
|
||||||
|
private function verifyAndConsumeState(string $state) : ?array
|
||||||
|
{
|
||||||
|
$parts = \explode('.', $state, 2);
|
||||||
|
if (2 !== \count($parts)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
[$payloadB64, $sigB64] = $parts;
|
||||||
|
$sig = $this->b64urlDecode($sigB64);
|
||||||
|
if ($sig === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = \hash_hmac('sha256', $payloadB64, $this->stateHmacKey(), true);
|
||||||
|
if (!\hash_equals($expected, $sig)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payloadJson = $this->b64urlDecode($payloadB64);
|
||||||
|
if ($payloadJson === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$payload = \json_decode($payloadJson, true);
|
||||||
|
if (!\is_array($payload) || empty($payload['csrf']) || empty($payload['nonce']) || empty($payload['op'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must match the current browser session.
|
||||||
|
if ($payload['csrf'] !== \RainLoop\Utils::GetCsrfToken()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay protection: nonce must exist server-side and is consumed once.
|
||||||
|
$oActions = \RainLoop\Api::Actions();
|
||||||
|
$key = $this->stateNonceKey((string) $payload['nonce']);
|
||||||
|
$seen = $oActions->StorageProvider()->Get(null, StorageType::NOBODY, $key);
|
||||||
|
if (!$seen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ts = (int) ($payload['ts'] ?? 0);
|
||||||
|
if ($ts && \abs(\time() - $ts) > 900) { // 15 minutes
|
||||||
|
// Expired: clear nonce to avoid accumulating stale entries.
|
||||||
|
$oActions->StorageProvider()->Clear(null, StorageType::NOBODY, $key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oActions->StorageProvider()->Clear(null, StorageType::NOBODY, $key);
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,8 +6,8 @@ class LoginRemotePlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
NAME = 'Login Remote',
|
NAME = 'Login Remote',
|
||||||
AUTHOR = 'SnappyMail',
|
AUTHOR = 'SnappyMail',
|
||||||
URL = 'https://snappymail.eu/',
|
URL = 'https://snappymail.eu/',
|
||||||
VERSION = '1.5',
|
VERSION = '1.6',
|
||||||
RELEASE = '2024-09-20',
|
RELEASE = '2026-10-22',
|
||||||
REQUIRED = '2.36.1',
|
REQUIRED = '2.36.1',
|
||||||
CATEGORY = 'Login',
|
CATEGORY = 'Login',
|
||||||
LICENSE = 'MIT',
|
LICENSE = 'MIT',
|
||||||
|
|
@ -43,6 +43,7 @@ class LoginRemotePlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
static::$login = true;
|
static::$login = true;
|
||||||
|
$sPassword = new \SnappyMail\SensitiveString($sPassword);
|
||||||
$oAccount = $oActions->LoginProcess($sEmail, $sPassword);
|
$oAccount = $oActions->LoginProcess($sEmail, $sPassword);
|
||||||
}
|
}
|
||||||
catch (\Throwable $oException)
|
catch (\Throwable $oException)
|
||||||
|
|
|
||||||
|
|
@ -19,36 +19,37 @@ The following steps are require in SnappyMail:
|
||||||
- Master User is dependent on Dovecot config (see below)
|
- Master User is dependent on Dovecot config (see below)
|
||||||
- Master User Password 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".
|
- 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)
|
- 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.
|
This concludes the setup of SnappyMail.
|
||||||
|
|
||||||
### Dovecot
|
### Dovecot
|
||||||
|
|
||||||
In Dovecot, you need to enable Master User.
|
In Dovecot, you need to enable Master User.
|
||||||
Enable ```!include auth-master.conf.ext``` in /etc/dovecot/conf.d/10-auth.conf.
|
Enable ```!include auth-master.conf.ext``` in /etc/dovecot/conf.d/10-auth.conf.
|
||||||
The file /etc/dovecot/conf.d/auth-master.conf.ext should contain:
|
In Dovecot 2.3, the file /etc/dovecot/conf.d/auth-master.conf.ext should contain:
|
||||||
```
|
```
|
||||||
# Authentication for master users. Included from auth.conf.
|
|
||||||
|
|
||||||
# By adding master=yes setting inside a passdb you make the passdb a list
|
|
||||||
# of "master users", who can log in as anyone else.
|
|
||||||
# <doc/wiki/Authentication.MasterUsers.txt>
|
|
||||||
|
|
||||||
# Example master user passdb using passwd-file. You can use any passdb though.
|
|
||||||
passdb {
|
passdb {
|
||||||
driver = passwd-file
|
driver = passwd-file
|
||||||
master = yes
|
master = yes
|
||||||
args = /etc/dovecot/master-users
|
args = /etc/dovecot/master-users
|
||||||
|
|
||||||
# Unless you're using PAM, you probably still want the destination user to
|
|
||||||
# be looked up from passdb that it really exists. pass=yes does that.
|
|
||||||
pass = yes
|
pass = yes
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In Dovecot 2.4, the file /etc/dovecot/conf.d/auth-master.conf.ext should contain:
|
||||||
|
```
|
||||||
|
passdb passwd-file {
|
||||||
|
master = yes
|
||||||
|
passwd_file_path = /etc/dovecot/master-users
|
||||||
|
result_success = continue
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
You then need to create a master user in /etc/dovecot/master-users:
|
You then need to create a master user in /etc/dovecot/master-users:
|
||||||
```
|
```
|
||||||
admin:PASSWORD::::::allow_nets=local,172.17.0.0/16
|
admin:PASSWORD::::::allow_nets=local,172.17.0.0/16
|
||||||
|
|
@ -80,3 +81,11 @@ The user is always considered logged in, as authentication is handled through re
|
||||||
Auto login can be disabled in the plugin settings.
|
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```.
|
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.
|
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',
|
NAME = 'Proxy Auth',
|
||||||
AUTHOR = 'Philipp',
|
AUTHOR = 'Philipp',
|
||||||
URL = 'https://www.mundhenk.org/',
|
URL = 'https://www.mundhenk.org/',
|
||||||
VERSION = '0.5',
|
VERSION = '0.7',
|
||||||
RELEASE = '2024-09-20',
|
RELEASE = '2026-05-17',
|
||||||
REQUIRED = '2.36.1',
|
REQUIRED = '2.36.1',
|
||||||
CATEGORY = 'Login',
|
CATEGORY = 'Login',
|
||||||
LICENSE = 'MIT',
|
LICENSE = 'MIT',
|
||||||
|
|
@ -21,26 +21,6 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
$this->addHook('login.credentials', 'MapEmailAddress');
|
$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)
|
public function MapEmailAddress(string &$sEmail, string &$sImapUser, string &$sPassword, string &$sSmtpUser)
|
||||||
{
|
{
|
||||||
$oActions = \RainLoop\Api::Actions();
|
$oActions = \RainLoop\Api::Actions();
|
||||||
|
|
@ -80,42 +60,6 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
$sMsg = "Remote User: " . $sRemoteUser;
|
$sMsg = "Remote User: " . $sRemoteUser;
|
||||||
$oLogger->Write($sMsg, $sLevel, $sPrefix);
|
$oLogger->Write($sMsg, $sLevel, $sPrefix);
|
||||||
|
|
||||||
$sProxyIP = $this->Config()->getDecrypted('plugin', 'proxy_ip', '');
|
|
||||||
$sMsg = "ProxyIP: " . $sProxyIP;
|
|
||||||
$oLogger->Write($sMsg, $sLevel, $sPrefix);
|
|
||||||
|
|
||||||
$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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($sProxyRequest) {
|
|
||||||
/* create master user login from remote user header and settings */
|
/* create master user login from remote user header and settings */
|
||||||
$sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser;
|
$sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser;
|
||||||
$sPassword = new \SnappyMail\SensitiveString(\trim($this->Config()->getDecrypted('plugin', 'master_password', '')));
|
$sPassword = new \SnappyMail\SensitiveString(\trim($this->Config()->getDecrypted('plugin', 'master_password', '')));
|
||||||
|
|
@ -135,10 +79,6 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
\MailSo\Base\Http::Location('./');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function ServiceUserHeaderSet() : bool
|
public function ServiceUserHeaderSet() : bool
|
||||||
{
|
{
|
||||||
$oActions = \RainLoop\Api::Actions();
|
$oActions = \RainLoop\Api::Actions();
|
||||||
|
|
@ -188,18 +128,6 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
->SetDescription('Name of header containing username')
|
->SetDescription('Name of header containing username')
|
||||||
->SetDefaultValue('Remote-User')
|
->SetDefaultValue('Remote-User')
|
||||||
->SetEncrypted(),
|
->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')
|
\RainLoop\Plugins\Property::NewInstance('auto_login')
|
||||||
->SetAllowedInJs(true)
|
->SetAllowedInJs(true)
|
||||||
->SetLabel('Activate automatic login')
|
->SetLabel('Activate automatic login')
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
NAME = 'Search Filters',
|
NAME = 'Search Filters',
|
||||||
AUTHOR = 'AbdoBnHesham',
|
AUTHOR = 'AbdoBnHesham',
|
||||||
URL = 'https://github.com/the-djmaze/snappymail/pull/1673',
|
URL = 'https://github.com/the-djmaze/snappymail/pull/1673',
|
||||||
VERSION = '0.2',
|
VERSION = '0.3',
|
||||||
RELEASE = '2024-06-28',
|
RELEASE = '2026-10-22',
|
||||||
REQUIRED = '2.36.3',
|
REQUIRED = '2.36.3',
|
||||||
CATEGORY = 'General',
|
CATEGORY = 'General',
|
||||||
LICENSE = 'MIT',
|
LICENSE = 'MIT',
|
||||||
|
|
@ -51,45 +51,33 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
$Filters = $aSettings['SFilters'];
|
$Filters = $aSettings['SFilters'];
|
||||||
|
|
||||||
foreach ($Filters as $filter) {
|
foreach ($Filters as $filter) {
|
||||||
$this->Manager()->logWrite(json_encode([
|
$this->Manager()->logWrite(json_encode(['filter' => $filter]), LOG_WARNING);
|
||||||
'filter' => $filter,
|
|
||||||
]), LOG_WARNING);
|
|
||||||
|
|
||||||
$folder = 'INBOX';
|
|
||||||
$searchQ = $filter['searchQ'];
|
$searchQ = $filter['searchQ'];
|
||||||
$uids = $this->searchMessages($oImapClient, $searchQ, "INBOX");
|
|
||||||
|
|
||||||
//Mark as read/seen
|
// obsługa keyword (multi-folder)
|
||||||
if ($filter['fSeen']) {
|
if (stripos($searchQ, 'keyword=') !== false) {
|
||||||
foreach ($uids as $uid) {
|
$oMailClient = $this->Manager()->Actions()->MailClient();
|
||||||
$oRange = new MailSo\Imap\SequenceSet([$uid]);
|
|
||||||
$this->Manager()->Actions()->MailClient()->MessageSetFlag(
|
// Pobieramy wszystkie foldery top-level
|
||||||
$folder,
|
$folders = $oMailClient->Folders('', false, '');
|
||||||
$oRange,
|
foreach ($folders as $f) {
|
||||||
MailSo\Imap\Enumerations\MessageFlag::SEEN
|
$folder = $f->FullName ?? null;
|
||||||
);
|
if (!$folder) continue;
|
||||||
|
|
||||||
|
$uids = $this->searchMessages($oImapClient, $searchQ, $folder);
|
||||||
|
if (!empty($uids)) {
|
||||||
|
$this->applyActions($oImapClient, $filter, $uids, $folder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Flag/Star message
|
continue; // przechodzimy do następnego filtra
|
||||||
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
|
// normalny search w INBOX
|
||||||
if ($filter['fFolder']) {
|
$uids = $this->searchMessages($oImapClient, $searchQ, 'INBOX');
|
||||||
$folder = $filter['fFolder'];
|
if (!empty($uids)) {
|
||||||
foreach ($uids as $uid) {
|
$this->applyActions($oImapClient, $filter, $uids, 'INBOX');
|
||||||
$oRange = new MailSo\Imap\SequenceSet([$uid]);
|
|
||||||
$oImapClient->MessageMove("INBOX", $folder, $oRange);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,10 +87,7 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
string $search,
|
string $search,
|
||||||
string $folder = "INBOX"
|
string $folder = "INBOX"
|
||||||
): array {
|
): array {
|
||||||
$oParams = new \MailSo\Mail\MessageListParams();
|
try {
|
||||||
$oParams->sSearch = $search;
|
|
||||||
$oParams->sFolderName = $folder;
|
|
||||||
|
|
||||||
$bUseCache = false;
|
$bUseCache = false;
|
||||||
$oSearchCriterias = \MailSo\Imap\SearchCriterias::fromString(
|
$oSearchCriterias = \MailSo\Imap\SearchCriterias::fromString(
|
||||||
$imapClient,
|
$imapClient,
|
||||||
|
|
@ -114,6 +99,54 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
|
|
||||||
$imapClient->FolderSelect($folder);
|
$imapClient->FolderSelect($folder);
|
||||||
return $imapClient->MessageSearch($oSearchCriterias, true);
|
return $imapClient->MessageSearch($oSearchCriterias, true);
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->Manager()->logWrite(
|
||||||
|
'SearchFilters IMAP error in folder "' . $folder .
|
||||||
|
'" for search "' . $search . '": ' . $e->getMessage(),
|
||||||
|
LOG_ERR
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyActions(
|
||||||
|
\MailSo\Imap\ImapClient $imapClient,
|
||||||
|
array $filter,
|
||||||
|
array $uids,
|
||||||
|
string $folder
|
||||||
|
) {
|
||||||
|
// Mark as read/seen
|
||||||
|
if (!empty($filter['fSeen'])) {
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
$oRange = new \MailSo\Imap\SequenceSet([$uid]);
|
||||||
|
$this->Manager()->Actions()->MailClient()->MessageSetFlag(
|
||||||
|
$folder,
|
||||||
|
$oRange,
|
||||||
|
\MailSo\Imap\Enumerations\MessageFlag::SEEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag/Star message
|
||||||
|
if (!empty($filter['fFlag'])) {
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
$oRange = new \MailSo\Imap\SequenceSet([$uid]);
|
||||||
|
$this->Manager()->Actions()->MailClient()->MessageSetFlag(
|
||||||
|
$folder,
|
||||||
|
$oRange,
|
||||||
|
\MailSo\Imap\Enumerations\MessageFlag::FLAGGED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to folder
|
||||||
|
if (!empty($filter['fFolder']) && $filter['fFolder'] !== -1) {
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
$oRange = new \MailSo\Imap\SequenceSet([$uid]);
|
||||||
|
$imapClient->MessageMove('INBOX', $filter['fFolder'], $oRange);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GetFilters()
|
public function GetFilters()
|
||||||
|
|
@ -173,7 +206,6 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
|
||||||
} else {
|
} else {
|
||||||
$aSettings['SFilters'][$foundIndex] = $newFilter;
|
$aSettings['SFilters'][$foundIndex] = $newFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->jsonResponse(__FUNCTION__, $this->saveUserSettings($aSettings));
|
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
|
private static function getQRCode(MainAccount $oAccount, string $secret) : string
|
||||||
{
|
{
|
||||||
$email = \rawurlencode($oAccount->Email());
|
$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(
|
$QR = \SnappyMail\QRCode::getMinimumQRCode(
|
||||||
// "otpauth://totp/{$issuer}:{$email}?secret={$secret}&issuer={$issuer}",
|
// "otpauth://totp/{$issuer}:{$email}?secret={$secret}&issuer={$issuer}",
|
||||||
"otpauth://totp/{$email}?secret={$secret}",
|
"otpauth://totp/{$email}?secret={$secret}",
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Show Secret"
|
||||||
BUTTON_HIDE_SECRET = "Hide Secret"
|
BUTTON_HIDE_SECRET = "Hide Secret"
|
||||||
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
|
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
|
||||||
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not 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.\n"
|
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.\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."
|
||||||
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
|
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
|
||||||
TITLE_TEST_CODE = "2-Step verification test"
|
TITLE_TEST_CODE = "2-Step verification test"
|
||||||
LABEL_CODE = "Code"
|
LABEL_CODE = "Code"
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Show Secret"
|
||||||
BUTTON_HIDE_SECRET = "Hide Secret"
|
BUTTON_HIDE_SECRET = "Hide Secret"
|
||||||
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
|
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
|
||||||
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not 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.\n"
|
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.\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."
|
||||||
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
|
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
|
||||||
TITLE_TEST_CODE = "2-Step verification test"
|
TITLE_TEST_CODE = "2-Step verification test"
|
||||||
LABEL_CODE = "Code"
|
LABEL_CODE = "Code"
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Titok megjelenítése"
|
||||||
BUTTON_HIDE_SECRET = "Titok elrejtése"
|
BUTTON_HIDE_SECRET = "Titok elrejtése"
|
||||||
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Beállítva"
|
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Beállítva"
|
||||||
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Nincs 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.\n"
|
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.\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."
|
||||||
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Tesztelés nélkül nem lehet megváltoztatni ezt a beállítást."
|
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"
|
TITLE_TEST_CODE = "2-lépéses hitelesítés teszt"
|
||||||
LABEL_CODE = "Kód"
|
LABEL_CODE = "Kód"
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Mostra la chiave segreta"
|
||||||
BUTTON_HIDE_SECRET = "Nascondi la chiave segreta"
|
BUTTON_HIDE_SECRET = "Nascondi la chiave segreta"
|
||||||
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configurato"
|
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configurato"
|
||||||
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Non 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.\n"
|
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.\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."
|
||||||
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Non puoi modificare questa impostazione prima del test."
|
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Non puoi modificare questa impostazione prima del test."
|
||||||
TITLE_TEST_CODE = "Test di verifica a 2 fattori"
|
TITLE_TEST_CODE = "Test di verifica a 2 fattori"
|
||||||
LABEL_CODE = "Codice"
|
LABEL_CODE = "Codice"
|
||||||
|
|
|
||||||
20
plugins/two-factor-auth/langs/uk.ini
Normal file
20
plugins/two-factor-auth/langs/uk.ini
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[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 = "隐藏密钥"
|
BUTTON_HIDE_SECRET = "隐藏密钥"
|
||||||
TWO_FACTOR_SECRET_CONFIGURED_DESC = "已设置"
|
TWO_FACTOR_SECRET_CONFIGURED_DESC = "已设置"
|
||||||
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "未设置"
|
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "未设置"
|
||||||
TWO_FACTOR_SECRET_DESC = "将下面的信息导入 Google Authenticator (或其他 TOTP 客户端)。 扫描下面的二维码或手动输入密钥。\n"
|
TWO_FACTOR_SECRET_DESC = "将下面的信息导入 Google Authenticator (或其他 TOTP 客户端)。 扫描下面的二维码或手动输入密钥。"
|
||||||
TWO_FACTOR_BACKUP_CODES_DESC = "如果你无法从验证器获取验证码,你可以使用备用代码登录。 当你使用过某一个备用代码后,它将会失效。\n"
|
TWO_FACTOR_BACKUP_CODES_DESC = "如果你无法从验证器获取验证码,你可以使用备用代码登录。 当你使用过某一个备用代码后,它将会失效。"
|
||||||
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "完成测试前你无法更改此设置。"
|
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "完成测试前你无法更改此设置。"
|
||||||
TITLE_TEST_CODE = "两步验证测试"
|
TITLE_TEST_CODE = "两步验证测试"
|
||||||
LABEL_CODE = "代码"
|
LABEL_CODE = "代码"
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,7 @@ abstract class Collection extends \ArrayObject implements \JsonSerializable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function append(mixed $mItem, bool $bToTop = false): void
|
||||||
* @param mixed $mItem
|
|
||||||
*/
|
|
||||||
public function append($mItem, bool $bToTop = false) : void
|
|
||||||
{
|
{
|
||||||
if ($bToTop) {
|
if ($bToTop) {
|
||||||
$array = $this->getArrayCopy();
|
$array = $this->getArrayCopy();
|
||||||
|
|
@ -64,14 +61,14 @@ abstract class Collection extends \ArrayObject implements \JsonSerializable
|
||||||
return $callable($this->getArrayCopy(), ...$arguments);
|
return $callable($this->getArrayCopy(), ...$arguments);
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
public function Slice(int $offset, ?int $length = null, bool $preserve_keys = false)
|
public function Slice(int $offset, ?int $length = null, bool $preserve_keys = false)/*: static*/
|
||||||
{
|
{
|
||||||
return new static(
|
return new static(
|
||||||
\array_slice($this->getArrayCopy(), $offset, $length, $preserve_keys)
|
\array_slice($this->getArrayCopy(), $offset, $length, $preserve_keys)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function Crop(?int $length = null, int $offset = 0, bool $preserve_keys = false)
|
public function Crop(?int $length = null, int $offset = 0, bool $preserve_keys = false): self
|
||||||
{
|
{
|
||||||
$this->exchangeArray(
|
$this->exchangeArray(
|
||||||
\array_slice($this->getArrayCopy(), $offset, $length, $preserve_keys)
|
\array_slice($this->getArrayCopy(), $offset, $length, $preserve_keys)
|
||||||
|
|
@ -79,8 +76,8 @@ abstract class Collection extends \ArrayObject implements \JsonSerializable
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
$aNames = \explode('\\', \get_class($this));
|
$aNames = \explode('\\', \get_class($this));
|
||||||
return array(
|
return array(
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,22 @@ abstract class DateTimeHelper
|
||||||
\DateTime::createFromFormat('d M Y H:i:s O', $sDateTime, static::GetUtcTimeZoneObject())
|
\DateTime::createFromFormat('d M Y H:i:s O', $sDateTime, static::GetUtcTimeZoneObject())
|
||||||
// Using T (obsolete timezone abbreviation)
|
// Using T (obsolete timezone abbreviation)
|
||||||
?: \DateTime::createFromFormat('d M Y H:i:s T', $sDateTime, static::GetUtcTimeZoneObject());
|
?: \DateTime::createFromFormat('d M Y H:i:s T', $sDateTime, static::GetUtcTimeZoneObject());
|
||||||
// 398045302 is 1982-08-13 00:08:22 the date RFC 822 was created
|
|
||||||
if (!$oDateTime || 398045302 > $oDateTime->getTimestamp()) {
|
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}'");
|
\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;
|
||||||
}
|
}
|
||||||
return $oDateTime ? $oDateTime->getTimestamp() : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -160,13 +160,20 @@ abstract class HtmlUtils
|
||||||
|
|
||||||
// Remove all remaining data-* attributes
|
// Remove all remaining data-* attributes
|
||||||
foreach ($xpath->query('//*[@*[starts-with(name(), "data-")]]') as $oElement) {
|
foreach ($xpath->query('//*[@*[starts-with(name(), "data-")]]') as $oElement) {
|
||||||
$sTagNameLower = \strtolower($oElement->nodeName);
|
|
||||||
if ($oElement->hasAttributes()) {
|
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) {
|
foreach ($oElement->attributes as $oAttr) {
|
||||||
if ('data-' === \substr(\strtolower($oAttr->nodeName), 0, 5)) {
|
if ('data-' === \substr(\strtolower($oAttr->nodeName), 0, 5)) {
|
||||||
$oElement->removeAttribute($oAttr->nodeName);
|
$aNames[] = $oAttr->nodeName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
foreach ($aNames as $sName) {
|
||||||
|
$oElement->removeAttribute($sName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,7 @@ class Http
|
||||||
return $oInstance;
|
return $oInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static function GetServer(string $sKey, mixed $mDefault = null): mixed
|
||||||
* @param mixed $mDefault = null
|
|
||||||
*
|
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
public static function GetServer(string $sKey, $mDefault = null)
|
|
||||||
{
|
{
|
||||||
return isset($_SERVER[$sKey]) ? $_SERVER[$sKey] : $mDefault;
|
return isset($_SERVER[$sKey]) ? $_SERVER[$sKey] : $mDefault;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ abstract class Utils
|
||||||
return 'iso-8859-8';
|
return 'iso-8859-8';
|
||||||
}
|
}
|
||||||
|
|
||||||
return \preg_replace('/^(cp-?|windows?)(12[\d])/', 'windows-$1', $sEncoding);
|
return \preg_replace('/^(cp|windows)-?(12[\d]*)/i', 'windows-$2', $sEncoding);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function NormalizeCharsetByValue(string $sCharset, string $sValue) : string
|
public static function NormalizeCharsetByValue(string $sCharset, string $sValue) : string
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@ class BodyStructure implements \JsonSerializable
|
||||||
return $gParts->valid() ? $gParts->current()->Charset() : '';
|
return $gParts->valid() ? $gParts->current()->Charset() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function SearchByCallback(callable $fCallback, /*BodyStructure*/ $parent = null) : iterable
|
public function SearchByCallback(callable $fCallback, /*?BodyStructure*/ $parent = null): iterable
|
||||||
{
|
{
|
||||||
if ($fCallback($this, $parent)) {
|
if ($fCallback($this, $parent)) {
|
||||||
yield $this;
|
yield $this;
|
||||||
|
|
@ -557,8 +557,8 @@ class BodyStructure implements \JsonSerializable
|
||||||
return $aDict;
|
return $aDict;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return array(
|
return array(
|
||||||
'mimeIndex' => $this->sPartID,
|
'mimeIndex' => $this->sPartID,
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,7 @@ class FetchResponse
|
||||||
return $this->aEnvelopeCache;
|
return $this->aEnvelopeCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function GetFetchEnvelopeValue(int $iIndex, ?string $mNullResult = null): mixed
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
public function GetFetchEnvelopeValue(int $iIndex, ?string $mNullResult = null)
|
|
||||||
{
|
{
|
||||||
return self::findEnvelopeIndex($this->GetEnvelope(), $iIndex, $mNullResult);
|
return self::findEnvelopeIndex($this->GetEnvelope(), $iIndex, $mNullResult);
|
||||||
}
|
}
|
||||||
|
|
@ -82,9 +79,8 @@ class FetchResponse
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like: UID, RFC822.SIZE, MODSEQ, INTERNALDATE, FLAGS, BODYSTRUCTURE
|
* Like: UID, RFC822.SIZE, MODSEQ, INTERNALDATE, FLAGS, BODYSTRUCTURE
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function GetFetchValue(string $sFetchItemName)
|
public function GetFetchValue(string $sFetchItemName): mixed
|
||||||
{
|
{
|
||||||
if (isset($this->oImapResponse->ResponseList[3]) && \is_array($this->oImapResponse->ResponseList[3])) {
|
if (isset($this->oImapResponse->ResponseList[3]) && \is_array($this->oImapResponse->ResponseList[3])) {
|
||||||
$bNextIsValue = false;
|
$bNextIsValue = false;
|
||||||
|
|
@ -104,7 +100,6 @@ class FetchResponse
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like: BODY[HEADER.FIELDS (RETURN-PATH RECEIVED MIME-VERSION MESSAGE-ID CONTENT-TYPE FROM TO CC BCC SENDER REPLY-TO DELIVERED-TO IN-REPLY-TO REFERENCES DATE SUBJECT SENSITIVITY X-MSMAIL-PRIORITY IMPORTANCE X-PRIORITY X-DRAFT-INFO RETURN-RECEIPT-TO DISPOSITION-NOTIFICATION-TO X-CONFIRM-READING-TO AUTHENTICATION-RESULTS X-DKIM-AUTHENTICATION-RESULTS LIST-UNSUBSCRIBE X-SPAM-STATUS X-SPAMD-RESULT X-BOGOSITY X-VIRUS X-VIRUS-SCANNED X-VIRUS-STATUS)]
|
* Like: BODY[HEADER.FIELDS (RETURN-PATH RECEIVED MIME-VERSION MESSAGE-ID CONTENT-TYPE FROM TO CC BCC SENDER REPLY-TO DELIVERED-TO IN-REPLY-TO REFERENCES DATE SUBJECT SENSITIVITY X-MSMAIL-PRIORITY IMPORTANCE X-PRIORITY X-DRAFT-INFO RETURN-RECEIPT-TO DISPOSITION-NOTIFICATION-TO X-CONFIRM-READING-TO AUTHENTICATION-RESULTS X-DKIM-AUTHENTICATION-RESULTS LIST-UNSUBSCRIBE X-SPAM-STATUS X-SPAMD-RESULT X-BOGOSITY X-VIRUS X-VIRUS-SCANNED X-VIRUS-STATUS)]
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function GetHeaderFieldsValue(): string
|
public function GetHeaderFieldsValue(): string
|
||||||
{
|
{
|
||||||
|
|
@ -144,10 +139,7 @@ class FetchResponse
|
||||||
&& \in_array(Enumerations\FetchType::RFC822_SIZE, $oImapResponse->ResponseList[3]);
|
&& \in_array(Enumerations\FetchType::RFC822_SIZE, $oImapResponse->ResponseList[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private static function findEnvelopeIndex(array $aEnvelope, int $iIndex, ?string $mNullResult): mixed
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
private static function findEnvelopeIndex(array $aEnvelope, int $iIndex, ?string $mNullResult)
|
|
||||||
{
|
{
|
||||||
return (isset($aEnvelope[$iIndex]) && '' !== $aEnvelope[$iIndex])
|
return (isset($aEnvelope[$iIndex]) && '' !== $aEnvelope[$iIndex])
|
||||||
? $aEnvelope[$iIndex] : $mNullResult;
|
? $aEnvelope[$iIndex] : $mNullResult;
|
||||||
|
|
|
||||||
|
|
@ -145,8 +145,8 @@ class Folder implements \JsonSerializable
|
||||||
return $role ? \ltrim($role, '\\') : null;
|
return $role ? \ltrim($role, '\\') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
$selectable = $this->Selectable();
|
$selectable = $this->Selectable();
|
||||||
$result = array(
|
$result = array(
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ class FolderCollection extends \MailSo\Base\Collection
|
||||||
parent::append($oFolder, $bToTop);
|
parent::append($oFolder, $bToTop);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return array(
|
return array(
|
||||||
'@Object' => 'Collection/FolderCollection',
|
'@Object' => 'Collection/FolderCollection',
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ class FolderInformation implements \JsonSerializable
|
||||||
// || \in_array($sFlag, $this->Flags);
|
// || \in_array($sFlag, $this->Flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
$result = array(
|
$result = array(
|
||||||
'id' => $this->MAILBOXID,
|
'id' => $this->MAILBOXID,
|
||||||
|
|
|
||||||
|
|
@ -339,7 +339,7 @@ class ImapClient extends \MailSo\Net\NetClient
|
||||||
/**
|
/**
|
||||||
* RFC 5161
|
* RFC 5161
|
||||||
*/
|
*/
|
||||||
public function Enable(/*string|array*/ $mCapabilityNames) : void
|
public function Enable(string|array $mCapabilityNames): void
|
||||||
{
|
{
|
||||||
if (\is_string($mCapabilityNames)) {
|
if (\is_string($mCapabilityNames)) {
|
||||||
$mCapabilityNames = [$mCapabilityNames];
|
$mCapabilityNames = [$mCapabilityNames];
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,8 @@ class NamespaceResult implements \JsonSerializable
|
||||||
return $sPrefix;
|
return $sPrefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return array(
|
return array(
|
||||||
'@Object' => 'Object/Namespaces',
|
'@Object' => 'Object/Namespaces',
|
||||||
|
|
|
||||||
|
|
@ -79,12 +79,7 @@ class THREAD extends Request
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function validateThreadItem(mixed $mValue): int|array|false
|
||||||
* @param mixed $mValue
|
|
||||||
*
|
|
||||||
* @return int | array | false
|
|
||||||
*/
|
|
||||||
private function validateThreadItem($mValue)
|
|
||||||
{
|
{
|
||||||
if (\is_numeric($mValue)) {
|
if (\is_numeric($mValue)) {
|
||||||
$mValue = (int) $mValue;
|
$mValue = (int) $mValue;
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class ACL implements \JsonSerializable
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PHP 8.1
|
/** PHP 8.1
|
||||||
public function hasRight(string|\MailSo\Imap\Enumerations\FolderACL $right)
|
public function hasRight(string|\MailSo\Imap\Enumerations\FolderACL $right): bool
|
||||||
{
|
{
|
||||||
if ($right instanceof \BackedEnum) {
|
if ($right instanceof \BackedEnum) {
|
||||||
return \str_contains($this->rights, $right->value);
|
return \str_contains($this->rights, $right->value);
|
||||||
|
|
@ -49,8 +49,8 @@ class ACL implements \JsonSerializable
|
||||||
return \str_contains($this->rights, $right);
|
return \str_contains($this->rights, $right);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'@Object' => 'Object/FolderACLRights',
|
'@Object' => 'Object/FolderACLRights',
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ class SequenceSet /*extends \SplFixedArray*/ implements \Countable
|
||||||
return \in_array($value, $this->data);
|
return \in_array($value, $this->data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function indexOf($value)/*: int|false*/
|
public function indexOf($value): int|false
|
||||||
{
|
{
|
||||||
return \array_search($value, $this->data);
|
return \array_search($value, $this->data);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,8 @@ class Settings extends \MailSo\Net\ConnectSettings
|
||||||
return $object;
|
return $object;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
if (\in_array('SORT', $this->disabled_capabilities)) {
|
if (\in_array('SORT', $this->disabled_capabilities)) {
|
||||||
$this->disabled_capabilities[] = 'ESORT';
|
$this->disabled_capabilities[] = 'ESORT';
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ abstract class Driver
|
||||||
$this->oTimeZone = new \DateTimeZone('UTC');
|
$this->oTimeZone = new \DateTimeZone('UTC');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function SetTimeZone(/*\DateTimeZone | string*/ $mTimeZone) : self
|
public function SetTimeZone(\DateTimeZone|string $mTimeZone): self
|
||||||
{
|
{
|
||||||
$this->oTimeZone = $mTimeZone instanceof \DateTimeZone
|
$this->oTimeZone = $mTimeZone instanceof \DateTimeZone
|
||||||
? $mTimeZone
|
? $mTimeZone
|
||||||
|
|
@ -92,7 +92,7 @@ abstract class Driver
|
||||||
return isset(self::PREFIXES[$iType]) ? $sName . self::PREFIXES[$iType].': ' : '';
|
return isset(self::PREFIXES[$iType]) ? $sName . self::PREFIXES[$iType].': ' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
final public function Write(string $sDesc, int $iType = \LOG_INFO, string $sName = '', bool $bDiplayCrLf = false)
|
final public function Write(string $sDesc, int $iType = \LOG_INFO, string $sName = '', bool $bDiplayCrLf = false): bool
|
||||||
{
|
{
|
||||||
$sDesc = \ltrim(
|
$sDesc = \ltrim(
|
||||||
($this->bTimePrefix ? '['.$this->getTimeWithMicroSec().']' : '').
|
($this->bTimePrefix ? '['.$this->getTimeWithMicroSec().']' : '').
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ class Logger extends \SplFixedArray
|
||||||
\E_USER_ERROR => \LOG_ERR,
|
\E_USER_ERROR => \LOG_ERR,
|
||||||
\E_USER_WARNING => \LOG_WARNING,
|
\E_USER_WARNING => \LOG_WARNING,
|
||||||
\E_USER_NOTICE => \LOG_NOTICE,
|
\E_USER_NOTICE => \LOG_NOTICE,
|
||||||
\E_STRICT => \LOG_CRIT,
|
// \E_STRICT => \LOG_CRIT,
|
||||||
\E_RECOVERABLE_ERROR => \LOG_ERR,
|
\E_RECOVERABLE_ERROR => \LOG_ERR,
|
||||||
\E_DEPRECATED => \LOG_INFO,
|
\E_DEPRECATED => \LOG_INFO,
|
||||||
\E_USER_DEPRECATED => \LOG_INFO
|
\E_USER_DEPRECATED => \LOG_INFO
|
||||||
|
|
@ -168,7 +168,7 @@ class Logger extends \SplFixedArray
|
||||||
\E_USER_ERROR => '-USER',
|
\E_USER_ERROR => '-USER',
|
||||||
\E_USER_WARNING => '-USER',
|
\E_USER_WARNING => '-USER',
|
||||||
\E_USER_NOTICE => '-USER',
|
\E_USER_NOTICE => '-USER',
|
||||||
\E_STRICT => '-STRICT',
|
// \E_STRICT => '-STRICT',
|
||||||
\E_RECOVERABLE_ERROR => '-RECOVERABLE',
|
\E_RECOVERABLE_ERROR => '-RECOVERABLE',
|
||||||
\E_DEPRECATED => '-DEPRECATED',
|
\E_DEPRECATED => '-DEPRECATED',
|
||||||
\E_USER_DEPRECATED => '-USER_DEPRECATED'
|
\E_USER_DEPRECATED => '-USER_DEPRECATED'
|
||||||
|
|
@ -206,7 +206,7 @@ class Logger extends \SplFixedArray
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function signalHandler($signo, /*?array*/$siginfo = null)
|
public function signalHandler(int $signo, mixed $siginfo = null): void
|
||||||
{
|
{
|
||||||
if (\SIGTERM == $signo) {
|
if (\SIGTERM == $signo) {
|
||||||
exit;
|
exit;
|
||||||
|
|
@ -241,10 +241,7 @@ class Logger extends \SplFixedArray
|
||||||
return (bool) $iResult;
|
return (bool) $iResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function WriteDump(mixed $mValue, int $iType = \LOG_INFO, string $sName = ''): bool
|
||||||
* @param mixed $mValue
|
|
||||||
*/
|
|
||||||
public function WriteDump($mValue, int $iType = \LOG_INFO, string $sName = '') : bool
|
|
||||||
{
|
{
|
||||||
return $this->Write(\print_r($mValue, true), $iType, $sName);
|
return $this->Write(\print_r($mValue, true), $iType, $sName);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,13 @@ class Attachment implements \JsonSerializable
|
||||||
return $this->iUid;
|
return $this->iUid;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __call(string $name, array $arguments) //: mixed
|
public function __call(string $name, array $arguments): mixed
|
||||||
{
|
{
|
||||||
return $this->oBodyStructure->{$name}(...$arguments);
|
return $this->oBodyStructure->{$name}(...$arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return \array_merge([
|
return \array_merge([
|
||||||
'@Object' => 'Object/Attachment',
|
'@Object' => 'Object/Attachment',
|
||||||
|
|
|
||||||
|
|
@ -181,14 +181,12 @@ class MailClient
|
||||||
/**
|
/**
|
||||||
* Streams mime part to $mCallback
|
* Streams mime part to $mCallback
|
||||||
*
|
*
|
||||||
* @param mixed $mCallback
|
|
||||||
*
|
|
||||||
* @throws \InvalidArgumentException
|
* @throws \InvalidArgumentException
|
||||||
* @throws \MailSo\RuntimeException
|
* @throws \MailSo\RuntimeException
|
||||||
* @throws \MailSo\Net\Exceptions\*
|
* @throws \MailSo\Net\Exceptions\*
|
||||||
* @throws \MailSo\Imap\Exceptions\*
|
* @throws \MailSo\Imap\Exceptions\*
|
||||||
*/
|
*/
|
||||||
public function MessageMimeStream($mCallback, string $sFolderName, int $iIndex, string $sMimeIndex) : bool
|
public function MessageMimeStream(callable $mCallback, string $sFolderName, int $iIndex, string $sMimeIndex): bool
|
||||||
{
|
{
|
||||||
if (!\is_callable($mCallback)) {
|
if (!\is_callable($mCallback)) {
|
||||||
throw new \ValueError;
|
throw new \ValueError;
|
||||||
|
|
|
||||||
|
|
@ -127,12 +127,12 @@ class Message implements \JsonSerializable
|
||||||
$this->SpamScore = \intval(\max(0, \min(100, $value)));
|
$this->SpamScore = \intval(\max(0, \min(100, $value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function SetThreads(array $aThreadUIDs)
|
public function SetThreads(array $aThreadUIDs): void
|
||||||
{
|
{
|
||||||
$this->aThreadUIDs = $aThreadUIDs;
|
$this->aThreadUIDs = $aThreadUIDs;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function SetThreadUnseen(array $aUnseenUIDs)
|
public function SetThreadUnseen(array $aUnseenUIDs): void
|
||||||
{
|
{
|
||||||
$this->aThreadUnseenUIDs = $aUnseenUIDs;
|
$this->aThreadUnseenUIDs = $aUnseenUIDs;
|
||||||
}
|
}
|
||||||
|
|
@ -486,8 +486,8 @@ class Message implements \JsonSerializable
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
/*
|
/*
|
||||||
// JMAP-only RFC8621 keywords (RFC5788)
|
// JMAP-only RFC8621 keywords (RFC5788)
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,7 @@ class MessageCollection extends \MailSo\Base\Collection
|
||||||
|
|
||||||
public int $ThreadUid = 0;
|
public int $ThreadUid = 0;
|
||||||
|
|
||||||
// MailSo\Imap\FolderInformation
|
public ?\MailSo\Imap\FolderInformation $FolderInfo = null;
|
||||||
public $FolderInfo = null;
|
|
||||||
|
|
||||||
public array $NewMessages = array();
|
public array $NewMessages = array();
|
||||||
|
|
||||||
|
|
@ -56,8 +55,8 @@ class MessageCollection extends \MailSo\Base\Collection
|
||||||
throw new \BadMethodCallException('disallowed');
|
throw new \BadMethodCallException('disallowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return \array_merge(parent::jsonSerialize(), array(
|
return \array_merge(parent::jsonSerialize(), array(
|
||||||
'totalEmails' => $this->totalEmails,
|
'totalEmails' => $this->totalEmails,
|
||||||
|
|
|
||||||
|
|
@ -200,8 +200,8 @@ class Email implements \JsonSerializable
|
||||||
return $this->ToString();
|
return $this->ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return array(
|
return array(
|
||||||
'@Object' => 'Object/Email',
|
'@Object' => 'Object/Email',
|
||||||
|
|
|
||||||
|
|
@ -156,8 +156,8 @@ class EmailCollection extends \MailSo\Base\Collection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return \array_slice($this->getArrayCopy(), 0, 100);
|
return \array_slice($this->getArrayCopy(), 0, 100);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -198,8 +198,8 @@ class Header implements \JsonSerializable
|
||||||
return $this->Value();
|
return $this->Value();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
$aResult = array(
|
$aResult = array(
|
||||||
'@Object' => 'Object/MimeHeader',
|
'@Object' => 'Object/MimeHeader',
|
||||||
|
|
|
||||||
|
|
@ -136,14 +136,12 @@ class HeaderCollection extends \MailSo\Base\Collection
|
||||||
$sHeadersValue = \rtrim($sHeadersValue);
|
$sHeadersValue = \rtrim($sHeadersValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('=?' === \substr(\ltrim($sHeadersValue), 0, 2)) {
|
// RFC 5322 unfolding removes the line break and keeps the folding
|
||||||
$sHeadersValue = \ltrim($sHeadersValue);
|
// 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($sHeadersValue, 0, 2)) {
|
|
||||||
$sValue .= $sHeadersValue;
|
|
||||||
} else {
|
} else {
|
||||||
$sValue .= "\n".$sHeadersValue;
|
$sValue .= ' '.\ltrim($sHeadersValue);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (null !== $sName) {
|
if (null !== $sName) {
|
||||||
|
|
@ -229,8 +227,8 @@ class HeaderCollection extends \MailSo\Base\Collection
|
||||||
return \implode("\r\n", $this->getArrayCopy());
|
return \implode("\r\n", $this->getArrayCopy());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return array(
|
return array(
|
||||||
'@Object' => 'Collection/MimeHeaderCollection',
|
'@Object' => 'Collection/MimeHeaderCollection',
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,8 @@ class Parameter implements \JsonSerializable
|
||||||
return $this->ToString();
|
return $this->ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return array(
|
return array(
|
||||||
'name' => $this->sName,
|
'name' => $this->sName,
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,8 @@ class ParameterCollection extends \MailSo\Base\Collection
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
$aResult = array();
|
$aResult = array();
|
||||||
foreach ($this as $oParam) {
|
foreach ($this as $oParam) {
|
||||||
|
|
|
||||||
|
|
@ -128,8 +128,8 @@ class ConnectSettings implements \JsonSerializable
|
||||||
return $object;
|
return $object;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return array(
|
return array(
|
||||||
// '@Object' => 'Object/ConnectSettings',
|
// '@Object' => 'Object/ConnectSettings',
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class SSLContext implements \JsonSerializable
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$oConfig = \RainLoop\API::Config();
|
$oConfig = \RainLoop\Api::Config();
|
||||||
$this->verify_peer = !!$oConfig->Get('ssl', 'verify_certificate', true);
|
$this->verify_peer = !!$oConfig->Get('ssl', 'verify_certificate', true);
|
||||||
$this->verify_peer_name = !!$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);
|
$this->allow_self_signed = !!$oConfig->Get('ssl', 'allow_self_signed', false);
|
||||||
|
|
@ -59,8 +59,8 @@ class SSLContext implements \JsonSerializable
|
||||||
return $object;
|
return $object;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
$aResult = \get_object_vars($this);
|
$aResult = \get_object_vars($this);
|
||||||
// $aResult['@Object'] = 'Object/SSLContext';
|
// $aResult['@Object'] = 'Object/SSLContext';
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ class Settings extends \MailSo\Net\ConnectSettings
|
||||||
return $object;
|
return $object;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return \array_merge(
|
return \array_merge(
|
||||||
parent::jsonSerialize(),
|
parent::jsonSerialize(),
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class Settings extends \MailSo\Net\ConnectSettings
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$oConfig = \RainLoop\API::Config();
|
$oConfig = \RainLoop\Api::Config();
|
||||||
$this->viewErrors = !!$oConfig->Get('labs', 'smtp_show_server_errors', false);
|
$this->viewErrors = !!$oConfig->Get('labs', 'smtp_show_server_errors', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,8 +48,8 @@ class Settings extends \MailSo\Net\ConnectSettings
|
||||||
return $object;
|
return $object;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\ReturnTypeWillChange]
|
// public function jsonSerialize(): array
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return \array_merge(
|
return \array_merge(
|
||||||
parent::jsonSerialize(),
|
parent::jsonSerialize(),
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,8 @@ class SmtpClient extends \MailSo\Net\NetClient
|
||||||
$SASL->verify($sResult);
|
$SASL->verify($sResult);
|
||||||
// Now end the authentication
|
// Now end the authentication
|
||||||
$sRequest = '';
|
$sRequest = '';
|
||||||
$this->sendRequestWithCheck($sRequest, 235);
|
$this->sendRaw($sRequest, true, '');
|
||||||
|
$this->validateResponse(235, '');
|
||||||
} else switch ($type) {
|
} else switch ($type) {
|
||||||
// RFC 4616
|
// RFC 4616
|
||||||
case 'PLAIN':
|
case 'PLAIN':
|
||||||
|
|
|
||||||
|
|
@ -167,10 +167,7 @@ class Actions
|
||||||
return $this->oConfig;
|
return $this->oConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function fabrica(string $sName, ?Model\Account $oAccount = null): mixed
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
protected function fabrica(string $sName, ?Model\Account $oAccount = null)
|
|
||||||
{
|
{
|
||||||
$mResult = null;
|
$mResult = null;
|
||||||
$this->oPlugins->RunHook('main.fabrica', array($sName, &$mResult), false);
|
$this->oPlugins->RunHook('main.fabrica', array($sName, &$mResult), false);
|
||||||
|
|
@ -505,7 +502,7 @@ class Actions
|
||||||
return $this->oPlugins;
|
return $this->oPlugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function LoggerAuthHelper(?Model\Account $oAccount, string $sLogin = '', bool $admin = false): void
|
public function LoggerAuthHelper(?Model\Account $oAccount, string $sLogin = '', bool $admin = false): void
|
||||||
{
|
{
|
||||||
if ($sLogin) {
|
if ($sLogin) {
|
||||||
$sHost = $admin ? $this->Http()->GetHost(true, true) : \MailSo\Base\Utils::getEmailAddressDomain($sLogin);
|
$sHost = $admin ? $this->Http()->GetHost(true, true) : \MailSo\Base\Utils::getEmailAddressDomain($sLogin);
|
||||||
|
|
@ -591,6 +588,7 @@ class Actions
|
||||||
'ListInlineAttachments' => false,
|
'ListInlineAttachments' => false,
|
||||||
'CollapseBlockquotes' => $oConfig->Get('defaults', 'collapse_blockquotes', true),
|
'CollapseBlockquotes' => $oConfig->Get('defaults', 'collapse_blockquotes', true),
|
||||||
'MaxBlockquotesLevel' => 0,
|
'MaxBlockquotesLevel' => 0,
|
||||||
|
'minFontSize' => 5,
|
||||||
'simpleAttachmentsList' => false,
|
'simpleAttachmentsList' => false,
|
||||||
'listGrouped' => $oConfig->Get('defaults', 'mail_list_grouped', false),
|
'listGrouped' => $oConfig->Get('defaults', 'mail_list_grouped', false),
|
||||||
'MessagesPerPage' => \max(10, \intval($oConfig->Get('webmail', 'messages_per_page', 25)) ?: 25),
|
'MessagesPerPage' => \max(10, \intval($oConfig->Get('webmail', 'messages_per_page', 25)) ?: 25),
|
||||||
|
|
@ -687,6 +685,7 @@ class Actions
|
||||||
$aResult['ListInlineAttachments'] = (bool)$oSettings->GetConf('ListInlineAttachments', $aResult['ListInlineAttachments']);
|
$aResult['ListInlineAttachments'] = (bool)$oSettings->GetConf('ListInlineAttachments', $aResult['ListInlineAttachments']);
|
||||||
$aResult['CollapseBlockquotes'] = (bool)$oSettings->GetConf('CollapseBlockquotes', $aResult['CollapseBlockquotes']);
|
$aResult['CollapseBlockquotes'] = (bool)$oSettings->GetConf('CollapseBlockquotes', $aResult['CollapseBlockquotes']);
|
||||||
$aResult['MaxBlockquotesLevel'] = (int)$oSettings->GetConf('MaxBlockquotesLevel', $aResult['MaxBlockquotesLevel']);
|
$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['simpleAttachmentsList'] = (bool)$oSettings->GetConf('simpleAttachmentsList', $aResult['simpleAttachmentsList']);
|
||||||
$aResult['listGrouped'] = (bool)$oSettings->GetConf('listGrouped', $aResult['listGrouped']);
|
$aResult['listGrouped'] = (bool)$oSettings->GetConf('listGrouped', $aResult['listGrouped']);
|
||||||
$aResult['ContactsAutosave'] = (bool)$oSettings->GetConf('ContactsAutosave', $aResult['ContactsAutosave']);
|
$aResult['ContactsAutosave'] = (bool)$oSettings->GetConf('ContactsAutosave', $aResult['ContactsAutosave']);
|
||||||
|
|
@ -956,12 +955,7 @@ class Actions
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function GetActionParam(string $sKey, mixed $mDefault = null): mixed
|
||||||
* @param mixed $mDefault = null
|
|
||||||
*
|
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
public function GetActionParam(string $sKey, $mDefault = null)
|
|
||||||
{
|
{
|
||||||
return isset($this->aCurrentActionParams[$sKey]) ?
|
return isset($this->aCurrentActionParams[$sKey]) ?
|
||||||
$this->aCurrentActionParams[$sKey] : $mDefault;
|
$this->aCurrentActionParams[$sKey] : $mDefault;
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,7 @@ trait Accounts
|
||||||
'mainEmail' => \RainLoop\Api::Actions()->getMainAccountFromToken()->Email(),
|
'mainEmail' => \RainLoop\Api::Actions()->getMainAccountFromToken()->Email(),
|
||||||
'contactsAllowed' => $this->AddressBookProvider($oAccount)->IsActive(),
|
'contactsAllowed' => $this->AddressBookProvider($oAccount)->IsActive(),
|
||||||
'HideUnsubscribed' => false,
|
'HideUnsubscribed' => false,
|
||||||
|
'defaultSort' => '',
|
||||||
'useThreads' => (bool) $oConfig->Get('defaults', 'mail_use_threads', false),
|
'useThreads' => (bool) $oConfig->Get('defaults', 'mail_use_threads', false),
|
||||||
'threadAlgorithm' => '',
|
'threadAlgorithm' => '',
|
||||||
'ReplySameFolder' => (bool) $oConfig->Get('defaults', 'mail_reply_same_folder', false),
|
'ReplySameFolder' => (bool) $oConfig->Get('defaults', 'mail_reply_same_folder', false),
|
||||||
|
|
@ -226,6 +227,7 @@ trait Accounts
|
||||||
$aResult['TrashFolder'] = (string) $oSettingsLocal->GetConf('TrashFolder', '');
|
$aResult['TrashFolder'] = (string) $oSettingsLocal->GetConf('TrashFolder', '');
|
||||||
$aResult['ArchiveFolder'] = (string) $oSettingsLocal->GetConf('ArchiveFolder', '');
|
$aResult['ArchiveFolder'] = (string) $oSettingsLocal->GetConf('ArchiveFolder', '');
|
||||||
$aResult['HideUnsubscribed'] = (bool) $oSettingsLocal->GetConf('HideUnsubscribed', $aResult['HideUnsubscribed']);
|
$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['useThreads'] = (bool) $oSettingsLocal->GetConf('UseThreads', $aResult['useThreads']);
|
||||||
$aResult['threadAlgorithm'] = (string) $oSettingsLocal->GetConf('threadAlgorithm', $aResult['threadAlgorithm']);
|
$aResult['threadAlgorithm'] = (string) $oSettingsLocal->GetConf('threadAlgorithm', $aResult['threadAlgorithm']);
|
||||||
$aResult['ReplySameFolder'] = (bool) $oSettingsLocal->GetConf('ReplySameFolder', $aResult['ReplySameFolder']);
|
$aResult['ReplySameFolder'] = (bool) $oSettingsLocal->GetConf('ReplySameFolder', $aResult['ReplySameFolder']);
|
||||||
|
|
|
||||||
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