Compare commits

..

1 commit

Author SHA1 Message Date
djmaze
208f1c7298 v2.38.2.1 2026-10-23 11:08:37 +02:00
317 changed files with 7173 additions and 10132 deletions

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
FROM alpine:3.24.3 AS builder
RUN apk add --no-cache php85 php85-json php-phar php-zip
FROM alpine:3.18.5 AS builder
RUN apk add --no-cache php82 php82-json php-phar php-zip
RUN apk add --no-cache npm
RUN npm install -g gulp yarn
WORKDIR /source
@ -23,7 +23,7 @@ RUN set -eux; \
rm -v /snappymail/README.md /snappymail/_include.php
# Inspired by the original Rainloop dockerfile from youtous on GitLab
FROM php:8.5-fpm-alpine AS final
FROM php:8.2-fpm-alpine AS final
LABEL org.label-schema.description="SnappyMail webmail client image using nginx, php-fpm on Alpine"
@ -99,6 +99,9 @@ RUN set -eux; \
# mysql
RUN docker-php-ext-install pdo_mysql
# opcache
RUN docker-php-ext-install opcache
# postgres
RUN set -eux; \
apk add --no-cache postgresql-libs; \
@ -119,13 +122,6 @@ RUN set -eux; \
apk del .build-dependencies; \
apk del .deps
# sqlite
RUN set -eux; \
apk add --no-cache sqlite-libs; \
apk add --no-cache --virtual .deps sqlite-dev; \
docker-php-ext-install pdo_sqlite; \
apk del .deps
# tidy
RUN set -eux; \
apk add --no-cache tidyhtml; \

View file

@ -9,12 +9,6 @@ UPLOAD_MAX_SIZE=${UPLOAD_MAX_SIZE:-25M}
MEMORY_LIMIT=${MEMORY_LIMIT:-128M}
SECURE_COOKIES=${SECURE_COOKIES:-true}
# Disable listening on ipv6 for ipv4-only environments, or nginx will fail to start
if ! ip a | grep 'inet6 ' > /dev/null; then
echo "[INFO] ipv6 is disabled. Configuring nginx to not listen on ipv6"
sed -i '/listen \[::\]:/d' /etc/nginx/nginx.conf
fi
# Set attachment size limit
sed -i "s/<UPLOAD_MAX_SIZE>/$UPLOAD_MAX_SIZE/g" /usr/local/etc/php-fpm.d/php-fpm.conf /etc/nginx/nginx.conf
sed -i "s/<MEMORY_LIMIT>/$MEMORY_LIMIT/g" /usr/local/etc/php-fpm.d/php-fpm.conf

View file

@ -36,5 +36,5 @@ YMXQVq4SJt7UpWABAORudfJxsBqCRKtPlZMgGTJLjcOkyFJ9C2Fx7DeN0J4I
## Publishing and Credits
I will analyze and fix the reported issue as fast as possible.
Together with the reporter, I plan the disclosure of the found and fixed vulnerability.
Together with the reporter I plan the disclosure of the found and fixed vulnerability.
Credits to the reporter are granted and can be included in all public communication if desired.

View file

@ -1,247 +0,0 @@
#!/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")
)
);
}

View file

@ -9,7 +9,7 @@ if (isset($options['plugins'])) {
require(ROOT_DIR . '/build/plugins.php');
}
$gulp = trim(shell_exec('which gulp'));
$gulp = trim(`which gulp`);
if (!$gulp) {
exit('gulp not installed, run as root: npm install --global gulp-cli');
}
@ -34,7 +34,7 @@ $options['aur'] = isset($options['aur']);
// Docker build
$options['docker'] = isset($options['docker']);
if ($options['docker'] && !($docker = trim(shell_exec('which docker')))) {
if ($options['docker'] && !($docker = trim(`which docker`))) {
exit('docker not found');
}
if ($options['docker'] && $options['aur']) {
@ -60,7 +60,7 @@ if (!isset($options['skip-gulp'])) {
$cmddir = escapeshellcmd(ROOT_DIR) . '/snappymail/v/0.0.0/static';
if ($gzip = trim(shell_exec('which gzip'))) {
if ($gzip = trim(`which gzip`)) {
echo "\x1b[33;1m === Gzip *.js and *.css === \x1b[0m\n";
passthru("{$gzip} -k --best {$cmddir}/js/*.js");
passthru("{$gzip} -k --best {$cmddir}/js/min/*.js");
@ -70,7 +70,7 @@ if (!isset($options['skip-gulp'])) {
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/min/boot.min.js.gz');
}
if ($brotli = trim(shell_exec('which brotli'))) {
if ($brotli = trim(`which brotli`)) {
echo "\x1b[33;1m === Brotli *.js and *.css === \x1b[0m\n";
passthru("{$brotli} -k --best {$cmddir}/js/*.js");
passthru("{$brotli} -k --best {$cmddir}/js/min/*.js");
@ -188,7 +188,7 @@ file_put_contents("{$destPath}core.json", '{
// Arch User Repository
if ($options['aur']) {
// extension_loaded('blake2')
if (!function_exists('b2sum') && $b2sum = trim(shell_exec('which b2sum'))) {
if (!function_exists('b2sum') && $b2sum = trim(`which b2sum`)) {
function b2sum($file) {
$file = escapeshellarg($file);
exec("b2sum --binary {$file} 2>&1", $output, $exitcode);
@ -244,7 +244,7 @@ else if ($options['docker']) {
$zip_filename = "snappymail-{$package->version}.zip";
copy($zip_destination, "./.docker/release/{$zip_filename}");
if ($docker) {
passthru("{$docker} build --pull -f " . ROOT_DIR . '/.docker/release/Dockerfile ' . ROOT_DIR . " --build-arg FILES_ZIP={$zip_filename} -t snappymail:{$package->version}");
passthru("{$docker} build --pull " . ROOT_DIR . "/.docker/release/ --build-arg FILES_ZIP={$zip_filename} -t snappymail:{$package->version}");
} else {
echo "Docker not installed!\n";
}

View file

@ -1,7 +1,6 @@
import 'External/User/ko';
import { SMAudio } from 'Common/Audio';
import { arrayLength } from 'Common/Utils';
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser';
import {
@ -241,15 +240,7 @@ export class AppUser extends AbstractApp {
showMessageComposer(params = [])
{
let msg = params[1];
if (1 == arrayLength(msg)) {
msg = msg[0];
}
if (msg) {
msg.decrypt().then((/*success*/)=>showScreenPopup(ComposePopupView, params));
} else {
showScreenPopup(ComposePopupView, params);
}
showScreenPopup(ComposePopupView, params);
}
}
@ -260,8 +251,7 @@ AskPopupView.password = function(sAskDesc, btnText, ask) {
view => resolve({
password:view.passphrase(),
username:/*ask & 2 ? */view.username(),
remember:/*ask & 4 ? */view.remember(),
rememberPermanent: view.rememberPermanent(),
remember:/*ask & 4 ? */view.remember()
}),
() => resolve(null),
true,

View file

@ -232,7 +232,6 @@ export const
detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'),
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
minFontSize = parseInt(SettingsUserStore.minFontSize()),
result = {
hasExternals: false,
@ -258,15 +257,6 @@ export const
rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px'
},
allowedAttributes = [
// SnappyMail
// 'data-x-href-broken',
// 'data-x-href-tracking',
'data-x-src',
// 'data-x-src-hidden',
// 'data-x-src-tracking',
// 'data-x-src-broken',
// 'data-x-style-url',
// 'data-x-style-broken-urls',
// defaults
'name',
'dir', 'lang', 'style', 'title',
@ -309,11 +299,8 @@ export const
// Not supported by <template> element
// .replace(/<!doctype[^>]*>/gi, '')
// .replace(/<\?xml[^>]*\?>/gi, '')
// https://github.com/the-djmaze/snappymail/issues/1860
.replace(/<(\/?)x-html(\s[^>]*)?>/gi, '')
.replace(/<(\/?)head(\s[^>]*)?>/gi, '')
.replace(/<body(\s[^>]*)?>/i, '<div class="mail-body"$1>')
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div$2>')
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
// .replace(/<\/?(html|head)[^>]*>/gi, '')
// Fix Reddit https://github.com/the-djmaze/snappymail/issues/540
.replace(/<span class="preview-text"[\s\S]+?<\/span>/, '')
@ -506,12 +493,12 @@ export const
let attachment;
if (value.startsWith('cid:'))
{
setAttribute('data-x-src', value);
value = value.slice(4);
setAttribute('data-x-src-cid', value);
attachment = findAttachmentByCid(value);
if (attachment?.download) {
oElement.src = attachment.linkPreview();
// oElement.title || (oElement.title = '+attachment.fileName);
oElement.title += ' ('+attachment.fileName+')';
attachment.isInline(true);
attachment.isLinked(true);
}
@ -627,14 +614,12 @@ export const
if (urls_broken.length) {
setAttribute('data-x-style-broken-urls', JSON.stringify(urls_broken));
}
/*
// https://github.com/the-djmaze/snappymail/issues/1082
// Use getComputedStyle?
if (oStyle.fontSize && minFontSize > pInt(oStyle.fontSize)) {
oStyle.fontSize = minFontSize + "px";
// oStyle.setProperty('font-size', minFontSize + "px");
if (11 > pInt(oStyle.fontSize)) {
oStyle.removeProperty('font-size');
}
*/
// Removes background and color
// Many e-mails incorrectly only define one, not both
// And in dark theme mode this kills the readability

View file

@ -97,7 +97,7 @@ Object.assign(ko.bindingHandlers, {
fValue.subscribe(fUpdateEditorValue);
// ko.addDisposeCallback(element, () => {
// ko.utils.domNodeDisposal.addDisposeCallback(element, () => {
// });
}
}

7
dev/External/ko.js vendored
View file

@ -15,7 +15,6 @@ export const
* based solely on the values of other observables in the application
*/
koComputable = fn => ko.computed(fn, {'pure':true}),
// koObservable = value => ko.observable(value),
addObservablesTo = (target, observables) =>
forEachObjectEntry(observables, (key, value) =>
@ -31,7 +30,7 @@ export const
onEvent = (element, event, fn) => {
element.addEventListener(event, fn);
ko.addDisposeCallback(element, () => element.removeEventListener(event, fn));
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener(event, fn));
},
onKey = (key, element, fValueAccessor, fAllBindings, model) => {
@ -176,6 +175,6 @@ ko.extenders.falseTimeout = (target, option) => {
// functions
ko.observable.fn.askDeleteHelper = function(prop='askDelete') {
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, prop] });
ko.observable.fn.askDeleteHelper = function() {
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, 'askDelete'] });
};

View file

@ -73,9 +73,4 @@ export class EmailModel extends AbstractModel {
}
return result || name;
}
get domain() {
return this.email.replace(/^.+@([^@]+)$/, '$1');
// return this.email.slice(this.email.lastIndexOf('@'))
}
}

View file

@ -1,16 +1,19 @@
//import { AbstractModel } from 'Knoin/AbstractModel';
import { EmailModel } from 'Model/Email';
import { AbstractModel } from 'Knoin/AbstractModel';
import { addObservablesTo, addComputablesTo } from 'External/ko';
export class IdentityModel extends EmailModel /*AbstractModel*/ {
export class IdentityModel extends AbstractModel {
/**
* @param {string} id
* @param {string} email
*/
constructor() {
super();
addObservablesTo(this, {
id: '',
label: '',
// email: '',
// name: '',
email: '',
name: '',
replyTo: '',
bcc: '',
@ -24,7 +27,6 @@ export class IdentityModel extends EmailModel /*AbstractModel*/ {
smimeKey: '',
smimeCertificate: '',
smimeCertificateChain: '',
askDelete: false,
@ -34,18 +36,16 @@ export class IdentityModel extends EmailModel /*AbstractModel*/ {
addComputablesTo(this, {
smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----'),
smimeKeyValid: () => /^-----BEGIN (ENCRYPTED |RSA )?PRIVATE KEY-----/.test(this.smimeKey()),
smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate()),
smimeCertificateChainValid: () => !this.smimeCertificateChain()
|| /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificateChain())
smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate())
});
}
/**
* @returns {string}
*/
toString() {
const name = this.name,
email = this.email,
formattedName() {
const name = this.name(),
email = this.email(),
label = this.label();
return (name ? `${name} ` : '') + `<${email}>` + (label ? ` (${label})` : '');
}

View file

@ -24,10 +24,6 @@ import Remote from 'Remote/User/Fetch';
import { MimeToMessage } from 'Mime/Utils';
import { PgpUserStore } from 'Stores/User/Pgp';
import { IdentityUserStore } from 'Stores/User/Identity';
import { Passphrases } from 'Storage/Passphrases';
const
PreviewHTML = `<html>
<head>
@ -602,140 +598,4 @@ export class MessageModel extends AbstractModel {
return result.html || plainToHtml(this.plain());
}
async decrypt() {
const msg = this;
if (msg.pgpEncrypted() && !msg.pgpDecrypted()) {
return await msg.pgpDecrypt().then(()=>msg.pgpDecrypted());
}
if (msg.smimeEncrypted() && !msg.smimeDecrypted()) {
return await msg.smimeDecrypt().then(()=>msg.smimeDecrypted());
}
return true;
}
/*
async decrypted() {
return msg.pgpDecrypted() || msg.smimeDecrypted());
}
*/
async pgpDecrypt() {
const oMessage = this,
data = oMessage.pgpEncrypted();
delete data.error;
await PgpUserStore.decrypt(oMessage).then(result => {
if (!result) {
// TODO: translate
throw Error('Decryption failed, canceled or not possible');
}
oMessage.pgpDecrypted(true);
if (result.data) {
MimeToMessage(result.data, oMessage);
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
if (result.signatures?.length) {
oMessage.pgpSigned({
signatures: result.signatures,
success: !!result.signatures.length
});
}
}
})
.catch(e => {
data.error = e.message;
})
.finally(() => {
oMessage.pgpEncrypted(data);
});
}
pgpVerify(/*self, event*/) {
const oMessage = this;
PgpUserStore.verify(oMessage).then(result => {
if (result) {
oMessage.pgpSigned(result);
} else {
alert('Verification failed or no valid public key found');
}
/*
if (result?.success) {
i18n('CRYPTO/GOOD_SIGNATURE', {
USER: validKey.user + ' (' + validKey.id + ')'
});
message.getText()
} else {
const keyIds = arrayLength(signingKeyIds) ? signingKeyIds : null,
additional = keyIds
? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ')
: '';
i18n('CRYPTO/ERROR', {
TYPE: 'OpenPGP',
ERROR: 'message'
}) + (additional ? ' (' + additional + ')' : '');
}
*/
});
}
async smimeDecrypt() {
const message = this;
const addresses = message.from.concat(message.to, message.cc, message.bcc).map(item => item.email),
identity = IdentityUserStore.find(item => addresses.includes(item.email)),
data = message.smimeEncrypted(); // { partId: "1" }
if (data && identity) {
delete data.error;
let pass, params = { ...data }; // clone
params.folder = message.folder;
params.uid = message.uid;
// params.bodyPart = params.bodyPart?.raw;
params.certificate = identity.smimeCertificate();
params.privateKey = identity.smimeKey();
if (identity.smimeKeyEncrypted()) {
pass = await Passphrases.ask(identity,
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email}),
'CRYPTO/DECRYPT'
);
if (!pass) {
return;
}
params.passphrase = pass?.password;
}
await Remote.post('SMimeDecryptMessage', null, params).then(response => {
if (response?.Result?.data) {
message.smimeDecrypted(true);
MimeToMessage(response.Result.data, message);
message.html() ? message.viewHtml() : message.viewPlain();
pass && pass.remember && Passphrases.handle(identity, pass.password);
if ('signed' in response.Result) {
message.smimeSigned(response.Result.signed);
}
}
}).catch(e => {
data.error = e.message
})
.finally(() => {
message.smimeEncrypted(data);
});
}
}
smimeVerify(/*self, event*/) {
const message = this,
data = message.smimeSigned(); // { partId: "1", micAlg: "pgp-sha256" }
if (data) {
const params = { ...data }; // clone
params.folder = message.folder;
params.uid = message.uid;
params.bodyPart = data.bodyPart?.raw;
params.sigPart = data.sigPart?.bodyRaw;
Remote.post('SMimeVerifyMessage', null, params).then(response => {
if (response?.Result) {
if (response.Result.body) {
MimeToMessage(response.Result.body, message);
message.html() ? message.viewHtml() : message.viewPlain();
}
data.success = response.Result.success;
message.smimeSigned(data);
}
});
}
}
}

View file

@ -18,8 +18,8 @@ export class AdminSettingsDomains /*extends AbstractViewSettings*/ {
testUsername() {
Remote.request('AdminDomainMatch',
(iError, oData) => {
if (oData?.Result?.name) {
alert(`${oData.Result.email} matched domain: ${oData.Result.name}`);
if (oData?.Result?.domain) {
alert(`${oData.Result.email} matched domain: ${oData.Result.domain.name}`);
} else {
alert('No domain match');
}

View file

@ -2,7 +2,7 @@ import ko from 'ko';
import { koComputable } from 'External/ko';
import { SettingsGet } from 'Common/Globals';
import { i18n, translateTrigger, getErrorMessage } from 'Common/Translator';
import { i18n, translateTrigger } from 'Common/Translator';
import { ContactUserStore } from 'Stores/User/Contact';
import Remote from 'Remote/User/Fetch';
@ -15,7 +15,6 @@ export class UserSettingsContacts /*extends AbstractViewSettings*/ {
this.syncUrl = ContactUserStore.syncUrl;
this.syncUser = ContactUserStore.syncUser;
this.syncPass = ContactUserStore.syncPass;
this.syncError = ko.observable('');
this.syncModeOptions = koComputable(() => {
translateTrigger();
@ -49,15 +48,4 @@ export class UserSettingsContacts /*extends AbstractViewSettings*/ {
})
);
}
test() {
this.syncError('');
Remote.request('TestContactsSyncData', (iError, data) => {
iError && this.syncError(data.messageAdditional || data.message || getErrorMessage(iError, data));
}, {
Url: ContactUserStore.syncUrl(),
User: ContactUserStore.syncUser(),
Password: ContactUserStore.syncPass()
})
}
}

View file

@ -44,51 +44,6 @@ export class UserSettingsGeneral extends AbstractViewSettings {
this.desktopNotifications = NotificationUserStore.enabled;
this.isDesktopNotificationAllowed = NotificationUserStore.allowed;
const i18nSort = s => i18n('MESSAGE_LIST/SORT_'+s);
this.sortSupported = FolderUserStore.hasCapability('SORT');
this.sortOptions = [
{
value: '',
text: i18nSort('DATE_DESC')
},
{
value: 'DATE',
text: i18nSort('DATE_ASC')
},
{
value: 'FROM',
text: i18nSort('FROM_ASC')
},
{
value: 'REVERSE FROM',
text: i18nSort('FROM_DESC')
},
{
value: 'REVERSE SIZE',
text: i18nSort('SIZE_DESC')
},
{
value: 'SIZE',
text: i18nSort('SIZE_ASC')
},
{
value: 'SUBJECT',
text: i18nSort('SUBJECT_ASC')
},
{
value: 'REVERSE SUBJECT',
text: i18nSort('SUBJECT_DESC')
},
{
value: 'REVERSE ARRIVAL',
text: i18nSort('ARRIVAL_DESC')
},
{
value: 'ARRIVAL',
text: i18nSort('ARRIVAL_ASC')
}
];
this.threadsAllowed = AppUserStore.threadsAllowed;
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
this.threadAlgorithms = ko.observableArray();
@ -96,11 +51,12 @@ export class UserSettingsGeneral extends AbstractViewSettings {
capa.startsWith('THREAD=') && this.threadAlgorithms.push(capa.slice(7))
);
this.threadAlgorithms.sort((a, b) => a.length - b.length);
this.threadAlgorithm = SettingsUserStore.threadAlgorithm;
['defaultSort', 'useThreads', 'threadAlgorithm',
['useThreads', 'threadAlgorithm',
// These use addSetting()
'layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel', 'minFontSize',
'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel',
// These are in addSettings()
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
'viewHTML', 'viewImages', 'viewImagesWhitelist', 'removeColors', 'allowStyles', 'allowDraftAutosave',
@ -122,7 +78,7 @@ export class UserSettingsGeneral extends AbstractViewSettings {
identityMainDesc: () => {
const identity = IdentityUserStore.main();
return identity ? identity.toString() : '---';
return identity ? identity.formattedName() : '---';
},
editorDefaultTypes: () => {
@ -161,8 +117,6 @@ export class UserSettingsGeneral extends AbstractViewSettings {
this.addSetting('CheckMailInterval');
this.addSetting('Layout');
this.addSetting('MaxBlockquotesLevel');
this.addSetting('minFontSize');
this.addSetting('defaultSort');
this.addSettings([
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',

View file

@ -1,155 +1,21 @@
import { AskPopupView } from 'View/Popup/Ask';
import { SettingsUserStore } from 'Stores/User/Settings';
import { SettingsGet } from '../Common/Globals';
import { isArray } from '../Common/Utils';
export const Passphrases = new WeakMap();
Passphrases.ask = async (key, sAskDesc, btnText) => {
if (Passphrases.has(key)) {
return { password: Passphrases.handle(key)/*, remember:false*/ };
} else if (Passphrases.hasInLocalStorage(key)) {
return { password: await getFromLocalStorage(key) };
} else {
const pass = await AskPopupView.password(sAskDesc, btnText,
window.crypto.subtle && canUseLocalStorage(key) ? 0b1101 : 0b0101);
pass.rememberPermanent && await saveToLocalStorage(key, pass.password);
return pass;
}
};
Passphrases._deleteFromSession = Passphrases.delete;
Passphrases.ask = async (key, sAskDesc, btnText) =>
Passphrases.has(key)
? {password:Passphrases.handle(key)/*, remember:false*/}
: await AskPopupView.password(sAskDesc, btnText, 5);
const timeouts = {};
// get/set accessor to control deletion after N minutes of inactivity
Passphrases.handle = (key, pass) => {
const timeout = SettingsUserStore.keyPassForget();
if (timeout && !timeouts[key]) {
timeouts[key] = (() => Passphrases._deleteFromSession(key)).debounce(timeout * 60 * 1000);
timeouts[key] = (()=>Passphrases.delete(key)).debounce(timeout * 1000);
}
pass && Passphrases.set(key, pass);
timeout && timeouts[key]();
return Passphrases.get(key);
};
const deleteFromLocalStorage = (key) => {
const keyId = getKeyId(key);
if (keyId) {
localStorage.removeItem(keyId);
}
};
Passphrases.delete = (key) => {
deleteFromLocalStorage(key);
return Passphrases._deleteFromSession(key);
};
Passphrases.hasInLocalStorage = (key) => {
const keyId = getKeyId(key);
return keyId && localStorage.getItem(keyId) !== null;
};
const saveToLocalStorage = async (key, pass) => {
const keyId = getKeyId(key);
if (!keyId) {
return;
}
if (!pass) {
localStorage.removeItem(keyId);
return;
}
try {
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const derivedKey = await deriveKeyFromHash(SettingsGet('accountHash'), salt);
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const encrypted = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
derivedKey,
new TextEncoder().encode(pass)
);
localStorage.setItem(keyId, JSON.stringify([
btoa(String.fromCharCode.apply(null, salt)),
btoa(String.fromCharCode.apply(null, iv)),
btoa(String.fromCharCode.apply(null, new Uint8Array(encrypted)))
]));
} catch (e) {
console.error('Passphrases.saveToLocalStorage failed', e);
}
};
const getFromLocalStorage = async (key) => {
const keyId = getKeyId(key);
if (!keyId) {
return undefined;
}
const jsonData = localStorage.getItem(keyId);
if (!jsonData) {
console.error('Passphrases.getFromLocalStorage failed: no data found');
return undefined;
}
try {
const saltIvData = JSON.parse(jsonData);
if (!saltIvData || !isArray(saltIvData) || saltIvData.length !== 3) {
// noinspection ExceptionCaughtLocallyJS
throw new Error('invalid passphrase data');
}
const toUint8 = (str) => new Uint8Array(atob(str).split('').map(c => c.charCodeAt(0)));
const derivedKey = await deriveKeyFromHash(SettingsGet('accountHash'), toUint8(saltIvData[0]));
const decrypted = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: toUint8(saltIvData[1])
},
derivedKey,
toUint8(saltIvData[2])
);
return String.fromCharCode.apply(null, new Uint8Array(decrypted));
} catch (e) {
localStorage.removeItem(keyId);
console.error('Passphrases.getFromLocalStorage failed', e);
return undefined;
}
};
const canUseLocalStorage = (key) => getKeyId(key) !== undefined;
const getKeyId = (key) => {
if (key && typeof key.id === 'string' && key.id.length > 4 && typeof key.forgetPass === 'function') {
// only deal with keys that we can forget (OpenPGB, GnuPG)
return key.id + '_local_key';
} else {
console.info('Passphrases.getKeyId: unsupported key type');
return undefined;
}
};
const deriveKeyFromHash = async (hash, salt) => {
if (!hash) {
throw new Error('empty accountHash');
}
return window.crypto.subtle.importKey(
'raw',
new TextEncoder().encode(hash),
{ 'name': 'PBKDF2' },
false,
['deriveKey']
).then(keyMaterial => {
return window.crypto.subtle.deriveKey(
{
'name': 'PBKDF2',
'salt': salt,
'iterations': 512,
'hash': 'SHA-256'
},
keyMaterial,
{ 'name': 'AES-GCM', 'length': 256 },
false,
['encrypt', 'decrypt']
);
});
};

View file

@ -51,13 +51,6 @@ export const GnuPGUserStore = new class {
key.for = email => aEmails.includes(IDN.toASCII(email));
key.askDelete = ko.observable(false);
key.openForDeletion = ko.observable(null).askDeleteHelper();
key.askForgetPass = ko.observable(false);
key.openForPassForget = ko.observable(null).askDeleteHelper('askForgetPass');
key.forgetPass = () => {
Passphrases.delete(key);
key.hasStoredPass(false);
};
key.hasStoredPass = ko.observable(Passphrases.hasInLocalStorage(key));
key.remove = () => {
if (key.askDelete()) {
Remote.request('GnupgDeleteKey',
@ -76,7 +69,6 @@ export const GnuPGUserStore = new class {
isPrivate: isPrivate
}
);
isPrivate && key.forgetPass();
}
};
if (isPrivate) {

View file

@ -13,7 +13,7 @@ import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey';
import { Passphrases } from 'Storage/Passphrases';
import { baseCollator, getNotification } from 'Common/Translator';
import { baseCollator } from 'Common/Translator';
const
loaded = () => !!window.openpgp,
@ -58,9 +58,10 @@ const
*/
publicKeysItem = 'openpgp-public-keys',
privateKeysItem = 'openpgp-private-keys',
storage = window.localStorage,
loadOpenPgpKeys = async itemname => {
let keys = [], key,
armoredKeys = JSON.parse(localStorage.getItem(itemname)),
armoredKeys = JSON.parse(storage.getItem(itemname)),
i = arrayLength(armoredKeys);
while (i--) try {
key = await openpgp.readKey({armoredKey:armoredKeys[i]});
@ -73,9 +74,9 @@ const
storeOpenPgpKeys = (keys, section) => {
let armoredKeys = keys.map(item => item.armor);
if (armoredKeys.length) {
localStorage.setItem(section, JSON.stringify(armoredKeys));
storage.setItem(section, JSON.stringify(armoredKeys));
} else {
localStorage.removeItem(section);
storage.removeItem(section);
}
};
@ -90,9 +91,6 @@ class OpenPgpKeyModel {
this.armor = armor;
this.askDelete = ko.observable(false);
this.openForDeletion = ko.observable(null).askDeleteHelper();
this.hasStoredPass = ko.observable(Passphrases.hasInLocalStorage(this));
this.askForgetPass = ko.observable(false);
this.openForPassForget = ko.observable(null).askDeleteHelper('askForgetPass');
// key.getUserIDs()
// key.getPrimaryUser()
}
@ -118,29 +116,12 @@ class OpenPgpKeyModel {
if (this.key.isPrivate()) {
OpenPGPUserStore.privateKeys.remove(this);
storeOpenPgpKeys(OpenPGPUserStore.privateKeys, privateKeysItem);
this.forgetPass()
} else {
OpenPGPUserStore.publicKeys.remove(this);
storeOpenPgpKeys(OpenPGPUserStore.publicKeys, publicKeysItem);
}
Remote.request('DeletePGPKey',
(iError, oData) => {
if (oData) {
if (iError || oData.Result === false) {
alert(oData.message || getNotification(iError));
}
}
}, {
key: this.armor
}
);
}
}
forgetPass() {
Passphrases.delete(this);
this.hasStoredPass(false);
}
/*
toJSON() {
return this.armor;

View file

@ -5,7 +5,6 @@ import { LayoutSideView, LayoutBottomView } from 'Common/EnumsUser';
import { pInt } from 'Common/Utils';
import { $htmlCL, SettingsGet, fireEvent } from 'Common/Globals';
import { ThemeStore } from 'Stores/Theme';
import { FolderUserStore } from 'Stores/User/Folder';
export const SettingsUserStore = new class {
constructor() {
@ -23,7 +22,6 @@ export const SettingsUserStore = new class {
allowStyles: 0,
collapseBlockquotes: 1,
maxBlockquotesLevel: 0,
minFontSize: 0,
listInlineAttachments: 0,
simpleAttachmentsList: 0,
useCheckboxesInList: 1,
@ -31,7 +29,6 @@ export const SettingsUserStore = new class {
showNextMessage: 0,
allowDraftAutosave: 1,
useThreads: 0,
defaultSort: '',
threadAlgorithm: '',
replySameFolder: 0,
hideUnsubscribed: 0,
@ -99,7 +96,6 @@ export const SettingsUserStore = new class {
'AllowStyles',
'CollapseBlockquotes',
'MaxBlockquotesLevel',
'minFontSize',
'ListInlineAttachments',
'simpleAttachmentsList',
'UseCheckboxesInList',
@ -107,7 +103,6 @@ export const SettingsUserStore = new class {
'showNextMessage',
'AllowDraftAutosave',
'useThreads',
'defaultSort',
'threadAlgorithm',
'ReplySameFolder',
'HideUnsubscribed',
@ -166,7 +161,5 @@ export const SettingsUserStore = new class {
self.messageReadDelay(pInt(SettingsGet('MessageReadDelay')));
self.autoLogout(pInt(SettingsGet('AutoLogout')));
self.keyPassForget(pInt(SettingsGet('keyPassForget')));
FolderUserStore.sortMode(self.defaultSort());
}
};

View file

@ -1,6 +1,6 @@
#V-PopupsLanguages {
max-width: 720px;
max-width: 710px;
label {
display: inline-block;

View file

@ -125,9 +125,8 @@
/* Right */
.right {
border-left: 1px solid var(--border-color, #ddd);
max-width: 600px;
width: 100%;
border-left: 1px solid var(--border-color, #ddd);
}
.b-view-content-toolbar {

View file

@ -82,7 +82,7 @@
border-left: 3px solid transparent;
color: var(--folders-color, #333);
display: block;
line-height: 2.2em;
line-height: 34px;
padding: 0 2em 0 @folderItemPadding;
position: relative;
text-decoration: none;
@ -151,7 +151,7 @@
color: var(--unread-count-color, #fff);
font-size: 80%;
line-height: 1.5em;
margin-top: 0.5em;
margin-top: 7px;
min-width: 1.7em;
padding: 1px 4px;
text-align: center;

View file

@ -45,8 +45,4 @@
content: ')';
}
.identities-list tr + tr .identity-default {
display: none;
}
}

View file

@ -15,14 +15,10 @@ export class AskPopupView extends AbstractViewPopup {
askUsername: false,
passphrase: '',
askPass: false,
remember: true, // remember for session
askRemeber: false,
rememberPermanent: false,
askRememberPermanent: false,
remember: true,
askRemeber: false
});
this.rememberPermanent.subscribe(value => value && this.remember(true));
this.fYesAction = null;
this.fNoAction = null;
@ -52,12 +48,10 @@ export class AskPopupView extends AbstractViewPopup {
this.askDesc(sAskDesc || '');
this.askUsername(ask & 2);
this.askPass(ask & 1);
this.askRemeber(ask & 4); // 0b0100
this.askRememberPermanent(ask & 0b1000)
this.askRemeber(ask & 4);
this.username('');
this.passphrase('');
this.remember(true);
this.rememberPermanent(false);
this.yesButton(i18n(btnText || 'GLOBAL/YES'));
this.noButton(i18n(ask ? 'GLOBAL/CANCEL' : 'GLOBAL/NO'));
this.fYesAction = fYesFunc;

View file

@ -44,7 +44,6 @@ import Remote from 'Remote/User/Fetch';
import { ComposeAttachmentModel } from 'Model/ComposeAttachment';
import { EmailModel } from 'Model/Email';
import { IdentityModel } from 'Model/Identity';
import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
import { addressparser } from 'Mime/Address';
@ -93,7 +92,7 @@ const
findIdentity = addresses => {
addresses = addresses.map(item => item.email);
return IdentityUserStore.find(item => addresses.includes(item.email));
return IdentityUserStore.find(item => addresses.includes(item.email()));
},
/**
@ -344,7 +343,7 @@ export class ComposePopupView extends AbstractViewPopup {
IdentityUserStore.map(item => ({
item: item,
optValue: item.id(),
optText: item
optText: item.formattedName()
})),
canBeSentOrSaved: () => !this.sending() && !this.saving()
@ -359,7 +358,7 @@ export class ComposePopupView extends AbstractViewPopup {
currentIdentity: value => {
if (value) {
this.from(value.toString());
this.from(value.formattedName());
this.doEncrypt(value.pgpEncrypt() || SettingsUserStore.pgpEncrypt());
this.doSign(value.pgpSign() || SettingsUserStore.pgpSign());
}
@ -455,7 +454,7 @@ export class ComposePopupView extends AbstractViewPopup {
contactsCommand: self => self.allowContacts
});
this.from(IdentityUserStore()[0].toString());
this.from(IdentityUserStore()[0].formattedName());
}
sentFolder()
@ -863,7 +862,6 @@ export class ComposePopupView extends AbstractViewPopup {
case ComposeType.Forward:
case ComposeType.ForwardAsAttachment:
identity = findIdentity(oLastMessage.to.concat(oLastMessage.cc, oLastMessage.bcc))
|| findIdentity(oLastMessage.from)
/* || findIdentity(oLastMessage.deliveredTo)*/;
break;
case ComposeType.Draft:
@ -873,20 +871,10 @@ export class ComposePopupView extends AbstractViewPopup {
// case ComposeType.Empty:
}
}
// Set from custom email
if (!identity
&& oLastMessage && (ComposeType.Reply === options.mode || ComposeType.ReplyAll === options.mode)
&& 1 === oLastMessage.to.length
// && mEmail.includes(oLastMessage.to[0].domain)
) {
identity = new IdentityModel;
identity.name = oLastMessage.to[0].name;
identity.email = oLastMessage.to[0].email;
}
identity = identity || IdentityUserStore()[0];
if (identity) {
// excludeEmail.add(identity.email);
excludeEmail[identity.email] = true;
// excludeEmail.add(identity.email());
excludeEmail[identity.email()] = true;
}
if (arrayLength(options.to)) {
@ -971,7 +959,7 @@ export class ComposePopupView extends AbstractViewPopup {
// https://github.com/the-djmaze/snappymail/issues/491
tpl.innerHTML = oLastMessage.bodyAsHTML();
tpl.content.querySelectorAll('img').forEach(img => {
img.src || img.dataset.xSrc || img.replaceWith(img.alt || img.title)
img.src || img.dataset.xSrcCid || img.dataset.xSrc || img.replaceWith(img.alt || img.title)
});
sText = tpl.innerHTML.trim();
@ -1393,7 +1381,7 @@ export class ComposePopupView extends AbstractViewPopup {
allRecipients() {
return [
// From/sender is also recipient (Sent mailbox)
// this.currentIdentity().email,
// this.currentIdentity().email(),
this.from(),
this.to(),
this.cc(),
@ -1413,8 +1401,7 @@ export class ComposePopupView extends AbstractViewPopup {
key && options.push(['OpenPGP', key]);
key = GnuPGUserStore.getPrivateKeyFor(email, 1);
key && options.push(['GnuPG', key]);
identity.smimeKeyValid() && identity.smimeCertificateValid()
&& identity.smimeCertificateChainValid() && identity.email === email
identity.smimeKeyValid() && identity.smimeCertificateValid() && identity.email() === email
&& options.push(['S/MIME']);
console.dir({signOptions: options});
this.signOptions(options);
@ -1433,7 +1420,7 @@ export class ComposePopupView extends AbstractViewPopup {
const count = recipients.length,
identity = this.currentIdentity(),
from = (identity.smimeKey() && identity.smimeCertificate()) ? identity.email : null;
from = (identity.smimeKey() && identity.smimeCertificate()) ? identity.email() : null;
count
&& count === recipients.filter(email =>
email == from
@ -1510,21 +1497,12 @@ export class ComposePopupView extends AbstractViewPopup {
**/
linkedData: []
},
recipients = draft ? [identity.email] : this.allRecipients(),
recipients = draft ? [identity.email()] : this.allRecipients(),
signOptions = !draft && this.doSign() && this.signOptions(),
encryptOptions = this.doEncrypt() && this.encryptOptions(),
isHtml = this.oEditor.isHtml();
if (isHtml) {
tpl.innerHTML = Text;
tpl.content.querySelectorAll('img').forEach(img => {
if (img.dataset.xSrc) {
img.src = img.dataset.xSrc;
img.removeAttribute('data-x-src')
}
});
Text = tpl.innerHTML.trim();
do {
l = Text.length;
Text = Text
@ -1594,7 +1572,6 @@ export class ComposePopupView extends AbstractViewPopup {
*/
break;
} catch (e) {
Passphrases.delete(signOptions[i][1])
console.error(e);
}
} else if ('GnuPG' == signOptions[i][0]) {
@ -1612,12 +1589,11 @@ export class ComposePopupView extends AbstractViewPopup {
// TODO: sign in PHP fails
params.sign = 'S/MIME';
// params.signCertificate = identity.smimeCertificate();
// params.signCertificateChain = identity.smimeCertificateChain();
// params.signPrivateKey = identity.smimeKey();
// params.attachCertificate = false;
if (identity.smimeKeyEncrypted()) {
const pass = await Passphrases.ask(identity,
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email}),
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}),
'CRYPTO/SIGN'
);
if (null != pass) {
@ -1637,9 +1613,7 @@ export class ComposePopupView extends AbstractViewPopup {
Object.entries(PgpUserStore.getPublicKeyOfEmails(recipients) || {}).forEach(([k,v]) =>
params.autocrypt.push({
addr: k,
keydata: v
.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '')
.replace(/\s+/g, '') // Single base64 string, let the server do compliance formatting
keydata: v.replace(/-----(BEGIN|END) PGP PUBLIC KEY BLOCK-----/g, '').trim()
})
);
for (let i = 0; i < encryptOptions.length; ++i) {
@ -1659,7 +1633,7 @@ export class ComposePopupView extends AbstractViewPopup {
if ('S/MIME' == encryptOptions[i]) {
params.encryptCertificates = [identity.smimeCertificate()];
SMimeUserStore.forEach(certificate => {
certificate.emailAddress != identity.email
certificate.emailAddress != identity.email()
&& recipients.includes(certificate.emailAddress)
&& params.encryptCertificates.push(certificate.id)
});

View file

@ -57,8 +57,8 @@ export class IdentityPopupView extends AbstractViewPopup {
this.submitError(oData.message);
}
}, {
name: identity.name,
email: identity.email,
name: identity.name(),
email: identity.email(),
privateKey: identity.smimeKey(),
passphrase: result.password
});

View file

@ -105,9 +105,9 @@ export class OpenPgpGeneratePopupView extends AbstractViewPopup {
}
onShow() {
this.name(''/*IdentityUserStore()[0].name*/);
this.name(''/*IdentityUserStore()[0].name()*/);
this.password('');
this.email(''/*IdentityUserStore()[0].email*/);
this.email(''/*IdentityUserStore()[0].email()*/);
this.emailError(false);
this.submitError('');
}

View file

@ -213,19 +213,15 @@ export class MailMessageList extends AbstractViewRight {
sortText: () => {
let mode = FolderUserStore.sortMode(),
has = w => mode.includes(w),
desc = '' === mode || has('REVERSE');
desc = '' === mode || mode.includes('REVERSE');
mode = mode.split(/\s+/);
if (has('FROM')) {
if (mode.includes('FROM')) {
return '@' + (desc ? '⬆' : '⬇');
}
if (has('SUBJECT')) {
if (mode.includes('SUBJECT')) {
return '𝐒' + (desc ? '⬆' : '⬇');
}
if (has('SIZE')) {
return '✉' + (desc ? '⬇' : '⬆');
}
return (has('ARRIVAL') ? '📨' : '📅') + (desc ? '⬇' : '⬆');
return (mode.includes('SIZE') ? '✉' : '📅') + (desc ? '⬇' : '⬆');
},
downloadAsZipAllowed: () => this.attachmentsActions.includes('zip')
@ -303,14 +299,14 @@ export class MailMessageList extends AbstractViewRight {
item => sFolder === item?.folder && iUid == item?.uid
);
if ('INBOX' === sFolder && 'INBOX' !== FolderUserStore.currentFolderFullName()) {
if ('INBOX' === sFolder) {
hasher.setHash(mailBox(sFolder));
}
if (message) {
this.selector.selectMessageItem(message);
} else {
if ('INBOX' !== sFolder && sFolder !== FolderUserStore.currentFolderFullName()) {
if ('INBOX' !== sFolder) {
hasher.setHash(mailBox(sFolder));
}
if (sFolder && iUid) {

View file

@ -56,6 +56,9 @@ import { showScreenPopup } from 'Knoin/Knoin';
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
import { GnuPGUserStore } from 'Stores/User/GnuPG';
import { OpenPGPUserStore } from 'Stores/User/OpenPGP';
import { IdentityUserStore } from 'Stores/User/Identity';
import { Passphrases } from 'Storage/Passphrases';
const
oMessageScrollerDom = () => elementById('messageItem') || {},
@ -130,8 +133,6 @@ export class MailMessageView extends AbstractViewRight {
// viewer
viewFromShort: '',
dkimData: ['none', '', ''],
spfData: ['none', '', ''],
dmarcData: ['none', '', ''],
nowTracking: false
});
@ -196,7 +197,7 @@ export class MailMessageView extends AbstractViewRight {
dkimIcon: () => {
switch (this.dkimData()[0]) {
case 'none':
return '🚫︎';
return '';
case 'pass':
return '✔';
default:
@ -204,43 +205,12 @@ export class MailMessageView extends AbstractViewRight {
}
},
dkimIconClass: () => 'pass' === this.dkimData()[0] ? 'iconcolor-green' : 'iconcolor-red',
dkimTitle:() => {
const dkim = this.dkimData();
return dkim[0] ? dkim[2] || 'DKIM: ' + dkim[0] : '';
},
spfIcon: () => {
switch (this.spfData()[0]) {
case 'none':
return '🚫︎';
case 'pass':
return '✔';
default:
return '✖';
}
},
spfIconClass: () => 'pass' === this.spfData()[0] ? 'iconcolor-green' : 'iconcolor-red',
spfTitle:() => {
const spf = this.spfData();
return spf[0] ? spf[2] || 'SPF: ' + spf[0] : '';
},
dmarcIcon: () => {
switch (this.dmarcData()[0]) {
case 'none':
return '🚫︎';
case 'pass':
return '✔';
default:
return '✖';
}
},
dmarcIconClass: () => 'pass' === this.dmarcData()[0] ? 'iconcolor-green' : 'iconcolor-red',
dmarcTitle:() => {
const dmarc = this.dmarcData();
return dmarc[0] ? dmarc[2] || 'DMARC: ' + dmarc[0] : '';
},
showWhitelistOptions: () => 'match' === SettingsUserStore.viewImages(),
firstUnsubsribeLink: () => currentMessage()?.unsubsribeLinks()[0] || '',
@ -263,8 +233,6 @@ export class MailMessageView extends AbstractViewRight {
// TODO: make first param a user setting #683
this.viewFromShort(message.from.toString(false, true));
this.dkimData(message.dkim[0] || ['none', '', '']);
this.spfData(message.spf[0] || ['none', '', '']);
this.dmarcData(message.dmarc[0] || ['none', '', '']);
this.nowTracking(false);
} else {
MessagelistUserStore.selectedMessage(null);
@ -642,19 +610,125 @@ export class MailMessageView extends AbstractViewRight {
}
pgpDecrypt() {
currentMessage().decrypt();
const oMessage = currentMessage(),
data = oMessage.pgpEncrypted();
delete data.error;
PgpUserStore.decrypt(oMessage).then(result => {
if (!result) {
// TODO: translate
throw Error('Decryption failed, canceled or not possible');
}
oMessage.pgpDecrypted(true);
if (result.data) {
MimeToMessage(result.data, oMessage);
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
if (result.signatures?.length) {
oMessage.pgpSigned({
signatures: result.signatures,
success: !!result.signatures.length
});
}
}
})
.catch(e => {
data.error = e.message;
})
.finally(() => {
oMessage.pgpEncrypted(data);
});
}
pgpVerify(/*self, event*/) {
currentMessage().pgpVerify();
const oMessage = currentMessage()/*, ctrl = event.target.closest('.openpgp-control')*/;
PgpUserStore.verify(oMessage).then(result => {
if (result) {
oMessage.pgpSigned(result);
} else {
alert('Verification failed or no valid public key found');
}
/*
if (result?.success) {
i18n('CRYPTO/GOOD_SIGNATURE', {
USER: validKey.user + ' (' + validKey.id + ')'
});
message.getText()
} else {
const keyIds = arrayLength(signingKeyIds) ? signingKeyIds : null,
additional = keyIds
? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ')
: '';
i18n('CRYPTO/ERROR', {
TYPE: 'OpenPGP',
ERROR: 'message'
}) + (additional ? ' (' + additional + ')' : '');
}
*/
});
}
async smimeDecrypt() {
currentMessage().decrypt();
const message = currentMessage();
const addresses = message.from.concat(message.to, message.cc, message.bcc).map(item => item.email),
identity = IdentityUserStore.find(item => addresses.includes(item.email())),
data = message.smimeEncrypted(); // { partId: "1" }
if (data && identity) {
delete data.error;
let pass, params = { ...data }; // clone
params.folder = message.folder;
params.uid = message.uid;
// params.bodyPart = params.bodyPart?.raw;
params.certificate = identity.smimeCertificate();
params.privateKey = identity.smimeKey();
if (identity.smimeKeyEncrypted()) {
pass = await Passphrases.ask(identity,
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email()}),
'CRYPTO/DECRYPT'
);
if (!pass) {
return;
}
params.passphrase = pass?.password;
}
Remote.post('SMimeDecryptMessage', null, params).then(response => {
if (response?.Result?.data) {
message.smimeDecrypted(true);
MimeToMessage(response.Result.data, message);
message.html() ? message.viewHtml() : message.viewPlain();
pass && pass.remember && Passphrases.handle(identity, pass.password);
if ('signed' in response.Result) {
message.smimeSigned(response.Result.signed);
}
}
}).catch(e => {
data.error = e.message
})
.finally(() => {
message.smimeEncrypted(data);
});
}
}
smimeVerify(/*self, event*/) {
currentMessage().smimeVerify();
const message = currentMessage(),
data = message.smimeSigned(); // { partId: "1", micAlg: "pgp-sha256" }
if (data) {
const params = { ...data }; // clone
params.folder = message.folder;
params.uid = message.uid;
params.bodyPart = data.bodyPart?.raw;
params.sigPart = data.sigPart?.bodyRaw;
Remote.post('SMimeVerifyMessage', null, params).then(response => {
if (response?.Result) {
if (response.Result.body) {
MimeToMessage(response.Result.body, message);
message.html() ? message.viewHtml() : message.viewPlain();
}
data.success = response.Result.success;
message.smimeSigned(data);
}
});
}
}
}

View file

@ -77,7 +77,7 @@ Now, login to [http://localhost:8888/?admin](http://localhost:8888/?admin) with
To use PostgreSQL as the DB, in Admin Panel, click `Contacts`, check `Enable contacts` and , and under `Storage (PDO)` choose the following:
- Type: `PostgresSQL`
- Data Source Name (DSN): `host=db;port=5432;dbname=snappymail`
- Data Source Name (DSN): `host=postgres;port=5432;dbname=snappymail`
- User `snappymail`
- Password `snappymail`

View file

@ -1,4 +1,4 @@
This app packages SnappyMail <upstream>2.38.2</upstream>.
This app packages SnappyMail <upstream>2.38.2.1</upstream>.
SnappyMail is a simple, modern, lightweight & fast web-based email client.

View file

@ -4,7 +4,7 @@ RUN mkdir -p /app/code
WORKDIR /app/code
# If you change the extraction below, be sure to test on scaleway
VERSION=2.38.2
VERSION=2.38.2.1
RUN wget https://github.com/the-djmaze/snappymail/releases/download/v${VERSION}/snappymail-${VERSION}.zip -O /tmp/snappymail.zip && \
unzip /tmp/snappymail.zip -d /app/code && \
rm /tmp/snappymail.zip && \

View file

@ -28,13 +28,7 @@
* Custom 'data' folder path
*/
if (class_exists('OC')) {
define('APP_DATA_FOLDER_PATH', \rtrim(\trim(
// \OCA\SnappyMail\Util\SnappyMailHelper::getSystemConfigValue('datadirectory', '')
\method_exists(\OC::$server, 'getSystemConfig')
? \OC::$server->getSystemConfig()->getValue('datadirectory', '')
// Nextcloud 34+
: \OC::$server->get(\OCP\IConfig::class)->getSystemValue('datadirectory', '')
), '\\/') . '/appdata_snappymail/');
define('APP_DATA_FOLDER_PATH', \rtrim(\trim(\OC::$server->getSystemConfig()->getValue('datadirectory', '')), '\\/').'/appdata_snappymail/');
} else {
http_response_code(400);
header($_SERVER['SERVER_PROTOCOL'].' 400 Bad Request', true, 400);

View file

@ -40,7 +40,7 @@ There, click on the link to go to the SnappyMail admin panel.
<repository>https://github.com/the-djmaze/snappymail/tree/master/integrations/nextcloud</repository>
<bugs>https://github.com/the-djmaze/snappymail/issues</bugs>
<dependencies>
<php min-version="8.2" min-int-size="32" />
<php min-version="7.4" min-int-size="32" />
<lib>mbstring</lib>
<lib>zlib</lib>
<!--
@ -63,9 +63,8 @@ There, click on the link to go to the SnappyMail admin panel.
<lib>uuid</lib>
<lib>xxtea</lib>
<lib>zip</lib>
<nextcloud min-version="25" max-version="33" />
-->
<nextcloud min-version="25" />
<nextcloud min-version="20" max-version="30" />
</dependencies>
<settings>
<admin>OCA\SnappyMail\Settings\AdminSettings</admin>

View file

@ -5,6 +5,7 @@ namespace OCA\SnappyMail\AppInfo;
use OCA\SnappyMail\Util\SnappyMailHelper;
use OCA\SnappyMail\Controller\FetchController;
use OCA\SnappyMail\Controller\PageController;
use OCA\SnappyMail\Dashboard\UnreadMailWidget;
use OCA\SnappyMail\Search\Provider;
use OCA\SnappyMail\Listeners\AccessTokenUpdatedListener;
@ -12,13 +13,12 @@ use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\IL10N;
use OCP\IUser;
use OCP\User\Events\PostLoginEvent;
use OCP\User\Events\BeforeUserLoggedOutEvent;
use OCA\OIDCLogin\Events\AccessTokenUpdatedEvent;
use Psr\Container\ContainerInterface;
class Application extends App implements IBootstrap
{
public const APP_ID = 'snappymail';
@ -30,27 +30,26 @@ class Application extends App implements IBootstrap
public function register(IRegistrationContext $context): void
{
// SnappyMailHelper::$server = $c->query('ServerContainer');
/**
* Controllers
*/
$context->registerService(
'PageController', function(ContainerInterface $c) {
'PageController', function($c) {
return new PageController(
$c->query('AppName'),
$c->query('Request'),
$c
$c->query('Request')
);
}
);
$context->registerService(
'FetchController', function(ContainerInterface $c) {
'FetchController', function($c) {
return new FetchController(
$c->query('AppName'),
$c->query('Request'),
$c
$c->getServer()->getAppManager(),
$c->query('ServerContainer')->getConfig(),
$c->query(IL10N::class)
);
}
);
@ -68,28 +67,27 @@ class Application extends App implements IBootstrap
$context->registerEventListener(AccessTokenUpdatedEvent::class, AccessTokenUpdatedListener::class);
// TODO: Not working yet, needs a Vue UI
// $context->registerDashboardWidget(\OCA\SnappyMail\Dashboard\UnreadMailWidget::class);
// $context->registerDashboardWidget(UnreadMailWidget::class);
}
public function boot(IBootContext $context): void
{
// SnappyMailHelper::$server = $context->getServerContainer();
if (!\is_dir(\rtrim(\trim(SnappyMailHelper::getSystemConfigValue('datadirectory', '')), '\\/') . '/appdata_snappymail')) {
if (!\is_dir(\rtrim(\trim(\OC::$server->getSystemConfig()->getValue('datadirectory', '')), '\\/') . '/appdata_snappymail')) {
return;
}
$dispatcher = $context->getAppContainer()->query(\OCP\EventDispatcher\IEventDispatcher::class);
$dispatcher = $context->getAppContainer()->query('OCP\EventDispatcher\IEventDispatcher');
$dispatcher->addListener(PostLoginEvent::class, function (PostLoginEvent $Event) {
/*
$config = \OC::$server->getConfig();
// Only store the user's password in the current session if they have
// enabled auto-login using Nextcloud username or email address.
if (SnappyMailHelper::getAppConfigValue('snappymail-autologin', false)
|| SnappyMailHelper::getAppConfigValue('snappymail-autologin-with-email', false)) {
if ($config->getAppValue('snappymail', 'snappymail-autologin', false)
|| $config->getAppValue('snappymail', 'snappymail-autologin-with-email', false)) {
*/
$sUID = $Event->getUser()->getUID();
SnappyMailHelper::setSessionValue('snappymail-nc-uid', $sUID);
SnappyMailHelper::setSessionValue('snappymail-passphrase', SnappyMailHelper::encodePassword($Event->getPassword(), $sUID));
\OC::$server->getSession()['snappymail-nc-uid'] = $sUID;
\OC::$server->getSession()['snappymail-passphrase'] = SnappyMailHelper::encodePassword($Event->getPassword(), $sUID);
/*
}
*/
@ -97,7 +95,7 @@ class Application extends App implements IBootstrap
$dispatcher->addListener(BeforeUserLoggedOutEvent::class, function (BeforeUserLoggedOutEvent $Event) {
// https://github.com/nextcloud/server/issues/36083#issuecomment-1387370634
// SnappyMailHelper::setSessionValue('snappymail-passphrase', '');
// \OC::$server->getSession()['snappymail-passphrase'] = '';
SnappyMailHelper::loadApp();
// \RainLoop\Api::Actions()->Logout(true);
\RainLoop\Api::Actions()->DoLogout();
@ -108,12 +106,12 @@ class Application extends App implements IBootstrap
$class = 'OCA\Impersonate\Events\BeginImpersonateEvent';
if (\class_exists($class)) {
$dispatcher->addListener($class, function ($Event) {
SnappyMailHelper::setSessionValue('snappymail-passphrase', '');
\OC::$server->getSession()['snappymail-passphrase'] = '';
SnappyMailHelper::loadApp();
\RainLoop\Api::Actions()->Logout(true);
});
$dispatcher->addListener('OCA\Impersonate\Events\EndImpersonateEvent', function ($Event) {
SnappyMailHelper::setSessionValue('snappymail-passphrase', '');
\OC::$server->getSession()['snappymail-passphrase'] = '';
SnappyMailHelper::loadApp();
\RainLoop\Api::Actions()->Logout(true);
});

View file

@ -3,14 +3,14 @@
namespace OCA\SnappyMail\Command;
use OCA\SnappyMail\Util\SnappyMailHelper;
use OC\Core\Command\Base;
use OCP\IConfig;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Settings extends Command
class Settings extends Base
{
protected IUserManager $userManager;
protected IConfig $config;

View file

@ -37,11 +37,7 @@ class ContentSecurityPolicy extends \OCP\AppFramework\Http\ContentSecurityPolicy
public function getSnappyMailNonce() {
static $sNonce;
if (!$sNonce) {
$cspManager = (\method_exists(\OC::$server, 'getContentSecurityPolicyNonceManager')
? \OC::$server->getContentSecurityPolicyNonceManager()
// Nextcloud 34+
: \OCP\Server::get(\OC\Security\CSP\ContentSecurityPolicyNonceManager::class)
);
$cspManager = \OC::$server->getContentSecurityPolicyNonceManager();
$sNonce = $cspManager->getNonce() ?: \SnappyMail\UUID::generate();
if (\method_exists($cspManager, 'browserSupportsCspV3') && !$cspManager->browserSupportsCspV3()) {
$this->addAllowedScriptDomain("'nonce-{$sNonce}'");

View file

@ -11,27 +11,26 @@ use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
class FetchController extends Controller
{
class FetchController extends Controller {
private IConfig $config;
private IL10N $l10n;
private IAppManager $appManager;
private IL10N $l;
public function __construct(string $appName, IRequest $request, \Psr\Container\ContainerInterface $container)
{
public function __construct(string $appName, IRequest $request, IAppManager $appManager, IConfig $config, IL10N $l) {
parent::__construct($appName, $request);
$this->config = $container->query(IConfig::class);
$this->l10n = $container->query(IL10N::class);
$this->config = $config;
$this->appManager = $appManager;
$this->l = $l;
}
public function upgrade(): JSONResponse
{
public function upgrade(): JSONResponse {
$error = 'Upgrade failed';
try {
SnappyMailHelper::loadApp();
if (\SnappyMail\Upgrade::core()) {
return new JSONResponse([
'status' => 'success',
'Message' => $this->l10n->t('Upgraded successfully')
'Message' => $this->l->t('Upgraded successfully')
]);
}
} catch (Exception $e) {
@ -43,8 +42,7 @@ class FetchController extends Controller
]);
}
public function setAdmin(): JSONResponse
{
public function setAdmin(): JSONResponse {
try {
$sUrl = '';
$sPath = '';
@ -59,7 +57,7 @@ class FetchController extends Controller
} else {
return new JSONResponse([
'status' => 'error',
'Message' => $this->l10n->t('Invalid argument(s)')
'Message' => $this->l->t('Invalid argument(s)')
]);
}
@ -89,7 +87,7 @@ class FetchController extends Controller
return new JSONResponse([
'status' => 'success',
'Message' => $this->l10n->t('Saved successfully')
'Message' => $this->l->t('Saved successfully')
]);
} catch (Exception $e) {
return new JSONResponse([
@ -102,27 +100,24 @@ class FetchController extends Controller
/**
* @NoAdminRequired
*/
public function setPersonal(): JSONResponse
{
public function setPersonal(): JSONResponse {
try {
$sEmail = '';
if (isset($_POST['appname'], $_POST['snappymail-password'], $_POST['snappymail-email']) && 'snappymail' === $_POST['appname']) {
$sUser = SnappyMailHelper::getUID();
// $sUser = $this->userSession->getUser()?->getUID();
if ($sUser) {
$sEmail = $_POST['snappymail-email'];
$this->config->setUserValue($sUser, 'snappymail', 'snappymail-email', $sEmail);
$sUser = \OC::$server->getUserSession()->getUser()->getUID();
$sPass = $_POST['snappymail-password'];
if ('******' !== $sPass) {
$this->config->setUserValue($sUser, 'snappymail', 'passphrase',
$sPass ? SnappyMailHelper::encodePassword($sPass, \md5($sEmail)) : '');
}
$sEmail = $_POST['snappymail-email'];
$this->config->setUserValue($sUser, 'snappymail', 'snappymail-email', $sEmail);
$sPass = $_POST['snappymail-password'];
if ('******' !== $sPass) {
$this->config->setUserValue($sUser, 'snappymail', 'passphrase',
$sPass ? SnappyMailHelper::encodePassword($sPass, \md5($sEmail)) : '');
}
} else {
return new JSONResponse([
'status' => 'error',
'Message' => $this->l10n->t('Invalid argument(s)'),
'Message' => $this->l->t('Invalid argument(s)'),
'Email' => $sEmail
]);
}
@ -133,7 +128,7 @@ class FetchController extends Controller
return new JSONResponse([
'status' => 'success',
'Message' => $this->l10n->t('Saved successfully'),
'Message' => $this->l->t('Saved successfully'),
'Email' => $sEmail
]);
} catch (Exception $e) {

View file

@ -8,28 +8,16 @@ use OCA\SnappyMail\ContentSecurityPolicy;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IConfig;
use OCP\INavigationManager;
use OCP\IRequest;
class PageController extends Controller
{
private IConfig $config;
private INavigationManager $navigationManager;
public function __construct($appName, IRequest $request, \Psr\Container\ContainerInterface $container)
{
parent::__construct($appName, $request);
$this->config = $container->query(IConfig::class);
$this->navigationManager = $container->query(INavigationManager::class);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function index()
{
$config = \OC::$server->getConfig();
$bAdmin = false;
if (!empty($_SERVER['QUERY_STRING'])) {
SnappyMailHelper::loadApp();
@ -39,8 +27,8 @@ class PageController extends Controller
}
}
if (!$bAdmin && SnappyMailHelper::getAppConfigValue('snappymail-no-embed')) {
$this->navigationManager->setActiveEntry('snappymail');
if (!$bAdmin && $config->getAppValue('snappymail', 'snappymail-no-embed')) {
\OC::$server->getNavigationManager()->setActiveEntry('snappymail');
\OCP\Util::addScript('snappymail', 'snappymail');
\OCP\Util::addStyle('snappymail', 'style');
SnappyMailHelper::startApp();
@ -55,7 +43,7 @@ class PageController extends Controller
return $response;
}
$this->navigationManager->setActiveEntry('snappymail');
\OC::$server->getNavigationManager()->setActiveEntry('snappymail');
\OCP\Util::addStyle('snappymail', 'embed');

View file

@ -3,13 +3,10 @@
namespace OCA\SnappyMail\Migration;
use OCA\SnappyMail\AppInfo\Application;
use OCP\App\IAppManager;
use OCP\IConfig;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
use Psr\Log\LoggerInterface;
use OCP\ILogger;
/**
* Note: this is not run at install, but when:
@ -18,24 +15,11 @@ use Psr\Log\LoggerInterface;
*/
class InstallStep implements IRepairStep
{
private IAppManager $appManager;
private IConfig $config;
private LoggerInterface $logger;
public function __construct(IAppManager $appManager, IConfig $config, LoggerInterface $logger)
{
$this->appManager = $appManager;
$this->config = $config;
$this->logger = $logger;
}
public function getName()
{
public function getName() {
return 'Setup SnappyMail';
}
public function run(IOutput $output)
{
public function run(IOutput $output) {
$output->info('clearstatcache');
\clearstatcache();
@ -69,7 +53,7 @@ class InstallStep implements IRepairStep
if (!$oConfig->Get('webmail', 'app_path')) {
$output->info('Set config [webmail]app_path');
$oConfig->Set('webmail', 'app_path', $this->appManager->getAppWebPath('snappymail') . '/app/');
$oConfig->Set('webmail', 'app_path', \OC::$server->getAppManager()->getAppWebPath('snappymail') . '/app/');
$oConfig->Set('webmail', 'allow_languages_on_settings', false);
$oConfig->Set('login', 'allow_languages_on_login', false);
$bSave = true;
@ -132,7 +116,9 @@ class InstallStep implements IRepairStep
// ex: php occ config:app:set snappymail custom_config_file --value="/path/to/config.php"
// https://github.com/the-djmaze/snappymail/pull/1197
try {
$customConfigFile = $this->config->getAppValue(Application::APP_ID, 'custom_config_file');
/** @var IConfig $ncConfig */
$ncConfig = \OC::$server->get(IConfig::class);
$customConfigFile = $ncConfig->getAppValue(Application::APP_ID, 'custom_config_file');
if ($customConfigFile) {
$output->info("Load custom config: {$customConfigFile}");
if (!\str_contains($customConfigFile, ':') && \is_readable($customConfigFile)) {
@ -143,7 +129,9 @@ class InstallStep implements IRepairStep
}
} catch (\Throwable $e) {
$output->warning("custom config error: " . $e->getMessage());
$this->logger->error("custom config error: " . $e->getMessage());
/** @var \Psr\Log\LoggerInterface $logger */
$logger = \OC::$server->get(\Psr\Log\LoggerInterface::class);
$logger->error("custom config error: " . $e->getMessage());
}
}
}

View file

@ -91,7 +91,8 @@ class Provider implements IProvider
$MessageCollection = $oMailClient->MessageList($oParams);
$baseURL = $this->urlGenerator->linkToRoute('snappymail.page.index');
// if (SnappyMailHelper::getAppConfigValue('snappymail-no-embed')) {
// $config = \OC::$server->getConfig();
// if ($config->getAppValue('snappymail', 'snappymail-no-embed')) {
// $baseURL .= '?target=';
// } else {
$baseURL .= '#';

View file

@ -6,37 +6,18 @@ use OCP\AppFramework\Http\TemplateResponse;
use OCP\IConfig;
use OCP\Settings\ISettings;
use OCP\IUserSession;
use OCP\IGroupManager;
use OCP\IURLGenerator;
use OCP\App\IAppManager;
use OCP\Util;
class AdminSettings implements ISettings
{
private IConfig $config;
private IUserSession $userSession;
private IGroupManager $groupManager;
private IURLGenerator $urlGenerator;
private IAppManager $appManager;
private $config;
public function __construct(
IConfig $config,
IUserSession $userSession,
IGroupManager $groupManager,
IURLGenerator $urlGenerator,
IAppManager $appManager
) {
public function __construct(IConfig $config)
{
$this->config = $config;
$this->userSession = $userSession;
$this->groupManager = $groupManager;
$this->urlGenerator = $urlGenerator;
$this->appManager = $appManager;
}
public function getForm()
{
SnappyMailHelper::loadApp();
\OCA\SnappyMail\Util\SnappyMailHelper::loadApp();
$keys = [
'snappymail-autologin',
@ -46,15 +27,15 @@ class AdminSettings implements ISettings
];
$parameters = [];
foreach ($keys as $k) {
$parameters[$k] = $this->config->getAppValue('snappymail', $k);
$v = $this->config->getAppValue('snappymail', $k);
$parameters[$k] = $v;
}
$uid = $this->userSession->getUser()?->getUID();
if ($uid && $this->groupManager->isAdmin($uid)) {
$uid = \OC::$server->getUserSession()->getUser()->getUID();
if (\OC_User::isAdminUser($uid)) {
// $parameters['snappymail-admin-panel-link'] = SnappyMailHelper::getAppUrl().'?admin';
SnappyMailHelper::loadApp();
$parameters['snappymail-admin-panel-link'] =
$this->urlGenerator->linkToRoute('snappymail.page.index')
\OC::$server->getURLGenerator()->linkToRoute('snappymail.page.index')
. '?' . \RainLoop\Api::Config()->Get('security', 'admin_panel_key', 'admin');
}
@ -68,7 +49,7 @@ class AdminSettings implements ISettings
$parameters['snappymail-admin-password'] = $sPassword;
$parameters['can-import-rainloop'] = $sPassword && \is_dir(
\rtrim(\trim($this->config->getSystemValue('datadirectory', '')), '\\/')
\rtrim(\trim(\OC::$server->getSystemConfig()->getValue('datadirectory', '')), '\\/')
. '/rainloop-storage'
);
@ -84,7 +65,7 @@ class AdminSettings implements ISettings
// Prevent "Failed loading /nextcloud/snappymail/v/2.N.N/static/js/min/libs.min.js"
$app_path = $oConfig->Get('webmail', 'app_path');
if (!$app_path) {
$app_path = $this->appManager->getAppWebPath('snappymail') . '/app/';
$app_path = \OC::$server->getAppManager()->getAppWebPath('snappymail') . '/app/';
$oConfig->Set('webmail', 'app_path', $app_path);
$oConfig->Set('webmail', 'theme', 'NextcloudV25+');
$oConfig->Save();

View file

@ -1,7 +1,6 @@
<?php
namespace OCA\SnappyMail\Settings;
use OCA\SnappyMail\Util\SnappyMailHelper;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IConfig;
use OCP\Settings\ISettings;
@ -17,7 +16,7 @@ class PersonalSettings implements ISettings
public function getForm()
{
$uid = SnappyMailHelper::getUID();
$uid = \OC::$server->getUserSession()->getUser()->getUID();
$sEmail = $this->config->getUserValue($uid, 'snappymail', 'snappymail-email');
if ($sPass = $this->config->getUserValue($uid, 'snappymail', 'snappymail-password')) {
$this->config->deleteUserValue($uid, 'snappymail', 'snappymail-password');

View file

@ -13,9 +13,9 @@ class RainLoop
* skips: /data/rainloop-storage/INSTALLED
* skips: /data/rainloop-storage/index.php
*/
public static function import(): array
public static function import() : array
{
$dir = \rtrim(\trim(SnappyMailHelper::getSystemConfigValue('datadirectory', '')), '\\/');
$dir = \rtrim(\trim(\OC::$server->getSystemConfig()->getValue('datadirectory', '')), '\\/');
$dir_snappy = $dir . '/appdata_snappymail/';
$dir_rainloop = $dir . '/rainloop-storage';
$result = [];

View file

@ -15,100 +15,10 @@ class SnappyMailResponse extends \OCP\AppFramework\Http\Response
}
}
/**
* Nextcloud 34+
* @CarlSchwan 2026-03-10
* refactor: remove long deprecated IServerContainer methods
*
* https://github.com/nextcloud/server/commit/39c14c383b698467711a2de09870d7c032d92f63
*/
class SnappyMailHelper
{
public static $server;
public static function getServerContainer(): object
{
return static::$server || \OC::$server;
}
public static function getConfig(): object
{
return \method_exists(\OC::$server, 'getConfig')
? \OC::$server->getConfig()
// Nextcloud 34+
: \OCP\Server::get(\OCP\IConfig::class);
}
public static function getAppConfigValue(string $key, mixed $default = null): object
{
return (\method_exists(\OC::$server, 'getConfig')
? \OC::$server->getConfig()
// Nextcloud 34+
: \OCP\Server::get(\OCP\IAppConfig::class)
)->getAppValue('snappymail', $key, $default);
}
public static function getNavigationManager(): object
{
return \method_exists(\OC::$server, 'getNavigationManager')
? \OC::$server->getNavigationManager()
// Nextcloud 32+
: \OCP\Server::get(\OCP\INavigationManager::class);
}
public static function getSystemConfigValue(string $key, mixed $default = null): mixed
{
return \method_exists(\OC::$server, 'getSystemConfig')
? \OC::$server->getSystemConfig()->getValue($key, $default)
// Nextcloud 34+
: \OCP\Server::get(\OCP\IConfig::class)->getSystemValue($key, $default);
}
public static function getUID(): mixed
{
return static::getUser()?->getUID();
}
public static function getUser(): object
{
return static::getUserSession()->getUser();
}
public static function getUserSession(): object
{
return (\method_exists(\OC::$server, 'getUserSession')
? \OC::$server->getUserSession()
// Nextcloud 34+
: \OCP\Server::get(\OCP\IUserSession::class)
);
}
public static function getSession(): object
{
return (\method_exists(\OC::$server, 'getSession')
? \OC::$server->getSession()
// Nextcloud 34+
: \OCP\Server::get(\OCP\ISession::class)
);
}
public static function getSessionValue(string $key): mixed
{
return static::getSession()->get($key);
}
public static function setSessionValue(string $key, mixed $value): void
{
if (\method_exists(\OC::$server, 'getSession')) {
\OC::$server->getSession()[$key] = $value;
} else {
// Nextcloud 34+
\OCP\Server::get(\OCP\ISession::class)->set($key, $value);
}
}
public static function loadApp(): void
public static function loadApp() : void
{
if (\class_exists('RainLoop\\Api')) {
return;
@ -127,13 +37,13 @@ class SnappyMailHelper
// define('APP_VERSION', '0.0.0');
// define('APP_INDEX_ROOT_PATH', __DIR__ . DIRECTORY_SEPARATOR);
// include APP_INDEX_ROOT_PATH.'snappymail/v/'.APP_VERSION.'/include.php';
// define('APP_DATA_FOLDER_PATH', \rtrim(\trim(static::getSystemConfigValue('datadirectory', '')), '\\/').'/appdata_snappymail/');
// define('APP_DATA_FOLDER_PATH', \rtrim(\trim(\OC::$server->getSystemConfig()->getValue('datadirectory', '')), '\\/').'/appdata_snappymail/');
$app_dir = \dirname(\dirname(__DIR__)) . '/app';
require_once $app_dir . '/index.php';
}
public static function startApp(bool $handle = false): void
public static function startApp(bool $handle = false)
{
static::loadApp();
@ -147,7 +57,7 @@ class SnappyMailHelper
$oActions = \RainLoop\Api::Actions();
if (isset($_GET[$oConfig->Get('security', 'admin_panel_key', 'admin')])) {
if ($oConfig->Get('security', 'allow_admin_panel', true)
&& \OC_User::isAdminUser(static::getUID())
&& \OC_User::isAdminUser(\OC::$server->getUserSession()->getUser()->getUID())
&& !$oActions->IsAdminLoggined(false)
) {
$sRand = \MailSo\Base\Utils::Sha1Rand();
@ -169,7 +79,7 @@ class SnappyMailHelper
// file_get_contents(\OC::$SERVERROOT . 'config/config.php');
// $CONFIG['version']
if (24 < $OC_Version[0]) {
$ocSession = static::getSession();
$ocSession = \OC::$server->getSession();
$ocSession->reopen();
if (!$doLogin && $ocSession['snappymail-uid'] && $ocSession['snappymail-uid'] != $aCredentials[0]) {
// UID changed, Impersonate plugin probably active
@ -182,6 +92,7 @@ class SnappyMailHelper
if ($doLogin && $aCredentials[1] && $aCredentials[2]) {
$isOIDC = \str_starts_with($aCredentials[2], 'oidc_login|');
try {
$ocSession = \OC::$server->getSession();
$oAccount = $oActions->LoginProcess($aCredentials[1], $aCredentials[2]);
if (!$isOIDC && $oAccount
&& $oConfig->Get('login', 'sign_me_auto', \RainLoop\Enumerations\SignMeType::DefaultOff) === \RainLoop\Enumerations\SignMeType::DefaultOn
@ -191,9 +102,9 @@ class SnappyMailHelper
} catch (\Throwable $e) {
// Login failure, reset password to prevent more attempts
if (!$isOIDC) {
$sUID = static::getUID();
static::setSessionValue('snappymail-passphrase', '');
static::getConfig()->setUserValue($sUID, 'snappymail', 'passphrase', '');
$sUID = \OC::$server->getUserSession()->getUser()->getUID();
\OC::$server->getSession()['snappymail-passphrase'] = '';
\OC::$server->getConfig()->setUserValue($sUID, 'snappymail', 'passphrase', '');
\SnappyMail\Log::error('Nextcloud', $e->getMessage());
}
}
@ -214,13 +125,14 @@ class SnappyMailHelper
// Check if OpenID Connect (OIDC) is enabled and used for login
// https://apps.nextcloud.com/apps/oidc_login
public static function isOIDCLogin(): bool
public static function isOIDCLogin() : bool
{
if (static::getAppConfigValue('snappymail-autologin-oidc', false)) {
$config = \OC::$server->getConfig();
if ($config->getAppValue('snappymail', 'snappymail-autologin-oidc', false)) {
// Check if the OIDC Login app is enabled
if (\OC::$server->getAppManager()->isEnabledForUser('oidc_login')) {
// Check if session is an OIDC Login
$ocSession = static::getSession();
$ocSession = \OC::$server->getSession();
if ($ocSession->get('is_oidc')) {
// IToken->getPassword() ???
if ($ocSession->get('oidc_access_token')) {
@ -237,11 +149,11 @@ class SnappyMailHelper
return false;
}
private static function getLoginCredentials(): array
private static function getLoginCredentials() : array
{
$sUID = static::getUID();
$config = static::getConfig();
$ocSession = static::getSession();
$sUID = \OC::$server->getUserSession()->getUser()->getUID();
$config = \OC::$server->getConfig();
$ocSession = \OC::$server->getSession();
// If the user has set credentials for SnappyMail in their personal settings,
// this has the first priority.
@ -264,8 +176,7 @@ class SnappyMailHelper
// If OpenID Connect (OIDC) is enabled and used for login, use this.
if (static::isOIDCLogin()) {
$sEmail = $config->getUserValue($sUID, 'settings', 'email');
$pwd = new \SnappyMail\SensitiveString("oidc_login|{$sUID}");
return [$sUID, $sEmail, $pwd];
return [$sUID, $sEmail, "oidc_login|{$sUID}"];
}
// Only use the user's password in the current session if they have
@ -291,12 +202,12 @@ class SnappyMailHelper
return [$sUID, '', ''];
}
public static function getAppUrl(): string
public static function getAppUrl() : string
{
return \OC::$server->getURLGenerator()->linkToRoute('snappymail.page.appGet');
}
public static function normalizeUrl(string $sUrl): string
public static function normalizeUrl(string $sUrl) : string
{
$sUrl = \rtrim(\trim($sUrl), '/\\');
if ('.php' !== \strtolower(\substr($sUrl, -4))) {
@ -306,13 +217,13 @@ class SnappyMailHelper
return $sUrl;
}
public static function encodePassword(string $sPassword, string $sSalt): string
public static function encodePassword(string $sPassword, string $sSalt) : string
{
static::loadApp();
return \SnappyMail\Crypt::EncryptUrlSafe($sPassword, $sSalt);
}
public static function decodePassword(string $sPassword, string $sSalt): ?\SnappyMail\SensitiveString
public static function decodePassword(string $sPassword, string $sSalt) : ?\SnappyMail\SensitiveString
{
static::loadApp();
$result = \SnappyMail\Crypt::DecryptUrlSafe($sPassword, $sSalt);

View file

@ -20,7 +20,7 @@ return "SnappyMail Webmail is a browser-based multilingual IMAP client with an a
# script_snappymail_versions()
sub script_snappymail_versions
{
return ( "2.38.2" );
return ( "2.38.2.1" );
}
sub script_snappymail_version_desc

View file

@ -3,7 +3,7 @@
"title": "SnappyMail",
"description": "Simple, modern & fast web-based email client",
"private": true,
"version": "2.38.2",
"version": "2.38.2.1",
"homepage": "https://snappymail.eu",
"author": {
"name": "DJ Maze",
@ -43,11 +43,10 @@
"dependencies": {
"@rollup/plugin-node-resolve": "^13.1.3",
"@rollup/plugin-replace": "^3.0.1",
"@rollup/plugin-terser": "^1.0.2",
"babel-eslint": "^10.1.0",
"del": "^6.0.0",
"eslint": "^7.32.0",
"gulp": "^5.0.1",
"gulp": "^5.0.0",
"gulp-append-prepend": "^1.0.9",
"gulp-cached": "^1.1.1",
"gulp-clean-css": "^4.3.0",
@ -65,6 +64,7 @@
"gulp-terser": "^2.1.0",
"rollup": "^2.56.3",
"rollup-plugin-external-globals": "^0.6.1",
"rollup-plugin-includepaths": "^0.2.4"
"rollup-plugin-includepaths": "^0.2.4",
"rollup-plugin-terser": "^7.0.2"
}
}

View file

@ -38,10 +38,7 @@
queue = [],
avatars = new Map,
ncAvatars = new Map,
identicons = new Map,
templateId = 'MailMessageView',
b64 = data => btoa(unescape(encodeURIComponent(data))),
b64url = data => b64(data).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
getBimiSelector = msg => {
// Get 's' value out of 'v=BIMI1; s=foo;'
let bimiSelector = msg.headers().valueByName('BIMI-Selector');
@ -49,21 +46,9 @@
return bimiSelector || '';
},
getBimiId = msg => ('pass' == msg.from[0].dkimStatus ? 1 : 0) + '-' + getBimiSelector(msg),
email = msg => msg.from[0].email.toLowerCase(),
getAvatarUid = msg => `${getBimiId(msg)}/${email(msg)}`,
getAvatar = msg => ncAvatars.get(email(msg)) || avatars.get(getAvatarUid(msg)),
getAvatarUri = msg => {
if ('remote' === msg.avatar) {
msg.avatar = `?Avatar/${getBimiId(msg)}/${b64url(email(msg))}`;
}
/*
else if (!msg.avatar.startsWith('data:')) {
msg.avatar = null;
}
*/
return msg.avatar;
},
getAvatarUrl = msg => `?Avatar/${getBimiId(msg)}/${msg.avatar}`,
getAvatarUid = msg => `${getBimiId(msg)}/${msg.from[0].email.toLowerCase()}`,
getAvatar = msg => ncAvatars.get(msg.from[0].email.toLowerCase()) || avatars.get(getAvatarUid(msg)),
hash = async txt => {
if (/^[0-9a-f]{15,}$/i.test(txt)) {
return txt;
@ -79,56 +64,58 @@
(from.name?.split(/[^\p{L}]+/gu) || []).reduce((a, s) => a + (s[0] || ''), '')
.slice(0,2)
.toUpperCase(),
setIdenticon = (msg, fn, cb) => {
const from = email(msg);
if (identicons.get(from)) {
fn(identicons.get(from));
} else {
hash(from).then(hash => {
const uri = 'data:image/svg+xml;base64,' + b64(window.identiconSvg(
hash,
fromChars(msg.from[0]),
window.getComputedStyle(getEl('rl-app'), null).getPropertyValue('font-family')
));
fn(uri);
identicons.set(email(msg), uri);
cb?.(uri);
});
}
},
setIdenticon = (from, fn) => hash(from.email).then(hash =>
fn('data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(window.identiconSvg(
hash,
fromChars(from),
window.getComputedStyle(getEl('rl-app'), null).getPropertyValue('font-family')
)))))
),
addQueue = (msg, fn) => {
if (msg.from?.[0]) {
if (getAvatarUri(msg)) {
if (rl.pluginSettingsGet('avatars', 'delay')) {
setIdenticon(msg, fn, ()=>{
queue.push([msg, fn]);
runQueue();
});
} else {
fn(msg.avatar);
}
} else {
setIdenticon(msg, fn);
}
msg.from?.[0] && setIdenticon(msg.from[0], fn);
if (rl.pluginSettingsGet('avatars', 'delay')) {
queue.push([msg, fn]);
runQueue();
} else if (msg.avatar) {
fn(getAvatarUrl(msg));
}
},
runQueue = (() => {
let item = queue.shift();
while (item) {
let url = getAvatar(item[0]);
if (url) {
item[1](url);
item = queue.shift();
continue;
} else if (item[0].avatar) {
item[1](item[0].avatar);
if (item[0].from) {
let url = getAvatar(item[0]),
uid = getAvatarUid(item[0]);
if (url) {
item[1](url);
item = queue.shift();
continue;
} else if (item[0].avatar) {
item[1](getAvatarUrl(item[0]));
} else if (!avatars.has(uid)) {
let from = item[0].from[0];
rl.pluginRemoteRequest((iError, data) => {
if (!iError && data?.Result.type) {
url = `data:${data.Result.type};base64,${data.Result.data}`;
avatars.set(uid, url);
item[1](url);
} else {
avatars.set(uid, '');
}
runQueue();
}, 'Avatar', {
bimi: 'pass' == from.dkimStatus ? 1 : 0,
bimiSelector: getBimiSelector(item[0]),
email: from.email
});
}
}
runQueue();
break;
}
}).debounce(1000);
// addEventListener('DOMContentLoaded', () => {
/**
* Modify templates
*/
@ -201,19 +188,13 @@
fn = url=>{element.src = url};
element.onerror = ()=>{
element.onerror = null;
setIdenticon(msg, fn, uri=>avatars.set(getAvatarUid(msg), uri));
setIdenticon(msg.from[0], fn);
};
if (url) {
fn(url);
} else if (msg.avatar?.startsWith('data:')) {
fn(msg.avatar);
} else {
element.onload = ()=>{
if (!element.src.startsWith('data:')) {
element.onload = null;
avatars.set(getAvatarUid(msg), element.src);
}
};
addQueue(msg, fn);
}
}
@ -240,8 +221,11 @@
if (url) {
fn(url);
} else if (msg.avatar) {
fn(getAvatarUri(msg));
fn(msg.avatar.startsWith('data:') ? msg.avatar : getAvatarUrl(msg));
} else {
// let from = msg.from[0];
// view.viewUserPic(`?Avatar/${'pass' == from.dkimStatus ? 1 : 0}/${encodeURIComponent(from.email)}`);
// view.viewUserPicVisible(true);
addQueue(msg, fn);
}
}

View file

@ -10,12 +10,12 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
NAME = 'Avatars',
AUTHOR = 'SnappyMail',
URL = 'https://snappymail.eu/',
VERSION = '1.22',
RELEASE = '2025-03-10',
VERSION = '1.20',
RELEASE = '2024-08-26',
REQUIRED = '2.33.0',
CATEGORY = 'Contacts',
LICENSE = 'MIT',
DESCRIPTION = 'Show graphic of sender in message and messages list (supports Contacts, BIMI, Gravatar, favicon and identicon)';
DESCRIPTION = 'Show graphic of sender in message and messages list (supports BIMI, Gravatar, favicon and identicon, Contacts is still TODO)';
public function Init() : void
{
@ -79,8 +79,11 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
|| ($this->Config()->Get('plugin', 'bimi', false) && 'pass' == $mFrom['dkimStatus'])
|| ($this->Config()->Get('plugin', 'favicon', false) && 'pass' == $mFrom['dkimStatus'])
)
) {
return 'remote';
) try {
// Base64Url
return \SnappyMail\Crypt::EncryptUrlSafe($mFrom['email']);
} catch (\Throwable $e) {
\SnappyMail\Log::error('Crypt', $e->getMessage());
}
if ('pass' == $mFrom['dkimStatus'] && $this->Config()->Get('plugin', 'service', true)) {
// 'data:image/png;base64,[a-zA-Z0-9+/=]'
@ -109,17 +112,17 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
}
/**
* GET /?Avatar/${bimi}/Encoded(${from.email})
* Nextcloud Mail uses insecure unencoded 'index.php/apps/mail/api/avatars/url/local%40example.com'
* GET /?Avatar/${bimi}/Encrypted(${from.email})
* Nextcloud Mail uses insecure unencrypted 'index.php/apps/mail/api/avatars/url/local%40example.com'
*/
// public function ServiceAvatar(...$aParts)
public function ServiceAvatar(string $sServiceName, string $sBimi, string $sEncodedEmail)
public function ServiceAvatar(string $sServiceName, string $sBimi, string $sEncryptedEmail)
{
$maxAge = 86400;
$sEmail = \MailSo\Base\Utils::UrlSafeBase64Decode($sEncodedEmail);
$sEmail = \SnappyMail\Crypt::DecryptUrlSafe($sEncryptedEmail);
$aBimi = \explode('-', $sBimi, 2);
$sBimiSelector = isset($aBimi[1]) ? $aBimi[1] : 'default';
// $sEmail && \MailSo\Base\Http::setETag("{$sBimiSelector}-{$sEncodedEmail}");
// $sEmail && \MailSo\Base\Http::setETag("{$sBimiSelector}-{$sEncryptedEmail}");
if ($sEmail && ($aResult = $this->getAvatar($sEmail, !empty($aBimi[0]), $sBimiSelector))) {
\header("Cache-Control: max-age={$maxAge}, private");
\header('Expires: '.\gmdate('D, j M Y H:i:s', $maxAge + \time()).' UTC');
@ -139,10 +142,6 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetAllowedInJs(true)
->SetDefaultValue(true),
\RainLoop\Plugins\Property::NewInstance('contacts')->SetLabel('Contacts')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDescription('Use the PHOTO stored on a matching address book contact')
->SetDefaultValue(true),
\RainLoop\Plugins\Property::NewInstance('bimi')->SetLabel('BIMI')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(false)
@ -189,8 +188,11 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
return $aResult;
}
private static function getServicePng(string $sDomain) : ?string
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid.
private static function getServiceIcon(string $sEmail) : ?string
{
$sDomain = \explode('@', $sEmail);
$sDomain = \array_pop($sDomain);
$aServices = [
"services/{$sDomain}",
'services/' . static::serviceDomain($sDomain)
@ -198,20 +200,9 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
foreach ($aServices as $service) {
$file = __DIR__ . "/images/{$service}.png";
if (\file_exists($file)) {
return $file;
return 'data:image/png;base64,' . \base64_encode(\file_get_contents($file));
}
}
return null;
}
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid.
private static function getServiceIcon(string $sEmail) : ?string
{
$aParts = \explode('@', $sEmail);
$file = static::getServicePng(\array_pop($aParts));
if ($file) {
return 'data:image/png;base64,' . \base64_encode(\file_get_contents($file));
}
$aResult = static::getCachedImage($sEmail);
if ($aResult) {
@ -234,23 +225,30 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
\header('Cache-Control: private');
// \header('Expires: '.\gmdate('D, j M Y H:i:s', \time() + 86400).' UTC');
// A photo stored on the contact is authoritative, so it is consulted
// before the cache and before any network lookup: it beats a guessed
// avatar and replaces up to five remote round-trips. Deliberately not
// written to the avatars cache, which has no expiry - a cached copy
// would shadow later edits to the contact.
if ($this->Config()->Get('plugin', 'contacts', true)) {
$aResult = static::getContactPhoto($sAsciiEmail);
if ($aResult) {
return $aResult;
}
}
$aResult = static::getCachedImage($sEmail);
if ($aResult) {
return $aResult;
}
// TODO: lookup contacts vCard and return PHOTO value
/*
if (!$aResult) {
$oActions = \RainLoop\Api::Actions();
$oAccount = $oActions->getAccountFromToken();
if ($oAccount) {
$oAddressBookProvider = $oActions->AddressBookProvider($oAccount);
if ($oAddressBookProvider) {
$oContact = $oAddressBookProvider->GetContactByEmail($sEmail);
if ($oContact && $oContact->vCard && $oContact->vCard['PHOTO']) {
$aResult = [
'text/vcard',
$oContact->vCard
];
}
}
}
}
*/
if (!$aResult) {
$sDomain = \explode('@', $sEmail);
@ -270,7 +268,7 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
}
if ($this->Config()->Get('plugin', 'gravatar', false)) {
$aUrls[] = 'https://gravatar.com/avatar/'.\hash('sha256', \strtolower($sAsciiEmail)).'?s=80&d=404';
$aUrls[] = 'https://gravatar.com/avatar/'.\md5(\strtolower($sAsciiEmail)).'?s=80&d=404';
}
foreach ($aUrls as $sUrl) {
@ -286,13 +284,20 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid.
if ($bBimi && !$aResult) {
$file = static::getServicePng($sDomain);
if ($file) {
\MailSo\Base\Http::setLastModified(\filemtime($file));
$aResult = [
'image/png',
\file_get_contents($file)
];
$aServices = [
"services/{$sDomain}",
'services/' . static::serviceDomain($sDomain)
];
foreach ($aServices as $service) {
$file = __DIR__ . "/images/{$service}.png";
if (\file_exists($file)) {
\MailSo\Base\Http::setLastModified(\filemtime($file));
$aResult = [
'image/png',
\file_get_contents($file)
];
break;
}
}
if (!$aResult && $this->Config()->Get('plugin', 'favicon', false)) {
@ -313,77 +318,6 @@ class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
return $sDomain;
}
/**
* Return the PHOTO of the address book contact owning this address.
*
* vCard 4.0 stores it as a data: URI, vCard 3.0 as ENCODING=b with a TYPE
* parameter, and either version may point at an external URL instead.
*/
private static function getContactPhoto(string $sEmail) : ?array
{
try {
$oActions = \RainLoop\Api::Actions();
$oAccount = $oActions->getAccountFromToken(false);
if (!$oAccount) {
return null;
}
$oProvider = $oActions->AddressBookProvider($oAccount);
if (!$oProvider || !$oProvider->IsActive()) {
return null;
}
// Several contacts can share an address - a synced card, a manual
// entry, an auto-collected one - and only one of them may carry the
// photo. GetContactByEmail() returns whichever matches first, so
// fall back to scanning the matches for one that actually has it.
$oPhoto = null;
$oContact = $oProvider->GetContactByEmail($sEmail);
if ($oContact && $oContact->vCard && isset($oContact->vCard->PHOTO)) {
$oPhoto = $oContact->vCard->PHOTO;
} else {
$iCount = 0;
foreach ($oProvider->GetContacts(0, 20, $sEmail, $iCount) as $oCandidate) {
if ($oCandidate->vCard && isset($oCandidate->vCard->PHOTO)) {
$oPhoto = $oCandidate->vCard->PHOTO;
break;
}
}
}
if (!$oPhoto) {
return null;
}
$sValue = \trim((string) $oPhoto);
if (!\strlen($sValue)) {
return null;
}
// vCard 4.0: data:image/jpeg;base64,...
if (\preg_match('#^data:(image/[a-z0-9.+-]+);base64,(.+)$#si', $sValue, $aMatch)) {
$sBinary = \base64_decode(\preg_replace('#\s+#', '', $aMatch[2]), true);
return $sBinary ? [\strtolower($aMatch[1]), $sBinary] : null;
}
// vCard 3.0: PHOTO;ENCODING=b;TYPE=JPEG:<base64>
$sEncoding = \strtolower((string) ($oPhoto['ENCODING'] ?? ''));
if ('b' === $sEncoding || 'base64' === $sEncoding) {
$sBinary = \base64_decode(\preg_replace('#\s+#', '', $sValue), true);
if ($sBinary) {
$sType = \strtolower((string) ($oPhoto['TYPE'] ?? '')) ?: 'jpeg';
return [\str_starts_with($sType, 'image/') ? $sType : "image/{$sType}", $sBinary];
}
return null;
}
// Either version may reference the image instead of embedding it.
if (\preg_match('#^https?://#i', $sValue)) {
return static::getUrl($sValue);
}
} catch (\Throwable $oException) {
\SnappyMail\Log::notice('Avatar', 'contact photo lookup failed: ' . $oException->getMessage());
}
return null;
}
private static function cacheImage(string $sEmail, array $aResult) : void
{
if (!\is_dir(\APP_PRIVATE_DATA . 'avatars')) {

View file

@ -1,13 +0,0 @@
[SETTINGS_CHANGE_PASSWORD]
LEGEND_CHANGE_PASSWORD = "Змінити пароль"
LABEL_CURRENT_PASSWORD = "Поточний пароль"
LABEL_NEW_PASSWORD = "Новий пароль"
LABEL_REPEAT_PASSWORD = "Підтвердьте новий пароль"
BUTTON_UPDATE_PASSWORD = "Змінити"
ERROR_PASSWORD_MISMATCH = "Паролі не збігаються, спробуйте ще раз"
[NOTIFICATIONS]
COULD_NOT_SAVE_NEW_PASSWORD = "Не вдалося зберегти новий пароль"
CURRENT_PASSWORD_INCORRECT = "Поточний пароль не вірний"
NEW_PASSWORD_SHORT = "Пароль закороткий"
NEW_PASSWORD_WEAK = "Пароль надто легкий"
NEW_PASSWORD_HIBP = "Пароль знайдено в сервісі Have I Been Pwned"

View file

@ -6,7 +6,7 @@ class CompactComposerPlugin extends \RainLoop\Plugins\AbstractPlugin
NAME = 'Compact Composer',
AUTHOR = 'Sergey Mosin',
URL = 'https://github.com/the-djmaze/snappymail/pull/1466',
VERSION = '1.0.6',
VERSION = '1.0.5',
RELEASE = '2024-08-08',
REQUIRED = '2.34.0',
LICENSE = 'AGPL v3',

View file

@ -100,6 +100,10 @@
}
},
pasteSanitizer = (event) => {
return rl.Utils.cleanHtml(event.detail.html).html;
},
pasteImageHandler = (e, squire) => {
const items = [...e.detail.clipboardData.items];
@ -138,12 +142,7 @@
toolbar.className = 'squire-toolbar btn-toolbar';
const actions = this.makeActions(squire, toolbar);
this.squire.addEventListener('willPaste', (event) => {
// https://github.com/fastmail/Squire?tab=readme-ov-file#addeventlistener
// The content that will be inserted is available as either the fragment property, or the text property for plain text, on the detail property of the event. You can modify this text/fragment in your event handler to change what will be pasted
tpl.innerHTML = rl.Utils.cleanHtml(event.detail.html).html;
event.detail.fragment = tpl.content;
});
this.squire.addEventListener('willPaste', pasteSanitizer);
this.squire.addEventListener('pasteImage', (e) => {
pasteImageHandler(e, squire);
});

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,8 @@ class ICSViewerPlugin extends \RainLoop\Plugins\AbstractPlugin
const
NAME = 'ICS Viewer',
AUTHOR = 'PhieF',
VERSION = '1.1',
RELEASE = '2026-10-22',
VERSION = '1.0',
RELEASE = '2024-09-17',
CATEGORY = 'Messages',
DESCRIPTION = 'Display ICS attachment using ical lib, or JSON-LD details, based on viewICS',
REQUIRED = '2.34.0';

View file

@ -86,17 +86,10 @@
});
}
});
// ICS attachment.
// attachments is a ko.observableArray, so it must be
// unwrapped before array methods are used on it.
// A scheduling mail does not always carry a text/calendar
// attachment either: the part is often inline inside
// multipart/alternative and repeated as application/ics.
let ics = (msg.attachments() || []).find(attachment =>
'text/calendar' == attachment.mimeType
|| 'application/ics' == attachment.mimeType
|| 'text/x-vcalendar' == attachment.mimeType
|| /\.ics$/i.test(attachment.fileName || ''));
// ICS attachment
// let ics = msg.attachments.find(attachment => 'application/ics' == attachment.mimeType);
let ics = msg.attachments.find(attachment => 'text/calendar' == attachment.mimeType);
if (ics && ics.download) {
// fetch it and parse the VEVENT

View file

@ -139,41 +139,29 @@ class LdapContactsSuggestions implements \RainLoop\Providers\Suggestions\ISugges
$sFilter .= (1 < count($aItems) ? '(|' : '').$sSubFilter.(1 < count($aItems) ? ')' : '');
$sFilter .= ')';
// A directory rarely keeps everything worth suggesting in one branch:
// meeting rooms and other bookable resources commonly live outside
// the people branch. Searching a single subtree either misses them,
// or - if the base is widened to the domain root - drags every
// service account into the suggestion list. Base DNs are therefore
// separated by '|', which cannot appear unescaped in a DN, so an
// existing single-branch configuration keeps working unchanged.
$aBaseDns = \array_filter(\array_map('trim', \explode('|', $sBaseDn)), 'strlen');
$this->logWrite('ldap_search: start: '.$sBaseDn.' / '.$sFilter, \LOG_INFO, 'LDAP');
$oS = @\ldap_search($oCon, $sBaseDn, $sFilter, $aItems, 0, 30, 30);
if ($oS) {
$aEntries = @\ldap_get_entries($oCon, $oS);
if (is_array($aEntries)) {
if (isset($aEntries['count'])) {
unset($aEntries['count']);
}
foreach ($aBaseDns as $sOneBaseDn) {
$this->logWrite('ldap_search: start: '.$sOneBaseDn.' / '.$sFilter, \LOG_INFO, 'LDAP');
$oS = @\ldap_search($oCon, $sOneBaseDn, $sFilter, $aItems, 0, 30, 30);
if ($oS) {
$aEntries = @\ldap_get_entries($oCon, $oS);
if (is_array($aEntries)) {
if (isset($aEntries['count'])) {
unset($aEntries['count']);
}
foreach ($aEntries as $aItem) {
if ($aItem) {
$sName = $sEmail = '';
list ($sEmail, $sName) = $this->findNameAndEmail($aItem, $aEmails, $aNames, $aUIDs);
if (!empty($sEmail)) {
$aResult[] = array($sEmail, $sName);
}
foreach ($aEntries as $aItem) {
if ($aItem) {
$sName = $sEmail = '';
list ($sEmail, $sName) = $this->findNameAndEmail($aItem, $aEmails, $aNames, $aUIDs);
if (!empty($sEmail)) {
$aResult[] = array($sEmail, $sName);
}
}
} else {
$this->logLdapError($oCon, 'ldap_get_entries');
}
} else {
// One unreachable branch must not silence the others.
$this->logLdapError($oCon, 'ldap_search ('.$sOneBaseDn.')');
$this->logLdapError($oCon, 'ldap_get_entries');
}
} else {
$this->logLdapError($oCon, 'ldap_search');
}
}

View file

@ -4,8 +4,8 @@ class LdapContactsSuggestionsPlugin extends \RainLoop\Plugins\AbstractPlugin
{
const
NAME = 'Contacts suggestions (LDAP)',
VERSION = '2.15',
RELEASE = '2026-08-19',
VERSION = '2.14',
RELEASE = '2024-03-12',
REQUIRED = '2.35.3',
CATEGORY = 'Contacts',
DESCRIPTION = 'Get contacts suggestions from LDAP.';
@ -79,7 +79,7 @@ class LdapContactsSuggestionsPlugin extends \RainLoop\Plugins\AbstractPlugin
->SetType(\RainLoop\Enumerations\PluginPropertyType::PASSWORD)
->SetDefaultValue(''),
\RainLoop\Plugins\Property::NewInstance('base_dn')->SetLabel('Search base DN')
->SetDescription('DN to use as the search base. Supported tokens: {domain}, {domain:dc}, {email}, {email:user}, {email:domain}, {login}, {imap:login}, {imap:host}, {imap:port} Several branches may be given, separated by | - useful when meeting rooms or other resources live outside the people branch.')
->SetDescription('DN to use as the search base. Supported tokens: {domain}, {domain:dc}, {email}, {email:user}, {email:domain}, {login}, {imap:login}, {imap:host}, {imap:port}')
->SetDefaultValue('ou=People,dc=example,dc=com'),
\RainLoop\Plugins\Property::NewInstance('object_classes')->SetLabel('objectClasses')
->SetDescription('LDAP objectClasses to search for, comma separated list')

View file

@ -1 +1 @@
This plugins allows to authenticate using LDAP mail field, yet login field would still be used for IMAP and SMTP authentication.
Plugin which allows you to set up LDAP username by email address (when IMAP/SMTP requires it)

View file

@ -8,12 +8,12 @@ class LDAPLoginMappingPlugin extends AbstractPlugin
{
const
NAME = 'LDAP login mapping',
VERSION = '2.4',
VERSION = '2.3',
AUTHOR = 'RainLoop Team, Ludovic Pouzenc<ludovic@pouzenc.fr>, ZephOne<zephone@protonmail.com>',
RELEASE = '2025-01-09',
RELEASE = '2024-09-20',
REQUIRED = '2.36.1',
CATEGORY = 'Login',
DESCRIPTION = 'This plugins allows to authenticate using LDAP mail field, yet login field would still be used for IMAP and SMTP authentication';
DESCRIPTION = 'Enable custom mapping using ldap field';
/**
* @var array
*/
@ -104,10 +104,9 @@ class LDAPLoginMappingPlugin extends AbstractPlugin
$sResult = $this->ldapSearch($sEmail);
if ( is_array($sResult) ) {
$sImapUser = $sResult['login'];
$sSmtpUser = $sResult['login'];
$sEmail = $sResult['email'];
}
syslog(LOG_WARNING, "plugins/ldap-login-mapping/index.php:FilterLoginCredentials() auth try: $sIP/$sEmail, resolved as $sImapUser/$sEmail");
syslog(LOG_WARNING, "plugins/ldap-login-mapping/index.php:FilterLoginСredentials() auth try: $sIP/$sEmail, resolved as $sImapUser/$sEmail");
}
}

View file

@ -1,20 +0,0 @@
The MIT License (MIT)
Copyright (c) 2016 RainLoop Team
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,39 +0,0 @@
<?php
class LoginHttpPlugin extends \RainLoop\Plugins\AbstractPlugin
{
const
NAME = 'Login HTTP',
AUTHOR = 'Alterative Editions',
URL = 'https://alterative.fr/',
VERSION = '1.0',
RELEASE = '2026-02-09',
REQUIRED = '2.21.0',
CATEGORY = 'Login',
LICENSE = 'MIT',
DESCRIPTION = 'Automatic HTTP Login : uses PHP_AUTH_USER & PHP_AUTH_PW for IMAP connection';
public function Init() : void
{
$this->addPartHook('ExternalHTTP', 'ServiceExternalHTTP');
}
public function ServiceExternalHTTP() : string
{
$oActions = \RainLoop\Api::Actions();
$oActions->Http()->ServerNoCache();
$sUser=$_SERVER['PHP_AUTH_USER'] ?? '';
$sPassword=$_SERVER['PHP_AUTH_PW'] ?? '';
try {
$oAccount = $oActions->LoginProcess($sUser, new \SnappyMail\SensitiveString($sPassword));
if ($oAccount) {
\MailSo\Base\Http::Location('./');
return true;
}
} catch (\Throwable $e) {
// Log l'erreur si besoin
}
\MailSo\Base\Http::Location('./?ExternalHTTP');
return true;
}
}

View file

@ -1,91 +1,54 @@
((rl) => {
const client_id = rl.pluginSettingsGet("login-o365", "client_id"),
allowAnyDomain = !!rl.pluginSettingsGet("login-o365", "allow_any_domain"),
isSupportedEmail = (email) => {
email = (email || "").toLowerCase();
if (!email.includes("@")) return false;
if (allowAnyDomain) return true;
return /@(outlook\.com|hotmail\.com|live\.com)$/.test(email);
},
startOAuth = (op, opts = {}) => {
const email = (opts.email || "").toLowerCase();
const name = opts.name || "";
const returnHash = opts.return || "";
(rl => {
const client_id = rl.pluginSettingsGet('login-o365', 'client_id'),
// https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris
query = rl.pluginSettingsGet('login-o365', 'personal') ? '' : '?',
tenant = rl.pluginSettingsGet('login-o365', 'tenant'),
login = () => {
document.location = 'https://login.microsoftonline.com/'+tenant+'/oauth2/v2.0/authorize?' + (new URLSearchParams({
response_type: 'code',
client_id: client_id,
redirect_uri: document.location.href.replace(/\/$/, '') + '/' + query + 'LoginO365',
scope: [
// Associate personal info
'openid',
'offline_access',
'email',
'profile',
// Access IMAP and SMTP through OAUTH
'https://graph.microsoft.com/IMAP.AccessAsUser.All',
// 'https://graph.microsoft.com/Mail.ReadWrite'
'https://graph.microsoft.com/Mail.Send'
/* // Legacy:
'https://outlook.office.com/SMTP.Send',
'https://outlook.office.com/IMAP.AccessAsUser.All'
*/
].join(' '),
state: 'o365', // + rl.settings.app('token') + localStorage.getItem('smctoken')
// Force authorize screen, so we always get a refresh_token
access_type: 'offline',
prompt: 'consent'
}));
};
// Server mints a signed state + correct redirect_uri and returns full authUrl.
rl.pluginRemoteRequest((iError, data) => {
const url = data?.Result?.authUrl;
if (!iError && url) {
document.location = url;
}
}, "LoginO365AuthUrl", {
op: op,
email: email,
name: name,
return: returnHash,
});
};
if (client_id) {
addEventListener('sm-user-login', e => {
if (event.detail.get('Email').includes('@hotmail.com')) {
e.preventDefault();
login();
}
});
if (client_id) {
addEventListener("sm-user-login", (e) => {
const email = (e.detail.get("Email") || "").toLowerCase();
if (isSupportedEmail(email)) {
e.preventDefault();
startOAuth("login", { email });
}
});
addEventListener('rl-view-model', e => {
if ('Login' === e.detail.viewModelTemplateID) {
const
container = e.detail.viewModelDom.querySelector('#plugin-Login-BottomControlGroup'),
btn = Element.fromHTML('<button type="button">Outlook</button>'),
div = Element.fromHTML('<div class="controls"></div>');
btn.onclick = login;
div.append(btn);
container && container.append(div);
}
});
}
addEventListener("rl-view-model", (e) => {
if ("Login" === e.detail.viewModelTemplateID) {
const
container = e.detail.viewModelDom.querySelector("#plugin-Login-BottomControlGroup"),
btn = Element.fromHTML('<button type="button">Outlook</button>'),
div = Element.fromHTML('<div class="controls"></div>');
btn.onclick = () => {
// Best-effort: try to read the email field if present.
const input = e.detail.viewModelDom.querySelector('input[type="email"], input[name="Email"], input[name="email"], input');
const email = (input?.value || "").toLowerCase();
if (!email || isSupportedEmail(email)) {
startOAuth("login", { email });
}
};
div.append(btn);
container && container.append(div);
}
// "Add account" popup (Settings → Accounts → Add account)
if ("PopupsAccount" === e.detail.viewModelTemplateID) {
// Only for the "Add account" mode, not "Edit account".
if (typeof e.detail.isNew === "function" && !e.detail.isNew()) {
return;
}
const root = e.detail.viewModelDom;
if (!root) return;
const footer = root.querySelector("footer");
const form = root.querySelector("#accountform");
const addButton = root.querySelector("button.buttonAddAccount");
if (!footer || !form || !addButton) return;
// Avoid inserting duplicates when view model is re-rendered.
if (root.querySelector(".plugin-o365-add-account")) return;
const btn = Element.fromHTML(
'<button type="button" class="btn plugin-o365-add-account" style="margin-left: 6px;">Outlook</button>'
);
btn.onclick = () => {
const email = (form.querySelector('input[name="email"]')?.value || "").trim().toLowerCase();
const name = (form.querySelector('input[name="name"]')?.value || "").trim();
if (!email || !isSupportedEmail(email)) {
return;
}
startOAuth("add", { email, name, return: document.location.hash || "#/settings/accounts" });
};
// Put the button next to the default Add Account submit button.
footer.insertBefore(btn, addButton.nextSibling);
}
});
}
})(window.rl);

View file

@ -1,20 +1,15 @@
<?php
/**
* SnappyMail login-o365 plugin
* You need to register an app in Azure portal and add
* a secret, redirect URIs and the following API permissions:
* https://outlook.office.com/IMAP.AccessAsUser.All
* https://outlook.office.com/SMTP.Send
* openid offline_access email profile
* Microsoft requires an Azure account that has an active subscription
* I'm not going to pay, so feel free to fix this code yourself.
* https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
* https://answers.microsoft.com/en-us/msoffice/forum/all/configuration-for-imap-pop-and-smtp-with-oauth-in/3db47d43-25ac-4e0b-b957-22585e6caf15
*
* https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps
*
* https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris
* Query: redirect_uri=https://{DOMAIN}/?LoginO365
* Path: redirect_uri=https://{DOMAIN}/LoginO365
*
* If running behind nginx reverse proxy you might
* need to add the following to your nginx config:
* location = /LoginO365 {
* return 302 /?LoginO365&$args;
* }
* Azure: redirect_uri=https://{DOMAIN}/?LoginO365
* Personal: redirect_uri=https://{DOMAIN}/LoginO365
*/
use RainLoop\Model\MainAccount;
@ -22,674 +17,232 @@ use RainLoop\Providers\Storage\Enumerations\StorageType;
class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
{
const
NAME = 'Office365/Outlook OAuth2',
VERSION = '0.4',
RELEASE = '2025-12-22',
REQUIRED = '2.36.1',
CATEGORY = 'Login',
DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2';
const
NAME = 'Office365/Outlook OAuth2',
VERSION = '0.3',
RELEASE = '2024-09-29',
REQUIRED = '2.36.1',
CATEGORY = 'Login',
DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2';
// v2 endpoints
const
AUTH_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/authorize',
TOKEN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token';
// https://login.microsoftonline.com/{{tenant}}/v2.0/.well-known/openid-configuration
const
LOGIN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/auth',
TOKEN_URI = 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token';
/**
* In-request cache of decrypted token bundles, keyed by lowercase email.
* This avoids re-decrypting the same blob multiple times during a single request.
*
* Shape:
* [
* 'user@outlook.com' => ['access_token'=>..., 'refresh_token'=>..., 'expires'=>..., 'expires_in'=>...],
* ...
* ]
*/
private static array $auth = [];
private static ?array $auth = null;
public function Init() : void
{
$this->UseLangs(true);
$this->addJs('LoginOAuth2.js');
$this->addHook('imap.before-login', 'clientLogin');
$this->addHook('smtp.before-login', 'clientLogin');
$this->addHook('sieve.before-login', 'clientLogin');
public function Init() : void
{
$this->UseLangs(true);
$this->addJs('LoginOAuth2.js');
$this->addHook('imap.before-login', 'clientLogin');
$this->addHook('smtp.before-login', 'clientLogin');
$this->addHook('sieve.before-login', 'clientLogin');
$this->addPartHook('LoginO365', 'ServiceLoginO365');
// Used by JS to obtain an auth URL with signed state (for both login + add-account flows).
$this->addJsonHook('LoginO365AuthUrl', 'DoLoginO365AuthUrl');
$this->addPartHook('LoginO365', 'ServiceLoginO365');
// Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true
$this->addHook('filter.http-paths', 'httpPaths');
// Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true
$this->addHook('filter.http-paths', 'httpPaths');
}
// Cleanup: when an additional account is removed, also remove its encrypted refresh token bundle.
$this->addHook('json.after-AccountDelete', 'afterAccountDelete');
}
public function httpPaths(array &$aPaths) : void
{
// Personal accounts workaround
if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) {
$aPaths = ['LoginO365'];
}
public function httpPaths(array &$aPaths) : void
{
if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) {
$aPaths = ['LoginO365'];
}
if (!empty($aPaths[0]) && 'LoginO365' === $aPaths[0]) {
$oConfig = \RainLoop\Api::Config();
$oConfig->Set('security', 'secfetch_allow',
\trim($oConfig->Get('security', 'secfetch_allow', '') . ';site=cross-site', ';')
);
}
}
if (!empty($aPaths[0]) && 'LoginO365' === $aPaths[0]) {
$oConfig = \RainLoop\Api::Config();
$oConfig->Set('security', 'secfetch_allow',
\trim($oConfig->Get('security', 'secfetch_allow', '') . ';site=cross-site', ';')
);
}
}
public function ServiceLoginO365() : string
{
$oActions = \RainLoop\Api::Actions();
$oHttp = $oActions->Http();
$oHttp->ServerNoCache();
public function ServiceLoginO365() : string
{
$oActions = \RainLoop\Api::Actions();
$oHttp = $oActions->Http();
$oHttp->ServerNoCache();
try
try
{
if (isset($_GET['error'])) {
$desc = $_GET['error_description'] ?? '';
throw new \RuntimeException("{$_GET['error']}: {$desc}");
}
// Must have code + state
if (!isset($_GET['code']) || empty($_GET['state'])) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$oO365 = $this->o365Connector();
if (!$oO365) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$iNow = \time();
$redirectUri = $this->redirectUri();
$tenant = $this->Config()->Get('plugin', 'tenant', 'common');
$state = (string) $_GET['state'];
$statePayload = $this->verifyAndConsumeState($state);
if (!$statePayload) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$aTokenWrap = $oO365->getAccessToken(
\str_replace('{{tenant}}', $tenant, static::TOKEN_URI),
'authorization_code',
[
'code' => $_GET['code'],
'redirect_uri' => $redirectUri
]
);
if (!\is_array($aTokenWrap) || !isset($aTokenWrap['code'])) {
throw new \RuntimeException('Token request failed: ' . \json_encode($aTokenWrap));
}
if (200 !== (int)$aTokenWrap['code']) {
$err = $aTokenWrap['result']['error'] ?? '';
$desc = $aTokenWrap['result']['error_description'] ?? '';
throw new \RuntimeException("Token HTTP {$aTokenWrap['code']}: {$err} / {$desc}");
}
$aToken = $aTokenWrap['result'] ?? [];
$accessToken = $aToken['access_token'] ?? '';
$refreshToken = $aToken['refresh_token'] ?? '';
$expiresIn = (int)($aToken['expires_in'] ?? 0);
$idToken = $aToken['id_token'] ?? '';
if ($accessToken === '') {
throw new \RuntimeException('access_token missing');
}
if ($refreshToken === '') {
throw new \RuntimeException('refresh_token missing');
}
if ($idToken === '') {
// We rely on id_token to get email/sub without Graph.
throw new \RuntimeException('id_token missing (add openid email profile scopes)');
}
// Parse id_token (JWT) to get identity (sub + email)
$claims = $this->decodeJwtPayload($idToken);
if (!\is_array($claims)) {
throw new \RuntimeException('Cannot decode id_token payload');
}
$email = $claims['email'] ?? ($claims['preferred_username'] ?? ($claims['upn'] ?? ''));
$sub = $claims['sub'] ?? '';
if ($sub === '') {
throw new \RuntimeException('unknown id from id_token');
}
if ($email === '') {
throw new \RuntimeException('unknown email address from id_token');
}
if (!$this->isSupportedEmail(\strtolower($email))) {
throw new \RuntimeException('Unsupported email domain for this plugin');
}
$tokenBundle = [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_in' => $expiresIn,
'expires' => $iNow + $expiresIn
];
$op = $statePayload['op'] ?? 'login';
if ('add' === $op) {
$oMainAccount = $oActions->getMainAccountFromToken(false);
if (!$oMainAccount) {
throw new \RuntimeException('Add-account flow requires logged in main account');
}
if (!empty($statePayload['main']) && $statePayload['main'] !== $oMainAccount->Email()) {
throw new \RuntimeException('Add-account state does not match current main account');
}
// Store token bundle encrypted with MAIN account crypt key (never store refresh_token unencrypted).
// This is later used by imap/smtp/sieve.before-login for *additional* accounts.
$this->storeAccountTokens($oMainAccount, $email, $tokenBundle);
// Create/validate an AdditionalAccount entry exactly like SnappyMail expects in "additionalaccounts".
// We set the "password" to the OAuth subject (sub) as an opaque secret; the plugin will inject XOAUTH2.
$oPassword = new \SnappyMail\SensitiveString($sub);
$oAdditional = $oActions->LoginProcess($email, $oPassword, false);
if (!$oAdditional instanceof \RainLoop\Model\AdditionalAccount) {
throw new \RuntimeException('Failed to create additional account');
}
$asciiEmail = \SnappyMail\IDN::emailToAscii($oAdditional->Email());
if ($asciiEmail === $oMainAccount->Email()) {
throw new \RuntimeException('Cannot add main account as additional');
}
$aAccounts = $oActions->GetAccounts($oMainAccount);
$aEntry = $oAdditional->asTokenArray($oMainAccount);
if (!empty($statePayload['name']) && \is_string($statePayload['name'])) {
$aEntry['name'] = \trim($statePayload['name']);
} else if (isset($aAccounts[$asciiEmail]['name'])) {
// Preserve previous custom label if re-adding/updating.
$aEntry['name'] = (string) $aAccounts[$asciiEmail]['name'];
}
$aAccounts[$asciiEmail] = $aEntry;
$oActions->SetAccounts($oMainAccount, $aAccounts);
// Cache for this request (used during LoginProcess() above and any subsequent logins).
static::$auth[\strtolower($asciiEmail)] = $tokenBundle;
$returnHash = '';
if (!empty($statePayload['return']) && \is_string($statePayload['return']) && \str_starts_with($statePayload['return'], '#')) {
$returnHash = $statePayload['return'];
}
$oActions->Location(\RainLoop\Utils::WebPath() . $returnHash);
exit;
}
// Default: "login" flow (preserve existing behavior)
static::$auth[\strtolower($email)] = $tokenBundle;
// SnappyMail uses password as opaque string; plugin injects XOAUTH2 later.
$oPassword = new \SnappyMail\SensitiveString($sub);
$oAccount = $oActions->LoginProcess($email, $oPassword);
if ($oAccount) {
$oActions->StorageProvider()->Put(
$oAccount,
StorageType::SESSION,
\RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON($tokenBundle, $oAccount->CryptKey())
);
}
}
catch (\Throwable $e) {
$oActions->Logger()->WriteException($e, \LOG_ERR);
}
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
public function configMapping() : array
{
return [
\RainLoop\Plugins\Property::NewInstance('client_id')
->SetLabel('Client ID')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetAllowedInJs(),
\RainLoop\Plugins\Property::NewInstance('client_secret')
->SetLabel('Client Secret')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetEncrypted(),
\RainLoop\Plugins\Property::NewInstance('tenant')
->SetLabel('Tenant')
->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION)
->SetDefaultValue(['common','consumers','organizations'])
->SetAllowedInJs(),
\RainLoop\Plugins\Property::NewInstance('personal')
// When true: redirect URI uses query parameter form "/?LoginO365" (Azure supports it).
// When false: redirect URI uses path form "/LoginO365" (useful behind reverse proxies).
->SetLabel('Use "/?LoginO365" redirect URI')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(false)
->SetAllowedInJs(),
\RainLoop\Plugins\Property::NewInstance('allow_any_domain')
->SetLabel('Allow any domain (not only outlook.com/hotmail.com/live.com)')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(false)
->SetAllowedInJs()
];
}
public function clientLogin(\RainLoop\Model\Account $oAccount, \MailSo\Net\NetClient $oClient, \MailSo\Net\ConnectSettings $oSettings) : void
{
$email = \strtolower($oAccount->Email());
if (!$this->isSupportedEmail($email)) {
return;
}
$oActions = \RainLoop\Api::Actions();
$aData = static::$auth[$email] ?? null;
if (!$aData) {
try {
if ($oAccount instanceof MainAccount) {
$blob = $oActions->StorageProvider()->Get(
$oAccount,
StorageType::SESSION,
\RainLoop\Utils::GetSessionToken()
);
$aData = \SnappyMail\Crypt::DecryptFromJSON($blob, $oAccount->CryptKey());
} else if ($oAccount instanceof \RainLoop\Model\AdditionalAccount) {
$oMain = $oActions->getMainAccountFromToken(false);
if (!$oMain) {
return;
}
$blob = $oActions->StorageProvider()->Get(
$oMain,
StorageType::CONFIG,
$this->tokenStorageKey($email)
);
$aData = \SnappyMail\Crypt::DecryptFromJSON($blob, $oMain->CryptKey());
}
} catch (\Throwable $e) {
return;
}
}
if (empty($aData['access_token']) || empty($aData['refresh_token']) || empty($aData['expires'])) {
return;
}
// Refresh if expired (or close to expiry)
if (\time() >= ((int)$aData['expires'] - 30)) {
$oO365 = $this->o365Connector();
if ($oO365) {
$tenant = $this->Config()->Get('plugin', 'tenant', 'common');
$aRefreshWrap = $oO365->getAccessToken(
\str_replace('{{tenant}}', $tenant, static::TOKEN_URI),
'refresh_token',
['refresh_token' => $aData['refresh_token']]
);
if (\is_array($aRefreshWrap) && isset($aRefreshWrap['code']) && 200 === (int)$aRefreshWrap['code']) {
$r = $aRefreshWrap['result'] ?? [];
if (!empty($r['access_token'])) {
$aData['access_token'] = $r['access_token'];
}
if (!empty($r['refresh_token'])) {
$aData['refresh_token'] = $r['refresh_token'];
}
$expiresIn = (int)($r['expires_in'] ?? 0);
if ($expiresIn > 0) {
$aData['expires'] = \time() + $expiresIn;
$aData['expires_in'] = $expiresIn;
}
// Persist updated bundle (encrypted).
if ($oAccount instanceof MainAccount) {
$oActions->StorageProvider()->Put(
$oAccount,
StorageType::SESSION,
\RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON($aData, $oAccount->CryptKey())
);
} else if ($oAccount instanceof \RainLoop\Model\AdditionalAccount) {
$oMain = $oActions->getMainAccountFromToken(false);
if ($oMain) {
$oActions->StorageProvider()->Put(
$oMain,
StorageType::CONFIG,
$this->tokenStorageKey($email),
\SnappyMail\Crypt::EncryptToJSON($aData, $oMain->CryptKey())
);
}
}
}
}
}
static::$auth[$email] = $aData;
// Inject XOAUTH2/OAUTHBEARER
$oSettings->passphrase = $aData['access_token'];
\array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2');
}
/**
* Server-side cleanup hook: after a successful AccountDelete, remove stored token bundle for that email.
* This prevents leaving encrypted refresh tokens behind when an additional account is removed.
*/
public function afterAccountDelete(array &$aResponse) : void
{
if (empty($aResponse['Result'])) {
return;
}
$oActions = \RainLoop\Api::Actions();
$oMain = $oActions->getMainAccountFromToken(false);
if (!$oMain) {
return;
}
$email = \strtolower(\SnappyMail\IDN::emailToAscii(\trim((string) $oActions->GetActionParam('emailToDelete', ''))));
if ($email && $this->isSupportedEmail($email)) {
$oActions->StorageProvider()->Clear($oMain, StorageType::CONFIG, $this->tokenStorageKey($email));
}
}
protected function o365Connector() : ?\OAuth2\Client
{
$client_id = \trim($this->Config()->Get('plugin', 'client_id', ''));
$client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', ''));
if ($client_id && $client_secret) {
try {
$oO365 = new \OAuth2\Client($client_id, $client_secret);
$oActions = \RainLoop\Api::Actions();
$sProxy = $oActions->Config()->Get('labs', 'curl_proxy', '');
if (\strlen($sProxy)) {
$oO365->setCurlOption(CURLOPT_PROXY, $sProxy);
$sProxyAuth = $oActions->Config()->Get('labs', 'curl_proxy_auth', '');
if (\strlen($sProxyAuth)) {
$oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth);
}
}
return $oO365;
} catch (\Throwable $e) {
\RainLoop\Api::Actions()->Logger()->WriteException($e, \LOG_ERR);
}
}
return null;
}
private function decodeJwtPayload(string $jwt) : ?array
{
$parts = \explode('.', $jwt);
if (\count($parts) < 2) {
return null;
}
$payload = $parts[1];
$payload .= \str_repeat('=', (4 - (\strlen($payload) % 4)) % 4);
$json = \base64_decode(\strtr($payload, '-_', '+/'));
if ($json === false) {
return null;
}
$data = \json_decode($json, true);
return \is_array($data) ? $data : null;
}
/**
* JSON action called by JS to obtain an MS authorize URL with signed state.
* This avoids exposing any signing secret to JS and keeps redirect_uri consistent with server logic.
*/
public function DoLoginO365AuthUrl() : array
{
$oActions = \RainLoop\Api::Actions();
$op = (string) $this->jsonParam('op', 'login');
if (!\in_array($op, ['login', 'add'], true)) {
return $this->jsonResponse(__FUNCTION__, false);
}
$email = \strtolower(\trim((string) $this->jsonParam('email', '')));
$name = \trim((string) $this->jsonParam('name', ''));
$returnHash = (string) $this->jsonParam('return', '');
if ($returnHash && !\str_starts_with($returnHash, '#')) {
$returnHash = '';
}
// For add-account flow, require a logged-in main account (we must write to its additionalaccounts storage).
$oMainAccount = null;
if ('add' === $op) {
$oMainAccount = $oActions->getMainAccountFromToken(false);
if (!$oMainAccount) {
return $this->jsonResponse(__FUNCTION__, false);
}
}
// Optional server-side guard: only permit supported consumer domains unless configured otherwise.
if ($email && !$this->isSupportedEmail($email)) {
return $this->jsonResponse(__FUNCTION__, false);
}
$oConfig = $this->Config();
$client_id = \trim($oConfig->Get('plugin', 'client_id', ''));
if (!$client_id) {
return $this->jsonResponse(__FUNCTION__, false);
}
$nonce = $this->b64url(\random_bytes(16));
// Store nonce server-side to prevent replay; consumed on callback.
$oActions->StorageProvider()->Put(
null,
StorageType::NOBODY,
$this->stateNonceKey($nonce),
(string) \time()
);
$payload = [
'v' => 1,
'op' => $op,
'csrf' => \RainLoop\Utils::GetCsrfToken(),
'nonce' => $nonce,
'ts' => \time()
];
if ('add' === $op && $oMainAccount) {
$payload['main'] = $oMainAccount->Email();
if ($name) {
$payload['name'] = \substr($name, 0, 100);
}
if ($returnHash) {
$payload['return'] = \substr($returnHash, 0, 200);
}
}
$state = $this->signState($payload);
$tenant = $oConfig->Get('plugin', 'tenant', 'common');
$redirectUri = $this->redirectUri();
$params = [
'response_type' => 'code',
'client_id' => $client_id,
'redirect_uri' => $redirectUri,
'scope' => \implode(' ', [
'openid',
'offline_access',
'email',
'profile',
'https://outlook.office.com/IMAP.AccessAsUser.All',
'https://outlook.office.com/SMTP.Send',
]),
'state' => $state,
// Helps MS UI prefill, but does not change server-side validation.
];
if ($email) {
$params['login_hint'] = $email;
}
$authUrl = \str_replace('{{tenant}}', $tenant, static::AUTH_URI)
. '?'
. \http_build_query($params, '', '&', PHP_QUERY_RFC3986);
return $this->jsonResponse(__FUNCTION__, [
'authUrl' => $authUrl
]);
}
private function isSupportedEmail(string $email) : bool
{
if ((bool)$this->Config()->Get('plugin', 'allow_any_domain', false)) {
return \str_contains($email, '@');
}
return \str_ends_with($email, '@hotmail.com')
|| \str_ends_with($email, '@outlook.com')
|| \str_ends_with($email, '@live.com');
}
/**
* Build absolute base URL (works behind nginx reverse proxy).
*/
private function baseUrl() : string
{
$scheme = (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']))
? $_SERVER['HTTP_X_FORWARDED_PROTO']
: ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http');
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
if (!$host) {
throw new \RuntimeException('Cannot determine HTTP_HOST');
}
return $scheme . '://' . $host;
}
/**
* Redirect URI used for the Azure app registration.
* When plugin.personal=true -> "/?LoginO365"
* When plugin.personal=false -> "/LoginO365"
*/
private function redirectUri() : string
{
$base = \rtrim($this->baseUrl(), '/');
$useQuery = (bool)$this->Config()->Get('plugin', 'personal', false);
return $useQuery ? ($base . '/?LoginO365') : ($base . '/LoginO365');
}
private function tokenStorageKey(string $emailLower) : string
{
// Stored under MAIN account StorageType::CONFIG (encrypted with main CryptKey).
// Email is hashed to avoid path/encoding issues across storage backends.
return 'login-o365.tokens.' . \sha1($emailLower);
}
private function storeAccountTokens(MainAccount $oMainAccount, string $email, array $tokenBundle) : void
{
$emailLower = \strtolower(\SnappyMail\IDN::emailToAscii($email));
\RainLoop\Api::Actions()->StorageProvider()->Put(
$oMainAccount,
StorageType::CONFIG,
$this->tokenStorageKey($emailLower),
\SnappyMail\Crypt::EncryptToJSON($tokenBundle, $oMainAccount->CryptKey())
);
}
private function stateNonceKey(string $nonce) : string
{
return 'login-o365.state.' . $nonce;
}
private function b64url(string $bin) : string
{
return \rtrim(\strtr(\base64_encode($bin), '+/', '-_'), '=');
}
private function b64urlDecode(string $b64url) /*: string|false*/
{
$pad = (4 - (\strlen($b64url) % 4)) % 4;
return \base64_decode(\strtr($b64url . \str_repeat('=', $pad), '-_', '+/'), true);
}
private function stateHmacKey() : string
{
// Uses the plugin client_secret (server-side only) as HMAC key.
// This prevents any user-controlled tampering of the state payload.
$key = \trim($this->Config()->getDecrypted('plugin', 'client_secret', ''));
if (!$key) {
// Fallback for misconfiguration; keeps behavior deterministic.
$key = 'login-o365';
}
return $key;
}
private function signState(array $payload) : string
{
$json = \json_encode($payload);
if (!$json) {
$json = '{}';
}
$payloadB64 = $this->b64url($json);
$sig = \hash_hmac('sha256', $payloadB64, $this->stateHmacKey(), true);
return $payloadB64 . '.' . $this->b64url($sig);
}
/**
* Verify signature + CSRF + nonce, then consumes nonce to prevent replay.
* Returns decoded payload on success, null on failure.
*/
private function verifyAndConsumeState(string $state) : ?array
{
$parts = \explode('.', $state, 2);
if (2 !== \count($parts)) {
return null;
}
[$payloadB64, $sigB64] = $parts;
$sig = $this->b64urlDecode($sigB64);
if ($sig === false) {
return null;
}
$expected = \hash_hmac('sha256', $payloadB64, $this->stateHmacKey(), true);
if (!\hash_equals($expected, $sig)) {
return null;
}
$payloadJson = $this->b64urlDecode($payloadB64);
if ($payloadJson === false) {
return null;
}
$payload = \json_decode($payloadJson, true);
if (!\is_array($payload) || empty($payload['csrf']) || empty($payload['nonce']) || empty($payload['op'])) {
return null;
}
// Must match the current browser session.
if ($payload['csrf'] !== \RainLoop\Utils::GetCsrfToken()) {
return null;
}
// Replay protection: nonce must exist server-side and is consumed once.
$oActions = \RainLoop\Api::Actions();
$key = $this->stateNonceKey((string) $payload['nonce']);
$seen = $oActions->StorageProvider()->Get(null, StorageType::NOBODY, $key);
if (!$seen) {
return null;
}
$ts = (int) ($payload['ts'] ?? 0);
if ($ts && \abs(\time() - $ts) > 900) { // 15 minutes
// Expired: clear nonce to avoid accumulating stale entries.
$oActions->StorageProvider()->Clear(null, StorageType::NOBODY, $key);
return null;
}
$oActions->StorageProvider()->Clear(null, StorageType::NOBODY, $key);
return $payload;
}
}
if (isset($_GET['error'])) {
throw new \RuntimeException("{$_GET['error']}: {$_GET['error_description']}");
}
if (!isset($_GET['code']) || empty($_GET['state']) || 'o365' !== $_GET['state']) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$oO365 = $this->o365Connector();
if (!$oO365) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$iExpires = \time();
$aResponse = $oO365->getAccessToken(
\str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI),
'authorization_code',
array(
'code' => $_GET['code'],
'redirect_uri' => $oHttp->GetFullUrl().'?LoginO365'
)
);
if (200 != $aResponse['code']) {
if (isset($aResponse['result']['error'])) {
throw new \RuntimeException(
$aResponse['code']
. ': '
. $aResponse['result']['error']
. ' / '
. $aResponse['result']['error_description']
);
}
throw new \RuntimeException("HTTP: {$aResponse['code']}");
}
$aResponse = $aResponse['result'];
if (empty($aResponse['access_token'])) {
throw new \RuntimeException('access_token missing');
}
if (empty($aResponse['refresh_token'])) {
throw new \RuntimeException('refresh_token missing');
}
$sAccessToken = $aResponse['access_token'];
$iExpires += $aResponse['expires_in'];
$oO365->setAccessToken($sAccessToken);
$aUserInfo = $oO365->fetch('https://graph.microsoft.com/oidc/userinfo');
if (200 != $aUserInfo['code']) {
throw new \RuntimeException("HTTP: {$aResponse['code']}");
}
$aUserInfo = $aUserInfo['result'];
if (empty($aUserInfo['id'])) {
throw new \RuntimeException('unknown id');
}
if (empty($aUserInfo['email'])) {
throw new \RuntimeException('unknown email address');
}
static::$auth = [
'access_token' => $sAccessToken,
'refresh_token' => $aResponse['refresh_token'],
'expires_in' => $aResponse['expires_in'],
'expires' => $iExpires
];
$oPassword = new \SnappyMail\SensitiveString($aUserInfo['id']);
$oAccount = $oActions->LoginProcess($aUserInfo['email'], $oPassword);
// $oAccount = MainAccount::NewInstanceFromCredentials($oActions, $aUserInfo['email'], $aUserInfo['email'], $oPassword, true);
if ($oAccount) {
// $oActions->SetMainAuthAccount($oAccount);
// $oActions->SetAuthToken($oAccount);
$oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON(static::$auth, $oAccount->CryptKey())
);
}
}
catch (\Exception $oException)
{
$oActions->Logger()->WriteException($oException, \LOG_ERR);
}
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
public function configMapping() : array
{
return [
\RainLoop\Plugins\Property::NewInstance('personal')
->SetLabel('Use with personal accounts')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(true)
->SetAllowedInJs()
->SetDescription('Sign in users with personal Microsoft accounts such as Outlook.com (Hotmail)'),
\RainLoop\Plugins\Property::NewInstance('client_id')
->SetLabel('Client ID')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetAllowedInJs()
->SetDescription('https://github.com/the-djmaze/snappymail/wiki/FAQ#o365'),
\RainLoop\Plugins\Property::NewInstance('client_secret')
->SetLabel('Client Secret')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetEncrypted(),
\RainLoop\Plugins\Property::NewInstance('tenant_id')
->SetLabel('Tenant ID')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING),
\RainLoop\Plugins\Property::NewInstance('tenant')->SetLabel('Tenant')
->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION)
->SetDefaultValue(['common','consumers','organizations'])
->SetAllowedInJs()
];
}
public function clientLogin(\RainLoop\Model\Account $oAccount, \MailSo\Net\NetClient $oClient, \MailSo\Net\ConnectSettings $oSettings) : void
{
if ($oAccount instanceof MainAccount && \str_ends_with($oAccount->Email(), '@hotmail.com')) {
$oActions = \RainLoop\Api::Actions();
try {
$aData = static::$auth ?: \SnappyMail\Crypt::DecryptFromJSON(
$oActions->StorageProvider()->Get($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken()),
$oAccount->CryptKey()
);
} catch (\Throwable $oException) {
// $oActions->Logger()->WriteException($oException, \LOG_ERR);
return;
}
if (!empty($aData['expires']) && !empty($aData['access_token']) && !empty($aData['refresh_token'])) {
if (\time() >= $aData['expires']) {
$iExpires = \time();
$oO365 = $this->o365Connector();
if ($oO365) {
$aRefreshTokenResponse = $oO365->getAccessToken(
\str_replace('{{tenant}}', $this->Config()->Get('plugin', 'tenant', 'common'), static::TOKEN_URI),
'refresh_token',
array('refresh_token' => $aData['refresh_token'])
);
if (!empty($aRefreshTokenResponse['result']['access_token'])) {
$aData['access_token'] = $aRefreshTokenResponse['result']['access_token'];
$aResponse['expires'] = $iExpires + $aResponse['expires_in'];
$oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON($aData, $oAccount->CryptKey())
);
}
}
}
$oSettings->passphrase = $aData['access_token'];
\array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2');
}
}
}
protected function o365Connector() : ?\OAuth2\Client
{
$client_id = \trim($this->Config()->Get('plugin', 'client_id', ''));
$client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', ''));
if ($client_id && $client_secret) {
try
{
$oO365 = new \OAuth2\Client($client_id, $client_secret);
$oActions = \RainLoop\Api::Actions();
$sProxy = $oActions->Config()->Get('labs', 'curl_proxy', '');
if (\strlen($sProxy)) {
$oO365->setCurlOption(CURLOPT_PROXY, $sProxy);
$sProxyAuth = $oActions->Config()->Get('labs', 'curl_proxy_auth', '');
if (\strlen($sProxyAuth)) {
$oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth);
}
}
return $oO365;
}
catch (\Exception $oException)
{
$oActions->Logger()->WriteException($oException, \LOG_ERR);
}
}
return null;
}
}

View file

@ -6,8 +6,8 @@ class LoginRemotePlugin extends \RainLoop\Plugins\AbstractPlugin
NAME = 'Login Remote',
AUTHOR = 'SnappyMail',
URL = 'https://snappymail.eu/',
VERSION = '1.6',
RELEASE = '2026-10-22',
VERSION = '1.5',
RELEASE = '2024-09-20',
REQUIRED = '2.36.1',
CATEGORY = 'Login',
LICENSE = 'MIT',
@ -43,7 +43,6 @@ class LoginRemotePlugin extends \RainLoop\Plugins\AbstractPlugin
try
{
static::$login = true;
$sPassword = new \SnappyMail\SensitiveString($sPassword);
$oAccount = $oActions->LoginProcess($sEmail, $sPassword);
}
catch (\Throwable $oException)

View file

@ -1,7 +1,5 @@
<?php
use OCA\SnappyMail\Util\SnappyMailHelper;
class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
{
const
@ -45,7 +43,7 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
$this->addHook('sieve.before-login', 'beforeLogin');
} else {
\SnappyMail\Log::debug('Nextcloud', 'NOT integrated');
// SnappyMailHelper::getConfig()->getAppValue('snappymail', 'snappymail-no-embed');
// \OC::$server->getConfig()->getAppValue('snappymail', 'snappymail-no-embed');
$this->addHook('main.content-security-policy', 'ContentSecurityPolicy');
}
}
@ -69,7 +67,7 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
public static function IsLoggedIn()
{
return static::IsIntegrated() && SnappyMailHelper::getUserSession()->isLoggedIn();
return static::IsIntegrated() && \OC::$server->getUserSession()->isLoggedIn();
}
public function loginCredentials(string &$sEmail, string &$sLogin, ?string &$sPassword = null) : void
@ -79,13 +77,13 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
* When user changes email address, all settings are gone as the new
* _data_/_default_/storage/{domain}/{local-part} is used
*/
// $ocUser = SnappyMailHelper::getUser();
// $ocUser = \OC::$server->getUserSession()->getUser();
// $sEmail = $ocUser->getEMailAddress() ?: $ocUser->getPrimaryEMailAddress() ?: $sEmail;
}
public function loginCredentials2(string &$sEmail, ?string &$sPassword = null) : void
{
$ocUser = SnappyMailHelper::getUser();
$ocUser = \OC::$server->getUserSession()->getUser();
$sEmail = $ocUser->getEMailAddress() ?: $ocUser->getPrimaryEMailAddress() ?: $sEmail;
}
@ -95,12 +93,12 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
// it is enabled in config, the user is currently logged in with OIDC,
// the current snappymail account is the OIDC account and no account defined explicitly
if ($oAccount instanceof \RainLoop\Model\MainAccount
&& SnappyMailHelper::isOIDCLogin()
&& \OCA\SnappyMail\Util\SnappyMailHelper::isOIDCLogin()
// && $oClient->supportsAuthType('OAUTHBEARER') // v2.28
&& \str_starts_with($oSettings->passphrase, 'oidc_login|')
) {
// $oSettings->passphrase = SnappyMailHelper::getSessionValue('snappymail-passphrase');
$oSettings->passphrase = SnappyMailHelper::getSessionValue('oidc_access_token');
// $oSettings->passphrase = \OC::$server->getSession()->get('snappymail-passphrase');
$oSettings->passphrase = \OC::$server->getSession()->get('oidc_access_token');
\array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER');
}
}
@ -215,7 +213,7 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
public function FilterAppData($bAdmin, &$aResult) : void
{
if (!$bAdmin && \is_array($aResult)) {
$ocUser = SnappyMailHelper::getUser();
$ocUser = \OC::$server->getUserSession()->getUser();
$sUID = $ocUser->getUID();
$oUrlGen = \OC::$server->getURLGenerator();
$sWebDAV = $oUrlGen->getAbsoluteURL($oUrlGen->linkTo('', 'remote.php') . '/dav');
@ -227,7 +225,7 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
// 'WebDAV_files' => $sWebDAV . '/files/' . $sUID
];
if (empty($aResult['Auth'])) {
$config = SnappyMailHelper::getConfig();
$config = \OC::$server->getConfig();
$sEmail = '';
// Only store the user's password in the current session if they have
// enabled auto-login using Nextcloud username or email address.
@ -250,9 +248,9 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
}
/*
if ($config->getAppValue('snappymail', 'snappymail-autologin-oidc', false)) {
if (SnappyMailHelper::getSessionValue('is_oidc')) {
if (\OC::$server->getSession()->get('is_oidc')) {
$sEmail = "{$sUID}@nextcloud";
$aResult['DevPassword'] = SnappyMailHelper::getSessionValue('oidc_access_token');
$aResult['DevPassword'] = \OC::$server->getSession()->get('oidc_access_token');
} else {
\SnappyMail\Log::debug('Nextcloud', 'Not an OIDC login');
}
@ -271,7 +269,7 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
$aResult['ContactsSync']['User'] = $sUID;
$bSave = true;
}
$pass = SnappyMailHelper::getSessionValue('snappymail-passphrase');
$pass = \OC::$server->getSession()['snappymail-passphrase'];
if ($pass/* && empty($aResult['ContactsSync']['Password'])*/) {
$pass = \SnappyMail\Crypt::DecryptUrlSafe($pass, $sUID);
if ($pass) {
@ -299,8 +297,8 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
{
if (!\RainLoop\Api::Config()->Get('webmail', 'allow_languages_on_settings', true)) {
$aResultLang = \SnappyMail\L10n::getLanguages($bAdmin);
$userId = SnappyMailHelper::getUID();
$userLang = SnappyMailHelper::getConfig()->getUserValue($userId, 'core', 'lang', 'en');
$userId = \OC::$server->getUserSession()->getUser()->getUID();
$userLang = \OC::$server->getConfig()->getUserValue($userId, 'core', 'lang', 'en');
$userLang = \strtr($userLang, '_', '-');
$sLanguage = $this->determineLocale($userLang, $aResultLang);
// Check if $sLanguage is null

View file

@ -11,7 +11,7 @@ class NextcloudStorage extends \RainLoop\Providers\Storage\FileStorage
{
$sDataPath = parent::GenerateFilePath($mAccount, $iStorageType, $bMkDir);
if (StorageType::CONFIG === $iStorageType) {
$sUID = \OCA\SnappyMail\Util\SnappyMailHelper::getUID();
$sUID = \OC::$server->getUserSession()->getUser()->getUID();
$sDataPath .= ".config/{$sUID}/";
if ($bMkDir && !\is_dir($sDataPath) && !\mkdir($sDataPath, 0700, true)) {
throw new \RainLoop\Exceptions\Exception('Can\'t make storage directory "'.$sDataPath.'"');

View file

@ -19,34 +19,33 @@ The following steps are require in SnappyMail:
- Master User is dependent on Dovecot config (see below)
- Master User Password is dependent on Dovecot config (see below)
- Header Name is dependent on authentication solution. This is the header containing the name of currently logged in user. In case of Authelia, this is "Remote-User".
- Check Proxy: Since this plugin partially bypasses authentication, it is important to only allow this access from well-defined hosts. It is highly recommended to activate this option!
- When checking for reverse proxy, it is required to set the IP filter to either an IP address or a subnet.
- Automatic Login: Automatically logs in the user of user header is present (see below)
> **Security note**
>
> This plugin trusts the configured request header as proof of identity. Anyone who can reach the SnappyMail container directly and set that header can log in as any user. You **must** ensure that SnappyMail is only reachable through your reverse proxy / SSO chain (e.g. via the docker network, a firewall, or by binding SnappyMail to a non-public interface) and that the upstream proxy strips any client-supplied value of the header before forwarding. The plugin itself does no source-IP validation — earlier versions had a `check_proxy` option, but it inspected the forwarded client IP (the end user's IP) and therefore did not actually verify that the request came from the proxy. It has been removed; gate access at the network layer instead.
This concludes the setup of SnappyMail.
### Dovecot
In Dovecot, you need to enable Master User.
Enable ```!include auth-master.conf.ext``` in /etc/dovecot/conf.d/10-auth.conf.
In Dovecot 2.3, the file /etc/dovecot/conf.d/auth-master.conf.ext should contain:
The file /etc/dovecot/conf.d/auth-master.conf.ext should contain:
```
# Authentication for master users. Included from auth.conf.
# By adding master=yes setting inside a passdb you make the passdb a list
# of "master users", who can log in as anyone else.
# <doc/wiki/Authentication.MasterUsers.txt>
# Example master user passdb using passwd-file. You can use any passdb though.
passdb {
driver = passwd-file
master = yes
args = /etc/dovecot/master-users
pass = yes
}
```
In Dovecot 2.4, the file /etc/dovecot/conf.d/auth-master.conf.ext should contain:
```
passdb passwd-file {
master = yes
passwd_file_path = /etc/dovecot/master-users
result_success = continue
# Unless you're using PAM, you probably still want the destination user to
# be looked up from passdb that it really exists. pass=yes does that.
pass = yes
}
```
@ -81,11 +80,3 @@ The user is always considered logged in, as authentication is handled through re
Auto login can be disabled in the plugin settings.
You can also change the logout link in admin panel -> Config -> custom_logout_link to the one of your authentication system, e.g., ```https://auth.yourdomain.com/logout```.
In this case, you can log out from your overall system via SnappyMail.
## Troubleshooting
### IMAP `AUTHENTICATIONFAILED` after a container rebuild / upgrade
The master user/password fields are encrypted at rest using SnappyMail's `APP_SALT`. If that salt is regenerated (e.g., the data volume was reset, the container was rebuilt without persisting `_data_`, or the salt file was rotated), the values in `plugin-proxy-auth.json` can no longer be decrypted. `getDecrypted()` then silently returns `null`, an empty password is passed to IMAP, and Dovecot rejects the login with `AUTHENTICATIONFAILED`.
Fix: open admin panel -> Extensions -> Proxy Auth, re-enter the Master User and Master Password (and any other previously-set encrypted fields), and save. The values will be re-encrypted under the current salt.

View file

@ -6,8 +6,8 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
NAME = 'Proxy Auth',
AUTHOR = 'Philipp',
URL = 'https://www.mundhenk.org/',
VERSION = '0.7',
RELEASE = '2026-05-17',
VERSION = '0.5',
RELEASE = '2024-09-20',
REQUIRED = '2.36.1',
CATEGORY = 'Login',
LICENSE = 'MIT',
@ -21,6 +21,26 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
$this->addHook('login.credentials', 'MapEmailAddress');
}
/* by https://gist.github.com/tott/7684443 */
/**
* Check if a given ip is in a network
* @param string $ip IP to check in IPV4 format eg. 127.0.0.1
* @param string $range IP/CIDR netmask eg. 127.0.0.0/24, also 127.0.0.1 is accepted and /32 assumed
* @return boolean true if the ip is in this range / false if not.
*/
private function ip_in_range( $ip, $range ) {
if ( strpos( $range, '/' ) == false ) {
$range .= '/32';
}
// $range is in IP/CIDR format eg 127.0.0.1/24
list( $range, $netmask ) = explode( '/', $range, 2 );
$range_decimal = ip2long( $range );
$ip_decimal = ip2long( $ip );
$wildcard_decimal = pow( 2, ( 32 - $netmask ) ) - 1;
$netmask_decimal = ~ $wildcard_decimal;
return ( ( $ip_decimal & $netmask_decimal ) == ( $range_decimal & $netmask_decimal ) );
}
public function MapEmailAddress(string &$sEmail, string &$sImapUser, string &$sPassword, string &$sSmtpUser)
{
$oActions = \RainLoop\Api::Actions();
@ -60,19 +80,59 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
$sMsg = "Remote User: " . $sRemoteUser;
$oLogger->Write($sMsg, $sLevel, $sPrefix);
/* create master user login from remote user header and settings */
$sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser;
$sPassword = new \SnappyMail\SensitiveString(\trim($this->Config()->getDecrypted('plugin', 'master_password', '')));
$sProxyIP = $this->Config()->getDecrypted('plugin', 'proxy_ip', '');
$sMsg = "ProxyIP: " . $sProxyIP;
$oLogger->Write($sMsg, $sLevel, $sPrefix);
try
{
static::$login = true;
$oAccount = $oActions->LoginProcess($sEmail, $sPassword);
$sProxyCheck = $this->Config()->getDecrypted('plugin', 'proxy_check', '');
$sClientIPs = $this->Manager()->Actions()->Http()->GetClientIP(true);
/* make sure that remote user is only set by authorized proxy to avoid security risks */
if ($sProxyCheck) {
$sProxyRequest = false;
$sMsg = "checking client IPs: " . $sClientIPs;
$oLogger->Write($sMsg, $sLevel, $sPrefix);
$sClientIPs = explode(", ", $sClientIPs);
if (is_array($sClientIPs)) {
foreach ($sClientIPs as &$sIP) {
$sMsg = "checking client IP: " . $sIP;
$oLogger->Write($sMsg, $sLevel, $sPrefix);
if ($this->ip_in_range($sIP, $sProxyIP)) {
$sProxyRequest = true;
}
}
} else {
$sMsg = "checking client IP: " . $sClientIPs;
$oLogger->Write($sMsg, $sLevel, $sPrefix);
if ($this->ip_in_range($sClientIPs, $sProxyIP)) {
$sProxyRequest = true;
}
}
} else {
$sProxyRequest = true;
}
catch (\Throwable $oException)
{
$oLogger = $oActions->Logger();
$oLogger && $oLogger->WriteException($oException);
if ($sProxyRequest) {
/* create master user login from remote user header and settings */
$sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser;
$sPassword = new \SnappyMail\SensitiveString(\trim($this->Config()->getDecrypted('plugin', 'master_password', '')));
try
{
static::$login = true;
$oAccount = $oActions->LoginProcess($sEmail, $sPassword);
}
catch (\Throwable $oException)
{
$oLogger = $oActions->Logger();
$oLogger && $oLogger->WriteException($oException);
}
\MailSo\Base\Http::Location('./');
return true;
}
\MailSo\Base\Http::Location('./');
@ -128,6 +188,18 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
->SetDescription('Name of header containing username')
->SetDefaultValue('Remote-User')
->SetEncrypted(),
\RainLoop\Plugins\Property::NewInstance('check_proxy')
->SetLabel('Check Proxy')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDescription('Activates check if proxy is connecting')
->SetDefaultValue(true)
->SetEncrypted(),
\RainLoop\Plugins\Property::NewInstance('proxy_ip')
->SetLabel('Proxy IPNet')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT)
->SetDescription('IP or Subnet of proxy, auth header will only be accepted from this address')
->SetDefaultValue('10.1.0.0/24')
->SetEncrypted(),
\RainLoop\Plugins\Property::NewInstance('auto_login')
->SetAllowedInJs(true)
->SetLabel('Activate automatic login')

View file

@ -6,8 +6,8 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
NAME = 'Search Filters',
AUTHOR = 'AbdoBnHesham',
URL = 'https://github.com/the-djmaze/snappymail/pull/1673',
VERSION = '0.3',
RELEASE = '2026-10-22',
VERSION = '0.2',
RELEASE = '2024-06-28',
REQUIRED = '2.36.3',
CATEGORY = 'General',
LICENSE = 'MIT',
@ -51,33 +51,45 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
$Filters = $aSettings['SFilters'];
foreach ($Filters as $filter) {
$this->Manager()->logWrite(json_encode(['filter' => $filter]), LOG_WARNING);
$this->Manager()->logWrite(json_encode([
'filter' => $filter,
]), LOG_WARNING);
$folder = 'INBOX';
$searchQ = $filter['searchQ'];
$uids = $this->searchMessages($oImapClient, $searchQ, "INBOX");
// obsługa keyword (multi-folder)
if (stripos($searchQ, 'keyword=') !== false) {
$oMailClient = $this->Manager()->Actions()->MailClient();
// Pobieramy wszystkie foldery top-level
$folders = $oMailClient->Folders('', false, '');
foreach ($folders as $f) {
$folder = $f->FullName ?? null;
if (!$folder) continue;
$uids = $this->searchMessages($oImapClient, $searchQ, $folder);
if (!empty($uids)) {
$this->applyActions($oImapClient, $filter, $uids, $folder);
}
//Mark as read/seen
if ($filter['fSeen']) {
foreach ($uids as $uid) {
$oRange = new MailSo\Imap\SequenceSet([$uid]);
$this->Manager()->Actions()->MailClient()->MessageSetFlag(
$folder,
$oRange,
MailSo\Imap\Enumerations\MessageFlag::SEEN
);
}
continue; // przechodzimy do następnego filtra
}
// normalny search w INBOX
$uids = $this->searchMessages($oImapClient, $searchQ, 'INBOX');
if (!empty($uids)) {
$this->applyActions($oImapClient, $filter, $uids, 'INBOX');
//Flag/Star message
if ($filter['fFlag']) {
foreach ($uids as $uid) {
$oRange = new MailSo\Imap\SequenceSet([$uid]);
$this->Manager()->Actions()->MailClient()->MessageSetFlag(
$folder,
$oRange,
MailSo\Imap\Enumerations\MessageFlag::FLAGGED
);
}
}
// Move to folder
if ($filter['fFolder']) {
$folder = $filter['fFolder'];
foreach ($uids as $uid) {
$oRange = new MailSo\Imap\SequenceSet([$uid]);
$oImapClient->MessageMove("INBOX", $folder, $oRange);
}
}
}
}
@ -87,66 +99,21 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
string $search,
string $folder = "INBOX"
): array {
try {
$bUseCache = false;
$oSearchCriterias = \MailSo\Imap\SearchCriterias::fromString(
$imapClient,
$folder,
$search,
true,
$bUseCache
);
$oParams = new \MailSo\Mail\MessageListParams();
$oParams->sSearch = $search;
$oParams->sFolderName = $folder;
$imapClient->FolderSelect($folder);
return $imapClient->MessageSearch($oSearchCriterias, true);
$bUseCache = false;
$oSearchCriterias = \MailSo\Imap\SearchCriterias::fromString(
$imapClient,
$folder,
$search,
true,
$bUseCache
);
} catch (\Throwable $e) {
$this->Manager()->logWrite(
'SearchFilters IMAP error in folder "' . $folder .
'" for search "' . $search . '": ' . $e->getMessage(),
LOG_ERR
);
return [];
}
}
private function applyActions(
\MailSo\Imap\ImapClient $imapClient,
array $filter,
array $uids,
string $folder
) {
// Mark as read/seen
if (!empty($filter['fSeen'])) {
foreach ($uids as $uid) {
$oRange = new \MailSo\Imap\SequenceSet([$uid]);
$this->Manager()->Actions()->MailClient()->MessageSetFlag(
$folder,
$oRange,
\MailSo\Imap\Enumerations\MessageFlag::SEEN
);
}
}
// Flag/Star message
if (!empty($filter['fFlag'])) {
foreach ($uids as $uid) {
$oRange = new \MailSo\Imap\SequenceSet([$uid]);
$this->Manager()->Actions()->MailClient()->MessageSetFlag(
$folder,
$oRange,
\MailSo\Imap\Enumerations\MessageFlag::FLAGGED
);
}
}
// Move to folder
if (!empty($filter['fFolder']) && $filter['fFolder'] !== -1) {
foreach ($uids as $uid) {
$oRange = new \MailSo\Imap\SequenceSet([$uid]);
$imapClient->MessageMove('INBOX', $filter['fFolder'], $oRange);
}
}
$imapClient->FolderSelect($folder);
return $imapClient->MessageSearch($oSearchCriterias, true);
}
public function GetFilters()
@ -206,6 +173,7 @@ class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
} else {
$aSettings['SFilters'][$foundIndex] = $newFilter;
}
return $this->jsonResponse(__FUNCTION__, $this->saveUserSettings($aSettings));
}

View file

@ -131,7 +131,7 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
private static function getQRCode(MainAccount $oAccount, string $secret) : string
{
$email = \rawurlencode($oAccount->Email());
// $issuer = \rawurlencode(\RainLoop\Api::Config()->Get('webmail', 'title', 'SnappyMail'));
// $issuer = \rawurlencode(\RainLoop\API::Config()->Get('webmail', 'title', 'SnappyMail'));
$QR = \SnappyMail\QRCode::getMinimumQRCode(
// "otpauth://totp/{$issuer}:{$email}?secret={$secret}&issuer={$issuer}",
"otpauth://totp/{$email}?secret={$secret}",

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Show Secret"
BUTTON_HIDE_SECRET = "Hide Secret"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not configured"
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually."
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After you’ve used a backup code to sign in, it will become inactive."
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually.\n"
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After you’ve used a backup code to sign in, it will become inactive.\n"
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
TITLE_TEST_CODE = "2-Step verification test"
LABEL_CODE = "Code"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Show Secret"
BUTTON_HIDE_SECRET = "Hide Secret"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not configured"
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually."
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After you’ve used a backup code to sign in, it will become inactive."
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually.\n"
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After you’ve used a backup code to sign in, it will become inactive.\n"
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
TITLE_TEST_CODE = "2-Step verification test"
LABEL_CODE = "Code"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Titok megjelenítése"
BUTTON_HIDE_SECRET = "Titok elrejtése"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Beállítva"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Nincs beállítva"
TWO_FACTOR_SECRET_DESC = "Importáld ezt az infót a Google Authenticator kliensedbe (vagy más TOTP kliensbe) az alábbi QR kód használatával vagy a kód manuális megadatásával."
TWO_FACTOR_BACKUP_CODES_DESC = "Ha nem kapod meg a kódokat a Google Authenticator kliensből (vagy más TOTP kliensből), akkor a bejelentkezéshez használhatod a biztonsági kódot. A biztonsági kód használata után inaktívvá válik."
TWO_FACTOR_SECRET_DESC = "Importáld ezt az infót a Google Authenticator kliensedbe (vagy más TOTP kliensbe) az alábbi QR kód használatával vagy a kód manuális megadatásával.\n"
TWO_FACTOR_BACKUP_CODES_DESC = "Ha nem kapod meg a kódokat a Google Authenticator kliensből (vagy más TOTP kliensből), akkor a bejelentkezéshez használhatod a biztonsági kódot. A biztonsági kód használata után inaktívvá válik.\n"
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Tesztelés nélkül nem lehet megváltoztatni ezt a beállítást."
TITLE_TEST_CODE = "2-lépéses hitelesítés teszt"
LABEL_CODE = "Kód"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Mostra la chiave segreta"
BUTTON_HIDE_SECRET = "Nascondi la chiave segreta"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configurato"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Non configurato"
TWO_FACTOR_SECRET_DESC = "Importa queste informazioni nella tua app Google Authenticator (o un altro programma per l'autenticazione a due fattori) utilizzando il codice QR fornito qui sotto o inserendo il codice manualmente."
TWO_FACTOR_BACKUP_CODES_DESC = "Se non riesci a ricevere i codici tramite Google Authenticator (o un altro programma per l'autenticazione a due fattori), puoi utilizzare i codici di backup per accedere. Dopo che hai utilizzato un codice di backup per accedere, diventerà inattivo."
TWO_FACTOR_SECRET_DESC = "Importa queste informazioni nella tua app Google Authenticator (o un altro programma per l'autenticazione a due fattori) utilizzando il codice QR fornito qui sotto o inserendo il codice manualmente.\n"
TWO_FACTOR_BACKUP_CODES_DESC = "Se non riesci a ricevere i codici tramite Google Authenticator (o un altro programma per l'autenticazione a due fattori), puoi utilizzare i codici di backup per accedere. Dopo che hai utilizzato un codice di backup per accedere, diventerà inattivo.\n"
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Non puoi modificare questa impostazione prima del test."
TITLE_TEST_CODE = "Test di verifica a 2 fattori"
LABEL_CODE = "Codice"

View file

@ -1,20 +0,0 @@
[PLUGIN_2FA]
LEGEND_TWO_FACTOR_AUTH = "2-етапна перевірка (TOTP)"
LABEL_ENABLE_TWO_FACTOR = "Увімкнути 2-етапну перевірку"
LABEL_TWO_FACTOR_USER = "Ім'я користувача"
LABEL_TWO_FACTOR_STATUS = "Статус"
LABEL_TWO_FACTOR_SECRET = "Ключ"
LABEL_TWO_FACTOR_BACKUP_CODES = "Резервні коди"
BUTTON_CREATE = "Create a secret"
BUTTON_ACTIVATE = "Activate"
LINK_TEST = "Тестувати"
BUTTON_SHOW_SECRET = "Показати ключ"
BUTTON_HIDE_SECRET = "Приховати ключ"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Налаштовано"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Не налаштовано"
TWO_FACTOR_SECRET_DESC = "Імпортуйте цю інформацію у свій клієнт Google Authenticator (або інший клієнт TOTP), використовуючи наданий нижче QR-код або ввівши код вручну"
TWO_FACTOR_BACKUP_CODES_DESC = "Якщо ви не можете отримати код через Google Authenticator (або інший клієнт TOTP), ви можете скористатися резервними кодами для входу. Після того, як ви використаєте резервний код для входу, він стане неактивним. Закреслюйте його"
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Ви не можете змінити це налаштування перед тестуванням."
TITLE_TEST_CODE = "Тест 2-етапної перевірки"
LABEL_CODE = "Код"
LABEL_TWO_FACTOR_CODE = "Код підтвердження"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "显示密钥"
BUTTON_HIDE_SECRET = "隐藏密钥"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "已设置"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "未设置"
TWO_FACTOR_SECRET_DESC = "将下面的信息导入 Google Authenticator (或其他 TOTP 客户端)。 扫描下面的二维码或手动输入密钥。"
TWO_FACTOR_BACKUP_CODES_DESC = "如果你无法从验证器获取验证码,你可以使用备用代码登录。 当你使用过某一个备用代码后,它将会失效。"
TWO_FACTOR_SECRET_DESC = "将下面的信息导入 Google Authenticator (或其他 TOTP 客户端)。 扫描下面的二维码或手动输入密钥。\n"
TWO_FACTOR_BACKUP_CODES_DESC = "如果你无法从验证器获取验证码,你可以使用备用代码登录。 当你使用过某一个备用代码后,它将会失效。\n"
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "完成测试前你无法更改此设置。"
TITLE_TEST_CODE = "两步验证测试"
LABEL_CODE = "代码"

View file

@ -25,7 +25,10 @@ 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) {
$array = $this->getArrayCopy();
@ -36,18 +39,18 @@ abstract class Collection extends \ArrayObject implements \JsonSerializable
}
}
public function keys(): array
public function keys() : array
{
return \array_keys($this->getArrayCopy());
}
public function Add($mItem, bool $bToTop = false): self
public function Add($mItem, bool $bToTop = false) : self
{
$this->append($mItem, $bToTop);
return $this;
}
public function Clear(): void
public function Clear() : void
{
$this->exchangeArray([]);
}
@ -61,14 +64,14 @@ abstract class Collection extends \ArrayObject implements \JsonSerializable
return $callable($this->getArrayCopy(), ...$arguments);
}
*/
public function Slice(int $offset, ?int $length = null, bool $preserve_keys = false)/*: static*/
public function Slice(int $offset, ?int $length = null, bool $preserve_keys = false)
{
return new static(
\array_slice($this->getArrayCopy(), $offset, $length, $preserve_keys)
);
}
public function Crop(?int $length = null, int $offset = 0, bool $preserve_keys = false): self
public function Crop(?int $length = null, int $offset = 0, bool $preserve_keys = false)
{
$this->exchangeArray(
\array_slice($this->getArrayCopy(), $offset, $length, $preserve_keys)
@ -76,8 +79,8 @@ abstract class Collection extends \ArrayObject implements \JsonSerializable
return $this;
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
$aNames = \explode('\\', \get_class($this));
return array(

View file

@ -54,22 +54,11 @@ abstract class DateTimeHelper
\DateTime::createFromFormat('d M Y H:i:s O', $sDateTime, static::GetUtcTimeZoneObject())
// Using T (obsolete timezone abbreviation)
?: \DateTime::createFromFormat('d M Y H:i:s T', $sDateTime, static::GetUtcTimeZoneObject());
try {
$timestamp = $oDateTime->getTimestamp();
// 398045302 is 1982-08-13 00:08:22, the date RFC 822 was created
if (398045302 > $timestamp) {
\SnappyMail\Log::notice('', "Failed to parse RFC 2822 date '{$sDateTime}'");
return 0;
}
return $timestamp;
} catch (\Throwable $error) {
// Catch integer overflow or other fatal errors
\SnappyMail\Log::notice('', "Failed to parse RFC 2822 date '{$sDateTime}'. {$error->getMessage()}");
return 0;
// 398045302 is 1982-08-13 00:08:22 the date RFC 822 was created
if (!$oDateTime || 398045302 > $oDateTime->getTimestamp()) {
\SnappyMail\Log::notice('', "Failed to parse RFC 2822 date '{$sDateTime}'");
}
return $oDateTime ? $oDateTime->getTimestamp() : 0;
}
/**

View file

@ -160,20 +160,13 @@ abstract class HtmlUtils
// Remove all remaining data-* attributes
foreach ($xpath->query('//*[@*[starts-with(name(), "data-")]]') as $oElement) {
$sTagNameLower = \strtolower($oElement->nodeName);
if ($oElement->hasAttributes()) {
// DOMNamedNodeMap is live: removing the attribute the foreach is
// currently on ends the iteration, so exactly one data-* was
// removed per element however many it carried. Collect the
// names first, then remove.
$aNames = [];
foreach ($oElement->attributes as $oAttr) {
if ('data-' === \substr(\strtolower($oAttr->nodeName), 0, 5)) {
$aNames[] = $oAttr->nodeName;
$oElement->removeAttribute($oAttr->nodeName);
}
}
foreach ($aNames as $sName) {
$oElement->removeAttribute($sName);
}
}
}

View file

@ -30,7 +30,12 @@ class Http
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;
}

View file

@ -59,7 +59,7 @@ abstract class Utils
return 'iso-8859-8';
}
return \preg_replace('/^(cp|windows)-?(12[\d]*)/i', 'windows-$2', $sEncoding);
return \preg_replace('/^(cp-?|windows?)(12[\d])/', 'windows-$1', $sEncoding);
}
public static function NormalizeCharsetByValue(string $sCharset, string $sValue) : string

View file

@ -40,27 +40,27 @@ class BodyStructure implements \JsonSerializable
*/
private array $aSubParts;
public function Charset(): string
public function Charset() : string
{
return $this->sCharset;
}
public function ContentTransferEncoding(): string
public function ContentTransferEncoding() : string
{
return $this->sContentTransferEncoding;
}
public function ContentType(): string
public function ContentType() : string
{
return $this->sContentType;
}
public function PartID(): string
public function PartID() : string
{
return $this->sPartID;
}
public function FileName(bool $bCalculateOnEmpty = false): string
public function FileName(bool $bCalculateOnEmpty = false) : string
{
$sFileName = \trim($this->sFileName);
if (\strlen($sFileName) || !$bCalculateOnEmpty) {
@ -90,7 +90,7 @@ class BodyStructure implements \JsonSerializable
return ($this->isInline() ? 'inline' : 'part' ) . $sIdx;
}
public function EstimatedSize(): int
public function EstimatedSize() : int
{
$fCoefficient = 1;
switch ($this->sContentTransferEncoding)
@ -106,17 +106,17 @@ class BodyStructure implements \JsonSerializable
return (int) ($this->iSize * $fCoefficient);
}
public function SubParts(): array
public function SubParts() : array
{
return $this->aSubParts;
}
public function isInline(): bool
public function isInline() : bool
{
return 'inline' === $this->sDisposition || \strlen($this->sContentID);
}
public function isText(): bool
public function isText() : bool
{
return 'text/html' === $this->sContentType
|| 'text/plain' === $this->sContentType
@ -125,7 +125,7 @@ class BodyStructure implements \JsonSerializable
}
// https://datatracker.ietf.org/doc/html/rfc3156#section-4
public function isPgpEncrypted(): bool
public function isPgpEncrypted() : bool
{
return 'multipart/encrypted' === $this->sContentType
&& !empty($this->aContentTypeParams['protocol'])
@ -138,7 +138,7 @@ class BodyStructure implements \JsonSerializable
}
// https://datatracker.ietf.org/doc/html/rfc3156#section-5
public function isPgpSigned(): bool
public function isPgpSigned() : bool
{
return 'multipart/signed' === $this->sContentType
&& !empty($this->aContentTypeParams['protocol'])
@ -149,7 +149,7 @@ class BodyStructure implements \JsonSerializable
}
// https://datatracker.ietf.org/doc/html/rfc2633#section-3.3
public function isSMimeEncrypted(): bool
public function isSMimeEncrypted() : bool
{
$type = \strtolower(\trim($this->aContentTypeParams['smime-type'] ?? ''));
return ContentType::isPkcs7Mime($this->sContentType)
@ -158,7 +158,7 @@ class BodyStructure implements \JsonSerializable
}
// https://www.rfc-editor.org/rfc/rfc8551.html#section-3.5
public function isSMimeSigned(): bool
public function isSMimeSigned() : bool
{
return ('multipart/signed' === $this->sContentType
&& !empty($this->aContentTypeParams['protocol'])
@ -172,7 +172,7 @@ class BodyStructure implements \JsonSerializable
);
}
protected function IsAttachment(/*BodyStructure*/ $oParent): bool
protected function IsAttachment(/*BodyStructure*/ $oParent) : bool
{
// return 'application/pgp-encrypted' !== $this->sContentType
return (!$oParent || !$oParent->isPgpEncrypted())
@ -184,14 +184,14 @@ class BodyStructure implements \JsonSerializable
);
}
public function IsFlowedFormat(): bool
public function IsFlowedFormat() : bool
{
return !empty($this->aContentTypeParams['format'])
&& 'flowed' === \strtolower(\trim($this->aContentTypeParams['format']))
&& !\in_array($this->sContentTransferEncoding, array('base64', 'quoted-printable'));
}
public function GetHtmlAndPlainParts(): array
public function GetHtmlAndPlainParts() : array
{
$aParts = [];
@ -231,7 +231,7 @@ class BodyStructure implements \JsonSerializable
return $aParts;
}
public function SearchCharset(): string
public function SearchCharset() : string
{
$gParts = $this->SearchByCallback(function ($oPart, $oParent) {
return $oPart->Charset() && $oPart->isText() && !$oPart->IsAttachment($oParent);
@ -246,7 +246,7 @@ class BodyStructure implements \JsonSerializable
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)) {
yield $this;
@ -256,7 +256,7 @@ class BodyStructure implements \JsonSerializable
}
}
public function SearchAttachmentsParts(): iterable
public function SearchAttachmentsParts() : iterable
{
return $this->SearchByCallback(function ($oItem, $oParent) {
// return $oItem->IsAttachment();
@ -264,7 +264,7 @@ class BodyStructure implements \JsonSerializable
});
}
public function SearchByContentType(string $sContentType): iterable
public function SearchByContentType(string $sContentType) : iterable
{
$sContentType = \strtolower($sContentType);
return $this->SearchByCallback(function ($oItem) use ($sContentType) {
@ -272,14 +272,14 @@ class BodyStructure implements \JsonSerializable
});
}
public function SearchByContentTypes(array $aContentTypes): iterable
public function SearchByContentTypes(array $aContentTypes) : iterable
{
return $this->SearchByCallback(function ($oItem) use ($aContentTypes) {
return \in_array($oItem->sContentType, $aContentTypes);
});
}
public function GetPartByMimeIndex(string $sMimeIndex): self
public function GetPartByMimeIndex(string $sMimeIndex) : self
{
$oPart = null;
if (\strlen($sMimeIndex)) {
@ -300,7 +300,7 @@ class BodyStructure implements \JsonSerializable
return $oPart;
}
private static function decodeAttrParameter(array $aParams, string $sParamName, string $sCharset): string
private static function decodeAttrParameter(array $aParams, string $sParamName, string $sCharset) : string
{
$sResult = '';
if (isset($aParams[$sParamName])) {
@ -349,7 +349,7 @@ class BodyStructure implements \JsonSerializable
return $sResult;
}
public static function NewInstance(array $aBodyStructure, string $sPartID = ''): ?self
public static function NewInstance(array $aBodyStructure, string $sPartID = '') : ?self
{
if (2 > \count($aBodyStructure)) {
return null;
@ -542,7 +542,7 @@ class BodyStructure implements \JsonSerializable
* Returns dict with key="charset" and value="US-ASCII" for array ("CHARSET" "US-ASCII").
* Keys are lowercased (StringDictionary itself does this), values are not altered.
*/
private static function getKeyValueListFromArrayList(array $aList): array
private static function getKeyValueListFromArrayList(array $aList) : array
{
$aDict = array();
$iLen = \count($aList);
@ -557,8 +557,8 @@ class BodyStructure implements \JsonSerializable
return $aDict;
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return array(
'mimeIndex' => $this->sPartID,

View file

@ -26,7 +26,7 @@ class FetchResponse
$this->oImapResponse = $oImapResponse;
}
public function GetEnvelope(bool $bForce = false): ?array
public function GetEnvelope(bool $bForce = false) : ?array
{
if (null === $this->aEnvelopeCache || $bForce) {
$this->aEnvelopeCache = $this->GetFetchValue(Enumerations\FetchType::ENVELOPE);
@ -34,12 +34,15 @@ class FetchResponse
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);
}
public function GetFetchEnvelopeEmailCollection(int $iIndex, string $sParentCharset = \MailSo\Base\Enumerations\Charset::ISO_8859_1): ?\MailSo\Mime\EmailCollection
public function GetFetchEnvelopeEmailCollection(int $iIndex, string $sParentCharset = \MailSo\Base\Enumerations\Charset::ISO_8859_1) : ?\MailSo\Mime\EmailCollection
{
$oResult = null;
$aEmails = $this->GetFetchEnvelopeValue($iIndex);
@ -68,7 +71,7 @@ class FetchResponse
return $oResult;
}
public function GetFetchBodyStructure(): ?BodyStructure
public function GetFetchBodyStructure() : ?BodyStructure
{
$aBodyStructureArray = $this->GetFetchValue(Enumerations\FetchType::BODYSTRUCTURE);
@ -79,8 +82,9 @@ class FetchResponse
/**
* Like: UID, RFC822.SIZE, MODSEQ, INTERNALDATE, FLAGS, BODYSTRUCTURE
* @return mixed
*/
public function GetFetchValue(string $sFetchItemName): mixed
public function GetFetchValue(string $sFetchItemName)
{
if (isset($this->oImapResponse->ResponseList[3]) && \is_array($this->oImapResponse->ResponseList[3])) {
$bNextIsValue = false;
@ -100,8 +104,9 @@ 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)]
* @return mixed
*/
public function GetHeaderFieldsValue(): string
public function GetHeaderFieldsValue() : string
{
$bNextIsValue = false;
@ -124,7 +129,7 @@ class FetchResponse
return '';
}
public static function isValidImapResponse(Response $oImapResponse): bool
public static function isValidImapResponse(Response $oImapResponse) : bool
{
return
!$oImapResponse->IsStatusResponse
@ -133,13 +138,16 @@ class FetchResponse
&& \is_array($oImapResponse->ResponseList[3]);
}
public static function hasUidAndSize(Response $oImapResponse): bool
public static function hasUidAndSize(Response $oImapResponse) : bool
{
return \in_array(Enumerations\FetchType::UID, $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])
? $aEnvelope[$iIndex] : $mNullResult;

View file

@ -53,22 +53,22 @@ class Folder implements \JsonSerializable
*/
}
public function setAttributes(array $aAttributes): void
public function setAttributes(array $aAttributes) : void
{
$this->aAttributes = \array_map('mb_strtolower', $aAttributes);
}
public function setSubscribed(): void
public function setSubscribed() : void
{
$this->aAttributes = \array_unique(\array_merge($this->aAttributes, ['\\subscribed']));
}
public function setDelimiter(?string $sDelimiter): void
public function setDelimiter(?string $sDelimiter) : void
{
$this->sDelimiter = $sDelimiter;
}
public function Name(): string
public function Name() : string
{
$sNameRaw = $this->FullName;
if ($this->sDelimiter) {
@ -78,49 +78,49 @@ class Folder implements \JsonSerializable
return $sNameRaw;
}
public function Delimiter(): ?string
public function Delimiter() : ?string
{
return $this->sDelimiter;
}
public function Selectable(): bool
public function Selectable() : bool
{
return !\in_array('\\noselect', $this->aAttributes)
&& !\in_array('\\nonexistent', $this->aAttributes);
}
public function IsSubscribed(): bool
public function IsSubscribed() : bool
{
return \in_array('\\subscribed', $this->aAttributes);
}
public function IsInbox(): bool
public function IsInbox() : bool
{
return 'INBOX' === \strtoupper($this->FullName) || \in_array('\\inbox', $this->aAttributes);
}
public function SetMetadata(string $sName, string $sData): void
public function SetMetadata(string $sName, string $sData) : void
{
$this->aMetadata[$sName] = $sData;
}
public function SetAllMetadata(array $aMetadata): void
public function SetAllMetadata(array $aMetadata) : void
{
$this->aMetadata = $aMetadata;
}
public function GetMetadata(string $sName): ?string
public function GetMetadata(string $sName) : ?string
{
return isset($this->aMetadata[$sName]) ? $this->aMetadata[$sName] : null;
}
public function Metadata(): array
public function Metadata() : array
{
return $this->aMetadata;
}
// JMAP RFC 8621
public function Role(): ?string
public function Role() : ?string
{
$role = \strtolower($this->GetMetadata(MetadataKeys::SPECIALUSE) ?: '');
if (!$role) {
@ -145,8 +145,8 @@ class Folder implements \JsonSerializable
return $role ? \ltrim($role, '\\') : null;
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
$selectable = $this->Selectable();
$result = array(

View file

@ -19,14 +19,14 @@ class FolderCollection extends \MailSo\Base\Collection
{
// public bool $Optimized = false;
public function append($oFolder, bool $bToTop = false): void
public function append($oFolder, bool $bToTop = false) : void
{
assert($oFolder instanceof Folder);
parent::append($oFolder, $bToTop);
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return array(
'@Object' => 'Collection/FolderCollection',

View file

@ -40,15 +40,15 @@ class FolderInformation implements \JsonSerializable
$this->IsWritable = $bIsWritable;
}
public function IsFlagSupported(string $sFlag): bool
public function IsFlagSupported(string $sFlag) : bool
{
return \in_array('\\*', $this->PermanentFlags)
|| \in_array($sFlag, $this->PermanentFlags);
// || \in_array($sFlag, $this->Flags);
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
$result = array(
'id' => $this->MAILBOXID,

View file

@ -49,7 +49,7 @@ class ImapClient extends \MailSo\Net\NetClient
*/
private bool $UTF8 = false;
public function Hash(): string
public function Hash() : string
{
return \md5('ImapClientHash/'.
$this->Settings->username . '@' .
@ -64,8 +64,8 @@ class ImapClient extends \MailSo\Net\NetClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
// public function Connect(Settings $oSettings): void
public function Connect(\MailSo\Net\ConnectSettings $oSettings): void
// public function Connect(Settings $oSettings) : void
public function Connect(\MailSo\Net\ConnectSettings $oSettings) : void
{
$this->aTagTimeouts['*'] = \microtime(true);
@ -79,7 +79,7 @@ class ImapClient extends \MailSo\Net\NetClient
}
}
private function StartTLS(): void
private function StartTLS() : void
{
if ($this->hasCapability('STARTTLS')) {
$this->SendRequestGetResponse('STARTTLS');
@ -93,7 +93,7 @@ class ImapClient extends \MailSo\Net\NetClient
}
}
public function supportsAuthType(string $sasl_type): bool
public function supportsAuthType(string $sasl_type) : bool
{
return $this->hasCapability("AUTH={$sasl_type}");
}
@ -104,7 +104,7 @@ class ImapClient extends \MailSo\Net\NetClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function Login(Settings $oSettings): self
public function Login(Settings $oSettings) : self
{
if ($this->bIsLoggined) {
return $this;
@ -242,7 +242,7 @@ class ImapClient extends \MailSo\Net\NetClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function Logout(): void
public function Logout() : void
{
if ($this->bIsLoggined) {
$this->bIsLoggined = false;
@ -250,12 +250,12 @@ class ImapClient extends \MailSo\Net\NetClient
}
}
public function IsLoggined(): bool
public function IsLoggined() : bool
{
return $this->IsConnected() && $this->bIsLoggined;
}
public function IsSelected(): bool
public function IsSelected() : bool
{
return $this->IsLoggined() && $this->oCurrentFolderInfo;
}
@ -265,7 +265,7 @@ class ImapClient extends \MailSo\Net\NetClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function Capability(): ?array
public function Capability() : ?array
{
if (!$this->aCapaRaw) {
$this->setCapabilities($this->SendRequestGetResponse('CAPABILITY'));
@ -276,7 +276,7 @@ class ImapClient extends \MailSo\Net\NetClient
return $this->aCapa;
}
public function Capabilities(): ?array
public function Capabilities() : ?array
{
if (!$this->aCapaRaw) {
$this->Capability();
@ -284,7 +284,7 @@ class ImapClient extends \MailSo\Net\NetClient
return $this->aCapaRaw;
}
public function CapabilityValue(string $sExtentionName): ?string
public function CapabilityValue(string $sExtentionName) : ?string
{
$sExtentionName = \trim($sExtentionName) . '=';
$aCapabilities = $this->Capability() ?: [];
@ -296,7 +296,7 @@ class ImapClient extends \MailSo\Net\NetClient
return null;
}
private function setCapabilities(ResponseCollection $oResponseCollection): void
private function setCapabilities(ResponseCollection $oResponseCollection) : void
{
$aList = $oResponseCollection->getCapabilityResult();
$this->aCapaRaw = $aList;
@ -330,7 +330,7 @@ class ImapClient extends \MailSo\Net\NetClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function hasCapability(string $sExtentionName): bool
public function hasCapability(string $sExtentionName) : bool
{
$sExtentionName = \trim($sExtentionName);
return $sExtentionName && \in_array(\strtoupper($sExtentionName), $this->Capability() ?: []);
@ -339,7 +339,7 @@ class ImapClient extends \MailSo\Net\NetClient
/**
* RFC 5161
*/
public function Enable(string|array $mCapabilityNames): void
public function Enable(/*string|array*/ $mCapabilityNames) : void
{
if (\is_string($mCapabilityNames)) {
$mCapabilityNames = [$mCapabilityNames];
@ -354,7 +354,7 @@ class ImapClient extends \MailSo\Net\NetClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function GetNamespaces(): ?NamespaceResult
public function GetNamespaces() : ?NamespaceResult
{
if (!$this->hasCapability('NAMESPACE')) {
return null;
@ -382,7 +382,7 @@ class ImapClient extends \MailSo\Net\NetClient
* and that the client will need to discover upload limits for each mailbox,
* as they might differ from mailbox to mailbox.
*/
public function AppendLimit(): ?int
public function AppendLimit() : ?int
{
$string = $this->CapabilityValue('APPENDLIMIT');
return \is_null($string) ? null : (int) $string;
@ -393,7 +393,7 @@ class ImapClient extends \MailSo\Net\NetClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function Noop(): self
public function Noop() : self
{
$this->SendRequestGetResponse('NOOP');
return $this;
@ -405,7 +405,7 @@ class ImapClient extends \MailSo\Net\NetClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function SendRequest(string $sCommand, array $aParams = array(), bool $bBreakOnLiteral = false): string
public function SendRequest(string $sCommand, array $aParams = array(), bool $bBreakOnLiteral = false) : string
{
$sCommand = \trim($sCommand);
if (!\strlen($sCommand)) {
@ -438,13 +438,13 @@ class ImapClient extends \MailSo\Net\NetClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function SendRequestGetResponse(string $sCommand, array $aParams = array()): ResponseCollection
public function SendRequestGetResponse(string $sCommand, array $aParams = array()) : ResponseCollection
{
$this->SendRequest($sCommand, $aParams);
return $this->getResponse();
}
protected function getResponseValue(ResponseCollection $oResponseCollection, int $type = 0): string
protected function getResponseValue(ResponseCollection $oResponseCollection, int $type = 0) : string
{
$oResponse = $oResponseCollection->getLast();
if ($oResponse && (!$type || $type === $oResponse->ResponseType)) {
@ -466,7 +466,7 @@ class ImapClient extends \MailSo\Net\NetClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
protected function streamResponse(?string $sEndTag = null): void
protected function streamResponse(?string $sEndTag = null) : void
{
try {
if (\is_resource($this->ConnectionResource())) {
@ -488,7 +488,7 @@ class ImapClient extends \MailSo\Net\NetClient
}
}
protected function getResponse(?string $sEndTag = null): ResponseCollection
protected function getResponse(?string $sEndTag = null) : ResponseCollection
{
try {
$oResult = new ResponseCollection;
@ -535,8 +535,8 @@ class ImapClient extends \MailSo\Net\NetClient
return $oResult;
}
// public function yieldUntaggedResponses(): \Generator
public function yieldUntaggedResponses(): iterable
// public function yieldUntaggedResponses() : \Generator
public function yieldUntaggedResponses() : iterable
{
try {
$oResult = new ResponseCollection;
@ -577,7 +577,7 @@ class ImapClient extends \MailSo\Net\NetClient
}
}
protected function prepareParamLine(array $aParams = array()): string
protected function prepareParamLine(array $aParams = array()) : string
{
$sReturn = '';
foreach ($aParams as $mParamItem) {
@ -590,18 +590,18 @@ class ImapClient extends \MailSo\Net\NetClient
return $sReturn;
}
protected function getNewTag(): string
protected function getNewTag() : string
{
++$this->iTagCount;
return $this->getCurrentTag();
}
protected function getCurrentTag(): string
protected function getCurrentTag() : string
{
return $this->TAG_PREFIX.$this->iTagCount;
}
public function EscapeString(?string $sStringForEscape): string
public function EscapeString(?string $sStringForEscape) : string
{
if (null === $sStringForEscape) {
return 'NIL';
@ -617,7 +617,7 @@ class ImapClient extends \MailSo\Net\NetClient
return '"' . \addcslashes($sStringForEscape, '\\"') . '"';
}
public function getLogName(): string
public function getLogName() : string
{
return 'IMAP';
}
@ -626,7 +626,7 @@ class ImapClient extends \MailSo\Net\NetClient
* RFC 2971
* Don't have to be logged in to call this command
*/
public function ServerID(): string
public function ServerID() : string
{
if ($this->hasCapability('ID')) {
foreach ($this->SendRequestGetResponse('ID', [null]) as $oResponse) {
@ -647,7 +647,7 @@ class ImapClient extends \MailSo\Net\NetClient
* RFC 4978
* It is RECOMMENDED that the client uses TLS compression.
*//*
public function Compress(): bool
public function Compress() : bool
{
try {
if ($this->hasCapability('COMPRESS=DEFLATE')) {
@ -663,12 +663,12 @@ class ImapClient extends \MailSo\Net\NetClient
return false;
}*/
public function EscapeFolderName(string $sFolderName): string
public function EscapeFolderName(string $sFolderName) : string
{
return $this->EscapeString($this->UTF8 ? $sFolderName : \MailSo\Base\Utils::Utf8ToUtf7Modified($sFolderName));
}
public function toUTF8(string $sText): string
public function toUTF8(string $sText) : string
{
return $this->UTF8 ? $sText : \MailSo\Base\Utils::Utf7ModifiedToUtf8($sText);
}

View file

@ -59,7 +59,7 @@ class NamespaceResult implements \JsonSerializable
}
}
public function GetPersonalPrefix(): string
public function GetPersonalPrefix() : string
{
$sPrefix = '';
if (isset($this->aPersonal[0])) {
@ -72,8 +72,8 @@ class NamespaceResult implements \JsonSerializable
return $sPrefix;
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return array(
'@Object' => 'Object/Namespaces',

View file

@ -42,7 +42,7 @@ class THREAD extends Request
parent::__construct($oImapClient);
}
public function setAlgorithm(string $sAlgorithm): void
public function setAlgorithm(string $sAlgorithm) : void
{
$sAlgorithm = \strtoupper($sAlgorithm);
if (!$this->oImapClient->hasCapability("THREAD={$sAlgorithm}")) {
@ -51,7 +51,7 @@ class THREAD extends Request
$this->sAlgorithm = $sAlgorithm;
}
public function SendRequestIterateResponse(): iterable
public function SendRequestIterateResponse() : iterable
{
$this->oImapClient->SendRequest(
($this->bUid ? 'UID THREAD' : 'THREAD'),
@ -79,7 +79,12 @@ 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)) {
$mValue = (int) $mValue;

View file

@ -28,19 +28,19 @@ class ACL implements \JsonSerializable
$this->rights = $rights;
}
public function identifier(): string
public function identifier() : string
{
return $this->identifier;
}
/** PHP 8.1
public function hasRight(string|\MailSo\Imap\Enumerations\FolderACL $right): bool
public function hasRight(string|\MailSo\Imap\Enumerations\FolderACL $right)
{
if ($right instanceof \BackedEnum) {
return \str_contains($this->rights, $right->value);
}
*/
public function hasRight(string $right): bool
public function hasRight(string $right) : bool
{
$const = '\\MailSo\\Imap\\Enumerations\\FolderACL::' . \strtoupper($right);
if (\defined($const)) {
@ -49,8 +49,8 @@ class ACL implements \JsonSerializable
return \str_contains($this->rights, $right);
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return [
'@Object' => 'Object/FolderACLRights',

View file

@ -53,7 +53,7 @@ class SequenceSet /*extends \SplFixedArray*/ implements \Countable
return \in_array($value, $this->data);
}
public function indexOf($value): int|false
public function indexOf($value)/*: int|false*/
{
return \array_search($value, $this->data);
}

View file

@ -40,7 +40,7 @@ class Settings extends \MailSo\Net\ConnectSettings
public array
$disabled_capabilities = [];
public static function fromArray(array $aSettings): self
public static function fromArray(array $aSettings) : self
{
$object = parent::fromArray($aSettings);
@ -119,8 +119,8 @@ class Settings extends \MailSo\Net\ConnectSettings
return $object;
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
if (\in_array('SORT', $this->disabled_capabilities)) {
$this->disabled_capabilities[] = 'ESORT';

View file

@ -48,7 +48,7 @@ abstract class Driver
$this->oTimeZone = new \DateTimeZone('UTC');
}
public function SetTimeZone(\DateTimeZone|string $mTimeZone): self
public function SetTimeZone(/*\DateTimeZone | string*/ $mTimeZone) : self
{
$this->oTimeZone = $mTimeZone instanceof \DateTimeZone
? $mTimeZone
@ -56,43 +56,43 @@ abstract class Driver
return $this;
}
public function DisableGuidPrefix(): self
public function DisableGuidPrefix() : self
{
$this->bGuidPrefix = false;
return $this;
}
public function DisableTimePrefix(): self
public function DisableTimePrefix() : self
{
$this->bTimePrefix = false;
return $this;
}
public function DisableTypedPrefix(): self
public function DisableTypedPrefix() : self
{
$this->bTypedPrefix = false;
return $this;
}
abstract protected function writeImplementation($mDesc): bool;
abstract protected function writeImplementation($mDesc) : bool;
protected function clearImplementation(): bool
protected function clearImplementation() : bool
{
return true;
}
protected function getTimeWithMicroSec(): string
protected function getTimeWithMicroSec() : string
{
return \substr((new \DateTime('now', $this->oTimeZone))->format('Y-m-d H:i:s.u'), 0, -3);
}
protected function getTypedPrefix(int $iType, string $sName = ''): string
protected function getTypedPrefix(int $iType, string $sName = '') : string
{
$sName = \strlen($sName) ? $sName : $this->sName;
return isset(self::PREFIXES[$iType]) ? $sName . self::PREFIXES[$iType].': ' : '';
}
final public function Write(string $sDesc, int $iType = \LOG_INFO, string $sName = '', bool $bDiplayCrLf = false): bool
final public function Write(string $sDesc, int $iType = \LOG_INFO, string $sName = '', bool $bDiplayCrLf = false)
{
$sDesc = \ltrim(
($this->bTimePrefix ? '['.$this->getTimeWithMicroSec().']' : '').
@ -105,7 +105,7 @@ abstract class Driver
return $this->writeImplementation($sDesc);
}
final public function Clear(): bool
final public function Clear() : bool
{
return $this->clearImplementation();
}

View file

@ -84,7 +84,7 @@ class Logger extends \SplFixedArray
/**
* @staticvar string $sCache;
*/
public static function Guid(): string
public static function Guid() : string
{
static $sCache = null;
if (null === $sCache) {
@ -94,7 +94,7 @@ class Logger extends \SplFixedArray
return $sCache;
}
public function append($oDriver): void
public function append($oDriver) : void
{
if ($oDriver) {
$this->setSize(1);
@ -102,7 +102,7 @@ class Logger extends \SplFixedArray
}
}
public function IsEnabled(): bool
public function IsEnabled() : bool
{
return 0 < $this->count();
}
@ -110,7 +110,7 @@ class Logger extends \SplFixedArray
public function AddSecret(
#[\SensitiveParameter]
string $sWord
): void
) : void
{
// $this->bShowSecrets && $this->Write("AddSecret '{$sWord}'", \LOG_INFO);
$sWord = \trim($sWord);
@ -121,18 +121,18 @@ class Logger extends \SplFixedArray
}
}
public function SetShowSecrets(bool $bShow): self
public function SetShowSecrets(bool $bShow) : self
{
$this->bShowSecrets = $bShow;
return $this;
}
public function ShowSecrets(): bool
public function ShowSecrets() : bool
{
return $this->bShowSecrets;
}
public function SetLevel(int $iLevel): self
public function SetLevel(int $iLevel) : self
{
$this->iLevel = $iLevel;
return $this;
@ -150,7 +150,7 @@ class Logger extends \SplFixedArray
\E_USER_ERROR => \LOG_ERR,
\E_USER_WARNING => \LOG_WARNING,
\E_USER_NOTICE => \LOG_NOTICE,
// \E_STRICT => \LOG_CRIT,
\E_STRICT => \LOG_CRIT,
\E_RECOVERABLE_ERROR => \LOG_ERR,
\E_DEPRECATED => \LOG_INFO,
\E_USER_DEPRECATED => \LOG_INFO
@ -168,13 +168,13 @@ class Logger extends \SplFixedArray
\E_USER_ERROR => '-USER',
\E_USER_WARNING => '-USER',
\E_USER_NOTICE => '-USER',
// \E_STRICT => '-STRICT',
\E_STRICT => '-STRICT',
\E_RECOVERABLE_ERROR => '-RECOVERABLE',
\E_DEPRECATED => '-DEPRECATED',
\E_USER_DEPRECATED => '-USER_DEPRECATED'
);
public function __phpErrorHandler(int $iErrNo, string $sErrStr, string $sErrFile, int $iErrLine): bool
public function __phpErrorHandler(int $iErrNo, string $sErrStr, string $sErrFile, int $iErrLine) : bool
{
if (\error_reporting() & $iErrNo) {
$this->Write(
@ -196,7 +196,7 @@ class Logger extends \SplFixedArray
\error_log('Uncaught exception: ' . $oException);
}
public function __loggerShutDown(): void
public function __loggerShutDown() : void
{
if ($this->bUsed) {
$error = \error_get_last();
@ -206,7 +206,7 @@ class Logger extends \SplFixedArray
}
}
public function signalHandler(int $signo, mixed $siginfo = null): void
public function signalHandler($signo, /*?array*/$siginfo = null)
{
if (\SIGTERM == $signo) {
exit;
@ -219,7 +219,7 @@ class Logger extends \SplFixedArray
}
}
public function Write(string $sDesc, int $iType = \LOG_INFO, string $sName = '', bool $bDiplayCrLf = false): bool
public function Write(string $sDesc, int $iType = \LOG_INFO, string $sName = '', bool $bDiplayCrLf = false) : bool
{
if ($this->iLevel < $iType) {
return true;
@ -241,14 +241,17 @@ class Logger extends \SplFixedArray
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);
}
private $aExceptions = [];
public function WriteException(\Throwable $oException, int $iType = \LOG_NOTICE, string $sName = ''): void
public function WriteException(\Throwable $oException, int $iType = \LOG_NOTICE, string $sName = '') : void
{
if (!\in_array($oException, $this->aExceptions)) {
$this->Write((string) $oException, $iType, $sName);
@ -256,7 +259,7 @@ class Logger extends \SplFixedArray
}
}
public function WriteExceptionShort(\Throwable $oException, int $iType = \LOG_NOTICE, string $sName = ''): void
public function WriteExceptionShort(\Throwable $oException, int $iType = \LOG_NOTICE, string $sName = '') : void
{
if (!\in_array($oException, $this->aExceptions)) {
$this->Write($oException->getMessage(), $iType, $sName);

View file

@ -32,7 +32,7 @@ class Attachment implements \JsonSerializable
$this->oBodyStructure = $oBodyStructure;
}
public function Clear(): self
public function Clear() : self
{
$this->sFolder = '';
$this->iUid = 0;
@ -41,23 +41,23 @@ class Attachment implements \JsonSerializable
return $this;
}
public function Folder(): string
public function Folder() : string
{
return $this->sFolder;
}
public function Uid(): int
public function Uid() : int
{
return $this->iUid;
}
public function __call(string $name, array $arguments): mixed
public function __call(string $name, array $arguments) //: mixed
{
return $this->oBodyStructure->{$name}(...$arguments);
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return \array_merge([
'@Object' => 'Object/Attachment',

View file

@ -37,12 +37,12 @@ class MailClient
$this->oImapClient = new \MailSo\Imap\ImapClient;
}
public function ImapClient(): \MailSo\Imap\ImapClient
public function ImapClient() : \MailSo\Imap\ImapClient
{
return $this->oImapClient;
}
private function getEnvelopeOrHeadersRequestString(): string
private function getEnvelopeOrHeadersRequestString() : string
{
if ($this->oImapClient->Settings->message_all_headers) {
return FetchType::BODY_HEADER_PEEK;
@ -115,7 +115,7 @@ class MailClient
* @throws \MailSo\Imap\Exceptions\*
* @throws \MailSo\Mail\Exceptions\*
*/
public function MessageSetFlag(string $sFolderName, SequenceSet $oRange, string $sMessageFlag, bool $bSetAction = true, bool $bSkipUnsupportedFlag = false): void
public function MessageSetFlag(string $sFolderName, SequenceSet $oRange, string $sMessageFlag, bool $bSetAction = true, bool $bSkipUnsupportedFlag = false) : void
{
if (\count($oRange)) {
if ($this->oImapClient->FolderSelect($sFolderName)->IsFlagSupported($sMessageFlag)) {
@ -133,7 +133,7 @@ class MailClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function Message(string $sFolderName, int $iIndex, bool $bIndexIsUid = true, ?\MailSo\Cache\CacheClient $oCacher = null): ?Message
public function Message(string $sFolderName, int $iIndex, bool $bIndexIsUid = true, ?\MailSo\Cache\CacheClient $oCacher = null) : ?Message
{
if (1 > $iIndex) {
throw new \ValueError;
@ -181,12 +181,14 @@ class MailClient
/**
* Streams mime part to $mCallback
*
* @param mixed $mCallback
*
* @throws \InvalidArgumentException
* @throws \MailSo\RuntimeException
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function MessageMimeStream(callable $mCallback, string $sFolderName, int $iIndex, string $sMimeIndex): bool
public function MessageMimeStream($mCallback, string $sFolderName, int $iIndex, string $sMimeIndex) : bool
{
if (!\is_callable($mCallback)) {
throw new \ValueError;
@ -277,7 +279,7 @@ class MailClient
return ($aFetchResponse && 1 === \count($aFetchResponse));
}
public function MessageAppendFile(string $sMessageFileName, string $sFolderToSave, ?array $aAppendFlags = null): int
public function MessageAppendFile(string $sMessageFileName, string $sFolderToSave, ?array $aAppendFlags = null) : int
{
if (!\is_file($sMessageFileName) || !\is_readable($sMessageFileName)) {
throw new \ValueError;
@ -297,7 +299,7 @@ class MailClient
* Returns list of new messages since $iPrevUidNext
* Currently only for INBOX
*/
private function getFolderNextMessageInformation(string $sFolderName, int $iPrevUidNext, int $iCurrentUidNext): array
private function getFolderNextMessageInformation(string $sFolderName, int $iPrevUidNext, int $iCurrentUidNext) : array
{
$aNewMessages = array();
@ -347,7 +349,7 @@ class MailClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function FolderInformation(string $sFolderName, int $iPrevUidNext = 0, ?SequenceSet $oRange = null): array
public function FolderInformation(string $sFolderName, int $iPrevUidNext = 0, ?SequenceSet $oRange = null) : array
{
if ($oRange) {
// $aInfo = $this->oImapClient->FolderExamine($sFolderName)->jsonSerialize();
@ -389,7 +391,7 @@ class MailClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function FolderHash(string $sFolderName): string
public function FolderHash(string $sFolderName) : string
{
try
{
@ -403,7 +405,7 @@ class MailClient
return '';
}
public function MessageThread(string $sFolderName, string $sMessageID): MessageCollection
public function MessageThread(string $sFolderName, string $sMessageID) : MessageCollection
{
$this->oImapClient->FolderExamine($sFolderName);
@ -432,7 +434,7 @@ class MailClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
protected function ThreadsMap(string $sAlgorithm, MessageCollection $oMessageCollection, ?\MailSo\Cache\CacheClient $oCacher, bool $bBackground = false): array
protected function ThreadsMap(string $sAlgorithm, MessageCollection $oMessageCollection, ?\MailSo\Cache\CacheClient $oCacher, bool $bBackground = false) : array
{
$oFolderInfo = $oMessageCollection->FolderInfo;
$sFolderName = $oFolderInfo->FullName;
@ -510,7 +512,7 @@ class MailClient
}
// All threads UID's except the most recent UID of each thread
protected function ThreadsOldUids(array $aAllThreads, MessageCollection $oMessageCollection, ?\MailSo\Cache\CacheClient $oCacher, bool $bBackground = false): array
protected function ThreadsOldUids(array $aAllThreads, MessageCollection $oMessageCollection, ?\MailSo\Cache\CacheClient $oCacher, bool $bBackground = false) : array
{
$oFolderInfo = $oMessageCollection->FolderInfo;
@ -583,7 +585,7 @@ class MailClient
* @throws \MailSo\Imap\Exceptions\*
*/
protected function MessageListByRequestIndexOrUids(MessageCollection $oMessageCollection, SequenceSet $oRange,
array &$aAllThreads = [], array &$aUnseenUIDs = []): void
array &$aAllThreads = [], array &$aUnseenUIDs = []) : void
{
if (\count($oRange)) {
$aFetchItems = array(
@ -631,7 +633,7 @@ class MailClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
private function GetUids(MessageListParams $oParams, FolderInformation $oInfo, bool $onlyCache = false): ?array
private function GetUids(MessageListParams $oParams, FolderInformation $oInfo, bool $onlyCache = false) : ?array
{
$oCacher = $oParams->oCacher;
$sFolderName = $oParams->sFolderName;
@ -723,7 +725,7 @@ class MailClient
return $aResultUids;
}
public function MessageListUnseen(MessageListParams $oParams, FolderInformation $oInfo): array
public function MessageListUnseen(MessageListParams $oParams, FolderInformation $oInfo) : array
{
$oUnseenParams = new MessageListParams;
$oUnseenParams->sFolderName = $oParams->sFolderName;
@ -747,7 +749,7 @@ class MailClient
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function MessageList(MessageListParams $oParams): MessageCollection
public function MessageList(MessageListParams $oParams) : MessageCollection
{
if (0 > $oParams->iOffset || 0 > $oParams->iLimit) {
throw new \ValueError;
@ -909,7 +911,7 @@ class MailClient
return $oMessageCollection;
}
public function FindMessageUidByMessageId(string $sFolderName, string $sMessageId): ?int
public function FindMessageUidByMessageId(string $sFolderName, string $sMessageId) : ?int
{
if (!\strlen($sMessageId)) {
throw new \ValueError;
@ -922,7 +924,7 @@ class MailClient
return 1 === \count($aUids) && \is_numeric($aUids[0]) ? (int) $aUids[0] : null;
}
public function Folders(string $sParent, string $sListPattern, bool $bUseListSubscribeStatus): ?FolderCollection
public function Folders(string $sParent, string $sListPattern, bool $bUseListSubscribeStatus) : ?FolderCollection
{
$oFolderCollection = $this->oImapClient->FolderStatusList($sParent, $sListPattern);
if (!$oFolderCollection->count()) {
@ -955,7 +957,7 @@ class MailClient
/**
* @throws \ValueError
*/
public function FolderCreate(string $sFolderNameInUtf8, string $sFolderParentFullName = '', bool $bSubscribeOnCreation = true, string $sDelimiter = ''): ?\MailSo\Imap\Folder
public function FolderCreate(string $sFolderNameInUtf8, string $sFolderParentFullName = '', bool $bSubscribeOnCreation = true, string $sDelimiter = '') : ?\MailSo\Imap\Folder
{
$sFolderNameInUtf8 = \trim($sFolderNameInUtf8);
$sFolderParentFullName = \trim($sFolderParentFullName);
@ -1002,7 +1004,7 @@ class MailClient
/**
* @throws \InvalidArgumentException
*/
public function FolderRename(string $sPrevFolderFullName, string $sNewFolderFullName): self
public function FolderRename(string $sPrevFolderFullName, string $sNewFolderFullName) : self
{
if (!\strlen($sPrevFolderFullName) || !\strlen($sNewFolderFullName)) {
throw new \ValueError;
@ -1045,7 +1047,7 @@ class MailClient
/**
* @throws \InvalidArgumentException
*/
public function SetLogger(?\MailSo\Log\Logger $oLogger): void
public function SetLogger(?\MailSo\Log\Logger $oLogger) : void
{
$this->oLogger = $oLogger;
$this->oImapClient->SetLogger($oLogger);

View file

@ -92,52 +92,52 @@ class Message implements \JsonSerializable
return \property_exists($this, $k) ? $this->$k : null;
}
public function Subject(): string
public function Subject() : string
{
return $this->sSubject;
}
public function From(): ?\MailSo\Mime\EmailCollection
public function From() : ?\MailSo\Mime\EmailCollection
{
return $this->oFrom;
}
public function Uid(): int
public function Uid() : int
{
return $this->Uid;
}
public function Attachments(): ?AttachmentCollection
public function Attachments() : ?AttachmentCollection
{
return $this->Attachments;
}
public function setPlain(string $value): void
public function setPlain(string $value) : void
{
$this->sPlain = $value;
}
public function setHtml(string $value): void
public function setHtml(string $value) : void
{
$this->sHtml = $value;
}
private function setSpamScore($value): void
private function setSpamScore($value) : void
{
$this->SpamScore = \intval(\max(0, \min(100, $value)));
}
public function SetThreads(array $aThreadUIDs): void
public function SetThreads(array $aThreadUIDs)
{
$this->aThreadUIDs = $aThreadUIDs;
}
public function SetThreadUnseen(array $aUnseenUIDs): void
public function SetThreadUnseen(array $aUnseenUIDs)
{
$this->aThreadUnseenUIDs = $aUnseenUIDs;
}
public static function fromFetchResponse(string $sFolder, \MailSo\Imap\FetchResponse $oFetchResponse, ?\MailSo\Imap\BodyStructure $oBodyStructure = null): self
public static function fromFetchResponse(string $sFolder, \MailSo\Imap\FetchResponse $oFetchResponse, ?\MailSo\Imap\BodyStructure $oBodyStructure = null) : self
{
$oMessage = new self;
@ -463,7 +463,7 @@ class Message implements \JsonSerializable
return $oMessage;
}
public function ETag(string $sClientHash): string
public function ETag(string $sClientHash) : string
{
return \md5('MessageHash/' . \implode('/', [
$this->sFolder,
@ -477,7 +477,7 @@ class Message implements \JsonSerializable
// https://datatracker.ietf.org/doc/html/rfc5788#section-3.4.1
// Thunderbird $label1 is same as $Important?
// Thunderbird $label4 is same as $todo?
protected function getFlags(): array
protected function getFlags() : array
{
return \array_unique(\str_replace(
['$readreceipt', '$replied', /* 'junk', 'nonjunk', '$queued', '$sent', 'sent'*/],
@ -486,8 +486,8 @@ class Message implements \JsonSerializable
));
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
/*
// JMAP-only RFC8621 keywords (RFC5788)

View file

@ -36,7 +36,8 @@ class MessageCollection extends \MailSo\Base\Collection
public int $ThreadUid = 0;
public ?\MailSo\Imap\FolderInformation $FolderInfo = null;
// MailSo\Imap\FolderInformation
public $FolderInfo = null;
public array $NewMessages = array();
@ -44,19 +45,19 @@ class MessageCollection extends \MailSo\Base\Collection
public bool $Limited = false;
public function append($oMessage, bool $bToTop = false): void
public function append($oMessage, bool $bToTop = false) : void
{
assert($oMessage instanceof Message);
parent::append($oMessage, $bToTop);
}
public function Clear(): void
public function Clear() : void
{
throw new \BadMethodCallException('disallowed');
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return \array_merge(parent::jsonSerialize(), array(
'totalEmails' => $this->totalEmails,

View file

@ -200,8 +200,8 @@ class Email implements \JsonSerializable
return $this->ToString();
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return array(
'@Object' => 'Object/Email',

View file

@ -27,13 +27,13 @@ class EmailCollection extends \MailSo\Base\Collection
}
}
public function append($oEmail, bool $bToTop = false): void
public function append($oEmail, bool $bToTop = false) : void
{
assert($oEmail instanceof Email);
parent::append($oEmail, $bToTop);
}
public function Unique(): self
public function Unique() : self
{
$aReturn = array();
@ -47,7 +47,7 @@ class EmailCollection extends \MailSo\Base\Collection
return new static($aReturn);
}
public function ToString(bool $bConvertSpecialsName = false, bool $bIdn = false): string
public function ToString(bool $bConvertSpecialsName = false, bool $bIdn = false) : string
{
$aReturn = array();
foreach ($this as $oEmail) {
@ -57,12 +57,12 @@ class EmailCollection extends \MailSo\Base\Collection
return \implode(', ', $aReturn);
}
public function __toString(): string
public function __toString() : string
{
return $this->ToString();
}
private function parseEmailAddresses(string $sRawEmails): void
private function parseEmailAddresses(string $sRawEmails) : void
{
// $sRawEmails = \MailSo\Base\Utils::Trim($sRawEmails);
$sRawEmails = \trim($sRawEmails);
@ -156,8 +156,8 @@ class EmailCollection extends \MailSo\Base\Collection
}
}
// public function jsonSerialize(): array
public function jsonSerialize(): mixed
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return \array_slice($this->getArrayCopy(), 0, 100);
}

Some files were not shown because too many files have changed in this diff Show more