mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-08-25 10:09:20 +03:00
Merge branch 'upstream/master' into dev/sync-nextcloud-addressbook
This commit is contained in:
commit
957654c746
1528 changed files with 85567 additions and 32765 deletions
|
|
@ -1,51 +1,188 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
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
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install
|
||||
COPY . .
|
||||
# Patch release.php with hotfix from: https://github.com/xgbstar1/snappymail-docker/blob/main/Dockerfile, so that release.php doesn't fail with error
|
||||
RUN sed -i 's_^if.*rename.*snappymail.v.0.0.0.*$_if (!!system("mv snappymail/v/0.0.0 snappymail/v/{$package->version}")) {_' cli/release.php || true
|
||||
RUN php release.php
|
||||
RUN set -eux; \
|
||||
VERSION=$( ls build/dist/releases/webmail ); \
|
||||
ls -al build/dist/releases/webmail/$VERSION/snappymail-$VERSION.tar.gz; \
|
||||
mkdir -p /snappymail; \
|
||||
tar -zxvf build/dist/releases/webmail/$VERSION/snappymail-$VERSION.tar.gz -C /snappymail; \
|
||||
find /snappymail -type d -exec chmod 550 {} \; ; \
|
||||
find /snappymail -type f -exec chmod 440 {} \; ; \
|
||||
find /snappymail/data -type d -exec chmod 750 {} \; ; \
|
||||
# Remove unneeded files
|
||||
rm -v /snappymail/README.md /snappymail/_include.php
|
||||
|
||||
# Inspired by the original Rainloop dockerfile from youtous on GitLab
|
||||
FROM php:8.1-fpm-bullseye
|
||||
FROM php:8.2-fpm-alpine AS final
|
||||
|
||||
ARG FILES_ZIP
|
||||
|
||||
|
||||
|
||||
LABEL org.label-schema.description="SnappyMail webmail client image using nginx, php-fpm based on Debian Buster"
|
||||
|
||||
ENV UID=991 GID=991 UPLOAD_MAX_SIZE=25M LOG_TO_STDERR=true MEMORY_LIMIT=128M SECURE_COOKIES=true
|
||||
ENV fpm.pool.clear_env=false
|
||||
LABEL org.label-schema.description="SnappyMail webmail client image using nginx, php-fpm on Alpine"
|
||||
|
||||
# Install dependencies such as nginx
|
||||
RUN mkdir -p /usr/share/man/man1/ /usr/share/man/man3/ /usr/share/man/man7/ && \
|
||||
apt-get update -q --fix-missing && \
|
||||
apt-get -y upgrade && \
|
||||
apt-get install --no-install-recommends -y \
|
||||
apt-transport-https gnupg openssl wget curl ca-certificates nginx supervisor sudo \
|
||||
unzip libzip-dev libxml2-dev libldb-dev libldap2-dev \
|
||||
sqlite3 libsqlite3-dev libsqlite3-0 libpq-dev postgresql-client mariadb-client logrotate \
|
||||
zip mlocate libpcre3-dev libicu-dev \
|
||||
build-essential chrpath libssl-dev \
|
||||
libxft-dev libfreetype6 libfreetype6-dev \
|
||||
libpng-dev libjpeg62-turbo-dev \
|
||||
libfontconfig1 libfontconfig1-dev \
|
||||
&& \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache ca-certificates nginx supervisor bash
|
||||
|
||||
# Install PHP extensions
|
||||
RUN php -m && \
|
||||
docker-php-ext-configure ldap --with-libdir=lib/$(uname -m)-linux-gnu/ && \
|
||||
docker-php-ext-configure intl && \
|
||||
docker-php-ext-configure gd --with-freetype --with-jpeg && \
|
||||
docker-php-ext-install ldap opcache pdo_mysql pdo_pgsql zip intl gd && \
|
||||
php -m
|
||||
# apcu
|
||||
RUN set -eux; \
|
||||
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
||||
pecl install apcu; \
|
||||
docker-php-ext-enable apcu; \
|
||||
docker-php-source delete; \
|
||||
apk del .build-dependencies;
|
||||
|
||||
# gd
|
||||
RUN set -eux; \
|
||||
apk add --no-cache freetype libjpeg-turbo libpng; \
|
||||
apk add --no-cache --virtual .deps freetype-dev libjpeg-turbo-dev libpng-dev; \
|
||||
docker-php-ext-configure gd --with-freetype --with-jpeg; \
|
||||
docker-php-ext-install gd; \
|
||||
apk del .deps
|
||||
|
||||
# gmagick
|
||||
# RUN set -eux; \
|
||||
# apk add --no-cache graphicsmagick libgomp; \
|
||||
# apk add --no-cache --virtual .deps graphicsmagick-dev libtool; \
|
||||
# apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
||||
# pecl install gmagick-2.0.6RC1; \
|
||||
# docker-php-ext-enable gmagick; \
|
||||
# docker-php-source delete; \
|
||||
# apk del .build-dependencies; \
|
||||
# apk del .deps
|
||||
|
||||
# gnupg
|
||||
RUN set -eux; \
|
||||
apk add --no-cache gnupg gpgme; \
|
||||
apk add --no-cache --virtual .deps gpgme-dev; \
|
||||
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
||||
pecl install gnupg; \
|
||||
docker-php-ext-enable gnupg; \
|
||||
docker-php-source delete; \
|
||||
apk del .build-dependencies; \
|
||||
apk del .deps
|
||||
|
||||
# imagick
|
||||
RUN set -eux; \
|
||||
apk add --no-cache imagemagick libgomp; \
|
||||
apk add --no-cache --virtual .deps imagemagick-dev; \
|
||||
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
||||
echo | pecl install imagick; \
|
||||
docker-php-ext-enable imagick; \
|
||||
docker-php-source delete; \
|
||||
apk del .build-dependencies; \
|
||||
apk del .deps
|
||||
|
||||
# intl
|
||||
RUN set -eux; \
|
||||
apk add --no-cache icu-libs; \
|
||||
apk add --no-cache --virtual .deps icu-dev; \
|
||||
docker-php-ext-configure intl; \
|
||||
docker-php-ext-install intl; \
|
||||
apk del .deps
|
||||
|
||||
# ldap
|
||||
RUN set -eux; \
|
||||
apk add --no-cache libldap; \
|
||||
apk add --no-cache --virtual .deps openldap-dev; \
|
||||
docker-php-ext-configure ldap; \
|
||||
docker-php-ext-install ldap; \
|
||||
apk del .deps
|
||||
|
||||
# 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; \
|
||||
apk add --no-cache --virtual .deps postgresql-dev; \
|
||||
docker-php-ext-install pdo_pgsql; \
|
||||
apk del .deps
|
||||
|
||||
# redis
|
||||
RUN set -eux; \
|
||||
apk add --no-cache liblzf zstd-libs; \
|
||||
apk add --no-cache --virtual .deps zstd-dev; \
|
||||
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
||||
pecl install igbinary; \
|
||||
docker-php-ext-enable igbinary; \
|
||||
pecl install --configureoptions 'enable-redis-igbinary="yes" enable-redis-lzf="yes" enable-redis-zstd="yes"' redis; \
|
||||
docker-php-ext-enable redis; \
|
||||
docker-php-source delete; \
|
||||
apk del .build-dependencies; \
|
||||
apk del .deps
|
||||
|
||||
# tidy
|
||||
RUN set -eux; \
|
||||
apk add --no-cache tidyhtml; \
|
||||
apk add --no-cache --virtual .deps tidyhtml-dev; \
|
||||
docker-php-ext-install tidy; \
|
||||
apk del .deps
|
||||
|
||||
# uuid
|
||||
RUN set -eux; \
|
||||
apk add --no-cache libuuid; \
|
||||
apk add --no-cache --virtual .deps util-linux-dev; \
|
||||
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
||||
pecl install uuid; \
|
||||
docker-php-ext-enable uuid; \
|
||||
docker-php-source delete; \
|
||||
apk del .build-dependencies; \
|
||||
apk del .deps
|
||||
|
||||
# xxtea - Manually install php8 compatible version from https://github.com/xxtea/xxtea-pecl master branch
|
||||
RUN set -eux; \
|
||||
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
||||
wget -q https://github.com/xxtea/xxtea-pecl/tarball/3f5888a29045e12301254151737c5dab4523a1c1 -O xxtea.tar; \
|
||||
echo '9cbfd9c27255767deb26ddedf69e738d401d88ac9762d82c8510f9768842ca18 xxtea.tar' | sha256sum -c -; \
|
||||
tar -C /usr/src -xvf xxtea.tar; \
|
||||
cd /usr/src/xxtea-xxtea-pecl-3f5888a; \
|
||||
phpize; \
|
||||
./configure --with-php-config=/usr/local/bin/php-config --enable-xxtea=yes; \
|
||||
make install; \
|
||||
docker-php-ext-enable xxtea; \
|
||||
cd -; \
|
||||
rm -fv xxtea.tar; \
|
||||
rm -rfv /usr/src/xxtea*; \
|
||||
apk del .build-dependencies;
|
||||
|
||||
# zip
|
||||
RUN set -eux; \
|
||||
apk add --no-cache libzip; \
|
||||
apk add --no-cache --virtual .deps libzip-dev; \
|
||||
docker-php-ext-install zip; \
|
||||
apk del .deps
|
||||
|
||||
# Install snappymail
|
||||
WORKDIR /tmp
|
||||
COPY ${FILES_ZIP} .
|
||||
RUN mkdir /snappymail && \
|
||||
unzip -q ${FILES_ZIP} -d /snappymail && \
|
||||
find /snappymail -type d -exec chmod 755 {} \; && \
|
||||
find /snappymail -type f -exec chmod 644 {} \; && \
|
||||
rm -rf ${FILES_ZIP}
|
||||
# The 'www-data' user/group in alpine is 82:82. The 'nginx' user/group in alpine is 101:101, and is part of www-data group
|
||||
COPY --chown=www-data:www-data --from=builder /snappymail /snappymail
|
||||
# Use a custom snappymail data folder
|
||||
RUN mv -v /snappymail/data /var/lib/snappymail;
|
||||
# Setup configs
|
||||
COPY --chown=root:root .docker/release/files /
|
||||
RUN set -eux; \
|
||||
chown www-data:www-data /snappymail/include.php; \
|
||||
chmod 440 /snappymail/include.php; \
|
||||
chmod +x /entrypoint.sh; \
|
||||
# Disable the built-in php-fpm configs, since we're using our own config
|
||||
mv -v /usr/local/etc/php-fpm.d/docker.conf /usr/local/etc/php-fpm.d/docker.conf.disabled; \
|
||||
mv -v /usr/local/etc/php-fpm.d/www.conf /usr/local/etc/php-fpm.d/www.conf.disabled; \
|
||||
mv -v /usr/local/etc/php-fpm.d/zz-docker.conf /usr/local/etc/php-fpm.d/zz-docker.conf.disabled;
|
||||
|
||||
# Install other content
|
||||
COPY files /
|
||||
RUN chmod +x /entrypoint.sh && chmod +x /logrotate-loop.sh
|
||||
VOLUME /snappymail/data
|
||||
USER root
|
||||
WORKDIR /snappymail
|
||||
VOLUME /var/lib/snappymail
|
||||
EXPOSE 8888
|
||||
EXPOSE 9000
|
||||
ENTRYPOINT []
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
|
|
|||
92
.docker/release/files/entrypoint.sh
Normal file → Executable file
92
.docker/release/files/entrypoint.sh
Normal file → Executable file
|
|
@ -1,23 +1,20 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Create not root user
|
||||
groupadd --gid "$GID" php-cli -f
|
||||
adduser --uid "$UID" --disabled-password --gid "$GID" --shell /bin/bash --home /home/php-cli php-cli --force --gecos ""
|
||||
|
||||
DEBUG=${DEBUG:-}
|
||||
if [ "$DEBUG" = 'true' ]; then
|
||||
set -x
|
||||
fi
|
||||
UPLOAD_MAX_SIZE=${UPLOAD_MAX_SIZE:-25M}
|
||||
MEMORY_LIMIT=${MEMORY_LIMIT:-128M}
|
||||
SECURE_COOKIES=${SECURE_COOKIES:-true}
|
||||
|
||||
# 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
|
||||
|
||||
# Set log output to STDERR if wanted (LOG_TO_STDERR=true)
|
||||
if [ "$LOG_TO_STDERR" = true ]; then
|
||||
echo "[INFO] Logging to stderr activated"
|
||||
sed -i "s/.*error_log.*$/error_log \/dev\/stderr warn;/" /etc/nginx/nginx.conf
|
||||
sed -i "s/.*error_log.*$/php_admin_value[error_log] = \/dev\/stderr/" /usr/local/etc/php-fpm.d/php-fpm.conf
|
||||
fi
|
||||
|
||||
# Secure cookies
|
||||
if [ "${SECURE_COOKIES}" = true ]; then
|
||||
if [ "${SECURE_COOKIES}" = 'true' ]; then
|
||||
echo "[INFO] Secure cookies activated"
|
||||
{
|
||||
echo 'session.cookie_httponly = On';
|
||||
|
|
@ -26,43 +23,58 @@ if [ "${SECURE_COOKIES}" = true ]; then
|
|||
} > /usr/local/etc/php/conf.d/cookies.ini;
|
||||
fi
|
||||
|
||||
# Copy snappymail default config if absent
|
||||
SNAPPYMAIL_CONFIG_FILE=/snappymail/data/_data_/_default_/configs/application.ini
|
||||
echo "[INFO] Snappymail version: $( ls /snappymail/snappymail/v )"
|
||||
|
||||
# Set permissions on snappymail data
|
||||
echo "[INFO] Setting permissions on /var/lib/snappymail"
|
||||
chown -R www-data:www-data /var/lib/snappymail/
|
||||
chmod 550 /var/lib/snappymail/
|
||||
find /var/lib/snappymail/ -type d -exec chmod 750 {} \;
|
||||
|
||||
# Create snappymail default config if absent
|
||||
SNAPPYMAIL_CONFIG_FILE=/var/lib/snappymail/_data_/_default_/configs/application.ini
|
||||
if [ ! -f "$SNAPPYMAIL_CONFIG_FILE" ]; then
|
||||
echo "[INFO] Creating default Snappymail configuration"
|
||||
mkdir -p $(dirname $SNAPPYMAIL_CONFIG_FILE)
|
||||
cp /usr/local/include/application.ini $SNAPPYMAIL_CONFIG_FILE
|
||||
echo "[INFO] Creating default Snappymail configuration: $SNAPPYMAIL_CONFIG_FILE"
|
||||
# Run snappymail and exit. This populates the snappymail data directory and generates the config file
|
||||
# On error, print php exception and exit
|
||||
EXITCODE=
|
||||
su - www-data -s /bin/sh -c 'php /snappymail/index.php' > /tmp/out || EXITCODE=$?
|
||||
if [ -n "$EXITCODE" ]; then
|
||||
cat /tmp/out
|
||||
exit "$EXITCODE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[INFO] Overriding values in snappymail configuration: $SNAPPYMAIL_CONFIG_FILE"
|
||||
# Enable output of snappymail logs
|
||||
if [ "${LOG_TO_STDERR}" = true ]; then
|
||||
sed -z 's/\; Enable logging\nenable = Off/\; Enable logging\nenable = On/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
sed 's/^filename = .*/filename = "errors.log"/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
sed 's/^write_on_error_only = .*/write_on_error_only = Off/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
sed 's/^write_on_php_error_only = .*/write_on_php_error_only = On/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
else
|
||||
sed -z 's/\; Enable logging\nenable = On/\; Enable logging\nenable = Off/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
fi
|
||||
sed '/^\; Enable logging/{
|
||||
N
|
||||
s/enable = Off/enable = On/
|
||||
}' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
# Redirect snappymail logs to stderr /stdout
|
||||
sed 's/^filename = .*/filename = "stderr"/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
sed 's/^write_on_error_only = .*/write_on_error_only = Off/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
sed 's/^write_on_php_error_only = .*/write_on_php_error_only = On/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
# Always enable snappymail Auth logging
|
||||
sed 's/^auth_logging = .*/auth_logging = On/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
sed 's/^auth_logging_filename = .*/auth_logging_filename = "auth.log"/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
sed 's/^auth_logging_format = .*/auth_logging_format = "[{date:Y-m-d H:i:s}] Auth failed: ip={request:ip} user={imap:login} host={imap:host} port={imap:port}"/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
# Redirect snappymail logs to stderr /stdout
|
||||
mkdir -p /snappymail/data/_data_/_default_/logs/
|
||||
# empty logs
|
||||
cp /dev/null /snappymail/data/_data_/_default_/logs/errors.log
|
||||
cp /dev/null /snappymail/data/_data_/_default_/logs/auth.log
|
||||
chown -R php-cli:php-cli /snappymail/data/
|
||||
sed 's/^auth_syslog = .*/auth_syslog = Off/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||
|
||||
# Fix permissions
|
||||
chown -R $UID:$GID /snappymail/data /var/log /var/lib/nginx
|
||||
chmod o+w /dev/stdout
|
||||
chmod o+w /dev/stderr
|
||||
(
|
||||
while ! nc -vz -w 1 127.0.0.1 8888 > /dev/null 2>&1; do echo "[INFO] Checking whether nginx is alive"; sleep 1; done
|
||||
while ! nc -vz -w 1 127.0.0.1 9000 > /dev/null 2>&1; do echo "[INFO] Checking whether php-fpm is alive"; sleep 1; done
|
||||
# Create snappymail admin password if absent
|
||||
SNAPPYMAIL_ADMIN_PASSWORD_FILE=/var/lib/snappymail/_data_/_default_/admin_password.txt
|
||||
if [ ! -f "$SNAPPYMAIL_ADMIN_PASSWORD_FILE" ]; then
|
||||
echo "[INFO] Creating Snappymail admin password file: $SNAPPYMAIL_ADMIN_PASSWORD_FILE"
|
||||
wget -T 1 -qO- 'http://127.0.0.1:8888/?/AdminAppData/0/12345/' > /dev/null
|
||||
echo "[INFO] Snappymail Admin Panel ready at http://localhost:8888/?admin. Login using password in $SNAPPYMAIL_ADMIN_PASSWORD_FILE"
|
||||
fi
|
||||
|
||||
|
||||
# Touch supervisord PID file in order to fix permissions
|
||||
touch /run/supervisord.pid
|
||||
chown php-cli:php-cli /run/supervisord.pid
|
||||
wget -T 1 -qO- 'http://127.0.0.1:8888/' > /dev/null
|
||||
echo "[INFO] Snappymail ready at http://localhost:8888/"
|
||||
) &
|
||||
|
||||
# RUN !
|
||||
exec sudo -u php-cli -g php-cli /usr/bin/supervisord -c '/supervisor.conf' --pidfile '/run/supervisord.pid'
|
||||
exec /usr/bin/supervisord -c /supervisor.conf --pidfile /run/supervisord.pid
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
/snappymail/data/_data_/_default_/logs/* {
|
||||
size 10M
|
||||
rotate 0
|
||||
missingok
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ http {
|
|||
default_type application/octet-stream;
|
||||
|
||||
access_log off;
|
||||
error_log /tmp/ngx_error.log error;
|
||||
error_log /dev/stderr error;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 15;
|
||||
|
|
@ -95,7 +95,7 @@ http {
|
|||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
fastcgi_param HTTP_PROXY "";
|
||||
fastcgi_index index.php;
|
||||
fastcgi_pass unix:/tmp/php-fpm.sock;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_intercept_errors on;
|
||||
fastcgi_request_buffering off;
|
||||
fastcgi_param REMOTE_ADDR $http_x_real_ip;
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
#!/bin/bash
|
||||
# ----------------------------------------------------------------------
|
||||
# Simple script to invoke logrotate at regular intervals
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
# from https://github.com/misho-kr/docker-appliances/blob/master/nginx-nodejs/logrotate-loop.sh
|
||||
|
||||
LOGROTATE_BIN="logrotate"
|
||||
|
||||
STATE="$HOME/logrotate.state"
|
||||
CONF="/etc/logrotate.d/snappymail"
|
||||
|
||||
export LOGROTATE_BIN STATE CONF
|
||||
|
||||
RUN_INTERVAL="3600" # every hour
|
||||
|
||||
# helper functions for logging
|
||||
export FMT="%a %b %d %Y %H:%M:%S GMT%z (%Z)"
|
||||
|
||||
function log_date() {
|
||||
echo "$(date +"$FMT"): $*"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Main loop of the logrotate service:
|
||||
#
|
||||
# while True:
|
||||
# sleep N seconds
|
||||
# run logrotate
|
||||
#
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
function logrotate_loop() {
|
||||
|
||||
trap on_terminate TERM INT
|
||||
|
||||
local interval="${1}"
|
||||
|
||||
log_date "===================================================="
|
||||
log_date
|
||||
log_date "logrotate service starting (pid=$$)"
|
||||
log_date "logrotate process will run every ${interval} seconds"
|
||||
|
||||
while true; do
|
||||
|
||||
current_time=$(date "+%s")
|
||||
next_run_time=$(( current_time + interval ))
|
||||
|
||||
while (( current_time < next_run_time ))
|
||||
do
|
||||
logrotate_sleep $(( next_run_time - current_time ))
|
||||
current_time=$(date "+%s")
|
||||
done
|
||||
|
||||
logrotate_run
|
||||
done
|
||||
}
|
||||
|
||||
# helper function to execute logrotate and pass it the right parameters
|
||||
function logrotate_run() {
|
||||
|
||||
log_date "logrotate will run now"
|
||||
${LOGROTATE_BIN} -s ${STATE} ${CONF}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Procedure to idle the execution for a number of seconds
|
||||
#
|
||||
# There are two requirements:
|
||||
#
|
||||
# - export the PID of the sleep command so that it can be terminated
|
||||
# in case the logrotate service is being shutdown
|
||||
# - keep this (bash) process responsive to SIGTERM while in sleep
|
||||
# mode (normally the signal will be masked and will not be delivered
|
||||
# until the subprocess completes)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
proc_sleep_pid=""
|
||||
|
||||
function logrotate_sleep() {
|
||||
|
||||
local sleep_interval=${1}
|
||||
|
||||
log_date "logrotate will sleep for ${sleep_interval} seconds"
|
||||
|
||||
( exec -a "logrotate: sleep" sleep ${sleep_interval} )&
|
||||
|
||||
proc_sleep_pid=$!
|
||||
wait ${proc_sleep_pid}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Signal handler for logrotate service to make sure the process exits:
|
||||
#
|
||||
# - properly by terminating the sleep process that is used to idle
|
||||
# the service
|
||||
# - gracefully by writing a message in the log
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
function on_terminate() {
|
||||
|
||||
log_date "logrotate will terminate"
|
||||
log_date
|
||||
|
||||
kill -TERM ${proc_sleep_pid}
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# main
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
logrotate_loop ${1:-RUN_INTERVAL}
|
||||
3
.docker/release/files/snappymail/include.php
Normal file
3
.docker/release/files/snappymail/include.php
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<?php
|
||||
define('APP_DATA_FOLDER_PATH', '/var/lib/snappymail/');
|
||||
?>
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
|
||||
[program:nginx]
|
||||
command=nginx -c /etc/nginx/nginx.conf -g 'daemon off;'
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
user=php-cli
|
||||
user=root
|
||||
numprocs=1
|
||||
autostart=true
|
||||
autorestart=false
|
||||
|
|
@ -17,7 +20,7 @@ stderr_logfile_maxbytes=0
|
|||
[program:php-fpm]
|
||||
command=php-fpm -F
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
user=php-cli
|
||||
user=root
|
||||
numprocs=1
|
||||
autostart=true
|
||||
autorestart=false
|
||||
|
|
@ -27,34 +30,11 @@ stdout_logfile_maxbytes=0
|
|||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; reads snappymail logs
|
||||
[program:snappymail-auth]
|
||||
command=tail -f /snappymail/data/_data_/_default_/logs/auth.log
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:snappymail-errors]
|
||||
command=tail -f /snappymail/data/_data_/_default_/logs/errors.log
|
||||
# everything is an error
|
||||
stdout_logfile=/dev/stderr
|
||||
stdout_logfile_maxbytes=0
|
||||
redirect_stderr=true
|
||||
|
||||
[program:logrotate]
|
||||
command=/logrotate-loop.sh
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[eventlistener:subprocess-stopped]
|
||||
command=php /listener.php
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
user=php-cli
|
||||
user=root
|
||||
numprocs=1
|
||||
events=PROCESS_STATE_EXITED,PROCESS_STATE_STOPPED,PROCESS_STATE_FATAL
|
||||
autostart=true
|
||||
autorestart=unexpected
|
||||
autorestart=unexpected
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
[global]
|
||||
daemonize = no
|
||||
error_log = /dev/stderr
|
||||
log_buffering = no
|
||||
|
||||
[default]
|
||||
listen = /tmp/php-fpm.sock
|
||||
listen = 9000
|
||||
user = www-data
|
||||
listen.owner = www-data
|
||||
listen.group = www-data
|
||||
pm = ondemand
|
||||
pm.max_children = 30
|
||||
pm.process_idle_timeout = 10s
|
||||
pm.max_requests = 500
|
||||
catch_workers_output = yes
|
||||
decorate_workers_output = no
|
||||
chdir = /
|
||||
php_admin_value[error_log] = /tmp/php_error.log
|
||||
pm.status_path = /status
|
||||
php_admin_value[log_errors] = On
|
||||
php_admin_value[expose_php] = Off
|
||||
php_admin_value[display_errors] = Off
|
||||
|
|
|
|||
|
|
@ -1,320 +0,0 @@
|
|||
; SnappyMail configuration file
|
||||
; Please don't add custom parameters here, those will be overwritten
|
||||
|
||||
[webmail]
|
||||
; Text displayed as page title
|
||||
title = "SnappyMail Webmail"
|
||||
|
||||
; Text displayed on startup
|
||||
loading_description = "SnappyMail"
|
||||
favicon_url = ""
|
||||
app_path = ""
|
||||
|
||||
; Theme used by default
|
||||
theme = "Default"
|
||||
|
||||
; Allow theme selection on settings screen
|
||||
allow_themes = On
|
||||
allow_user_background = Off
|
||||
|
||||
; Language used by default
|
||||
language = "en"
|
||||
|
||||
; Admin Panel interface language
|
||||
language_admin = "en"
|
||||
|
||||
; Allow language selection on settings screen
|
||||
allow_languages_on_settings = On
|
||||
allow_additional_accounts = On
|
||||
allow_additional_identities = On
|
||||
|
||||
; Number of messages displayed on page by default
|
||||
messages_per_page = 20
|
||||
|
||||
; Mark message read after N seconds
|
||||
message_read_delay = 5
|
||||
|
||||
; File size limit (MB) for file upload on compose screen
|
||||
; 0 for unlimited.
|
||||
attachment_size_limit = 2
|
||||
|
||||
[interface]
|
||||
show_attachment_thumbnail = On
|
||||
|
||||
[contacts]
|
||||
; Enable contacts
|
||||
enable = Off
|
||||
allow_sync = Off
|
||||
sync_interval = 20
|
||||
type = "sqlite"
|
||||
pdo_dsn = "host=127.0.0.1;port=3306;dbname=snappymail"
|
||||
pdo_user = "root"
|
||||
pdo_password = ""
|
||||
suggestions_limit = 30
|
||||
|
||||
[security]
|
||||
custom_server_signature = "SnappyMail"
|
||||
x_xss_protection_header = "1; mode=block"
|
||||
openpgp = Off
|
||||
|
||||
; Access settings
|
||||
allow_admin_panel = On
|
||||
|
||||
; Login and password for web admin panel
|
||||
admin_login = "admin"
|
||||
admin_password = ""
|
||||
admin_totp = ""
|
||||
admin_panel_host = ""
|
||||
admin_panel_key = "admin"
|
||||
force_https = Off
|
||||
hide_x_mailer_header = On
|
||||
|
||||
; For example to allow all images use "img-src https:". More info at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#directives
|
||||
content_security_policy = ""
|
||||
|
||||
; Report CSP errors to PHP and/or SnappyMail Log
|
||||
csp_report = Off
|
||||
|
||||
; A valid cipher method from https://php.net/openssl_get_cipher_methods
|
||||
encrypt_cipher = "aes-256-cbc-hmac-sha1"
|
||||
|
||||
; Strict, Lax or None
|
||||
cookie_samesite = "Strict"
|
||||
|
||||
; Additional allowed Sec-Fetch combinations separated by ";".
|
||||
; For example:
|
||||
; * Allow iframe on same domain in any mode: dest=iframe,site=same-origin
|
||||
; * Allow navigate to iframe on same domain: mode=navigate,dest=iframe,site=same-origin
|
||||
; * Allow navigate to iframe on (sub)domain: mode=navigate,dest=iframe,site=same-site
|
||||
; * Allow navigate to iframe from any domain: mode=navigate,dest=iframe,site=cross-site
|
||||
;
|
||||
; Default is "site=same-origin;site=none"
|
||||
secfetch_allow = ""
|
||||
|
||||
[admin_panel]
|
||||
allow_update = Off
|
||||
|
||||
[ssl]
|
||||
; Require verification of SSL certificate used.
|
||||
verify_certificate = Off
|
||||
|
||||
; Allow self-signed certificates. Requires verify_certificate.
|
||||
allow_self_signed = On
|
||||
|
||||
; https://www.openssl.org/docs/man1.1.1/man3/SSL_CTX_set_security_level.html
|
||||
security_level = 1
|
||||
|
||||
; Location of Certificate Authority file on local filesystem (/etc/ssl/certs/ca-certificates.crt)
|
||||
cafile = ""
|
||||
|
||||
; capath must be a correctly hashed certificate directory. (/etc/ssl/certs/)
|
||||
capath = ""
|
||||
|
||||
; Location of client certificate file (pem format with private key) on local filesystem
|
||||
local_cert = ""
|
||||
|
||||
; This can help mitigate the CRIME attack vector.
|
||||
disable_compression = On
|
||||
|
||||
[capa]
|
||||
quota = On
|
||||
|
||||
; Allow clear folder and delete messages without moving to trash
|
||||
dangerous_actions = On
|
||||
|
||||
; Allow download attachments as Zip (and optionally others)
|
||||
attachments_actions = On
|
||||
|
||||
[login]
|
||||
; If someone logs in without "@domain.tld", this value will be used
|
||||
; When this value is HTTP_HOST, the $_SERVER["HTTP_HOST"] value is used.
|
||||
; When this value is SERVER_NAME, the $_SERVER["SERVER_NAME"] value is used.
|
||||
; When this value is gethostname, the gethostname() value is used.
|
||||
;
|
||||
default_domain = ""
|
||||
|
||||
; Allow language selection on webmail login screen
|
||||
allow_languages_on_login = On
|
||||
|
||||
; Detect language from browser header `Accept-Language`
|
||||
determine_user_language = On
|
||||
|
||||
; Like default_domain but then HTTP_HOST/SERVER_NAME without www.
|
||||
determine_user_domain = Off
|
||||
login_lowercase = On
|
||||
|
||||
; This option allows webmail to remember the logged in user
|
||||
; once they closed the browser window.
|
||||
;
|
||||
; Values:
|
||||
; "DefaultOff" - can be used, disabled by default;
|
||||
; "DefaultOn" - can be used, enabled by default;
|
||||
; "Unused" - cannot be used
|
||||
sign_me_auto = "DefaultOff"
|
||||
|
||||
[plugins]
|
||||
; Enable plugin support
|
||||
enable = Off
|
||||
|
||||
; Comma-separated list of enabled plugins
|
||||
enabled_list = ""
|
||||
|
||||
[defaults]
|
||||
; Editor mode used by default (Plain, Html)
|
||||
view_editor_type = "Html"
|
||||
|
||||
; layout: 0 - no preview, 1 - side preview, 2 - bottom preview
|
||||
view_layout = 1
|
||||
view_use_checkboxes = On
|
||||
autologout = 30
|
||||
view_html = On
|
||||
show_images = Off
|
||||
contacts_autosave = On
|
||||
mail_use_threads = Off
|
||||
allow_draft_autosave = On
|
||||
mail_reply_same_folder = Off
|
||||
|
||||
[logs]
|
||||
; Enable logging
|
||||
enable = Off
|
||||
|
||||
; Path where log files will be stored
|
||||
path = ""
|
||||
|
||||
; Log messages of set RFC 5424 section 6.2.1 Severity level and higher (0 = highest, 7 = lowest).
|
||||
; 0 = Emergency
|
||||
; 1 = Alert
|
||||
; 2 = Critical
|
||||
; 3 = Error
|
||||
; 4 = Warning
|
||||
; 5 = Notice
|
||||
; 6 = Informational
|
||||
; 7 = Debug
|
||||
level = 4
|
||||
|
||||
; Required for development purposes only.
|
||||
; Disabling this option is not recommended.
|
||||
hide_passwords = On
|
||||
time_zone = "UTC"
|
||||
|
||||
; Log filename.
|
||||
; For security reasons, some characters are removed from filename.
|
||||
; Allows for pattern-based folder creation (see examples below).
|
||||
;
|
||||
; Patterns:
|
||||
; {date:Y-m-d} - Replaced by pattern-based date
|
||||
; Detailed info: http://www.php.net/manual/en/function.date.php
|
||||
; {user:email} - Replaced by user's email address
|
||||
; If user is not logged in, value is set to "unknown"
|
||||
; {user:login} - Replaced by user's login (the user part of an email)
|
||||
; If user is not logged in, value is set to "unknown"
|
||||
; {user:domain} - Replaced by user's domain name (the domain part of an email)
|
||||
; If user is not logged in, value is set to "unknown"
|
||||
; {user:uid} - Replaced by user's UID regardless of account currently used
|
||||
;
|
||||
; {user:ip}
|
||||
; {request:ip} - Replaced by user's IP address
|
||||
;
|
||||
; Others:
|
||||
; {imap:login} {imap:host} {imap:port}
|
||||
; {smtp:login} {smtp:host} {smtp:port}
|
||||
;
|
||||
; Examples:
|
||||
; filename = "log-{date:Y-m-d}.txt"
|
||||
; filename = "{date:Y-m-d}/{user:domain}/{user:email}_{user:uid}.log"
|
||||
; filename = "{user:email}-{date:Y-m-d}.txt"
|
||||
; filename = "syslog"
|
||||
filename = "log-{date:Y-m-d}.txt"
|
||||
|
||||
; Enable auth logging in a separate file (for fail2ban)
|
||||
auth_logging = Off
|
||||
auth_logging_filename = "fail2ban/auth-{date:Y-m-d}.txt"
|
||||
auth_logging_format = "[{date:Y-m-d H:i:s}] Auth failed: ip={request:ip} user={imap:login} host={imap:host} port={imap:port}"
|
||||
|
||||
; Enable auth logging to syslog for fail2ban
|
||||
auth_syslog = On
|
||||
|
||||
[debug]
|
||||
; Special option required for development purposes
|
||||
enable = Off
|
||||
javascript = Off
|
||||
css = Off
|
||||
|
||||
[cache]
|
||||
; The section controls caching of the entire application.
|
||||
;
|
||||
; Enables caching in the system
|
||||
enable = On
|
||||
|
||||
; Path where cache files will be stored
|
||||
path = ""
|
||||
|
||||
; Additional caching key. If changed, cache is purged
|
||||
index = "v1"
|
||||
|
||||
; Can be: files, APCU, memcache, redis (beta)
|
||||
fast_cache_driver = "files"
|
||||
|
||||
; Additional caching key. If changed, fast cache is purged
|
||||
fast_cache_index = "v1"
|
||||
|
||||
; Browser-level cache. If enabled, caching is maintainted without using files
|
||||
http = On
|
||||
|
||||
; Browser-level cache time (seconds, Expires header)
|
||||
http_expires = 3600
|
||||
|
||||
; Caching message UIDs when searching and sorting (threading)
|
||||
server_uids = On
|
||||
system_data = On
|
||||
|
||||
[imap]
|
||||
use_force_selection = Off
|
||||
use_expunge_all_on_delete = Off
|
||||
message_list_fast_simple_search = On
|
||||
message_list_permanent_filter = ""
|
||||
message_all_headers = Off
|
||||
show_login_alert = On
|
||||
fetch_new_messages = On
|
||||
|
||||
[labs]
|
||||
; Display message RFC 2822 date and time header, instead of the arrival internal date.
|
||||
date_from_headers = On
|
||||
allow_message_append = Off
|
||||
|
||||
; When login fails, wait N seconds before responding
|
||||
login_fault_delay = 5
|
||||
log_ajax_response_write_limit = 300
|
||||
allow_html_editor_biti_buttons = Off
|
||||
allow_ctrl_enter_on_compose = On
|
||||
smtp_show_server_errors = Off
|
||||
sieve_auth_plain_initial = On
|
||||
sieve_allow_fileinto_inbox = Off
|
||||
|
||||
; PHP mail() remove To and Subject headers
|
||||
mail_func_clear_headers = On
|
||||
|
||||
; PHP mail() set -f emailaddress
|
||||
mail_func_additional_parameters = Off
|
||||
folders_spec_limit = 50
|
||||
curl_proxy = ""
|
||||
curl_proxy_auth = ""
|
||||
custom_login_link = ""
|
||||
custom_logout_link = ""
|
||||
http_client_ip_check_proxy = Off
|
||||
fast_cache_memcache_host = "127.0.0.1"
|
||||
fast_cache_memcache_port = 11211
|
||||
fast_cache_redis_host = "127.0.0.1"
|
||||
fast_cache_redis_port = 6379
|
||||
use_local_proxy_for_external_images = On
|
||||
image_exif_auto_rotate = Off
|
||||
cookie_default_path = ""
|
||||
cookie_default_secure = Off
|
||||
replace_env_in_configuration = ""
|
||||
boundary_prefix = ""
|
||||
dev_email = ""
|
||||
dev_password = ""
|
||||
|
||||
[version]
|
||||
current = "2.28.4"
|
||||
saved = "Sun, 18 Dec 2022 22:10:48 +0000"
|
||||
7
.docker/release/test/build_and_test.sh
Executable file
7
.docker/release/test/build_and_test.sh
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
# This script uses docker buildx to build a docker image, and loads it into the docker daemon. Then it executes ./test.sh to test the docker image
|
||||
# It is useful for testing release builds in development
|
||||
set -eu
|
||||
IMAGE=snappymail/snappymail:test
|
||||
DOCKER_BUILDX=1 docker build -t "$IMAGE" -f .docker/release/Dockerfile .
|
||||
.docker/release/test/test.sh "$IMAGE"
|
||||
29
.docker/release/test/config.yaml
Normal file
29
.docker/release/test/config.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# See: https://github.com/GoogleContainerTools/container-structure-test
|
||||
# See: https://github.com/GoogleContainerTools/container-structure-test/issues/78
|
||||
schemaVersion: 2.0.0
|
||||
commandTests:
|
||||
- name: Integration test
|
||||
command: /bin/sh
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
set -eux
|
||||
DEBUG=true /entrypoint.sh > /tmp/test 2>&1 &
|
||||
sleep 5
|
||||
cat /tmp/test
|
||||
pidof supervisord
|
||||
pidof nginx
|
||||
pidof php-fpm
|
||||
ls -al /var/lib/snappymail/_data_/_default_/configs/application.ini
|
||||
ls -al /var/lib/snappymail/_data_/_default_/admin_password.txt
|
||||
nc -vz 127.0.0.1 8888
|
||||
nc -vz 127.0.0.1 9000
|
||||
wget -S -T 3 -O /dev/null http://127.0.0.1:8888
|
||||
kill `pidof supervisord`
|
||||
metadataTest:
|
||||
exposedPorts: ["8888", "9000"]
|
||||
volumes: ["/var/lib/snappymail"]
|
||||
entrypoint: []
|
||||
cmd: ["/entrypoint.sh"]
|
||||
workdir: /snappymail
|
||||
user: root
|
||||
10
.docker/release/test/test.sh
Executable file
10
.docker/release/test/test.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/bin/sh
|
||||
# This script tests a given docker image using https://github.com/GoogleContainerTools/container-structure-test
|
||||
set -eu
|
||||
SCRIPT_DIR=$( cd "$(dirname "$0")" && pwd )
|
||||
IMAGE=${1:-}
|
||||
echo "Testing image: $IMAGE"
|
||||
docker run --rm -i \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock:ro \
|
||||
-v "$SCRIPT_DIR/config.yaml:/config.yaml:ro" \
|
||||
gcr.io/gcp-runtimes/container-structure-test:latest test --image "$IMAGE" --config config.yaml
|
||||
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/.git
|
||||
/node_modules
|
||||
|
|
@ -3,7 +3,7 @@ module.exports = {
|
|||
// extends: ['eslint:recommended', 'plugin:prettier/recommended'],
|
||||
extends: ['eslint:recommended'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 6,
|
||||
ecmaVersion: 11,
|
||||
sourceType: 'module'
|
||||
},
|
||||
env: {
|
||||
|
|
@ -35,10 +35,13 @@ module.exports = {
|
|||
// vendors/bootstrap/bootstrap.native.js
|
||||
'BSN': "readonly",
|
||||
// Mailvelope
|
||||
'mailvelope': "readonly"
|
||||
'mailvelope': "readonly",
|
||||
// Punycode
|
||||
'IDN': "readonly"
|
||||
},
|
||||
// http://eslint.org/docs/rules/
|
||||
rules: {
|
||||
'no-cond-assign': 0,
|
||||
// plugins
|
||||
'no-mixed-spaces-and-tabs': 'off',
|
||||
'max-len': [
|
||||
|
|
|
|||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
|
@ -1,2 +1,2 @@
|
|||
community_bridge: SnappyMail
|
||||
github: the-djmaze
|
||||
custom: ["https://www.paypal.me/thedjmaze", "https://snappymail.eu"]
|
||||
|
|
|
|||
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -30,8 +30,9 @@ If applicable, add screenshots to help explain your problem.
|
|||
- SnappyMail Version:
|
||||
- Mode: [e.g. standalone, nextcloud, cyberpanel, docker]
|
||||
|
||||
**[Debug/logging information](https://github.com/the-djmaze/snappymail/wiki/FAQ#how-do-i-enable-logging)**
|
||||
Place them here (few lines) or as attachments (many lines)
|
||||
**Debug/logging information**
|
||||
[Read here how to log](https://github.com/the-djmaze/snappymail/wiki/FAQ#how-do-i-enable-logging)
|
||||
- [ ] I've placed them here (few lines) or as attachments (many lines)
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
|
|
|||
93
.github/workflows/docker-pr.yml
vendored
Normal file
93
.github/workflows/docker-pr.yml
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
name: docker-pr
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# This step generates the docker tags
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
env:
|
||||
# This env var ensures {{sha}} is a real commit SHA for type=ref,event=pr
|
||||
DOCKER_METADATA_PR_HEAD_SHA: 'true'
|
||||
with:
|
||||
images: |
|
||||
djmaze/snappymail
|
||||
ghcr.io/${{ github.repository }}
|
||||
# type=ref,event=pr generates tag(s) on PRs only. E.g. 'pr-123', 'pr-123-abc0123'
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=ref,suffix=-{{sha}},event=pr
|
||||
# The rest of the org.opencontainers.image.xxx labels are dynamically generated
|
||||
labels: |
|
||||
org.opencontainers.image.description=SnappyMail
|
||||
org.opencontainers.image.licenses=AGPLv3
|
||||
|
||||
# See: https://github.com/docker/build-push-action/blob/v2.6.1/docs/advanced/cache.md#github-cache
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
# See: https://github.com/docker/buildx/issues/59
|
||||
- name: Build
|
||||
id: build
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: '.'
|
||||
file: ./.docker/release/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
- name: Docker images
|
||||
run: |
|
||||
docker images
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
TAG=$( echo "${{ steps.meta.outputs.tags }}" | head -n1 )
|
||||
.docker/release/test/test.sh "$TAG"
|
||||
|
||||
- name: Build all archs
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: '.'
|
||||
file: ./.docker/release/Dockerfile
|
||||
platforms: linux/386,linux/amd64,linux/arm64
|
||||
push: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
# Temp fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
- name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
117
.github/workflows/docker.yml
vendored
Normal file
117
.github/workflows/docker.yml
vendored
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
name: docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v2.*'
|
||||
|
||||
# This is needed to push to GitHub Container Registry. See https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# This step generates the docker tags
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
env:
|
||||
# This env var ensures {{sha}} is a real commit SHA for type=ref,event=pr
|
||||
DOCKER_METADATA_PR_HEAD_SHA: 'true'
|
||||
with:
|
||||
images: |
|
||||
djmaze/snappymail
|
||||
ghcr.io/${{ github.repository }}
|
||||
# type=ref,event=branch generates tag(s) on branch only. E.g. 'master', 'master-abc0123'
|
||||
# type=ref,event=tag generates tag(s) on tags only. E.g. 'v0.0.0', 'v0.0.0-abc0123', and 'latest'
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
# The rest of the org.opencontainers.image.xxx labels are dynamically generated
|
||||
labels: |
|
||||
org.opencontainers.image.description=SnappyMail
|
||||
org.opencontainers.image.licenses=AGPLv3
|
||||
|
||||
# See: https://github.com/docker/build-push-action/blob/v2.6.1/docs/advanced/cache.md#github-cache
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Login to Docker Hub registry
|
||||
if: startsWith(github.ref, 'refs/tags/') # Login only on tags
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: startsWith(github.ref, 'refs/tags/') # Login only on tags
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# See: https://github.com/docker/buildx/issues/59
|
||||
- name: Build
|
||||
id: build
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: '.'
|
||||
file: ./.docker/release/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
- name: Docker images
|
||||
run: |
|
||||
docker images
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
TAG=$( echo "${{ steps.meta.outputs.tags }}" | head -n1 )
|
||||
.docker/release/test/test.sh "$TAG"
|
||||
|
||||
- name: Build and push
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: '.'
|
||||
file: ./.docker/release/Dockerfile
|
||||
# TODO: Add more arches?
|
||||
# platforms: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/s390x
|
||||
platforms: linux/386,linux/amd64,linux/arm64
|
||||
push: ${{ startsWith(github.ref, 'refs/tags/') }} # Push only on tags
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
# Temp fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
- name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
<FilesMatch "index\.php">
|
||||
AcceptPathInfo On
|
||||
</FilesMatch>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
# Redirect cPanel
|
||||
RewriteRule cpsess.* https://%{HTTP_HOST}/ [L,R=301]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.+)$ index.php/$1 [L,QSA]
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_expires.c>
|
||||
|
|
|
|||
1592
CHANGELOG.md
1592
CHANGELOG.md
File diff suppressed because it is too large
Load diff
59
README.md
59
README.md
|
|
@ -5,6 +5,10 @@
|
|||
<br>
|
||||
<h1>SnappyMail</h1>
|
||||
<br>
|
||||
|
||||
[](https://github.com/the-djmaze/snappymail/actions/workflows/docker.yml)
|
||||
[](https://hub.docker.com/r/djmaze/snappymail/tags)
|
||||
|
||||
<p>
|
||||
Simple, modern, lightweight & fast web-based email client.
|
||||
</p>
|
||||
|
|
@ -26,7 +30,7 @@ For more information about the product, check [snappymail.eu](https://snappymail
|
|||
|
||||
Information about installing the product, check the [wiki page](https://github.com/the-djmaze/snappymail/wiki/Installation-instructions).
|
||||
|
||||
And don't forget to read the [RainLoop documentation](https://www.rainloop.net/docs/).
|
||||
And don't forget to read the whole [Wiki](https://github.com/the-djmaze/snappymail/wiki).
|
||||
|
||||
## License
|
||||
|
||||
|
|
@ -34,7 +38,7 @@ And don't forget to read the [RainLoop documentation](https://www.rainloop.net/d
|
|||
**GNU AFFERO GENERAL PUBLIC LICENSE Version 3 (AGPL)**.
|
||||
http://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
Copyright (c) 2020 - 2023 SnappyMail
|
||||
Copyright (c) 2020 - 2024 SnappyMail
|
||||
Copyright (c) 2013 - 2022 RainLoop
|
||||
|
||||
## Modifications
|
||||
|
|
@ -45,7 +49,7 @@ This fork of RainLoop has the following changes:
|
|||
* Admin uses password_hash/password_verify
|
||||
* Auth failed attempts written to syslog
|
||||
* Added Fail2ban instructions
|
||||
* ES2018
|
||||
* ES2020
|
||||
* PHP 7.4+ required
|
||||
* PHP mbstring extension required
|
||||
* PHP replaced pclZip with PharData and ZipArchive
|
||||
|
|
@ -79,12 +83,7 @@ This fork of RainLoop has the following changes:
|
|||
* Added [Fetch Metadata Request Headers](https://www.w3.org/TR/fetch-metadata/) checks
|
||||
* Reduced excessive DOM size
|
||||
* Support [Kolab groupware](https://kolab.org/)
|
||||
* Support IMAP RFC 2971 ID extension
|
||||
* Support IMAP RFC 5258 LIST-EXTENDED
|
||||
* Support IMAP RFC 5464 METADATA
|
||||
* Support IMAP RFC 5819 LIST-STATUS
|
||||
* Support IMAP RFC 7628 SASL OAUTHBEARER aka XOAUTH2
|
||||
* Support IMAP4rev2 RFC 9051
|
||||
* Support many more [IMAP RFC's](https://snappymail.eu/comparison#IMAP)
|
||||
* Support Sodium and OpenSSL for encryption
|
||||
* Much better PGP support
|
||||
|
||||
|
|
@ -141,28 +140,28 @@ RainLoop 1.17 vs SnappyMail
|
|||
|
||||
|js/* |RainLoop |Snappy |
|
||||
|--------------- |--------: |--------: |
|
||||
|admin.js |2.170.153 | 80.102 |
|
||||
|app.js |4.207.787 | 407.874 |
|
||||
|boot.js | 868.735 | 4.142 |
|
||||
|libs.js | 658.812 | 187.076 |
|
||||
|sieve.js | 0 | 85.141 |
|
||||
|admin.js |2.170.153 | 84.054 |
|
||||
|app.js |4.207.787 | 441.754 |
|
||||
|boot.js | 868.735 | 4.147 |
|
||||
|libs.js | 658.812 | 193.716 |
|
||||
|sieve.js | 0 | 84.598 |
|
||||
|polyfills.js | 334.608 | 0 |
|
||||
|serviceworker.js | 0 | 285 |
|
||||
|TOTAL |8.240.095 | 764.620 |
|
||||
|TOTAL |8.240.095 | 808.554 |
|
||||
|
||||
|js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|
||||
|--------------- |--------: |--------: |------: |------: |--------: |--------: |
|
||||
|admin.min.js | 256.831 | 39.350 | 73.606 | 13.163 | 60.877 | 11.805 |
|
||||
|app.min.js | 515.367 | 186.311 |139.456 | 62.929 |110.485 | 54.076 |
|
||||
|boot.min.js | 84.659 | 2.084 | 26.998 | 1.202 | 23.643 | 1.003 |
|
||||
|libs.min.js | 584.772 | 90.808 |180.901 | 33.754 |155.182 | 30.224 |
|
||||
|sieve.min.js | 0 | 41.399 | 0 | 10.394 | 0 | 9.356 |
|
||||
|admin.min.js | 256.831 | 41.162 | 73.606 | 13.885 | 60.877 | 12.434 |
|
||||
|app.min.js | 515.367 | 199.730 |139.456 | 67.669 |110.485 | 57.672 |
|
||||
|boot.min.js | 84.659 | 2.087 | 26.998 | 1.204 | 23.643 | 1.002 |
|
||||
|libs.min.js | 584.772 | 92.365 |180.901 | 34.487 |155.182 | 30.830 |
|
||||
|sieve.min.js | 0 | 41.093 | 0 | 10.325 | 0 | 9.327 |
|
||||
|polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 |
|
||||
|TOTAL user |1.217.635 | 279.203 |358.761 | 97.885 |299.485 | 85.303 |
|
||||
|TOTAL user+sieve |1.217.635 | 320.602 |358.761 |108.279 |299.485 | 94.659 |
|
||||
|TOTAL admin | 959.099 | 132.242 |292.911 | 48.119 |249.877 | 43.032 |
|
||||
|TOTAL user |1.217.635 | 294.182 |358.761 |103.360 |299.485 | 89.504 |
|
||||
|TOTAL user+sieve |1.217.635 | 335.275 |358.761 |113.685 |299.485 | 98.831 |
|
||||
|TOTAL admin | 959.099 | 135.614 |292.911 | 49.576 |249.877 | 44.266 |
|
||||
|
||||
For a user it is around 72% smaller and faster than traditional RainLoop.
|
||||
For a user it is around 68% smaller and faster than traditional RainLoop.
|
||||
|
||||
### CSS changes
|
||||
|
||||
|
|
@ -189,12 +188,12 @@ For a user it is around 72% smaller and faster than traditional RainLoop.
|
|||
|
||||
|css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli |
|
||||
|------------ |-------: |------: |------: |------: |--------: |
|
||||
|app.css | 340.331 | 84.390 | 46.946 | 17.605 | 15.084 |
|
||||
|app.min.css | 274.947 | 67.774 | 39.647 | 15.487 | 13.527 |
|
||||
|app.css | 340.331 | 84.691 | 46.946 | 17.693 | 15.157 |
|
||||
|app.min.css | 274.947 | 68.052 | 39.647 | 15.589 | 13.610 |
|
||||
|boot.css | | 1.326 | | 664 | 545 |
|
||||
|boot.min.css | | 1.071 | | 590 | 474 |
|
||||
|admin.css | | 30.482 | | 6.988 | 6.092 |
|
||||
|admin.min.css | | 24.607 | | 6.315 | 5.579 |
|
||||
|admin.css | | 30.602 | | 7.023 | 6.112 |
|
||||
|admin.min.css | | 24.717 | | 6.346 | 5.586 |
|
||||
|
||||
### PGP
|
||||
RainLoop uses the old OpenPGP.js v2
|
||||
|
|
@ -208,7 +207,7 @@ See https://github.com/the-djmaze/openpgpjs for development
|
|||
|
||||
|OpenPGP |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|
||||
|--------------- |--------: |--------: |------: |-------: |--------: |--------: |
|
||||
|openpgp.min.js | 330.742 | 541.176 |102.388 | 168.266 | 84.241 | 138.278 |
|
||||
|openpgp.min.js | 330.742 | 546.165 |102.388 | 169.207 | 84.241 | 138.688 |
|
||||
|openpgp.worker | 1.499 | | 824 | | 695 | |
|
||||
|
||||
|
||||
|
|
@ -225,5 +224,3 @@ Still TODO:
|
|||
|ckeditor | ? | 520.035 | ? | 155.916 |
|
||||
|
||||
CKEditor including the 7 asset requests (css,language,plugins,icons) is 633.46 KB / 180.47 KB (gzip).
|
||||
|
||||
To use the old CKEditor, you must install the plugin.
|
||||
|
|
|
|||
|
|
@ -6,15 +6,15 @@ Currently due to the fast development only the latest version receives security
|
|||
|
||||
| Version | Supported |
|
||||
| -------- | --------- |
|
||||
| 2.13.x | ✔ |
|
||||
| < 2.13.0 | ❌ |
|
||||
| 2.30.x | ✔ |
|
||||
| < 2.30.0 | ❌ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security issues or vulnerabilities as an encrypted email to [security@snappymail.eu](mailto:security@snappymail.eu).
|
||||
Your report should be detailed enough with clear steps to reproduce and classify the found vulnerability.
|
||||
|
||||
You can find the PGP public key below and on the major public keyservers like [pgp.key-server.io](https://pgp.key-server.io).
|
||||
You can find the PGP public key below and on the major public keyservers like [keys.openpgp.org](https://keys.openpgp.org).
|
||||
```
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Comment: Type: 255-bit EdDSA
|
||||
|
|
|
|||
|
|
@ -28,10 +28,46 @@ $keys = [
|
|||
'url',
|
||||
'version'
|
||||
];
|
||||
/*
|
||||
$released = [
|
||||
'add-x-originating-ip-header',
|
||||
'avatars',
|
||||
'backup',
|
||||
'black-list',
|
||||
'change-password',
|
||||
'change-password-froxlor',
|
||||
'change-password-hestia',
|
||||
'change-password-hmailserver',
|
||||
'change-password-ispconfig',
|
||||
'change-password-poppassd',
|
||||
'custom-login-mapping',
|
||||
'imap-contacts-suggestions',
|
||||
'kolab',
|
||||
'ldap-contacts-suggestions',
|
||||
'ldap-identities',
|
||||
'ldap-login-mapping',
|
||||
'ldap-mail-accounts',
|
||||
'login-external',
|
||||
'login-external-sso',
|
||||
'login-override',
|
||||
'login-register',
|
||||
'login-remote',
|
||||
'mailbox-detect',
|
||||
'nextcloud',
|
||||
'override-smtp-credentials',
|
||||
'set-remote-addr',
|
||||
'smtp-use-from-adr-account',
|
||||
'snowfall-on-login-screen',
|
||||
'two-factor-auth',
|
||||
'view-ics',
|
||||
'white-list'
|
||||
];
|
||||
*/
|
||||
foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
|
||||
if (is_file("{$dir}/index.php") && !strpos($dir, '.bak')) {
|
||||
require "{$dir}/index.php";
|
||||
$name = basename($dir);
|
||||
// if (!in_array($name, $released)) continue;
|
||||
$class = new ReflectionClass(str_replace('-', '', $name) . 'Plugin');
|
||||
$manifest_item = [];
|
||||
foreach ($class->getConstants() as $key => $value) {
|
||||
|
|
|
|||
|
|
@ -24,9 +24,6 @@ $file = ROOT_DIR . '/integrations/cloudron/Dockerfile';
|
|||
file_put_contents($file, preg_replace('/VERSION=[0-9.]+/', "VERSION={$package->version}", file_get_contents($file)));
|
||||
$file = ROOT_DIR . '/integrations/cloudron/DESCRIPTION.md';
|
||||
file_put_contents($file, preg_replace('/<upstream>[^<]*</', "<upstream>{$package->version}<", file_get_contents($file)));
|
||||
// docker
|
||||
$file = ROOT_DIR . '/.docker/release/files/usr/local/include/application.ini';
|
||||
file_put_contents($file, preg_replace('/current = "[0-9.]+"/', "current = \"{$package->version}\"", file_get_contents($file)));
|
||||
// virtualmin
|
||||
$file = ROOT_DIR . '/integrations/virtualmin/snappymail.pl';
|
||||
file_put_contents($file, preg_replace('/return \\( "[0-9]+\\.[0-9]+\\.[0-9]+" \\)/', "return ( \"{$package->version}\" )", file_get_contents($file)));
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import ko from 'ko';
|
|||
import { logoutLink } from 'Common/Links';
|
||||
import { i18nToNodes, initOnStartOrLangChange } from 'Common/Translator';
|
||||
|
||||
import { arePopupsVisible } from 'Knoin/Knoin';
|
||||
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
import { initThemes } from 'Stores/Theme';
|
||||
|
||||
|
|
@ -18,6 +20,7 @@ export class AbstractApp {
|
|||
}
|
||||
|
||||
logoutReload(url) {
|
||||
arePopupsVisible(false);
|
||||
url = url || logoutLink();
|
||||
if (location.href !== url) {
|
||||
setTimeout(() => location.href = url, 100);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'External/ko';
|
||||
|
||||
import { Settings, SettingsGet } from 'Common/Globals';
|
||||
import { SettingsGet, SettingsAdmin } from 'Common/Globals';
|
||||
import { initThemes } from 'Stores/Theme';
|
||||
|
||||
import Remote from 'Remote/Admin/Fetch';
|
||||
|
|
@ -11,6 +11,8 @@ import { LoginAdminScreen } from 'Screen/Admin/Login';
|
|||
import { startScreens } from 'Knoin/Knoin';
|
||||
import { AbstractApp } from 'App/Abstract';
|
||||
|
||||
import { AskPopupView } from 'View/Popup/Ask';
|
||||
|
||||
export class AdminApp extends AbstractApp {
|
||||
constructor() {
|
||||
super(Remote);
|
||||
|
|
@ -23,7 +25,8 @@ export class AdminApp extends AbstractApp {
|
|||
}
|
||||
|
||||
start() {
|
||||
if (!Settings.app('adminAllowed')) {
|
||||
// if (!Settings.app('adminAllowed')) {
|
||||
if (!SettingsAdmin('allowed')) {
|
||||
rl.route.root();
|
||||
setTimeout(() => location.href = '/', 1);
|
||||
} else if (SettingsGet('Auth')) {
|
||||
|
|
@ -34,3 +37,16 @@ export class AdminApp extends AbstractApp {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
AskPopupView.credentials = function(sAskDesc, btnText) {
|
||||
return new Promise(resolve => {
|
||||
this.showModal([
|
||||
sAskDesc,
|
||||
view => resolve({username:view.username(), password:view.passphrase()}),
|
||||
() => resolve(null),
|
||||
true,
|
||||
3,
|
||||
btnText
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
124
dev/App/User.js
124
dev/App/User.js
|
|
@ -1,8 +1,7 @@
|
|||
import 'External/User/ko';
|
||||
|
||||
import { SMAudio } from 'Common/Audio';
|
||||
import { isArray, pInt } from 'Common/Utils';
|
||||
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility } from 'Common/UtilsUser';
|
||||
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser';
|
||||
|
||||
import {
|
||||
FolderType,
|
||||
|
|
@ -27,15 +26,15 @@ import {
|
|||
getFolderFromCacheList
|
||||
} from 'Common/Cache';
|
||||
|
||||
import { i18n, reloadTime } from 'Common/Translator';
|
||||
import { i18n, reloadTime, getErrorMessage } from 'Common/Translator';
|
||||
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
import { NotificationUserStore } from 'Stores/User/Notification';
|
||||
import { AccountUserStore } from 'Stores/User/Account';
|
||||
import { ContactUserStore } from 'Stores/User/Contact';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
import { PgpUserStore } from 'Stores/User/Pgp';
|
||||
import { SMimeUserStore } from 'Stores/User/SMime';
|
||||
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
||||
import { ThemeStore, initThemes } from 'Stores/Theme';
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
|
|
@ -43,9 +42,6 @@ import { MessageUserStore } from 'Stores/User/Message';
|
|||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { AccountModel } from 'Model/Account';
|
||||
import { IdentityModel } from 'Model/Identity';
|
||||
|
||||
import { LoginUserScreen } from 'Screen/User/Login';
|
||||
import { MailBoxUserScreen } from 'Screen/User/MailBox';
|
||||
import { SettingsUserScreen } from 'Screen/User/Settings';
|
||||
|
|
@ -89,6 +85,10 @@ export class AppUser extends AbstractApp {
|
|||
|
||||
this.folderList = FolderUserStore.folderList;
|
||||
this.messageList = MessagelistUserStore;
|
||||
|
||||
this.ask = AskPopupView;
|
||||
|
||||
this.loadAccountsAndIdentities = loadAccountsAndIdentities;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -113,7 +113,7 @@ export class AppUser extends AbstractApp {
|
|||
case FolderType.Trash:
|
||||
oMoveFolder = getFolderFromCacheList(FolderUserStore.trashFolder());
|
||||
nSetSystemFoldersNotification = iFolderType;
|
||||
bDelete = bDelete || UNUSED_OPTION_VALUE === FolderUserStore.trashFolder()
|
||||
bDelete = bDelete/* || UNUSED_OPTION_VALUE === FolderUserStore.trashFolder()*/
|
||||
|| sFromFolderFullName === FolderUserStore.spamFolder()
|
||||
|| sFromFolderFullName === FolderUserStore.trashFolder();
|
||||
break;
|
||||
|
|
@ -125,9 +125,7 @@ export class AppUser extends AbstractApp {
|
|||
// no default
|
||||
}
|
||||
|
||||
if (!oMoveFolder && !bDelete) {
|
||||
showScreenPopup(FolderSystemPopupView, [nSetSystemFoldersNotification]);
|
||||
} else if (bDelete) {
|
||||
if (bDelete) {
|
||||
showScreenPopup(AskPopupView, [
|
||||
i18n('POPUPS_ASK/DESC_WANT_DELETE_MESSAGES'),
|
||||
() => {
|
||||
|
|
@ -136,34 +134,11 @@ export class AppUser extends AbstractApp {
|
|||
]);
|
||||
} else if (oMoveFolder) {
|
||||
MessagelistUserStore.moveMessages(sFromFolderFullName, oUids, oMoveFolder.fullName);
|
||||
} else {
|
||||
showScreenPopup(FolderSystemPopupView, [nSetSystemFoldersNotification]);
|
||||
}
|
||||
}
|
||||
|
||||
accountsAndIdentities() {
|
||||
AccountUserStore.loading(true);
|
||||
IdentityUserStore.loading(true);
|
||||
|
||||
Remote.request('AccountsAndIdentities', (iError, oData) => {
|
||||
AccountUserStore.loading(false);
|
||||
IdentityUserStore.loading(false);
|
||||
|
||||
if (!iError) {
|
||||
let items = oData.Result.Accounts;
|
||||
AccountUserStore(isArray(items)
|
||||
? items.map(oValue => new AccountModel(oValue.email, oValue.name))
|
||||
: []
|
||||
);
|
||||
AccountUserStore.unshift(new AccountModel(SettingsGet('mainEmail'), '', false));
|
||||
|
||||
items = oData.Result.Identities;
|
||||
IdentityUserStore(isArray(items)
|
||||
? items.map(identityData => IdentityModel.reviveFromJson(identityData))
|
||||
: []
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} folder
|
||||
* @param {Array=} list = []
|
||||
|
|
@ -173,8 +148,10 @@ export class AppUser extends AbstractApp {
|
|||
}
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('register_protocol_offered');
|
||||
Remote.request('Logout', () => rl.logoutReload(Settings.app('customLogoutLink')));
|
||||
Remote.request('Logout', (iError, data) =>
|
||||
iError ? alert('Logout error: ' + getErrorMessage(iError, data))
|
||||
: rl.logoutReload(Settings.app('customLogoutLink'))
|
||||
);
|
||||
}
|
||||
|
||||
bootstart() {
|
||||
|
|
@ -206,19 +183,17 @@ export class AppUser extends AbstractApp {
|
|||
SettingsUserStore.init();
|
||||
ContactUserStore.init();
|
||||
|
||||
loadFolders(value => {
|
||||
loadFolders((success, error) => {
|
||||
try {
|
||||
if (value) {
|
||||
if (success) {
|
||||
startScreens([
|
||||
MailBoxUserScreen,
|
||||
SettingsUserScreen
|
||||
]);
|
||||
|
||||
setRefreshFoldersInterval(pInt(SettingsGet('CheckMailInterval')));
|
||||
setRefreshFoldersInterval(SettingsGet('CheckMailInterval'));
|
||||
|
||||
ContactUserStore.init();
|
||||
|
||||
this.accountsAndIdentities();
|
||||
loadAccountsAndIdentities();
|
||||
|
||||
setTimeout(() => {
|
||||
const cF = FolderUserStore.currentFolderFullName();
|
||||
|
|
@ -247,27 +222,17 @@ export class AppUser extends AbstractApp {
|
|||
setInterval(reloadTime, 60000);
|
||||
|
||||
PgpUserStore.init();
|
||||
SMimeUserStore.loadCertificates();
|
||||
|
||||
setTimeout(() => mailToHelper(SettingsGet('mailToEmail')), 500);
|
||||
|
||||
if (!localStorage.getItem('register_protocol_offered')) {
|
||||
// When auto-login is active
|
||||
navigator.registerProtocolHandler?.(
|
||||
'mailto',
|
||||
location.protocol + '//' + location.host + location.pathname + '?mailto&to=%s',
|
||||
(SettingsGet('title') || 'SnappyMail')
|
||||
);
|
||||
localStorage.setItem('register_protocol_offered', '1');
|
||||
}
|
||||
|
||||
} else {
|
||||
this.logout();
|
||||
alert('Folders error: ' + getErrorMessage(0, error))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
startScreens([LoginUserScreen]);
|
||||
}
|
||||
|
|
@ -278,3 +243,50 @@ export class AppUser extends AbstractApp {
|
|||
showScreenPopup(ComposePopupView, params);
|
||||
}
|
||||
}
|
||||
|
||||
AskPopupView.password = function(sAskDesc, btnText, ask) {
|
||||
return new Promise(resolve => {
|
||||
this.showModal([
|
||||
sAskDesc,
|
||||
view => resolve({
|
||||
password:view.passphrase(),
|
||||
username:/*ask & 2 ? */view.username(),
|
||||
remember:/*ask & 4 ? */view.remember()
|
||||
}),
|
||||
() => resolve(null),
|
||||
true,
|
||||
ask || 1,
|
||||
btnText
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
AskPopupView.cryptkey = () => new Promise(resolve => {
|
||||
const fn = () => AskPopupView.showModal([
|
||||
i18n('CRYPTO/ASK_CRYPTKEY_PASS'),
|
||||
view => {
|
||||
let pass = view.passphrase();
|
||||
if (pass) {
|
||||
Remote.post('ResealCryptKey', null, {
|
||||
passphrase: pass
|
||||
}).then(response => {
|
||||
resolve(response?.Result);
|
||||
}).catch(e => {
|
||||
if (111 === e.code) {
|
||||
fn();
|
||||
} else {
|
||||
console.error(e);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
() => resolve(null),
|
||||
true,
|
||||
1,
|
||||
i18n('CRYPTO/DECRYPT')
|
||||
]);
|
||||
fn();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export const SMAudio = new class {
|
|||
if ('running' == audioCtx.state && (this.supportedMp3 || this.supportedOgg)) {
|
||||
notificator = notificator || createNewObject();
|
||||
if (notificator) {
|
||||
// SettingsGet('NotificationSound').startsWith('custom@')
|
||||
notificator.src = Links.staticLink('sounds/'
|
||||
+ SettingsGet('NotificationSound')
|
||||
+ (this.supportedMp3 ? '.mp3' : '.ogg'));
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ Notifications = {
|
|||
ConnectionError: 104,
|
||||
DomainNotAllowed: 109,
|
||||
AccountNotAllowed: 110,
|
||||
CryptKeyError: 111,
|
||||
|
||||
ContactsSyncError: 140,
|
||||
|
||||
|
|
@ -95,7 +96,6 @@ Notifications = {
|
|||
JsonParse: 952,
|
||||
// JsonTimeout: 953,
|
||||
|
||||
UnknownNotification: 998,
|
||||
UnknownError: 999,
|
||||
|
||||
// Admin
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ MessageSetAction = {
|
|||
SetSeen: 0,
|
||||
UnsetSeen: 1,
|
||||
SetFlag: 2,
|
||||
UnsetFlag: 3
|
||||
UnsetFlag: 3,
|
||||
SetDeleted: 4,
|
||||
UnsetDeleted: 5
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
/* eslint key-spacing: 0 */
|
||||
/* eslint quote-props: 0 */
|
||||
|
||||
import { arrayLength } from 'Common/Utils';
|
||||
import { arrayLength, pInt } from 'Common/Utils';
|
||||
|
||||
export const RFC822 = 'message/rfc822';
|
||||
|
||||
const
|
||||
cache = {},
|
||||
|
|
@ -12,8 +14,8 @@ const
|
|||
lowerCase = text => text.toLowerCase().trim(),
|
||||
|
||||
exts = {
|
||||
eml: 'message/rfc822',
|
||||
mime: 'message/rfc822',
|
||||
eml: RFC822,
|
||||
mime: RFC822,
|
||||
vcard: 'text/vcard',
|
||||
vcf: 'text/vcard',
|
||||
htm: 'text/html',
|
||||
|
|
@ -28,6 +30,8 @@ const
|
|||
p7c: app+'pkcs7-mime',
|
||||
p7m: app+'pkcs7-mime',
|
||||
p7s: app+'pkcs7-signature',
|
||||
p12: app+'pkcs12',
|
||||
pfx: app+'x-pkcs12',
|
||||
torrent: app+'x-bittorrent',
|
||||
|
||||
// scripts
|
||||
|
|
@ -116,7 +120,8 @@ export const FileType = {
|
|||
Spreadsheet: 'spreadsheet',
|
||||
Presentation: 'presentation',
|
||||
Certificate: 'certificate',
|
||||
Archive: 'archive'
|
||||
Archive: 'archive',
|
||||
Calendar: 'calendar'
|
||||
};
|
||||
|
||||
export const FileInfo = {
|
||||
|
|
@ -133,7 +138,7 @@ export const FileInfo = {
|
|||
getContentType: fileName => {
|
||||
fileName = lowerCase(fileName);
|
||||
if ('winmail.dat' === fileName) {
|
||||
return app + 'ms-tnef';
|
||||
return app + 'vnd.ms-tnef';
|
||||
}
|
||||
let ext = fileName.split('.').pop();
|
||||
if (/^(txt|text|def|list|in|ini|log|sql|cfg|conf)$/.test(ext))
|
||||
|
|
@ -161,7 +166,7 @@ export const FileInfo = {
|
|||
*/
|
||||
getType: (ext, mimeType) => {
|
||||
ext = lowerCase(ext);
|
||||
mimeType = lowerCase(mimeType).replace('csv/plain', 'text/csv');
|
||||
mimeType = lowerCase(mimeType).replace('csv/plain', 'text/csv').replace('x-','');
|
||||
|
||||
let key = ext + mimeType;
|
||||
if (cache[key]) {
|
||||
|
|
@ -170,7 +175,7 @@ export const FileInfo = {
|
|||
|
||||
let result = FileType.Unknown;
|
||||
const mimeTypeParts = mimeType.split('/'),
|
||||
type = mimeTypeParts[1].replace('x-','').replace('-compressed',''),
|
||||
type = mimeTypeParts[1].replace('-compressed',''),
|
||||
match = str => mimeType.includes(str),
|
||||
archive = /^(zip|7z|tar|rar|gzip|bzip|bzip2)$/;
|
||||
|
||||
|
|
@ -187,9 +192,12 @@ export const FileInfo = {
|
|||
case ['php', 'js', 'css', 'xml', 'html'].includes(ext) || 'text/html' == mimeType:
|
||||
result = FileType.Code;
|
||||
break;
|
||||
case 'eml' == ext || ['message/delivery-status', 'message/rfc822'].includes(mimeType):
|
||||
case 'eml' == ext || ['message/delivery-status', RFC822].includes(mimeType):
|
||||
result = FileType.Eml;
|
||||
break;
|
||||
case 'ics' == ext || mimeType == 'text/calendar':
|
||||
result = FileType.Calendar;
|
||||
break;
|
||||
case 'text' == mimeTypeParts[0] || 'txt' == ext || 'log' == ext:
|
||||
result = FileType.Text;
|
||||
break;
|
||||
|
|
@ -199,9 +207,8 @@ export const FileInfo = {
|
|||
case 'pdf' == type || 'pdf' == ext:
|
||||
result = FileType.Pdf;
|
||||
break;
|
||||
case [app+'pgp-signature', app+'pgp-keys'].includes(mimeType)
|
||||
|| ['asc', 'pem', 'ppk'].includes(ext)
|
||||
|| [app+'pkcs7-signature'].includes(mimeType) || 'p7s' == ext:
|
||||
case [app+'pgp-signature', app+'pgp-keys', exts.p7m, exts.p7s, exts.p12, exts.pfx].includes(mimeType)
|
||||
|| ['asc', 'pem', 'ppk', 'p7s', 'p7m', 'p12', 'pfx'].includes(ext):
|
||||
result = FileType.Certificate;
|
||||
break;
|
||||
case match(msOffice+'.wordprocessingml') || match(openDoc+'.text') || match('vnd.ms-word')
|
||||
|
|
@ -240,6 +247,7 @@ export const FileInfo = {
|
|||
case FileType.Certificate:
|
||||
case FileType.Spreadsheet:
|
||||
case FileType.Presentation:
|
||||
case FileType.Calendar:
|
||||
return result + '-' + fileType;
|
||||
}
|
||||
return result;
|
||||
|
|
@ -266,8 +274,8 @@ export const FileInfo = {
|
|||
},
|
||||
|
||||
friendlySize: bytes => {
|
||||
bytes = parseInt(bytes, 10) || 0;
|
||||
let i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
bytes = pInt(bytes);
|
||||
let i = bytes ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0;
|
||||
return (bytes / Math.pow(1024, i)).toFixed(2>i ? 0 : 1) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { isArray, arrayLength } from 'Common/Utils';
|
||||
import {
|
||||
getFolderInboxName,
|
||||
getFolderFromCacheList
|
||||
} from 'Common/Cache';
|
||||
import { RFC822 } from 'Common/File';
|
||||
import { getFolderInboxName, getFolderFromCacheList } from 'Common/Cache';
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
import { SettingsGet } from 'Common/Globals';
|
||||
import { isArray, arrayLength, pInt } from 'Common/Utils';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
||||
|
|
@ -10,13 +10,13 @@ import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
|||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
let refreshInterval,
|
||||
// Default every 5 minutes
|
||||
refreshFoldersInterval = 300000;
|
||||
// Default every 15 minutes
|
||||
refreshFoldersInterval = 900000;
|
||||
|
||||
export const
|
||||
|
||||
setRefreshFoldersInterval = minutes => {
|
||||
refreshFoldersInterval = Math.max(5, minutes) * 60000;
|
||||
refreshFoldersInterval = Math.max(1, pInt(SettingsGet('minRefreshInterval')), pInt(minutes)) * 60000;
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = setInterval(() => {
|
||||
const cF = FolderUserStore.currentFolderFullName(),
|
||||
|
|
@ -29,7 +29,7 @@ setRefreshFoldersInterval = minutes => {
|
|||
|
||||
sortFolders = folders => {
|
||||
try {
|
||||
let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
|
||||
let collator = baseCollator(true);
|
||||
folders.sort((a, b) =>
|
||||
a.isInbox() ? -1 : (b.isInbox() ? 1 : collator.compare(a.fullName, b.fullName))
|
||||
);
|
||||
|
|
@ -50,15 +50,14 @@ folderListOptionsBuilder = (
|
|||
aDisabled,
|
||||
aHeaderLines,
|
||||
fRenameCallback,
|
||||
fDisableCallback,
|
||||
bNoSelectSelectable,
|
||||
aList = FolderUserStore.folderList()
|
||||
fDisableCallback
|
||||
) => {
|
||||
const
|
||||
aResult = [],
|
||||
sDeepPrefix = '\u00A0\u00A0\u00A0',
|
||||
// FolderSystemPopupView should always be true
|
||||
showUnsubscribed = fRenameCallback ? !SettingsUserStore.hideUnsubscribed() : true,
|
||||
isDisabled = fDisableCallback || (item => !item.selectable() || aDisabled.includes(item.fullName)),
|
||||
|
||||
foldersWalk = folders => {
|
||||
folders.forEach(oItem => {
|
||||
|
|
@ -69,10 +68,7 @@ folderListOptionsBuilder = (
|
|||
sDeepPrefix.repeat(oItem.deep) +
|
||||
fRenameCallback(oItem),
|
||||
system: false,
|
||||
disabled: !bNoSelectSelectable && (
|
||||
!oItem.selectable() ||
|
||||
aDisabled.includes(oItem.fullName) ||
|
||||
fDisableCallback(oItem))
|
||||
disabled: isDisabled(oItem)
|
||||
});
|
||||
}
|
||||
foldersWalk(oItem.subFolders());
|
||||
|
|
@ -93,7 +89,7 @@ folderListOptionsBuilder = (
|
|||
})
|
||||
);
|
||||
|
||||
foldersWalk(aList);
|
||||
foldersWalk(FolderUserStore.folderList());
|
||||
|
||||
return aResult;
|
||||
},
|
||||
|
|
@ -198,12 +194,12 @@ folderInformationMultiply = (boot = false) => {
|
|||
dropFilesInFolder = (sFolderFullName, files) => {
|
||||
let count = files.length;
|
||||
for (const file of files) {
|
||||
if ('message/rfc822' === file.type) {
|
||||
if (RFC822 === file.type) {
|
||||
let data = new FormData;
|
||||
data.append('folder', sFolderFullName);
|
||||
data.append('appendFile', file);
|
||||
Remote.request('FolderAppend', (iError, data)=>{
|
||||
iError && console.error(data.ErrorMessage);
|
||||
iError && console.error(data.message);
|
||||
0 == --count
|
||||
&& FolderUserStore.currentFolderFullName() == sFolderFullName
|
||||
&& MessagelistUserStore.reload(true, true);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const
|
|||
|
||||
Settings = rl.settings,
|
||||
SettingsGet = Settings.get,
|
||||
SettingsAdmin = name => (SettingsGet('Admin') || {})[name],
|
||||
SettingsCapa = name => name && !!(SettingsGet('Capa') || {})[name],
|
||||
|
||||
dropdowns = [],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createElement } from 'Common/Globals';
|
||||
import { forEachObjectEntry, pInt } from 'Common/Utils';
|
||||
import { forEachObjectEntry, isArray, pInt } from 'Common/Utils';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
const
|
||||
|
|
@ -207,7 +207,9 @@ export const
|
|||
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
|
||||
|
||||
result = {
|
||||
hasExternals: false
|
||||
hasExternals: false,
|
||||
tracking: false,
|
||||
linkedData: []
|
||||
},
|
||||
|
||||
findAttachmentByCid = cId => oAttachments.findByCid(cId),
|
||||
|
|
@ -269,12 +271,15 @@ export const
|
|||
// Not supported by <template> element
|
||||
// .replace(/<!doctype[^>]*>/gi, '')
|
||||
// .replace(/<\?xml[^>]*\?>/gi, '')
|
||||
.replace(/<(\/?)head(\s[^>]*)?>/gi, '')
|
||||
.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>/, '')
|
||||
// https://github.com/the-djmaze/snappymail/issues/900
|
||||
.replace(/\u2028/g,' ')
|
||||
// https://github.com/the-djmaze/snappymail/issues/1415
|
||||
.replace(/<br>\s*<\/p>/gi,'</p>')
|
||||
.trim();
|
||||
html = '';
|
||||
|
||||
|
|
@ -284,6 +289,21 @@ export const
|
|||
nodeIterator.referenceNode.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic support for Linked Data (Structured Email)
|
||||
* https://json-ld.org/
|
||||
* https://structured.email/
|
||||
**/
|
||||
tmpl.content.querySelectorAll('script[type="application/ld+json"]').forEach(oElement => {
|
||||
// Could be array of objects or single object
|
||||
try {
|
||||
const data = JSON.parse(oElement.textContent);
|
||||
(isArray(data) ? data : [data]).forEach(entry => result.linkedData.push(entry));
|
||||
} catch (e) {
|
||||
console.error(e, oElement.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
tmpl.content.querySelectorAll(
|
||||
disallowedTags
|
||||
+ (0 < bqLevel ? ',' + (new Array(1 + bqLevel).fill('blockquote').join(' ')) : '')
|
||||
|
|
@ -292,6 +312,18 @@ export const
|
|||
// https://github.com/the-djmaze/snappymail/issues/1125
|
||||
tmpl.content.querySelectorAll('form,button').forEach(oElement => replaceWithChildren(oElement));
|
||||
|
||||
// https://github.com/the-djmaze/snappymail/issues/1641
|
||||
let body = tmpl.content.querySelector('.mail-body');
|
||||
[...tmpl.content.querySelectorAll('.mail-body + .mail-body')]
|
||||
.forEach(oElement => body.append(...oElement.childNodes));
|
||||
/*
|
||||
.forEach(oElement => {
|
||||
let bq = createElement('blockquote');
|
||||
bq.append(...oElement.childNodes);
|
||||
body.replaceWith(bq);
|
||||
});
|
||||
*/
|
||||
|
||||
[...tmpl.content.querySelectorAll('*')].forEach(oElement => {
|
||||
const name = oElement.tagName,
|
||||
oStyle = oElement.style;
|
||||
|
|
@ -381,10 +413,14 @@ export const
|
|||
if ('A' === name) {
|
||||
value = oElement.href;
|
||||
if (!/^([a-z]+):/i.test(value)) {
|
||||
setAttribute('data-x-broken-href', value);
|
||||
setAttribute('data-x-href-broken', value);
|
||||
delAttribute('href');
|
||||
} else {
|
||||
oElement.href = stripTracking(value);
|
||||
if (oElement.href != value) {
|
||||
result.tracking = true;
|
||||
setAttribute('data-x-href-tracking', value);
|
||||
}
|
||||
setAttribute('target', '_blank');
|
||||
// setAttribute('rel', 'external nofollow noopener noreferrer');
|
||||
}
|
||||
|
|
@ -406,9 +442,8 @@ export const
|
|||
*/
|
||||
|
||||
let skipStyle = false;
|
||||
if (hasAttribute('src')) {
|
||||
value = stripTracking(delAttribute('src'));
|
||||
|
||||
value = delAttribute('src');
|
||||
if (value) {
|
||||
if ('IMG' === name) {
|
||||
oElement.loading = 'lazy';
|
||||
let attachment;
|
||||
|
|
@ -445,12 +480,18 @@ export const
|
|||
oStyle.display = 'none';
|
||||
// setAttribute('style', 'display:none');
|
||||
setAttribute('data-x-src-hidden', value);
|
||||
// result.tracking = true;
|
||||
}
|
||||
else if (httpre.test(value))
|
||||
{
|
||||
setAttribute('data-x-src', value);
|
||||
let src = stripTracking(value);
|
||||
if (src != value) {
|
||||
result.tracking = true;
|
||||
setAttribute('data-x-src-tracking', value);
|
||||
}
|
||||
setAttribute('data-x-src', src);
|
||||
result.hasExternals = true;
|
||||
oElement.alt || (oElement.alt = value.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
|
||||
oElement.alt || (oElement.alt = src.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
|
||||
}
|
||||
else if (value.startsWith('data:image/'))
|
||||
{
|
||||
|
|
@ -582,6 +623,8 @@ export const
|
|||
html = html
|
||||
.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gim, (...args) =>
|
||||
1 < args.length ? args[1].toString().replace(/\n/g, '<br>') : '')
|
||||
// Remove line duplication
|
||||
.replace(/<br><\/div>/gi, '</div>')
|
||||
.replace(/\r?\n/g, '')
|
||||
.replace(/\s+/gm, ' ');
|
||||
|
||||
|
|
@ -721,184 +764,7 @@ export const
|
|||
.replace(/\n/g, '<br>');
|
||||
blockquoteSwitcher();
|
||||
return tmpl.innerHTML.trim();
|
||||
},
|
||||
|
||||
WYSIWYGS = ko.observableArray();
|
||||
|
||||
WYSIWYGS.push(['Squire', (owner, container, onReady)=>{
|
||||
let squire = new SquireUI(container);
|
||||
setTimeout(()=>onReady(squire), 1);
|
||||
/*
|
||||
squire.on('blur', () => owner.blurTrigger());
|
||||
squire.on('focus', () => clearTimeout(owner.blurTimer));
|
||||
squire.on('mode', () => {
|
||||
owner.blurTrigger();
|
||||
owner.onModeChange?.(!owner.isPlain());
|
||||
});
|
||||
*/
|
||||
}]);
|
||||
|
||||
rl.registerWYSIWYG = (name, construct) => WYSIWYGS.push([name, construct]);
|
||||
|
||||
export class HtmlEditor {
|
||||
/**
|
||||
* @param {Object} element
|
||||
* @param {Function=} onBlur
|
||||
* @param {Function=} onReady
|
||||
* @param {Function=} onModeChange
|
||||
*/
|
||||
constructor(element, onBlur = null, onReady = null, onModeChange = null) {
|
||||
this.blurTimer = 0;
|
||||
|
||||
this.onBlur = onBlur;
|
||||
this.onModeChange = onModeChange;
|
||||
|
||||
if (element) {
|
||||
onReady = onReady ? [onReady] : [];
|
||||
this.onReady = fn => onReady.push(fn);
|
||||
// TODO: make 'which' user configurable
|
||||
// const which = 'CKEditor4',
|
||||
// wysiwyg = WYSIWYGS.find(item => which == item[0]) || WYSIWYGS.find(item => 'Squire' == item[0]);
|
||||
const wysiwyg = WYSIWYGS.find(item => 'Squire' == item[0]);
|
||||
wysiwyg[1](this, element, editor => {
|
||||
this.editor = editor;
|
||||
editor.on('blur', () => this.blurTrigger());
|
||||
editor.on('focus', () => clearTimeout(this.blurTimer));
|
||||
editor.on('mode', () => {
|
||||
this.blurTrigger();
|
||||
this.onModeChange?.(!this.isPlain());
|
||||
});
|
||||
this.onReady = fn => fn();
|
||||
onReady.forEach(fn => fn());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
blurTrigger() {
|
||||
if (this.onBlur) {
|
||||
clearTimeout(this.blurTimer);
|
||||
this.blurTimer = setTimeout(() => this.onBlur?.(), 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isHtml() {
|
||||
return this.editor ? !this.isPlain() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPlain() {
|
||||
return this.editor ? 'plain' === this.editor.mode : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
clearCachedSignature() {
|
||||
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||
clearCache: true
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} signature
|
||||
* @param {bool} html
|
||||
* @param {bool} insertBefore
|
||||
* @returns {void}
|
||||
*/
|
||||
setSignature(signature, html, insertBefore = false) {
|
||||
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||
isHtml: html,
|
||||
insertBefore: insertBefore,
|
||||
signature: signature
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean=} wrapIsHtml = false
|
||||
* @returns {string}
|
||||
*/
|
||||
getData() {
|
||||
let result = '';
|
||||
if (this.editor) {
|
||||
try {
|
||||
if (this.isPlain() && this.editor.plugins.plain && this.editor.__plain) {
|
||||
result = this.editor.__plain.getRawData();
|
||||
} else {
|
||||
result = this.editor.getData();
|
||||
}
|
||||
} catch (e) {} // eslint-disable-line no-empty
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
getDataWithHtmlMark() {
|
||||
return (this.isHtml() ? ':HTML:' : '') + this.getData();
|
||||
}
|
||||
|
||||
modeWysiwyg() {
|
||||
this.onReady(() => this.editor.setMode('wysiwyg'));
|
||||
}
|
||||
modePlain() {
|
||||
this.onReady(() => this.editor.setMode('plain'));
|
||||
}
|
||||
|
||||
setHtmlOrPlain(text) {
|
||||
text.startsWith(':HTML:')
|
||||
? this.setHtml(text.slice(6))
|
||||
: this.setPlain(text);
|
||||
}
|
||||
|
||||
setData(mode, data) {
|
||||
this.onReady(() => {
|
||||
const editor = this.editor;
|
||||
this.clearCachedSignature();
|
||||
try {
|
||||
editor.setMode(mode);
|
||||
if (this.isPlain() && editor.plugins.plain && editor.__plain) {
|
||||
editor.__plain.setRawData(data);
|
||||
} else {
|
||||
editor.setData(data);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
|
||||
setHtml(html) {
|
||||
this.setData('wysiwyg', html/*.replace(/<p[^>]*><\/p>/gi, '')*/);
|
||||
}
|
||||
|
||||
setPlain(txt) {
|
||||
this.setData('plain', txt);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.onReady(() => this.editor.focus());
|
||||
}
|
||||
|
||||
hasFocus() {
|
||||
try {
|
||||
return !!this.editor?.focusManager.hasFocus;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
blur() {
|
||||
this.onReady(() => this.editor.focusManager.blur(true));
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.onReady(() => this.isPlain() ? this.setPlain('') : this.setHtml(''));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rl.Utils = {
|
||||
htmlToPlain: htmlToPlain,
|
||||
|
|
|
|||
161
dev/Common/HtmlEditor.js
Normal file
161
dev/Common/HtmlEditor.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
export const
|
||||
WYSIWYGS = ko.observableArray();
|
||||
|
||||
WYSIWYGS.push({
|
||||
name: 'Squire',
|
||||
construct: (owner, container, onReady) => onReady(new SquireUI(container))
|
||||
});
|
||||
|
||||
rl.registerWYSIWYG = (name, construct) => WYSIWYGS.push({name, construct});
|
||||
|
||||
export class HtmlEditor {
|
||||
/**
|
||||
* @param {Object} element
|
||||
* @param {Function=} onBlur
|
||||
* @param {Function=} onReady
|
||||
* @param {Function=} onModeChange
|
||||
*/
|
||||
constructor(element, onReady = null, onModeChange = null, onBlur = null) {
|
||||
this.blurTimer = 0;
|
||||
|
||||
this.onBlur = onBlur;
|
||||
this.onModeChange = onModeChange;
|
||||
|
||||
if (element) {
|
||||
onReady = onReady ? [onReady] : [];
|
||||
this.onReady = fn => onReady.push(fn);
|
||||
const which = SettingsUserStore.editorWysiwyg(),
|
||||
wysiwyg = WYSIWYGS.find(item => which == item.name) || WYSIWYGS.find(item => 'Squire' == item.name);
|
||||
wysiwyg.construct(this, element, editor => setTimeout(()=>{
|
||||
this.editor = editor;
|
||||
editor.on('blur', () => this.blurTrigger());
|
||||
editor.on('focus', () => clearTimeout(this.blurTimer));
|
||||
editor.on('mode', () => {
|
||||
this.blurTrigger();
|
||||
this.onModeChange?.(!this.isPlain());
|
||||
});
|
||||
this.onReady = fn => fn();
|
||||
onReady.forEach(fn => fn());
|
||||
},1));
|
||||
}
|
||||
}
|
||||
|
||||
blurTrigger() {
|
||||
if (this.onBlur) {
|
||||
clearTimeout(this.blurTimer);
|
||||
this.blurTimer = setTimeout(() => this.onBlur?.(), 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isHtml() {
|
||||
return this.editor ? !this.isPlain() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPlain() {
|
||||
return this.editor ? 'plain' === this.editor.mode : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
clearCachedSignature() {
|
||||
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||
clearCache: true
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} signature
|
||||
* @param {bool} html
|
||||
* @param {bool} insertBefore
|
||||
* @returns {void}
|
||||
*/
|
||||
setSignature(signature, html, insertBefore = false) {
|
||||
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||
isHtml: html,
|
||||
insertBefore: insertBefore,
|
||||
signature: signature
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean=} wrapIsHtml = false
|
||||
* @returns {string}
|
||||
*/
|
||||
getData() {
|
||||
let result = '';
|
||||
if (this.editor) {
|
||||
try {
|
||||
if (this.isPlain()) {
|
||||
result = this.editor.getPlainData();
|
||||
} else {
|
||||
result = this.editor.getData();
|
||||
}
|
||||
} catch (e) {} // eslint-disable-line no-empty
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
getDataWithHtmlMark() {
|
||||
return (this.isHtml() ? ':HTML:' : '') + this.getData();
|
||||
}
|
||||
|
||||
modeWysiwyg() {
|
||||
this.onReady(() => this.editor.setMode('wysiwyg'));
|
||||
}
|
||||
modePlain() {
|
||||
this.onReady(() => this.editor.setMode('plain'));
|
||||
}
|
||||
|
||||
setHtmlOrPlain(text) {
|
||||
text.startsWith(':HTML:')
|
||||
? this.setHtml(text.slice(6))
|
||||
: this.setPlain(text);
|
||||
}
|
||||
|
||||
setData(mode, data) {
|
||||
this.onReady(() => {
|
||||
const editor = this.editor;
|
||||
this.clearCachedSignature();
|
||||
try {
|
||||
editor.setMode(mode);
|
||||
if (this.isPlain()) {
|
||||
editor.setPlainData(data);
|
||||
} else {
|
||||
editor.setData(data);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
|
||||
setHtml(html) {
|
||||
this.setData('wysiwyg', html/*.replace(/<p[^>]*><\/p>/gi, '')*/);
|
||||
}
|
||||
|
||||
setPlain(txt) {
|
||||
this.setData('plain', txt);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.onReady(() => this.editor.focus());
|
||||
}
|
||||
|
||||
blur() {
|
||||
this.onReady(() => this.editor.blur());
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.onReady(() => this.isPlain() ? this.setPlain('') : this.setHtml(''));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { pInt } from 'Common/Utils';
|
||||
import { doc, Settings } from 'Common/Globals';
|
||||
import { doc, Settings, SettingsAdmin } from 'Common/Globals';
|
||||
|
||||
const
|
||||
BASE = doc.location.pathname.replace(/\/+$/,'') + '/',
|
||||
HASH_PREFIX = '#/',
|
||||
|
||||
adminPath = () => rl.adminArea() && !Settings.app('adminHost'),
|
||||
adminPath = () => rl.adminArea() && !SettingsAdmin('host'),
|
||||
|
||||
prefix = () => BASE + '?' + (adminPath() ? Settings.app('adminPath') : '');
|
||||
prefix = () => BASE + '?' + (adminPath() ? SettingsAdmin('path') : '');
|
||||
|
||||
export const
|
||||
SUB_QUERY_PREFIX = '&q[]=',
|
||||
|
|
@ -38,11 +38,10 @@ export const
|
|||
|
||||
/**
|
||||
* @param {string} download
|
||||
* @param {string=} customSpecSuffix
|
||||
* @returns {string}
|
||||
*/
|
||||
attachmentDownload = (download, customSpecSuffix) =>
|
||||
serverRequestRaw('Download', download, customSpecSuffix),
|
||||
attachmentDownload = (download) =>
|
||||
serverRequestRaw('Download', download),
|
||||
|
||||
proxy = url =>
|
||||
BASE + '?/ProxyExternal/'
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { koComputable } from 'External/ko';
|
|||
oCallbacks:
|
||||
ItemSelect
|
||||
MiddleClick
|
||||
AutoSelect
|
||||
canSelect
|
||||
ItemGetUid
|
||||
UpOrDown
|
||||
*/
|
||||
|
|
@ -21,15 +21,13 @@ export class Selector {
|
|||
* @param {koProperty} koFocusedItem
|
||||
* @param {string} sItemSelector
|
||||
* @param {string} sItemCheckedSelector
|
||||
* @param {string} sItemFocusedSelector
|
||||
*/
|
||||
constructor(
|
||||
koList,
|
||||
koSelectedItem,
|
||||
koFocusedItem,
|
||||
sItemSelector,
|
||||
sItemCheckedSelector,
|
||||
sItemFocusedSelector
|
||||
sItemCheckedSelector
|
||||
) {
|
||||
koFocusedItem = (koFocusedItem || ko.observable(null)).extend({ toggleSubscribeProperty: [this, 'focused'] });
|
||||
koSelectedItem = (koSelectedItem || ko.observable(null)).extend({ toggleSubscribeProperty: [null, 'selected'] });
|
||||
|
|
@ -46,7 +44,7 @@ export class Selector {
|
|||
|
||||
this.sItemSelector = sItemSelector;
|
||||
this.sItemCheckedSelector = sItemCheckedSelector;
|
||||
this.sItemFocusedSelector = sItemFocusedSelector;
|
||||
this.sItemFocusedSelector = sItemSelector + '.focused';
|
||||
|
||||
this.sLastUid = '';
|
||||
this.oCallbacks = {};
|
||||
|
|
@ -74,7 +72,7 @@ export class Selector {
|
|||
|
||||
koSelectedItem.subscribe(item => {
|
||||
if (item) {
|
||||
koList.forEach(subItem => subItem.checked(false));
|
||||
// koList.forEach(subItem => subItem.checked(false));
|
||||
selectedItemUseCallback && itemSelectedThrottle(item);
|
||||
} else {
|
||||
selectedItemUseCallback && itemSelected();
|
||||
|
|
@ -120,7 +118,8 @@ export class Selector {
|
|||
|
||||
if (isArray(aItems)) {
|
||||
let temp,
|
||||
isChecked;
|
||||
isChecked,
|
||||
next = this.iFocusedNextHelper || this.iSelectNextHelper;
|
||||
|
||||
aItems.forEach(item => {
|
||||
const uid = this.getItemUid(item);
|
||||
|
|
@ -145,24 +144,10 @@ export class Selector {
|
|||
|
||||
selectedItemUseCallback = true;
|
||||
|
||||
if (
|
||||
(this.iSelectNextHelper || this.iFocusedNextHelper) &&
|
||||
aItems.length &&
|
||||
!koFocusedItem()
|
||||
) {
|
||||
temp = null;
|
||||
if (this.iFocusedNextHelper) {
|
||||
temp = aItems[-1 === this.iFocusedNextHelper ? aItems.length - 1 : 0];
|
||||
}
|
||||
|
||||
if (!temp && this.iSelectNextHelper) {
|
||||
temp = aItems[-1 === this.iSelectNextHelper ? aItems.length - 1 : 0];
|
||||
}
|
||||
|
||||
if (next && aItems.length && !koFocusedItem()) {
|
||||
temp = aItems[-1 === next ? aItems.length - 1 : 0];
|
||||
if (temp) {
|
||||
if (this.iSelectNextHelper) {
|
||||
koSelectedItem(temp);
|
||||
}
|
||||
this.iSelectNextHelper && koSelectedItem(temp);
|
||||
|
||||
koFocusedItem(temp);
|
||||
|
||||
|
|
@ -200,10 +185,11 @@ export class Selector {
|
|||
|
||||
addEventsListeners(contentScrollable, {
|
||||
click: event => {
|
||||
let el = event.target.closestWithin(this.sItemSelector, contentScrollable);
|
||||
el && this.actionClick(ko.dataFor(el), event);
|
||||
const el = event.target.closestWithin(this.sItemSelector, contentScrollable);
|
||||
let item = el && ko.dataFor(el);
|
||||
el && (this.oCallbacks.click || (()=>1))(event, item) && this.actionClick(item, event);
|
||||
|
||||
const item = getItem(this.sItemCheckedSelector);
|
||||
item = getItem(this.sItemCheckedSelector);
|
||||
if (item) {
|
||||
if (event.shiftKey) {
|
||||
this.actionClick(item, event);
|
||||
|
|
@ -249,7 +235,7 @@ export class Selector {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
autoSelect(bForce) {
|
||||
(bForce || (this.oCallbacks.AutoSelect || (()=>1))())
|
||||
(bForce || (this.oCallbacks.canSelect || (()=>1))())
|
||||
&& this.focusedItem()
|
||||
&& this.selectedItem(this.focusedItem());
|
||||
}
|
||||
|
|
@ -268,10 +254,11 @@ export class Selector {
|
|||
* @param {boolean=} bForceSelect = false
|
||||
*/
|
||||
newSelectPosition(sEventKey, bShiftKey, bForceSelect) {
|
||||
let isArrow = 'ArrowUp' === sEventKey || 'ArrowDown' === sEventKey,
|
||||
result;
|
||||
let result;
|
||||
|
||||
const pageStep = 10,
|
||||
const up = 'ArrowUp' === sEventKey,
|
||||
isArrow = up || 'ArrowDown' === sEventKey,
|
||||
pageStep = 10,
|
||||
list = this.list(),
|
||||
listLen = list.length,
|
||||
focused = this.focusedItem();
|
||||
|
|
@ -283,8 +270,7 @@ export class Selector {
|
|||
} else if (listLen) {
|
||||
if (focused) {
|
||||
if (isArrow) {
|
||||
let i = list.indexOf(focused),
|
||||
up = 'ArrowUp' == sEventKey;
|
||||
let i = list.indexOf(focused);
|
||||
if (bShiftKey) {
|
||||
shiftStart = -1 < shiftStart ? shiftStart : i;
|
||||
shiftStart == i
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const
|
|||
|
||||
getNotificationMessage = code => {
|
||||
let key = getKeyByValue(Notifications, code);
|
||||
return key ? I18N_DATA.NOTIFICATIONS[i18nKey(key).replace('_NOTIFICATION', '_ERROR')] : '';
|
||||
return key ? I18N_DATA.NOTIFICATIONS[key] : '';
|
||||
},
|
||||
|
||||
fromNow = date => relativeTime(Math.round((date.getTime() - Date.now()) / 1000));
|
||||
|
|
@ -45,8 +45,7 @@ export const
|
|||
}
|
||||
}
|
||||
if (Intl.RelativeTimeFormat) {
|
||||
let rtf = new Intl.RelativeTimeFormat(doc.documentElement.lang);
|
||||
return rtf.format(seconds, unit);
|
||||
return (new Intl.RelativeTimeFormat(doc.documentElement.lang)).format(seconds, unit);
|
||||
}
|
||||
// Safari < 14
|
||||
abs = Math.abs(seconds);
|
||||
|
|
@ -62,7 +61,7 @@ export const
|
|||
* @returns {string}
|
||||
*/
|
||||
i18n = (key, valueList, defaulValue) => {
|
||||
let result = null == defaulValue ? key : defaulValue;
|
||||
let result = defaulValue ?? key;
|
||||
let path = key.split('/');
|
||||
if (I18N_DATA[path[0]] && path[1]) {
|
||||
result = I18N_DATA[path[0]][path[1]] || result;
|
||||
|
|
@ -102,8 +101,7 @@ export const
|
|||
|
||||
timestampToString = (timeStampInUTC, formatStr) => {
|
||||
const now = Date.now(),
|
||||
time = 0 < timeStampInUTC ? Math.min(now, timeStampInUTC * 1000) : (0 === timeStampInUTC ? now : 0);
|
||||
|
||||
time = 0 < timeStampInUTC ? timeStampInUTC * 1000 : (0 === timeStampInUTC ? now : 0);
|
||||
if (31536000000 < time) {
|
||||
const m = new Date(time), h = LanguageStore.hourCycle();
|
||||
switch (formatStr) {
|
||||
|
|
@ -144,7 +142,7 @@ export const
|
|||
time = Date.parse(element.dateTime) / 1000;
|
||||
}
|
||||
|
||||
let key = element.dataset.momentFormat;
|
||||
let key = element.dataset.timeFormat;
|
||||
if (key) {
|
||||
element.textContent = timestampToString(time, key);
|
||||
if ('FULL' !== key && 'FROMNOW' !== key) {
|
||||
|
|
@ -186,6 +184,9 @@ export const
|
|||
|| '';
|
||||
},
|
||||
|
||||
getErrorMessage = (code, data) =>
|
||||
getNotification(code) || data?.messageAdditional || data?.message || data,
|
||||
|
||||
/**
|
||||
* @param {*} code
|
||||
* @returns {string}
|
||||
|
|
@ -212,14 +213,13 @@ export const
|
|||
script.remove();
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => reject(new Error('Language '+language+' failed'));
|
||||
script.onerror = () => reject(Error('Language '+language+' failed'));
|
||||
script.src = langLink(language, admin);
|
||||
// script.async = true;
|
||||
doc.head.append(script);
|
||||
}),
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} language
|
||||
* @param {boolean=} isEng = false
|
||||
* @returns {string}
|
||||
|
|
@ -229,6 +229,8 @@ export const
|
|||
'LANGS_NAMES' + (true === isEng ? '_EN' : '') + '/' + language,
|
||||
null,
|
||||
language
|
||||
);
|
||||
),
|
||||
|
||||
baseCollator = numeric => new Intl.Collator(doc.documentElement.lang, {numeric: !!numeric, sensitivity: 'base'});
|
||||
|
||||
init();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const
|
|||
|
||||
pInt = (value, defaultValue = 0) => {
|
||||
value = parseInt(value, 10);
|
||||
return isNaN(value) || !isFinite(value) ? defaultValue : value;
|
||||
return isFinite(value) ? value : defaultValue;
|
||||
},
|
||||
|
||||
defaultOptionsAfterRender = (domItem, item) =>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,57 @@ import { ThemeStore } from 'Stores/Theme';
|
|||
import Remote from 'Remote/User/Fetch';
|
||||
import { attachmentDownload } from 'Common/Links';
|
||||
|
||||
import { AccountModel } from 'Model/Account';
|
||||
import { IdentityModel } from 'Model/Identity';
|
||||
import { AccountUserStore } from 'Stores/User/Account';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
import { isArray } from 'Common/Utils';
|
||||
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
import { IdentityPopupView } from 'View/Popup/Identity';
|
||||
|
||||
export const
|
||||
|
||||
moveAction = ko.observable(false),
|
||||
// 1 = move, 2 = copy
|
||||
moveAction = ko.observable(0),
|
||||
|
||||
dropdownsDetectVisibility = (() =>
|
||||
dropdownVisibility(!!dropdowns.find(item => item.classList.contains('show')))
|
||||
).debounce(50),
|
||||
|
||||
|
||||
editIdentity = Identity => showScreenPopup(IdentityPopupView, [Identity]),
|
||||
|
||||
loadAccountsAndIdentities = () => {
|
||||
AccountUserStore.loading(true);
|
||||
IdentityUserStore.loading(true);
|
||||
|
||||
Remote.request('AccountsAndIdentities', (iError, oData) => {
|
||||
AccountUserStore.loading(false);
|
||||
IdentityUserStore.loading(false);
|
||||
|
||||
if (!iError) {
|
||||
let items = oData.Result.Accounts;
|
||||
AccountUserStore(isArray(items)
|
||||
? items.map(oValue => new AccountModel(oValue.email, oValue.name))
|
||||
: []
|
||||
);
|
||||
AccountUserStore.unshift(new AccountModel(SettingsGet('mainEmail'), '', false));
|
||||
|
||||
items = oData.Result.Identities;
|
||||
IdentityUserStore(isArray(items)
|
||||
? items.map(identityData => IdentityModel.reviveFromJson(identityData))
|
||||
: []
|
||||
);
|
||||
|
||||
// Invoke "Update Identity" pop up right after login
|
||||
// https://github.com/the-djmaze/snappymail/issues/1689
|
||||
const main = IdentityUserStore.main();
|
||||
main && !main.exists() && setTimeout(()=>editIdentity(main), 1000);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} link
|
||||
* @returns {boolean}
|
||||
|
|
@ -89,7 +132,7 @@ computedPaginatorHelper = (koCurrentPage, koPageCount) => {
|
|||
next = 0,
|
||||
limit = 2;
|
||||
|
||||
if (1 < pageCount || (0 < pageCount && pageCount < currentPage)) {
|
||||
if (1 < pageCount) {
|
||||
if (pageCount < currentPage) {
|
||||
fAdd(pageCount);
|
||||
prev = pageCount;
|
||||
|
|
@ -248,7 +291,7 @@ setLayoutResizer = (source, sClientSideKeyName, mode) =>
|
|||
|
||||
viewMessage = (oMessage, popup) => {
|
||||
if (popup) {
|
||||
oMessage.viewPopupMessage();
|
||||
oMessage.popupMessage();
|
||||
} else {
|
||||
MessageUserStore.error('');
|
||||
let id = 'rl-msg-' + oMessage.hash,
|
||||
|
|
@ -260,6 +303,8 @@ viewMessage = (oMessage, popup) => {
|
|||
class:'b-text-part'
|
||||
+ (oMessage.pgpSigned() ? ' openpgp-signed' : '')
|
||||
+ (oMessage.pgpEncrypted() ? ' openpgp-encrypted' : '')
|
||||
+ (oMessage.smimeSigned() ? ' smime-signed' : '')
|
||||
+ (oMessage.smimeEncrypted() ? ' smime-encrypted' : '')
|
||||
});
|
||||
MessageUserStore.purgeCache();
|
||||
}
|
||||
|
|
@ -276,7 +321,7 @@ viewMessage = (oMessage, popup) => {
|
|||
MessageUserStore.loading(false);
|
||||
oMessage.body.hidden = false;
|
||||
|
||||
if (oMessage.isUnseen()) {
|
||||
if (oMessage.isUnseen() && SettingsUserStore.messageReadAuto()) {
|
||||
MessageUserStore.MessageSeenTimer = setTimeout(
|
||||
() => MessagelistUserStore.setAction(oMessage.folder, MessageSetAction.SetSeen, [oMessage]),
|
||||
SettingsUserStore.messageReadDelay() * 1000 // seconds
|
||||
|
|
@ -301,10 +346,14 @@ populateMessageBody = (oMessage, popup) => {
|
|||
} else {
|
||||
let json = oData?.Result;
|
||||
if (json
|
||||
&& oMessage.hash === json.hash
|
||||
// && oMessage.folder === json.folder
|
||||
// && oMessage.uid == json.uid
|
||||
&& oMessage.revivePropertiesFromJson(json)
|
||||
&& ((
|
||||
oMessage.hash && oMessage.hash === json.hash
|
||||
) || (
|
||||
!oMessage.hash
|
||||
&& oMessage.folder === json.folder
|
||||
&& oMessage.uid == json.uid)
|
||||
)
|
||||
&& oMessage.revivePropertiesFromJson(json)
|
||||
) {
|
||||
/*
|
||||
if (bCached) {
|
||||
|
|
@ -321,5 +370,5 @@ populateMessageBody = (oMessage, popup) => {
|
|||
}
|
||||
};
|
||||
|
||||
leftPanelDisabled.subscribe(value => value && moveAction(false));
|
||||
leftPanelDisabled.subscribe(value => value && moveAction(0));
|
||||
moveAction.subscribe(value => value && leftPanelDisabled(false));
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export class CheckboxComponent {
|
|||
: ko.observable(!!params.value);
|
||||
|
||||
this.enable = ko.isObservable(params.enable) ? params.enable
|
||||
: ko.observable(undefined === params.enable || !!params.enable);
|
||||
: ko.observable(params.enable ?? 1);
|
||||
|
||||
this.label = params.label;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { doc, createElement, addEventsListeners } from 'Common/Globals';
|
||||
import { EmailModel, addressparser } from 'Model/Email';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { addressparser } from 'Mime/Address';
|
||||
|
||||
const contentType = 'snappymail/emailaddress',
|
||||
getAddressKey = li => li?.emailaddress?.key,
|
||||
|
|
@ -165,7 +166,9 @@ export class EmailAddressesComponent {
|
|||
|
||||
_parseInput(force) {
|
||||
let val = this.input.value;
|
||||
if ((force || val.includes(',') || val.includes(';')) && this._parseValue(val)) {
|
||||
if ((force || val.includes(',') || val.includes(';')
|
||||
|| (val.charAt(val.length-1)===' ' && this._simpleEmailMatch(val)))
|
||||
&& this._parseValue(val)) {
|
||||
this.input.value = '';
|
||||
}
|
||||
this._resizeInput();
|
||||
|
|
@ -284,6 +287,13 @@ export class EmailAddressesComponent {
|
|||
}
|
||||
}
|
||||
|
||||
_simpleEmailMatch(value) {
|
||||
// A very SIMPLE test to check if the value might be an email
|
||||
const val = value.trim();
|
||||
return /^[^@]*<[^\s@]{1,128}@[^\s@]{1,256}\.[\w]{2,32}>$/g.test(val)
|
||||
|| /^[^\s@]{1,128}@[^\s@]{1,256}\.[\w]{2,32}$/g.test(val);
|
||||
}
|
||||
|
||||
_renderTags() {
|
||||
let self = this;
|
||||
[...self.ul.children].forEach(node => node !== self.inputCont && node.remove());
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export class JCard {
|
|||
if (input) {
|
||||
// read from jCard
|
||||
if (typeof input !== 'object') {
|
||||
throw new Error('error reading vcard')
|
||||
throw Error('error reading vcard')
|
||||
}
|
||||
this.parseFromJCard(input)
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ export class JCard {
|
|||
arg = new VCardProperty(String(arg), value, params, type);
|
||||
}
|
||||
if (!(arg instanceof VCardProperty)) {
|
||||
throw new Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty');
|
||||
throw Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty');
|
||||
}
|
||||
let field = arg.getField();
|
||||
this.props.set(field, [arg]);
|
||||
|
|
@ -101,7 +101,7 @@ export class JCard {
|
|||
arg = new VCardProperty(String(arg), value, params, type);
|
||||
}
|
||||
if (!(arg instanceof VCardProperty)) {
|
||||
throw new Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty');
|
||||
throw Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty');
|
||||
}
|
||||
// VCardProperty arguments
|
||||
let field = arg.getField();
|
||||
|
|
@ -124,15 +124,15 @@ export class JCard {
|
|||
// VCardProperty argument
|
||||
else if (arg instanceof VCardProperty) {
|
||||
let propArray = this.props.get(arg.getField());
|
||||
if (!(propArray === null || propArray === void 0 ? void 0 : propArray.includes(arg)))
|
||||
throw new Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg));
|
||||
if (!propArray?.includes(arg))
|
||||
throw Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg));
|
||||
propArray.splice(propArray.indexOf(arg), 1);
|
||||
if (propArray.length === 0)
|
||||
this.props.delete(arg.getField());
|
||||
}
|
||||
// incorrect arguments
|
||||
else
|
||||
throw new Error('invalid argument of VCard.remove(), expects ' +
|
||||
throw Error('invalid argument of VCard.remove(), expects ' +
|
||||
'string and optional param filter or a VCardProperty');
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +202,7 @@ export class JCard {
|
|||
parseFullName(options) {
|
||||
let n = this.getOne('n');
|
||||
if (n === undefined) {
|
||||
throw new Error('\'fn\' VCardProperty not present in card, cannot parse full name');
|
||||
throw Error('\'fn\' VCardProperty not present in card, cannot parse full name');
|
||||
}
|
||||
let fnString = '';
|
||||
// Position in n -> position in fn
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export class VCardProperty {
|
|||
}
|
||||
// invalid property
|
||||
else {
|
||||
throw new Error('invalid Property constructor');
|
||||
throw Error('invalid Property constructor');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
59
dev/External/SquireUI.js
vendored
59
dev/External/SquireUI.js
vendored
|
|
@ -133,11 +133,11 @@ class SquireUI
|
|||
dir: {
|
||||
dir_ltr: {
|
||||
html: '⁋',
|
||||
cmd: () => squire.bidi('ltr')
|
||||
cmd: () => squire.setTextDirection('ltr')
|
||||
},
|
||||
dir_rtl: {
|
||||
html: '¶',
|
||||
cmd: () => squire.bidi('rtl')
|
||||
cmd: () => squire.setTextDirection('rtl')
|
||||
}
|
||||
},
|
||||
colors: {
|
||||
|
|
@ -237,7 +237,7 @@ class SquireUI
|
|||
cmd: () => {
|
||||
let node = squire.getSelectionClosest('IMG'),
|
||||
src = prompt("Image", node?.src || "https://");
|
||||
src?.length ? squire.insertImage(src) : (node && squire.detach(node));
|
||||
src?.length ? squire.insertImage(src) : node?.remove();
|
||||
},
|
||||
matches: 'IMG'
|
||||
},
|
||||
|
|
@ -270,6 +270,13 @@ class SquireUI
|
|||
btn.classList.toggle('active', 'source' == this.mode);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clear: {
|
||||
removeStyle: {
|
||||
html: '⎚',
|
||||
cmd: () => squire.setStyle()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -318,11 +325,6 @@ class SquireUI
|
|||
wysiwyg.className = 'squire-wysiwyg';
|
||||
wysiwyg.dir = 'auto';
|
||||
this.mode = ''; // 'plain' | 'wysiwyg'
|
||||
this.__plain = {
|
||||
getRawData: () => this.plain.value,
|
||||
setRawData: plain => this.plain.value = plain
|
||||
};
|
||||
|
||||
this.container = container;
|
||||
this.squire = squire;
|
||||
this.plain = plain;
|
||||
|
|
@ -403,9 +405,9 @@ class SquireUI
|
|||
|
||||
let changes = actions.changes;
|
||||
changes.undo.input.disabled = changes.redo.input.disabled = true;
|
||||
squire.addEventListener('undoStateChange', state => {
|
||||
changes.undo.input.disabled = !state.canUndo;
|
||||
changes.redo.input.disabled = !state.canRedo;
|
||||
squire.addEventListener('undoStateChange', e => {
|
||||
changes.undo.input.disabled = !e.detail.canUndo;
|
||||
changes.redo.input.disabled = !e.detail.canRedo;
|
||||
});
|
||||
|
||||
actions.font.fontSize.input.selectedIndex = actions.font.fontSize.defaultValueIndex;
|
||||
|
|
@ -478,21 +480,21 @@ class SquireUI
|
|||
squire.addEventListener('pathChange', e => {
|
||||
|
||||
const squireRoot = squire.getRoot();
|
||||
let elm = e.detail.element;
|
||||
|
||||
forEachObjectValue(actions, entries => {
|
||||
forEachObjectValue(entries, cfg => {
|
||||
// cfg.matches && cfg.input.classList.toggle('active', e.element && e.element.matches(cfg.matches));
|
||||
cfg.matches && cfg.input.classList.toggle('active', e.element && e.element.closestWithin(cfg.matches, squireRoot));
|
||||
// cfg.matches && cfg.input.classList.toggle('active', elm && elm.matches(cfg.matches));
|
||||
cfg.matches && cfg.input.classList.toggle('active', elm && elm.closestWithin(cfg.matches, squireRoot));
|
||||
});
|
||||
});
|
||||
|
||||
if (e.element) {
|
||||
if (elm) {
|
||||
// try to find font-family and/or font-size and set "select" elements' values
|
||||
|
||||
let sizeSelectedIndex = actions.font.fontSize.defaultValueIndex;
|
||||
let familySelectedIndex = defaultFontFamilyIndex;
|
||||
|
||||
let elm = e.element;
|
||||
let familyFound = false;
|
||||
let sizeFound = false;
|
||||
do {
|
||||
|
|
@ -524,21 +526,12 @@ class SquireUI
|
|||
});
|
||||
/*
|
||||
squire.addEventListener('cursor', e => {
|
||||
console.dir({cursor:e.range});
|
||||
console.dir({cursor:e.detail.range});
|
||||
});
|
||||
squire.addEventListener('select', e => {
|
||||
console.dir({select:e.range});
|
||||
console.dir({select:e.detail.range});
|
||||
});
|
||||
*/
|
||||
|
||||
// CKEditor gimmicks used by HtmlEditor
|
||||
this.plugins = {
|
||||
plain: true
|
||||
};
|
||||
this.focusManager = {
|
||||
hasFocus: () => squire._isFocused,
|
||||
blur: () => squire.blur()
|
||||
};
|
||||
}
|
||||
|
||||
doAction(name) {
|
||||
|
|
@ -576,7 +569,6 @@ class SquireUI
|
|||
this.modeSelect.selectedIndex = 'plain' == this.mode ? 1 : 0;
|
||||
}
|
||||
|
||||
// CKeditor gimmicks used by HtmlEditor
|
||||
on(type, fn) {
|
||||
if ('mode' == type) {
|
||||
this.onModeChange = fn;
|
||||
|
|
@ -619,6 +611,7 @@ class SquireUI
|
|||
// Move cursor above signature
|
||||
div.before(br);
|
||||
div.before(br.cloneNode());
|
||||
// squire._docWasChanged();
|
||||
}
|
||||
this._prev_txt_sig = signature;
|
||||
} catch (e) {
|
||||
|
|
@ -642,6 +635,18 @@ class SquireUI
|
|||
squire.setSelection( range );
|
||||
}
|
||||
|
||||
getPlainData() {
|
||||
return this.plain.value;
|
||||
}
|
||||
|
||||
setPlainData(text) {
|
||||
this.plain.value = text;
|
||||
}
|
||||
|
||||
blur() {
|
||||
this.squire.blur();
|
||||
}
|
||||
|
||||
focus() {
|
||||
if ('plain' == this.mode) {
|
||||
this.plain.focus();
|
||||
|
|
|
|||
27
dev/External/User/ko.js
vendored
27
dev/External/User/ko.js
vendored
|
|
@ -1,9 +1,9 @@
|
|||
import 'External/ko';
|
||||
import ko from 'ko';
|
||||
import { HtmlEditor } from 'Common/Html';
|
||||
import { RFC822 } from 'Common/File';
|
||||
import { HtmlEditor } from 'Common/HtmlEditor';
|
||||
import { timeToNode } from 'Common/Translator';
|
||||
import { doc, elementById, addEventsListeners, dropdowns, leftPanelDisabled } from 'Common/Globals';
|
||||
import { dropdownsDetectVisibility } from 'Common/UtilsUser';
|
||||
import { EmailAddressesComponent } from 'Component/EmailAddresses';
|
||||
import { ThemeStore } from 'Stores/Theme';
|
||||
import { dropFilesInFolder } from 'Common/Folders';
|
||||
|
|
@ -44,7 +44,7 @@ const rlContentType = 'snappymail/action',
|
|||
let files = false;
|
||||
// if (e.dataTransfer.types.includes('Files'))
|
||||
for (const item of e.dataTransfer.items) {
|
||||
files |= 'file' === item.kind && 'message/rfc822' === item.type;
|
||||
files |= 'file' === item.kind && RFC822 === item.type;
|
||||
}
|
||||
if (files || dragMessages()) {
|
||||
e.stopPropagation();
|
||||
|
|
@ -91,7 +91,7 @@ Object.assign(ko.bindingHandlers, {
|
|||
};
|
||||
|
||||
if (ko.isObservable(fValue)) {
|
||||
editor = new HtmlEditor(element, fUpdateKoValue, fOnReady, fUpdateKoValue);
|
||||
editor = new HtmlEditor(element, fOnReady, fUpdateKoValue, fUpdateKoValue);
|
||||
|
||||
fValue.__fetchEditorValue = fUpdateKoValue;
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ Object.assign(ko.bindingHandlers, {
|
|||
}
|
||||
},
|
||||
|
||||
moment: {
|
||||
time: {
|
||||
init: ttn,
|
||||
update: ttn
|
||||
},
|
||||
|
|
@ -194,10 +194,6 @@ Object.assign(ko.bindingHandlers, {
|
|||
};
|
||||
addEventsListeners(element, {
|
||||
dragstart: e => {
|
||||
dragData = {
|
||||
action: 'sortable',
|
||||
element: element
|
||||
};
|
||||
setDragAction(e, 'sortable', 'move', element, element);
|
||||
element.style.opacity = 0.25;
|
||||
},
|
||||
|
|
@ -247,18 +243,5 @@ Object.assign(ko.bindingHandlers, {
|
|||
dropdowns.push(element);
|
||||
element.ddBtn = new BSN.Dropdown(element.querySelector('.dropdown-toggle'));
|
||||
}
|
||||
},
|
||||
|
||||
openDropdownTrigger: {
|
||||
update: (element, fValueAccessor) => {
|
||||
if (ko.unwrap(fValueAccessor())) {
|
||||
const el = element.ddBtn;
|
||||
el.open || el.toggle();
|
||||
// el.focus();
|
||||
|
||||
dropdownsDetectVisibility();
|
||||
fValueAccessor()(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
30
dev/External/ko.js
vendored
30
dev/External/ko.js
vendored
|
|
@ -28,6 +28,11 @@ export const
|
|||
|
||||
dispose = disposable => isFunction(disposable?.dispose) && disposable.dispose(),
|
||||
|
||||
onEvent = (element, event, fn) => {
|
||||
element.addEventListener(event, fn);
|
||||
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener(event, fn));
|
||||
},
|
||||
|
||||
onKey = (key, element, fValueAccessor, fAllBindings, model) => {
|
||||
let fn = event => {
|
||||
if (key == event.key) {
|
||||
|
|
@ -36,8 +41,7 @@ export const
|
|||
fValueAccessor().call(model);
|
||||
}
|
||||
};
|
||||
element.addEventListener('keydown', fn);
|
||||
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keydown', fn));
|
||||
onEvent(element, 'keydown', fn);
|
||||
},
|
||||
|
||||
// With this we don't need delegateRunOnDestroy
|
||||
|
|
@ -62,8 +66,7 @@ Object.assign(ko.bindingHandlers, {
|
|||
},
|
||||
update: (element, fValueAccessor) => {
|
||||
let value = ko.unwrap(fValueAccessor());
|
||||
value = isFunction(value) ? value() : value;
|
||||
errorTip(element, value);
|
||||
errorTip(element, isFunction(value) ? value() : value);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -82,6 +85,15 @@ Object.assign(ko.bindingHandlers, {
|
|||
onKey(' ', element, fValueAccessor, fAllBindings, model)
|
||||
},
|
||||
|
||||
toggle: {
|
||||
init: (element, fValueAccessor) => {
|
||||
let observable = fValueAccessor(),
|
||||
fn = () => observable(!observable());
|
||||
onEvent(element, 'click', fn);
|
||||
onEvent(element, 'keydown', event => ' ' == event.key && fn());
|
||||
}
|
||||
},
|
||||
|
||||
i18nUpdate: {
|
||||
update: (element, fValueAccessor) => {
|
||||
ko.unwrap(fValueAccessor());
|
||||
|
|
@ -89,16 +101,12 @@ Object.assign(ko.bindingHandlers, {
|
|||
}
|
||||
},
|
||||
|
||||
title: {
|
||||
update: (element, fValueAccessor) => element.title = ko.unwrap(fValueAccessor())
|
||||
},
|
||||
|
||||
command: {
|
||||
init: (element, fValueAccessor, fAllBindings, viewModel, bindingContext) => {
|
||||
const command = fValueAccessor();
|
||||
|
||||
if (!command || !command.canExecute) {
|
||||
throw new Error('Value should be a command');
|
||||
throw Error('Value should be a command');
|
||||
}
|
||||
|
||||
ko.bindingHandlers['FORM'==element.nodeName ? 'submit' : 'click'].init(
|
||||
|
|
@ -110,10 +118,8 @@ Object.assign(ko.bindingHandlers, {
|
|||
);
|
||||
},
|
||||
update: (element, fValueAccessor) => {
|
||||
const cl = element.classList;
|
||||
|
||||
let disabled = !fValueAccessor().canExecute();
|
||||
cl.toggle('disabled', disabled);
|
||||
element.classList.toggle('disabled', disabled);
|
||||
|
||||
if (element.matches('INPUT,TEXTAREA,BUTTON')) {
|
||||
element.disabled = disabled;
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title></title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(125,128,128,0.3);
|
||||
border-bottom: 1px solid #888;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
header * {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
header time {
|
||||
float: right;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 2px solid rgba(125,128,128,0.5);
|
||||
margin: 0;
|
||||
padding: 0 0 0 10px;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
body > * {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
#attachments > * {
|
||||
border: 1px solid rgba(125,128,128,0.5);
|
||||
padding: 0.25em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
#attachments > *::before {
|
||||
content: '📎 ';
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
|
|
@ -5,9 +5,13 @@ function typeCast(curValue, newValue) {
|
|||
if (null != curValue) {
|
||||
switch (typeof curValue)
|
||||
{
|
||||
case 'boolean': return 0 != newValue && !!newValue;
|
||||
case 'number': return isFinite(newValue) ? parseFloat(newValue) : 0;
|
||||
case 'string': return null != newValue ? '' + newValue : '';
|
||||
case 'boolean':
|
||||
return 0 != newValue && !!newValue;
|
||||
case 'number':
|
||||
newValue = parseFloat(newValue);
|
||||
return isFinite(newValue) ? newValue : 0;
|
||||
case 'string':
|
||||
return null != newValue ? '' + newValue : '';
|
||||
case 'object':
|
||||
if (curValue.constructor.reviveFromJson) {
|
||||
return curValue.constructor.reviveFromJson(newValue);
|
||||
|
|
@ -23,10 +27,10 @@ export class AbstractModel {
|
|||
constructor() {
|
||||
/*
|
||||
if (new.target === AbstractModel) {
|
||||
throw new Error("Can't instantiate AbstractModel!");
|
||||
throw Error("Can't instantiate AbstractModel!");
|
||||
}
|
||||
*/
|
||||
this.disposables = [];
|
||||
Object.defineProperty(this, 'disposables', {value: []});
|
||||
}
|
||||
|
||||
addObservables(observables) {
|
||||
|
|
|
|||
|
|
@ -60,10 +60,9 @@ export class AbstractViewPopup extends AbstractView
|
|||
this.keyScope.scope = name;
|
||||
this.modalVisible = ko.observable(false).extend({ rateLimit: 0 });
|
||||
this.close = () => this.modalVisible(false);
|
||||
this.tryToClose = () => (false === this.onClose()) || this.close();
|
||||
addShortcut('escape,close', '', name, () => {
|
||||
if (this.modalVisible() && false !== this.onClose()) {
|
||||
this.close();
|
||||
}
|
||||
this.modalVisible() && this.tryToClose();
|
||||
return false;
|
||||
// return true; Issue with supported modal close
|
||||
});
|
||||
|
|
@ -116,6 +115,11 @@ export class AbstractViewSettings
|
|||
onHide() {}
|
||||
viewModelDom
|
||||
*/
|
||||
/**
|
||||
* When this[name] does not exists, create as observable with value of SettingsGet(name)
|
||||
* When this[name+'Trigger'] does not exists, create as observable
|
||||
* Subscribe to this[name], and handle saving the setting
|
||||
*/
|
||||
addSetting(name, valueCb)
|
||||
{
|
||||
let prop = name[0].toLowerCase() + name.slice(1),
|
||||
|
|
@ -131,6 +135,7 @@ export class AbstractViewSettings
|
|||
rl.app.Remote.saveSetting(name, value,
|
||||
iError => {
|
||||
this[trigger](iError ? SaveSettingStatus.Failed : SaveSettingStatus.Success);
|
||||
// iError || Settings.set(name, value);
|
||||
setTimeout(() => this[trigger](SaveSettingStatus.Idle), 1000);
|
||||
}
|
||||
);
|
||||
|
|
@ -138,6 +143,10 @@ export class AbstractViewSettings
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreach name if this[name] does not exists, create as observable with value of SettingsGet(name)
|
||||
* Subscribe to this[name], for saving the setting
|
||||
*/
|
||||
addSettings(names)
|
||||
{
|
||||
names.forEach(name => {
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ const
|
|||
screen = screenName => (screenName && SCREENS.get(screenName)) || null,
|
||||
|
||||
/**
|
||||
* Creates the extended AbstractView model
|
||||
* @param {Function} ViewModelClass
|
||||
* @param {Object=} vmScreen
|
||||
* @returns {*}
|
||||
*/
|
||||
buildViewModel = (ViewModelClass, vmScreen) => {
|
||||
if (ViewModelClass && !ViewModelClass.__builded) {
|
||||
let vmDom = null;
|
||||
if (ViewModelClass && !ViewModelClass.__vm) {
|
||||
const
|
||||
vm = new ViewModelClass(vmScreen),
|
||||
id = vm.viewModelTemplateID,
|
||||
|
|
@ -39,16 +39,15 @@ const
|
|||
dialog = ViewTypePopup === vm.viewType,
|
||||
vmPlace = doc.getElementById(position);
|
||||
|
||||
ViewModelClass.__builded = true;
|
||||
ViewModelClass.__vm = vm;
|
||||
|
||||
if (vmPlace) {
|
||||
vmDom = dialog
|
||||
ViewModelClass.__vm = vm;
|
||||
|
||||
let vmDom = dialog
|
||||
? createElement('dialog',{id:'V-'+id})
|
||||
: createElement('div',{id:'V-'+id,hidden:''})
|
||||
vmPlace.append(vmDom);
|
||||
|
||||
vm.viewModelDom = ViewModelClass.__dom = vmDom;
|
||||
vm.viewModelDom = vmDom;
|
||||
|
||||
if (dialog) {
|
||||
// Firefox < 98 / Safari < 15.4 HTMLDialogElement not defined
|
||||
|
|
@ -59,13 +58,11 @@ const
|
|||
vmDom.before(vmDom.backdrop = createElement('div',{class:'dialog-backdrop'}));
|
||||
vmDom.setAttribute('open','');
|
||||
vmDom.open = true;
|
||||
vmDom.returnValue = null;
|
||||
vmDom.backdrop.hidden = false;
|
||||
};
|
||||
vmDom.close = v => {
|
||||
vmDom.close = () => {
|
||||
// if (vmDom.dispatchEvent(new CustomEvent('cancel', {cancelable:true}))) {
|
||||
vmDom.backdrop.hidden = true;
|
||||
vmDom.returnValue = v;
|
||||
vmDom.removeAttribute('open', null);
|
||||
vmDom.open = false;
|
||||
// vmDom.dispatchEvent(new CustomEvent('close'));
|
||||
|
|
@ -77,13 +74,17 @@ const
|
|||
// vmDom.addEventListener('close', () => vm.modalVisible(false));
|
||||
|
||||
// show/hide popup/modal
|
||||
// transitionend is called for each property, so we only listen to `opacity`
|
||||
// as defined in CSS by `dialog:not(.animate)`
|
||||
const endShowHide = e => {
|
||||
if (e.target === vmDom) {
|
||||
if (e.target === vmDom && 'opacity' === e.propertyName) {
|
||||
if (vmDom.classList.contains('animate')) {
|
||||
vm.afterShow?.();
|
||||
fireEvent('rl-vm-visible', vm);
|
||||
} else {
|
||||
vmDom.close();
|
||||
vm.afterHide?.();
|
||||
// fireEvent('rl-vm-hidden', vm);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -139,10 +140,9 @@ const
|
|||
screen.viewModels.forEach(ViewModelClass => {
|
||||
if (
|
||||
ViewModelClass.__vm &&
|
||||
ViewModelClass.__dom &&
|
||||
ViewTypePopup !== ViewModelClass.__vm.viewType
|
||||
) {
|
||||
fn(ViewModelClass.__vm, ViewModelClass.__dom);
|
||||
fn(ViewModelClass.__vm, ViewModelClass.__vm.viewModelDom);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
@ -152,7 +152,7 @@ const
|
|||
forEachViewModel(screenToHide, (vm, dom) => {
|
||||
dom.hidden = true;
|
||||
vm.onHide?.();
|
||||
destroy && vm.viewModelDom.remove();
|
||||
destroy && dom.remove();
|
||||
});
|
||||
ThemeStore.isMobile() && leftPanelDisabled(true);
|
||||
},
|
||||
|
|
@ -164,10 +164,10 @@ const
|
|||
*/
|
||||
screenOnRoute = (screenName, subPart) => {
|
||||
screenName = screenName || defaultScreenName;
|
||||
if (screenName && fireEvent('sm-show-screen', screenName, 1)) {
|
||||
if (screenName && fireEvent('sm-show-screen', screenName + (subPart ? '/' + subPart : ''), 1)) {
|
||||
// Close all popups
|
||||
for (let vm of visiblePopups) {
|
||||
(false === vm.onClose()) || vm.close();
|
||||
vm.tryToClose();
|
||||
}
|
||||
|
||||
let vmScreen = screen(screenName);
|
||||
|
|
@ -229,15 +229,11 @@ export const
|
|||
* @returns {void}
|
||||
*/
|
||||
showScreenPopup = (ViewModelClassToShow, params = []) => {
|
||||
const vm = buildViewModel(ViewModelClassToShow) && ViewModelClassToShow.__dom && ViewModelClassToShow.__vm;
|
||||
|
||||
const vm = buildViewModel(ViewModelClassToShow);
|
||||
if (vm) {
|
||||
params = params || [];
|
||||
|
||||
vm.beforeShow?.(...params);
|
||||
|
||||
vm.modalVisible(true);
|
||||
|
||||
vm.onShow?.(...params);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
197
dev/Mime/Address.js
Normal file
197
dev/Mime/Address.js
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { decodeEncodedWords } from 'Mime/Encoding';
|
||||
|
||||
/**
|
||||
* Parses structured e-mail addresses from an address/mailbox(-list) field
|
||||
* https://datatracker.ietf.org/doc/html/rfc2822#section-3.4
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* "Name <address@domain>"
|
||||
*
|
||||
* will be converted to
|
||||
*
|
||||
* [{name: "Name", email: "address@domain"}]
|
||||
*
|
||||
* @param {String} str Address field
|
||||
* @return {Array} An array of address objects
|
||||
*/
|
||||
export function addressparser(str) {
|
||||
str = (str || '').toString();
|
||||
|
||||
let
|
||||
endOperator = '',
|
||||
node = {
|
||||
type: 'text',
|
||||
value: ''
|
||||
},
|
||||
escaped = false,
|
||||
address = [],
|
||||
addresses = [];
|
||||
|
||||
const
|
||||
/*
|
||||
* Operator tokens and which tokens are expected to end the sequence
|
||||
*/
|
||||
OPERATORS = {
|
||||
'"': '"',
|
||||
'(': ')',
|
||||
'<': '>',
|
||||
',': '',
|
||||
// Groups are ended by semicolons
|
||||
':': ';',
|
||||
// Semicolons are not a legal delimiter per the RFC2822 grammar other
|
||||
// than for terminating a group, but they are also not valid for any
|
||||
// other use in this context. Given that some mail clients have
|
||||
// historically allowed the semicolon as a delimiter equivalent to the
|
||||
// comma in their UI, it makes sense to treat them the same as a comma
|
||||
// when used outside of a group.
|
||||
';': ''
|
||||
},
|
||||
pushToken = token => {
|
||||
token.value = (token.value || '').toString().trim();
|
||||
token.value.length && address.push(token);
|
||||
node = {
|
||||
type: 'text',
|
||||
value: ''
|
||||
},
|
||||
escaped = false;
|
||||
},
|
||||
pushAddress = () => {
|
||||
if (address.length) {
|
||||
address = _handleAddress(address);
|
||||
if (address.length) {
|
||||
addresses = addresses.concat(address);
|
||||
}
|
||||
}
|
||||
address = [];
|
||||
};
|
||||
|
||||
[...str].forEach(chr => {
|
||||
if (!escaped && (chr === endOperator || (!endOperator && chr in OPERATORS))) {
|
||||
pushToken(node);
|
||||
if (',' === chr || ';' === chr) {
|
||||
pushAddress();
|
||||
} else {
|
||||
endOperator = endOperator ? '' : OPERATORS[chr];
|
||||
if ('<' === chr) {
|
||||
node.type = 'email';
|
||||
} else if ('(' === chr) {
|
||||
node.type = 'comment';
|
||||
} else if (':' === chr) {
|
||||
node.type = 'group';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
node.value += chr;
|
||||
escaped = !escaped && '\\' === chr;
|
||||
}
|
||||
});
|
||||
pushToken(node);
|
||||
|
||||
pushAddress();
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts tokens for a single address into an address object
|
||||
*
|
||||
* @param {Array} tokens Tokens object
|
||||
* @return {Object} Address object
|
||||
*/
|
||||
function _handleAddress(tokens) {
|
||||
let
|
||||
isGroup = false,
|
||||
address = {},
|
||||
addresses = [],
|
||||
data = {
|
||||
email: [],
|
||||
comment: [],
|
||||
group: [],
|
||||
text: []
|
||||
};
|
||||
|
||||
tokens.forEach(token => {
|
||||
isGroup = isGroup || 'group' === token.type;
|
||||
data[token.type].push(token.value);
|
||||
});
|
||||
|
||||
// If there is no text but a comment, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
||||
/*
|
||||
addresses.push({
|
||||
email: '',
|
||||
name: data.text.join(' ').trim(),
|
||||
group: addressparser(data.group.join(','))
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
});
|
||||
*/
|
||||
addresses = addresses.concat(addressparser(data.group.join(',')));
|
||||
} else {
|
||||
// If no address was found, try to detect one from regular text
|
||||
if (!data.email.length && data.text.length) {
|
||||
var i = data.text.length;
|
||||
while (i--) {
|
||||
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
||||
data.email = data.text.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// still no address
|
||||
if (!data.email.length) {
|
||||
i = data.text.length;
|
||||
while (i--) {
|
||||
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, address => {
|
||||
if (!data.email.length) {
|
||||
data.email = [address.trim()];
|
||||
return '';
|
||||
}
|
||||
return address.trim();
|
||||
});
|
||||
if (data.email.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's still no text but a comment exists, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
|
||||
// Keep only the first address occurence, push others to regular text
|
||||
if (data.email.length > 1) {
|
||||
data.text = data.text.concat(data.email.splice(1));
|
||||
}
|
||||
|
||||
address = {
|
||||
// Join values with spaces
|
||||
email: decodeEncodedWords(data.email.join(' ').trim()),
|
||||
name: decodeEncodedWords(data.text.join(' ').trim())
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
};
|
||||
|
||||
if (address.email === address.name) {
|
||||
if (address.email.includes('@')) {
|
||||
address.name = '';
|
||||
} else {
|
||||
address.email = '';
|
||||
}
|
||||
}
|
||||
|
||||
// address.email = address.email.replace(/^[<]+(.*)[>]+$/g, '$1');
|
||||
|
||||
addresses.push(address);
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
35
dev/Mime/Encoding.js
Normal file
35
dev/Mime/Encoding.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
const
|
||||
QPDecodeParams = [/=([0-9A-F]{2})/g, (...args) => String.fromCharCode(parseInt(args[1], 16))];
|
||||
|
||||
export const
|
||||
// https://datatracker.ietf.org/doc/html/rfc2045#section-6.8
|
||||
BDecode = atob,
|
||||
|
||||
// unescape(encodeURIComponent()) makes the UTF-16 DOMString to an UTF-8 string
|
||||
BEncode = data => btoa(unescape(encodeURIComponent(data))),
|
||||
/* // Without deprecated 'unescape':
|
||||
BEncode = data => btoa(encodeURIComponent(data).replace(
|
||||
/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode('0x' + p1)
|
||||
)),
|
||||
*/
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2045#section-6.7
|
||||
QPDecode = data => data.replace(/=\r?\n/g, '').replace(...QPDecodeParams),
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2047#section-4.1
|
||||
// https://datatracker.ietf.org/doc/html/rfc2047#section-4.2
|
||||
// encoded-word = "=?" charset "?" encoding "?" encoded-text "?="
|
||||
decodeEncodedWords = data =>
|
||||
data.replace(/=\?([^?]+)\?(B|Q)\?(.+?)\?=/g, (m, charset, encoding, text) =>
|
||||
decodeText(charset, 'B' == encoding ? BDecode(text) : QPDecode(text))
|
||||
)
|
||||
,
|
||||
|
||||
decodeText = (charset, data) => {
|
||||
try {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings
|
||||
return new TextDecoder(charset).decode(Uint8Array.from(data, c => c.charCodeAt(0)));
|
||||
} catch (e) {
|
||||
console.error({charset:charset,error:e});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,17 +1,5 @@
|
|||
//import { b64Encode } from 'Common/Utils';
|
||||
|
||||
const
|
||||
// RFC2045
|
||||
QPDecodeParams = [/=([0-9A-F]{2})/g, (...args) => String.fromCharCode(parseInt(args[1], 16))],
|
||||
QPDecode = data => data.replace(/=\r?\n/g, '').replace(...QPDecodeParams),
|
||||
decodeText = (charset, data) => {
|
||||
try {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings
|
||||
return new TextDecoder(charset).decode(Uint8Array.from(data, c => c.charCodeAt(0)));
|
||||
} catch (e) {
|
||||
console.error({charset:charset,error:e});
|
||||
}
|
||||
};
|
||||
import { decodeEncodedWords, BDecode, BEncode, QPDecode, decodeText } from 'Mime/Encoding';
|
||||
import { addressparser } from 'Mime/Address';
|
||||
|
||||
export function ParseMime(text)
|
||||
{
|
||||
|
|
@ -27,7 +15,52 @@ export function ParseMime(text)
|
|||
this.bodyEnd = 0;
|
||||
this.boundary = '';
|
||||
this.bodyText = '';
|
||||
this.headers = {};
|
||||
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6
|
||||
// https://datatracker.ietf.org/doc/html/rfc4021
|
||||
this.headers = {
|
||||
// Required
|
||||
date = null,
|
||||
from = [], // mailbox-list
|
||||
// Optional
|
||||
sender = [], // mailbox MUST occur with multi-address
|
||||
'reply-to' = [], // address-list
|
||||
to = [], // address-list
|
||||
cc = [], // address-list
|
||||
bcc = [], // address-list
|
||||
'message-id' = '', // msg-id SHOULD be present
|
||||
'in-reply-to' = '', // 1*msg-id SHOULD occur in some replies
|
||||
references = '', // 1*msg-id SHOULD occur in some replies
|
||||
subject = '', // unstructured
|
||||
// Optional unlimited
|
||||
comments = [], // unstructured
|
||||
keywords = [], // phrase *("," phrase)
|
||||
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6.6
|
||||
'resent-date' = [],
|
||||
'resent-from' = [],
|
||||
'resent-sender' = [],
|
||||
'resent-to' = [],
|
||||
'resent-cc' = [],
|
||||
'resent-bcc' = [],
|
||||
'resent-msg-id' = [],
|
||||
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6.7
|
||||
trace = [],
|
||||
'return-path' = '', // angle-addr
|
||||
received = [],
|
||||
// optional others outside RFC2822
|
||||
'mime-version' = '', // RFC2045
|
||||
'content-transfer-encoding' = '',
|
||||
'content-type' = '',
|
||||
'delivered-to' = [], // RFC9228 addr-spec
|
||||
'authentication-results' = '', // dkim, spf, dmarc
|
||||
'dkim-signature' = '',
|
||||
'x-rspamd-queue-id' = '',
|
||||
'x-rspamd-action' = '',
|
||||
'x-spamd-bar' = '',
|
||||
'x-rspamd-server' = '',
|
||||
'x-spamd-result' = '',
|
||||
'x-remote-address' = '',
|
||||
// etc.
|
||||
};
|
||||
}
|
||||
*/
|
||||
|
||||
|
|
@ -50,26 +83,25 @@ export function ParseMime(text)
|
|||
get body() {
|
||||
let body = this.bodyRaw,
|
||||
charset = this.header('content-type')?.params.charset,
|
||||
encoding = this.headerValue('content-transfer-encoding');
|
||||
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
|
||||
if ('quoted-printable' == encoding) {
|
||||
body = QPDecode(body);
|
||||
} else if ('base64' == encoding) {
|
||||
body = atob(body.replace(/\r?\n/g, ''));
|
||||
body = BDecode(body.replace(/\r?\n/g, ''));
|
||||
}
|
||||
return decodeText(charset, body);
|
||||
}
|
||||
|
||||
get dataUrl() {
|
||||
let body = this.bodyRaw,
|
||||
encoding = this.headerValue('content-transfer-encoding');
|
||||
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
|
||||
if ('base64' == encoding) {
|
||||
body = body.replace(/\r?\n/g, '');
|
||||
} else {
|
||||
if ('quoted-printable' == encoding) {
|
||||
body = QPDecode(body);
|
||||
}
|
||||
body = btoa(body);
|
||||
// body = b64Encode(body);
|
||||
body = BEncode(body);
|
||||
}
|
||||
return 'data:' + this.headerValue('content-type') + ';base64,' + body;
|
||||
}
|
||||
|
|
@ -80,7 +112,7 @@ export function ParseMime(text)
|
|||
}
|
||||
|
||||
getByContentType(type) {
|
||||
if (type == this.headerValue('content-type')) {
|
||||
if (type == this.headerValue('content-type')?.toLowerCase()) {
|
||||
return this;
|
||||
}
|
||||
let i = 0, p = this.parts, part;
|
||||
|
|
@ -92,6 +124,9 @@ export function ParseMime(text)
|
|||
}
|
||||
}
|
||||
|
||||
// mailbox-list or address-list
|
||||
const lists = ['from','reply-to','to','cc','bcc'];
|
||||
|
||||
const ParsePart = (mimePart, start_pos = 0, id = '') =>
|
||||
{
|
||||
let part = new MimePart,
|
||||
|
|
@ -113,11 +148,19 @@ export function ParseMime(text)
|
|||
[...header.matchAll(/;\s*([^;=]+)=\s*"?([^;"]+)"?/g)].forEach(param =>
|
||||
params[param[1].trim().toLowerCase()] = param[2].trim()
|
||||
);
|
||||
// encoded-word = "=?" charset "?" encoding "?" encoded-text "?="
|
||||
match[2] = match[2].trim().replace(/=\?([^?]+)\?(B|Q)\?(.+?)\?=/g, (m, charset, encoding, text) =>
|
||||
decodeText(charset, 'B' == encoding ? atob(text) : QPDecode(text))
|
||||
);
|
||||
headers[match[1].trim().toLowerCase()] = {
|
||||
let field = match[1].trim().toLowerCase();
|
||||
if (lists.includes(field)) {
|
||||
match[2] = addressparser(match[2]);
|
||||
} else if ('keywords' === field) {
|
||||
match[2] = match[2].split(',').forEach(entry => decodeEncodedWords(entry.trim()));
|
||||
match[2] = (headers[field]?.value || []).concat(match[2]);
|
||||
} else {
|
||||
match[2] = decodeEncodedWords(match[2].trim());
|
||||
if ('comments' === field) {
|
||||
match[2] = (headers[field]?.value || []).push(match[2]);
|
||||
}
|
||||
}
|
||||
headers[field] = {
|
||||
value: match[2],
|
||||
params: params
|
||||
};
|
||||
|
|
@ -132,7 +175,7 @@ export function ParseMime(text)
|
|||
let boundary = headers['content-type']?.params.boundary;
|
||||
if (boundary) {
|
||||
part.boundary = boundary;
|
||||
let regex = new RegExp('(?:^|\r?\n)--' + boundary + '(?:--)?(?:\r?\n|$)', 'g'),
|
||||
let regex = new RegExp('(?:^|\r?\n)--' + RegExp.escape(boundary) + '(?:--)?(?:\r?\n|$)', 'g'),
|
||||
body = mimePart.slice(head.length),
|
||||
bodies = body.split(regex),
|
||||
pos = part.bodyStart;
|
||||
|
|
|
|||
|
|
@ -4,23 +4,34 @@ import { AttachmentModel } from 'Model/Attachment';
|
|||
import { FileInfo } from 'Common/File';
|
||||
import { BEGIN_PGP_MESSAGE } from 'Stores/User/Pgp';
|
||||
|
||||
import { EmailModel } from 'Model/Email';
|
||||
|
||||
/**
|
||||
* @param string data
|
||||
* @param MessageModel message
|
||||
*/
|
||||
export function MimeToMessage(data, message)
|
||||
{
|
||||
let signed;
|
||||
const struct = ParseMime(data);
|
||||
if (struct.headers) {
|
||||
let html = struct.getByContentType('text/html'),
|
||||
subject = struct.headerValue('subject');
|
||||
html = html ? html.body : '';
|
||||
|
||||
// Content-Type: ...; protected-headers="v1"
|
||||
subject && message.subject(subject);
|
||||
|
||||
// EmailCollectionModel
|
||||
['from','to'].forEach(name => message[name].fromString(struct.headerValue(name)));
|
||||
['from','to'].forEach(name => {
|
||||
const items = message[name];
|
||||
struct.headerValue(name)?.forEach(item => {
|
||||
item = new EmailModel(item.email, item.name);
|
||||
// Make them unique
|
||||
if (item.email && item.name || !items.find(address => address.email == item.email)) {
|
||||
items.push(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
struct.forEach(part => {
|
||||
let cd = part.header('content-disposition'),
|
||||
|
|
@ -54,12 +65,28 @@ export function MimeToMessage(data, message)
|
|||
} else {
|
||||
message.attachments.push(attachment);
|
||||
}
|
||||
} else if ('multipart/signed' === type.value && 'application/pgp-signature' === type.params.protocol) {
|
||||
signed = {
|
||||
} else if ('multipart/signed' === type.value) {
|
||||
let protocol = type.params.protocol;
|
||||
if ('application/pgp-signature' === protocol) {
|
||||
message.pgpSigned({
|
||||
micAlg: type.micalg,
|
||||
bodyPart: part.parts[0],
|
||||
sigPart: part.parts[1]
|
||||
});
|
||||
} else if ('application/pkcs7-signature' === protocol.replace('x-')) {
|
||||
message.smimeSigned({
|
||||
micAlg: type.micalg,
|
||||
bodyPart: part,
|
||||
sigPart: part.parts[1], // For importing
|
||||
detached: true
|
||||
});
|
||||
}
|
||||
} else if ('application/pkcs7-mime' === type.value /*&& 'signed-data' === type.params['smime-type']=*/) {
|
||||
message.smimeSigned({
|
||||
micAlg: type.micalg,
|
||||
bodyPart: part.parts[0],
|
||||
sigPart: part.parts[1]
|
||||
};
|
||||
bodyPart: part,
|
||||
detached: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -70,10 +97,7 @@ export function MimeToMessage(data, message)
|
|||
message.plain(data);
|
||||
}
|
||||
|
||||
if (!signed && message.plain().includes(BEGIN_PGP_MESSAGE)) {
|
||||
signed = true;
|
||||
if (message.plain().includes(BEGIN_PGP_MESSAGE)) {
|
||||
message.pgpSigned(true);
|
||||
}
|
||||
message.pgpSigned(signed);
|
||||
|
||||
// TODO: Verify instantly?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export class AbstractCollectionModel extends Array
|
|||
constructor() {
|
||||
/*
|
||||
if (new.target === AbstractCollectionModel) {
|
||||
throw new Error("Can't instantiate AbstractCollectionModel!");
|
||||
throw Error("Can't instantiate AbstractCollectionModel!");
|
||||
}
|
||||
*/
|
||||
super();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ export class AccountModel extends AbstractModel {
|
|||
&& setTimeout(()=>this.fetchUnread(), (Math.ceil(Math.random() * 10)) * 3000);
|
||||
}
|
||||
|
||||
label() {
|
||||
return this.name || IDN.toUnicode(this.email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get INBOX unread messages
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -126,7 +126,10 @@ export class AttachmentModel extends AbstractModel {
|
|||
}
|
||||
|
||||
get download() {
|
||||
return b64EncodeJSONSafe({
|
||||
return b64EncodeJSONSafe(this.url ? {
|
||||
fileName: this.fileName,
|
||||
data: this.url.replace(/^.+,/, '')
|
||||
} : {
|
||||
folder: this.folder,
|
||||
uid: this.uid,
|
||||
mimeIndex: this.mimeIndex,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { baseCollator } from 'Common/Translator';
|
||||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||
import { AttachmentModel } from 'Model/Attachment';
|
||||
|
||||
|
|
@ -10,14 +11,24 @@ export class AttachmentCollectionModel extends AbstractCollectionModel
|
|||
* @returns {AttachmentCollectionModel}
|
||||
*/
|
||||
static reviveFromJson(items) {
|
||||
return super.reviveFromJson(items, attachment => AttachmentModel.reviveFromJson(attachment));
|
||||
/*
|
||||
const attachments = super.reviveFromJson(items, attachment => AttachmentModel.reviveFromJson(attachment));
|
||||
let collator = baseCollator(true);
|
||||
attachments.sort((a, b) => {
|
||||
if (a.isInline()) {
|
||||
if (!b.isInline()) {
|
||||
return 1;
|
||||
}
|
||||
} else if (!b.isInline()) {
|
||||
return -1;
|
||||
}
|
||||
return collator.compare(a.fileName, b.fileName);
|
||||
});
|
||||
/*
|
||||
if (attachments) {
|
||||
attachments.InlineCount = attachments.reduce((accumulator, a) => accumulator + (a.isInline ? 1 : 0), 0);
|
||||
}
|
||||
return attachments;
|
||||
*/
|
||||
return attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export class ContactModel extends AbstractModel {
|
|||
focused: false,
|
||||
selected: false,
|
||||
checked: false,
|
||||
sendToAll: true,
|
||||
|
||||
deleted: false,
|
||||
readOnly: false,
|
||||
|
|
@ -134,22 +135,6 @@ export class ContactModel extends AbstractModel {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array|null}
|
||||
*/
|
||||
getNameAndEmailHelper() {
|
||||
let name = (this.givenName() + ' ' + this.surName()).trim(),
|
||||
email = this.email()[0]?.value();
|
||||
/*
|
||||
// this.jCard.getOne('fn')?.notEmpty() ||
|
||||
this.jCard.parseFullName({set:true});
|
||||
// let name = this.jCard.getOne('nickname'),
|
||||
let name = this.jCard.getOne('fn'),
|
||||
email = this.jCard.getOne('email');
|
||||
*/
|
||||
return email ? [email, name] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @static
|
||||
* @param {jCard} json
|
||||
|
|
@ -209,19 +194,15 @@ export class ContactModel extends AbstractModel {
|
|||
return contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
generateUid() {
|
||||
return '' + this.id;
|
||||
}
|
||||
|
||||
addEmail() {
|
||||
// home, work
|
||||
this.email.push({
|
||||
value: ko.observable('')
|
||||
// type: prop.params.type
|
||||
});
|
||||
|
||||
if (this.sendToAllDisplayStatus())
|
||||
document.getElementById('send-to-all').style.display = 'block';
|
||||
}
|
||||
|
||||
addTel() {
|
||||
|
|
@ -318,4 +299,9 @@ export class ContactModel extends AbstractModel {
|
|||
+ (this.checked() ? ' checked' : '')
|
||||
+ (this.focused() ? ' focused' : '');
|
||||
}
|
||||
|
||||
sendToAllDisplayStatus() {
|
||||
return this.email.length > 1
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,202 +4,6 @@ import { AbstractModel } from 'Knoin/AbstractModel';
|
|||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parses structured e-mail addresses from an address field
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* "Name <address@domain>"
|
||||
*
|
||||
* will be converted to
|
||||
*
|
||||
* [{name: "Name", address: "address@domain"}]
|
||||
*
|
||||
* @param {String} str Address field
|
||||
* @return {Array} An array of address objects
|
||||
*/
|
||||
export function addressparser(str) {
|
||||
str = (str || '').toString();
|
||||
|
||||
let
|
||||
endOperator = '',
|
||||
node = {
|
||||
type: 'text',
|
||||
value: ''
|
||||
},
|
||||
escaped = false,
|
||||
address = [],
|
||||
addresses = [];
|
||||
|
||||
const
|
||||
/*
|
||||
* Operator tokens and which tokens are expected to end the sequence
|
||||
*/
|
||||
OPERATORS = {
|
||||
'"': '"',
|
||||
'(': ')',
|
||||
'<': '>',
|
||||
',': '',
|
||||
// Groups are ended by semicolons
|
||||
':': ';',
|
||||
// Semicolons are not a legal delimiter per the RFC2822 grammar other
|
||||
// than for terminating a group, but they are also not valid for any
|
||||
// other use in this context. Given that some mail clients have
|
||||
// historically allowed the semicolon as a delimiter equivalent to the
|
||||
// comma in their UI, it makes sense to treat them the same as a comma
|
||||
// when used outside of a group.
|
||||
';': ''
|
||||
},
|
||||
pushToken = token => {
|
||||
token.value = (token.value || '').toString().trim();
|
||||
token.value.length && address.push(token);
|
||||
node = {
|
||||
type: 'text',
|
||||
value: ''
|
||||
},
|
||||
escaped = false;
|
||||
},
|
||||
pushAddress = () => {
|
||||
if (address.length) {
|
||||
address = _handleAddress(address);
|
||||
if (address.length) {
|
||||
addresses = addresses.concat(address);
|
||||
}
|
||||
}
|
||||
address = [];
|
||||
};
|
||||
|
||||
[...str].forEach(chr => {
|
||||
if (!escaped && (chr === endOperator || (!endOperator && chr in OPERATORS))) {
|
||||
pushToken(node);
|
||||
if (',' === chr || ';' === chr) {
|
||||
pushAddress();
|
||||
} else {
|
||||
endOperator = endOperator ? '' : OPERATORS[chr];
|
||||
if ('<' === chr) {
|
||||
node.type = 'email';
|
||||
} else if ('(' === chr) {
|
||||
node.type = 'comment';
|
||||
} else if (':' === chr) {
|
||||
node.type = 'group';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
node.value += chr;
|
||||
escaped = !escaped && '\\' === chr;
|
||||
}
|
||||
});
|
||||
pushToken(node);
|
||||
|
||||
pushAddress();
|
||||
|
||||
return addresses;
|
||||
// return addresses.map(item => (item.name || item.email) ? new EmailModel(item.email, item.name) : null).filter(v => v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts tokens for a single address into an address object
|
||||
*
|
||||
* @param {Array} tokens Tokens object
|
||||
* @return {Object} Address object
|
||||
*/
|
||||
function _handleAddress(tokens) {
|
||||
let
|
||||
isGroup = false,
|
||||
address = {},
|
||||
addresses = [],
|
||||
data = {
|
||||
email: [],
|
||||
comment: [],
|
||||
group: [],
|
||||
text: []
|
||||
};
|
||||
|
||||
tokens.forEach(token => {
|
||||
isGroup = isGroup || 'group' === token.type;
|
||||
data[token.type].push(token.value);
|
||||
});
|
||||
|
||||
// If there is no text but a comment, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
||||
/*
|
||||
addresses.push({
|
||||
email: '',
|
||||
name: data.text.join(' ').trim(),
|
||||
group: addressparser(data.group.join(','))
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
});
|
||||
*/
|
||||
addresses = addresses.concat(addressparser(data.group.join(',')));
|
||||
} else {
|
||||
// If no address was found, try to detect one from regular text
|
||||
if (!data.email.length && data.text.length) {
|
||||
var i = data.text.length;
|
||||
while (i--) {
|
||||
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
||||
data.email = data.text.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// still no address
|
||||
if (!data.email.length) {
|
||||
i = data.text.length;
|
||||
while (i--) {
|
||||
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, address => {
|
||||
if (!data.email.length) {
|
||||
data.email = [address.trim()];
|
||||
return '';
|
||||
}
|
||||
return address.trim();
|
||||
});
|
||||
if (data.email.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's still no text but a comment exists, replace the two
|
||||
if (!data.text.length && data.comment.length) {
|
||||
data.text = data.comment;
|
||||
data.comment = [];
|
||||
}
|
||||
|
||||
// Keep only the first address occurence, push others to regular text
|
||||
if (data.email.length > 1) {
|
||||
data.text = data.text.concat(data.email.splice(1));
|
||||
}
|
||||
|
||||
address = {
|
||||
// Join values with spaces
|
||||
email: data.email.join(' ').trim(),
|
||||
name: data.text.join(' ').trim()
|
||||
// ,comment: data.comment.join(' ').trim()
|
||||
};
|
||||
|
||||
if (address.email === address.name) {
|
||||
if (address.email.includes('@')) {
|
||||
address.name = '';
|
||||
} else {
|
||||
address.email = '';
|
||||
}
|
||||
}
|
||||
|
||||
// address.email = address.email.replace(/^[<]+(.*)[>]+$/g, '$1');
|
||||
|
||||
addresses.push(address);
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
export class EmailModel extends AbstractModel {
|
||||
/**
|
||||
* @param {string=} email = ''
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||
import { EmailModel, addressparser } from 'Model/Email';
|
||||
import { EmailModel } from 'Model/Email';
|
||||
import { forEachObjectValue } from 'Common/Utils';
|
||||
import { addressparser } from 'Mime/Address';
|
||||
|
||||
'use strict';
|
||||
|
||||
|
|
@ -51,4 +52,24 @@ export class EmailCollectionModel extends AbstractCollectionModel
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {array} [{name: "Name", email: "address@domain"}]
|
||||
*/
|
||||
/*
|
||||
static fromArray(addresses) {
|
||||
let list = new this();
|
||||
list.fromArray(addresses);
|
||||
return list;
|
||||
}
|
||||
fromArray(addresses) {
|
||||
addresses.forEach(item => {
|
||||
item = new EmailModel(item.email, item.name);
|
||||
// Make them unique
|
||||
if (item.email && item.name || !this.find(address => address.email == item.email)) {
|
||||
this.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
|||
import { UNUSED_OPTION_VALUE } from 'Common/Consts';
|
||||
import { isArray, getKeyByValue, forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
|
||||
import { ClientSideKeyNameExpandedFolders, FolderType, FolderMetadataKeys } from 'Common/EnumsUser';
|
||||
import { clearCache, getFolderFromCacheList, setFolder, setFolderInboxName, removeFolderFromCacheList } from 'Common/Cache';
|
||||
import { clearCache, getFolderFromCacheList, setFolder, setFolderInboxName } from 'Common/Cache';
|
||||
import { Settings, SettingsGet, fireEvent } from 'Common/Globals';
|
||||
import { Notifications } from 'Common/Enums';
|
||||
|
||||
import * as Local from 'Storage/Client';
|
||||
|
||||
|
|
@ -15,16 +14,23 @@ import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
|||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
import { sortFolders } from 'Common/Folders';
|
||||
import { i18n, translateTrigger, getNotification } from 'Common/Translator';
|
||||
import { i18n, translateTrigger } from 'Common/Translator';
|
||||
|
||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
|
||||
import { /*koComputable,*/ addObservablesTo } from 'External/ko';
|
||||
|
||||
//import { mailBox } from 'Common/Links';
|
||||
import { mailBox } from 'Common/Links';
|
||||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { FileInfo } from 'Common/File';
|
||||
|
||||
import { FolderPopupView } from 'View/Popup/Folder';
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
|
||||
import { isAllowedKeyword } from 'Stores/User/Folder';
|
||||
|
||||
const
|
||||
// isPosNumeric = value => null != value && /^[0-9]*$/.test(value.toString()),
|
||||
|
||||
|
|
@ -98,7 +104,7 @@ export const
|
|||
// Repeat every 15 minutes?
|
||||
// this.foldersTimeout = setTimeout(loadFolders, 900000);
|
||||
})
|
||||
.catch(() => fCallback && setTimeout(fCallback, 1, false));
|
||||
.catch(e => fCallback && setTimeout(fCallback, 1, false, e));
|
||||
};
|
||||
|
||||
export class FolderCollectionModel extends AbstractCollectionModel
|
||||
|
|
@ -111,6 +117,8 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
|||
this.namespace;
|
||||
this.optimized
|
||||
this.capabilities
|
||||
this.allow; // allow adding
|
||||
// this.exist;
|
||||
}
|
||||
*/
|
||||
|
||||
|
|
@ -252,6 +260,10 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
|||
return result;
|
||||
}
|
||||
|
||||
visible() {
|
||||
return this.filter(folder => folder.visible());
|
||||
}
|
||||
|
||||
storeIt() {
|
||||
FolderUserStore.displaySpecSetting(Settings.app('folderSpecLimit') < this.CountRec);
|
||||
|
||||
|
|
@ -273,7 +285,7 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
|||
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
|
||||
AppUserStore.threadsAllowed(!!this.capabilities.some(capa => capa.startsWith('THREAD=')));
|
||||
|
||||
// FolderUserStore.folderListOptimized(!!this.optimized);
|
||||
// FolderUserStore.optimized(!!this.optimized);
|
||||
FolderUserStore.quotaUsage(this.quotaUsage);
|
||||
FolderUserStore.quotaLimit(this.quotaLimit);
|
||||
FolderUserStore.capabilities(this.capabilities);
|
||||
|
|
@ -294,6 +306,7 @@ export class FolderModel extends AbstractModel {
|
|||
super();
|
||||
|
||||
this.fullName = '';
|
||||
this.parentName = '';
|
||||
this.delimiter = '';
|
||||
this.deep = 0;
|
||||
this.expires = 0;
|
||||
|
|
@ -304,6 +317,7 @@ export class FolderModel extends AbstractModel {
|
|||
this.etag = '';
|
||||
this.id = 0;
|
||||
this.uidNext = 0;
|
||||
this.size = 0;
|
||||
|
||||
addObservablesTo(this, {
|
||||
name: '',
|
||||
|
|
@ -313,12 +327,10 @@ export class FolderModel extends AbstractModel {
|
|||
|
||||
focused: false,
|
||||
selected: false,
|
||||
editing: false,
|
||||
isSubscribed: true,
|
||||
checkable: false, // Check for new messages
|
||||
askDelete: false,
|
||||
|
||||
nameForEdit: '',
|
||||
errorMsg: '',
|
||||
|
||||
totalEmails: 0,
|
||||
|
|
@ -338,7 +350,6 @@ export class FolderModel extends AbstractModel {
|
|||
this.addSubscribables({
|
||||
kolabType: sValue => this.metadata[FolderMetadataKeys.KolabFolderType] = sValue,
|
||||
permanentFlags: aValue => this.tagsAllowed(aValue.includes('\\*')),
|
||||
editing: value => value && this.nameForEdit(this.name()),
|
||||
unreadEmails: unread => FolderType.Inbox === this.type() && fireEvent('mailbox.inbox-unread-count', unread)
|
||||
});
|
||||
|
||||
|
|
@ -360,20 +371,19 @@ export class FolderModel extends AbstractModel {
|
|||
.extend({ notify: 'always' });
|
||||
*/
|
||||
/*
|
||||
https://www.rfc-editor.org/rfc/rfc8621.html#section-2
|
||||
"myRights": {
|
||||
"mayAddItems": true,
|
||||
"mayRename": false,
|
||||
"maySubmit": true,
|
||||
"mayDelete": false,
|
||||
"maySetKeywords": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"maySetSeen": true,
|
||||
"mayReadItems": true
|
||||
},
|
||||
// https://www.rfc-editor.org/rfc/rfc8621.html#section-2
|
||||
this.myRights = {
|
||||
'mayAddItems': true,
|
||||
'mayCreateChild': true,
|
||||
'mayDelete': true,
|
||||
'mayReadItems': true,
|
||||
'mayRemoveItems': true,
|
||||
'mayRename': true,
|
||||
'maySetKeywords': true,
|
||||
'maySetSeen': true,
|
||||
'maySubmit': true
|
||||
};
|
||||
*/
|
||||
|
||||
this.addComputables({
|
||||
|
||||
isInbox: () => FolderType.Inbox === this.type(),
|
||||
|
|
@ -384,6 +394,7 @@ export class FolderModel extends AbstractModel {
|
|||
// isSubscribed: () => this.attributes().includes('\\subscribed'),
|
||||
|
||||
hasVisibleSubfolders: () => !!this.subFolders().find(folder => folder.visible()),
|
||||
visibleSubfolders: () => this.subFolders().visible(),
|
||||
|
||||
hasSubscriptions: () => this.isSubscribed() | !!this.subFolders().find(
|
||||
oFolder => {
|
||||
|
|
@ -392,8 +403,6 @@ export class FolderModel extends AbstractModel {
|
|||
}
|
||||
),
|
||||
|
||||
canBeEdited: () => !this.type() && this.exists/* && this.selectable()*/,
|
||||
|
||||
isSystemFolder: () => this.type()
|
||||
| (FolderUserStore.allowKolab() && !!this.kolabType() & !SettingsUserStore.unhideKolabFolders()),
|
||||
|
||||
|
|
@ -404,6 +413,8 @@ export class FolderModel extends AbstractModel {
|
|||
canBeSubscribed: () => this.selectable()
|
||||
&& !(this.isSystemFolder() | !SettingsUserStore.hideUnsubscribed()),
|
||||
|
||||
optionalTags: () => this.permanentFlags.filter(isAllowedKeyword),
|
||||
|
||||
/**
|
||||
* Folder is visible when:
|
||||
* - hasVisibleSubfolders()
|
||||
|
|
@ -456,62 +467,34 @@ export class FolderModel extends AbstractModel {
|
|||
return '';
|
||||
},
|
||||
|
||||
friendlySize: () => FileInfo.friendlySize(this.size),
|
||||
|
||||
detailedName: () => this.name() + ' ' + this.nameInfo(),
|
||||
|
||||
hasSubscribedUnreadMessagesSubfolders: () =>
|
||||
!!this.subFolders().find(
|
||||
folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
|
||||
)
|
||||
/*
|
||||
!!this.subFolders().filter(
|
||||
folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
|
||||
).length
|
||||
*/
|
||||
// ,href: () => this.canBeSelected() && mailBox(this.fullNameHash)
|
||||
icon: () => {
|
||||
switch (this.type())
|
||||
{
|
||||
case 1: return '📥'; // FolderType.Inbox
|
||||
case 2: return '📧'; // FolderType.Sent icon-paper-plane
|
||||
case 3: return '🗎'; // FolderType.Drafts
|
||||
case 4: return '⚠'; // FolderType.Junk
|
||||
case 5: return '🗑'; // FolderType.Trash
|
||||
case 6: return '🗄'; // FolderType.Archive
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
hasUnreadInSub: () =>
|
||||
this.subFolders().some(
|
||||
folder => folder.unreadEmails() | folder.hasUnreadInSub()
|
||||
),
|
||||
|
||||
href: () => this.canBeSelected() && mailBox(this.fullNameHash)
|
||||
});
|
||||
}
|
||||
|
||||
edit() {
|
||||
this.canBeEdited() && this.editing(true);
|
||||
}
|
||||
|
||||
unedit() {
|
||||
this.editing(false);
|
||||
}
|
||||
|
||||
rename() {
|
||||
const folder = this,
|
||||
nameToEdit = folder.nameForEdit().trim();
|
||||
if (nameToEdit && folder.name() !== nameToEdit) {
|
||||
Remote.abort('Folders').post('FolderRename', FolderUserStore.foldersRenaming, {
|
||||
folder: folder.fullName,
|
||||
newFolderName: nameToEdit,
|
||||
subscribe: folder.isSubscribed() ? 1 : 0
|
||||
})
|
||||
.then(data => {
|
||||
folder.name(nameToEdit/*data.name*/);
|
||||
if (folder.subFolders.length) {
|
||||
Remote.setTrigger(FolderUserStore.foldersLoading, true);
|
||||
// clearTimeout(Remote.foldersTimeout);
|
||||
// Remote.foldersTimeout = setTimeout(loadFolders, 500);
|
||||
setTimeout(loadFolders, 500);
|
||||
// TODO: rename all subfolders with folder.delimiter to prevent reload?
|
||||
} else {
|
||||
removeFolderFromCacheList(folder.fullName);
|
||||
folder.fullName = data.Result.fullName;
|
||||
setFolder(folder);
|
||||
const parent = getFolderFromCacheList(folder.parentName);
|
||||
sortFolders(parent ? parent.subFolders : FolderUserStore.folderList);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
FolderUserStore.folderListError(
|
||||
getNotification(error.code, '', Notifications.CantRenameFolder)
|
||||
+ '.\n' + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
folder.editing(false);
|
||||
showScreenPopup(FolderPopupView, [this]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -543,6 +526,8 @@ export class FolderModel extends AbstractModel {
|
|||
|
||||
folder.isSubscribed(attr('\\subscribed'));
|
||||
folder.exists = !attr('\\nonexistent');
|
||||
folder.subFolders.allow = !attr('\\noinferiors');
|
||||
// folder.subFolders.exist = attr('\\haschildren') || !attr('\\hasnochildren');
|
||||
folder.selectable(folder.exists && !attr('\\noselect'));
|
||||
|
||||
type && 'mail' != type && folder.kolabType(type);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
import { addObservablesTo } from 'External/ko';
|
||||
import { addObservablesTo, addComputablesTo } from 'External/ko';
|
||||
|
||||
export class IdentityModel extends AbstractModel {
|
||||
/**
|
||||
|
|
@ -11,16 +11,32 @@ export class IdentityModel extends AbstractModel {
|
|||
|
||||
addObservablesTo(this, {
|
||||
id: '',
|
||||
label: '',
|
||||
email: '',
|
||||
name: '',
|
||||
|
||||
replyTo: '',
|
||||
bcc: '',
|
||||
sentFolder: '',
|
||||
|
||||
signature: '',
|
||||
signatureInsertBefore: false,
|
||||
|
||||
askDelete: false
|
||||
pgpSign: false,
|
||||
pgpEncrypt: false,
|
||||
|
||||
smimeKey: '',
|
||||
smimeCertificate: '',
|
||||
|
||||
askDelete: false,
|
||||
|
||||
exists: false
|
||||
});
|
||||
|
||||
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())
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -29,8 +45,8 @@ export class IdentityModel extends AbstractModel {
|
|||
*/
|
||||
formattedName() {
|
||||
const name = this.name(),
|
||||
email = this.email();
|
||||
|
||||
return name ? name + ' <' + email + '>' : email;
|
||||
email = this.email(),
|
||||
label = this.label();
|
||||
return (name ? `${name} ` : '') + `<${email}>` + (label ? ` (${label})` : '');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,27 +2,85 @@ import ko from 'ko';
|
|||
|
||||
import { i18n } from 'Common/Translator';
|
||||
|
||||
import { doc, SettingsGet } from 'Common/Globals';
|
||||
import { doc, elementById, SettingsGet } from 'Common/Globals';
|
||||
import { encodeHtml, plainToHtml, htmlToPlain, cleanHtml } from 'Common/Html';
|
||||
import { forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
|
||||
import { serverRequestRaw, proxy } from 'Common/Links';
|
||||
import { addObservablesTo, addComputablesTo } from 'External/ko';
|
||||
|
||||
import { FolderUserStore, isAllowedKeyword } from 'Stores/User/Folder';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
import { FileInfo } from 'Common/File';
|
||||
import { FileInfo, RFC822 } from 'Common/File';
|
||||
import { AttachmentCollectionModel } from 'Model/AttachmentCollection';
|
||||
import { EmailCollectionModel } from 'Model/EmailCollection';
|
||||
import { MimeHeaderCollectionModel } from 'Model/MimeHeaderCollection';
|
||||
//import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
|
||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
|
||||
import PreviewHTML from 'Html/PreviewMessage.html';
|
||||
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { MimeToMessage } from 'Mime/Utils';
|
||||
|
||||
const
|
||||
PreviewHTML = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title></title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(125,128,128,0.3);
|
||||
border-bottom: 1px solid #888;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
header * {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
header time {
|
||||
float: right;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 2px solid rgba(125,128,128,0.5);
|
||||
margin: 0;
|
||||
padding: 0 0 0 10px;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
body > * {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
#attachments > * {
|
||||
border: 1px solid rgba(125,128,128,0.5);
|
||||
padding: 0.25em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
#attachments > *::before {
|
||||
content: '📎 ';
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`,
|
||||
|
||||
msgHtml = msg => cleanHtml(msg.html(), msg.attachments(), '#rl-msg-' + msg.hash),
|
||||
|
||||
toggleTag = (message, keyword) => {
|
||||
|
|
@ -55,43 +113,51 @@ export class MessageModel extends AbstractModel {
|
|||
constructor() {
|
||||
super();
|
||||
|
||||
this.folder = '';
|
||||
this.uid = 0;
|
||||
this.hash = '';
|
||||
this.from = new EmailCollectionModel;
|
||||
this.to = new EmailCollectionModel;
|
||||
this.cc = new EmailCollectionModel;
|
||||
this.bcc = new EmailCollectionModel;
|
||||
this.sender = new EmailCollectionModel;
|
||||
this.replyTo = new EmailCollectionModel;
|
||||
this.deliveredTo = new EmailCollectionModel;
|
||||
this.body = null;
|
||||
this.draftInfo = [];
|
||||
this.dkim = [];
|
||||
this.spf = [];
|
||||
this.dmarc = [];
|
||||
this.messageId = '';
|
||||
this.inReplyTo = '';
|
||||
this.references = '';
|
||||
this.autocrypt = {};
|
||||
Object.assign(this, {
|
||||
folder: '',
|
||||
uid: 0,
|
||||
hash: '',
|
||||
from: new EmailCollectionModel,
|
||||
to: new EmailCollectionModel,
|
||||
cc: new EmailCollectionModel,
|
||||
bcc: new EmailCollectionModel,
|
||||
sender: new EmailCollectionModel,
|
||||
replyTo: new EmailCollectionModel,
|
||||
deliveredTo: new EmailCollectionModel,
|
||||
body: null,
|
||||
draftInfo: [],
|
||||
dkim: [],
|
||||
spf: [],
|
||||
dmarc: [],
|
||||
messageId: '',
|
||||
inReplyTo: '',
|
||||
references: '',
|
||||
// autocrypt: ko.observableArray(),
|
||||
hasVirus: null, // or boolean when scanned
|
||||
priority: 3, // Normal
|
||||
senderEmailsString: '',
|
||||
senderClearEmailsString: '',
|
||||
isSpam: false,
|
||||
spamScore: 0,
|
||||
spamResult: '',
|
||||
size: 0,
|
||||
readReceipt: '',
|
||||
preview: null,
|
||||
|
||||
attachments: ko.observableArray(new AttachmentCollectionModel),
|
||||
threads: ko.observableArray(),
|
||||
threadUnseen: ko.observableArray(),
|
||||
unsubsribeLinks: ko.observableArray(),
|
||||
flags: ko.observableArray(),
|
||||
headers: ko.observableArray(new MimeHeaderCollectionModel)
|
||||
});
|
||||
|
||||
addObservablesTo(this, {
|
||||
subject: '',
|
||||
plain: '',
|
||||
html: '',
|
||||
size: 0,
|
||||
spamScore: 0,
|
||||
spamResult: '',
|
||||
isSpam: false,
|
||||
hasVirus: null, // or boolean when scanned
|
||||
dateTimestamp: 0,
|
||||
internalTimestamp: 0,
|
||||
priority: 3, // Normal
|
||||
|
||||
senderEmailsString: '',
|
||||
senderClearEmailsString: '',
|
||||
|
||||
deleted: false,
|
||||
dateTimestampSource: 0,
|
||||
|
||||
// Also used by Selector
|
||||
focused: false,
|
||||
|
|
@ -101,26 +167,29 @@ export class MessageModel extends AbstractModel {
|
|||
isHtml: false,
|
||||
hasImages: false,
|
||||
hasExternals: false,
|
||||
|
||||
pgpSigned: null,
|
||||
pgpVerified: null,
|
||||
hasTracking: false,
|
||||
|
||||
encrypted: false,
|
||||
|
||||
pgpSigned: null,
|
||||
pgpEncrypted: null,
|
||||
pgpDecrypted: false,
|
||||
|
||||
readReceipt: '',
|
||||
smimeSigned: null,
|
||||
smimeEncrypted: null,
|
||||
smimeDecrypted: false,
|
||||
|
||||
// rfc8621
|
||||
id: '',
|
||||
// threadId: ''
|
||||
});
|
||||
|
||||
this.attachments = ko.observableArray(new AttachmentCollectionModel);
|
||||
this.threads = ko.observableArray();
|
||||
this.threadUnseen = ko.observableArray();
|
||||
this.unsubsribeLinks = ko.observableArray();
|
||||
this.flags = ko.observableArray();
|
||||
/**
|
||||
* Basic support for Linked Data (Structured Email)
|
||||
* https://json-ld.org/
|
||||
* https://structured.email/
|
||||
**/
|
||||
linkedData: []
|
||||
});
|
||||
|
||||
addComputablesTo(this, {
|
||||
attachmentIconClass: () =>
|
||||
|
|
@ -128,24 +197,28 @@ export class MessageModel extends AbstractModel {
|
|||
threadsLen: () => rl.app.messageList.threadUid() ? 0 : this.threads().length,
|
||||
threadUnseenLen: () => rl.app.messageList.threadUid() ? 0 : this.threadUnseen().length,
|
||||
|
||||
threadsLenText: () => {
|
||||
const unseenLen = this.threadUnseenLen();
|
||||
return this.threadsLen() + (unseenLen > 0 ? '/' + unseenLen : '');
|
||||
},
|
||||
|
||||
isUnseen: () => !this.flags().includes('\\seen'),
|
||||
isFlagged: () => this.flags().includes('\\flagged'),
|
||||
isDeleted: () => this.flags().includes('\\deleted'),
|
||||
// isJunk: () => this.flags().includes('$junk') && !this.flags().includes('$nonjunk'),
|
||||
// isPhishing: () => this.flags().includes('$phishing'),
|
||||
|
||||
tagOptions: () => {
|
||||
const tagOptions = [];
|
||||
FolderUserStore.currentFolder().permanentFlags.forEach(value => {
|
||||
if (isAllowedKeyword(value)) {
|
||||
let lower = value.toLowerCase();
|
||||
tagOptions.push({
|
||||
css: 'msgflag-' + lower,
|
||||
value: value,
|
||||
checked: this.flags().includes(lower),
|
||||
label: i18n('MESSAGE_TAGS/'+lower, 0, value),
|
||||
toggle: (/*obj*/) => toggleTag(this, value)
|
||||
});
|
||||
}
|
||||
FolderUserStore.currentFolder().optionalTags().forEach(value => {
|
||||
let lower = value.toLowerCase();
|
||||
tagOptions.push({
|
||||
css: 'msgflag-' + lower,
|
||||
value: value,
|
||||
checked: this.flags().includes(lower),
|
||||
label: i18n('MESSAGE_TAGS/'+lower, 0, value),
|
||||
toggle: (/*obj*/) => toggleTag(this, value)
|
||||
});
|
||||
});
|
||||
return tagOptions
|
||||
},
|
||||
|
|
@ -174,14 +247,18 @@ export class MessageModel extends AbstractModel {
|
|||
return options;
|
||||
}
|
||||
});
|
||||
|
||||
this.smimeSigned.subscribe(value =>
|
||||
value?.body && MimeToMessage(value.body, this)
|
||||
);
|
||||
}
|
||||
|
||||
get requestHash() {
|
||||
return b64EncodeJSONSafe({
|
||||
folder: this.folder,
|
||||
uid: this.uid,
|
||||
mimeType: 'message/rfc822',
|
||||
fileName: (this.subject() || 'message-' + this.hash) + '.eml',
|
||||
mimeType: RFC822,
|
||||
fileName: (this.subject() || 'message') + '-' + this.hash + '.eml',
|
||||
accountHash: SettingsGet('accountHash')
|
||||
});
|
||||
}
|
||||
|
|
@ -191,23 +268,23 @@ export class MessageModel extends AbstractModel {
|
|||
}
|
||||
|
||||
spamStatus() {
|
||||
let spam = this.spamResult();
|
||||
return spam ? i18n(this.isSpam() ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
|
||||
let spam = this.spamResult;
|
||||
return spam ? i18n(this.isSpam ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
friendlySize() {
|
||||
return FileInfo.friendlySize(this.size());
|
||||
return FileInfo.friendlySize(this.size);
|
||||
}
|
||||
|
||||
computeSenderEmail() {
|
||||
const list = this[
|
||||
[FolderUserStore.sentFolder(), FolderUserStore.draftsFolder()].includes(this.folder) ? 'to' : 'from'
|
||||
];
|
||||
this.senderEmailsString(list.toString(true));
|
||||
this.senderClearEmailsString(list.map(email => email?.email).filter(email => email).join(', '));
|
||||
this.senderEmailsString = list.toString(true);
|
||||
this.senderClearEmailsString = list.map(email => email?.email).filter(email => email).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -218,8 +295,63 @@ export class MessageModel extends AbstractModel {
|
|||
if (super.revivePropertiesFromJson(json)) {
|
||||
// this.foundCIDs = isArray(json.FoundCIDs) ? json.FoundCIDs : [];
|
||||
// this.attachments(AttachmentCollectionModel.reviveFromJson(json.attachments, this.foundCIDs));
|
||||
// this.headers(MimeHeaderCollectionModel.reviveFromJson(json.headers));
|
||||
|
||||
this.computeSenderEmail();
|
||||
|
||||
let value, headers = this.headers();
|
||||
/* // These could be by Envelope or MIME
|
||||
this.messageId = headers.valueByName('Message-Id');
|
||||
this.subject(headers.valueByName('Subject'));
|
||||
this.sender = EmailCollectionModel.fromString(headers.valueByName('Sender'));
|
||||
this.from = EmailCollectionModel.fromArray(headers.valueByName('From'));
|
||||
this.replyTo = EmailCollectionModel.fromArray(headers.valueByName('Reply-To'));
|
||||
this.to = EmailCollectionModel.fromArray(headers.valueByName('To'));
|
||||
this.cc = EmailCollectionModel.fromArray(headers.valueByName('Cc'));
|
||||
this.bcc = EmailCollectionModel.fromArray(headers.valueByName('Bcc'));
|
||||
this.inReplyTo = headers.valueByName('In-Reply-To');
|
||||
|
||||
this.deliveredTo = EmailCollectionModel.fromString(headers.valueByName('Delivered-To'));
|
||||
*/
|
||||
// Priority
|
||||
value = headers.valueByName('X-MSMail-Priority')
|
||||
|| headers.valueByName('Importance')
|
||||
|| headers.valueByName('X-Priority');
|
||||
if (value) {
|
||||
if (/[h12]/.test(value[0])) {
|
||||
this.priority = 1;
|
||||
} else if (/[l45]/.test(value[0])) {
|
||||
this.priority = 5;
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe links
|
||||
if (value = headers.valueByName('List-Unsubscribe')) {
|
||||
this.unsubsribeLinks(value.split(',').map(
|
||||
link => link.replace(/^[ <>]+|[ <>]+$/g, '')
|
||||
));
|
||||
}
|
||||
|
||||
if (headers.valueByName('X-Virus')) {
|
||||
this.hasVirus = true;
|
||||
}
|
||||
if (value = headers.valueByName('X-Virus-Status')) {
|
||||
if (value.includes('infected')) {
|
||||
this.hasVirus = true;
|
||||
} else if (value.includes('clean')) {
|
||||
this.hasVirus = false;
|
||||
}
|
||||
}
|
||||
/*
|
||||
if (value = headers.valueByName('X-Virus-Scanned')) {
|
||||
this.virusScanned(value);
|
||||
}
|
||||
|
||||
// https://autocrypt.org/level1.html#the-autocrypt-header
|
||||
headers.valuesByName('Autocrypt').forEach(value => {
|
||||
this.autocrypt.push(new MimeHeaderAutocryptModel(value));
|
||||
});
|
||||
*/
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -230,12 +362,11 @@ export class MessageModel extends AbstractModel {
|
|||
lineAsCss(flags=1) {
|
||||
let classes = [];
|
||||
forEachObjectEntry({
|
||||
deleted: this.deleted(),
|
||||
selected: this.selected(),
|
||||
checked: this.checked(),
|
||||
unseen: this.isUnseen(),
|
||||
focused: this.focused(),
|
||||
priorityHigh: this.priority() === 1,
|
||||
priorityHigh: this.priority === 1,
|
||||
withAttachments: !!this.attachments().length,
|
||||
// hasChildrenMessage: 1 < this.threadsLen()
|
||||
}, (key, value) => value && classes.push(key));
|
||||
|
|
@ -301,8 +432,10 @@ export class MessageModel extends AbstractModel {
|
|||
let result = msgHtml(this);
|
||||
this.hasExternals(result.hasExternals);
|
||||
this.hasImages(!!result.hasExternals);
|
||||
this.hasTracking(!!result.tracking);
|
||||
this.linkedData(result.linkedData);
|
||||
body.innerHTML = result.html;
|
||||
if (!this.isSpam() && FolderUserStore.spamFolder() != this.folder) {
|
||||
if (!this.isSpam && FolderUserStore.spamFolder() != this.folder) {
|
||||
if ('always' === SettingsUserStore.viewImages()) {
|
||||
this.showExternalImages();
|
||||
}
|
||||
|
|
@ -316,7 +449,7 @@ export class MessageModel extends AbstractModel {
|
|||
? this.plain()
|
||||
.replace(/-----BEGIN PGP (SIGNED MESSAGE-----(\r?\n[^\r\n]+)+|SIGNATURE-----[\s\S]*)/sg, '')
|
||||
.trim()
|
||||
: htmlToPlain(body.innerHTML)
|
||||
: htmlToPlain(body.innerHTML || msgHtml(this).html)
|
||||
)
|
||||
);
|
||||
this.hasImages(false);
|
||||
|
|
@ -326,6 +459,7 @@ export class MessageModel extends AbstractModel {
|
|||
this.isHtml(html);
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
viewHtml() {
|
||||
|
|
@ -336,14 +470,22 @@ export class MessageModel extends AbstractModel {
|
|||
return this.viewBody(false);
|
||||
}
|
||||
|
||||
viewPopupMessage(print) {
|
||||
swapColors() {
|
||||
const cl = this.body?.classList;
|
||||
cl && cl.toggle('swapColors');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean=} print = false
|
||||
*/
|
||||
popupMessage(print) {
|
||||
const
|
||||
timeStampInUTC = this.dateTimestamp() || 0,
|
||||
ccLine = this.cc.toString(),
|
||||
bccLine = this.bcc.toString(),
|
||||
m = 0 < timeStampInUTC ? new Date(timeStampInUTC * 1000) : null,
|
||||
win = open('', 'sm-msg-'+this.requestHash
|
||||
/*,newWindow ? 'innerWidth=' + elementById('V-MailMessageView').clientWidth : ''*/
|
||||
,SettingsUserStore.messageNewWindow() ? 'innerWidth=' + elementById('V-MailMessageView').clientWidth : ''
|
||||
),
|
||||
sdoc = win.document,
|
||||
subject = encodeHtml(this.subject()),
|
||||
|
|
@ -364,25 +506,11 @@ export class MessageModel extends AbstractModel {
|
|||
.replace('</body>', `<div id="attachments">${attachments}</div></body>`)
|
||||
);
|
||||
sdoc.close();
|
||||
print && setTimeout(() => win.print(), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean=} print = false
|
||||
*/
|
||||
popupMessage() {
|
||||
this.viewPopupMessage();
|
||||
(true === print) && setTimeout(() => win.print(), 100);
|
||||
}
|
||||
|
||||
printMessage() {
|
||||
this.viewPopupMessage(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
generateUid() {
|
||||
return this.folder + '/' + this.uid;
|
||||
this.popupMessage(true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -435,7 +563,7 @@ export class MessageModel extends AbstractModel {
|
|||
hasImages = true;
|
||||
},
|
||||
attr = 'data-x-src',
|
||||
src, useProxy = !!SettingsGet('useLocalProxyForExternalImages');
|
||||
src, useProxy = !!SettingsGet('proxyExternalImages');
|
||||
body.querySelectorAll('img[' + attr + ']').forEach(node => {
|
||||
src = node.getAttribute(attr);
|
||||
if (isValid(src)) {
|
||||
|
|
|
|||
|
|
@ -30,12 +30,13 @@ export class MessageCollectionModel extends AbstractCollectionModel
|
|||
let msg = MessageUserStore.message();
|
||||
return super.reviveFromJson(object, message => {
|
||||
// If message is currently viewed, use that.
|
||||
// Maybe then use msg.revivePropertiesFromJson(message) ?
|
||||
message = (msg && msg.hash === message.hash) ? msg : MessageModel.reviveFromJson(message);
|
||||
if (message) {
|
||||
message.deleted(false);
|
||||
return message;
|
||||
if (msg && msg.hash === message.hash) {
|
||||
msg.revivePropertiesFromJson(message);
|
||||
message = msg;
|
||||
} else {
|
||||
message = MessageModel.reviveFromJson(message);
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
dev/Model/MimeHeader.js
Normal file
13
dev/Model/MimeHeader.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import ko from 'ko';
|
||||
|
||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
|
||||
export class MimeHeaderModel extends AbstractModel
|
||||
{
|
||||
constructor() {
|
||||
super();
|
||||
this.name = '';
|
||||
this.value = '';
|
||||
this.parameters = ko.observableArray();
|
||||
}
|
||||
}
|
||||
34
dev/Model/MimeHeaderAutocrypt.js
Normal file
34
dev/Model/MimeHeaderAutocrypt.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//import { AbstractModel } from 'Knoin/AbstractModel';
|
||||
|
||||
export class MimeHeaderAutocryptModel/* extends AbstractModel*/
|
||||
{
|
||||
constructor(value) {
|
||||
// super();
|
||||
this.addr = '';
|
||||
this.prefer_encrypt = 'nopreference', // nopreference or mutual
|
||||
this.keydata = '';
|
||||
|
||||
if (value) {
|
||||
value.split(';').forEach(entry => {
|
||||
entry = entry.match(/^([^=]+)=(.*)$/);
|
||||
const trim = str => (str || '').trim().replace(/^["']|["']+$/g, '');
|
||||
this[trim(entry[1]).replace('-', '_')] = trim(entry[2]);
|
||||
});
|
||||
this.keydata = this.keydata.replace(/\s+/g, '\n');
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
let result = `addr=${this.addr}; `;
|
||||
if ('mutual' === this.prefer_encrypt) {
|
||||
result += 'prefer-encrypt=mutual; ';
|
||||
}
|
||||
return result + 'keydata=' + this.keydata.replace(/\n/g, '\n ');
|
||||
}
|
||||
|
||||
pem() {
|
||||
return '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n'
|
||||
+ this.keydata
|
||||
+ '\n-----END PGP PUBLIC KEY BLOCK-----';
|
||||
}
|
||||
}
|
||||
38
dev/Model/MimeHeaderCollection.js
Normal file
38
dev/Model/MimeHeaderCollection.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||
import { MimeHeaderModel } from 'Model/MimeHeader';
|
||||
|
||||
'use strict';
|
||||
|
||||
export class MimeHeaderCollectionModel extends AbstractCollectionModel
|
||||
{
|
||||
/**
|
||||
* @param {?Array} json
|
||||
* @returns {MimeHeaderCollectionModel}
|
||||
*/
|
||||
static reviveFromJson(items) {
|
||||
return super.reviveFromJson(items, header => MimeHeaderModel.reviveFromJson(header));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {?MimeHeader}
|
||||
*/
|
||||
getByName(name)
|
||||
{
|
||||
name = name.toLowerCase();
|
||||
return this.find(header => header.name.toLowerCase() === name);
|
||||
}
|
||||
|
||||
valueByName(name)
|
||||
{
|
||||
const header = this.getByName(name);
|
||||
return header ? header.value : '';
|
||||
}
|
||||
|
||||
valuesByName(name)
|
||||
{
|
||||
name = name.toLowerCase();
|
||||
return this.filter(header => header.name.toLowerCase() === name).map(header => header.value);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -8,18 +8,17 @@ let iJsonErrorCount = 0;
|
|||
const getURL = (add = '') => serverRequest('Json') + pString(add),
|
||||
|
||||
checkResponseError = data => {
|
||||
const err = data ? data.ErrorCode : null;
|
||||
const err = data ? data.code : null;
|
||||
if (Notifications.InvalidToken === err) {
|
||||
console.error(getNotification(err));
|
||||
console.error(getNotification(err) + ` (${data.messageAdditional})`);
|
||||
// alert(getNotification(err));
|
||||
rl.logoutReload();
|
||||
setTimeout(rl.logoutReload, 5000);
|
||||
} else if ([
|
||||
Notifications.AuthError,
|
||||
Notifications.ConnectionError,
|
||||
Notifications.DomainNotAllowed,
|
||||
Notifications.AccountNotAllowed,
|
||||
Notifications.MailServerError,
|
||||
Notifications.UnknownNotification,
|
||||
Notifications.UnknownError
|
||||
].includes(err)
|
||||
) {
|
||||
|
|
@ -132,8 +131,8 @@ export class AbstractFetchRemote
|
|||
|
||||
fetchJSON(sAction, getURL(sGetAdd),
|
||||
sGetAdd ? null : (params || {}),
|
||||
undefined === iTimeout ? 30000 : pInt(iTimeout),
|
||||
data => {
|
||||
pInt(iTimeout ?? 30000),
|
||||
async data => {
|
||||
let iError = 0;
|
||||
if (data) {
|
||||
/*
|
||||
|
|
@ -145,10 +144,14 @@ export class AbstractFetchRemote
|
|||
iJsonErrorCount = 0;
|
||||
} else {
|
||||
checkResponseError(data);
|
||||
iError = data.ErrorCode || Notifications.UnknownError
|
||||
iError = data.code || Notifications.UnknownError
|
||||
}
|
||||
}
|
||||
|
||||
if (111 === iError && rl.app.ask && await rl.app.ask.cryptkey()) {
|
||||
return this.request(sAction, fCallback, params, iTimeout, sGetAdd);
|
||||
}
|
||||
|
||||
fCallback && fCallback(
|
||||
iError,
|
||||
data,
|
||||
|
|
@ -170,13 +173,6 @@ export class AbstractFetchRemote
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {?Function} fCallback
|
||||
*/
|
||||
getPublicKey(fCallback) {
|
||||
this.request('GetPublicKey', fCallback);
|
||||
}
|
||||
|
||||
setTrigger(trigger, value) {
|
||||
if (trigger) {
|
||||
value = !!value;
|
||||
|
|
@ -193,12 +189,16 @@ export class AbstractFetchRemote
|
|||
post(action, fTrigger, params, timeOut) {
|
||||
this.setTrigger(fTrigger, true);
|
||||
return fetchJSON(action, getURL(), params || {}, pInt(timeOut, 30000),
|
||||
data => {
|
||||
async data => {
|
||||
abort(action, 0, 1);
|
||||
|
||||
if (!data) {
|
||||
return Promise.reject(new FetchError(Notifications.JsonParse));
|
||||
}
|
||||
|
||||
if (111 === data?.code && rl.app.ask && await rl.app.ask.cryptkey()) {
|
||||
return this.post(action, fTrigger, params, timeOut);
|
||||
}
|
||||
/*
|
||||
let isCached = false, type = '';
|
||||
if (data?.epoch) {
|
||||
|
|
@ -222,8 +222,8 @@ export class AbstractFetchRemote
|
|||
if (!data.Result || action !== data.Action) {
|
||||
checkResponseError(data);
|
||||
return Promise.reject(new FetchError(
|
||||
data ? data.ErrorCode : 0,
|
||||
data ? (data.ErrorMessageAdditional || data.ErrorMessage) : ''
|
||||
data ? data.code : 0,
|
||||
data ? (data.messageAdditional || data.message) : ''
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,16 +64,6 @@ class RemoteUserFetch extends AbstractFetchRemote {
|
|||
[key]: value
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
folderMove(sPrevFolderFullName, sNewFolderFullName, bSubscribe) {
|
||||
return this.post('FolderMove', FolderUserStore.foldersRenaming, {
|
||||
folder: sPrevFolderFullName,
|
||||
newFolder: sNewFolderFullName,
|
||||
subscribe: bSubscribe ? 1 : 0
|
||||
});
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
export default new RemoteUserFetch();
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export class AbstractSettingsScreen extends AbstractScreen {
|
|||
|
||||
if (RoutedSettingsViewModel) {
|
||||
// const vmPlace = elementById('V-SettingsPane') || elementById('V-AdminPane);
|
||||
const vmPlace = this.viewModels[1].__dom,
|
||||
const vmPlace = this.viewModels[1].__vm.viewModelDom,
|
||||
SettingsViewModelClass = RoutedSettingsViewModel.vmc;
|
||||
if (SettingsViewModelClass.__vm) {
|
||||
settingsScreen = SettingsViewModelClass.__vm;
|
||||
|
|
@ -46,7 +46,6 @@ export class AbstractSettingsScreen extends AbstractScreen {
|
|||
settingsScreen.viewModelDom = viewModelDom;
|
||||
settingsScreen.viewModelTemplateID = RoutedSettingsViewModel.template;
|
||||
|
||||
SettingsViewModelClass.__dom = viewModelDom;
|
||||
SettingsViewModelClass.__vm = settingsScreen;
|
||||
|
||||
fireEvent('rl-view-model.create', settingsScreen);
|
||||
|
|
@ -118,7 +117,7 @@ export class AbstractSettingsScreen extends AbstractScreen {
|
|||
rules = {
|
||||
subname: /^(.*)$/,
|
||||
normalize_: (rquest, vals) => {
|
||||
vals.subname = null == vals.subname ? defaultRoute : pString(vals.subname);
|
||||
vals.subname = pString(vals.subname ?? defaultRoute);
|
||||
return [vals.subname];
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export class MailBoxUserScreen extends AbstractScreen {
|
|||
* @returns {void}
|
||||
*/
|
||||
onRoute(folderHash, page, search, messageUid) {
|
||||
// Only works when FolderUserStore.folderList() is loaded
|
||||
const folder = getFolderFromHashMap(folderHash.replace(/~([\d]+)$/, ''));
|
||||
if (folder) {
|
||||
FolderUserStore.currentFolder(folder);
|
||||
|
|
@ -108,7 +109,7 @@ export class MailBoxUserScreen extends AbstractScreen {
|
|||
*/
|
||||
onBuild() {
|
||||
doc.addEventListener('click', event =>
|
||||
event.target.closest('#rl-right') && moveAction(false)
|
||||
event.target.closest('#rl-right') && moveAction(0)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ export class AdminSettingsAbout /*extends AbstractViewSettings*/ {
|
|||
});
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
Remote.request('AdminClearCache');
|
||||
}
|
||||
|
||||
updateCoreData() {
|
||||
if (!this.coreUpdating()) {
|
||||
this.coreUpdating(true);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,40 @@ import ko from 'ko';
|
|||
|
||||
import Remote from 'Remote/Admin/Fetch';
|
||||
import { forEachObjectEntry } from 'Common/Utils';
|
||||
import { SettingsAdmin } from 'Common/Globals';
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
import { ThemeStore } from 'Stores/Theme';
|
||||
|
||||
export class AdminSettingsConfig /*extends AbstractViewSettings*/ {
|
||||
|
||||
constructor() {
|
||||
this.config = ko.observableArray();
|
||||
this.search = ko.observableArray();
|
||||
this.saved = ko.observable(false).extend({ falseTimeout: 5000 });
|
||||
|
||||
this.search.subscribe(value => {
|
||||
const v = value.toLowerCase(),
|
||||
qsa = (node, selector, fn) => node.querySelectorAll(selector).forEach(fn),
|
||||
match = node => node.textContent.toLowerCase().includes(v);
|
||||
if (v.length) {
|
||||
qsa(this.viewModelDom, 'tbody', tbody => {
|
||||
let show = match(tbody.querySelector('th'));
|
||||
if (show) {
|
||||
qsa(tbody, '[hidden]', n => n.hidden = false);
|
||||
} else {
|
||||
qsa(tbody, 'tbody td:first-child', td => {
|
||||
let hide = !match(td);
|
||||
show = show || !hide;
|
||||
// td.closest('tr').hidden = hide;
|
||||
td.parentNode.hidden = hide;
|
||||
});
|
||||
}
|
||||
tbody.hidden = !show;
|
||||
});
|
||||
} else {
|
||||
qsa(this.viewModelDom, 'table [hidden]', n => n.hidden = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beforeShow() {
|
||||
|
|
@ -28,13 +56,19 @@ export class AdminSettingsConfig /*extends AbstractViewSettings*/ {
|
|||
items: []
|
||||
};
|
||||
forEachObjectEntry(items, (skey, item) => {
|
||||
if ('language' === skey) {
|
||||
item[2] = ('webmail' === key) ? LanguageStore.languages : SettingsAdmin('languages');
|
||||
} else if ('theme' === skey) {
|
||||
item[2] = ThemeStore.themes;
|
||||
}
|
||||
'admin_password' === skey ||
|
||||
section.items.push({
|
||||
key: `config[${key}][${skey}]`,
|
||||
name: skey,
|
||||
value: item[0],
|
||||
type: getInputType(item[0], skey.includes('password')),
|
||||
comment: item[1]
|
||||
comment: item[1],
|
||||
options: item[2]
|
||||
});
|
||||
});
|
||||
cfg.push(section);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export class AdminSettingsContacts extends AbstractViewSettings {
|
|||
this.addSetting('contactsMySQLSSLVerify');
|
||||
this.addSetting('contactsMySQLSSLCiphers');
|
||||
|
||||
this.addSetting('contactsSQLiteGlobal');
|
||||
|
||||
addObservablesTo(this, {
|
||||
testing: false,
|
||||
testContactsSuccess: false,
|
||||
|
|
@ -102,7 +104,8 @@ export class AdminSettingsContacts extends AbstractViewSettings {
|
|||
PdoPassword: this.contactsPdoPassword(),
|
||||
MySQLSSLCA: this.contactsMySQLSSLCA(),
|
||||
MySQLSSLVerify: this.contactsMySQLSSLVerify(),
|
||||
MySQLSSLCiphers: this.contactsMySQLSSLCiphers()
|
||||
MySQLSSLCiphers: this.contactsMySQLSSLCiphers(),
|
||||
SQLiteGlobal: this.contactsSQLiteGlobal()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import ko from 'ko';
|
||||
|
||||
import {
|
||||
isArray
|
||||
} from 'Common/Utils';
|
||||
|
||||
import { addObservablesTo, addSubscribablesTo, addComputablesTo } from 'External/ko';
|
||||
|
||||
import { SaveSettingStatus } from 'Common/Enums';
|
||||
import { Settings, SettingsGet, SettingsCapa } from 'Common/Globals';
|
||||
import { SettingsAdmin, SettingsGet, SettingsCapa } from 'Common/Globals';
|
||||
import { translatorReload, convertLangName } from 'Common/Translator';
|
||||
|
||||
import { AbstractViewSettings } from 'Knoin/AbstractViews';
|
||||
|
|
@ -24,11 +20,7 @@ export class AdminSettingsGeneral extends AbstractViewSettings {
|
|||
super();
|
||||
|
||||
this.language = LanguageStore.language;
|
||||
this.languages = LanguageStore.languages;
|
||||
|
||||
const aLanguagesAdmin = Settings.app('languagesAdmin');
|
||||
this.languagesAdmin = ko.observableArray(isArray(aLanguagesAdmin) ? aLanguagesAdmin : []);
|
||||
this.languageAdmin = ko.observable(SettingsGet('languageAdmin'));
|
||||
this.languageAdmin = ko.observable(SettingsAdmin('language'));
|
||||
|
||||
this.theme = ThemeStore.theme;
|
||||
this.themes = ThemeStore.themes;
|
||||
|
|
@ -107,14 +99,18 @@ export class AdminSettingsGeneral extends AbstractViewSettings {
|
|||
}
|
||||
|
||||
selectLanguage() {
|
||||
showScreenPopup(LanguagesPopupView, [this.language, this.languages(), LanguageStore.userLanguage()]);
|
||||
showScreenPopup(LanguagesPopupView, [
|
||||
this.language,
|
||||
LanguageStore.languages,
|
||||
LanguageStore.userLanguage()
|
||||
]);
|
||||
}
|
||||
|
||||
selectLanguageAdmin() {
|
||||
showScreenPopup(LanguagesPopupView, [
|
||||
this.languageAdmin,
|
||||
this.languagesAdmin(),
|
||||
SettingsGet('languageUsers')
|
||||
SettingsAdmin('languages'),
|
||||
SettingsAdmin('clientLanguage')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export class AdminSettingsPackages extends AbstractViewSettings {
|
|||
if (iError) {
|
||||
this.packagesError(
|
||||
getNotification(install ? Notifications.CantInstallPackage : Notifications.CantDeletePackage)
|
||||
+ (data.ErrorMessage ? ':\n' + data.ErrorMessage : '')
|
||||
+ (data.message ? ':\n' + data.message : '')
|
||||
);
|
||||
} else if (data.Result.Reload) {
|
||||
location.reload();
|
||||
|
|
@ -113,8 +113,8 @@ export class AdminSettingsPackages extends AbstractViewSettings {
|
|||
if (iError) {
|
||||
plugin.enabled(disable);
|
||||
this.packagesError(
|
||||
(Notifications.UnsupportedPluginPackage === iError && data?.ErrorMessage)
|
||||
? data.ErrorMessage
|
||||
(Notifications.UnsupportedPluginPackage === iError && data?.message)
|
||||
? data.message
|
||||
: getNotification(iError)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
constructor() {
|
||||
super();
|
||||
|
||||
this.addSettings(['useLocalProxyForExternalImages']);
|
||||
this.addSettings(['proxyExternalImages', 'autoVerifySignatures']);
|
||||
|
||||
this.weakPassword = rl.app.weakPassword;
|
||||
|
||||
|
|
@ -28,6 +28,7 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
|
||||
viewQRCode: '',
|
||||
|
||||
capaGnuPG: SettingsCapa('GnuPG'),
|
||||
capaOpenPGP: SettingsCapa('OpenPGP')
|
||||
});
|
||||
|
||||
|
|
@ -65,7 +66,8 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
|
||||
adminPasswordNew2: reset,
|
||||
|
||||
capaOpenPGP: value => Remote.saveSetting('CapaOpenPGP', value)
|
||||
capaGnuPG: value => Remote.saveSetting('capaGnuPG', value),
|
||||
capaOpenPGP: value => Remote.saveSetting('capaOpenPGP', value)
|
||||
});
|
||||
|
||||
this.adminTOTP(SettingsGet('adminTOTP'));
|
||||
|
|
@ -75,6 +77,16 @@ export class AdminSettingsSecurity extends AbstractViewSettings {
|
|||
});
|
||||
}
|
||||
|
||||
generateTOTP() {
|
||||
let CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
|
||||
length = 16,
|
||||
secret = '';
|
||||
while (0 < length--) {
|
||||
secret += CHARS[Math.floor(Math.random() * 32)];
|
||||
}
|
||||
this.adminTOTP(secret);
|
||||
}
|
||||
|
||||
saveAdminUserCommand() {
|
||||
if (!this.adminLogin().trim()) {
|
||||
this.adminLoginError(true);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import ko from 'ko';
|
|||
|
||||
//import { koComputable } from 'External/ko';
|
||||
import { SettingsCapa, SettingsGet } from 'Common/Globals';
|
||||
import { loadAccountsAndIdentities, editIdentity } from 'Common/UtilsUser';
|
||||
|
||||
import { AccountUserStore } from 'Stores/User/Account';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
|
|
@ -11,7 +12,6 @@ import Remote from 'Remote/User/Fetch';
|
|||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
|
||||
import { AccountPopupView } from 'View/Popup/Account';
|
||||
import { IdentityPopupView } from 'View/Popup/Identity';
|
||||
|
||||
export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
|
||||
constructor() {
|
||||
|
|
@ -43,11 +43,11 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
|
|||
}
|
||||
|
||||
addNewIdentity() {
|
||||
showScreenPopup(IdentityPopupView);
|
||||
editIdentity();
|
||||
}
|
||||
|
||||
editIdentity(identity) {
|
||||
showScreenPopup(IdentityPopupView, [identity]);
|
||||
editIdentity(identity);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,7 +64,7 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
|
|||
rl.route.root();
|
||||
setTimeout(() => location.reload(), 1);
|
||||
} else {
|
||||
rl.app.accountsAndIdentities();
|
||||
loadAccountsAndIdentities();
|
||||
}
|
||||
}, {
|
||||
emailToDelete: accountToRemove.email
|
||||
|
|
@ -88,7 +88,7 @@ export class UserSettingsAccounts /*extends AbstractViewSettings*/ {
|
|||
|
||||
accountsAndIdentitiesAfterMove() {
|
||||
Remote.request('AccountsAndIdentitiesSortOrder', null, {
|
||||
Accounts: AccountUserStore.getEmailAddresses().filter(v => v != SettingsGet('mainEmail')),
|
||||
Accounts: AccountUserStore.filter(item => item.isAdditional()).map(item => item.email),
|
||||
Identities: IdentityUserStore.map(item => (item ? item.id() : ""))
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import Remote from 'Remote/User/Fetch';
|
|||
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
|
||||
//import { FolderPopupView } from 'View/Popup/Folder';
|
||||
import { FolderCreatePopupView } from 'View/Popup/FolderCreate';
|
||||
import { FolderSystemPopupView } from 'View/Popup/FolderSystem';
|
||||
|
||||
|
|
@ -41,8 +42,8 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
|
|||
|
||||
this.displaySpecSetting = FolderUserStore.displaySpecSetting;
|
||||
this.folderList = FolderUserStore.folderList;
|
||||
this.folderListOptimized = FolderUserStore.folderListOptimized;
|
||||
this.folderListError = FolderUserStore.folderListError;
|
||||
this.folderListOptimized = FolderUserStore.optimized;
|
||||
this.folderListError = FolderUserStore.error;
|
||||
this.hideUnsubscribed = SettingsUserStore.hideUnsubscribed;
|
||||
this.unhideKolabFolders = SettingsUserStore.unhideKolabFolders;
|
||||
|
||||
|
|
@ -55,7 +56,7 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
|
|||
}
|
||||
|
||||
onShow() {
|
||||
FolderUserStore.folderListError('');
|
||||
FolderUserStore.error('');
|
||||
}
|
||||
/*
|
||||
onBuild(oDom) {
|
||||
|
|
@ -75,7 +76,7 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
|
|||
&& folderToRemove.askDelete()
|
||||
) {
|
||||
if (0 < folderToRemove.totalEmails()) {
|
||||
// FolderUserStore.folderListError(getNotification(Notifications.CantDeleteNonEmptyFolder));
|
||||
// FolderUserStore.error(getNotification(Notifications.CantDeleteNonEmptyFolder));
|
||||
folderToRemove.errorMsg(getNotification(Notifications.CantDeleteNonEmptyFolder));
|
||||
} else {
|
||||
folderForDeletion(null);
|
||||
|
|
@ -96,7 +97,7 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
|
|||
}
|
||||
},
|
||||
error => {
|
||||
FolderUserStore.folderListError(
|
||||
FolderUserStore.error(
|
||||
getNotification(error.code, '', Notifications.CantDeleteFolder)
|
||||
+ '.\n' + error.message
|
||||
);
|
||||
|
|
@ -108,7 +109,7 @@ export class UserSettingsFolders /*extends AbstractViewSettings*/ {
|
|||
}
|
||||
|
||||
hideError() {
|
||||
this.folderListError('');
|
||||
FolderUserStore.error('');
|
||||
}
|
||||
|
||||
toggleFolderKolabType(folder, event) {
|
||||
|
|
|
|||
|
|
@ -5,15 +5,17 @@ import { SaveSettingStatus } from 'Common/Enums';
|
|||
import { LayoutSideView, LayoutBottomView } from 'Common/EnumsUser';
|
||||
import { setRefreshFoldersInterval } from 'Common/Folders';
|
||||
import { Settings, SettingsGet } from 'Common/Globals';
|
||||
import { isArray } from 'Common/Utils';
|
||||
import { WYSIWYGS } from 'Common/HtmlEditor';
|
||||
import { addSubscribablesTo, addComputablesTo } from 'External/ko';
|
||||
import { i18n, translateTrigger, translatorReload, convertLangName } from 'Common/Translator';
|
||||
import { editIdentity } from 'Common/UtilsUser';
|
||||
|
||||
import { AbstractViewSettings } from 'Knoin/AbstractViews';
|
||||
import { showScreenPopup } from 'Knoin/Knoin';
|
||||
|
||||
import { AppUserStore } from 'Stores/User/App';
|
||||
import { LanguageStore } from 'Stores/Language';
|
||||
import { FolderUserStore } from 'Stores/User/Folder';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||
import { NotificationUserStore } from 'Stores/User/Notification';
|
||||
|
|
@ -21,13 +23,14 @@ import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
|||
|
||||
import Remote from 'Remote/User/Fetch';
|
||||
|
||||
import { IdentityPopupView } from 'View/Popup/Identity';
|
||||
import { LanguagesPopupView } from 'View/Popup/Languages';
|
||||
|
||||
export class UserSettingsGeneral extends AbstractViewSettings {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.mailto = ko.observable(!!navigator.registerProtocolHandler);
|
||||
|
||||
this.language = LanguageStore.language;
|
||||
this.languages = LanguageStore.languages;
|
||||
this.hourCycle = LanguageStore.hourCycle;
|
||||
|
|
@ -36,17 +39,30 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
this.notificationSound = ko.observable(SettingsGet('NotificationSound'));
|
||||
this.notificationSounds = ko.observableArray(SettingsGet('newMailSounds'));
|
||||
|
||||
this.minRefreshInterval = SettingsGet('minRefreshInterval');
|
||||
|
||||
this.desktopNotifications = NotificationUserStore.enabled;
|
||||
this.isDesktopNotificationAllowed = NotificationUserStore.allowed;
|
||||
|
||||
this.threadsAllowed = AppUserStore.threadsAllowed;
|
||||
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
|
||||
this.threadAlgorithms = ko.observableArray();
|
||||
FolderUserStore.capabilities.forEach(capa =>
|
||||
capa.startsWith('THREAD=') && this.threadAlgorithms.push(capa.slice(7))
|
||||
);
|
||||
this.threadAlgorithms.sort((a, b) => a.length - b.length);
|
||||
this.threadAlgorithm = SettingsUserStore.threadAlgorithm;
|
||||
|
||||
['layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
|
||||
'editorDefaultType', 'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
||||
['useThreads', 'threadAlgorithm',
|
||||
// These use addSetting()
|
||||
'layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
|
||||
'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel',
|
||||
// These are in addSettings()
|
||||
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
||||
'viewHTML', 'viewImages', 'viewImagesWhitelist', 'removeColors', 'allowStyles', 'allowDraftAutosave',
|
||||
'hideDeleted', 'listInlineAttachments', 'simpleAttachmentsList', 'collapseBlockquotes', 'maxBlockquotesLevel',
|
||||
'useCheckboxesInList', 'listGrouped', 'useThreads', 'replySameFolder', 'msgDefaultAction', 'allowSpellcheck',
|
||||
'showNextMessage'
|
||||
'hideDeleted', 'listInlineAttachments', 'simpleAttachmentsList', 'collapseBlockquotes',
|
||||
'useCheckboxesInList', 'listGrouped', 'replySameFolder', 'allowSpellcheck',
|
||||
'messageReadAuto', 'showNextMessage', 'messageNewWindow'
|
||||
].forEach(name => this[name] = SettingsUserStore[name]);
|
||||
|
||||
this.allowLanguagesOnSettings = !!SettingsGet('allowLanguagesOnSettings');
|
||||
|
|
@ -55,16 +71,13 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
|
||||
this.identities = IdentityUserStore;
|
||||
|
||||
this.wysiwygs = WYSIWYGS;
|
||||
|
||||
addComputablesTo(this, {
|
||||
languageFullName: () => convertLangName(this.language()),
|
||||
|
||||
identityMain: () => {
|
||||
const list = this.identities();
|
||||
return isArray(list) ? list.find(item => item && !item.id()) : null;
|
||||
},
|
||||
|
||||
identityMainDesc: () => {
|
||||
const identity = this.identityMain();
|
||||
const identity = IdentityUserStore.main();
|
||||
return identity ? identity.formattedName() : '---';
|
||||
},
|
||||
|
||||
|
|
@ -76,6 +89,8 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
];
|
||||
},
|
||||
|
||||
hasWysiwygs: () => 1 < WYSIWYGS().length,
|
||||
|
||||
msgDefaultActions: () => {
|
||||
translateTrigger();
|
||||
return [
|
||||
|
|
@ -95,6 +110,7 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
});
|
||||
|
||||
this.addSetting('EditorDefaultType');
|
||||
this.addSetting('editorWysiwyg');
|
||||
this.addSetting('MsgDefaultAction');
|
||||
this.addSetting('MessageReadDelay');
|
||||
this.addSetting('MessagesPerPage');
|
||||
|
|
@ -102,10 +118,13 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
this.addSetting('Layout');
|
||||
this.addSetting('MaxBlockquotesLevel');
|
||||
|
||||
this.addSettings(['ViewHTML', 'ViewImages', 'ViewImagesWhitelist', 'HideDeleted', 'RemoveColors', 'AllowStyles',
|
||||
'ListInlineAttachments', 'simpleAttachmentsList', 'UseCheckboxesInList', 'listGrouped', 'ReplySameFolder',
|
||||
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt', 'allowSpellcheck',
|
||||
'DesktopNotifications', 'SoundNotification', 'CollapseBlockquotes', 'AllowDraftAutosave', 'showNextMessage']);
|
||||
this.addSettings([
|
||||
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',
|
||||
'ViewHTML', 'ViewImages', 'ViewImagesWhitelist', 'RemoveColors', 'AllowStyles', 'AllowDraftAutosave',
|
||||
'HideDeleted', 'ListInlineAttachments', 'simpleAttachmentsList', 'CollapseBlockquotes',
|
||||
'UseCheckboxesInList', 'listGrouped', 'ReplySameFolder', 'allowSpellcheck',
|
||||
'messageReadAuto', 'showNextMessage', 'messageNewWindow',
|
||||
'DesktopNotifications', 'SoundNotification']);
|
||||
|
||||
const fReloadLanguageHelper = (saveSettingsStep) => () => {
|
||||
this.languageTrigger(saveSettingsStep);
|
||||
|
|
@ -133,6 +152,11 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
Remote.saveSetting('UseThreads', value);
|
||||
},
|
||||
|
||||
threadAlgorithm: value => {
|
||||
MessagelistUserStore([]);
|
||||
Remote.saveSetting('threadAlgorithm', value);
|
||||
},
|
||||
|
||||
checkMailInterval: () => {
|
||||
setRefreshFoldersInterval(SettingsUserStore.checkMailInterval());
|
||||
}
|
||||
|
|
@ -140,8 +164,7 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
}
|
||||
|
||||
editMainIdentity() {
|
||||
const identity = this.identityMain();
|
||||
identity && showScreenPopup(IdentityPopupView, [identity]);
|
||||
editIdentity(IdentityUserStore.main());
|
||||
}
|
||||
|
||||
testSoundNotification() {
|
||||
|
|
@ -155,4 +178,15 @@ export class UserSettingsGeneral extends AbstractViewSettings {
|
|||
selectLanguage() {
|
||||
showScreenPopup(LanguagesPopupView, [this.language, this.languages(), LanguageStore.userLanguage()]);
|
||||
}
|
||||
|
||||
registerMailto() {
|
||||
console.log(`mailto = ${location.protocol}//${location.host}${location.pathname}?mailto`);
|
||||
navigator.registerProtocolHandler(
|
||||
'mailto',
|
||||
`${location.protocol}//${location.host}${location.pathname}?mailto&to=%s`,
|
||||
(SettingsGet('title') || 'SnappyMail')
|
||||
);
|
||||
alert(i18n('GLOBAL/DONE'));
|
||||
this.mailto(0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import { showScreenPopup } from 'Knoin/Knoin';
|
|||
import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport';
|
||||
import { OpenPgpGeneratePopupView } from 'View/Popup/OpenPgpGenerate';
|
||||
|
||||
//import Remote from 'Remote/User/Fetch';
|
||||
import { SMimeUserStore } from 'Stores/User/SMime';
|
||||
import { SMimeImportPopupView } from 'View/Popup/SMimeImport';
|
||||
|
||||
export class UserSettingsSecurity extends AbstractViewSettings {
|
||||
constructor() {
|
||||
|
|
@ -25,9 +26,9 @@ export class UserSettingsSecurity extends AbstractViewSettings {
|
|||
this.autoLogoutOptions = koComputable(() => {
|
||||
translateTrigger();
|
||||
return [
|
||||
{ id: 0, name: i18n('SETTINGS_SECURITY/AUTOLOGIN_NEVER_OPTION_NAME') },
|
||||
{ id: 0, name: i18n('SETTINGS_SECURITY/NEVER') },
|
||||
{ id: 5, name: relativeTime(300) },
|
||||
{ id: 10, name: relativeTime(600) },
|
||||
{ id: 15, name: relativeTime(900) },
|
||||
{ id: 30, name: relativeTime(1800) },
|
||||
{ id: 60, name: relativeTime(3600) },
|
||||
{ id: 120, name: relativeTime(7200) },
|
||||
|
|
@ -37,12 +38,17 @@ export class UserSettingsSecurity extends AbstractViewSettings {
|
|||
});
|
||||
this.addSetting('AutoLogout');
|
||||
|
||||
this.keyPassForget = SettingsUserStore.keyPassForget;
|
||||
this.addSetting('keyPassForget');
|
||||
|
||||
this.gnupgPublicKeys = GnuPGUserStore.publicKeys;
|
||||
this.gnupgPrivateKeys = GnuPGUserStore.privateKeys;
|
||||
|
||||
this.openpgpkeysPublic = OpenPGPUserStore.publicKeys;
|
||||
this.openpgpkeysPrivate = OpenPGPUserStore.privateKeys;
|
||||
|
||||
this.smimeCertificates = SMimeUserStore;
|
||||
|
||||
this.canOpenPGP = SettingsCapa('OpenPGP');
|
||||
this.canGnuPG = GnuPGUserStore.isSupported();
|
||||
this.canMailvelope = !!window.mailvelope;
|
||||
|
|
@ -56,6 +62,14 @@ export class UserSettingsSecurity extends AbstractViewSettings {
|
|||
showScreenPopup(OpenPgpGeneratePopupView);
|
||||
}
|
||||
|
||||
importToOpenPGP() {
|
||||
OpenPGPUserStore.loadBackupKeys();
|
||||
}
|
||||
|
||||
importToSMime() {
|
||||
showScreenPopup(SMimeImportPopupView);
|
||||
}
|
||||
|
||||
onBuild() {
|
||||
/**
|
||||
* Create an iframe to display the Mailvelope keyring settings.
|
||||
|
|
|
|||
|
|
@ -99,8 +99,8 @@ export class UserSettingsThemes /*extends AbstractViewSettings*/ {
|
|||
themeBackground.hash(data?.Result?.hash || '');
|
||||
if (!themeBackground.name() || !themeBackground.hash()) {
|
||||
let errorMsg = '';
|
||||
if (data.ErrorCode) {
|
||||
switch (data.ErrorCode) {
|
||||
if (data.code) {
|
||||
switch (data.code) {
|
||||
case UploadErrorCode.FileIsTooBig:
|
||||
errorMsg = i18n('SETTINGS_THEMES/ERROR_FILE_IS_TOO_BIG');
|
||||
break;
|
||||
|
|
@ -111,7 +111,7 @@ export class UserSettingsThemes /*extends AbstractViewSettings*/ {
|
|||
}
|
||||
}
|
||||
|
||||
themeBackground.error(errorMsg || data.ErrorMessage || i18n('SETTINGS_THEMES/ERROR_UNKNOWN'));
|
||||
themeBackground.error(errorMsg || data.message || i18n('SETTINGS_THEMES/ERROR_UNKNOWN'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ import {
|
|||
*/
|
||||
export class ConditionalCommand extends ControlCommand
|
||||
{
|
||||
constructor()
|
||||
constructor(identifier)
|
||||
{
|
||||
super();
|
||||
super(identifier);
|
||||
this.test = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ export class AddressTest extends TestCommand
|
|||
this.header_list = new GrammarStringList;
|
||||
this.key_list = new GrammarStringList;
|
||||
// rfc5260#section-6
|
||||
// this.index = new GrammarNumber;
|
||||
// this.last = false;
|
||||
this.index = new GrammarNumber;
|
||||
this.last = false;
|
||||
// rfc5703#section-6
|
||||
// this.mime
|
||||
// this.anychild
|
||||
|
|
@ -67,7 +67,7 @@ export class AddressTest extends TestCommand
|
|||
}
|
||||
}
|
||||
return result
|
||||
// + (this.last ? ' :last' : (this.index.value ? ' :index ' + this.index : ''))
|
||||
+ (this.last ? ' :last' : (this.index.value ? ' :index ' + this.index : ''))
|
||||
+ (this.comparator ? ' :comparator ' + this.comparator : '')
|
||||
+ ' ' + this.address_part
|
||||
+ ' ' + this.match_type
|
||||
|
|
@ -234,8 +234,8 @@ export class HeaderTest extends TestCommand
|
|||
this.header_names = new GrammarStringList;
|
||||
this.key_list = new GrammarStringList;
|
||||
// rfc5260#section-6
|
||||
// this.index = new GrammarNumber;
|
||||
// this.last = false;
|
||||
this.index = new GrammarNumber;
|
||||
this.last = false;
|
||||
// rfc5703#section-6
|
||||
this.mime = false;
|
||||
this.anychild = false;
|
||||
|
|
@ -278,7 +278,7 @@ export class HeaderTest extends TestCommand
|
|||
}
|
||||
}
|
||||
return result
|
||||
// + (this.last ? ' :last' : (this.index.value ? ' :index ' + this.index : ''))
|
||||
+ (this.last ? ' :last' : (this.index.value ? ' :index ' + this.index : ''))
|
||||
+ (this.comparator ? ' :comparator ' + this.comparator : '')
|
||||
+ ' ' + this.match_type
|
||||
+ ' ' + this.header_names
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ class FlagCommand extends ActionCommand
|
|||
|
||||
toString()
|
||||
{
|
||||
return this.identifier + ' ' + this._variablename + ' ' + this.list_of_flags + ';';
|
||||
let name = this._variablename;
|
||||
return this.identifier + (name.length ? ' ' + this.variablename : '') + ' ' + this.list_of_flags + ';';
|
||||
}
|
||||
|
||||
get variablename()
|
||||
|
|
|
|||
|
|
@ -8,22 +8,17 @@ import {
|
|||
GrammarString
|
||||
} from 'Sieve/Grammar';
|
||||
|
||||
/**
|
||||
* https://tools.ietf.org/html/rfc5429#section-2.1
|
||||
*/
|
||||
export class ErejectCommand extends ActionCommand
|
||||
class rfc5429Command extends ActionCommand
|
||||
{
|
||||
constructor()
|
||||
constructor(identifier)
|
||||
{
|
||||
super();
|
||||
super(identifier);
|
||||
this._reason = new GrammarQuotedString;
|
||||
}
|
||||
|
||||
get require() { return 'ereject'; }
|
||||
|
||||
toString()
|
||||
{
|
||||
return 'ereject ' + this._reason + ';';
|
||||
return this.require + ' ' + this._reason + ';';
|
||||
}
|
||||
|
||||
get reason()
|
||||
|
|
@ -44,38 +39,20 @@ export class ErejectCommand extends ActionCommand
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* https://tools.ietf.org/html/rfc5429#section-2.1
|
||||
*/
|
||||
export class ErejectCommand extends rfc5429Command
|
||||
{
|
||||
constructor() { super('ereject'); }
|
||||
get require() { return 'ereject'; }
|
||||
}
|
||||
|
||||
/**
|
||||
* https://tools.ietf.org/html/rfc5429#section-2.2
|
||||
*/
|
||||
export class RejectCommand extends ActionCommand
|
||||
export class RejectCommand extends rfc5429Command
|
||||
{
|
||||
constructor()
|
||||
{
|
||||
super();
|
||||
this._reason = new GrammarQuotedString;
|
||||
}
|
||||
|
||||
constructor() { super('reject'); }
|
||||
get require() { return 'reject'; }
|
||||
|
||||
toString()
|
||||
{
|
||||
return 'reject ' + this._reason + ';';
|
||||
}
|
||||
|
||||
get reason()
|
||||
{
|
||||
return this._reason.value;
|
||||
}
|
||||
|
||||
set reason(value)
|
||||
{
|
||||
this._reason.value = value;
|
||||
}
|
||||
|
||||
pushArguments(args)
|
||||
{
|
||||
if (args[0] instanceof GrammarString) {
|
||||
this._reason = args[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ export class GrammarTestList extends Array
|
|||
// return '(\r\n\t' + arrayToString(this, ',\r\n\t') + '\r\n)';
|
||||
return '(' + this.join(', ') + ')';
|
||||
}
|
||||
return this.length ? this[0] : '';
|
||||
return this.length ? this[0].toString() : '';
|
||||
}
|
||||
|
||||
push(value)
|
||||
|
|
@ -254,7 +254,7 @@ export class GrammarStringList extends Array
|
|||
if (1 < this.length) {
|
||||
return '[' + this.join(',') + ']';
|
||||
}
|
||||
return this.length ? this[0] : '';
|
||||
return this.length ? this[0].toString() : '';
|
||||
}
|
||||
|
||||
push(value)
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ export class AbstractModel {
|
|||
constructor() {
|
||||
/*
|
||||
if (new.target === AbstractModel) {
|
||||
throw new Error("Can't instantiate AbstractModel!");
|
||||
throw Error("Can't instantiate AbstractModel!");
|
||||
}
|
||||
*/
|
||||
this.disposables = [];
|
||||
Object.defineProperty(this, 'disposables', {value: []});
|
||||
}
|
||||
|
||||
addObservables(observables) {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ export class FilterModel extends AbstractModel {
|
|||
this.addObservables({
|
||||
enabled: true,
|
||||
askDelete: false,
|
||||
canBeDeleted: true,
|
||||
|
||||
name: '',
|
||||
nameError: false,
|
||||
|
|
@ -181,27 +180,6 @@ export class FilterModel extends AbstractModel {
|
|||
return true;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
// '@Object': 'Object/Filter',
|
||||
ID: this.id,
|
||||
Enabled: this.enabled() ? 1 : 0,
|
||||
Name: this.name,
|
||||
Conditions: this.conditions,
|
||||
ConditionsType: this.conditionsType,
|
||||
|
||||
ActionType: this.actionType(),
|
||||
ActionValue: this.actionValue,
|
||||
ActionValueSecond: this.actionValueSecond,
|
||||
ActionValueThird: this.actionValueThird,
|
||||
ActionValueFourth: this.actionValueFourth,
|
||||
|
||||
Keep: this.keep() ? 1 : 0,
|
||||
Stop: this.stop() ? 1 : 0,
|
||||
MarkAsRead: this.markAsRead() ? 1 : 0
|
||||
};
|
||||
}
|
||||
|
||||
addCondition() {
|
||||
this.conditions.push(new FilterConditionModel());
|
||||
}
|
||||
|
|
@ -210,6 +188,24 @@ export class FilterModel extends AbstractModel {
|
|||
this.conditions.remove(oConditionToDelete);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
ID: this.id,
|
||||
Enabled: this.enabled(),
|
||||
Name: this.name(),
|
||||
Conditions: this.conditions(),
|
||||
ConditionsType: this.conditionsType(),
|
||||
ActionType: this.actionType(),
|
||||
ActionValue: this.actionValue(),
|
||||
ActionValueSecond: this.actionValueSecond(),
|
||||
ActionValueThird: this.actionValueThird(),
|
||||
ActionValueFourth: this.actionValueFourth(),
|
||||
Keep: this.keep(),
|
||||
Stop: this.stop(),
|
||||
MarkAsRead: this.markAsRead()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @static
|
||||
* @param {FetchJsonFilter} json
|
||||
|
|
@ -222,7 +218,10 @@ export class FilterModel extends AbstractModel {
|
|||
if (filter) {
|
||||
filter.id = '' + (filter.id || '');
|
||||
filter.conditions(
|
||||
(json.Conditions || []).map(aData => FilterConditionModel.reviveFromJson(aData)).filter(v => v)
|
||||
(json.Conditions || json.conditions || []).map(condition => {
|
||||
condition['@Object'] = 'Object/FilterCondition';
|
||||
return FilterConditionModel.reviveFromJson(condition)
|
||||
}).filter(v => v)
|
||||
);
|
||||
}
|
||||
return filter;
|
||||
|
|
|
|||
|
|
@ -79,18 +79,17 @@ export class FilterConditionModel extends AbstractModel {
|
|||
return true;
|
||||
}
|
||||
|
||||
// static reviveFromJson(json) {}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
// '@Object': 'Object/FilterCondition',
|
||||
Field: this.field,
|
||||
Type: this.type,
|
||||
Value: this.value,
|
||||
ValueSecond: this.valueSecond
|
||||
Field: this.field(),
|
||||
Type: this.type(),
|
||||
Value: this.value(),
|
||||
ValueSecond: this.valueSecond()
|
||||
};
|
||||
}
|
||||
|
||||
// static reviveFromJson(json) {}
|
||||
|
||||
cloneSelf() {
|
||||
const filterCond = new FilterConditionModel();
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ function filtersToSieveScript(filters)
|
|||
''
|
||||
];
|
||||
|
||||
const quote = string => '"' + string.trim().replace(/(\\|")/g, '\\$1') + '"';
|
||||
const StripSpaces = string => string.replace(/\s+/, ' ').trim();
|
||||
const quote = string => '"' + string.replace(/(\\|")/g, '\\$1') + '"';
|
||||
const StripSpaces = string => string.replace(/\s+/, ' ');
|
||||
|
||||
// conditionToSieveScript
|
||||
const conditionToString = (condition, require) =>
|
||||
|
|
@ -26,8 +26,8 @@ function filtersToSieveScript(filters)
|
|||
let result = '',
|
||||
type = condition.type(),
|
||||
field = condition.field(),
|
||||
value = condition.value().trim(),
|
||||
valueSecond = condition.valueSecond().trim();
|
||||
value = condition.value(),
|
||||
valueSecond = condition.valueSecond();
|
||||
|
||||
if (value.length && ('Header' !== field || valueSecond.length)) {
|
||||
switch (type)
|
||||
|
|
@ -85,7 +85,7 @@ function filtersToSieveScript(filters)
|
|||
}
|
||||
|
||||
if (('From' === field || 'Recipient' === field) && value.includes(',')) {
|
||||
result += ' [' + value.split(',').map(value => quote(value)).join(', ').trim() + ']';
|
||||
result += ' [' + value.split(',').map(value => quote(value)).join(', ') + ']';
|
||||
} else if ('Size' === field) {
|
||||
result += ' ' + value;
|
||||
} else {
|
||||
|
|
@ -130,7 +130,7 @@ function filtersToSieveScript(filters)
|
|||
result.push(sTab + 'addflag "\\\\Seen";');
|
||||
}
|
||||
|
||||
let value = filter.actionValue().trim();
|
||||
let value = filter.actionValue();
|
||||
value = value.length ? quote(value) : 0;
|
||||
switch (filter.actionType())
|
||||
{
|
||||
|
|
@ -146,21 +146,21 @@ function filtersToSieveScript(filters)
|
|||
let days = 1,
|
||||
subject = '',
|
||||
addresses = '',
|
||||
paramValue = filter.actionValueSecond().trim();
|
||||
paramValue = filter.actionValueSecond();
|
||||
|
||||
if (paramValue.length) {
|
||||
subject = ':subject ' + quote(StripSpaces(paramValue)) + ' ';
|
||||
}
|
||||
|
||||
paramValue = ('' + (filter.actionValueThird() || '')).trim();
|
||||
paramValue = ('' + (filter.actionValueThird() || ''));
|
||||
if (paramValue.length) {
|
||||
days = Math.max(1, parseInt(paramValue, 10));
|
||||
}
|
||||
|
||||
paramValue = ('' + (filter.actionValueFourth() || '')).trim()
|
||||
paramValue = ('' + (filter.actionValueFourth() || ''))
|
||||
if (paramValue.length) {
|
||||
paramValue = paramValue.split(',').map(email =>
|
||||
email.trim().length ? quote(email) : ''
|
||||
email.length ? quote(email) : ''
|
||||
).filter(email => email.length);
|
||||
if (paramValue.length) {
|
||||
addresses = ':addresses [' + paramValue.join(', ') + '] ';
|
||||
|
|
@ -228,7 +228,7 @@ function filtersToSieveScript(filters)
|
|||
}
|
||||
|
||||
// fileStringToCollection
|
||||
function sieveScriptToFilters(script)
|
||||
function rainloopScriptToFilters(script)
|
||||
{
|
||||
let regex = /BEGIN:HEADER([\s\S]+?)END:HEADER/gm,
|
||||
filters = [],
|
||||
|
|
@ -239,7 +239,6 @@ function sieveScriptToFilters(script)
|
|||
json = decodeURIComponent(escape(atob(json[1].replace(/\s+/g, ''))));
|
||||
if (json && json.length && (json = JSON.parse(json))) {
|
||||
json['@Object'] = 'Object/Filter';
|
||||
json.Conditions.forEach(condition => condition['@Object'] = 'Object/FilterCondition');
|
||||
filter = FilterModel.reviveFromJson(json);
|
||||
filter && filters.push(filter);
|
||||
}
|
||||
|
|
@ -261,7 +260,6 @@ export class SieveScriptModel extends AbstractModel
|
|||
exists: false,
|
||||
nameError: false,
|
||||
askDelete: false,
|
||||
canBeDeleted: true,
|
||||
hasChanges: false
|
||||
});
|
||||
|
||||
|
|
@ -280,13 +278,8 @@ export class SieveScriptModel extends AbstractModel
|
|||
// this.body(filtersToSieveScript(this.filters));
|
||||
}
|
||||
|
||||
rawToFilters() {
|
||||
return sieveScriptToFilters(this.body());
|
||||
// this.filters(sieveScriptToFilters(this.body()));
|
||||
}
|
||||
|
||||
verify() {
|
||||
this.nameError(!this.name().trim());
|
||||
this.nameError(!this.name());
|
||||
return !this.nameError();
|
||||
}
|
||||
|
||||
|
|
@ -315,9 +308,8 @@ export class SieveScriptModel extends AbstractModel
|
|||
const script = super.reviveFromJson(json);
|
||||
if (script) {
|
||||
if (script.allowFilters()) {
|
||||
script.filters(sieveScriptToFilters(script.body()));
|
||||
script.filters(rainloopScriptToFilters(script.body()));
|
||||
}
|
||||
script.canBeDeleted(SIEVE_FILE_NAME !== json.name);
|
||||
script.exists(true);
|
||||
script.hasChanges(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ const
|
|||
sDeepPrefix = '\u00A0\u00A0\u00A0',
|
||||
showUnsubscribed = true/*!SettingsUserStore.hideUnsubscribed()*/,
|
||||
|
||||
disabled = rl.settings.get('sieveAllowFileintoInbox') ? '' : 'INBOX',
|
||||
|
||||
foldersWalk = folders => {
|
||||
folders.forEach(oItem => {
|
||||
if (showUnsubscribed || oItem.hasSubscriptions() || !oItem.exists) {
|
||||
|
|
@ -39,7 +37,7 @@ const
|
|||
id: oItem.fullName,
|
||||
name: sDeepPrefix.repeat(oItem.deep) + oItem.detailedName(),
|
||||
system: false,
|
||||
disabled: !oItem.selectable() || disabled == oItem.fullName
|
||||
disabled: !oItem.selectable()
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -120,12 +118,14 @@ export class FilterPopupView extends rl.pluginPopupView {
|
|||
id: FilterAction.MoveTo,
|
||||
name: i18nFilter('ACTION_MOVE_TO')
|
||||
});
|
||||
this.actionTypeOptions.push({
|
||||
id: FilterAction.Forward,
|
||||
name: i18nFilter('ACTION_FORWARD_TO')
|
||||
});
|
||||
}
|
||||
|
||||
// redirect command
|
||||
this.actionTypeOptions.push({
|
||||
id: FilterAction.Forward,
|
||||
name: i18nFilter('ACTION_FORWARD_TO')
|
||||
});
|
||||
|
||||
if (capa.includes('reject')) {
|
||||
this.actionTypeOptions.push({ id: FilterAction.Reject, name: i18nFilter('ACTION_REJECT') });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export class SieveScriptPopupView extends rl.pluginPopupView {
|
|||
|
||||
if (iError) {
|
||||
self.saveError(true);
|
||||
self.errorText(data?.ErrorMessageAdditional || getNotification(iError));
|
||||
self.errorText(data?.messageAdditional || getNotification(iError));
|
||||
} else {
|
||||
script.exists() || scripts.push(script);
|
||||
script.exists(true);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ try {
|
|||
data = data ? decodeURIComponent(data[2]) : null;
|
||||
data = data ? JSON.parse(data) : {};
|
||||
win[sName] = {
|
||||
getItem: key => data[key] == null ? null : data[key],
|
||||
getItem: key => data[key] ?? null,
|
||||
setItem: (key, value) => {
|
||||
data[key] = ''+value; // forces the value to a string
|
||||
document.cookie = sName+'='+encodeURIComponent(JSON.stringify(data))
|
||||
|
|
|
|||
|
|
@ -1 +1,21 @@
|
|||
export const Passphrases = new Map();
|
||||
import { AskPopupView } from 'View/Popup/Ask';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
|
||||
export const Passphrases = new WeakMap();
|
||||
|
||||
Passphrases.ask = async (key, sAskDesc, btnText) =>
|
||||
Passphrases.has(key)
|
||||
? {password:Passphrases.handle(key)/*, remember:false*/}
|
||||
: await AskPopupView.password(sAskDesc, btnText, 5);
|
||||
|
||||
const timeouts = {};
|
||||
// get/set accessor to control deletion after N minutes of inactivity
|
||||
Passphrases.handle = (key, pass) => {
|
||||
const timeout = SettingsUserStore.keyPassForget();
|
||||
if (timeout && !timeouts[key]) {
|
||||
timeouts[key] = (()=>Passphrases.delete(key)).debounce(timeout * 1000);
|
||||
}
|
||||
pass && Passphrases.set(key, pass);
|
||||
timeout && timeouts[key]();
|
||||
return Passphrases.get(key);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ DomainAdminStore.fetch = () => {
|
|||
if (!iError) {
|
||||
DomainAdminStore(
|
||||
data.Result.map(item => {
|
||||
item.name = IDN.toUnicode(item.name);
|
||||
item.disabled = ko.observable(item.disabled);
|
||||
item.askDelete = ko.observable(false);
|
||||
return item;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export const LanguageStore = {
|
|||
const aLanguages = Settings.app('languages');
|
||||
this.languages(isArray(aLanguages) ? aLanguages : []);
|
||||
this.language(SettingsGet('language'));
|
||||
this.userLanguage(SettingsGet('userLanguage'));
|
||||
this.userLanguage(SettingsGet('clientLanguage'));
|
||||
this.hourCycle(SettingsGet('hourCycle'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import { addObservablesTo, koArrayWithDestroy } from 'External/ko';
|
|||
|
||||
export const AccountUserStore = koArrayWithDestroy();
|
||||
|
||||
AccountUserStore.loading = ko.observable(false).extend({ debounce: 100 });
|
||||
|
||||
AccountUserStore.getEmailAddresses = () => AccountUserStore.map(item => item.email);
|
||||
|
||||
addObservablesTo(AccountUserStore, {
|
||||
email: '',
|
||||
signature: ''
|
||||
loading: false
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue