Compare commits

..

No commits in common. "master" and "v2.24.5" have entirely different histories.

1790 changed files with 45188 additions and 116523 deletions

View file

@ -1,192 +1,51 @@
# syntax=docker/dockerfile:1
FROM alpine:3.24.3 AS builder
RUN apk add --no-cache php85 php85-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 # Inspired by the original Rainloop dockerfile from youtous on GitLab
FROM php:8.5-fpm-alpine AS final FROM php:8.1-fpm-bullseye
LABEL org.label-schema.description="SnappyMail webmail client image using nginx, php-fpm on Alpine" 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
# Install dependencies such as nginx # Install dependencies such as nginx
RUN apk add --no-cache ca-certificates nginx supervisor bash 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/*
# Install PHP extensions # Install PHP extensions
# apcu RUN php -m && \
RUN set -eux; \ docker-php-ext-configure ldap --with-libdir=lib/$(uname -m)-linux-gnu/ && \
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \ docker-php-ext-configure intl && \
pecl install apcu; \ docker-php-ext-configure gd --with-freetype --with-jpeg && \
docker-php-ext-enable apcu; \ docker-php-ext-install ldap opcache pdo_mysql pdo_pgsql zip intl gd && \
docker-php-source delete; \ php -m
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
# 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
# sqlite
RUN set -eux; \
apk add --no-cache sqlite-libs; \
apk add --no-cache --virtual .deps sqlite-dev; \
docker-php-ext-install pdo_sqlite; \
apk del .deps
# tidy
RUN set -eux; \
apk add --no-cache tidyhtml; \
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 # Install snappymail
# 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 WORKDIR /tmp
COPY --chown=www-data:www-data --from=builder /snappymail /snappymail COPY ${FILES_ZIP} .
# Use a custom snappymail data folder RUN mkdir /snappymail && \
RUN mv -v /snappymail/data /var/lib/snappymail; unzip -q ${FILES_ZIP} -d /snappymail && \
# Setup configs find /snappymail -type d -exec chmod 755 {} \; && \
COPY --chown=root:root .docker/release/files / find /snappymail -type f -exec chmod 644 {} \; && \
RUN set -eux; \ rm -rf ${FILES_ZIP}
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;
USER root # Install other content
WORKDIR /snappymail COPY files /
VOLUME /var/lib/snappymail RUN chmod +x /entrypoint.sh && chmod +x /logrotate-loop.sh
VOLUME /snappymail/data
EXPOSE 8888 EXPOSE 8888
EXPOSE 9000
ENTRYPOINT []
CMD ["/entrypoint.sh"] CMD ["/entrypoint.sh"]

96
.docker/release/files/entrypoint.sh Executable file → Normal file
View file

@ -1,26 +1,23 @@
#!/bin/sh #!/bin/sh
set -eu
DEBUG=${DEBUG:-} # Create not root user
if [ "$DEBUG" = 'true' ]; then groupadd --gid "$GID" php-cli -f
set -x adduser --uid "$UID" --disabled-password --gid "$GID" --shell /bin/bash --home /home/php-cli php-cli --force --gecos ""
fi
UPLOAD_MAX_SIZE=${UPLOAD_MAX_SIZE:-25M}
MEMORY_LIMIT=${MEMORY_LIMIT:-128M}
SECURE_COOKIES=${SECURE_COOKIES:-true}
# Disable listening on ipv6 for ipv4-only environments, or nginx will fail to start
if ! ip a | grep 'inet6 ' > /dev/null; then
echo "[INFO] ipv6 is disabled. Configuring nginx to not listen on ipv6"
sed -i '/listen \[::\]:/d' /etc/nginx/nginx.conf
fi
# Set attachment size limit # Set attachment size limit
sed -i "s/<UPLOAD_MAX_SIZE>/$UPLOAD_MAX_SIZE/g" /usr/local/etc/php-fpm.d/php-fpm.conf /etc/nginx/nginx.conf sed -i "s/<UPLOAD_MAX_SIZE>/$UPLOAD_MAX_SIZE/g" /usr/local/etc/php-fpm.d/php-fpm.conf /etc/nginx/nginx.conf
sed -i "s/<MEMORY_LIMIT>/$MEMORY_LIMIT/g" /usr/local/etc/php-fpm.d/php-fpm.conf sed -i "s/<MEMORY_LIMIT>/$MEMORY_LIMIT/g" /usr/local/etc/php-fpm.d/php-fpm.conf
# 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 # Secure cookies
if [ "${SECURE_COOKIES}" = 'true' ]; then if [ "${SECURE_COOKIES}" = true ]; then
echo "[INFO] Secure cookies activated" echo "[INFO] Secure cookies activated"
{ {
echo 'session.cookie_httponly = On'; echo 'session.cookie_httponly = On';
@ -29,58 +26,43 @@ if [ "${SECURE_COOKIES}" = 'true' ]; then
} > /usr/local/etc/php/conf.d/cookies.ini; } > /usr/local/etc/php/conf.d/cookies.ini;
fi fi
echo "[INFO] Snappymail version: $( ls /snappymail/snappymail/v )" # Copy snappymail default config if absent
SNAPPYMAIL_CONFIG_FILE=/snappymail/data/_data_/_default_/configs/application.ini
# 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 if [ ! -f "$SNAPPYMAIL_CONFIG_FILE" ]; then
echo "[INFO] Creating default Snappymail configuration: $SNAPPYMAIL_CONFIG_FILE" echo "[INFO] Creating default Snappymail configuration"
# Run snappymail and exit. This populates the snappymail data directory and generates the config file mkdir -p $(dirname $SNAPPYMAIL_CONFIG_FILE)
# On error, print php exception and exit cp /usr/local/include/application.ini $SNAPPYMAIL_CONFIG_FILE
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 fi
echo "[INFO] Overriding values in snappymail configuration: $SNAPPYMAIL_CONFIG_FILE"
# Enable output of snappymail logs # Enable output of snappymail logs
sed '/^\; Enable logging/{ if [ "${LOG_TO_STDERR}" = true ]; then
N sed -z 's/\; Enable logging\nenable = Off/\; Enable logging\nenable = On/' -i $SNAPPYMAIL_CONFIG_FILE
s/enable = Off/enable = On/ sed 's/^filename = .*/filename = "errors.log"/' -i $SNAPPYMAIL_CONFIG_FILE
}' -i $SNAPPYMAIL_CONFIG_FILE sed 's/^write_on_error_only = .*/write_on_error_only = Off/' -i $SNAPPYMAIL_CONFIG_FILE
# Redirect snappymail logs to stderr /stdout sed 's/^write_on_php_error_only = .*/write_on_php_error_only = On/' -i $SNAPPYMAIL_CONFIG_FILE
sed 's/^filename = .*/filename = "stderr"/' -i $SNAPPYMAIL_CONFIG_FILE else
sed 's/^write_on_error_only = .*/write_on_error_only = Off/' -i $SNAPPYMAIL_CONFIG_FILE sed -z 's/\; Enable logging\nenable = On/\; Enable logging\nenable = Off/' -i $SNAPPYMAIL_CONFIG_FILE
sed 's/^write_on_php_error_only = .*/write_on_php_error_only = On/' -i $SNAPPYMAIL_CONFIG_FILE fi
# Always enable snappymail Auth logging # Always enable snappymail Auth logging
sed 's/^auth_logging = .*/auth_logging = On/' -i $SNAPPYMAIL_CONFIG_FILE 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_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 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
sed 's/^auth_syslog = .*/auth_syslog = Off/' -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/
( # Fix permissions
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 chown -R $UID:$GID /snappymail/data /var/log /var/lib/nginx
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 chmod o+w /dev/stdout
# Create snappymail admin password if absent chmod o+w /dev/stderr
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
wget -T 1 -qO- 'http://127.0.0.1:8888/' > /dev/null
echo "[INFO] Snappymail ready at http://localhost:8888/" # Touch supervisord PID file in order to fix permissions
) & touch /run/supervisord.pid
chown php-cli:php-cli /run/supervisord.pid
# RUN ! # RUN !
exec /usr/bin/supervisord -c /supervisor.conf --pidfile /run/supervisord.pid exec sudo -u php-cli -g php-cli /usr/bin/supervisord -c '/supervisor.conf' --pidfile '/run/supervisord.pid'

View file

@ -0,0 +1,5 @@
/snappymail/data/_data_/_default_/logs/* {
size 10M
rotate 0
missingok
}

View file

@ -11,7 +11,7 @@ http {
default_type application/octet-stream; default_type application/octet-stream;
access_log off; access_log off;
error_log /dev/stderr error; error_log /tmp/ngx_error.log error;
sendfile on; sendfile on;
keepalive_timeout 15; keepalive_timeout 15;
@ -52,8 +52,6 @@ http {
server { server {
listen 8888; listen 8888;
listen [::]:8888;
#listen [::]:8888 ipv6only=off;
root /snappymail; root /snappymail;
index index.php; index index.php;
charset utf-8; charset utf-8;
@ -97,7 +95,7 @@ http {
fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTP_PROXY ""; fastcgi_param HTTP_PROXY "";
fastcgi_index index.php; fastcgi_index index.php;
fastcgi_pass 127.0.0.1:9000; fastcgi_pass unix:/tmp/php-fpm.sock;
fastcgi_intercept_errors on; fastcgi_intercept_errors on;
fastcgi_request_buffering off; fastcgi_request_buffering off;
fastcgi_param REMOTE_ADDR $http_x_real_ip; fastcgi_param REMOTE_ADDR $http_x_real_ip;

View file

@ -0,0 +1,113 @@
#!/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}

View file

@ -1,3 +0,0 @@
<?php
define('APP_DATA_FOLDER_PATH', '/var/lib/snappymail/');
?>

View file

@ -1,13 +1,10 @@
[supervisord] [supervisord]
nodaemon=true nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
[program:nginx] [program:nginx]
command=nginx -c /etc/nginx/nginx.conf -g 'daemon off;' command=nginx -c /etc/nginx/nginx.conf -g 'daemon off;'
process_name=%(program_name)s_%(process_num)02d process_name=%(program_name)s_%(process_num)02d
user=root user=php-cli
numprocs=1 numprocs=1
autostart=true autostart=true
autorestart=false autorestart=false
@ -20,7 +17,7 @@ stderr_logfile_maxbytes=0
[program:php-fpm] [program:php-fpm]
command=php-fpm -F command=php-fpm -F
process_name=%(program_name)s_%(process_num)02d process_name=%(program_name)s_%(process_num)02d
user=root user=php-cli
numprocs=1 numprocs=1
autostart=true autostart=true
autorestart=false autorestart=false
@ -30,10 +27,33 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 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] [eventlistener:subprocess-stopped]
command=php /listener.php command=php /listener.php
process_name=%(program_name)s_%(process_num)02d process_name=%(program_name)s_%(process_num)02d
user=root user=php-cli
numprocs=1 numprocs=1
events=PROCESS_STATE_EXITED,PROCESS_STATE_STOPPED,PROCESS_STATE_FATAL events=PROCESS_STATE_EXITED,PROCESS_STATE_STOPPED,PROCESS_STATE_FATAL
autostart=true autostart=true

View file

@ -1,21 +1,15 @@
[global] [global]
daemonize = no daemonize = no
error_log = /dev/stderr
log_buffering = no
[default] [default]
listen = 9000 listen = /tmp/php-fpm.sock
user = www-data
listen.owner = www-data
listen.group = www-data
pm = ondemand pm = ondemand
pm.max_children = 30 pm.max_children = 30
pm.process_idle_timeout = 10s pm.process_idle_timeout = 10s
pm.max_requests = 500 pm.max_requests = 500
catch_workers_output = yes catch_workers_output = yes
decorate_workers_output = no
chdir = / chdir = /
pm.status_path = /status php_admin_value[error_log] = /tmp/php_error.log
php_admin_value[log_errors] = On php_admin_value[log_errors] = On
php_admin_value[expose_php] = Off php_admin_value[expose_php] = Off
php_admin_value[display_errors] = Off php_admin_value[display_errors] = Off

View file

@ -0,0 +1,309 @@
; 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]
; Enable CSRF protection (http://en.wikipedia.org/wiki/Cross-site_request_forgery)
csrf_protection = On
custom_server_signature = "SnappyMail"
x_xss_protection_header = "1; mode=block"
openpgp = Off
; Login and password for web admin panel
admin_login = "admin"
admin_password = ""
admin_totp = ""
; Access settings
allow_admin_panel = On
hide_x_mailer_header = On
admin_panel_host = ""
admin_panel_key = "admin"
; 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
dangerous_actions = On
message_actions = On
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
determine_user_language = On
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
[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
[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
[labs]
cache_system_data = On
date_from_headers = On
allow_message_append = Off
login_fault_delay = 1
log_ajax_response_write_limit = 300
allow_html_editor_biti_buttons = Off
allow_ctrl_enter_on_compose = On
try_to_detect_hidden_images = Off
use_app_debug_js = Off
use_app_debug_css = Off
smtp_show_server_errors = Off
sieve_auth_plain_initial = On
sieve_allow_fileinto_inbox = Off
mail_func_clear_headers = On
mail_func_additional_parameters = Off
folders_spec_limit = 50
curl_proxy = ""
curl_proxy_auth = ""
force_https = Off
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
check_new_messages = On
replace_env_in_configuration = ""
boundary_prefix = ""
dev_email = ""
dev_password = ""
[version]
current = "2.24.5"
saved = "Sun, 18 Dec 2022 22:10:48 +0000"

View file

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

View file

@ -1,29 +0,0 @@
# 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

View file

@ -1,10 +0,0 @@
#!/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

View file

@ -1,2 +0,0 @@
/.git
/node_modules

View file

@ -3,7 +3,7 @@ module.exports = {
// extends: ['eslint:recommended', 'plugin:prettier/recommended'], // extends: ['eslint:recommended', 'plugin:prettier/recommended'],
extends: ['eslint:recommended'], extends: ['eslint:recommended'],
parserOptions: { parserOptions: {
ecmaVersion: 11, ecmaVersion: 6,
sourceType: 'module' sourceType: 'module'
}, },
env: { env: {
@ -35,17 +35,10 @@ module.exports = {
// vendors/bootstrap/bootstrap.native.js // vendors/bootstrap/bootstrap.native.js
'BSN': "readonly", 'BSN': "readonly",
// Mailvelope // Mailvelope
'mailvelope': "readonly", 'mailvelope': "readonly"
// Punycode
'IDN': "readonly",
// Turndown
'TurndownService': "readonly",
// Marked
'marked': "readonly"
}, },
// http://eslint.org/docs/rules/ // http://eslint.org/docs/rules/
rules: { rules: {
'no-cond-assign': 0,
// plugins // plugins
'no-mixed-spaces-and-tabs': 'off', 'no-mixed-spaces-and-tabs': 'off',
'max-len': [ 'max-len': [

2
.github/FUNDING.yml vendored
View file

@ -1,2 +1,2 @@
github: the-djmaze community_bridge: SnappyMail
custom: ["https://www.paypal.me/thedjmaze", "https://snappymail.eu"] custom: ["https://www.paypal.me/thedjmaze", "https://snappymail.eu"]

View file

@ -28,11 +28,9 @@ If applicable, add screenshots to help explain your problem.
- IMAP daemon: [e.g. courier, dovecot] - IMAP daemon: [e.g. courier, dovecot]
- PHP version: - PHP version:
- SnappyMail Version: - SnappyMail Version:
- Mode: [e.g. standalone, nextcloud, cyberpanel, docker]
**Debug/logging information** **[Debug/logging information](https://github.com/the-djmaze/snappymail/wiki/FAQ#how-do-i-enable-logging)**
[Read here how to log](https://github.com/the-djmaze/snappymail/wiki/FAQ#how-do-i-enable-logging) Place them here (few lines) or as attachments (many lines)
- [ ] I've placed them here (few lines) or as attachments (many lines)
**Additional context** **Additional context**
Add any other context about the problem here. Add any other context about the problem here.

View file

@ -1,93 +0,0 @@
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

View file

@ -1,117 +0,0 @@
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

3
.gitignore vendored
View file

@ -17,7 +17,6 @@
/data /data
/MULTIPLY /MULTIPLY
/include.php /include.php
/issues
.idea/ .idea/
.env .env
/test /test
@ -25,8 +24,6 @@
/vendors/knockout/spec /vendors/knockout/spec
/vendors/openpgp-5 /vendors/openpgp-5
!/vendors/openpgp-5/dist !/vendors/openpgp-5/dist
/vendors/squire2
!/vendors/squire2/dist
/vendors/vanillaqr.js/ /vendors/vanillaqr.js/
/integrations/nextcloud/rainloop /integrations/nextcloud/rainloop
/integrations/owncloud/rainloop /integrations/owncloud/rainloop

4
.gitmodules vendored
View file

@ -1,7 +1,3 @@
[submodule "vendors/openpgp-5"] [submodule "vendors/openpgp-5"]
path = vendors/openpgp-5 path = vendors/openpgp-5
url = git@github.com:the-djmaze/openpgpjs.git url = git@github.com:the-djmaze/openpgpjs.git
[submodule "vendors/squire2"]
path = vendors/squire2
url = git@github.com:the-djmaze/Squire.git
branch = snappymail

View file

@ -1,15 +1,7 @@
<FilesMatch "index\.php">
AcceptPathInfo On
</FilesMatch>
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
# Redirect cPanel # Redirect cPanel
RewriteRule cpsess.* https://%{HTTP_HOST}/ [L,R=301] RewriteRule cpsess.* https://%{HTTP_HOST}/ [L,R=301]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.+)$ index.php/$1 [L,QSA]
</IfModule> </IfModule>
<IfModule mod_expires.c> <IfModule mod_expires.c>
@ -34,6 +26,7 @@
# Header set Strict-Transport-Security "max-age=31536000" # Header set Strict-Transport-Security "max-age=31536000"
Header set imagetoolbar "no" Header set imagetoolbar "no"
# Header set X-Content-Type-Options "nosniff" # Header set X-Content-Type-Options "nosniff"
# Header set X-Frame-Options "DENY"
# Header set X-XSS-Protection "1; mode=block" # Header set X-XSS-Protection "1; mode=block"
Header set Service-Worker-Allowed "/" Header set Service-Worker-Allowed "/"

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,7 @@
**Editing HTML Template Files** **Editing HTML Template Files**
1. Edit data/\_data_/\_default_/configs/application.ini 1. Edit data/\_data_/\_default_/configs/application.ini
2. Set `[cache] system_data` to Off 2. Set 'cache_system_data' to Off
**Release** **Release**

View file

@ -5,10 +5,6 @@
<br> <br>
<h1>SnappyMail</h1> <h1>SnappyMail</h1>
<br> <br>
[![github-actions](https://github.com/the-djmaze/snappymail/actions/workflows/docker.yml/badge.svg)](https://github.com/the-djmaze/snappymail/actions/workflows/docker.yml)
[![docker-image-size](https://img.shields.io/docker/image-size/djmaze/snappymail/latest)](https://hub.docker.com/r/djmaze/snappymail/tags)
<p> <p>
Simple, modern, lightweight &amp; fast web-based email client. Simple, modern, lightweight &amp; fast web-based email client.
</p> </p>
@ -30,7 +26,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). 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 whole [Wiki](https://github.com/the-djmaze/snappymail/wiki). And don't forget to read the [RainLoop documentation](https://www.rainloop.net/docs/).
## License ## License
@ -38,7 +34,7 @@ And don't forget to read the whole [Wiki](https://github.com/the-djmaze/snappyma
**GNU AFFERO GENERAL PUBLIC LICENSE Version 3 (AGPL)**. **GNU AFFERO GENERAL PUBLIC LICENSE Version 3 (AGPL)**.
http://www.gnu.org/licenses/agpl-3.0.html http://www.gnu.org/licenses/agpl-3.0.html
Copyright (c) 2020 - 2024 SnappyMail Copyright (c) 2020 - 2022 SnappyMail
Copyright (c) 2013 - 2022 RainLoop Copyright (c) 2013 - 2022 RainLoop
## Modifications ## Modifications
@ -49,7 +45,7 @@ This fork of RainLoop has the following changes:
* Admin uses password_hash/password_verify * Admin uses password_hash/password_verify
* Auth failed attempts written to syslog * Auth failed attempts written to syslog
* Added Fail2ban instructions * Added Fail2ban instructions
* ES2020 * ES2018
* PHP 7.4+ required * PHP 7.4+ required
* PHP mbstring extension required * PHP mbstring extension required
* PHP replaced pclZip with PharData and ZipArchive * PHP replaced pclZip with PharData and ZipArchive
@ -66,7 +62,7 @@ This fork of RainLoop has the following changes:
* CRLF => LF line endings * CRLF => LF line endings
* Embed boot.js and boot.css into index.html * Embed boot.js and boot.css into index.html
* Removal of old JavaScript code (things are native these days) * Removal of old JavaScript code (things are native these days)
* Added modified [Squire](https://github.com/the-djmaze/Squire/tree/snappymail) HTML editor as replacement for CKEditor * Added modified [Squire](https://github.com/neilj/Squire) HTML editor as replacement for CKEditor
* Updated [Sabre/VObject](https://github.com/sabre-io/vobject) * Updated [Sabre/VObject](https://github.com/sabre-io/vobject)
* Split Admin specific JavaScript code from User code * Split Admin specific JavaScript code from User code
* Split Sieve specific JavaScript code from User code * Split Sieve specific JavaScript code from User code
@ -83,7 +79,12 @@ This fork of RainLoop has the following changes:
* Added [Fetch Metadata Request Headers](https://www.w3.org/TR/fetch-metadata/) checks * Added [Fetch Metadata Request Headers](https://www.w3.org/TR/fetch-metadata/) checks
* Reduced excessive DOM size * Reduced excessive DOM size
* Support [Kolab groupware](https://kolab.org/) * Support [Kolab groupware](https://kolab.org/)
* Support many more [IMAP RFC's](https://snappymail.eu/comparison#IMAP) * 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 Sodium and OpenSSL for encryption * Support Sodium and OpenSSL for encryption
* Much better PGP support * Much better PGP support
@ -140,28 +141,28 @@ RainLoop 1.17 vs SnappyMail
|js/* |RainLoop |Snappy | |js/* |RainLoop |Snappy |
|--------------- |--------: |--------: | |--------------- |--------: |--------: |
|admin.js |2.170.153 | 84.925 | |admin.js |2.170.153 | 82.218 |
|app.js |4.207.787 | 447.263 | |app.js |4.207.787 | 412.596 |
|boot.js | 868.735 | 4.343 | |boot.js | 868.735 | 2.427 |
|libs.js | 658.812 | 233.728 | |libs.js | 658.812 | 191.301 |
|sieve.js | 0 | 91.418 | |sieve.js | 0 | 85.960 |
|polyfills.js | 334.608 | 0 | |polyfills.js | 334.608 | 0 |
|serviceworker.js | 0 | 285 | |serviceworker.js | 0 | 285 |
|TOTAL |8.240.095 | 861.962 | |TOTAL |8.240.095 | 774.787 |
|js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli | |js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|--------------- |--------: |--------: |------: |------: |--------: |--------: | |--------------- |--------: |--------: |------: |------: |--------: |--------: |
|admin.min.js | 256.831 | 41.719 | 73.606 | 14.022 | 60.877 | 12.567 | |admin.min.js | 256.831 | 40.756 | 73.606 | 13.419 | 60.877 | 12.018 |
|app.min.js | 515.367 | 202.101 |139.456 | 68.505 |110.485 | 58.481 | |app.min.js | 515.367 | 190.149 |139.456 | 63.499 |110.485 | 54.471 |
|boot.min.js | 84.659 | 2.231 | 26.998 | 1.271 | 23.643 | 1.067 | |boot.min.js | 84.659 | 1.518 | 26.998 | 919 | 23.643 | 747 |
|libs.min.js | 584.772 | 110.646 |180.901 | 39.518 |155.182 | 35.207 | |libs.min.js | 584.772 | 90.768 |180.901 | 33.615 |155.182 | 30.170 |
|sieve.min.js | 0 | 45.504 | 0 | 11.131 | 0 | 9.917 | |sieve.min.js | 0 | 41.905 | 0 | 10.477 | 0 | 9.456 |
|polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 | |polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 |
|TOTAL user |1.217.635 | 314.978 |358.761 |109.294 |299.485 | 94.755 | |TOTAL user |1.217.635 | 282.435 |358.761 | 98.033 |299.485 | 85.388 |
|TOTAL user+sieve |1.217.635 | 360.482 |358.761 |120.425 |299.485 |104.672 | |TOTAL user+sieve |1.217.635 | 324.340 |358.761 |108.510 |299.485 | 94.844 |
|TOTAL admin | 959.099 | 154.596 |292.911 | 54.811 |249.877 | 48.841 | |TOTAL admin | 959.099 | 133.042 |292.911 | 47.953 |249.877 | 42.935 |
For a user it is around 66% smaller and faster than traditional RainLoop. For a user its around 70% smaller and faster than traditional RainLoop.
### CSS changes ### CSS changes
@ -188,12 +189,12 @@ For a user it is around 66% smaller and faster than traditional RainLoop.
|css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli | |css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli |
|------------ |-------: |------: |------: |------: |--------: | |------------ |-------: |------: |------: |------: |--------: |
|app.css | 340.331 | 85.073 | 46.946 | 17.792 | 15.210 | |app.css | 340.331 | 82.794 | 46.946 | 17.185 | 14.735 |
|app.min.css | 274.947 | 68.272 | 39.647 | 15.615 | 13.636 | |app.min.css | 274.947 | 66.702 | 39.647 | 15.146 | 13.290 |
|boot.css | | 1.326 | | 664 | 545 | |boot.css | | 1.326 | | 664 | 545 |
|boot.min.css | | 1.071 | | 590 | 474 | |boot.min.css | | 1.071 | | 590 | 474 |
|admin.css | | 30.880 | | 7.045 | 6.127 | |admin.css | | 30.281 | | 6.906 | 5.999 |
|admin.min.css | | 24.959 | | 6.368 | 5.615 | |admin.min.css | | 24.493 | | 6.233 | 5.501 |
### PGP ### PGP
RainLoop uses the old OpenPGP.js v2 RainLoop uses the old OpenPGP.js v2
@ -207,7 +208,7 @@ See https://github.com/the-djmaze/openpgpjs for development
|OpenPGP |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli | |OpenPGP |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|--------------- |--------: |--------: |------: |-------: |--------: |--------: | |--------------- |--------: |--------: |------: |-------: |--------: |--------: |
|openpgp.min.js | 330.742 | 546.165 |102.388 | 169.207 | 84.241 | 138.688 | |openpgp.min.js | 330.742 | 539.988 |102.388 | 167.371 | 84.241 | 137.413 |
|openpgp.worker | 1.499 | | 824 | | 695 | | |openpgp.worker | 1.499 | | 824 | | 695 | |
@ -217,6 +218,7 @@ The [Squire](https://github.com/neilj/Squire) implementation is not 100% compati
Still TODO: Still TODO:
* support for tables (really needed?!?) * support for tables (really needed?!?)
* support BIDI (really needed?!?)
| | normal | min | gzip | min gzip | | | normal | min | gzip | min gzip |
|-------- |-------: |-------: |------: |--------: | |-------- |-------: |-------: |------: |--------: |
@ -224,3 +226,5 @@ Still TODO:
|ckeditor | ? | 520.035 | ? | 155.916 | |ckeditor | ? | 520.035 | ? | 155.916 |
CKEditor including the 7 asset requests (css,language,plugins,icons) is 633.46 KB / 180.47 KB (gzip). 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.

View file

@ -6,15 +6,15 @@ Currently due to the fast development only the latest version receives security
| Version | Supported | | Version | Supported |
| -------- | --------- | | -------- | --------- |
| 2.34.x | ✔ | | 2.13.x | ✔ |
| < 2.34.0 | ❌ | | < 2.13.0 | ❌ |
## Reporting a Vulnerability ## Reporting a Vulnerability
Please report security issues or vulnerabilities as an encrypted email to [security@snappymail.eu](mailto:security@snappymail.eu). 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. 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 [keys.openpgp.org](https://keys.openpgp.org). You can find the PGP public key below and on the major public keyservers like [pgp.key-server.io](https://pgp.key-server.io).
``` ```
-----BEGIN PGP PUBLIC KEY BLOCK----- -----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: Type: 255-bit EdDSA Comment: Type: 255-bit EdDSA
@ -36,5 +36,5 @@ YMXQVq4SJt7UpWABAORudfJxsBqCRKtPlZMgGTJLjcOkyFJ9C2Fx7DeN0J4I
## Publishing and Credits ## Publishing and Credits
I will analyze and fix the reported issue as fast as possible. I will analyze and fix the reported issue as fast as possible.
Together with the reporter, I plan the disclosure of the found and fixed vulnerability. Together with the reporter I plan the disclosure of the found and fixed vulnerability.
Credits to the reporter are granted and can be included in all public communication if desired. Credits to the reporter are granted and can be included in all public communication if desired.

View file

@ -4,6 +4,16 @@
//header('Strict-Transport-Security: max-age=31536000'); //header('Strict-Transport-Security: max-age=31536000');
/**
* Uncomment to use gzip compressed output
*/
//define('USE_GZIP', 1);
/**
* Uncomment to use brotli compressed output
*/
//define('USE_BROTLI', 1);
/** /**
* Uncomment to enable multiple domain installation. * Uncomment to enable multiple domain installation.
*/ */

50
assets/.htaccess Normal file
View file

@ -0,0 +1,50 @@
<IfModule mod_headers.c>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP:Accept-encoding} br
RewriteCond "%{REQUEST_FILENAME}\.br" -s
RewriteRule "^(.+\.js)$" "$1\.br" [L,T=application/javascript,QSA]
RewriteCond %{HTTP:Accept-encoding} gzip
RewriteCond "%{REQUEST_FILENAME}\.gz" -s
RewriteRule "^(.+\.js)$" "$1\.gz" [L,T=application/javascript,QSA]
RewriteCond %{HTTP:Accept-encoding} br
RewriteCond "%{REQUEST_FILENAME}\.br" -s
RewriteRule "^(.+\.css)$" "$1\.br" [L,T=text/css,QSA]
RewriteCond %{HTTP:Accept-encoding} gzip
RewriteCond "%{REQUEST_FILENAME}\.gz" -s
RewriteRule "^(.+\.css)$" "$1\.gz" [L,T=text/css,QSA]
<FilesMatch "\.(js|css)\.br$">
SetEnv no-gzip 1
SetEnv no-brotli 1
# Serve correct encoding type.
Header append Content-Encoding br
# Force proxies to cache brotli &
# non-brotli css/js files separately.
Header append Vary Accept-Encoding
</FilesMatch>
<FilesMatch "\.(js|css)\.gz$">
SetEnv no-gzip 1
SetEnv no-brotli 1
# Serve correct encoding type.
Header append Content-Encoding gzip
# Force proxies to cache gzipped &
# non-gzipped css/js files separately.
Header append Vary Accept-Encoding
</FilesMatch>
<FilesMatch "\.js\.(br|gz)$">
Header set Content-Type "application/javascript; charset=utf-8"
ForceType application/javascript
</FilesMatch>
<FilesMatch "\.css\.(br|gz)$">
Header set Content-Type "text/css; charset=utf-8"
ForceType text/css
</FilesMatch>
</IfModule>
</IfModule>

View file

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 992 B

After

Width:  |  Height:  |  Size: 992 B

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 866 B

After

Width:  |  Height:  |  Size: 866 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 335 B

After

Width:  |  Height:  |  Size: 335 B

Before After
Before After

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 B

View file

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

View file

@ -29,11 +29,15 @@ $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('snappymai
foreach ($files as $file) { foreach ($files as $file) {
if (is_file($file)) { if (is_file($file)) {
$newFile = str_replace('\\', '/', $file); $newFile = str_replace('\\', '/', $file);
$newName = str_replace('/.htaccess', '/_htaccess', $newFile); // $newFile = str_replace("'snappymail/v/'.", '', $newFile);
$nc_tar->addFile($file, "snappymail/app/{$newName}"); $nc_tar->addFile($file, "snappymail/app/{$newFile}");
$hashes["app/{$newFile}"] = hash_file('sha512', $file); $hashes["app/{$newFile}"] = hash_file('sha512', $file);
} }
} }
$nc_tar->delete("snappymail/app/snappymail/v/{$package->version}/app/.htaccess");
$nc_tar->addFile("snappymail/v/{$package->version}/app/.htaccess", "snappymail/app/snappymail/v/{$package->version}/app/_htaccess");
$nc_tar->delete("snappymail/app/snappymail/v/{$package->version}/static/.htaccess");
$nc_tar->addFile("snappymail/v/{$package->version}/static/.htaccess", "snappymail/app/snappymail/v/{$package->version}/static/_htaccess");
/* /*
$nc_tar->addFile('data/.htaccess'); $nc_tar->addFile('data/.htaccess');

View file

@ -28,46 +28,10 @@ $keys = [
'url', 'url',
'version' '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) { foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
if (is_file("{$dir}/index.php") && !strpos($dir, '.bak')) { if (is_file("{$dir}/index.php") && !strpos($dir, '.bak')) {
require "{$dir}/index.php"; require "{$dir}/index.php";
$name = basename($dir); $name = basename($dir);
// if (!in_array($name, $released)) continue;
$class = new ReflectionClass(str_replace('-', '', $name) . 'Plugin'); $class = new ReflectionClass(str_replace('-', '', $name) . 'Plugin');
$manifest_item = []; $manifest_item = [];
foreach ($class->getConstants() as $key => $value) { foreach ($class->getConstants() as $key => $value) {

View file

@ -9,7 +9,7 @@ if (isset($options['plugins'])) {
require(ROOT_DIR . '/build/plugins.php'); require(ROOT_DIR . '/build/plugins.php');
} }
$gulp = trim(shell_exec('which gulp')); $gulp = trim(`which gulp`);
if (!$gulp) { if (!$gulp) {
exit('gulp not installed, run as root: npm install --global gulp-cli'); exit('gulp not installed, run as root: npm install --global gulp-cli');
} }
@ -24,6 +24,9 @@ $file = ROOT_DIR . '/integrations/cloudron/Dockerfile';
file_put_contents($file, preg_replace('/VERSION=[0-9.]+/', "VERSION={$package->version}", file_get_contents($file))); file_put_contents($file, preg_replace('/VERSION=[0-9.]+/', "VERSION={$package->version}", file_get_contents($file)));
$file = ROOT_DIR . '/integrations/cloudron/DESCRIPTION.md'; $file = ROOT_DIR . '/integrations/cloudron/DESCRIPTION.md';
file_put_contents($file, preg_replace('/<upstream>[^<]*</', "<upstream>{$package->version}<", file_get_contents($file))); 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 // virtualmin
$file = ROOT_DIR . '/integrations/virtualmin/snappymail.pl'; $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))); file_put_contents($file, preg_replace('/return \\( "[0-9]+\\.[0-9]+\\.[0-9]+" \\)/', "return ( \"{$package->version}\" )", file_get_contents($file)));
@ -34,7 +37,7 @@ $options['aur'] = isset($options['aur']);
// Docker build // Docker build
$options['docker'] = isset($options['docker']); $options['docker'] = isset($options['docker']);
if ($options['docker'] && !($docker = trim(shell_exec('which docker')))) { if ($options['docker'] && !($docker = trim(`which docker`))) {
exit('docker not found'); exit('docker not found');
} }
if ($options['docker'] && $options['aur']) { if ($options['docker'] && $options['aur']) {
@ -60,7 +63,7 @@ if (!isset($options['skip-gulp'])) {
$cmddir = escapeshellcmd(ROOT_DIR) . '/snappymail/v/0.0.0/static'; $cmddir = escapeshellcmd(ROOT_DIR) . '/snappymail/v/0.0.0/static';
if ($gzip = trim(shell_exec('which gzip'))) { if ($gzip = trim(`which gzip`)) {
echo "\x1b[33;1m === Gzip *.js and *.css === \x1b[0m\n"; echo "\x1b[33;1m === Gzip *.js and *.css === \x1b[0m\n";
passthru("{$gzip} -k --best {$cmddir}/js/*.js"); passthru("{$gzip} -k --best {$cmddir}/js/*.js");
passthru("{$gzip} -k --best {$cmddir}/js/min/*.js"); passthru("{$gzip} -k --best {$cmddir}/js/min/*.js");
@ -70,7 +73,7 @@ if (!isset($options['skip-gulp'])) {
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/min/boot.min.js.gz'); unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/min/boot.min.js.gz');
} }
if ($brotli = trim(shell_exec('which brotli'))) { if ($brotli = trim(`which brotli`)) {
echo "\x1b[33;1m === Brotli *.js and *.css === \x1b[0m\n"; echo "\x1b[33;1m === Brotli *.js and *.css === \x1b[0m\n";
passthru("{$brotli} -k --best {$cmddir}/js/*.js"); passthru("{$brotli} -k --best {$cmddir}/js/*.js");
passthru("{$brotli} -k --best {$cmddir}/js/min/*.js"); passthru("{$brotli} -k --best {$cmddir}/js/min/*.js");
@ -157,6 +160,11 @@ $tar->addFromString('index.php', $index);
$zip->addFile('README.md'); $zip->addFile('README.md');
$tar->addFile('README.md'); $tar->addFile('README.md');
$data = file_get_contents('dev/serviceworker.js');
//$data = file_get_contents('snappymail/v/0.0.0/static/js/min/serviceworker.min.js');
$zip->addFromString('serviceworker.js', $data);
$tar->addFromString('serviceworker.js', $data);
$zip->close(); $zip->close();
$tar->compress(Phar::GZ); $tar->compress(Phar::GZ);
@ -188,7 +196,7 @@ file_put_contents("{$destPath}core.json", '{
// Arch User Repository // Arch User Repository
if ($options['aur']) { if ($options['aur']) {
// extension_loaded('blake2') // extension_loaded('blake2')
if (!function_exists('b2sum') && $b2sum = trim(shell_exec('which b2sum'))) { if (!function_exists('b2sum') && $b2sum = trim(`which b2sum`)) {
function b2sum($file) { function b2sum($file) {
$file = escapeshellarg($file); $file = escapeshellarg($file);
exec("b2sum --binary {$file} 2>&1", $output, $exitcode); exec("b2sum --binary {$file} 2>&1", $output, $exitcode);

View file

@ -3,10 +3,8 @@ import ko from 'ko';
import { logoutLink } from 'Common/Links'; import { logoutLink } from 'Common/Links';
import { i18nToNodes, initOnStartOrLangChange } from 'Common/Translator'; import { i18nToNodes, initOnStartOrLangChange } from 'Common/Translator';
import { arePopupsVisible } from 'Knoin/Knoin';
import { LanguageStore } from 'Stores/Language'; import { LanguageStore } from 'Stores/Language';
import { initThemes } from 'Stores/Theme'; import { ThemeStore } from 'Stores/Theme';
import { SelectComponent } from 'Component/Select'; import { SelectComponent } from 'Component/Select';
import { CheckboxComponent } from 'Component/Checkbox'; import { CheckboxComponent } from 'Component/Checkbox';
@ -20,7 +18,6 @@ export class AbstractApp {
} }
logoutReload(url) { logoutReload(url) {
arePopupsVisible(false);
url = url || logoutLink(); url = url || logoutLink();
if (location.href !== url) { if (location.href !== url) {
setTimeout(() => location.href = url, 100); setTimeout(() => location.href = url, 100);
@ -49,7 +46,7 @@ export class AbstractApp {
initOnStartOrLangChange(); initOnStartOrLangChange();
LanguageStore.populate(); LanguageStore.populate();
initThemes(); ThemeStore.populate();
this.start(); this.start();
} }

View file

@ -1,7 +1,7 @@
import 'External/ko'; import 'External/ko';
import { SettingsGet, SettingsAdmin } from 'Common/Globals'; import { Settings, SettingsGet } from 'Common/Globals';
import { initThemes } from 'Stores/Theme'; import { ThemeStore } from 'Stores/Theme';
import Remote from 'Remote/Admin/Fetch'; import Remote from 'Remote/Admin/Fetch';
@ -11,8 +11,6 @@ import { LoginAdminScreen } from 'Screen/Admin/Login';
import { startScreens } from 'Knoin/Knoin'; import { startScreens } from 'Knoin/Knoin';
import { AbstractApp } from 'App/Abstract'; import { AbstractApp } from 'App/Abstract';
import { AskPopupView } from 'View/Popup/Ask';
export class AdminApp extends AbstractApp { export class AdminApp extends AbstractApp {
constructor() { constructor() {
super(Remote); super(Remote);
@ -20,33 +18,19 @@ export class AdminApp extends AbstractApp {
} }
refresh() { refresh() {
initThemes(); ThemeStore.populate();
this.start(); this.start();
} }
start() { start() {
// if (!Settings.app('adminAllowed')) { if (!Settings.app('adminAllowed')) {
if (!SettingsAdmin('allowed')) {
rl.route.root(); rl.route.root();
setTimeout(() => location.href = '/', 1); setTimeout(() => location.href = '/', 1);
} else if (SettingsGet('Auth')) { } else if (SettingsGet('Auth')) {
this.weakPassword(SettingsGet('weakPassword')); this.weakPassword(SettingsGet('WeakPassword'));
startScreens([SettingsAdminScreen]); startScreens([SettingsAdminScreen]);
} else { } else {
startScreens([LoginAdminScreen]); startScreens([LoginAdminScreen]);
} }
} }
} }
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
]);
});
};

View file

@ -1,8 +1,8 @@
import 'External/User/ko'; import 'External/User/ko';
import { SMAudio } from 'Common/Audio'; import { SMAudio } from 'Common/Audio';
import { arrayLength } from 'Common/Utils'; import { isArray, pString } from 'Common/Utils';
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser'; import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility } from 'Common/UtilsUser';
import { import {
FolderType, FolderType,
@ -27,22 +27,25 @@ import {
getFolderFromCacheList getFolderFromCacheList
} from 'Common/Cache'; } from 'Common/Cache';
import { i18n, reloadTime, getErrorMessage } from 'Common/Translator'; import { i18n, reloadTime } from 'Common/Translator';
import { SettingsUserStore } from 'Stores/User/Settings'; import { SettingsUserStore } from 'Stores/User/Settings';
import { NotificationUserStore } from 'Stores/User/Notification'; import { NotificationUserStore } from 'Stores/User/Notification';
import { AccountUserStore } from 'Stores/User/Account'; import { AccountUserStore } from 'Stores/User/Account';
import { ContactUserStore } from 'Stores/User/Contact'; import { ContactUserStore } from 'Stores/User/Contact';
import { IdentityUserStore } from 'Stores/User/Identity';
import { FolderUserStore } from 'Stores/User/Folder'; import { FolderUserStore } from 'Stores/User/Folder';
import { PgpUserStore } from 'Stores/User/Pgp'; import { PgpUserStore } from 'Stores/User/Pgp';
import { SMimeUserStore } from 'Stores/User/SMime';
import { MessagelistUserStore } from 'Stores/User/Messagelist'; import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { ThemeStore, initThemes } from 'Stores/Theme'; import { ThemeStore, changeTheme } from 'Stores/Theme';
import { LanguageStore } from 'Stores/Language'; import { LanguageStore } from 'Stores/Language';
import { MessageUserStore } from 'Stores/User/Message'; import { MessageUserStore } from 'Stores/User/Message';
import Remote from 'Remote/User/Fetch'; import Remote from 'Remote/User/Fetch';
import { AccountModel } from 'Model/Account';
import { IdentityModel } from 'Model/Identity';
import { LoginUserScreen } from 'Screen/User/Login'; import { LoginUserScreen } from 'Screen/User/Login';
import { MailBoxUserScreen } from 'Screen/User/MailBox'; import { MailBoxUserScreen } from 'Screen/User/MailBox';
import { SettingsUserScreen } from 'Screen/User/Settings'; import { SettingsUserScreen } from 'Screen/User/Settings';
@ -58,7 +61,9 @@ import { AskPopupView } from 'View/Popup/Ask';
import { import {
folderInformation, folderInformation,
folderInformationMultiply, folderInformationMultiply,
setRefreshFoldersInterval refreshFoldersInterval,
messagesMoveHelper,
messagesDeleteHelper
} from 'Common/Folders'; } from 'Common/Folders';
import { loadFolders } from 'Model/FolderCollection'; import { loadFolders } from 'Model/FolderCollection';
@ -74,7 +79,7 @@ export class AppUser extends AbstractApp {
(currentTime > (lastTime + interval + 1000)) (currentTime > (lastTime + interval + 1000))
&& Remote.request('Version', && Remote.request('Version',
iError => (100 < iError) && location.reload(), iError => (100 < iError) && location.reload(),
{ version: Settings.app('version') } { Version: Settings.app('version') }
); );
lastTime = currentTime; lastTime = currentTime;
}, interval); }, interval);
@ -85,11 +90,6 @@ export class AppUser extends AbstractApp {
addEventListener('click', dropdownsDetectVisibility); addEventListener('click', dropdownsDetectVisibility);
this.folderList = FolderUserStore.folderList; this.folderList = FolderUserStore.folderList;
this.messageList = MessagelistUserStore;
this.ask = AskPopupView;
this.loadAccountsAndIdentities = loadAccountsAndIdentities;
} }
/** /**
@ -114,7 +114,7 @@ export class AppUser extends AbstractApp {
case FolderType.Trash: case FolderType.Trash:
oMoveFolder = getFolderFromCacheList(FolderUserStore.trashFolder()); oMoveFolder = getFolderFromCacheList(FolderUserStore.trashFolder());
nSetSystemFoldersNotification = iFolderType; nSetSystemFoldersNotification = iFolderType;
bDelete = bDelete/* || UNUSED_OPTION_VALUE === FolderUserStore.trashFolder()*/ bDelete = bDelete || UNUSED_OPTION_VALUE === FolderUserStore.trashFolder()
|| sFromFolderFullName === FolderUserStore.spamFolder() || sFromFolderFullName === FolderUserStore.spamFolder()
|| sFromFolderFullName === FolderUserStore.trashFolder(); || sFromFolderFullName === FolderUserStore.trashFolder();
break; break;
@ -126,20 +126,58 @@ export class AppUser extends AbstractApp {
// no default // no default
} }
if (bDelete) { if (!oMoveFolder && !bDelete) {
showScreenPopup(FolderSystemPopupView, [nSetSystemFoldersNotification]);
} else if (bDelete) {
showScreenPopup(AskPopupView, [ showScreenPopup(AskPopupView, [
i18n('POPUPS_ASK/DESC_WANT_DELETE_MESSAGES'), i18n('POPUPS_ASK/DESC_WANT_DELETE_MESSAGES'),
() => { () => {
MessagelistUserStore.moveMessages(sFromFolderFullName, oUids); messagesDeleteHelper(sFromFolderFullName, oUids);
MessagelistUserStore.removeMessagesFromList(sFromFolderFullName, oUids);
} }
]); ]);
} else if (oMoveFolder) { } else if (oMoveFolder) {
MessagelistUserStore.moveMessages(sFromFolderFullName, oUids, oMoveFolder.fullName); messagesMoveHelper(sFromFolderFullName, oMoveFolder.fullName, oUids);
} else { MessagelistUserStore.removeMessagesFromList(sFromFolderFullName, oUids, oMoveFolder.fullName);
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 => {
const identity = new IdentityModel(
pString(identityData.Id),
pString(identityData.Email)
);
identity.name(pString(identityData.Name));
identity.replyTo(pString(identityData.ReplyTo));
identity.bcc(pString(identityData.Bcc));
identity.signature(pString(identityData.Signature));
identity.signatureInsertBefore(!!identityData.SignatureInsertBefore);
return identity;
})
: []
);
}
});
}
/** /**
* @param {string} folder * @param {string} folder
* @param {Array=} list = [] * @param {Array=} list = []
@ -149,26 +187,25 @@ export class AppUser extends AbstractApp {
} }
logout() { logout() {
Remote.request('Logout', (iError, data) => Remote.request('Logout', () => rl.logoutReload(Settings.app('customLogoutLink')));
iError ? alert('Logout error: ' + getErrorMessage(iError, data))
: rl.logoutReload(Settings.app('customLogoutLink'))
);
} }
bootstart() { bootstart() {
super.bootstart(); super.bootstart();
addEventListener('resize', () => leftPanelDisabled(ThemeStore.isMobile() || 1000 > innerWidth));
addEventListener('beforeunload', event => { addEventListener('beforeunload', event => {
if (arePopupsVisible() || (!SettingsUserStore.usePreviewPane() && MessageUserStore.message())) { if (arePopupsVisible() || (ThemeStore.isMobile() && MessageUserStore.message())) {
event.preventDefault(); event.preventDefault();
return event.returnValue = i18n('POPUPS_ASK/EXIT_ARE_YOU_SURE'); return event.returnValue = "Are you sure you want to exit?";
} }
}, {capture: true}); }, {capture: true});
} }
refresh() { refresh() {
initThemes(); ThemeStore.populate();
LanguageStore.language(SettingsGet('language')); LanguageStore.language(SettingsGet('Language'));
changeTheme(SettingsGet('Theme'));
this.start(); this.start();
} }
@ -184,17 +221,25 @@ export class AppUser extends AbstractApp {
SettingsUserStore.init(); SettingsUserStore.init();
ContactUserStore.init(); ContactUserStore.init();
loadFolders((success, error) => { loadFolders(value => {
try { try {
if (success) { if (value) {
startScreens([ startScreens([
MailBoxUserScreen, MailBoxUserScreen,
SettingsUserScreen SettingsUserScreen
]); ]);
setRefreshFoldersInterval(SettingsGet('CheckMailInterval')); setInterval(() => {
const cF = FolderUserStore.currentFolderFullName(),
iF = getFolderInboxName();
folderInformation(iF);
iF === cF || folderInformation(cF);
folderInformationMultiply();
}, refreshFoldersInterval);
loadAccountsAndIdentities(); ContactUserStore.init();
this.accountsAndIdentities();
setTimeout(() => { setTimeout(() => {
const cF = FolderUserStore.currentFolderFullName(); const cF = FolderUserStore.currentFolderFullName();
@ -211,29 +256,38 @@ export class AppUser extends AbstractApp {
// initLeftSideLayoutResizer // initLeftSideLayoutResizer
setTimeout(() => { setTimeout(() => {
const left = elementById('rl-left'), const left = elementById('rl-left'),
right = elementById('rl-right'),
fToggle = () => fToggle = () =>
setLayoutResizer(left, ClientSideKeyNameFolderListSize, setLayoutResizer(left, right, ClientSideKeyNameFolderListSize,
(ThemeStore.isMobile() || leftPanelDisabled()) ? 0 : 'Width'); (ThemeStore.isMobile() || leftPanelDisabled()) ? 0 : 'Width');
if (left) { if (left && right) {
fToggle(); fToggle();
leftPanelDisabled.subscribe(fToggle); leftPanelDisabled.subscribe(fToggle);
} }
}, 1); }, 1);
setInterval(reloadTime, 60000); setInterval(reloadTime(), 60000);
PgpUserStore.init(); PgpUserStore.init();
SMimeUserStore.loadCertificates();
setTimeout(() => mailToHelper(SettingsGet('mailToEmail')), 500); // When auto-login is active
try {
navigator.registerProtocolHandler?.(
'mailto',
location.protocol + '//' + location.host + location.pathname + '?mailto&to=%s',
(SettingsGet('Title') || 'SnappyMail')
);
} catch (e) {} // eslint-disable-line no-empty
setTimeout(() => mailToHelper(SettingsGet('MailToEmail')), 500);
} else { } else {
this.logout(); this.logout();
alert('Folders error: ' + getErrorMessage(0, error))
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
}); });
} else { } else {
startScreens([LoginUserScreen]); startScreens([LoginUserScreen]);
} }
@ -241,62 +295,6 @@ export class AppUser extends AbstractApp {
showMessageComposer(params = []) showMessageComposer(params = [])
{ {
let msg = params[1]; showScreenPopup(ComposePopupView, params);
if (1 == arrayLength(msg)) {
msg = msg[0];
}
if (msg) {
msg.decrypt().then((/*success*/)=>showScreenPopup(ComposePopupView, params));
} else {
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(),
rememberPermanent: view.rememberPermanent(),
}),
() => 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();
});

View file

@ -116,7 +116,6 @@ export const SMAudio = new class {
if ('running' == audioCtx.state && (this.supportedMp3 || this.supportedOgg)) { if ('running' == audioCtx.state && (this.supportedMp3 || this.supportedOgg)) {
notificator = notificator || createNewObject(); notificator = notificator || createNewObject();
if (notificator) { if (notificator) {
// SettingsGet('NotificationSound').startsWith('custom@')
notificator.src = Links.staticLink('sounds/' notificator.src = Links.staticLink('sounds/'
+ SettingsGet('NotificationSound') + SettingsGet('NotificationSound')
+ (this.supportedMp3 ? '.mp3' : '.ogg')); + (this.supportedMp3 ? '.mp3' : '.ogg'));

View file

@ -1,5 +1,9 @@
let FOLDERS_CACHE = new Map, import { MessageSetAction } from 'Common/EnumsUser';
FOLDERS_HASH_MAP = new Map, import { isArray } from 'Common/Utils';
let FOLDERS_CACHE = {},
FOLDERS_NAME_CACHE = {},
MESSAGE_FLAGS_CACHE = {},
inboxFolderName = 'INBOX'; inboxFolderName = 'INBOX';
export const export const
@ -7,10 +11,18 @@ export const
* @returns {void} * @returns {void}
*/ */
clearCache = () => { clearCache = () => {
FOLDERS_CACHE.clear(); FOLDERS_CACHE = {};
FOLDERS_HASH_MAP.clear(); FOLDERS_NAME_CACHE = {};
MESSAGE_FLAGS_CACHE = {};
}, },
/**
* @param {string} folderFullName
* @param {string} uid
* @returns {string}
*/
getMessageKey = (folderFullName, uid) => folderFullName + '#' + uid,
/** /**
* @returns {string} * @returns {string}
*/ */
@ -22,35 +34,143 @@ export const
setFolderInboxName = name => inboxFolderName = name, setFolderInboxName = name => inboxFolderName = name,
/** /**
* @param {string} fullNameHash * @param {string} folderHash
* @returns {string} * @returns {string}
*/ */
getFolderFromHashMap = fullNameHash => getFolderFromCacheList(FOLDERS_HASH_MAP.get(fullNameHash)), getFolderFullName = folderHash => (folderHash && FOLDERS_NAME_CACHE[folderHash]) || '',
/** /**
* @param {string} folderHash
* @param {string} folderFullName
* @param {?FolderModel} folder * @param {?FolderModel} folder
*/ */
setFolder = folder => { setFolder = folder => {
folder.etag = ''; folder.hash = '';
FOLDERS_CACHE.set(folder.fullName, folder); FOLDERS_CACHE[folder.fullName] = folder;
FOLDERS_HASH_MAP.set(folder.fullNameHash, folder.fullName); FOLDERS_NAME_CACHE[folder.fullNameHash] = folder.fullName;
}, },
/** /**
* @param {string} folderFullName * @param {string} folderFullName
* @param {string} folderETag * @param {string} folderHash
*/ */
setFolderETag = (folderFullName, folderETag) => setFolderHash = (folderFullName, folderHash) =>
FOLDERS_CACHE.has(folderFullName) && (FOLDERS_CACHE.get(folderFullName).etag = folderETag), FOLDERS_CACHE[folderFullName] && (FOLDERS_CACHE[folderFullName].hash = folderHash),
/** /**
* @param {string} folderFullName * @param {string} folderFullName
* @returns {?FolderModel} * @returns {?FolderModel}
*/ */
getFolderFromCacheList = folderFullName => getFolderFromCacheList = folderFullName =>
FOLDERS_CACHE.get(folderFullName), FOLDERS_CACHE[folderFullName] || null,
/** /**
* @param {string} folderFullName * @param {string} folderFullName
*/ */
removeFolderFromCacheList = folderFullName => FOLDERS_CACHE.delete(folderFullName); removeFolderFromCacheList = folderFullName => delete FOLDERS_CACHE[folderFullName];
export class MessageFlagsCache
{
/**
* @param {string} folderFullName
* @param {string} uid
* @param {string} flag
* @returns {bool}
*/
static hasFlag(folderFullName, uid, flag) {
return MESSAGE_FLAGS_CACHE[folderFullName]?.[uid]?.includes(flag);
}
/**
* @param {string} folderFullName
* @param {string} uid
* @returns {?Array}
*/
static getFor(folderFullName, uid) {
return MESSAGE_FLAGS_CACHE[folderFullName]?.[uid];
}
/**
* @param {string} folderFullName
* @param {string} uid
* @param {Array} flagsCache
*/
static setFor(folderFullName, uid, flags) {
if (isArray(flags)) {
if (!MESSAGE_FLAGS_CACHE[folderFullName]) {
MESSAGE_FLAGS_CACHE[folderFullName] = {};
}
MESSAGE_FLAGS_CACHE[folderFullName][uid] = flags;
}
}
/**
* @param {string} folderFullName
*/
static clearFolder(folderFullName) {
MESSAGE_FLAGS_CACHE[folderFullName] = {};
}
/**
* @param {(MessageModel|null)} message
*/
static initMessage(message) {
if (message) {
const uid = message.uid,
flags = this.getFor(message.folder, uid),
thread = message.threads();
isArray(flags) && message.flags(flags);
if (thread.length) {
message.hasUnseenSubMessage(!!thread.find(iSubUid =>
(uid !== iSubUid) && !this.hasFlag(message.folder, iSubUid, '\\seen')
));
message.hasFlaggedSubMessage(!!thread.find(iSubUid =>
(uid !== iSubUid) && this.hasFlag(message.folder, iSubUid, '\\flagged')
));
}
}
}
/**
* @param {(MessageModel|null)} message
*/
static store(message) {
message && this.setFor(message.folder, message.uid, message.flags());
}
/**
* @param {string} folder
* @param {string} uid
* @param {number} setAction
*/
static storeBySetAction(folder, uid, setAction) {
let flags = this.getFor(folder, uid) || [];
const
unread = flags.includes('\\seen') ? 0 : 1,
add = item => flags.includes(item) || flags.push(item),
remove = item => flags = flags.filter(flag => flag != item);
switch (setAction) {
case MessageSetAction.SetSeen:
add('\\seen');
break;
case MessageSetAction.UnsetSeen:
remove('\\seen');
break;
case MessageSetAction.SetFlag:
add('\\flagged');
break;
case MessageSetAction.UnsetFlag:
remove('\\flagged');
break;
// no default
}
this.setFor(folder, uid, flags);
return unread;
}
}

View file

@ -5,10 +5,12 @@ export const
/** /**
* @enum {string} * @enum {string}
*/ */
ScopeMessageList = 'MessageList', Scope = {
ScopeFolderList = 'FolderList', MessageList: 'MessageList',
ScopeMessageView = 'MessageView', FolderList: 'FolderList',
ScopeSettings = 'Settings', MessageView: 'MessageView',
Settings: 'Settings'
},
/** /**
* @enum {number} * @enum {number}
@ -37,7 +39,7 @@ SaveSettingStatus = {
/** /**
* @enum {number} * @enum {number}
*/ */
Notifications = { Notification = {
RequestError: 1, RequestError: 1,
RequestAborted: 2, RequestAborted: 2,
RequestTimeout: 3, RequestTimeout: 3,
@ -50,7 +52,6 @@ Notifications = {
ConnectionError: 104, ConnectionError: 104,
DomainNotAllowed: 109, DomainNotAllowed: 109,
AccountNotAllowed: 110, AccountNotAllowed: 110,
CryptKeyError: 111,
ContactsSyncError: 140, ContactsSyncError: 140,
@ -96,6 +97,7 @@ Notifications = {
JsonParse: 952, JsonParse: 952,
// JsonTimeout: 953, // JsonTimeout: 953,
UnknownNotification: 998,
UnknownError: 999, UnknownError: 999,
// Admin // Admin

View file

@ -25,12 +25,12 @@ export const FolderType = {
FILES : 25; FILES : 25;
JOURNAL : 26; JOURNAL : 26;
*/ */
}, };
/** /**
* @enum {string} * @enum {string}
*/ */
FolderMetadataKeys = { export const FolderMetadataKeys = {
// RFC 5464 // RFC 5464
Comment: '/private/comment', Comment: '/private/comment',
CommentShared: '/shared/comment', CommentShared: '/shared/comment',
@ -39,12 +39,12 @@ FolderMetadataKeys = {
// Kolab // Kolab
KolabFolderType: '/private/vendor/kolab/folder-type', KolabFolderType: '/private/vendor/kolab/folder-type',
KolabFolderTypeShared: '/shared/vendor/kolab/folder-type' KolabFolderTypeShared: '/shared/vendor/kolab/folder-type'
}, };
/** /**
* @enum {string} * @enum {string}
*/ */
ComposeType = { export const ComposeType = {
Empty: 0, Empty: 0,
Reply: 1, Reply: 1,
ReplyAll: 2, ReplyAll: 2,
@ -52,34 +52,51 @@ ComposeType = {
ForwardAsAttachment: 4, ForwardAsAttachment: 4,
Draft: 5, Draft: 5,
EditAsNew: 6 EditAsNew: 6
}, };
/** /**
* @enum {number} * @enum {number}
*/ */
ClientSideKeyNameExpandedFolders = 3, export const
ClientSideKeyNameFolderListSize = 4, ClientSideKeyNameExpandedFolders = 3,
ClientSideKeyNameMessageListSize = 5, ClientSideKeyNameFolderListSize = 4,
ClientSideKeyNameLastSignMe = 7, ClientSideKeyNameMessageListSize = 5,
ClientSideKeyNameMessageHeaderFullInfo = 9, ClientSideKeyNameLastSignMe = 7,
ClientSideKeyNameMessageAttachmentControls = 10, ClientSideKeyNameMessageHeaderFullInfo = 9,
ClientSideKeyNameMessageAttachmentControls = 10;
/** /**
* @enum {number} * @enum {number}
*/ */
MessageSetAction = { export const MessageSetAction = {
SetSeen: 0, SetSeen: 0,
UnsetSeen: 1, UnsetSeen: 1,
SetFlag: 2, SetFlag: 2,
UnsetFlag: 3, UnsetFlag: 3
SetDeleted: 4, };
UnsetDeleted: 5
},
/** /**
* @enum {number} * @enum {number}
*/ */
//LayoutNoView = 0, export const MessagePriority = {
LayoutSideView = 1, Low: 5,
LayoutBottomView = 2 Normal: 3,
; High: 1
};
/**
* @enum {string}
*/
export const EditorDefaultType = {
Html: 'Html',
Plain: 'Plain'
};
/**
* @enum {number}
*/
export const Layout = {
NoPreview: 0,
SidePreview: 1,
BottomPreview: 2
};

View file

@ -1,9 +1,7 @@
/* eslint key-spacing: 0 */ /* eslint key-spacing: 0 */
/* eslint quote-props: 0 */ /* eslint quote-props: 0 */
import { arrayLength, pInt } from 'Common/Utils'; import { arrayLength } from 'Common/Utils';
export const RFC822 = 'message/rfc822';
const const
cache = {}, cache = {},
@ -14,8 +12,8 @@ const
lowerCase = text => text.toLowerCase().trim(), lowerCase = text => text.toLowerCase().trim(),
exts = { exts = {
eml: RFC822, eml: 'message/rfc822',
mime: RFC822, mime: 'message/rfc822',
vcard: 'text/vcard', vcard: 'text/vcard',
vcf: 'text/vcard', vcf: 'text/vcard',
htm: 'text/html', htm: 'text/html',
@ -30,8 +28,6 @@ const
p7c: app+'pkcs7-mime', p7c: app+'pkcs7-mime',
p7m: app+'pkcs7-mime', p7m: app+'pkcs7-mime',
p7s: app+'pkcs7-signature', p7s: app+'pkcs7-signature',
p12: app+'pkcs12',
pfx: app+'x-pkcs12',
torrent: app+'x-bittorrent', torrent: app+'x-bittorrent',
// scripts // scripts
@ -120,8 +116,7 @@ export const FileType = {
Spreadsheet: 'spreadsheet', Spreadsheet: 'spreadsheet',
Presentation: 'presentation', Presentation: 'presentation',
Certificate: 'certificate', Certificate: 'certificate',
Archive: 'archive', Archive: 'archive'
Calendar: 'calendar'
}; };
export const FileInfo = { export const FileInfo = {
@ -138,7 +133,7 @@ export const FileInfo = {
getContentType: fileName => { getContentType: fileName => {
fileName = lowerCase(fileName); fileName = lowerCase(fileName);
if ('winmail.dat' === fileName) { if ('winmail.dat' === fileName) {
return app + 'vnd.ms-tnef'; return app + 'ms-tnef';
} }
let ext = fileName.split('.').pop(); let ext = fileName.split('.').pop();
if (/^(txt|text|def|list|in|ini|log|sql|cfg|conf)$/.test(ext)) if (/^(txt|text|def|list|in|ini|log|sql|cfg|conf)$/.test(ext))
@ -166,7 +161,7 @@ export const FileInfo = {
*/ */
getType: (ext, mimeType) => { getType: (ext, mimeType) => {
ext = lowerCase(ext); ext = lowerCase(ext);
mimeType = lowerCase(mimeType).replace('csv/plain', 'text/csv').replace('x-',''); mimeType = lowerCase(mimeType).replace('csv/plain', 'text/csv');
let key = ext + mimeType; let key = ext + mimeType;
if (cache[key]) { if (cache[key]) {
@ -175,7 +170,7 @@ export const FileInfo = {
let result = FileType.Unknown; let result = FileType.Unknown;
const mimeTypeParts = mimeType.split('/'), const mimeTypeParts = mimeType.split('/'),
type = mimeTypeParts[1].replace('-compressed',''), type = mimeTypeParts[1].replace('x-','').replace('-compressed',''),
match = str => mimeType.includes(str), match = str => mimeType.includes(str),
archive = /^(zip|7z|tar|rar|gzip|bzip|bzip2)$/; archive = /^(zip|7z|tar|rar|gzip|bzip|bzip2)$/;
@ -192,12 +187,9 @@ export const FileInfo = {
case ['php', 'js', 'css', 'xml', 'html'].includes(ext) || 'text/html' == mimeType: case ['php', 'js', 'css', 'xml', 'html'].includes(ext) || 'text/html' == mimeType:
result = FileType.Code; result = FileType.Code;
break; break;
case 'eml' == ext || ['message/delivery-status', RFC822].includes(mimeType): case 'eml' == ext || ['message/delivery-status', 'message/rfc822'].includes(mimeType):
result = FileType.Eml; result = FileType.Eml;
break; break;
case 'ics' == ext || mimeType == 'text/calendar':
result = FileType.Calendar;
break;
case 'text' == mimeTypeParts[0] || 'txt' == ext || 'log' == ext: case 'text' == mimeTypeParts[0] || 'txt' == ext || 'log' == ext:
result = FileType.Text; result = FileType.Text;
break; break;
@ -207,8 +199,9 @@ export const FileInfo = {
case 'pdf' == type || 'pdf' == ext: case 'pdf' == type || 'pdf' == ext:
result = FileType.Pdf; result = FileType.Pdf;
break; break;
case [app+'pgp-signature', app+'pgp-keys', exts.p7m, exts.p7s, exts.p12, exts.pfx].includes(mimeType) case [app+'pgp-signature', app+'pgp-keys'].includes(mimeType)
|| ['asc', 'pem', 'ppk', 'p7s', 'p7m', 'p12', 'pfx'].includes(ext): || ['asc', 'pem', 'ppk'].includes(ext)
|| [app+'pkcs7-signature'].includes(mimeType) || 'p7s' == ext:
result = FileType.Certificate; result = FileType.Certificate;
break; break;
case match(msOffice+'.wordprocessingml') || match(openDoc+'.text') || match('vnd.ms-word') case match(msOffice+'.wordprocessingml') || match(openDoc+'.text') || match('vnd.ms-word')
@ -247,7 +240,6 @@ export const FileInfo = {
case FileType.Certificate: case FileType.Certificate:
case FileType.Spreadsheet: case FileType.Spreadsheet:
case FileType.Presentation: case FileType.Presentation:
case FileType.Calendar:
return result + '-' + fileType; return result + '-' + fileType;
} }
return result; return result;
@ -274,8 +266,8 @@ export const FileInfo = {
}, },
friendlySize: bytes => { friendlySize: bytes => {
bytes = pInt(bytes); bytes = parseInt(bytes, 10) || 0;
let i = bytes ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0; let i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2>i ? 0 : 1) + ' ' + sizes[i]; return (bytes / Math.pow(1024, i)).toFixed(2>i ? 0 : 1) + ' ' + sizes[i];
} }

View file

@ -1,35 +1,24 @@
import { RFC822 } from 'Common/File'; import { isArray, arrayLength } from 'Common/Utils';
import { getFolderInboxName, getFolderFromCacheList } from 'Common/Cache'; import {
import { baseCollator } from 'Common/Translator'; MessageFlagsCache,
import { SettingsGet } from 'Common/Globals'; setFolderHash,
import { isArray, arrayLength, pInt } from 'Common/Utils'; getFolderInboxName,
getFolderFromCacheList
} from 'Common/Cache';
import { SettingsUserStore } from 'Stores/User/Settings'; import { SettingsUserStore } from 'Stores/User/Settings';
import { FolderUserStore } from 'Stores/User/Folder'; import { FolderUserStore } from 'Stores/User/Folder';
import { MessagelistUserStore } from 'Stores/User/Messagelist'; import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { getNotification } from 'Common/Translator';
import { Settings } from 'Common/Globals';
import { serverRequest } from 'Common/Links';
import Remote from 'Remote/User/Fetch'; import Remote from 'Remote/User/Fetch';
let refreshInterval,
// Default every 15 minutes
refreshFoldersInterval = 900000;
export const export const
setRefreshFoldersInterval = minutes => {
refreshFoldersInterval = Math.max(1, pInt(SettingsGet('minRefreshInterval')), pInt(minutes)) * 60000;
clearInterval(refreshInterval);
refreshInterval = setInterval(() => {
const cF = FolderUserStore.currentFolderFullName(),
iF = getFolderInboxName();
folderInformation(iF);
iF === cF || folderInformation(cF);
folderInformationMultiply();
}, refreshFoldersInterval);
},
sortFolders = folders => { sortFolders = folders => {
try { try {
let collator = baseCollator(true); let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
folders.sort((a, b) => folders.sort((a, b) =>
a.isInbox() ? -1 : (b.isInbox() ? 1 : collator.compare(a.fullName, b.fullName)) a.isInbox() ? -1 : (b.isInbox() ? 1 : collator.compare(a.fullName, b.fullName))
); );
@ -50,14 +39,15 @@ folderListOptionsBuilder = (
aDisabled, aDisabled,
aHeaderLines, aHeaderLines,
fRenameCallback, fRenameCallback,
fDisableCallback fDisableCallback,
bNoSelectSelectable,
aList = FolderUserStore.folderList()
) => { ) => {
const const
aResult = [], aResult = [],
sDeepPrefix = '\u00A0\u00A0\u00A0', sDeepPrefix = '\u00A0\u00A0\u00A0',
// FolderSystemPopupView should always be true // FolderSystemPopupView should always be true
showUnsubscribed = fRenameCallback ? !SettingsUserStore.hideUnsubscribed() : true, showUnsubscribed = fRenameCallback ? !SettingsUserStore.hideUnsubscribed() : true,
isDisabled = fDisableCallback || (item => !item.selectable() || aDisabled.includes(item.fullName)),
foldersWalk = folders => { foldersWalk = folders => {
folders.forEach(oItem => { folders.forEach(oItem => {
@ -68,7 +58,10 @@ folderListOptionsBuilder = (
sDeepPrefix.repeat(oItem.deep) + sDeepPrefix.repeat(oItem.deep) +
fRenameCallback(oItem), fRenameCallback(oItem),
system: false, system: false,
disabled: isDisabled(oItem) disabled: !bNoSelectSelectable && (
!oItem.selectable() ||
aDisabled.includes(oItem.fullName) ||
fDisableCallback(oItem))
}); });
} }
foldersWalk(oItem.subFolders()); foldersWalk(oItem.subFolders());
@ -89,63 +82,79 @@ folderListOptionsBuilder = (
}) })
); );
foldersWalk(FolderUserStore.folderList()); foldersWalk(aList);
return aResult; return aResult;
}, },
// Every 5 minutes
refreshFoldersInterval = 300000,
/** /**
* @param {string} folder * @param {string} folder
* @param {Array=} list = [] * @param {Array=} list = []
*/ */
folderInformation = (folder, list) => { folderInformation = (folder, list) => {
if (folder?.trim()) { if (folder?.trim()) {
let count = 1; let fetch = !arrayLength(list);
const uids = []; const uids = [],
folderFromCache = getFolderFromCacheList(folder);
if (arrayLength(list)) { if (!fetch) {
list.forEach(messageListItem => { list.forEach(messageListItem => {
uids.push(messageListItem.uid); MessageFlagsCache.getFor(folder, messageListItem.uid) || uids.push(messageListItem.uid);
messageListItem.threads.forEach(uid => uids.push(uid)); messageListItem.threads.forEach(uid => {
MessageFlagsCache.getFor(folder, uid) || uids.push(uid);
});
}); });
count = uids.length; fetch = uids.length;
} }
if (count) { if (fetch) {
Remote.request('FolderInformation', (iError, data) => { Remote.request('FolderInformation', (iError, data) => {
if (!iError && data.Result) { if (!iError && data.Result) {
const result = data.Result, const result = data.Result,
folderFromCache = getFolderFromCacheList(result.name); folderFromCache = getFolderFromCacheList(result.Folder);
if (folderFromCache) { if (folderFromCache) {
const oldHash = folderFromCache.etag, const oldHash = folderFromCache.hash,
unreadCountChange = (folderFromCache.unreadEmails() !== result.unreadEmails); unreadCountChange = (folderFromCache.unreadEmails() !== result.unreadEmails);
// folderFromCache.revivePropertiesFromJson(result); // folderFromCache.revivePropertiesFromJson(result);
folderFromCache.expires = Date.now(); folderFromCache.expires = Date.now();
folderFromCache.uidNext = result.uidNext; folderFromCache.uidNext = result.UidNext;
folderFromCache.etag = result.etag; folderFromCache.hash = result.Hash;
folderFromCache.totalEmails(result.totalEmails); folderFromCache.totalEmails(result.totalEmails);
folderFromCache.unreadEmails(result.unreadEmails); folderFromCache.unreadEmails(result.unreadEmails);
unreadCountChange && MessageFlagsCache.clearFolder(folderFromCache.fullName);
if (result.MessagesFlags.length) {
result.MessagesFlags.forEach(message =>
MessageFlagsCache.setFor(folderFromCache.fullName, message.Uid.toString(), message.Flags)
);
MessagelistUserStore.reloadFlagsAndCachedMessage();
}
MessagelistUserStore.notifyNewMessages(folderFromCache.fullName, result.newMessages); MessagelistUserStore.notifyNewMessages(folderFromCache.fullName, result.newMessages);
if (!oldHash || unreadCountChange || result.etag !== oldHash) { if (!oldHash || unreadCountChange || result.Hash !== oldHash) {
if (folderFromCache.fullName === FolderUserStore.currentFolderFullName()) { if (folderFromCache.fullName === FolderUserStore.currentFolderFullName()) {
MessagelistUserStore.reload(); MessagelistUserStore.reload();
/*
} else if (getFolderInboxName() === folderFromCache.fullName) { } else if (getFolderInboxName() === folderFromCache.fullName) {
// Remote.messageList(null, {folder: getFolderFromCacheList(getFolderInboxName())}, true); // Remote.messageList(null, {folder: getFolderFromCacheList(getFolderInboxName())}, true);
Remote.messageList(null, {folder: getFolderInboxName()}, true); Remote.messageList(null, {folder: getFolderInboxName()}, true);
*/
} }
} }
} }
} }
}, { }, {
folder: folder, Folder: folder,
flagsUids: uids, FlagsUids: uids,
uidNext: getFolderFromCacheList(folder)?.uidNext || 0 // Used to check for new messages UidNext: folderFromCache?.uidNext || 0 // Used to check for new messages
}); });
} else if (SettingsUserStore.useThreads()) {
MessagelistUserStore.reloadFlagsAndCachedMessage();
} }
} }
}, },
@ -160,18 +169,21 @@ folderInformationMultiply = (boot = false) => {
if (!iError && arrayLength(oData.Result)) { if (!iError && arrayLength(oData.Result)) {
const utc = Date.now(); const utc = Date.now();
oData.Result.forEach(item => { oData.Result.forEach(item => {
const folder = getFolderFromCacheList(item.name); const folder = getFolderFromCacheList(item.Folder);
if (folder) { if (folder) {
const oldHash = folder.etag, const oldHash = folder.hash,
unreadCountChange = folder.unreadEmails() !== item.unreadEmails; unreadCountChange = folder.unreadEmails() !== item.unreadEmails;
// folder.revivePropertiesFromJson(item); // folder.revivePropertiesFromJson(item);
folder.expires = utc; folder.expires = utc;
folder.etag = item.etag; folder.hash = item.Hash;
folder.totalEmails(item.totalEmails); folder.totalEmails(item.totalEmails);
folder.unreadEmails(item.unreadEmails); folder.unreadEmails(item.unreadEmails);
if (!oldHash || item.etag !== oldHash) { unreadCountChange && MessageFlagsCache.clearFolder(folder.fullName);
if (!oldHash || item.Hash !== oldHash) {
if (folder.fullName === FolderUserStore.currentFolderFullName()) { if (folder.fullName === FolderUserStore.currentFolderFullName()) {
MessagelistUserStore.reload(); MessagelistUserStore.reload();
} }
@ -186,26 +198,104 @@ folderInformationMultiply = (boot = false) => {
boot && setTimeout(() => folderInformationMultiply(true), 2000); boot && setTimeout(() => folderInformationMultiply(true), 2000);
} }
}, { }, {
folders: folders Folders: folders
}); });
} }
}, },
dropFilesInFolder = (sFolderFullName, files) => { moveOrDeleteResponseHelper = (iError, oData) => {
let count = files.length; if (iError) {
for (const file of files) { setFolderHash(FolderUserStore.currentFolderFullName(), '');
if (RFC822 === file.type) { alert(getNotification(iError));
let data = new FormData; } else if (FolderUserStore.currentFolder()) {
data.append('folder', sFolderFullName); if (2 === arrayLength(oData.Result)) {
data.append('appendFile', file); setFolderHash(oData.Result[0], oData.Result[1]);
Remote.request('FolderAppend', (iError, data)=>{
iError && console.error(data.message);
0 == --count
&& FolderUserStore.currentFolderFullName() == sFolderFullName
&& MessagelistUserStore.reload(true, true);
}, data);
} else { } else {
--count; setFolderHash(FolderUserStore.currentFolderFullName(), '');
}
MessagelistUserStore.reload(!MessagelistUserStore.length);
}
},
messagesMoveHelper = (fromFolderFullName, toFolderFullName, uidsForMove) => {
const
sSpamFolder = FolderUserStore.spamFolder(),
isSpam = sSpamFolder === toFolderFullName,
isHam = !isSpam && sSpamFolder === fromFolderFullName && getFolderInboxName() === toFolderFullName;
Remote.abort('MessageList').request('MessageMove',
moveOrDeleteResponseHelper,
{
FromFolder: fromFolderFullName,
ToFolder: toFolderFullName,
Uids: [...uidsForMove].join(','),
MarkAsRead: (isSpam || FolderUserStore.trashFolder() === toFolderFullName) ? 1 : 0,
Learning: isSpam ? 'SPAM' : isHam ? 'HAM' : ''
}
);
},
messagesDeleteHelper = (sFromFolderFullName, aUidForRemove) => {
Remote.abort('MessageList').request('MessageDelete',
moveOrDeleteResponseHelper,
{
Folder: sFromFolderFullName,
Uids: [...aUidForRemove].join(',')
}
);
},
/**
* @param {string} sFromFolderFullName
* @param {Set} oUids
* @param {string} sToFolderFullName
* @param {boolean=} bCopy = false
*/
moveMessagesToFolder = (sFromFolderFullName, oUids, sToFolderFullName, bCopy) => {
if (sFromFolderFullName !== sToFolderFullName && oUids?.size) {
const oFromFolder = getFolderFromCacheList(sFromFolderFullName),
oToFolder = getFolderFromCacheList(sToFolderFullName);
if (oFromFolder && oToFolder) {
bCopy
? Remote.request('MessageCopy', null, {
FromFolder: oFromFolder.fullName,
ToFolder: oToFolder.fullName,
Uids: [...oUids].join(',')
})
: messagesMoveHelper(oFromFolder.fullName, oToFolder.fullName, oUids);
MessagelistUserStore.removeMessagesFromList(oFromFolder.fullName, oUids, oToFolder.fullName, bCopy);
return true;
}
}
return false;
},
dropFilesInFolder = (sFolderFullName, files) => {
let count = 0,
fn = () => 0 == --count
&& FolderUserStore.currentFolderFullName() == sFolderFullName
&& MessagelistUserStore.reload(true, true);
for (const file of files) {
if ('message/rfc822' === file.type) {
++count;
let data = new FormData;
data.append('Folder', sFolderFullName);
data.append('AppendFile', file);
data.XToken = Settings.app('token');
fetch(serverRequest('Append'), {
method: 'POST',
mode: 'same-origin',
cache: 'no-cache',
redirect: 'error',
referrerPolicy: 'no-referrer',
credentials: 'same-origin',
body: data
})
.then(fn)
.catch(fn);
} }
} }
}; };

View file

@ -1,19 +1,20 @@
import { doc, appEl, $htmlCL } from 'Common/Globals'; import { doc, elementById, $htmlCL } from 'Common/Globals';
// Fullscreen must be on app, else other popups fail // Fullscreen must be on app, else other popups fail
export const export const
appFullscreen = () => (doc.fullscreenElement || doc.webkitFullscreenElement) === appEl, app = elementById('rl-app'),
appFullscreen = () => (doc.fullscreenElement || doc.webkitFullscreenElement) === app,
exitFullscreen = () => appFullscreen() && (doc.exitFullscreen || doc.webkitExitFullscreen).call(doc), exitFullscreen = () => appFullscreen() && (doc.exitFullscreen || doc.webkitExitFullscreen).call(doc),
isFullscreen = ko.observable(false), isFullscreen = ko.observable(false),
toggleFullscreen = () => isFullscreen() ? exitFullscreen() : appEl.requestFullscreen(); toggleFullscreen = () => isFullscreen() ? exitFullscreen() : app.requestFullscreen();
if (appEl) { if (app) {
let event = 'fullscreenchange'; let event = 'fullscreenchange';
if (!appEl.requestFullscreen && appEl.webkitRequestFullscreen) { if (!app.requestFullscreen && app.webkitRequestFullscreen) {
appEl.requestFullscreen = appEl.webkitRequestFullscreen; app.requestFullscreen = app.webkitRequestFullscreen;
event = 'webkit'+event; event = 'webkit'+event;
} }
if (appEl.requestFullscreen) { if (app.requestFullscreen) {
doc.addEventListener(event, () => { doc.addEventListener(event, () => {
isFullscreen(appFullscreen()); isFullscreen(appFullscreen());
$htmlCL.toggle('rl-fullscreen', appFullscreen()); $htmlCL.toggle('rl-fullscreen', appFullscreen());

View file

@ -11,11 +11,8 @@ export const
elementById = id => doc.getElementById(id), elementById = id => doc.getElementById(id),
appEl = elementById('rl-app'),
Settings = rl.settings, Settings = rl.settings,
SettingsGet = Settings.get, SettingsGet = Settings.get,
SettingsAdmin = name => (SettingsGet('Admin') || {})[name],
SettingsCapa = name => name && !!(SettingsGet('Capa') || {})[name], SettingsCapa = name => name && !!(SettingsGet('Capa') || {})[name],
dropdowns = [], dropdowns = [],
@ -34,11 +31,6 @@ export const
new CustomEvent(name, {detail:detail, cancelable: !!cancelable}) new CustomEvent(name, {detail:detail, cancelable: !!cancelable})
), ),
stopEvent = event => {
event.preventDefault();
event.stopPropagation();
},
formFieldFocused = () => doc.activeElement?.matches('input,textarea'), formFieldFocused = () => doc.activeElement?.matches('input,textarea'),
addShortcut = (...args) => shortcuts.add(...args), addShortcut = (...args) => shortcuts.add(...args),

View file

@ -1,12 +1,9 @@
import { createElement } from 'Common/Globals'; import { createElement } from 'Common/Globals';
import { forEachObjectEntry, isArray, pInt } from 'Common/Utils'; import { forEachObjectEntry, pInt } from 'Common/Utils';
import { SettingsUserStore } from 'Stores/User/Settings'; import { SettingsUserStore } from 'Stores/User/Settings';
const const
tmpl = createElement('template'), tpl = createElement('template'),
turndown = new TurndownService(),
htmlre = /[&<>"']/g, htmlre = /[&<>"']/g,
httpre = /^(https?:)?\/\//i, httpre = /^(https?:)?\/\//i,
htmlmap = { htmlmap = {
@ -17,52 +14,13 @@ const
"'": '&#x27;' "'": '&#x27;'
}, },
keepTagContent = 'form,button,data', // font
allowedTags = [
// Structural Elements:
'blockquote','br','div','figcaption','figure','h1','h2','h3','h4','h5','h6','hgroup','hr','p','wbr',
'article','aside','header','footer','main','section',
'details','summary','nav',
// List Elements
'dd','dl','dt','li','ol','ul',
// Text Formatting Elements
'a','abbr','address','b','bdi','bdo','cite','code','del','dfn',
'em','i','ins','kbd','mark','pre','q','rp','rt','ruby','s','samp','small',
'span','strong','sub','sup','time','u','var',
// Deprecated by HTML Standard
'acronym','big','center','dir','font','marquee',
'nobr','plaintext','rb','rtc','strike','tt',
// Media Elements
'img',//'picture','source',
// Table Elements
'caption','col','colgroup','table','tbody','td','tfoot','th','thead','tr',
// Disallowed but converted later
'style','xmp'
].join(','),
nonEmptyTags = [
'A','B','EM','I','SPAN','STRONG'
],
blockquoteSwitcher = () => {
SettingsUserStore.collapseBlockquotes() &&
// tmpl.content.querySelectorAll('blockquote').forEach(node => {
[...tmpl.content.querySelectorAll('blockquote')].reverse().forEach(node => {
const el = createElement('details', {class:'sm-bq-switcher'});
el.innerHTML = '<summary>•••</summary>';
node.replaceWith(el);
el.append(node);
});
},
replaceWithChildren = node => node.replaceWith(...[...node.childNodes]), replaceWithChildren = node => node.replaceWith(...[...node.childNodes]),
urlRegExp = /https?:\/\/[^\p{C}\p{Z}]+[^\p{C}\p{Z}.]/gu, url = /(^|\s|\n|\/?>)(https?:\/\/[-A-Z0-9+&#/%?=()~_|!:,.;]*[-A-Z0-9+&#/%=~()_|])/gi,
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
email = /(^|\r|\n|\p{C}\p{Z})((?:[^"(),.:;<>@[\]\\\p{C}\p{Z}]+(?:\.[^"(),.:;<>@[\]\\\p{C}\p{Z}]+)*|"(?:\\?[^"\\\p{C}\p{Z}])*")@[^@\p{C}\p{Z}]+[^@\p{C}\p{Z}.])/gui, email = /(^|\s|\n|\/?>)((?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x21\x23-\x5b\x5d-\x7f]|\\[\x21\x23-\x5b\x5d-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x21-\x5a\x53-\x7f]|\\[\x21\x23-\x5b\x5d-\x7f])+)\]))/gi,
// rfc3966 // rfc3966
tel = /(tel:(\+[0-9().-]+|[0-9*#().-]+(;phone-context=\+[0-9+().-]+)?))/g, tel = /(^|\s|\n|\/?>)(tel:(\+[0-9().-]+|[0-9*#().-]+(;phone-context=\+[0-9+().-]+)?))/g,
// Strip tracking // Strip tracking
/** TODO: implement other url strippers like from /** TODO: implement other url strippers like from
@ -73,18 +31,15 @@ const
* https://github.com/M66B/FairEmail/blob/master/app/src/main/java/eu/faircode/email/UriHelper.java * https://github.com/M66B/FairEmail/blob/master/app/src/main/java/eu/faircode/email/UriHelper.java
*/ */
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
stripParams = /^(utm_|ec_|fbclid|mc_eid|mkt_tok|_hsenc|vero_id|oly_enc_id|oly_anon_id|__s|Referrer|mailing|elq|bch|trc|ref|correlation_id|pd_|pf_|email_hash)$/i, stripParams = /^(utm_|ec_|fbclid|mc_eid|mkt_tok|_hsenc|vero_id|oly_enc_id|oly_anon_id|__s|Referrer|mailing|elq|bch|trc|ref|correlation_id|pd_|pf_)/i,
urlGetParam = (url, name) => new URL(url).searchParams.get(name) || url, urlGetParam = (url, name) => new URL(url).searchParams.get(name) || url,
base64Url = data => atob(data.replace(/_/g,'/').replace(/-/g,'+')), base64Url = data => atob(data.replace(/_/g,'/').replace(/-/g,'+')),
decode = decodeURIComponent,
stripTracking = url => { stripTracking = url => {
try { try {
let nurl = url let nurl = url
// Copernica
.replace(/^.+\/(https%253A[^/?&]+).*$/i, (...m) => decode(decode(m[1])))
.replace(/tracking\.(printabout\.nl[^?]+)\?.*/i, (...m) => m[1]) .replace(/tracking\.(printabout\.nl[^?]+)\?.*/i, (...m) => m[1])
.replace(/(zalando\.nl[^?]+)\?.*/i, (...m) => m[1]) .replace(/(zalando\.nl[^?]+)\?.*/i, (...m) => m[1])
.replace(/^.+(awstrack\.me|redditmail\.com)\/.+(https:%2F%2F[^/]+).*/i, (...m) => decode(m[2])) .replace(/^.+(awstrack\.me|redditmail\.com)\/.+(https:%2F%2F[^/]+).*/i, (...m) => decodeURIComponent(m[2]))
.replace(/^.+(www\.google|safelinks\.protection\.outlook\.com|mailchimp\.com).+url=.+$/i, .replace(/^.+(www\.google|safelinks\.protection\.outlook\.com|mailchimp\.com).+url=.+$/i,
() => urlGetParam(url, 'url')) () => urlGetParam(url, 'url'))
.replace(/^.+click\.godaddy\.com.+$/i, () => urlGetParam(url, 'redir')) .replace(/^.+click\.godaddy\.com.+$/i, () => urlGetParam(url, 'redir'))
@ -119,97 +74,6 @@ const
}); });
} }
return url; return url;
},
cleanCSS = source =>
source.trim()
.replace(/;\s*-[^;]+/g, '')
.replace(/^\s*-[^;]+(;|$)/g, '')
.replace(/white-space[^;]+/g, '')
// Drop Microsoft Office style properties
// .replace(/mso-[^:;]+:[^;]+/gi, '')
,
/*
Parses given css string, and returns css object
keys as selectors and values are css rules
eliminates all css comments before parsing
@param source css string to be parsed
@return object css
*/
parseCSS = source => {
const css = [];
css.toString = () => css.reduce(
(ret, tmp) =>
ret + tmp.selector + ' {\n'
+ (tmp.type === 'media' ? tmp.subStyles.toString() : tmp.rules)
+ '}\n'
,
''
);
/**
* Given css array, parses it and then for every selector,
* prepends namespace to prevent css collision issues
*/
css.applyNamespace = namespace => css.forEach(obj => {
if (obj.type === 'media') {
obj.subStyles.applyNamespace(namespace);
} else {
obj.selector = obj.selector.split(',').map(selector =>
(namespace + ' .mail-body ' + selector.replace(/\./g, '.msg-'))
.replace(/\sbody/gi, '')
).join(',');
}
});
if (source) {
source = source
// strip comments
.replace(/\/\*[\s\S]*?\*\//gi, '')
// strip MS Word comments
.replace(/<!--[\s\S]*?-->/gi, '')
// strip HTML, as < is no CSS combinator anyway
.replace(/<[\s\S]*/gi, '');
// .replace(/<\/?[a-z][\s\S]*?>/gi, '');
// unified regex to match css & media queries together
let unified = /(?:(\s*?@(?:media)[\s\S]*?){([\s\S]*?)}\s*?})|(?:([\s\S]*?){([\s\S]*?)})/gi,
arr;
while (true) {
arr = unified.exec(source);
if (arr === null) {
break;
}
let selector = arr[arr[2] === undefined ? 3 : 1].split('\r\n').join('\n').trim()
// Never have more than a single line break in a row
.replace(/\n+/, "\n")
// Remove :root and html
.split(/\s+/g).map(item => item.replace(/^(:root|html)$/, '')).join(' ').trim();
// determine the type
if (selector.includes('@media')) {
// we have a media query
css.push({
selector: selector,
type: 'media',
subStyles: parseCSS(arr[2] + '\n}') //recursively parse media query inner css
});
} else if (selector && !selector.includes('@')) {
// we have standard css
// ignores @import, @keyframe, @font-face statements
css.push({
selector: selector,
rules: cleanCSS(arr[4])
});
}
}
}
return css;
}; };
export const export const
@ -225,32 +89,28 @@ export const
* @param {string} text * @param {string} text
* @returns {string} * @returns {string}
*/ */
cleanHtml = (html, oAttachments, msgId) => { cleanHtml = (html, oAttachments) => {
let aColor;
const const
debug = false, // Config()->Get('debug', 'enable', false); debug = false, // Config()->Get('debug', 'enable', false);
detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'), detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'),
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
minFontSize = parseInt(SettingsUserStore.minFontSize()),
result = { result = {
hasExternals: false, hasExternals: false
tracking: false,
linkedData: []
}, },
isMsg = !!msgId, findAttachmentByCid = cid => oAttachments.findByCid(cid),
findLocationByCid = cid => {
findAttachmentByCid = cId => oAttachments.findByCid(cId), const attachment = findAttachmentByCid(cid);
findLocationByCid = cId => {
const attachment = findAttachmentByCid(cId);
return attachment?.contentLocation ? attachment : 0; return attachment?.contentLocation ? attachment : 0;
}, },
// convert body attributes to CSS // convert body attributes to CSS
tasks = { tasks = {
link: value => aColor = value, link: value => {
if (/^#[a-fA-Z0-9]{3,6}$/.test(value)) {
tpl.content.querySelectorAll('a').forEach(node => node.style.color || (node.style.color = value))
}
},
text: (value, node) => node.style.color = value, text: (value, node) => node.style.color = value,
topmargin: (value, node) => node.style.marginTop = pInt(value) + 'px', topmargin: (value, node) => node.style.marginTop = pInt(value) + 'px',
leftmargin: (value, node) => node.style.marginLeft = pInt(value) + 'px', leftmargin: (value, node) => node.style.marginLeft = pInt(value) + 'px',
@ -258,15 +118,6 @@ export const
rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px' rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px'
}, },
allowedAttributes = [ allowedAttributes = [
// SnappyMail
// 'data-x-href-broken',
// 'data-x-href-tracking',
'data-x-src',
// 'data-x-src-hidden',
// 'data-x-src-tracking',
// 'data-x-src-broken',
// 'data-x-style-url',
// 'data-x-style-broken-urls',
// defaults // defaults
'name', 'name',
'dir', 'lang', 'style', 'title', 'dir', 'lang', 'style', 'title',
@ -294,112 +145,42 @@ export const
'abbr', 'scope', 'abbr', 'scope',
// td // td
'colspan', 'rowspan', 'headers' 'colspan', 'rowspan', 'headers'
// others ],
//'class', 'id', 'target' disallowedTags = [
'HEAD','STYLE','SVG','SCRIPT','TITLE','LINK','BASE','META',
'INPUT','OUTPUT','SELECT','BUTTON','TEXTAREA',
'BGSOUND','KEYGEN','SOURCE','OBJECT','EMBED','APPLET','IFRAME','FRAME','FRAMESET','VIDEO','AUDIO','AREA','MAP'
],
nonEmptyTags = [
'A','B','EM','I','SPAN','STRONG'
]; ];
if (SettingsUserStore.allowStyles()) { tpl.innerHTML = html
allowedAttributes.push('class');
} else {
msgId = 0;
}
tmpl.innerHTML = html
// .replace(/<pre[^>]*>[\s\S]*?<\/pre>/gi, pre => pre.replace(/\n/g, '\n<br>')) // .replace(/<pre[^>]*>[\s\S]*?<\/pre>/gi, pre => pre.replace(/\n/g, '\n<br>'))
.replace(/<!doctype[^>]*>/gi, '')
.replace(/<\?xml[^>]*\?>/gi, '')
// Not supported by <template> element // Not supported by <template> element
// .replace(/<!doctype[^>]*>/gi, '')
// .replace(/<\?xml[^>]*\?>/gi, '')
// https://github.com/the-djmaze/snappymail/issues/1860
.replace(/<(\/?)x-html(\s[^>]*)?>/gi, '')
.replace(/<(\/?)head(\s[^>]*)?>/gi, '')
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>') .replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
// .replace(/<\/?(html|head)[^>]*>/gi, '') .replace(/<\/?(html|head)[^>]*>/gi, '')
// Fix Reddit https://github.com/the-djmaze/snappymail/issues/540 // Fix Reddit https://github.com/the-djmaze/snappymail/issues/540
.replace(/<span class="preview-text"[\s\S]+?<\/span>/, '') .replace(/<span class="preview-text"[\s\S]+?<\/span>/, '')
// 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(); .trim();
html = ''; html = '';
// Strip all comments // \MailSo\Base\HtmlUtils::ClearComments()
const nodeIterator = document.createNodeIterator(tmpl.content, NodeFilter.SHOW_COMMENT); // https://github.com/the-djmaze/snappymail/issues/187
const nodeIterator = document.createNodeIterator(tpl.content, NodeFilter.SHOW_COMMENT);
while (nodeIterator.nextNode()) { while (nodeIterator.nextNode()) {
nodeIterator.referenceNode.remove(); nodeIterator.referenceNode.remove();
} }
/** tpl.content.querySelectorAll('*').forEach(oElement => {
* 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);
}
});
// https://github.com/the-djmaze/snappymail/issues/1125
tmpl.content.querySelectorAll(keepTagContent).forEach(oElement => replaceWithChildren(oElement));
tmpl.content.querySelectorAll(
':not('+allowedTags+'),a:empty,span:empty'
+ (0 < bqLevel ? ',' + (new Array(1 + bqLevel).fill('blockquote').join(' ')) : '')
).forEach(oElement => oElement.remove());
/* // Is this slower or faster?
).forEach(oElement => {
if (!node || !node.contains(oElement)) {
oElement.remove();
node = 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, const name = oElement.tagName,
oStyle = oElement.style; oStyle = oElement.style;
if ('STYLE' === name) {
let css = msgId ? parseCSS(oElement.textContent) : [];
if (css.length) {
css.applyNamespace(msgId);
css = css.toString();
if (SettingsUserStore.removeColors()) {
css = css.replace(/(background-)?color:[^};]+;?/g, '');
}
oElement.textContent = css;
} else {
oElement.remove();
}
return;
}
if ('XMP' === name) {
const pre = createElement('pre');
pre.innerHTML = encodeHtml(oElement.innerHTML);
oElement.replaceWith(pre);
return;
}
// \MailSo\Base\HtmlUtils::ClearTags() // \MailSo\Base\HtmlUtils::ClearTags()
if ('none' == oStyle.display if (disallowedTags.includes(name)
|| 'none' == oStyle.display
|| 'hidden' == oStyle.visibility || 'hidden' == oStyle.visibility
// || (oStyle.lineHeight && 1 > parseFloat(oStyle.lineHeight) // || (oStyle.lineHeight && 1 > parseFloat(oStyle.lineHeight)
// || (oStyle.maxHeight && 1 > parseFloat(oStyle.maxHeight) // || (oStyle.maxHeight && 1 > parseFloat(oStyle.maxHeight)
@ -409,31 +190,36 @@ export const
oElement.remove(); oElement.remove();
return; return;
} }
/*
// Idea to allow CSS
if ('STYLE' === name) {
msgId = '#rl-msg-061eb4d647771be4185943ce91f0039d';
oElement.textContent = oElement.textContent
.replace(/[^{}]+{/g, m => msgId + ' ' + m.replace(',', ', '+msgId+' '))
.replace(/(background-)color:[^};]+/g, '');
return;
}
*/
const aAttrsForRemove = [], const aAttrsForRemove = [],
className = oElement.className,
hasAttribute = name => oElement.hasAttribute(name), hasAttribute = name => oElement.hasAttribute(name),
getAttribute = name => hasAttribute(name) ? oElement.getAttribute(name).trim() : '', getAttribute = name => hasAttribute(name) ? oElement.getAttribute(name).trim() : '',
setAttribute = (name, value) => oElement.setAttribute(name, value), setAttribute = (name, value) => oElement.setAttribute(name, value),
delAttribute = name => { delAttribute = name => oElement.removeAttribute(name);
let value = getAttribute(name);
oElement.removeAttribute(name);
return value;
};
if ('mail-body' === className) { if ('mail-body' === oElement.className) {
forEachObjectEntry(tasks, (name, cb) => forEachObjectEntry(tasks, (name, cb) => {
hasAttribute(name) && cb(delAttribute(name), oElement) if (hasAttribute(name)) {
); cb(getAttribute(name), oElement);
} else if (msgId && className) { delAttribute(name);
oElement.className = className.replace(/(^|\s+)/g, '$1msg-'); }
});
} }
if (oElement.hasAttributes()) { if (oElement.hasAttributes()) {
let i = oElement.attributes.length; let i = oElement.attributes.length;
while (i--) { while (i--) {
let sAttrName = oElement.attributes[i].name.toLowerCase(); let sAttrName = oElement.attributes[i].name.toLowerCase();
if (!allowedAttributes.includes(sAttrName) && ('class' !== sAttrName || 'mail-body' !== className)) { if (!allowedAttributes.includes(sAttrName)) {
delAttribute(sAttrName); delAttribute(sAttrName);
aAttrsForRemove.push(sAttrName); aAttrsForRemove.push(sAttrName);
} }
@ -445,21 +231,25 @@ export const
// if ('TABLE' === name || 'TD' === name || 'TH' === name) { // if ('TABLE' === name || 'TD' === name || 'TH' === name) {
if (!oStyle.backgroundImage) { if (!oStyle.backgroundImage) {
if ('TD' !== name && 'TH' !== name) { if ('TD' !== name && 'TH' !== name) {
['width','height'].forEach(key => {
if (hasAttribute(key)) {
value = delAttribute(key);
oStyle[key] || (oStyle[key] = value.includes('%') ? value : value + 'px');
}
});
// Make width responsive // Make width responsive
value = oStyle.width; if (hasAttribute('width')) {
if (100 < parseInt(value,10) && !oStyle.maxWidth) { value = getAttribute('width');
oStyle.width = value.includes('%') ? value : value + 'px';
delAttribute('width');
}
value = oStyle.removeProperty('width');
if (value) {
oStyle.maxWidth = value; oStyle.maxWidth = value;
oStyle.width = '100%'; oStyle.width = '100%';
} }
// Make height responsive // Make height responsive
if (hasAttribute('height')) {
value = getAttribute('height');
oStyle.height = value.includes('%') ? value : value + 'px';
delAttribute('height');
}
value = oStyle.removeProperty('height'); value = oStyle.removeProperty('height');
if (value && !oStyle.maxHeight) { if (value) {
oStyle.maxHeight = value; oStyle.maxHeight = value;
} }
} }
@ -468,23 +258,18 @@ export const
if ('A' === name) { if ('A' === name) {
value = oElement.href; value = oElement.href;
if (!/^([a-z]+):/i.test(value)) { if (!/^([a-z]+):/i.test(value)) {
setAttribute('data-x-href-broken', value); setAttribute('data-x-broken-href', value);
delAttribute('href'); delAttribute('href');
} else { } else {
oElement.href = stripTracking(value); oElement.href = stripTracking(value);
if (oElement.href != value) {
result.tracking = true;
setAttribute('data-x-href-tracking', value);
}
setAttribute('target', '_blank'); setAttribute('target', '_blank');
// setAttribute('rel', 'external nofollow noopener noreferrer'); // setAttribute('rel', 'external nofollow noopener noreferrer');
} }
setAttribute('tabindex', '-1'); setAttribute('tabindex', '-1');
aColor && !oElement.style.color && (oElement.style.color = aColor);
} }
// if (['CENTER','FORM'].includes(name)) { // if (['CENTER','FORM'].includes(name)) {
if (nonEmptyTags.includes(name) && ('' == oElement.textContent.trim())) { if ('FORM' === name || 'O:P' === name || (nonEmptyTags.includes(name) && ('' == oElement.textContent.trim()))) {
('A' !== name || !oElement.querySelector('IMG')) && replaceWithChildren(oElement); ('A' !== name || !oElement.querySelector('IMG')) && replaceWithChildren(oElement);
return; return;
} }
@ -497,85 +282,81 @@ export const
*/ */
let skipStyle = false; let skipStyle = false;
if (isMsg) { if (hasAttribute('src')) {
value = isMsg && delAttribute('src'); value = stripTracking(getAttribute('src'));
if (value) { delAttribute('src');
if ('IMG' === name) {
oElement.loading = 'lazy'; if ('IMG' === name) {
let attachment; oElement.loading = 'lazy';
if (value.startsWith('cid:')) let attachment;
{ if (value.startsWith('cid:'))
setAttribute('data-x-src', value); {
value = value.slice(4); value = value.slice(4);
attachment = findAttachmentByCid(value); setAttribute('data-x-src-cid', value);
if (attachment?.download) { attachment = findAttachmentByCid(value);
oElement.src = attachment.linkPreview(); if (attachment?.download) {
// oElement.title || (oElement.title = '+attachment.fileName); oElement.src = attachment.linkPreview();
attachment.isInline(true); oElement.title += ' ('+attachment.fileName+')';
attachment.isLinked(true); attachment.isInline(true);
} attachment.isLinked(true);
} }
else if ((attachment = findLocationByCid(value))) }
{ else if ((attachment = findLocationByCid(value)))
if (attachment.download) { {
oElement.src = attachment.linkPreview(); if (attachment.download) {
attachment.isLinked(true); oElement.src = attachment.linkPreview();
} attachment.isLinked(true);
}
else if (detectHiddenImages
&& ((oStyle.maxHeight && 3 > pInt(oStyle.maxHeight)) // TODO: issue with 'in'
|| (oStyle.maxWidth && 3 > pInt(oStyle.maxWidth)) // TODO: issue with 'in'
|| (oStyle.width && 2 > pInt(oStyle.width))
|| [
'email.microsoftemail.com/open',
'github.com/notifications/beacon/',
'/track/open', // mandrillapp.com list-manage.com
'google-analytics.com'
].filter(uri => value.toLowerCase().includes(uri)).length
)) {
skipStyle = true;
oStyle.display = 'none';
// setAttribute('style', 'display:none');
setAttribute('data-x-src-hidden', value);
// result.tracking = true;
}
else if (httpre.test(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 = src.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
}
else if (value.startsWith('data:image/'))
{
oElement.src = value;
}
else
{
setAttribute('data-x-src-broken', value);
} }
} }
else if (detectHiddenImages
&& ((oStyle.maxHeight && 3 > pInt(oStyle.maxHeight)) // TODO: issue with 'in'
|| (oStyle.maxWidth && 3 > pInt(oStyle.maxWidth)) // TODO: issue with 'in'
|| [
'email.microsoftemail.com/open',
'github.com/notifications/beacon/',
'/track/open', // mandrillapp.com list-manage.com
'google-analytics.com'
].filter(uri => value.toLowerCase().includes(uri)).length
)) {
skipStyle = true;
oStyle.display = 'none';
// setAttribute('style', 'display:none');
setAttribute('data-x-src-hidden', value);
}
else if (httpre.test(value))
{
setAttribute('data-x-src', value);
result.hasExternals = true;
oElement.alt || (oElement.alt = value.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
}
else if (value.startsWith('data:image/'))
{
oElement.src = value;
}
else else
{ {
setAttribute('data-x-src-broken', value); setAttribute('data-x-src-broken', value);
} }
} }
else
{
setAttribute('data-x-src-broken', value);
}
} }
if (hasAttribute('background')) { if (hasAttribute('background')) {
oStyle.backgroundImage = 'url("' + delAttribute('background') + '")'; oStyle.backgroundImage = 'url("' + getAttribute('background') + '")';
delAttribute('background');
} }
if (hasAttribute('bgcolor')) { if (hasAttribute('bgcolor')) {
oStyle.backgroundColor = delAttribute('bgcolor'); oStyle.backgroundColor = getAttribute('bgcolor');
delAttribute('bgcolor');
} }
if (hasAttribute('color')) { if (hasAttribute('color')) {
oStyle.color = delAttribute('color'); oStyle.color = getAttribute('color');
delAttribute('color');
} }
if (!skipStyle) { if (!skipStyle) {
@ -627,10 +408,8 @@ export const
setAttribute('data-x-style-broken-urls', JSON.stringify(urls_broken)); setAttribute('data-x-style-broken-urls', JSON.stringify(urls_broken));
} }
// https://github.com/the-djmaze/snappymail/issues/1082 if (11 > pInt(oStyle.fontSize)) {
if (minFontSize > pInt(oStyle.fontSize)) { oStyle.removeProperty('font-size');
oStyle.fontSize = minFontSize + "px";
// oStyle.removeProperty('font-size');
} }
// Removes background and color // Removes background and color
@ -642,7 +421,8 @@ export const
oStyle.removeProperty('color'); oStyle.removeProperty('color');
} }
oStyle.cssText && (oStyle.cssText = cleanCSS(oStyle.cssText)); // Drop Microsoft Office style properties
// oStyle.cssText = oStyle.cssText.replace(/mso-[^:;]+:[^;]+/gi, '');
} }
if (debug && aAttrsForRemove.length) { if (debug && aAttrsForRemove.length) {
@ -650,10 +430,8 @@ export const
} }
}); });
isMsg && blockquoteSwitcher(); // return tpl.content.firstChild;
result.html = tpl.innerHTML.trim();
// return tmpl.content.firstChild;
result.html = tmpl.innerHTML.trim();
return result; return result;
}, },
@ -662,12 +440,9 @@ export const
* @returns {string} * @returns {string}
*/ */
htmlToPlain = html => { htmlToPlain = html => {
if (SettingsUserStore.markdown()) {
return htmlToMarkdown(html);
}
const const
hr = '⎯'.repeat(64), hr = '⎯'.repeat(64),
forEach = (selector, fn) => tmpl.content.querySelectorAll(selector).forEach(fn), forEach = (selector, fn) => tpl.content.querySelectorAll(selector).forEach(fn),
blockquotes = node => { blockquotes = node => {
let bq; let bq;
while ((bq = node.querySelector('blockquote'))) { while ((bq = node.querySelector('blockquote'))) {
@ -685,8 +460,6 @@ export const
html = html html = html
.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gim, (...args) => .replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gim, (...args) =>
1 < args.length ? args[1].toString().replace(/\n/g, '<br>') : '') 1 < args.length ? args[1].toString().replace(/\n/g, '<br>') : '')
// Remove line duplication
.replace(/<br><\/div>/gi, '</div>')
.replace(/\r?\n/g, '') .replace(/\r?\n/g, '')
.replace(/\s+/gm, ' '); .replace(/\s+/gm, ' ');
@ -697,12 +470,10 @@ export const
html = html.replace(/\n*<\/(div|tr)(\s[\s\S]*?)?>\n*/gi, '\n'); html = html.replace(/\n*<\/(div|tr)(\s[\s\S]*?)?>\n*/gi, '\n');
} }
tmpl.innerHTML = html tpl.innerHTML = html
.replace(/<t[dh](\s[\s\S]*?)?>/gi, '\t') .replace(/<t[dh](\s[\s\S]*?)?>/gi, '\t')
.replace(/<\/tr(\s[\s\S]*?)?>/gi, '\n'); .replace(/<\/tr(\s[\s\S]*?)?>/gi, '\n');
forEach('style', node => node.remove());
// lines // lines
forEach('hr', node => node.replaceWith(`\n\n${hr}\n\n`)); forEach('hr', node => node.replaceWith(`\n\n${hr}\n\n`));
@ -739,9 +510,7 @@ export const
forEach('a', a => { forEach('a', a => {
let txt = a.textContent, href = a.href; let txt = a.textContent, href = a.href;
return a.replaceWith( return a.replaceWith(
txt.replace(/[\s()-]+/g, '').includes(href.replace(/^[a-z]:/, '').replace(/[\s()-]+/g, '')) txt.trim() == href || href.includes('mailto:') ? txt : txt + ' ' + href + ' '
? txt
: txt + ' ' + href + ' '
); );
}); });
@ -751,21 +520,16 @@ export const
forEach('i,em', i => i.replaceWith(`*${i.textContent}*`)); forEach('i,em', i => i.replaceWith(`*${i.textContent}*`));
// Convert line-breaks // Convert line-breaks
tmpl.innerHTML = tmpl.innerHTML tpl.innerHTML = tpl.innerHTML
.replace(/\n{3,}/gm, '\n\n') .replace(/\n{3,}/gm, '\n\n')
.replace(/\n<br[^>]*>/g, '\n') .replace(/\n<br[^>]*>/g, '\n')
.replace(/<br[^>]*>\n/g, '\n'); .replace(/<br[^>]*>\n/g, '\n');
forEach('br', br => br.replaceWith('\n')); forEach('br', br => br.replaceWith('\n'));
// Blockquotes must be last // Blockquotes must be last
blockquotes(tmpl.content); blockquotes(tpl.content);
return (tmpl.content.textContent || '').trim(); return (tpl.content.textContent || '').trim();
},
htmlToMarkdown = html => {
tmpl.innerHTML = html;
return turndown.turndown(tmpl.content);
}, },
/** /**
@ -776,9 +540,7 @@ export const
plainToHtml = plain => { plainToHtml = plain => {
plain = plain.toString() plain = plain.toString()
.replace(/\r/g, '') .replace(/\r/g, '')
.replace(/^>[> ]>+/gm, ([match]) => (match ? match.replace(/[ ]+/g, '') : match)) .replace(/^>[> ]>+/gm, ([match]) => (match ? match.replace(/[ ]+/g, '') : match));
// https://github.com/the-djmaze/snappymail/issues/900
.replace(/\u2028/g,' ');
let bIn = false, let bIn = false,
bDo = true, bDo = true,
@ -819,28 +581,200 @@ export const
aText = aNextText; aText = aNextText;
} while (bDo); } while (bDo);
tmpl.innerHTML = aText.join('\n') return aText.join('\n')
// .replace(/~~~\/blockquote~~~\n~~~blockquote~~~/g, '\n') // .replace(/~~~\/blockquote~~~\n~~~blockquote~~~/g, '\n')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(urlRegExp, (...m) => { .replace(url, (...m) => {
m[0] = stripTracking(m[0]); m[2] = stripTracking(m[2]);
return `<a href="${m[0]}" target="_blank">${m[0]}</a>`; return `${m[1]}<a href="${m[2]}" target="_blank">${m[2]}</a>`;
}) })
.replace(email, '$1<a href="mailto:$2">$2</a>') .replace(email, '$1<a href="mailto:$2">$2</a>')
.replace(tel, '<a href="$1">$1</a>') .replace(tel, '$1<a href="$2">$2</a>')
.replace(/~~~blockquote~~~\s*/g, '<blockquote>') .replace(/~~~blockquote~~~\s*/g, '<blockquote>')
.replace(/\s*~~~\/blockquote~~~/g, '</blockquote>') .replace(/\s*~~~\/blockquote~~~/g, '</blockquote>')
.replace(/\n/g, '<br>'); .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 = { rl.Utils = {
cleanHtml: cleanHtml,
htmlToPlain: htmlToPlain, htmlToPlain: htmlToPlain,
plainToHtml: plainToHtml, plainToHtml: plainToHtml
htmlToMarkdown: htmlToMarkdown
// markdownToHtml: md => marked.parse(md)
}; };

View file

@ -1,161 +0,0 @@
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(''));
}
}

View file

@ -1,13 +1,13 @@
import { pInt } from 'Common/Utils'; import { pInt } from 'Common/Utils';
import { doc, Settings, SettingsAdmin } from 'Common/Globals'; import { Settings } from 'Common/Globals';
const const
BASE = doc.location.pathname.replace(/\/+$/,'') + '/',
HASH_PREFIX = '#/', HASH_PREFIX = '#/',
SERVER_PREFIX = './?',
adminPath = () => rl.adminArea() && !SettingsAdmin('host'), adminPath = () => rl.adminArea() && !Settings.app('adminHostUse'),
prefix = () => BASE + '?' + (adminPath() ? SettingsAdmin('path') : ''); prefix = () => SERVER_PREFIX + (adminPath() ? Settings.app('adminPath') : '');
export const export const
SUB_QUERY_PREFIX = '&q[]=', SUB_QUERY_PREFIX = '&q[]=',
@ -21,7 +21,7 @@ export const
/** /**
* @returns {string} * @returns {string}
*/ */
logoutLink = () => adminPath() ? prefix() : BASE, logoutLink = () => adminPath() ? prefix() : './',
/** /**
* @param {string} type * @param {string} type
@ -30,22 +30,22 @@ export const
* @returns {string} * @returns {string}
*/ */
serverRequestRaw = (type, hash) => serverRequestRaw = (type, hash) =>
BASE + '?/Raw/' + SUB_QUERY_PREFIX + '/' SERVER_PREFIX + '/Raw/' + SUB_QUERY_PREFIX + '/'
+ '0/' // Settings.get('accountHash') ? + '0/' // Settings.get('AccountHash') ?
+ (type + (type
? type + '/' + (hash ? SUB_QUERY_PREFIX + '/' + hash : '') ? type + '/' + (hash ? SUB_QUERY_PREFIX + '/' + hash : '')
: ''), : ''),
/** /**
* @param {string} download * @param {string} download
* @param {string=} customSpecSuffix
* @returns {string} * @returns {string}
*/ */
attachmentDownload = (download) => attachmentDownload = (download, customSpecSuffix) =>
serverRequestRaw('Download', download), serverRequestRaw('Download', download, customSpecSuffix),
proxy = url => proxy = url =>
BASE + '?/ProxyExternal/' SERVER_PREFIX + '/ProxyExternal/'
// + btoa(JSON.stringify([token,url]).replace(/ /g, '%20')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
+ btoa(url.replace(/ /g, '%20')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''), + btoa(url.replace(/ /g, '%20')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
// + b64EncodeJSONSafe(url.replace(/ /g, '%20')), // + b64EncodeJSONSafe(url.replace(/ /g, '%20')),
@ -55,16 +55,13 @@ export const
*/ */
serverRequest = type => prefix() + '/' + type + '/' + SUB_QUERY_PREFIX + '/0/', serverRequest = type => prefix() + '/' + type + '/' + SUB_QUERY_PREFIX + '/0/',
// Is '?/Css/0/Admin' needed?
cssLink = theme => BASE + '?/Css/0/User/-/' + encodeURI(theme) + '/-/' + Date.now() + '/Hash/-/Json/',
/** /**
* @param {string} lang * @param {string} lang
* @param {boolean} isAdmin * @param {boolean} isAdmin
* @returns {string} * @returns {string}
*/ */
langLink = (lang, isAdmin) => langLink = (lang, isAdmin) =>
BASE + '?/Lang/0/' + (isAdmin ? 'Admin' : 'App') SERVER_PREFIX + '/Lang/0/' + (isAdmin ? 'Admin' : 'App')
+ '/' + encodeURI(lang) + '/' + encodeURI(lang)
+ '/' + Settings.app('version') + '/', + '/' + Settings.app('version') + '/',
@ -79,10 +76,6 @@ export const
* @returns {string} * @returns {string}
*/ */
themePreviewLink = theme => { themePreviewLink = theme => {
if (theme.endsWith('@nextcloud')) {
theme = theme.slice(0, theme.length - 10).trim();
return parent.OC.webroot + '/themes/' + encodeURI(theme) + '/snappymail/preview.png';
}
let path = 'webVersionPath'; let path = 'webVersionPath';
if (theme.endsWith('@custom')) { if (theme.endsWith('@custom')) {
theme = theme.slice(0, theme.length - 7).trim(); theme = theme.slice(0, theme.length - 7).trim();

View file

@ -7,7 +7,7 @@ import { koComputable } from 'External/ko';
oCallbacks: oCallbacks:
ItemSelect ItemSelect
MiddleClick MiddleClick
canSelect AutoSelect
ItemGetUid ItemGetUid
UpOrDown UpOrDown
*/ */
@ -21,13 +21,15 @@ export class Selector {
* @param {koProperty} koFocusedItem * @param {koProperty} koFocusedItem
* @param {string} sItemSelector * @param {string} sItemSelector
* @param {string} sItemCheckedSelector * @param {string} sItemCheckedSelector
* @param {string} sItemFocusedSelector
*/ */
constructor( constructor(
koList, koList,
koSelectedItem, koSelectedItem,
koFocusedItem, koFocusedItem,
sItemSelector, sItemSelector,
sItemCheckedSelector sItemCheckedSelector,
sItemFocusedSelector
) { ) {
koFocusedItem = (koFocusedItem || ko.observable(null)).extend({ toggleSubscribeProperty: [this, 'focused'] }); koFocusedItem = (koFocusedItem || ko.observable(null)).extend({ toggleSubscribeProperty: [this, 'focused'] });
koSelectedItem = (koSelectedItem || ko.observable(null)).extend({ toggleSubscribeProperty: [null, 'selected'] }); koSelectedItem = (koSelectedItem || ko.observable(null)).extend({ toggleSubscribeProperty: [null, 'selected'] });
@ -44,7 +46,7 @@ export class Selector {
this.sItemSelector = sItemSelector; this.sItemSelector = sItemSelector;
this.sItemCheckedSelector = sItemCheckedSelector; this.sItemCheckedSelector = sItemCheckedSelector;
this.sItemFocusedSelector = sItemSelector + '.focused'; this.sItemFocusedSelector = sItemFocusedSelector;
this.sLastUid = ''; this.sLastUid = '';
this.oCallbacks = {}; this.oCallbacks = {};
@ -63,8 +65,8 @@ export class Selector {
this.listChecked.subscribe(items => { this.listChecked.subscribe(items => {
if (items.length) { if (items.length) {
koSelectedItem() ? koSelectedItem(null) : koSelectedItem.valueHasMutated?.(); koSelectedItem() ? koSelectedItem(null) : koSelectedItem.valueHasMutated?.();
} else { } else if (this.autoSelect()) {
this.autoSelect(); koSelectedItem(koFocusedItem());
} }
}); });
@ -72,7 +74,7 @@ export class Selector {
koSelectedItem.subscribe(item => { koSelectedItem.subscribe(item => {
if (item) { if (item) {
// koList.forEach(subItem => subItem.checked(false)); koList.forEach(subItem => subItem.checked(false));
selectedItemUseCallback && itemSelectedThrottle(item); selectedItemUseCallback && itemSelectedThrottle(item);
} else { } else {
selectedItemUseCallback && itemSelected(); selectedItemUseCallback && itemSelected();
@ -85,7 +87,8 @@ export class Selector {
* Below code is used to keep checked/focused/selected states when array is refreshed. * Below code is used to keep checked/focused/selected states when array is refreshed.
*/ */
let aCheckedCache = [], let aCache = [],
aCheckedCache = [],
mFocused = null, mFocused = null,
mSelected = null; mSelected = null;
@ -96,6 +99,7 @@ export class Selector {
items.forEach(item => { items.forEach(item => {
const uid = this.getItemUid(item); const uid = this.getItemUid(item);
if (uid) { if (uid) {
aCache.push(uid);
item.checked() && aCheckedCache.push(uid); item.checked() && aCheckedCache.push(uid);
if (!mFocused && item.focused()) { if (!mFocused && item.focused()) {
mFocused = uid; mFocused = uid;
@ -114,12 +118,12 @@ export class Selector {
koList.subscribe(aItems => { koList.subscribe(aItems => {
selectedItemUseCallback = false; selectedItemUseCallback = false;
this.unselect(); koFocusedItem(null);
koSelectedItem(null);
if (isArray(aItems)) { if (isArray(aItems)) {
let temp, let temp,
isChecked, isChecked;
next = this.iFocusedNextHelper || this.iSelectNextHelper;
aItems.forEach(item => { aItems.forEach(item => {
const uid = this.getItemUid(item); const uid = this.getItemUid(item);
@ -144,10 +148,24 @@ export class Selector {
selectedItemUseCallback = true; selectedItemUseCallback = true;
if (next && aItems.length && !koFocusedItem()) { if (
temp = aItems[-1 === next ? aItems.length - 1 : 0]; (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 (temp) { if (temp) {
this.iSelectNextHelper && koSelectedItem(temp); if (this.iSelectNextHelper) {
koSelectedItem(temp);
}
koFocusedItem(temp); koFocusedItem(temp);
@ -159,9 +177,12 @@ export class Selector {
this.iFocusedNextHelper = 0; this.iFocusedNextHelper = 0;
} }
!isChecked && !koSelectedItem() && this.autoSelect(); if (this.autoSelect() && !isChecked && !koSelectedItem()) {
koSelectedItem(koFocusedItem());
}
} }
aCache = [];
aCheckedCache = []; aCheckedCache = [];
mFocused = null; mFocused = null;
mSelected = null; mSelected = null;
@ -185,11 +206,10 @@ export class Selector {
addEventsListeners(contentScrollable, { addEventsListeners(contentScrollable, {
click: event => { click: event => {
const el = event.target.closestWithin(this.sItemSelector, contentScrollable); let el = event.target.closestWithin(this.sItemSelector, contentScrollable);
let item = el && ko.dataFor(el); el && this.actionClick(ko.dataFor(el), event);
el && (this.oCallbacks.click || (()=>1))(event, item) && this.actionClick(item, event);
item = getItem(this.sItemCheckedSelector); const item = getItem(this.sItemCheckedSelector);
if (item) { if (item) {
if (event.shiftKey) { if (event.shiftKey) {
this.actionClick(item, event); this.actionClick(item, event);
@ -234,10 +254,8 @@ export class Selector {
/** /**
* @returns {boolean} * @returns {boolean}
*/ */
autoSelect(bForce) { autoSelect() {
(bForce || (this.oCallbacks.canSelect || (()=>1))()) return (this.oCallbacks.AutoSelect || (()=>1))() && this.focusedItem();
&& this.focusedItem()
&& this.selectedItem(this.focusedItem());
} }
/** /**
@ -254,11 +272,10 @@ export class Selector {
* @param {boolean=} bForceSelect = false * @param {boolean=} bForceSelect = false
*/ */
newSelectPosition(sEventKey, bShiftKey, bForceSelect) { newSelectPosition(sEventKey, bShiftKey, bForceSelect) {
let result; let isArrow = 'ArrowUp' === sEventKey || 'ArrowDown' === sEventKey,
result;
const up = 'ArrowUp' === sEventKey, const pageStep = 10,
isArrow = up || 'ArrowDown' === sEventKey,
pageStep = 10,
list = this.list(), list = this.list(),
listLen = list.length, listLen = list.length,
focused = this.focusedItem(); focused = this.focusedItem();
@ -270,7 +287,8 @@ export class Selector {
} else if (listLen) { } else if (listLen) {
if (focused) { if (focused) {
if (isArrow) { if (isArrow) {
let i = list.indexOf(focused); let i = list.indexOf(focused),
up = 'ArrowUp' == sEventKey;
if (bShiftKey) { if (bShiftKey) {
shiftStart = -1 < shiftStart ? shiftStart : i; shiftStart = -1 < shiftStart ? shiftStart : i;
shiftStart == i shiftStart == i
@ -313,7 +331,9 @@ export class Selector {
if (result) { if (result) {
this.focusedItem(result); this.focusedItem(result);
!this.list.hasChecked() && this.autoSelect(bForceSelect); if ((this.autoSelect() || bForceSelect) && !this.list.hasChecked()) {
this.selectedItem(result);
}
this.scrollToFocused(); this.scrollToFocused();
} }
} }

View file

@ -1,5 +1,5 @@
import ko from 'ko'; import ko from 'ko';
import { Notifications, UploadErrorCode } from 'Common/Enums'; import { Notification, UploadErrorCode } from 'Common/Enums';
import { langLink } from 'Common/Links'; import { langLink } from 'Common/Links';
import { doc, createElement } from 'Common/Globals'; import { doc, createElement } from 'Common/Globals';
import { getKeyByValue, forEachObjectEntry } from 'Common/Utils'; import { getKeyByValue, forEachObjectEntry } from 'Common/Utils';
@ -13,7 +13,6 @@ const
if (rl.I18N) { if (rl.I18N) {
I18N_DATA = rl.I18N; I18N_DATA = rl.I18N;
rl.I18N = null; rl.I18N = null;
doc.documentElement.dir = I18N_DATA.LANG_DIR;
return 1; return 1;
} }
}, },
@ -21,8 +20,8 @@ const
i18nKey = key => key.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(), i18nKey = key => key.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(),
getNotificationMessage = code => { getNotificationMessage = code => {
let key = getKeyByValue(Notifications, code); let key = getKeyByValue(Notification, code);
return key ? I18N_DATA.NOTIFICATIONS[key] : ''; return key ? I18N_DATA.NOTIFICATIONS[i18nKey(key).replace('_NOTIFICATION', '_ERROR')] : '';
}, },
fromNow = date => relativeTime(Math.round((date.getTime() - Date.now()) / 1000)); fromNow = date => relativeTime(Math.round((date.getTime() - Date.now()) / 1000));
@ -45,9 +44,9 @@ export const
} }
} }
if (Intl.RelativeTimeFormat) { if (Intl.RelativeTimeFormat) {
return (new Intl.RelativeTimeFormat(doc.documentElement.lang)).format(seconds, unit); let rtf = new Intl.RelativeTimeFormat(doc.documentElement.lang);
return rtf.format(seconds, unit);
} }
// Safari < 14
abs = Math.abs(seconds); abs = Math.abs(seconds);
let rtf = rl.relativeTime.long[unit][0 > seconds ? 'past' : 'future'], let rtf = rl.relativeTime.long[unit][0 > seconds ? 'past' : 'future'],
plural = rl.relativeTime.plural(abs); plural = rl.relativeTime.plural(abs);
@ -61,7 +60,7 @@ export const
* @returns {string} * @returns {string}
*/ */
i18n = (key, valueList, defaulValue) => { i18n = (key, valueList, defaulValue) => {
let result = defaulValue ?? key; let result = null == defaulValue ? key : defaulValue;
let path = key.split('/'); let path = key.split('/');
if (I18N_DATA[path[0]] && path[1]) { if (I18N_DATA[path[0]] && path[1]) {
result = I18N_DATA[path[0]][path[1]] || result; result = I18N_DATA[path[0]][path[1]] || result;
@ -101,13 +100,14 @@ export const
timestampToString = (timeStampInUTC, formatStr) => { timestampToString = (timeStampInUTC, formatStr) => {
const now = Date.now(), const now = Date.now(),
time = 0 < timeStampInUTC ? timeStampInUTC * 1000 : (0 === timeStampInUTC ? now : 0); time = 0 < timeStampInUTC ? Math.min(now, timeStampInUTC * 1000) : (0 === timeStampInUTC ? now : 0);
if (31536000000 < time) { if (31536000000 < time) {
const m = new Date(time), h = LanguageStore.hourCycle(); const m = new Date(time), h = LanguageStore.hourCycle();
switch (formatStr) { switch (formatStr) {
case 'FROMNOW': case 'FROMNOW':
return fromNow(m); return fromNow(m);
case 'AUTO': { case 'SHORT': {
// 4 hours // 4 hours
if (14400000 >= now - time) if (14400000 >= now - time)
return fromNow(m); return fromNow(m);
@ -119,9 +119,7 @@ export const
{TIME: m.format('LT',0,h)} {TIME: m.format('LT',0,h)}
) )
: m.format( : m.format(
date.getFullYear() === m.getFullYear() date.getFullYear() === m.getFullYear() ? {day: '2-digit', month: 'short'} : {dateStyle: 'medium'}
? {day: '2-digit', month: 'short', hour: 'numeric', minute: 'numeric'}
: {dateStyle: 'medium', timeStyle: 'short'}
, 0, h); , 0, h);
} }
case 'FULL': case 'FULL':
@ -142,7 +140,7 @@ export const
time = Date.parse(element.dateTime) / 1000; time = Date.parse(element.dateTime) / 1000;
} }
let key = element.dataset.timeFormat; let key = element.dataset.momentFormat;
if (key) { if (key) {
element.textContent = timestampToString(time, key); element.textContent = timestampToString(time, key);
if ('FULL' !== key && 'FROMNOW' !== key) { if ('FULL' !== key && 'FROMNOW' !== key) {
@ -175,7 +173,7 @@ export const
*/ */
getNotification = (code, message = '', defCode = 0) => { getNotification = (code, message = '', defCode = 0) => {
code = pInt(code); code = pInt(code);
if (Notifications.ClientViewError === code && message) { if (Notification.ClientViewError === code && message) {
return message; return message;
} }
@ -184,9 +182,6 @@ export const
|| ''; || '';
}, },
getErrorMessage = (code, data) =>
getNotification(code) || data?.messageAdditional || data?.message || data,
/** /**
* @param {*} code * @param {*} code
* @returns {string} * @returns {string}
@ -213,13 +208,14 @@ export const
script.remove(); script.remove();
resolve(); resolve();
}; };
script.onerror = () => reject(Error('Language '+language+' failed')); script.onerror = () => reject(new Error('Language '+language+' failed'));
script.src = langLink(language, admin); script.src = langLink(language, admin);
// script.async = true; // script.async = true;
doc.head.append(script); doc.head.append(script);
}), }),
/** /**
*
* @param {string} language * @param {string} language
* @param {boolean=} isEng = false * @param {boolean=} isEng = false
* @returns {string} * @returns {string}
@ -229,8 +225,6 @@ export const
'LANGS_NAMES' + (true === isEng ? '_EN' : '') + '/' + language, 'LANGS_NAMES' + (true === isEng ? '_EN' : '') + '/' + language,
null, null,
language language
), );
baseCollator = numeric => new Intl.Collator(doc.documentElement.lang, {numeric: !!numeric, sensitivity: 'base'});
init(); init();

View file

@ -10,22 +10,19 @@ export const
pInt = (value, defaultValue = 0) => { pInt = (value, defaultValue = 0) => {
value = parseInt(value, 10); value = parseInt(value, 10);
return isFinite(value) ? value : defaultValue; return isNaN(value) || !isFinite(value) ? defaultValue : value;
}, },
defaultOptionsAfterRender = (domItem, item) => defaultOptionsAfterRender = (domItem, item) =>
item && undefined !== item.disabled && domItem?.classList.toggle('disabled', domItem.disabled = item.disabled), item && undefined !== item.disabled && domItem?.classList.toggle('disabled', domItem.disabled = item.disabled),
// unescape(encodeURIComponent()) makes the UTF-16 DOMString to an UTF-8 string // unescape(encodeURIComponent()) makes the UTF-16 DOMString to an UTF-8 string
b64Encode = data => btoa(unescape(encodeURIComponent(data))), b64EncodeJSON = data => btoa(unescape(encodeURIComponent(JSON.stringify(data)))),
/* // Without deprecated 'unescape': /* // Without deprecated 'unescape':
b64Encode = data => btoa(encodeURIComponent(data).replace( b64EncodeJSON = data => btoa(encodeURIComponent(JSON.stringify(data)).replace(
/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode('0x' + p1) /%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode('0x' + p1)
)), )),
*/ */
b64EncodeJSON = data => b64Encode(JSON.stringify(data)),
b64EncodeJSONSafe = data => b64EncodeJSON(data).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''), b64EncodeJSONSafe = data => b64EncodeJSON(data).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
getKeyByValue = (o, v) => Object.keys(o).find(key => o[key] === v); getKeyByValue = (o, v) => Object.keys(o).find(key => o[key] === v);

View file

@ -1,68 +1,26 @@
import { Notifications } from 'Common/Enums'; import { MessageFlagsCache } from 'Common/Cache';
import { Notification } from 'Common/Enums';
import { MessageSetAction, ComposeType/*, FolderType*/ } from 'Common/EnumsUser'; import { MessageSetAction, ComposeType/*, FolderType*/ } from 'Common/EnumsUser';
import { doc, createElement, elementById, dropdowns, dropdownVisibility, SettingsGet, leftPanelDisabled } from 'Common/Globals'; import { doc, createElement, elementById, dropdowns, dropdownVisibility, SettingsGet, leftPanelDisabled } from 'Common/Globals';
import { plainToHtml } from 'Common/Html'; import { plainToHtml } from 'Common/Html';
import { getNotification } from 'Common/Translator'; import { getNotification } from 'Common/Translator';
import { EmailCollectionModel } from 'Model/EmailCollection'; import { EmailModel } from 'Model/Email';
import { MessageModel } from 'Model/Message';
import { MessageUserStore } from 'Stores/User/Message'; import { MessageUserStore } from 'Stores/User/Message';
import { MessagelistUserStore } from 'Stores/User/Messagelist'; import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { SettingsUserStore } from 'Stores/User/Settings'; import { SettingsUserStore } from 'Stores/User/Settings';
import * as Local from 'Storage/Client'; import * as Local from 'Storage/Client';
import { ThemeStore } from 'Stores/Theme'; import { ThemeStore } from 'Stores/Theme';
import Remote from 'Remote/User/Fetch'; 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 export const
// 1 = move, 2 = copy moveAction = ko.observable(false),
moveAction = ko.observable(0),
dropdownsDetectVisibility = (() => dropdownsDetectVisibility = (() =>
dropdownVisibility(!!dropdowns.find(item => item.classList.contains('show'))) dropdownVisibility(!!dropdowns.find(item => item.classList.contains('show')))
).debounce(50), ).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 * @param {string} link
* @returns {boolean} * @returns {boolean}
@ -84,29 +42,6 @@ download = (link, name = "") => {
} }
}, },
downloadZip = (name, hashes, onError, fTrigger, folder) => {
if (hashes.length) {
let params = {
target: 'zip',
filename: name,
hashes: hashes
};
if (!onError) {
onError = () => alert('Download failed');
}
if (folder) {
params.folder = folder;
// params.uids = uids;
}
Remote.post('AttachmentsActions', fTrigger || null, params)
.then(result => {
let hash = result?.Result?.fileHash;
hash ? download(attachmentDownload(hash), hash+'.zip') : onError();
})
.catch(onError);
}
},
/** /**
* @returns {function} * @returns {function}
*/ */
@ -115,15 +50,14 @@ computedPaginatorHelper = (koCurrentPage, koPageCount) => {
const currentPage = koCurrentPage(), const currentPage = koCurrentPage(),
pageCount = koPageCount(), pageCount = koPageCount(),
result = [], result = [],
lang = doc.documentElement.lang,
fAdd = (index, push = true, customName = '') => { fAdd = (index, push = true, customName = '') => {
const name = index.toLocaleString(lang), const data = {
data = { current: index === currentPage,
current: index === currentPage, name: customName ? customName.toString() : index.toString(),
name: customName || name, custom: !!customName,
title: customName ? name : '', title: customName ? index.toString() : '',
value: index value: index.toString()
}; };
push ? result.push(data) : result.unshift(data); push ? result.push(data) : result.unshift(data);
}; };
@ -132,7 +66,7 @@ computedPaginatorHelper = (koCurrentPage, koPageCount) => {
next = 0, next = 0,
limit = 2; limit = 2;
if (1 < pageCount) { if (1 < pageCount || (0 < pageCount && pageCount < currentPage)) {
if (pageCount < currentPage) { if (pageCount < currentPage) {
fAdd(pageCount); fAdd(pageCount);
prev = pageCount; prev = pageCount;
@ -199,15 +133,29 @@ mailToHelper = mailToUrl => {
mailToUrl = mailToUrl.slice(7).split('?'); mailToUrl = mailToUrl.slice(7).split('?');
const const
email = decodeURIComponent(mailToUrl[0]), email = mailToUrl[0],
params = new URLSearchParams(mailToUrl[1]), params = new URLSearchParams(mailToUrl[1]),
to = params.get('to'), toEmailModel = value => null != value ? EmailModel.parseEmailLine(value) : null;
toEmailModel = value => EmailCollectionModel.fromString(value);
showMessageComposer([ showMessageComposer([
ComposeType.Empty, ComposeType.Empty,
null, null,
toEmailModel(to ? email + ',' + to : email), params.get('to')
? Object.values(
toEmailModel(email + ',' + params.get('to')).reduce((result, value) => {
if (value) {
if (result[value.email]) {
if (!result[value.email].name) {
result[value.email] = value;
}
} else {
result[value.email] = value;
}
}
return result;
}, {})
)
: EmailModel.parseEmailLine(email),
toEmailModel(params.get('cc')), toEmailModel(params.get('cc')),
toEmailModel(params.get('bcc')), toEmailModel(params.get('bcc')),
params.get('subject'), params.get('subject'),
@ -225,24 +173,37 @@ showMessageComposer = (params = []) =>
rl.app.showMessageComposer(params); rl.app.showMessageComposer(params);
}, },
setLayoutResizer = (source, sClientSideKeyName, mode) => setLayoutResizer = (source, target, sClientSideKeyName, mode) =>
{ {
if (source.layoutResizer && source.layoutResizer.mode != mode) { if (source.layoutResizer && source.layoutResizer.mode != mode) {
target.removeAttribute('style');
source.removeAttribute('style'); source.removeAttribute('style');
} }
source.observer?.disconnect(); source.observer?.disconnect();
// source.classList.toggle('resizable', mode); // source.classList.toggle('resizable', mode);
if (mode) { if (mode) {
const length = Local.get(sClientSideKeyName + mode) || SettingsGet('Resizer' + sClientSideKeyName + mode); const length = Local.get(sClientSideKeyName + mode) || SettingsGet('Resizer' + sClientSideKeyName + mode),
setTargetPos = mode => {
let value;
if ('Width' == mode) {
value = source.offsetWidth;
target.style.left = value + 'px';
} else {
value = source.offsetHeight;
target.style.top = (4 + source.offsetTop + value) + 'px';
}
return value;
};
if (length) { if (length) {
source.style[mode.toLowerCase()] = length + 'px'; source.style[mode.toLowerCase()] = length + 'px';
setTargetPos(mode);
} }
if (!source.layoutResizer) { if (!source.layoutResizer) {
const resizer = createElement('div', {'class':'resizer'}), const resizer = createElement('div', {'class':'resizer'}),
save = (data => Remote.saveSettings(0, data)).debounce(500), save = (data => Remote.saveSettings(0, data)).debounce(500),
size = {}, size = {},
store = () => { store = () => {
const value = ('Width' == resizer.mode) ? source.offsetWidth : source.offsetHeight, const value = setTargetPos(resizer.mode),
prop = resizer.key + resizer.mode; prop = resizer.key + resizer.mode;
(value == Local.get(prop)) || Local.set(prop, value); (value == Local.get(prop)) || Local.set(prop, value);
(value == SettingsGet('Resizer' + prop)) || save({['Resizer' + prop]: value}); (value == SettingsGet('Resizer' + prop)) || save({['Resizer' + prop]: value});
@ -289,86 +250,114 @@ setLayoutResizer = (source, sClientSideKeyName, mode) =>
} }
}, },
viewMessage = (oMessage, popup) => {
if (popup) {
oMessage.popupMessage();
} else {
MessageUserStore.error('');
let id = 'rl-msg-' + oMessage.hash,
body = oMessage.body || elementById(id);
if (!body) {
body = createElement('div',{
id:id,
hidden:'',
class:'b-text-part'
+ (oMessage.pgpSigned() ? ' openpgp-signed' : '')
+ (oMessage.pgpEncrypted() ? ' openpgp-encrypted' : '')
+ (oMessage.smimeSigned() ? ' smime-signed' : '')
+ (oMessage.smimeEncrypted() ? ' smime-encrypted' : '')
});
MessageUserStore.purgeCache();
}
body.message = oMessage;
oMessage.body = body;
if (!SettingsUserStore.viewHTML() || !oMessage.viewHtml()) {
oMessage.viewPlain();
}
MessageUserStore.bodiesDom().append(body);
MessageUserStore.loading(false);
oMessage.body.hidden = false;
if (oMessage.isUnseen() && SettingsUserStore.messageReadAuto()) {
MessageUserStore.MessageSeenTimer = setTimeout(
() => MessagelistUserStore.setAction(oMessage.folder, MessageSetAction.SetSeen, [oMessage]),
SettingsUserStore.messageReadDelay() * 1000 // seconds
);
}
}
},
populateMessageBody = (oMessage, popup) => { populateMessageBody = (oMessage, popup) => {
if (oMessage) { if (oMessage) {
popup || MessageUserStore.message(oMessage); popup || MessageUserStore.message(oMessage);
if (oMessage.body) { popup || MessageUserStore.loading(true);
viewMessage(oMessage, popup); Remote.message((iError, oData/*, bCached*/) => {
} else { if (iError) {
popup || MessageUserStore.loading(true); if (Notification.RequestAborted !== iError && !popup) {
Remote.message((iError, oData/*, bCached*/) => { MessageUserStore.message(null);
if (iError) { MessageUserStore.error(getNotification(iError));
if (Notifications.RequestAborted !== iError && !popup) { }
MessageUserStore.message(null); } else {
MessageUserStore.error(getNotification(iError)); let json = oData?.Result;
if (
json &&
MessageModel.validJson(json) &&
oMessage.folder === json.Folder
) {
const threads = oMessage.threads(),
isNew = !popup && oMessage.uid != json.Uid && threads.includes(json.Uid),
messagesDom = MessageUserStore.bodiesDom();
if (isNew) {
oMessage = MessageModel.reviveFromJson(json);
if (oMessage) {
oMessage.threads(threads);
MessageFlagsCache.initMessage(oMessage);
// Set clone
oMessage = MessageModel.fromMessageListItem(oMessage);
}
MessageUserStore.message(oMessage);
} }
} else {
let json = oData?.Result; if (oMessage && oMessage.uid == json.Uid) {
if (json popup || MessageUserStore.error('');
&& ((
oMessage.hash && oMessage.hash === json.hash
) || (
!oMessage.hash
&& oMessage.folder === json.folder
&& oMessage.uid == json.uid)
)
&& oMessage.revivePropertiesFromJson(json)
) {
/* /*
if (bCached) { if (bCached) {
delete json.flags; delete json.Flags;
} }
oMessage.body.remove();
*/ */
viewMessage(oMessage, popup); isNew || oMessage.revivePropertiesFromJson(json);
if (messagesDom) {
let id = 'rl-msg-' + oMessage.hash.replace(/[^a-zA-Z0-9]/g, ''),
body = elementById(id);
if (body) {
oMessage.body = body;
oMessage.isHtml(body.classList.contains('html'));
oMessage.hasImages(body.rlHasImages);
} else {
body = Element.fromHTML('<div id="' + id + '" hidden="" class="b-text-part '
+ (oMessage.pgpSigned() ? ' openpgp-signed' : '')
+ (oMessage.pgpEncrypted() ? ' openpgp-encrypted' : '')
+ '">'
+ '</div>');
oMessage.body = body;
if (!SettingsUserStore.viewHTML() || !oMessage.viewHtml()) {
oMessage.viewPlain();
}
MessageUserStore.purgeMessageBodyCache();
}
messagesDom.append(body);
popup || (oMessage.body.hidden = false);
popup && oMessage.viewPopupMessage();
}
MessageFlagsCache.initMessage(oMessage);
if (oMessage.isUnseen()) {
MessageUserStore.MessageSeenTimer = setTimeout(
() => MessagelistUserStore.setAction(oMessage.folder, MessageSetAction.SetSeen, [oMessage]),
SettingsUserStore.messageReadDelay() * 1000 // seconds
);
}
if (isNew) {
let selectedMessage = MessagelistUserStore.selectedMessage();
if (
selectedMessage &&
(oMessage.folder !== selectedMessage.folder || oMessage.uid != selectedMessage.uid)
) {
MessagelistUserStore.selectedMessage(null);
if (1 === MessagelistUserStore.length) {
MessagelistUserStore.focusedMessage(null);
}
} else if (!selectedMessage) {
selectedMessage = MessagelistUserStore.find(
subMessage =>
subMessage &&
subMessage.folder === oMessage.folder &&
subMessage.uid == oMessage.uid
);
if (selectedMessage) {
MessagelistUserStore.selectedMessage(selectedMessage);
MessagelistUserStore.focusedMessage(selectedMessage);
}
}
}
} }
} }
popup || MessageUserStore.loading(false); }
}, oMessage.folder, oMessage.uid); popup || MessageUserStore.loading(false);
} }, oMessage.folder, oMessage.uid);
} }
}; };
leftPanelDisabled.subscribe(value => value && moveAction(0)); leftPanelDisabled.subscribe(value => value && moveAction(false));
moveAction.subscribe(value => value && leftPanelDisabled(false)); moveAction.subscribe(value => value && leftPanelDisabled(false));

View file

@ -1,12 +1,10 @@
export class CheckboxComponent { export class CheckboxComponent {
constructor(params = {}) { constructor(params = {}) {
this.name = params.name;
this.value = ko.isObservable(params.value) ? params.value this.value = ko.isObservable(params.value) ? params.value
: ko.observable(!!params.value); : ko.observable(!!params.value);
this.enable = ko.isObservable(params.enable) ? params.enable this.enable = ko.isObservable(params.enable) ? params.enable
: ko.observable(params.enable ?? 1); : ko.observable(undefined === params.enable || !!params.enable);
this.label = params.label; this.label = params.label;
} }

View file

@ -1,30 +1,8 @@
import { doc, createElement, addEventsListeners } from 'Common/Globals'; import { doc, createElement, addEventsListeners } from 'Common/Globals';
import { EmailModel } from 'Model/Email'; import { EmailModel } from 'Model/Email';
import { addressparser } from 'Mime/Address';
const contentType = 'snappymail/emailaddress', const contentType = 'snappymail/emailaddress',
getAddressKey = li => li?.emailaddress?.key, getAddressKey = li => li?.emailaddress?.key;
parseEmailLine = line => addressparser(line).map(item =>
(item.name || item.email)
? new EmailModel(item.email, item.name) : null
).filter(v => v),
splitEmailLine = line => {
const result = [];
let exists = false;
addressparser(line).forEach(item => {
const address = (item.name || item.email)
? new EmailModel(item.email, item.name)
: null;
if (address?.email) {
exists = true;
}
result.push(address ? address.toLine() : item.name);
});
return exists ? result : null;
};
let dragAddress, datalist; let dragAddress, datalist;
@ -166,9 +144,7 @@ export class EmailAddressesComponent {
_parseInput(force) { _parseInput(force) {
let val = this.input.value; let val = this.input.value;
if ((force || val.includes(',') || val.includes(';') if ((force || val.includes(',') || val.includes(';')) && this._parseValue(val)) {
|| (val.charAt(val.length-1)===' ' && this._simpleEmailMatch(val)))
&& this._parseValue(val)) {
this.input.value = ''; this.input.value = '';
} }
this._resizeInput(); this._resizeInput();
@ -178,8 +154,8 @@ export class EmailAddressesComponent {
if (val) { if (val) {
const self = this, const self = this,
v = val.trim(), v = val.trim(),
hook = (v && [',', ';', '\n'].includes(v.slice(-1))) ? splitEmailLine(val) : null, hook = (v && [',', ';', '\n'].includes(v.slice(-1))) ? EmailModel.splitEmailLine(val) : null,
values = (hook || [val]).map(value => parseEmailLine(value)) values = (hook || [val]).map(value => EmailModel.parseEmailLine(value))
.flat(Infinity) .flat(Infinity)
.map(item => (item.toLine ? [item.toLine(), item] : [item, null])); .map(item => (item.toLine ? [item.toLine(), item] : [item, null]));
@ -287,13 +263,6 @@ 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() { _renderTags() {
let self = this; let self = this;
[...self.ul.children].forEach(node => node !== self.inputCont && node.remove()); [...self.ul.children].forEach(node => node !== self.inputCont && node.remove());

View file

@ -15,7 +15,7 @@ export class JCard {
if (input) { if (input) {
// read from jCard // read from jCard
if (typeof input !== 'object') { if (typeof input !== 'object') {
throw Error('error reading vcard') throw new Error('error reading vcard')
} }
this.parseFromJCard(input) this.parseFromJCard(input)
} }
@ -87,7 +87,7 @@ export class JCard {
arg = new VCardProperty(String(arg), value, params, type); arg = new VCardProperty(String(arg), value, params, type);
} }
if (!(arg instanceof VCardProperty)) { if (!(arg instanceof VCardProperty)) {
throw Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty'); throw new Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty');
} }
let field = arg.getField(); let field = arg.getField();
this.props.set(field, [arg]); this.props.set(field, [arg]);
@ -101,7 +101,7 @@ export class JCard {
arg = new VCardProperty(String(arg), value, params, type); arg = new VCardProperty(String(arg), value, params, type);
} }
if (!(arg instanceof VCardProperty)) { if (!(arg instanceof VCardProperty)) {
throw Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty'); throw new Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty');
} }
// VCardProperty arguments // VCardProperty arguments
let field = arg.getField(); let field = arg.getField();
@ -124,15 +124,15 @@ export class JCard {
// VCardProperty argument // VCardProperty argument
else if (arg instanceof VCardProperty) { else if (arg instanceof VCardProperty) {
let propArray = this.props.get(arg.getField()); let propArray = this.props.get(arg.getField());
if (!propArray?.includes(arg)) if (!(propArray === null || propArray === void 0 ? void 0 : propArray.includes(arg)))
throw Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg)); throw new Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg));
propArray.splice(propArray.indexOf(arg), 1); propArray.splice(propArray.indexOf(arg), 1);
if (propArray.length === 0) if (propArray.length === 0)
this.props.delete(arg.getField()); this.props.delete(arg.getField());
} }
// incorrect arguments // incorrect arguments
else else
throw Error('invalid argument of VCard.remove(), expects ' + throw new Error('invalid argument of VCard.remove(), expects ' +
'string and optional param filter or a VCardProperty'); 'string and optional param filter or a VCardProperty');
} }
@ -202,7 +202,7 @@ export class JCard {
parseFullName(options) { parseFullName(options) {
let n = this.getOne('n'); let n = this.getOne('n');
if (n === undefined) { if (n === undefined) {
throw Error('\'fn\' VCardProperty not present in card, cannot parse full name'); throw new Error('\'fn\' VCardProperty not present in card, cannot parse full name');
} }
let fnString = ''; let fnString = '';
// Position in n -> position in fn // Position in n -> position in fn

View file

@ -55,7 +55,7 @@ export class VCardProperty {
} }
// invalid property // invalid property
else { else {
throw Error('invalid Property constructor'); throw new Error('invalid Property constructor');
} }
} }

View file

@ -3,6 +3,10 @@
(doc => { (doc => {
const const
removeElements = 'HEAD,LINK,META,NOSCRIPT,SCRIPT,TEMPLATE,TITLE',
allowedElements = 'A,B,BLOCKQUOTE,BR,DIV,FONT,H1,H2,H3,H4,H5,H6,HR,IMG,LI,OL,P,SPAN,STRONG,TABLE,TD,TH,TR,U,UL',
allowedAttributes = 'abbr,align,background,bgcolor,border,cellpadding,cellspacing,class,color,colspan,dir,face,frame,height,href,hspace,id,lang,rowspan,rules,scope,size,src,style,target,type,usemap,valign,vspace,width'.split(','),
i18n = (str, def) => rl.i18n(str) || def, i18n = (str, def) => rl.i18n(str) || def,
ctrlKey = shortcuts.getMetaKey() + ' + ', ctrlKey = shortcuts.getMetaKey() + ' + ',
@ -15,19 +19,43 @@ const
htmlToPlain = html => rl.Utils.htmlToPlain(html).trim(), htmlToPlain = html => rl.Utils.htmlToPlain(html).trim(),
plainToHtml = text => rl.Utils.plainToHtml(text), plainToHtml = text => rl.Utils.plainToHtml(text),
forEachObjectValue = (obj, fn) => Object.values(obj).forEach(fn), getFragmentOfChildren = parent => {
let frag = doc.createDocumentFragment();
frag.append(...parent.childNodes);
return frag;
},
SquireDefaultConfig = { SquireDefaultConfig = {
/* /*
blockTag: 'P',
undo: {
documentSizeThreshold: -1, // -1 means no threshold
undoLimit: -1 // -1 means no limit
},
addLinks: true // allow_smart_html_links addLinks: true // allow_smart_html_links
*/ */
sanitizeToDOMFragment: (html, isPaste/*, squire*/) => { sanitizeToDOMFragment: (html, isPaste/*, squire*/) => {
html = (html||'') tpl.innerHTML = (html||'')
.replace(/<\/?(BODY|HTML)[^>]*>/gi,'') .replace(/<\/?(BODY|HTML)[^>]*>/gi,'')
.replace(/<!--[^>]+-->/g,'') .replace(/<!--[^>]+-->/g,'')
.replace(/<span[^>]*>\s*<\/span>/gi,'') .replace(/<span[^>]*>\s*<\/span>/gi,'')
.trim(); .trim();
tpl.innerHTML = isPaste ? rl.Utils.cleanHtml(html).html : html; tpl.querySelectorAll('a:empty,span:empty').forEach(el => el.remove());
if (isPaste) {
tpl.querySelectorAll(removeElements).forEach(el => el.remove());
tpl.querySelectorAll('*').forEach(el => {
if (!el.matches(allowedElements)) {
el.replaceWith(getFragmentOfChildren(el));
} else if (el.hasAttributes()) {
[...el.attributes].forEach(attr => {
let name = attr.name.toLowerCase();
if (!allowedAttributes.includes(name)) {
el.removeAttribute(name);
}
});
}
});
}
return tpl.content; return tpl.content;
} }
}; };
@ -44,8 +72,6 @@ class SquireUI
clr.value = ''; clr.value = '';
clr.onchange = () => squire.setStyle({[name]:clr.value}); clr.onchange = () => squire.setStyle({[name]:clr.value});
// Chrome 110+ https://github.com/the-djmaze/snappymail/issues/1199
// clr.oninput = () => squire.setStyle({[name]:clr.value});
setTimeout(()=>clr.click(),1); setTimeout(()=>clr.click(),1);
}, },
@ -58,7 +84,8 @@ class SquireUI
[i18n('SETTINGS_GENERAL/EDITOR_HTML'),'wysiwyg'], [i18n('SETTINGS_GENERAL/EDITOR_HTML'),'wysiwyg'],
[i18n('SETTINGS_GENERAL/EDITOR_PLAIN'),'plain'] [i18n('SETTINGS_GENERAL/EDITOR_PLAIN'),'plain']
], ],
cmd: s => this.setMode('plain' == s.value ? 'plain' : 'wysiwyg') cmd: s => this.setMode('plain' == s.value ? 'plain' : 'wysiwyg'),
hint: i18n('EDITOR/TEXT_SWITCHER_PLAIN_TEXT', 'Plain')
} }
}, },
font: { font: {
@ -84,45 +111,20 @@ class SquireUI
cmd: s => squire.setStyle({ fontFamily: s.value }) cmd: s => squire.setStyle({ fontFamily: s.value })
}, },
fontSize: { fontSize: {
select: [[i18n('GLOBAL/DEFAULT'),''],'11px','13px','16px','20px','24px','30px'], select: ['11px','13px','16px','20px','24px','30px'],
defaultValueIndex: 0,
cmd: s => squire.setStyle({ fontSize: s.value }) cmd: s => squire.setStyle({ fontSize: s.value })
// TODO: maybe consider using https://developer.mozilla.org/en-US/docs/Web/CSS/font-size#values
// example:
// select: ['','xx-small', 'x-small',' small',' medium', 'large', 'x-large', 'xx-large', 'xxx-large'],
// defaultValueIndex: 0,
},
// dir: {
// select: [
// [i18n('EDITOR/DIR_LTR', 'LTR'),'ltr'],
// [i18n('EDITOR/DIR_RTL', 'RTL'),'rtl'],
// [i18n('EDITOR/DIR_AUTO', 'Auto'),'auto'],
// ['',''],
// ],
// cmd: s => {
// squire.setAttribute('dir', s.value || null);
// // squire.setStyle({ 'unicode-bidi': 'plaintext' });
// }
// }
},
dir: {
dir_ltr: {
html: '⁋',
cmd: () => squire.setTextDirection('ltr')
},
dir_rtl: {
html: '¶',
cmd: () => squire.setTextDirection('rtl')
} }
}, },
colors: { colors: {
textColor: { textColor: {
html: 'A<sub>▾</sub>', html: 'A<sub>▾</sub>',
cmd: doClr('color') cmd: doClr('color'),
hint: 'Text color'
}, },
backgroundColor: { backgroundColor: {
html: '🎨', /* ▧ */ html: '🎨', /* ▧ */
cmd: doClr('backgroundColor') cmd: doClr('backgroundColor'),
hint: 'Background color'
}, },
}, },
inline: { inline: {
@ -130,37 +132,37 @@ class SquireUI
html: 'B', html: 'B',
cmd: () => this.doAction('bold'), cmd: () => this.doAction('bold'),
key: 'B', key: 'B',
matches: 'B,STRONG' hint: 'Bold'
}, },
italic: { italic: {
html: 'I', html: 'I',
cmd: () => this.doAction('italic'), cmd: () => this.doAction('italic'),
key: 'I', key: 'I',
matches: 'I' hint: 'Italic'
}, },
underline: { underline: {
html: '<u>U</u>', html: '<u>U</u>',
cmd: () => this.doAction('underline'), cmd: () => this.doAction('underline'),
key: 'U', key: 'U',
matches: 'U' hint: 'Underline'
}, },
strike: { strike: {
html: '<s>S</s>', html: '<s>S</s>',
cmd: () => this.doAction('strikethrough'), cmd: () => this.doAction('strikethrough'),
key: 'Shift + 7', key: 'Shift + 7',
matches: 'S' hint: 'Strikethrough'
}, },
sub: { sub: {
html: 'Xₙ', html: 'Xₙ',
cmd: () => this.doAction('subscript'), cmd: () => this.doAction('subscript'),
key: 'Shift + 5', key: 'Shift + 5',
matches: 'SUB' hint: 'Subscript'
}, },
sup: { sup: {
html: 'Xⁿ', html: 'Xⁿ',
cmd: () => this.doAction('superscript'), cmd: () => this.doAction('superscript'),
key: 'Shift + 6', key: 'Shift + 6',
matches: 'SUP' hint: 'Superscript'
} }
}, },
block: { block: {
@ -168,13 +170,13 @@ class SquireUI
html: '#', html: '#',
cmd: () => this.doList('OL'), cmd: () => this.doList('OL'),
key: 'Shift + 8', key: 'Shift + 8',
matches: 'OL' hint: 'Ordered list'
}, },
ul: { ul: {
html: '⋮', html: '⋮',
cmd: () => this.doList('UL'), cmd: () => this.doList('UL'),
key: 'Shift + 9', key: 'Shift + 9',
matches: 'UL' hint: 'Unordered list'
}, },
quote: { quote: {
html: '"', html: '"',
@ -182,17 +184,19 @@ class SquireUI
let parent = squire.getSelectionClosest('UL,OL,BLOCKQUOTE')?.nodeName; let parent = squire.getSelectionClosest('UL,OL,BLOCKQUOTE')?.nodeName;
('BLOCKQUOTE' == parent) ? squire.decreaseQuoteLevel() : squire.increaseQuoteLevel(); ('BLOCKQUOTE' == parent) ? squire.decreaseQuoteLevel() : squire.increaseQuoteLevel();
}, },
matches: 'BLOCKQUOTE' hint: 'Blockquote'
}, },
indentDecrease: { indentDecrease: {
html: '⇤', html: '⇤',
cmd: () => squire.changeIndentationLevel('decrease'), cmd: () => squire.changeIndentationLevel('decrease'),
key: ']' key: ']',
hint: 'Decrease indent'
}, },
indentIncrease: { indentIncrease: {
html: '⇥', html: '⇥',
cmd: () => squire.changeIndentationLevel('increase'), cmd: () => squire.changeIndentationLevel('increase'),
key: '[' key: '[',
hint: 'Increase indent'
} }
}, },
targets: { targets: {
@ -205,21 +209,21 @@ class SquireUI
url.length ? squire.makeLink(url) : (node && squire.removeLink()); url.length ? squire.makeLink(url) : (node && squire.removeLink());
} }
}, },
matches: 'A' hint: 'Link'
}, },
imageUrl: { imageUrl: {
html: '🖼️', html: '🖼️',
cmd: () => { cmd: () => {
let node = squire.getSelectionClosest('IMG'), let node = squire.getSelectionClosest('IMG'),
src = prompt("Image", node?.src || "https://"); src = prompt("Image", node?.src || "https://");
src?.length ? squire.insertImage(src) : node?.remove(); src.length ? squire.insertImage(src) : (node && squire.detach(node));
}, },
matches: 'IMG' hint: 'Image URL'
}, },
imageUpload: { imageUpload: {
html: '📂️', html: '📂️',
cmd: () => browseImage.click(), cmd: () => browseImage.click(),
matches: 'IMG' hint: 'Image select',
} }
}, },
/* /*
@ -231,26 +235,19 @@ class SquireUI
undo: { undo: {
html: '↶', html: '↶',
cmd: () => squire.undo(), cmd: () => squire.undo(),
key: 'Z' key: 'Z',
hint: 'Undo'
}, },
redo: { redo: {
html: '↷', html: '↷',
cmd: () => squire.redo(), cmd: () => squire.redo(),
key: 'Y' key: 'Y',
hint: 'Redo'
}, },
source: { source: {
html: '👁', html: '👁',
cmd: btn => { cmd: () => this.setMode('source' == this.mode ? 'wysiwyg' : 'source'),
this.setMode('source' == this.mode ? 'wysiwyg' : 'source'); hint: i18n('EDITOR/TEXT_SWITCHER_SOURCE', 'Source')
btn.classList.toggle('active', 'source' == this.mode);
}
}
},
clear: {
removeStyle: {
html: '⎚',
cmd: () => squire.setStyle()
} }
} }
}, },
@ -263,27 +260,6 @@ class SquireUI
clr.type = 'color'; clr.type = 'color';
toolbar.append(clr); toolbar.append(clr);
// Chrome https://github.com/the-djmaze/snappymail/issues/1199
let clrid = 'squire-colors',
colorlist = doc.getElementById(clrid),
add = hex => colorlist.append(new Option(hex));
if (!colorlist) {
colorlist = createElement('datalist');
colorlist.id = clrid;
// Color blind safe Tableau 10 by Maureen Stone
add('#4E79A7');
add('#F28E2B');
add('#E15759');
add('#76B7B2');
add('#59A14F');
add('#EDC948');
add('#B07AA1');
add('#FF9DA7');
add('#9C755F');
add('#BAB0AC');
doc.body.append(colorlist);
}
clr.setAttribute('list', clrid);
browseImage.type = 'file'; browseImage.type = 'file';
browseImage.accept = 'image/*'; browseImage.accept = 'image/*';
@ -298,18 +274,25 @@ class SquireUI
plain.className = 'squire-plain'; plain.className = 'squire-plain';
wysiwyg.className = 'squire-wysiwyg'; wysiwyg.className = 'squire-wysiwyg';
wysiwyg.dir = 'auto';
this.mode = ''; // 'plain' | 'wysiwyg' this.mode = ''; // 'plain' | 'wysiwyg'
this.__plain = {
getRawData: () => this.plain.value,
setRawData: plain => this.plain.value = plain
};
this.container = container; this.container = container;
this.squire = squire; this.squire = squire;
this.plain = plain; this.plain = plain;
this.wysiwyg = wysiwyg; this.wysiwyg = wysiwyg;
dispatchEvent(new CustomEvent('squire-toolbar', {detail:{squire:this,actions:actions}}));
toolbar.className = 'squire-toolbar btn-toolbar'; toolbar.className = 'squire-toolbar btn-toolbar';
let group, action/*, touchTap*/; let group, action/*, touchTap*/;
for (group in actions) { for (group in actions) {
/*
if ('bidi' == group && !rl.settings.app('allowHtmlEditorBitiButtons')) {
continue;
}
*/
let toolgroup = createElement('div'); let toolgroup = createElement('div');
toolgroup.className = 'btn-group'; toolgroup.className = 'btn-group';
toolgroup.id = 'squire-toolgroup-'+group; toolgroup.id = 'squire-toolgroup-'+group;
@ -330,7 +313,6 @@ class SquireUI
input.append(option); input.append(option);
}); });
} else { } else {
input.add(new Option(i18n('GLOBAL/DEFAULT'), ''));
Object.entries(cfg.select).forEach(([label, options]) => { Object.entries(cfg.select).forEach(([label, options]) => {
let group = createElement('optgroup'); let group = createElement('optgroup');
group.label = label; group.label = label;
@ -339,7 +321,7 @@ class SquireUI
option.style[action] = value; option.style[action] = value;
group.append(option); group.append(option);
}); });
input.add(group); input.append(group);
}); });
} }
ev = 'input'; ev = 'input';
@ -363,7 +345,6 @@ class SquireUI
*/ */
} }
input.addEventListener(ev, () => cfg.cmd(input)); input.addEventListener(ev, () => cfg.cmd(input));
cfg.hint = i18n('EDITOR/' + action.toUpperCase());
if (cfg.hint) { if (cfg.hint) {
input.title = cfg.key ? cfg.hint + ' (' + ctrlKey + cfg.key + ')' : cfg.hint; input.title = cfg.key ? cfg.hint + ' (' + ctrlKey + cfg.key + ')' : cfg.hint;
} else if (cfg.key) { } else if (cfg.key) {
@ -381,178 +362,36 @@ class SquireUI
let changes = actions.changes; let changes = actions.changes;
changes.undo.input.disabled = changes.redo.input.disabled = true; changes.undo.input.disabled = changes.redo.input.disabled = true;
squire.addEventListener('undoStateChange', e => { squire.addEventListener('undoStateChange', state => {
changes.undo.input.disabled = !e.detail.canUndo; changes.undo.input.disabled = !state.canUndo;
changes.redo.input.disabled = !e.detail.canRedo; changes.redo.input.disabled = !state.canRedo;
}); });
squire.addEventListener('pasteImage', e => {
const items = e.detail.clipboardData.items;
let l = items.length;
while (l--) {
const item = items[l];
if (/^image\/(png|jpeg|webp)/.test(item.type)) {
let reader = new FileReader();
reader.onload = event => {
let img = createElement("img"),
canvas = createElement("canvas"),
ctx = canvas.getContext('2d');
img.onload = ()=>{
ctx.drawImage(img, 0, 0);
let width = img.width, height = img.height;
if (width > height) {
// Landscape
if (width > 1024) {
height = height * 1024 / width;
width = 1024;
}
} else if (height > 1024) {
// Portrait
width = width * 1024 / height;
height = 1024;
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
squire.insertHTML('<img alt="" style="width:100%;max-width:'+width+'px" src="'+canvas.toDataURL()+'">', true);
};
img.src = event.target.result;
}
reader.readAsDataURL(item.getAsFile());
break;
}
}
});
actions.font.fontSize.input.selectedIndex = actions.font.fontSize.defaultValueIndex;
// squire.addEventListener('focus', () => shortcuts.off()); // squire.addEventListener('focus', () => shortcuts.off());
// squire.addEventListener('blur', () => shortcuts.on()); // squire.addEventListener('blur', () => shortcuts.on());
container.append(toolbar, wysiwyg, plain); container.append(toolbar, wysiwyg, plain);
/**
* @param {string} fontName
* @return {string}
*/
const normalizeFontName = (fontName) => fontName.trim().replace(/(^["']*|["']*$)/g, '').trim().toLowerCase();
/** @type {string[]} - lower cased array of available font families*/
const fontFamiliesLowerCase = Object.values(actions.font.fontFamily.input.options).map(option => option.value.toLowerCase());
/**
* A theme might have CSS like div.squire-wysiwyg[contenteditable="true"] {
* font-family: 'Times New Roman', Times, serif; }
* so let's find the best match squire.getRoot()'s font
* it will also help to properly handle generic font names like 'sans-serif'
* @type {number}
*/
let defaultFontFamilyIndex = 0;
const squireRootFonts = getComputedStyle(squire.getRoot()).fontFamily.split(',').map(normalizeFontName);
fontFamiliesLowerCase.some((family, index) => {
const matchFound = family.split(',').some(availableFontName => {
const normalizedFontName = normalizeFontName(availableFontName);
return squireRootFonts.some(squireFontName => squireFontName === normalizedFontName);
});
if (matchFound) {
defaultFontFamilyIndex = index;
}
return matchFound;
});
/**
* Instead of comparing whole 'font-family' strings,
* we are going to look for individual font names, because we might be
* editing a Draft started in another email client for example
*
* @type {Object.<string,number>}
*/
const fontNamesMap = {};
/**
* @param {string} fontFamily
* @param {number} index
*/
const processFontFamilyString = (fontFamily, index) => {
fontFamily.split(',').forEach(fontName => {
const key = normalizeFontName(fontName);
if (fontNamesMap[key] === undefined) {
fontNamesMap[key] = index;
}
});
};
// first deal with the default font family
processFontFamilyString(fontFamiliesLowerCase[defaultFontFamilyIndex], defaultFontFamilyIndex);
// and now with the rest of the font families
fontFamiliesLowerCase.forEach((fontFamily, index) => {
if (index !== defaultFontFamilyIndex) {
processFontFamilyString(fontFamily, index);
}
});
// -----
squire.addEventListener('pathChange', () => {
const squireRoot = squire.getRoot();
let range = squire.getSelection(),
collapsed = range.collapsed,
elm = collapsed ? range.endContainer : range?.commonAncestorContainer;
if (elm && !(elm instanceof Element)) {
elm = elm.parentElement;
}
forEachObjectValue(actions, entries => {
forEachObjectValue(entries, cfg => {
// Check if selection has a matching parent or contains a matching element
cfg.matches && cfg.input.classList.toggle('active', !!(elm && (
(!collapsed && [...elm.querySelectorAll(cfg.matches)].some(node => range.intersectsNode(node)))
|| elm.closestWithin(cfg.matches, squireRoot)
)));
});
});
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 familyFound = false;
let sizeFound = false;
do {
if (!familyFound && elm.style.fontFamily) {
familyFound = true;
familySelectedIndex = -1; // show empty select if we don't know the font
const fontNames = elm.style.fontFamily.split(',');
for (let i = 0; i < fontNames.length; i++) {
const index = fontNamesMap[normalizeFontName(fontNames[i])];
if (index !== undefined) {
familySelectedIndex = index;
break;
}
}
}
if (!sizeFound && elm.style.fontSize) {
sizeFound = true;
// -1 is ok because it will just show a black <select>
sizeSelectedIndex = actions.font.fontSize.select.indexOf(elm.style.fontSize);
}
elm = elm.parentElement;
} while ((!familyFound || !sizeFound) && elm && elm !== squireRoot);
actions.font.fontFamily.input.selectedIndex = familySelectedIndex;
actions.font.fontSize.input.selectedIndex = sizeSelectedIndex;
}
});
/* /*
squire.addEventListener('cursor', e => { squire.addEventListener('dragover', );
console.dir({cursor:e.detail.range}); squire.addEventListener('drop', );
}); squire.addEventListener('pathChange', );
squire.addEventListener('select', e => { squire.addEventListener('cursor', );
console.dir({select:e.detail.range}); squire.addEventListener('select', );
}); squire.addEventListener('input', );
squire.addEventListener('willPaste', );
squire.addEventListener( 'keydown keyup', monitorShiftKey )
squire.addEventListener( 'keydown', onKey )
*/ */
// CKEditor gimmicks used by HtmlEditor
this.plugins = {
plain: true
};
this.focusManager = {
hasFocus: () => squire._isFocused,
blur: () => squire.blur()
};
} }
doAction(name) { doAction(name) {
@ -565,11 +404,11 @@ class SquireUI
fn = {UL:'makeUnorderedList',OL:'makeOrderedList'}; fn = {UL:'makeUnorderedList',OL:'makeOrderedList'};
(parent == type) ? this.squire.removeList() : this.squire[fn[type]](); (parent == type) ? this.squire.removeList() : this.squire[fn[type]]();
} }
/*
testPresenceinSelection(format, validation) { testPresenceinSelection(format, validation) {
return validation.test(this.squire.getPath()) || this.squire.hasFormat(format); return validation.test(this.squire.getPath()) || this.squire.hasFormat(format);
} }
*/
setMode(mode) { setMode(mode) {
if (this.mode != mode) { if (this.mode != mode) {
let cl = this.container.classList, source = 'source' == this.mode; let cl = this.container.classList, source = 'source' == this.mode;
@ -590,6 +429,7 @@ class SquireUI
this.modeSelect.selectedIndex = 'plain' == this.mode ? 1 : 0; this.modeSelect.selectedIndex = 'plain' == this.mode ? 1 : 0;
} }
// CKeditor gimmicks used by HtmlEditor
on(type, fn) { on(type, fn) {
if ('mode' == type) { if ('mode' == type) {
this.onModeChange = fn; this.onModeChange = fn;
@ -632,7 +472,6 @@ class SquireUI
// Move cursor above signature // Move cursor above signature
div.before(br); div.before(br);
div.before(br.cloneNode()); div.before(br.cloneNode());
// squire._docWasChanged();
} }
this._prev_txt_sig = signature; this._prev_txt_sig = signature;
} catch (e) { } catch (e) {
@ -656,18 +495,6 @@ class SquireUI
squire.setSelection( range ); squire.setSelection( range );
} }
getPlainData() {
return this.plain.value;
}
setPlainData(text) {
this.plain.value = text;
}
blur() {
this.squire.blur();
}
focus() { focus() {
if ('plain' == this.mode) { if ('plain' == this.mode) {
this.plain.focus(); this.plain.focus();

View file

@ -1,12 +1,12 @@
import 'External/ko'; import 'External/ko';
import ko from 'ko'; import ko from 'ko';
import { RFC822 } from 'Common/File'; import { HtmlEditor } from 'Common/Html';
import { HtmlEditor } from 'Common/HtmlEditor';
import { timeToNode } from 'Common/Translator'; import { timeToNode } from 'Common/Translator';
import { doc, elementById, addEventsListeners, dropdowns, leftPanelDisabled } from 'Common/Globals'; import { doc, elementById, addEventsListeners, dropdowns, leftPanelDisabled } from 'Common/Globals';
import { dropdownsDetectVisibility } from 'Common/UtilsUser';
import { EmailAddressesComponent } from 'Component/EmailAddresses'; import { EmailAddressesComponent } from 'Component/EmailAddresses';
import { ThemeStore } from 'Stores/Theme'; import { ThemeStore } from 'Stores/Theme';
import { dropFilesInFolder } from 'Common/Folders'; import { moveMessagesToFolder, dropFilesInFolder } from 'Common/Folders';
import { setExpandedFolder } from 'Model/FolderCollection'; import { setExpandedFolder } from 'Model/FolderCollection';
import { FolderUserStore } from 'Stores/User/Folder'; import { FolderUserStore } from 'Stores/User/Folder';
import { MessagelistUserStore } from 'Stores/User/Messagelist'; import { MessagelistUserStore } from 'Stores/User/Messagelist';
@ -44,7 +44,7 @@ const rlContentType = 'snappymail/action',
let files = false; let files = false;
// if (e.dataTransfer.types.includes('Files')) // if (e.dataTransfer.types.includes('Files'))
for (const item of e.dataTransfer.items) { for (const item of e.dataTransfer.items) {
files |= 'file' === item.kind && RFC822 === item.type; files |= 'file' === item.kind && 'message/rfc822' === item.type;
} }
if (files || dragMessages()) { if (files || dragMessages()) {
e.stopPropagation(); e.stopPropagation();
@ -63,9 +63,7 @@ const rlContentType = 'snappymail/action',
dragDrop = (e, element, folder, dragData) => { dragDrop = (e, element, folder, dragData) => {
dragStop(e, element); dragStop(e, element);
if (dragMessages() && 'copyMove' == e.dataTransfer.effectAllowed) { if (dragMessages() && 'copyMove' == e.dataTransfer.effectAllowed) {
MessagelistUserStore.moveMessages( moveMessagesToFolder(FolderUserStore.currentFolderFullName(), dragData.data, folder.fullName, e.ctrlKey);
FolderUserStore.currentFolderFullName(), dragData.data, folder.fullName, e.ctrlKey
);
} else if (e.dataTransfer.types.includes('Files')) { } else if (e.dataTransfer.types.includes('Files')) {
dropFilesInFolder(folder.fullName, e.dataTransfer.files); dropFilesInFolder(folder.fullName, e.dataTransfer.files);
} }
@ -91,19 +89,19 @@ Object.assign(ko.bindingHandlers, {
}; };
if (ko.isObservable(fValue)) { if (ko.isObservable(fValue)) {
editor = new HtmlEditor(element, fOnReady, fUpdateKoValue, fUpdateKoValue); editor = new HtmlEditor(element, fUpdateKoValue, fOnReady, fUpdateKoValue);
fValue.__fetchEditorValue = fUpdateKoValue; fValue.__fetchEditorValue = fUpdateKoValue;
fValue.subscribe(fUpdateEditorValue); fValue.subscribe(fUpdateEditorValue);
// ko.addDisposeCallback(element, () => { // ko.utils.domNodeDisposal.addDisposeCallback(element, () => {
// }); // });
} }
} }
}, },
time: { moment: {
init: ttn, init: ttn,
update: ttn update: ttn
}, },
@ -194,6 +192,10 @@ Object.assign(ko.bindingHandlers, {
}; };
addEventsListeners(element, { addEventsListeners(element, {
dragstart: e => { dragstart: e => {
dragData = {
action: 'sortable',
element: element
};
setDragAction(e, 'sortable', 'move', element, element); setDragAction(e, 'sortable', 'move', element, element);
element.style.opacity = 0.25; element.style.opacity = 0.25;
}, },
@ -243,5 +245,18 @@ Object.assign(ko.bindingHandlers, {
dropdowns.push(element); dropdowns.push(element);
element.ddBtn = new BSN.Dropdown(element.querySelector('.dropdown-toggle')); 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);
}
}
} }
}); });

76
dev/External/ko.js vendored
View file

@ -15,7 +15,6 @@ export const
* based solely on the values of other observables in the application * based solely on the values of other observables in the application
*/ */
koComputable = fn => ko.computed(fn, {'pure':true}), koComputable = fn => ko.computed(fn, {'pure':true}),
// koObservable = value => ko.observable(value),
addObservablesTo = (target, observables) => addObservablesTo = (target, observables) =>
forEachObjectEntry(observables, (key, value) => forEachObjectEntry(observables, (key, value) =>
@ -29,22 +28,6 @@ export const
dispose = disposable => isFunction(disposable?.dispose) && disposable.dispose(), dispose = disposable => isFunction(disposable?.dispose) && disposable.dispose(),
onEvent = (element, event, fn) => {
element.addEventListener(event, fn);
ko.addDisposeCallback(element, () => element.removeEventListener(event, fn));
},
onKey = (key, element, fValueAccessor, fAllBindings, model) => {
let fn = event => {
if (key == event.key) {
// stopEvent(event);
// element.dispatchEvent(new Event('change'));
fValueAccessor().call(model);
}
};
onEvent(element, 'keydown', fn);
},
// With this we don't need delegateRunOnDestroy // With this we don't need delegateRunOnDestroy
koArrayWithDestroy = data => { koArrayWithDestroy = data => {
data = ko.observableArray(data); data = ko.observableArray(data);
@ -67,31 +50,46 @@ Object.assign(ko.bindingHandlers, {
}, },
update: (element, fValueAccessor) => { update: (element, fValueAccessor) => {
let value = ko.unwrap(fValueAccessor()); let value = ko.unwrap(fValueAccessor());
errorTip(element, isFunction(value) ? value() : value); value = isFunction(value) ? value() : value;
errorTip(element, value);
} }
}, },
onEnter: { onEnter: {
init: (element, fValueAccessor, fAllBindings, model) => init: (element, fValueAccessor, fAllBindings, viewModel) => {
onKey('Enter', element, fValueAccessor, fAllBindings, model) let fn = event => {
if ('Enter' == event.key) {
element.dispatchEvent(new Event('change'));
fValueAccessor().call(viewModel);
}
};
element.addEventListener('keydown', fn);
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keydown', fn));
}
}, },
onEsc: { onEsc: {
init: (element, fValueAccessor, fAllBindings, model) => init: (element, fValueAccessor, fAllBindings, viewModel) => {
onKey('Escape', element, fValueAccessor, fAllBindings, model) let fn = event => {
if ('Escape' == event.key) {
element.dispatchEvent(new Event('change'));
fValueAccessor().call(viewModel);
}
};
element.addEventListener('keyup', fn);
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keyup', fn));
}
}, },
onSpace: { onSpace: {
init: (element, fValueAccessor, fAllBindings, model) => init: (element, fValueAccessor, fAllBindings, viewModel) => {
onKey(' ', element, fValueAccessor, fAllBindings, model) let fn = event => {
}, if (' ' == event.key) {
fValueAccessor().call(viewModel, event);
toggle: { }
init: (element, fValueAccessor) => { };
let observable = fValueAccessor(), element.addEventListener('keyup', fn);
fn = () => observable(!observable()); ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keyup', fn));
onEvent(element, 'click', fn);
onEvent(element, 'keydown', event => ' ' == event.key && fn());
} }
}, },
@ -102,12 +100,16 @@ Object.assign(ko.bindingHandlers, {
} }
}, },
title: {
update: (element, fValueAccessor) => element.title = ko.unwrap(fValueAccessor())
},
command: { command: {
init: (element, fValueAccessor, fAllBindings, viewModel, bindingContext) => { init: (element, fValueAccessor, fAllBindings, viewModel, bindingContext) => {
const command = fValueAccessor(); const command = fValueAccessor();
if (!command || !command.canExecute) { if (!command || !command.canExecute) {
throw Error('Value should be a command'); throw new Error('Value should be a command');
} }
ko.bindingHandlers['FORM'==element.nodeName ? 'submit' : 'click'].init( ko.bindingHandlers['FORM'==element.nodeName ? 'submit' : 'click'].init(
@ -119,8 +121,10 @@ Object.assign(ko.bindingHandlers, {
); );
}, },
update: (element, fValueAccessor) => { update: (element, fValueAccessor) => {
const cl = element.classList;
let disabled = !fValueAccessor().canExecute(); let disabled = !fValueAccessor().canExecute();
element.classList.toggle('disabled', disabled); cl.toggle('disabled', disabled);
if (element.matches('INPUT,TEXTAREA,BUTTON')) { if (element.matches('INPUT,TEXTAREA,BUTTON')) {
element.disabled = disabled; element.disabled = disabled;
@ -176,6 +180,6 @@ ko.extenders.falseTimeout = (target, option) => {
// functions // functions
ko.observable.fn.askDeleteHelper = function(prop='askDelete') { ko.observable.fn.askDeleteHelper = function() {
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, prop] }); return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, 'askDelete'] });
}; };

View file

@ -0,0 +1,46 @@
<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;
}
</style>
</head>
<body></body>
</html>

View file

@ -5,13 +5,9 @@ function typeCast(curValue, newValue) {
if (null != curValue) { if (null != curValue) {
switch (typeof curValue) switch (typeof curValue)
{ {
case 'boolean': case 'boolean': return 0 != newValue && !!newValue;
return 0 != newValue && !!newValue; case 'number': return isFinite(newValue) ? parseFloat(newValue) : 0;
case 'number': case 'string': return null != newValue ? '' + newValue : '';
newValue = parseFloat(newValue);
return isFinite(newValue) ? newValue : 0;
case 'string':
return null != newValue ? '' + newValue : '';
case 'object': case 'object':
if (curValue.constructor.reviveFromJson) { if (curValue.constructor.reviveFromJson) {
return curValue.constructor.reviveFromJson(newValue); return curValue.constructor.reviveFromJson(newValue);
@ -27,10 +23,10 @@ export class AbstractModel {
constructor() { constructor() {
/* /*
if (new.target === AbstractModel) { if (new.target === AbstractModel) {
throw Error("Can't instantiate AbstractModel!"); throw new Error("Can't instantiate AbstractModel!");
} }
*/ */
Object.defineProperty(this, 'disposables', {value: []}); this.disposables = [];
} }
addObservables(observables) { addObservables(observables) {
@ -84,11 +80,13 @@ export class AbstractModel {
} }
revivePropertiesFromJson(json) { revivePropertiesFromJson(json) {
const model = this.constructor, let model = this.constructor;
valid = model.validJson(json); if (!model.validJson(json)) {
valid && forEachObjectEntry(json, (key, value) => { return false;
}
forEachObjectEntry(json, (key, value) => {
if ('@' !== key[0]) try { if ('@' !== key[0]) try {
// key = key[0].toLowerCase() + key.slice(1); key = key[0].toLowerCase() + key.slice(1);
switch (typeof this[key]) switch (typeof this[key])
{ {
case 'function': case 'function':
@ -105,11 +103,9 @@ export class AbstractModel {
this[key] = typeCast(this[key], value); this[key] = typeCast(this[key], value);
break; break;
case 'undefined': case 'undefined':
console.log(`Undefined ${model.name}.${key} set`);
this[key] = value; this[key] = value;
break; // fall through
// default: default:
// console.log((typeof this[key])+` ${model.name}.${key} not revived`);
// console.log((typeof this[key])+' '+(model.name)+'.'+key+' not revived'); // console.log((typeof this[key])+' '+(model.name)+'.'+key+' not revived');
} }
} catch (e) { } catch (e) {
@ -117,7 +113,7 @@ export class AbstractModel {
console.error(e); console.error(e);
} }
}); });
return valid; return true;
} }
} }

View file

@ -1,7 +1,7 @@
import ko from 'ko'; import ko from 'ko';
import { addObservablesTo, addComputablesTo, addSubscribablesTo } from 'External/ko'; import { addObservablesTo, addComputablesTo, addSubscribablesTo } from 'External/ko';
import { keyScope, addShortcut, SettingsGet, toggleLeftPanel, elementById } from 'Common/Globals'; import { keyScope, addShortcut, SettingsGet, leftPanelDisabled, toggleLeftPanel, elementById } from 'Common/Globals';
import { ViewTypePopup, showScreenPopup } from 'Knoin/Knoin'; import { ViewTypePopup, showScreenPopup } from 'Knoin/Knoin';
import { SaveSettingStatus } from 'Common/Enums'; import { SaveSettingStatus } from 'Common/Enums';
@ -59,10 +59,10 @@ export class AbstractViewPopup extends AbstractView
super('Popups' + name, ViewTypePopup); super('Popups' + name, ViewTypePopup);
this.keyScope.scope = name; this.keyScope.scope = name;
this.modalVisible = ko.observable(false).extend({ rateLimit: 0 }); this.modalVisible = ko.observable(false).extend({ rateLimit: 0 });
this.close = () => this.modalVisible(false);
this.tryToClose = () => (false === this.onClose()) || this.close();
addShortcut('escape,close', '', name, () => { addShortcut('escape,close', '', name, () => {
this.modalVisible() && this.tryToClose(); if (this.modalVisible() && false !== this.onClose()) {
this.close();
}
return false; return false;
// return true; Issue with supported modal close // return true; Issue with supported modal close
}); });
@ -78,6 +78,8 @@ export class AbstractViewPopup extends AbstractView
afterShow() {} // Happens after showModal() animation transitionend afterShow() {} // Happens after showModal() animation transitionend
onHide() {} // Happens before animation transitionend onHide() {} // Happens before animation transitionend
afterHide() {} // Happens after animation transitionend afterHide() {} // Happens after animation transitionend
close() {}
*/ */
} }
@ -94,6 +96,7 @@ export class AbstractViewLeft extends AbstractView
constructor(templateID) constructor(templateID)
{ {
super(templateID, 'left'); super(templateID, 'left');
this.leftPanelDisabled = leftPanelDisabled;
this.toggleLeftPanel = toggleLeftPanel; this.toggleLeftPanel = toggleLeftPanel;
} }
} }
@ -115,11 +118,6 @@ export class AbstractViewSettings
onHide() {} onHide() {}
viewModelDom 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) addSetting(name, valueCb)
{ {
let prop = name[0].toLowerCase() + name.slice(1), let prop = name[0].toLowerCase() + name.slice(1),
@ -135,7 +133,6 @@ export class AbstractViewSettings
rl.app.Remote.saveSetting(name, value, rl.app.Remote.saveSetting(name, value,
iError => { iError => {
this[trigger](iError ? SaveSettingStatus.Failed : SaveSettingStatus.Success); this[trigger](iError ? SaveSettingStatus.Failed : SaveSettingStatus.Success);
// iError || Settings.set(name, value);
setTimeout(() => this[trigger](SaveSettingStatus.Idle), 1000); setTimeout(() => this[trigger](SaveSettingStatus.Idle), 1000);
} }
); );
@ -143,10 +140,6 @@ 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) addSettings(names)
{ {
names.forEach(name => { names.forEach(name => {

View file

@ -1,12 +1,9 @@
import ko from 'ko'; import ko from 'ko';
import { koComputable } from 'External/ko'; import { koComputable } from 'External/ko';
import { doc, $htmlCL, elementById, createElement, fireEvent } from 'Common/Globals'; import { doc, $htmlCL, elementById, fireEvent } from 'Common/Globals';
import { forEachObjectEntry } from 'Common/Utils'; import { forEachObjectEntry } from 'Common/Utils';
import { i18nToNodes } from 'Common/Translator'; import { i18nToNodes } from 'Common/Translator';
import { leftPanelDisabled } from 'Common/Globals';
import { ThemeStore } from 'Stores/Theme';
let let
currentScreen = null, currentScreen = null,
defaultScreenName = ''; defaultScreenName = '';
@ -25,13 +22,13 @@ const
screen = screenName => (screenName && SCREENS.get(screenName)) || null, screen = screenName => (screenName && SCREENS.get(screenName)) || null,
/** /**
* Creates the extended AbstractView model
* @param {Function} ViewModelClass * @param {Function} ViewModelClass
* @param {Object=} vmScreen * @param {Object=} vmScreen
* @returns {*} * @returns {*}
*/ */
buildViewModel = (ViewModelClass, vmScreen) => { buildViewModel = (ViewModelClass, vmScreen) => {
if (ViewModelClass && !ViewModelClass.__vm) { if (ViewModelClass && !ViewModelClass.__builded) {
let vmDom = null;
const const
vm = new ViewModelClass(vmScreen), vm = new ViewModelClass(vmScreen),
id = vm.viewModelTemplateID, id = vm.viewModelTemplateID,
@ -39,30 +36,35 @@ const
dialog = ViewTypePopup === vm.viewType, dialog = ViewTypePopup === vm.viewType,
vmPlace = doc.getElementById(position); vmPlace = doc.getElementById(position);
if (vmPlace) { ViewModelClass.__builded = true;
ViewModelClass.__vm = vm; ViewModelClass.__vm = vm;
let vmDom = dialog if (vmPlace) {
? createElement('dialog',{id:'V-'+id}) vmDom = Element.fromHTML(dialog
: createElement('div',{id:'V-'+id,hidden:''}) ? '<dialog id="V-'+ id + '"></dialog>'
: '<div id="V-'+ id + '" hidden=""></div>');
vmPlace.append(vmDom); vmPlace.append(vmDom);
vm.viewModelDom = vmDom; vm.viewModelDom = ViewModelClass.__dom = vmDom;
if (dialog) { if (dialog) {
vm.close = () => hideScreenPopup(ViewModelClass);
// Firefox < 98 / Safari < 15.4 HTMLDialogElement not defined // Firefox < 98 / Safari < 15.4 HTMLDialogElement not defined
if (!vmDom.showModal) { if (!vmDom.showModal) {
vmDom.className = 'polyfill'; vmDom.className = 'polyfill';
vmDom.showModal = () => { vmDom.showModal = () => {
vmDom.backdrop || vmDom.backdrop ||
vmDom.before(vmDom.backdrop = createElement('div',{class:'dialog-backdrop'})); vmDom.before(vmDom.backdrop = Element.fromHTML('<div class="dialog-backdrop"></div>'));
vmDom.setAttribute('open',''); vmDom.setAttribute('open','');
vmDom.open = true; vmDom.open = true;
vmDom.returnValue = null;
vmDom.backdrop.hidden = false; vmDom.backdrop.hidden = false;
}; };
vmDom.close = () => { vmDom.close = v => {
// if (vmDom.dispatchEvent(new CustomEvent('cancel', {cancelable:true}))) { // if (vmDom.dispatchEvent(new CustomEvent('cancel', {cancelable:true}))) {
vmDom.backdrop.hidden = true; vmDom.backdrop.hidden = true;
vmDom.returnValue = v;
vmDom.removeAttribute('open', null); vmDom.removeAttribute('open', null);
vmDom.open = false; vmDom.open = false;
// vmDom.dispatchEvent(new CustomEvent('close')); // vmDom.dispatchEvent(new CustomEvent('close'));
@ -74,17 +76,14 @@ const
// vmDom.addEventListener('close', () => vm.modalVisible(false)); // vmDom.addEventListener('close', () => vm.modalVisible(false));
// show/hide popup/modal // 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 => { const endShowHide = e => {
if (e.target === vmDom && 'opacity' === e.propertyName) { if (e.target === vmDom) {
if (vmDom.classList.contains('animate')) { if (vmDom.classList.contains('animate')) {
autofocus(vmDom);
vm.afterShow?.(); vm.afterShow?.();
fireEvent('rl-vm-visible', vm);
} else { } else {
vmDom.close(); vmDom.close();
vm.afterHide?.(); vm.afterHide?.();
// fireEvent('rl-vm-hidden', vm);
} }
} }
}; };
@ -99,7 +98,6 @@ const
vmDom.backdrop.style.zIndex = 3000 + (visiblePopups.size * 2); vmDom.backdrop.style.zIndex = 3000 + (visiblePopups.size * 2);
} }
vm.keyScope.set(); vm.keyScope.set();
setTimeout(()=>autofocus(vmDom),1);
requestAnimationFrame(() => { // wait just before the next paint requestAnimationFrame(() => { // wait just before the next paint
vmDom.offsetHeight; // force a reflow vmDom.offsetHeight; // force a reflow
vmDom.classList.add('animate'); // trigger the transitions vmDom.classList.add('animate'); // trigger the transitions
@ -140,9 +138,10 @@ const
screen.viewModels.forEach(ViewModelClass => { screen.viewModels.forEach(ViewModelClass => {
if ( if (
ViewModelClass.__vm && ViewModelClass.__vm &&
ViewModelClass.__dom &&
ViewTypePopup !== ViewModelClass.__vm.viewType ViewTypePopup !== ViewModelClass.__vm.viewType
) { ) {
fn(ViewModelClass.__vm, ViewModelClass.__vm.viewModelDom); fn(ViewModelClass.__vm, ViewModelClass.__dom);
} }
}); });
}, },
@ -152,69 +151,85 @@ const
forEachViewModel(screenToHide, (vm, dom) => { forEachViewModel(screenToHide, (vm, dom) => {
dom.hidden = true; dom.hidden = true;
vm.onHide?.(); vm.onHide?.();
destroy && dom.remove(); destroy && vm.viewModelDom.remove();
}); });
ThemeStore.isMobile() && leftPanelDisabled(true);
}, },
/**
* @param {Function} ViewModelClassToHide
* @returns {void}
*/
hideScreenPopup = ViewModelClassToHide =>
ViewModelClassToHide?.__vm && ViewModelClassToHide.__dom
&& ViewModelClassToHide.__vm.modalVisible(false),
/** /**
* @param {string} screenName * @param {string} screenName
* @param {string} subPart * @param {string} subPart
* @returns {void} * @returns {void}
*/ */
screenOnRoute = (screenName, subPart) => { screenOnRoute = (screenName, subPart) => {
screenName = screenName || defaultScreenName; let vmScreen = null,
if (screenName && fireEvent('sm-show-screen', screenName + (subPart ? '/' + subPart : ''), 1)) { isSameScreen = false;
if (null == screenName || '' == screenName) {
screenName = defaultScreenName;
}
if (fireEvent('sm-show-screen', screenName, 1)) {
// Close all popups // Close all popups
for (let vm of visiblePopups) { for (let vm of visiblePopups) {
vm.tryToClose(); (false === vm.onClose()) || vm.close();
} }
let vmScreen = screen(screenName); if (screenName) {
if (!vmScreen) { vmScreen = screen(screenName);
vmScreen = screen(defaultScreenName); if (!vmScreen) {
if (vmScreen) { vmScreen = screen(defaultScreenName);
subPart = screenName + '/' + subPart; if (vmScreen) {
screenName = defaultScreenName; subPart = screenName + '/' + subPart;
} screenName = defaultScreenName;
}
if (vmScreen?.__started) {
let isSameScreen = currentScreen && vmScreen === currentScreen;
if (!vmScreen.__builded) {
vmScreen.__builded = true;
vmScreen.viewModels.forEach(ViewModelClass =>
buildViewModel(ViewModelClass, vmScreen)
);
vmScreen.onBuild?.();
}
setTimeout(() => {
// hide screen
currentScreen && !isSameScreen && hideScreen(currentScreen);
// --
currentScreen = vmScreen;
// show screen
if (!isSameScreen) {
vmScreen.onShow?.();
forEachViewModel(vmScreen, (vm, dom) => {
vm.beforeShow?.();
i18nToNodes(dom);
dom.hidden = false;
vm.onShow?.();
autofocus(dom);
});
} }
// -- }
vmScreen.__cross?.parse(subPart); if (vmScreen?.__started) {
}, 1); isSameScreen = currentScreen && vmScreen === currentScreen;
if (!vmScreen.__builded) {
vmScreen.__builded = true;
vmScreen.viewModels.forEach(ViewModelClass =>
buildViewModel(ViewModelClass, vmScreen)
);
vmScreen.onBuild?.();
}
setTimeout(() => {
// hide screen
currentScreen && !isSameScreen && hideScreen(currentScreen);
// --
currentScreen = vmScreen;
// show screen
if (!isSameScreen) {
vmScreen.onShow?.();
forEachViewModel(vmScreen, (vm, dom) => {
vm.beforeShow?.();
i18nToNodes(dom);
dom.hidden = false;
vm.onShow?.();
autofocus(dom);
});
}
// --
vmScreen.__cross?.parse(subPart);
}, 1);
}
} }
} }
}; };
@ -229,11 +244,15 @@ export const
* @returns {void} * @returns {void}
*/ */
showScreenPopup = (ViewModelClassToShow, params = []) => { showScreenPopup = (ViewModelClassToShow, params = []) => {
const vm = buildViewModel(ViewModelClassToShow); const vm = buildViewModel(ViewModelClassToShow) && ViewModelClassToShow.__dom && ViewModelClassToShow.__vm;
if (vm) { if (vm) {
params = params || []; params = params || [];
vm.beforeShow?.(...params); vm.beforeShow?.(...params);
vm.modalVisible(true); vm.modalVisible(true);
vm.onShow?.(...params); vm.onShow?.(...params);
} }
}, },
@ -266,7 +285,7 @@ export const
}); });
const cross = new Crossroads(); const cross = new Crossroads();
cross.addRoute(/^([^/]*)\/?(.*)$/, screenOnRoute); cross.addRoute(/^([a-zA-Z0-9-]*)\/?(.*)$/, screenOnRoute);
hasher.add(cross.parse.bind(cross)); hasher.add(cross.parse.bind(cross));
hasher.init(); hasher.init();

View file

@ -1,197 +0,0 @@
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;
}

View file

@ -1,35 +0,0 @@
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});
}
};

View file

@ -1,5 +1,16 @@
import { decodeEncodedWords, BDecode, BEncode, QPDecode, decodeText } from 'Mime/Encoding';
import { addressparser } from 'Mime/Address'; 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});
}
};
export function ParseMime(text) export function ParseMime(text)
{ {
@ -15,52 +26,7 @@ export function ParseMime(text)
this.bodyEnd = 0; this.bodyEnd = 0;
this.boundary = ''; this.boundary = '';
this.bodyText = ''; this.bodyText = '';
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6 this.headers = {};
// 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.
};
} }
*/ */
@ -83,25 +49,25 @@ export function ParseMime(text)
get body() { get body() {
let body = this.bodyRaw, let body = this.bodyRaw,
charset = this.header('content-type')?.params.charset, charset = this.header('content-type')?.params.charset,
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase(); encoding = this.headerValue('content-transfer-encoding');
if ('quoted-printable' == encoding) { if ('quoted-printable' == encoding) {
body = QPDecode(body); body = QPDecode(body);
} else if ('base64' == encoding) { } else if ('base64' == encoding) {
body = BDecode(body.replace(/\r?\n/g, '')); body = atob(body.replace(/\r?\n/g, ''));
} }
return decodeText(charset, body); return decodeText(charset, body);
} }
get dataUrl() { get dataUrl() {
let body = this.bodyRaw, let body = this.bodyRaw,
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase(); encoding = this.headerValue('content-transfer-encoding');
if ('base64' == encoding) { if ('base64' == encoding) {
body = body.replace(/\r?\n/g, ''); body = body.replace(/\r?\n/g, '');
} else { } else {
if ('quoted-printable' == encoding) { if ('quoted-printable' == encoding) {
body = QPDecode(body); body = QPDecode(body);
} }
body = BEncode(body); body = btoa(body);
} }
return 'data:' + this.headerValue('content-type') + ';base64,' + body; return 'data:' + this.headerValue('content-type') + ';base64,' + body;
} }
@ -112,7 +78,7 @@ export function ParseMime(text)
} }
getByContentType(type) { getByContentType(type) {
if (type == this.headerValue('content-type')?.toLowerCase()) { if (type == this.headerValue('content-type')) {
return this; return this;
} }
let i = 0, p = this.parts, part; let i = 0, p = this.parts, part;
@ -124,9 +90,6 @@ export function ParseMime(text)
} }
} }
// mailbox-list or address-list
const lists = ['from','reply-to','to','cc','bcc'];
const ParsePart = (mimePart, start_pos = 0, id = '') => const ParsePart = (mimePart, start_pos = 0, id = '') =>
{ {
let part = new MimePart, let part = new MimePart,
@ -148,19 +111,11 @@ export function ParseMime(text)
[...header.matchAll(/;\s*([^;=]+)=\s*"?([^;"]+)"?/g)].forEach(param => [...header.matchAll(/;\s*([^;=]+)=\s*"?([^;"]+)"?/g)].forEach(param =>
params[param[1].trim().toLowerCase()] = param[2].trim() params[param[1].trim().toLowerCase()] = param[2].trim()
); );
let field = match[1].trim().toLowerCase(); // encoded-word = "=?" charset "?" encoding "?" encoded-text "?="
if (lists.includes(field)) { match[2] = match[2].trim().replace(/=\?([^?]+)\?(B|Q)\?(.+?)\?=/g, (m, charset, encoding, text) =>
match[2] = addressparser(match[2]); decodeText(charset, 'B' == encoding ? atob(text) : QPDecode(text))
} else if ('keywords' === field) { );
match[2] = match[2].split(',').forEach(entry => decodeEncodedWords(entry.trim())); headers[match[1].trim().toLowerCase()] = {
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], value: match[2],
params: params params: params
}; };
@ -175,7 +130,7 @@ export function ParseMime(text)
let boundary = headers['content-type']?.params.boundary; let boundary = headers['content-type']?.params.boundary;
if (boundary) { if (boundary) {
part.boundary = boundary; part.boundary = boundary;
let regex = new RegExp('(?:^|\r?\n)--' + RegExp.escape(boundary) + '(?:--)?(?:\r?\n|$)', 'g'), let regex = new RegExp('(?:^|\r?\n)--' + boundary + '(?:--)?(?:\r?\n|$)', 'g'),
body = mimePart.slice(head.length), body = mimePart.slice(head.length),
bodies = body.split(regex), bodies = body.split(regex),
pos = part.bodyStart; pos = part.bodyStart;

View file

@ -1,43 +1,38 @@
import { ParseMime } from 'Mime/Parser'; import { ParseMime } from 'Mime/Parser';
import { AttachmentModel } from 'Model/Attachment'; import { AttachmentModel } from 'Model/Attachment';
import { EmailModel } from 'Model/Email';
import { FileInfo } from 'Common/File'; import { FileInfo } from 'Common/File';
import { BEGIN_PGP_MESSAGE } from 'Stores/User/Pgp'; import { BEGIN_PGP_MESSAGE } from 'Stores/User/Pgp';
import { EmailModel } from 'Model/Email';
/** /**
* @param string data * @param string data
* @param MessageModel message * @param MessageModel message
*/ */
export function MimeToMessage(data, message) export function MimeToMessage(data, message)
{ {
let signed;
const struct = ParseMime(data); const struct = ParseMime(data);
if (struct.headers) { if (struct.headers) {
let html = struct.getByContentType('text/html'), let html = struct.getByContentType('text/html');
subject = struct.headerValue('subject');
html = html ? html.body : ''; html = html ? html.body : '';
// Content-Type: ...; protected-headers="v1" if (struct.headers.subject) {
subject && message.subject(subject); message.subject(struct.headers.subject.value);
}
// EmailCollectionModel
['from','to'].forEach(name => { ['from','to'].forEach(name => {
const items = message[name]; if (struct.headers[name] && !message[name].length) {
struct.headerValue(name)?.forEach(item => { let mail = new EmailModel;
item = new EmailModel(item.email, item.name); mail.parse(struct.headers[name].value);
// Make them unique message[name].push(mail);
if (item.email && item.name || !items.find(address => address.email == item.email)) { }
items.push(item);
}
});
}); });
struct.forEach(part => { struct.forEach(part => {
let cd = part.header('content-disposition'), let cd = part.header('content-disposition'),
cId = part.header('content-id'), cid = part.header('content-id'),
type = part.header('content-type'); type = part.header('content-type');
if (cId || cd) { if (cid || cd) {
// if (cd && 'attachment' === cd.value) { // if (cd && 'attachment' === cd.value) {
let attachment = new AttachmentModel; let attachment = new AttachmentModel;
attachment.mimeType = type.value; attachment.mimeType = type.value;
@ -45,15 +40,17 @@ export function MimeToMessage(data, message)
attachment.fileNameExt = attachment.fileName.replace(/^.+(\.[a-z]+)$/, '$1'); attachment.fileNameExt = attachment.fileName.replace(/^.+(\.[a-z]+)$/, '$1');
attachment.fileType = FileInfo.getType('', type.value); attachment.fileType = FileInfo.getType('', type.value);
attachment.url = part.dataUrl; attachment.url = part.dataUrl;
attachment.estimatedSize = part.body.length; attachment.friendlySize = FileInfo.friendlySize(part.body.length);
/* /*
attachment.isThumbnail = false;
attachment.contentLocation = ''; attachment.contentLocation = '';
attachment.download = '';
attachment.folder = ''; attachment.folder = '';
attachment.uid = ''; attachment.uid = '';
attachment.mimeIndex = part.id; attachment.mimeIndex = part.id;
*/ */
attachment.cId = cId ? cId.value : ''; attachment.cid = cid ? cid.value : '';
if (cId && html) { if (cid && html) {
let cid = 'cid:' + attachment.contentId(), let cid = 'cid:' + attachment.contentId(),
found = html.includes(cid); found = html.includes(cid);
attachment.isInline(found); attachment.isInline(found);
@ -65,28 +62,12 @@ export function MimeToMessage(data, message)
} else { } else {
message.attachments.push(attachment); message.attachments.push(attachment);
} }
} else if ('multipart/signed' === type.value) { } else if ('multipart/signed' === type.value && 'application/pgp-signature' === type.params.protocol) {
let protocol = type.params.protocol; signed = {
if ('application/pgp-signature' === protocol) { MicAlg: type.micalg,
message.pgpSigned({ BodyPart: part.parts[0],
micAlg: type.micalg, SigPart: part.parts[1]
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,
detached: false
});
} }
}); });
@ -97,7 +78,10 @@ export function MimeToMessage(data, message)
message.plain(data); message.plain(data);
} }
if (message.plain().includes(BEGIN_PGP_MESSAGE)) { if (!signed && message.plain().includes(BEGIN_PGP_MESSAGE)) {
message.pgpSigned(true); signed = true;
} }
message.pgpSigned(signed);
// TODO: Verify instantly?
} }

View file

@ -5,7 +5,7 @@ export class AbstractCollectionModel extends Array
constructor() { constructor() {
/* /*
if (new.target === AbstractCollectionModel) { if (new.target === AbstractCollectionModel) {
throw Error("Can't instantiate AbstractCollectionModel!"); throw new Error("Can't instantiate AbstractCollectionModel!");
} }
*/ */
super(); super();

View file

@ -28,10 +28,6 @@ export class AccountModel extends AbstractModel {
&& setTimeout(()=>this.fetchUnread(), (Math.ceil(Math.random() * 10)) * 3000); && setTimeout(()=>this.fetchUnread(), (Math.ceil(Math.random() * 10)) * 3000);
} }
label() {
return this.name || IDN.toUnicode(this.email);
}
/** /**
* Get INBOX unread messages * Get INBOX unread messages
*/ */

View file

@ -1,8 +1,6 @@
import ko from 'ko'; import ko from 'ko';
import { FileInfo, FileType } from 'Common/File'; import { FileInfo, FileType } from 'Common/File';
import { stopEvent, SettingsGet, SettingsCapa } from 'Common/Globals';
import { b64EncodeJSONSafe } from 'Common/Utils';
import { import {
attachmentDownload, attachmentDownload,
serverRequestRaw serverRequestRaw
@ -20,12 +18,13 @@ export class AttachmentModel extends AbstractModel {
this.checked = ko.observable(true); this.checked = ko.observable(true);
this.mimeType = ''; this.mimeType = '';
// this.mimeTypeParams = '';
this.fileName = ''; this.fileName = '';
this.fileNameExt = ''; this.fileNameExt = '';
this.fileType = FileType.Unknown; this.fileType = FileType.Unknown;
this.cId = ''; this.isThumbnail = false;
this.cid = '';
this.contentLocation = ''; this.contentLocation = '';
this.download = '';
this.folder = ''; this.folder = '';
this.uid = ''; this.uid = '';
this.url = ''; this.url = '';
@ -53,7 +52,7 @@ export class AttachmentModel extends AbstractModel {
} }
toggleChecked(self, event) { toggleChecked(self, event) {
stopEvent(event); event.stopPropagation();
self.checked(!self.checked()); self.checked(!self.checked());
} }
@ -62,7 +61,7 @@ export class AttachmentModel extends AbstractModel {
} }
contentId() { contentId() {
return this.cId.replace(/^<+|>+$/g, ''); return this.cid.replace(/^<+|>+$/g, '');
} }
/** /**
@ -125,20 +124,6 @@ export class AttachmentModel extends AbstractModel {
); );
} }
get download() {
return b64EncodeJSONSafe(this.url ? {
fileName: this.fileName,
data: this.url.replace(/^.+,/, '')
} : {
folder: this.folder,
uid: this.uid,
mimeIndex: this.mimeIndex,
mimeType: this.mimeType,
fileName: this.fileName,
accountHash: SettingsGet('accountHash')
});
}
/** /**
* @returns {string} * @returns {string}
*/ */
@ -157,7 +142,7 @@ export class AttachmentModel extends AbstractModel {
* @returns {boolean} * @returns {boolean}
*/ */
hasThumbnail() { hasThumbnail() {
return SettingsCapa('AttachmentThumbnails') && this.isImage() && !this.isLinked(); return this.isThumbnail && !this.isLinked();
} }
/** /**
@ -166,7 +151,7 @@ export class AttachmentModel extends AbstractModel {
thumbnailStyle() { thumbnailStyle() {
return this.hasThumbnail() return this.hasThumbnail()
? 'background:url(' + serverRequestRaw('ViewThumbnail', this.download) + ')' ? 'background:url(' + serverRequestRaw('ViewThumbnail', this.download) + ')'
: null; : '';
} }
/** /**

View file

@ -1,4 +1,3 @@
import { baseCollator } from 'Common/Translator';
import { AbstractCollectionModel } from 'Model/AbstractCollection'; import { AbstractCollectionModel } from 'Model/AbstractCollection';
import { AttachmentModel } from 'Model/Attachment'; import { AttachmentModel } from 'Model/Attachment';
@ -11,32 +10,22 @@ export class AttachmentCollectionModel extends AbstractCollectionModel
* @returns {AttachmentCollectionModel} * @returns {AttachmentCollectionModel}
*/ */
static reviveFromJson(items) { static reviveFromJson(items) {
const attachments = super.reviveFromJson(items, attachment => AttachmentModel.reviveFromJson(attachment)); return 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);
});
/* /*
const attachments = super.reviveFromJson(items, attachment => AttachmentModel.reviveFromJson(attachment));
if (attachments) { if (attachments) {
attachments.InlineCount = attachments.reduce((accumulator, a) => accumulator + (a.isInline ? 1 : 0), 0); attachments.InlineCount = attachments.reduce((accumulator, a) => accumulator + (a.isInline ? 1 : 0), 0);
} }
*/
return attachments; return attachments;
*/
} }
/** /**
* @param {string} cId * @param {string} cid
* @returns {*} * @returns {*}
*/ */
findByCid(cId) { findByCid(cid) {
cId = cId.replace(/^<+|>+$/g, ''); cid = cid.replace(/^<+|>+$/g, '');
return this.find(item => cId === item.contentId()); return this.find(item => cid === item.contentId());
} }
} }

View file

@ -10,16 +10,16 @@ export class ComposeAttachmentModel extends AbstractModel {
* @param {?number=} size = null * @param {?number=} size = null
* @param {boolean=} isInline = false * @param {boolean=} isInline = false
* @param {boolean=} isLinked = false * @param {boolean=} isLinked = false
* @param {string=} cId = '' * @param {string=} CID = ''
* @param {string=} contentLocation = '' * @param {string=} contentLocation = ''
*/ */
constructor(id, fileName, size = null, isInline = false, isLinked = false, cId = '', contentLocation = '') { constructor(id, fileName, size = null, isInline = false, isLinked = false, CID = '', contentLocation = '') {
super(); super();
this.id = id; this.id = id;
this.isInline = !!isInline; this.isInline = !!isInline;
this.isLinked = !!isLinked; this.isLinked = !!isLinked;
this.cId = cId; this.CID = CID;
this.contentLocation = contentLocation; this.contentLocation = contentLocation;
this.fromMessage = false; this.fromMessage = false;

View file

@ -86,7 +86,6 @@ export class ContactModel extends AbstractModel {
focused: false, focused: false,
selected: false, selected: false,
checked: false, checked: false,
sendToAll: true,
deleted: false, deleted: false,
readOnly: false, readOnly: false,
@ -135,6 +134,22 @@ 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 * @static
* @param {jCard} json * @param {jCard} json
@ -194,15 +209,19 @@ export class ContactModel extends AbstractModel {
return contact; return contact;
} }
/**
* @returns {string}
*/
generateUid() {
return '' + this.id;
}
addEmail() { addEmail() {
// home, work // home, work
this.email.push({ this.email.push({
value: ko.observable('') value: ko.observable('')
// type: prop.params.type // type: prop.params.type
}); });
if (this.sendToAllDisplayStatus())
document.getElementById('send-to-all').style.display = 'block';
} }
addTel() { addTel() {
@ -245,7 +264,7 @@ export class ContactModel extends AbstractModel {
this.namePrefix(), this.namePrefix(),
this.nameSuffix() this.nameSuffix()
]/*, params, group*/); ]/*, params, group*/);
jCard.parseFullName({set:true}); // jCard.parseFullName({set:true});
['nickname', 'note', 'title'].forEach(field => ['nickname', 'note', 'title'].forEach(field =>
this[field]() ? jCard.set(field, this[field]()/*, params, group*/) : jCard.remove(field) this[field]() ? jCard.set(field, this[field]()/*, params, group*/) : jCard.remove(field)
@ -285,7 +304,7 @@ export class ContactModel extends AbstractModel {
// jCard.set('rev', '2022-05-21T10:59:52Z') // jCard.set('rev', '2022-05-21T10:59:52Z')
return { return {
uid: this.id, Uid: this.id,
jCard: JSON.stringify(jCard) jCard: JSON.stringify(jCard)
}; };
} }
@ -299,9 +318,4 @@ export class ContactModel extends AbstractModel {
+ (this.checked() ? ' checked' : '') + (this.checked() ? ' checked' : '')
+ (this.focused() ? ' focused' : ''); + (this.focused() ? ' focused' : '');
} }
sendToAllDisplayStatus() {
return this.email.length > 1
}
} }

View file

@ -4,18 +4,269 @@ import { AbstractModel } from 'Knoin/AbstractModel';
'use strict'; '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
*/
function addressparser(str) {
var tokenizer = new Tokenizer(str);
var tokens = tokenizer.tokenize();
var addresses = [];
var address = [];
var parsedAddresses = [];
tokens.forEach(token => {
if (token.type === 'operator' && (token.value === ',' || token.value === ';')) {
address.length && addresses.push(address);
address = [];
} else {
address.push(token);
}
});
address.length && addresses.push(address);
addresses.forEach(address => {
address = _handleAddress(address);
if (address.length) {
parsedAddresses = parsedAddresses.concat(address);
}
});
return parsedAddresses;
}
/**
* Converts tokens for a single address into an address object
*
* @param {Array} tokens Tokens object
* @return {Object} Address object
*/
function _handleAddress(tokens) {
var isGroup = false;
var state = 'text';
var address = void 0;
var addresses = [];
var data = {
address: [],
comment: [],
group: [],
text: []
};
// Filter out <addresses>, (comments) and regular text
tokens.forEach(token => {
if (token.type === 'operator') {
switch (token.value) {
case '<':
state = 'address';
break;
case '(':
state = 'comment';
break;
case ':':
state = 'group';
isGroup = true;
break;
default:
state = 'text';
}
} else if (token.value) {
data[state].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
data.text = data.text.join(' ');
addresses.push({
name: data.text || address && address.name,
group: data.group.length ? addressparser(data.group.join(',')) : []
});
} else {
// If no address was found, try to detect one from regular text
if (!data.address.length && data.text.length) {
var i = data.text.length;
while (i--) {
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
data.address = data.text.splice(i, 1);
break;
}
}
// still no address
if (!data.address.length) {
i = data.text.length;
while (i--) {
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, address => {
if (!data.address.length) {
data.address = [address.trim()];
return '';
}
return address.trim();
});
if (data.address.length) {
break;
}
}
}
}
// If there's still is no text but a comment exixts, 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.address.length > 1) {
data.text = data.text.concat(data.address.splice(1));
}
// Join values with spaces
data.text = data.text.join(' ');
data.address = data.address.join(' ');
if (!data.address && isGroup) {
return [];
}
address = {
address: data.address || data.text || '',
name: data.text || data.address || ''
};
if (address.address === address.name) {
if ((address.address || '').match(/@/)) {
address.name = '';
} else {
address.address = '';
}
}
addresses.push(address);
}
return addresses;
}
/*
* Operator tokens and which tokens are expected to end the sequence
*/
var 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.
';': ''
};
class Tokenizer
{
constructor(str) {
this.str = (str || '').toString();
this.operatorCurrent = '';
this.operatorExpecting = '';
this.node = null;
this.escaped = false;
this.list = [];
}
tokenize() {
var list = [];
[...this.str].forEach(c => this.checkChar(c));
this.list.forEach(node => {
node.value = (node.value || '').toString().trim();
node.value && list.push(node);
});
return list;
}
checkChar(chr) {
if ((chr in OPERATORS || chr === '\\') && this.escaped) {
this.escaped = false;
} else if (this.operatorExpecting && chr === this.operatorExpecting) {
this.node = {
type: 'operator',
value: chr
};
this.list.push(this.node);
this.node = null;
this.operatorExpecting = '';
this.escaped = false;
return;
} else if (!this.operatorExpecting && chr in OPERATORS) {
this.node = {
type: 'operator',
value: chr
};
this.list.push(this.node);
this.node = null;
this.operatorExpecting = OPERATORS[chr];
this.escaped = false;
return;
}
if (!this.escaped && chr === '\\') {
this.escaped = true;
return;
}
if (!this.node) {
this.node = {
type: 'text',
value: ''
};
this.list.push(this.node);
}
if (this.escaped && chr !== '\\') {
this.node.value += '\\';
}
this.node.value += chr;
this.escaped = false;
}
}
export class EmailModel extends AbstractModel { export class EmailModel extends AbstractModel {
/** /**
* @param {string=} email = '' * @param {string=} email = ''
* @param {string=} name = '' * @param {string=} name = ''
* @param {string=} dkimStatus = 'none' * @param {string=} dkimStatus = 'none'
*/ */
constructor(email, name, dkimStatus = 'none') { constructor(email = '', name = '', dkimStatus = 'none') {
super(); super();
this.email = email || ''; this.email = email;
this.name = name || ''; this.name = name;
this.dkimStatus = dkimStatus; this.dkimStatus = dkimStatus;
this.cleanup();
this.clearDuplicateName();
} }
/** /**
@ -25,34 +276,60 @@ export class EmailModel extends AbstractModel {
*/ */
static reviveFromJson(json) { static reviveFromJson(json) {
const email = super.reviveFromJson(json); const email = super.reviveFromJson(json);
email?.cleanup(); email?.clearDuplicateName();
return email?.valid() ? email : null; return email;
}
/**
* @returns {boolean}
*/
valid() {
return this.name || this.email;
} }
/** /**
* @returns {void} * @returns {void}
*/ */
cleanup() { clear() {
this.email = '';
this.name = '';
this.dkimStatus = 'none';
}
/**
* @returns {boolean}
*/
validate() {
return this.name || this.email;
}
/**
* @param {boolean} withoutName = false
* @returns {string}
*/
hash(withoutName = false) {
return '#' + (withoutName ? '' : this.name) + '#' + this.email + '#';
}
/**
* @returns {void}
*/
clearDuplicateName() {
if (this.name === this.email) { if (this.name === this.email) {
this.name = ''; this.name = '';
} }
} }
/**
* @param {string} query
* @returns {boolean}
*/
search(query) {
return (this.name + ' ' + this.email).toLowerCase().includes(query.toLowerCase());
}
/** /**
* @param {boolean} friendlyView = false * @param {boolean} friendlyView = false
* @param {boolean} wrapWithLink = false * @param {boolean} wrapWithLink = false
* @returns {string} * @returns {string}
*/ */
toLine(friendlyView, wrapWithLink) { toLine(friendlyView, wrapWithLink) {
let name = this.name, let result = this.email,
result = this.email, name = this.name,
toLink = text => toLink = text =>
'<a href="mailto:' '<a href="mailto:'
+ encodeHtml(result) + (name ? '?to=' + encodeURIComponent('"' + name + '" <' + result + '>') : '') + encodeHtml(result) + (name ? '?to=' + encodeURIComponent('"' + name + '" <' + result + '>') : '')
@ -71,11 +348,47 @@ export class EmailModel extends AbstractModel {
result = toLink(); result = toLink();
} }
} }
return result || name; return result;
} }
get domain() { static splitEmailLine(line) {
return this.email.replace(/^.+@([^@]+)$/, '$1'); const result = [];
// return this.email.slice(this.email.lastIndexOf('@')) let exists = false;
addressparser(line).forEach(item => {
const address = item.address
? new EmailModel(item.address.replace(/^[<]+(.*)[>]+$/g, '$1'), item.name || '')
: null;
if (address?.email) {
exists = true;
}
result.push(address ? address.toLine() : item.name);
});
return exists ? result : null;
}
static parseEmailLine(line) {
return addressparser(line).map(item =>
item.address ? new EmailModel(item.address.replace(/^[<]+(.*)[>]+$/g, '$1'), item.name || '') : null
).filter(v => v);
}
/**
* @param {string} emailAddress
* @returns {boolean}
*/
parse(emailAddress) {
emailAddress = emailAddress.trim();
if (emailAddress) {
const result = addressparser(emailAddress);
if (result.length) {
this.name = result[0].name || '';
this.email = result[0].address || '';
this.clearDuplicateName();
return true;
}
}
return false;
} }
} }

View file

@ -1,7 +1,5 @@
import { AbstractCollectionModel } from 'Model/AbstractCollection'; import { AbstractCollectionModel } from 'Model/AbstractCollection';
import { EmailModel } from 'Model/Email'; import { EmailModel } from 'Model/Email';
import { forEachObjectValue } from 'Common/Utils';
import { addressparser } from 'Mime/Address';
'use strict'; 'use strict';
@ -15,16 +13,6 @@ export class EmailCollectionModel extends AbstractCollectionModel
return super.reviveFromJson(items, email => EmailModel.reviveFromJson(email)); return super.reviveFromJson(items, email => EmailModel.reviveFromJson(email));
} }
/**
* @param {string} text
* @returns {EmailCollectionModel}
*/
static fromString(str) {
let list = new this();
list.fromString(str);
return list;
}
/** /**
* @param {boolean=} friendlyView = false * @param {boolean=} friendlyView = false
* @param {boolean=} wrapWithLink = false * @param {boolean=} wrapWithLink = false
@ -33,43 +21,4 @@ export class EmailCollectionModel extends AbstractCollectionModel
toString(friendlyView, wrapWithLink) { toString(friendlyView, wrapWithLink) {
return this.map(email => email.toLine(friendlyView, wrapWithLink)).join(', '); return this.map(email => email.toLine(friendlyView, wrapWithLink)).join(', ');
} }
/**
* @param {string} text
*/
fromString(str) {
if (str) {
let items = {}, key;
addressparser(str).forEach(item => {
item = new EmailModel(item.email, item.name);
// Make them unique
key = item.email || item.name;
if (key && (item.name || !items[key])) {
items[key] = item;
}
});
forEachObjectValue(items, item => this.push(item));
}
}
/**
* @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);
}
});
}
*/
} }

View file

@ -3,7 +3,7 @@ import { AbstractCollectionModel } from 'Model/AbstractCollection';
import { UNUSED_OPTION_VALUE } from 'Common/Consts'; import { UNUSED_OPTION_VALUE } from 'Common/Consts';
import { isArray, getKeyByValue, forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils'; import { isArray, getKeyByValue, forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
import { ClientSideKeyNameExpandedFolders, FolderType, FolderMetadataKeys } from 'Common/EnumsUser'; import { ClientSideKeyNameExpandedFolders, FolderType, FolderMetadataKeys } from 'Common/EnumsUser';
import { clearCache, getFolderFromCacheList, setFolder, setFolderInboxName } from 'Common/Cache'; import { getFolderFromCacheList, setFolder, setFolderInboxName } from 'Common/Cache';
import { Settings, SettingsGet, fireEvent } from 'Common/Globals'; import { Settings, SettingsGet, fireEvent } from 'Common/Globals';
import * as Local from 'Storage/Client'; import * as Local from 'Storage/Client';
@ -20,17 +20,10 @@ import { AbstractModel } from 'Knoin/AbstractModel';
import { /*koComputable,*/ addObservablesTo } from 'External/ko'; import { /*koComputable,*/ addObservablesTo } from 'External/ko';
import { mailBox } from 'Common/Links'; //import { mailBox } from 'Common/Links';
import Remote from 'Remote/User/Fetch'; 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 const
// isPosNumeric = value => null != value && /^[0-9]*$/.test(value.toString()), // isPosNumeric = value => null != value && /^[0-9]*$/.test(value.toString()),
@ -98,13 +91,12 @@ export const
Remote.abort('Folders') Remote.abort('Folders')
.post('Folders', FolderUserStore.foldersLoading) .post('Folders', FolderUserStore.foldersLoading)
.then(data => { .then(data => {
clearCache();
FolderCollectionModel.reviveFromJson(data.Result)?.storeIt(); FolderCollectionModel.reviveFromJson(data.Result)?.storeIt();
fCallback?.(true); fCallback?.(true);
// Repeat every 15 minutes? // Repeat every 15 minutes?
// this.foldersTimeout = setTimeout(loadFolders, 900000); // this.foldersTimeout = setTimeout(loadFolders, 900000);
}) })
.catch(e => fCallback && setTimeout(fCallback, 1, false, e)); .catch(() => fCallback && setTimeout(fCallback, 1, false));
}; };
export class FolderCollectionModel extends AbstractCollectionModel export class FolderCollectionModel extends AbstractCollectionModel
@ -117,8 +109,6 @@ export class FolderCollectionModel extends AbstractCollectionModel
this.namespace; this.namespace;
this.optimized this.optimized
this.capabilities this.capabilities
this.allow; // allow adding
// this.exist;
} }
*/ */
@ -134,11 +124,11 @@ export class FolderCollectionModel extends AbstractCollectionModel
); );
const result = super.reviveFromJson(object, oFolder => { const result = super.reviveFromJson(object, oFolder => {
let oCacheFolder = getFolderFromCacheList(oFolder.fullName); let oCacheFolder = getFolderFromCacheList(oFolder.FullName);
if (oCacheFolder) { if (oCacheFolder) {
// oCacheFolder.revivePropertiesFromJson(oFolder); // oCacheFolder.revivePropertiesFromJson(oFolder);
if (oFolder.etag) { if (oFolder.Hash) {
oCacheFolder.etag = oFolder.etag; oCacheFolder.hash = oFolder.Hash;
} }
if (null != oFolder.totalEmails) { if (null != oFolder.totalEmails) {
oCacheFolder.totalEmails(oFolder.totalEmails); oCacheFolder.totalEmails(oFolder.totalEmails);
@ -177,32 +167,32 @@ export class FolderCollectionModel extends AbstractCollectionModel
break; break;
} }
// Flags // Flags
if (oFolder.attributes.includes('\\sentmail')) { if (oFolder.Flags.includes('\\sentmail')) {
role = 'sent'; role = 'sent';
} }
if (oFolder.attributes.includes('\\spam')) { if (oFolder.Flags.includes('\\spam')) {
role = 'junk'; role = 'junk';
} }
if (oFolder.attributes.includes('\\bin')) { if (oFolder.Flags.includes('\\bin')) {
role = 'trash'; role = 'trash';
} }
if (oFolder.attributes.includes('\\important')) { if (oFolder.Flags.includes('\\important')) {
role = 'important'; role = 'important';
} }
if (oFolder.attributes.includes('\\starred')) { if (oFolder.Flags.includes('\\starred')) {
role = 'flagged'; role = 'flagged';
} }
if (oFolder.attributes.includes('\\all') || oFolder.flags.includes('\\allmail')) { if (oFolder.Flags.includes('\\all') || oFolder.Flags.includes('\\allmail')) {
role = 'all'; role = 'all';
} }
} }
*/ */
if (role) { if (role) {
role = role[0].toUpperCase() + role.slice(1); role = role[0].toUpperCase() + role.slice(1);
SystemFolders[role] || (SystemFolders[role] = oFolder.fullName); SystemFolders[role] || (SystemFolders[role] = oFolder.FullName);
} }
oCacheFolder.type(FolderType[getKeyByValue(SystemFolders, oFolder.fullName)] || 0); oCacheFolder.type(FolderType[getKeyByValue(SystemFolders, oFolder.FullName)] || 0);
oCacheFolder.collapsed(!expandedFolders oCacheFolder.collapsed(!expandedFolders
|| !isArray(expandedFolders) || !isArray(expandedFolders)
@ -231,13 +221,14 @@ export class FolderCollectionModel extends AbstractCollectionModel
name = parents.pop(), name = parents.pop(),
pfolder = getFolderFromCacheList(parentName); pfolder = getFolderFromCacheList(parentName);
if (!pfolder) { if (!pfolder) {
console.log('Create nonexistent folder ' + parentName);
pfolder = FolderModel.reviveFromJson({ pfolder = FolderModel.reviveFromJson({
'@Object': 'Object/Folder', '@Object': 'Object/Folder',
name: name, Name: name,
fullName: parentName, FullName: parentName,
delimiter: delimiter, Delimiter: delimiter,
attributes: ['\\nonexistent'] Exists: false,
isSubscribed: false,
Flags: ['\\nonexistent']
}); });
setFolder(pfolder); setFolder(pfolder);
result.splice(i, 0, pfolder); result.splice(i, 0, pfolder);
@ -260,10 +251,6 @@ export class FolderCollectionModel extends AbstractCollectionModel
return result; return result;
} }
visible() {
return this.filter(folder => folder.visible());
}
storeIt() { storeIt() {
FolderUserStore.displaySpecSetting(Settings.app('folderSpecLimit') < this.CountRec); FolderUserStore.displaySpecSetting(Settings.app('folderSpecLimit') < this.CountRec);
@ -283,9 +270,11 @@ export class FolderCollectionModel extends AbstractCollectionModel
FolderUserStore.namespace = this.namespace; FolderUserStore.namespace = this.namespace;
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT' // 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
AppUserStore.threadsAllowed(!!this.capabilities.some(capa => capa.startsWith('THREAD='))); AppUserStore.threadsAllowed(!!(
Settings.app('useImapThread') && this.capabilities.some(capa => capa.startsWith('THREAD='))
));
// FolderUserStore.optimized(!!this.optimized); // FolderUserStore.folderListOptimized(!!this.optimized);
FolderUserStore.quotaUsage(this.quotaUsage); FolderUserStore.quotaUsage(this.quotaUsage);
FolderUserStore.quotaLimit(this.quotaLimit); FolderUserStore.quotaLimit(this.quotaLimit);
FolderUserStore.capabilities(this.capabilities); FolderUserStore.capabilities(this.capabilities);
@ -306,7 +295,6 @@ export class FolderModel extends AbstractModel {
super(); super();
this.fullName = ''; this.fullName = '';
this.parentName = '';
this.delimiter = ''; this.delimiter = '';
this.deep = 0; this.deep = 0;
this.expires = 0; this.expires = 0;
@ -314,10 +302,9 @@ export class FolderModel extends AbstractModel {
this.exists = true; this.exists = true;
this.etag = ''; this.hash = '';
this.id = 0; // this.id = null;
this.uidNext = 0; this.uidNext = null;
this.size = 0;
addObservablesTo(this, { addObservablesTo(this, {
name: '', name: '',
@ -327,10 +314,12 @@ export class FolderModel extends AbstractModel {
focused: false, focused: false,
selected: false, selected: false,
editing: false,
isSubscribed: true, isSubscribed: true,
checkable: false, // Check for new messages checkable: false, // Check for new messages
askDelete: false, askDelete: false,
nameForEdit: '',
errorMsg: '', errorMsg: '',
totalEmails: 0, totalEmails: 0,
@ -343,13 +332,13 @@ export class FolderModel extends AbstractModel {
tagsAllowed: false tagsAllowed: false
}); });
this.attributes = ko.observableArray(); this.flags = ko.observableArray();
// For messages
this.permanentFlags = ko.observableArray(); this.permanentFlags = ko.observableArray();
this.addSubscribables({ this.addSubscribables({
kolabType: sValue => this.metadata[FolderMetadataKeys.KolabFolderType] = sValue, kolabType: sValue => this.metadata[FolderMetadataKeys.KolabFolderType] = sValue,
permanentFlags: aValue => this.tagsAllowed(aValue.includes('\\*')), 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) unreadEmails: unread => FolderType.Inbox === this.type() && fireEvent('mailbox.inbox-unread-count', unread)
}); });
@ -371,19 +360,20 @@ export class FolderModel extends AbstractModel {
.extend({ notify: 'always' }); .extend({ notify: 'always' });
*/ */
/* /*
// https://www.rfc-editor.org/rfc/rfc8621.html#section-2 https://www.rfc-editor.org/rfc/rfc8621.html#section-2
this.myRights = { "myRights": {
'mayAddItems': true, "mayAddItems": true,
'mayCreateChild': true, "mayRename": false,
'mayDelete': true, "maySubmit": true,
'mayReadItems': true, "mayDelete": false,
'mayRemoveItems': true, "maySetKeywords": true,
'mayRename': true, "mayRemoveItems": true,
'maySetKeywords': true, "mayCreateChild": true,
'maySetSeen': true, "maySetSeen": true,
'maySubmit': true "mayReadItems": true
}; },
*/ */
this.addComputables({ this.addComputables({
isInbox: () => FolderType.Inbox === this.type(), isInbox: () => FolderType.Inbox === this.type(),
@ -391,10 +381,7 @@ export class FolderModel extends AbstractModel {
isFlagged: () => FolderUserStore.currentFolder() === this isFlagged: () => FolderUserStore.currentFolder() === this
&& MessagelistUserStore.listSearch().includes('flagged'), && MessagelistUserStore.listSearch().includes('flagged'),
// isSubscribed: () => this.attributes().includes('\\subscribed'),
hasVisibleSubfolders: () => !!this.subFolders().find(folder => folder.visible()), hasVisibleSubfolders: () => !!this.subFolders().find(folder => folder.visible()),
visibleSubfolders: () => this.subFolders().visible(),
hasSubscriptions: () => this.isSubscribed() | !!this.subFolders().find( hasSubscriptions: () => this.isSubscribed() | !!this.subFolders().find(
oFolder => { oFolder => {
@ -403,6 +390,8 @@ export class FolderModel extends AbstractModel {
} }
), ),
canBeEdited: () => !this.type() && this.exists/* && this.selectable()*/,
isSystemFolder: () => this.type() isSystemFolder: () => this.type()
| (FolderUserStore.allowKolab() && !!this.kolabType() & !SettingsUserStore.unhideKolabFolders()), | (FolderUserStore.allowKolab() && !!this.kolabType() & !SettingsUserStore.unhideKolabFolders()),
@ -413,8 +402,6 @@ export class FolderModel extends AbstractModel {
canBeSubscribed: () => this.selectable() canBeSubscribed: () => this.selectable()
&& !(this.isSystemFolder() | !SettingsUserStore.hideUnsubscribed()), && !(this.isSystemFolder() | !SettingsUserStore.hideUnsubscribed()),
optionalTags: () => this.permanentFlags.filter(isAllowedKeyword),
/** /**
* Folder is visible when: * Folder is visible when:
* - hasVisibleSubfolders() * - hasVisibleSubfolders()
@ -456,47 +443,30 @@ export class FolderModel extends AbstractModel {
return name; return name;
}, },
nameInfo: () => { manageFolderSystemName: () => {
if (this.isSystemFolder()) { if (this.isSystemFolder()) {
translateTrigger(); translateTrigger();
let suffix = getSystemFolderName(this.type(), getKolabFolderName(this.kolabType())); let suffix = getSystemFolderName(this.type(), getKolabFolderName(this.kolabType()));
if (this.name() !== suffix && 'inbox' !== suffix.toLowerCase()) { if (this.name() !== suffix && 'inbox' !== suffix.toLowerCase()) {
return ' (' + suffix + ')'; return '(' + suffix + ')';
} }
} }
return ''; return '';
}, },
friendlySize: () => FileInfo.friendlySize(this.size), hasSubscribedUnreadMessagesSubfolders: () =>
!!this.subFolders().find(
detailedName: () => this.name() + ' ' + this.nameInfo(), folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
)
icon: () => { /*
switch (this.type()) !!this.subFolders().filter(
{ folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
case 1: return '📥'; // FolderType.Inbox ).length
case 2: return '📧'; // FolderType.Sent icon-paper-plane */
case 3: return '🗎'; // FolderType.Drafts // ,href: () => this.canBeSelected() && mailBox(this.fullNameHash)
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() {
showScreenPopup(FolderPopupView, [this]);
}
/** /**
* For url safe '/#/mailbox/...' path * For url safe '/#/mailbox/...' path
*/ */
@ -514,7 +484,6 @@ export class FolderModel extends AbstractModel {
const folder = super.reviveFromJson(json); const folder = super.reviveFromJson(json);
if (folder) { if (folder) {
const path = folder.fullName.split(folder.delimiter), const path = folder.fullName.split(folder.delimiter),
attr = name => folder.attributes.includes(name),
type = (folder.metadata[FolderMetadataKeys.KolabFolderType] type = (folder.metadata[FolderMetadataKeys.KolabFolderType]
|| folder.metadata[FolderMetadataKeys.KolabFolderTypeShared] || folder.metadata[FolderMetadataKeys.KolabFolderTypeShared]
|| '' || ''
@ -524,12 +493,6 @@ export class FolderModel extends AbstractModel {
path.pop(); path.pop();
folder.parentName = path.join(folder.delimiter); folder.parentName = path.join(folder.delimiter);
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); type && 'mail' != type && folder.kolabType(type);
} }
return folder; return folder;

View file

@ -1,52 +1,36 @@
//import { AbstractModel } from 'Knoin/AbstractModel'; import { AbstractModel } from 'Knoin/AbstractModel';
import { EmailModel } from 'Model/Email'; import { addObservablesTo } from 'External/ko';
import { addObservablesTo, addComputablesTo } from 'External/ko';
export class IdentityModel extends EmailModel /*AbstractModel*/ { export class IdentityModel extends AbstractModel {
constructor() { /**
* @param {string} id
* @param {string} email
*/
constructor(id, email) {
super(); super();
addObservablesTo(this, { addObservablesTo(this, {
id: '', id: id || '',
label: '', email: email,
// email: '', name: '',
// name: '',
replyTo: '', replyTo: '',
bcc: '', bcc: '',
sentFolder: '',
signature: '', signature: '',
signatureInsertBefore: false, signatureInsertBefore: false,
pgpSign: false, askDelete: false
pgpEncrypt: false,
smimeKey: '',
smimeCertificate: '',
smimeCertificateChain: '',
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()),
smimeCertificateChainValid: () => !this.smimeCertificateChain()
|| /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificateChain())
}); });
} }
/** /**
* @returns {string} * @returns {string}
*/ */
toString() { formattedName() {
const name = this.name, const name = this.name(),
email = this.email, email = this.email();
label = this.label();
return (name ? `${name} ` : '') + `<${email}>` + (label ? ` (${label})` : ''); return name ? name + ' <' + email + '>' : email;
} }
} }

View file

@ -1,91 +1,37 @@
import ko from 'ko'; import ko from 'ko';
import { MessagePriority } from 'Common/EnumsUser';
import { i18n } from 'Common/Translator'; import { i18n } from 'Common/Translator';
import { doc, elementById, SettingsGet } from 'Common/Globals'; import { doc, SettingsGet } from 'Common/Globals';
import { encodeHtml, plainToHtml, htmlToPlain, cleanHtml } from 'Common/Html'; import { encodeHtml, plainToHtml, htmlToPlain, cleanHtml } from 'Common/Html';
import { forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils'; import { isFunction, forEachObjectEntry } from 'Common/Utils';
import { serverRequestRaw, proxy } from 'Common/Links'; import { serverRequestRaw, proxy } from 'Common/Links';
import { addObservablesTo, addComputablesTo } from 'External/ko'; import { addObservablesTo, addComputablesTo } from 'External/ko';
import { FolderUserStore } from 'Stores/User/Folder'; import { FolderUserStore, isAllowedKeyword } from 'Stores/User/Folder';
import { SettingsUserStore } from 'Stores/User/Settings'; import { SettingsUserStore } from 'Stores/User/Settings';
import { FileInfo, RFC822 } from 'Common/File'; import { FileInfo } from 'Common/File';
import { AttachmentCollectionModel } from 'Model/AttachmentCollection'; import { AttachmentCollectionModel } from 'Model/AttachmentCollection';
import { EmailCollectionModel } from 'Model/EmailCollection'; import { EmailCollectionModel } from 'Model/EmailCollection';
import { MimeHeaderCollectionModel } from 'Model/MimeHeaderCollection';
//import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
import { AbstractModel } from 'Knoin/AbstractModel'; import { AbstractModel } from 'Knoin/AbstractModel';
import PreviewHTML from 'Html/PreviewMessage.html';
import { LanguageStore } from 'Stores/Language'; import { LanguageStore } from 'Stores/Language';
//import { MessageFlagsCache } from 'Common/Cache';
import Remote from 'Remote/User/Fetch'; import Remote from 'Remote/User/Fetch';
import { MimeToMessage } from 'Mime/Utils';
import { PgpUserStore } from 'Stores/User/Pgp';
import { IdentityUserStore } from 'Stores/User/Identity';
import { Passphrases } from 'Storage/Passphrases';
const const
PreviewHTML = `<html> hcont = Element.fromHTML('<div area="hidden" style="position:absolute;left:-200vw;width:max(50vw,400px)"></div>'),
<head> getRealHeight = el => {
<meta charset="utf-8"> hcont.innerHTML = el.outerHTML;
<title></title> const result = hcont.clientHeight;
<style> hcont.innerHTML = '';
html, body { return result;
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) => { toggleTag = (message, keyword) => {
const lower = keyword.toLowerCase(), const lower = keyword.toLowerCase(),
@ -94,12 +40,13 @@ body > * {
Remote.request('MessageSetKeyword', iError => { Remote.request('MessageSetKeyword', iError => {
if (!iError) { if (!iError) {
isSet ? flags.remove(lower) : flags.push(lower); isSet ? flags.remove(lower) : flags.push(lower);
// MessageFlagsCache.setFor(message.folder, message.uid, flags());
} }
}, { }, {
folder: message.folder, Folder: message.folder,
uids: message.uid, Uids: message.uid,
keyword: keyword, Keyword: keyword,
setAction: isSet ? 0 : 1 SetAction: isSet ? 0 : 1
}) })
}, },
@ -113,55 +60,47 @@ body > * {
unic[email.email] || localEmails.has(email.email) || localEmails.set(email.email, email) unic[email.email] || localEmails.has(email.email) || localEmails.set(email.email, email)
); );
doc.body.append(hcont);
export class MessageModel extends AbstractModel { export class MessageModel extends AbstractModel {
constructor() { constructor() {
super(); super();
Object.assign(this, { this.folder = '';
folder: '', this.uid = 0;
uid: 0, this.hash = '';
hash: '', this.requestHash = '';
from: new EmailCollectionModel, this.from = new EmailCollectionModel;
to: new EmailCollectionModel, this.to = new EmailCollectionModel;
cc: new EmailCollectionModel, this.cc = new EmailCollectionModel;
bcc: new EmailCollectionModel, this.bcc = new EmailCollectionModel;
sender: new EmailCollectionModel, this.replyTo = new EmailCollectionModel;
replyTo: new EmailCollectionModel, this.deliveredTo = new EmailCollectionModel;
deliveredTo: new EmailCollectionModel, this.body = null;
body: null, this.draftInfo = [];
draftInfo: [], this.dkim = [];
dkim: [], this.spf = [];
spf: [], this.dmarc = [];
dmarc: [], this.messageId = '';
messageId: '', this.inReplyTo = '';
inReplyTo: '', this.references = '';
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, { addObservablesTo(this, {
subject: '', subject: '',
plain: '', plain: '',
html: '', html: '',
dateTimestamp: 0, size: 0,
dateTimestampSource: 0, spamScore: 0,
spamResult: '',
isSpam: false,
hasVirus: null, // or boolean when scanned
dateTimeStampInUTC: 0,
priority: MessagePriority.Normal,
senderEmailsString: '',
senderClearEmailsString: '',
deleted: false,
// Also used by Selector // Also used by Selector
focused: false, focused: false,
@ -171,99 +110,52 @@ export class MessageModel extends AbstractModel {
isHtml: false, isHtml: false,
hasImages: false, hasImages: false,
hasExternals: false, hasExternals: false,
hasTracking: false,
encrypted: false,
pgpSigned: null, pgpSigned: null,
pgpVerified: null,
encrypted: false,
pgpEncrypted: null, pgpEncrypted: null,
pgpDecrypted: false, pgpDecrypted: false,
smimeSigned: null, readReceipt: '',
smimeEncrypted: null,
smimeDecrypted: false,
// rfc8621 hasUnseenSubMessage: false,
id: '', hasFlaggedSubMessage: false
// threadId: ''
/**
* Basic support for Linked Data (Structured Email)
* https://json-ld.org/
* https://structured.email/
**/
linkedData: []
}); });
this.attachments = ko.observableArray(new AttachmentCollectionModel);
this.threads = ko.observableArray();
this.unsubsribeLinks = ko.observableArray();
this.flags = ko.observableArray();
addComputablesTo(this, { addComputablesTo(this, {
attachmentIconClass: () => attachmentIconClass: () =>
this.encrypted() ? 'icon-lock' : FileInfo.getAttachmentsIconClass(this.attachments()), this.encrypted() ? 'icon-lock' : FileInfo.getAttachmentsIconClass(this.attachments()),
threadsLen: () => rl.app.messageList.threadUid() ? 0 : this.threads().length, threadsLen: () => 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'), isUnseen: () => !this.flags().includes('\\seen'),
isFlagged: () => this.flags().includes('\\flagged'), isFlagged: () => this.flags().includes('\\flagged'),
isDeleted: () => this.flags().includes('\\deleted'),
// isJunk: () => this.flags().includes('$junk') && !this.flags().includes('$nonjunk'), // isJunk: () => this.flags().includes('$junk') && !this.flags().includes('$nonjunk'),
// isPhishing: () => this.flags().includes('$phishing'), // isPhishing: () => this.flags().includes('$phishing'),
tagOptions: () => { tagOptions: () => {
const tagOptions = []; const tagOptions = [];
FolderUserStore.currentFolder().optionalTags().forEach(value => { FolderUserStore.currentFolder().permanentFlags.forEach(value => {
let lower = value.toLowerCase(); if (isAllowedKeyword(value)) {
tagOptions.push({ let lower = value.toLowerCase();
css: 'msgflag-' + lower, tagOptions.push({
value: value, css: 'msgflag-' + lower,
checked: this.flags().includes(lower), value: value,
label: i18n('MESSAGE_TAGS/'+lower, 0, value), checked: this.flags().includes(lower),
toggle: (/*obj*/) => toggleTag(this, value) label: i18n('MESSAGE_TAGS/'+lower, 0, value),
}); toggle: (/*obj*/) => toggleTag(this, value)
});
}
}); });
return tagOptions return tagOptions
},
whitelistOptions: () => {
let options = [];
if ('match' === SettingsUserStore.viewImages()) {
let from = this.from[0],
list = SettingsUserStore.viewImagesWhitelist(),
counts = {};
this.html().match(/src=["'][^"']+/g)?.forEach(m => {
m = m.replace(/^.+(:\/\/[^/]+).+$/, '$1');
if (counts[m]) {
++counts[m];
} else {
counts[m] = 1;
options.push(m);
}
});
options = options.filter(txt => !list.includes(txt)).sort((a,b) => (counts[a] < counts[b])
? 1
: (counts[a] > counts[b] ? -1 : a.localeCompare(b))
);
from && options.unshift(from.email);
}
return options;
} }
});
this.smimeSigned.subscribe(value =>
value?.body && MimeToMessage(value.body, this)
);
}
get requestHash() {
return b64EncodeJSONSafe({
folder: this.folder,
uid: this.uid,
mimeType: RFC822,
fileName: (this.subject() || 'message') + '-' + this.hash + '.eml',
accountHash: SettingsGet('accountHash')
}); });
} }
@ -272,23 +164,23 @@ export class MessageModel extends AbstractModel {
} }
spamStatus() { spamStatus() {
let spam = this.spamResult; let spam = this.spamResult();
return spam ? i18n(this.isSpam ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : ''; return spam ? i18n(this.isSpam() ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
} }
/** /**
* @returns {string} * @returns {string}
*/ */
friendlySize() { friendlySize() {
return FileInfo.friendlySize(this.size); return FileInfo.friendlySize(this.size());
} }
computeSenderEmail() { computeSenderEmail() {
const list = this[ const list = this[
[FolderUserStore.sentFolder(), FolderUserStore.draftsFolder()].includes(this.folder) ? 'to' : 'from' [FolderUserStore.sentFolder(), FolderUserStore.draftsFolder()].includes(this.folder) ? 'to' : 'from'
]; ];
this.senderEmailsString = list.toString(true); this.senderEmailsString(list.toString(true));
this.senderClearEmailsString = list.map(email => email?.email).filter(email => email).join(', '); this.senderClearEmailsString(list.map(email => email?.email).filter(email => email).join(', '));
} }
/** /**
@ -296,67 +188,14 @@ export class MessageModel extends AbstractModel {
* @returns {boolean} * @returns {boolean}
*/ */
revivePropertiesFromJson(json) { revivePropertiesFromJson(json) {
if ('Priority' in json && ![MessagePriority.High, MessagePriority.Low].includes(json.Priority)) {
json.Priority = MessagePriority.Normal;
}
if (super.revivePropertiesFromJson(json)) { if (super.revivePropertiesFromJson(json)) {
// this.foundCIDs = isArray(json.FoundCIDs) ? json.FoundCIDs : []; // this.foundCIDs = isArray(json.FoundCIDs) ? json.FoundCIDs : [];
// this.attachments(AttachmentCollectionModel.reviveFromJson(json.attachments, this.foundCIDs)); // this.attachments(AttachmentCollectionModel.reviveFromJson(json.Attachments, this.foundCIDs));
// this.headers(MimeHeaderCollectionModel.reviveFromJson(json.headers));
this.computeSenderEmail(); 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;
} }
} }
@ -366,13 +205,16 @@ export class MessageModel extends AbstractModel {
lineAsCss(flags=1) { lineAsCss(flags=1) {
let classes = []; let classes = [];
forEachObjectEntry({ forEachObjectEntry({
deleted: this.deleted(),
selected: this.selected(), selected: this.selected(),
checked: this.checked(), checked: this.checked(),
unseen: this.isUnseen(), unseen: this.isUnseen(),
focused: this.focused(), focused: this.focused(),
priorityHigh: this.priority === 1, priorityHigh: this.priority() === MessagePriority.High,
withAttachments: !!this.attachments().length, withAttachments: !!this.attachments().length,
// hasChildrenMessage: 1 < this.threadsLen() // hasChildrenMessage: 1 < this.threadsLen(),
hasUnseenSubMessage: this.hasUnseenSubMessage(),
hasFlaggedSubMessage: this.hasFlaggedSubMessage()
}, (key, value) => value && classes.push(key)); }, (key, value) => value && classes.push(key));
flags && this.flags().forEach(value => classes.push('msgflag-'+value)); flags && this.flags().forEach(value => classes.push('msgflag-'+value));
return classes.join(' '); return classes.join(' ');
@ -429,161 +271,141 @@ export class MessageModel extends AbstractModel {
return [[...toResult.values()], [...ccResult.values()]]; return [[...toResult.values()], [...ccResult.values()]];
} }
viewBody(html) { viewHtml() {
const body = this.body; const body = this.body;
if (body) { if (body && this.html()) {
if (html) { let result = cleanHtml(this.html(), this.attachments());
let result = msgHtml(this); this.hasExternals(result.hasExternals);
this.hasExternals(result.hasExternals); this.hasImages(body.rlHasImages = !!result.hasExternals);
this.hasImages(!!result.hasExternals);
this.hasTracking(!!result.tracking); body.innerHTML = result.html;
this.linkedData(result.linkedData);
body.innerHTML = result.html; body.classList.toggle('html', 1);
if (!this.isSpam && FolderUserStore.spamFolder() != this.folder) { body.classList.toggle('plain', 0);
if ('always' === SettingsUserStore.viewImages()) {
this.showExternalImages(); if (SettingsUserStore.showImages() && !this.isSpam() && FolderUserStore.spamFolder() != this.folder) {
} this.showExternalImages();
if ('match' === SettingsUserStore.viewImages()) {
this.showExternalImages(1);
}
}
} else {
body.innerHTML = plainToHtml(
(this.plain()
? this.plain()
.replace(/-----BEGIN PGP (SIGNED MESSAGE-----(\r?\n[^\r\n]+)+|SIGNATURE-----[\s\S]*)/sg, '')
.trim()
: htmlToPlain(body.innerHTML || msgHtml(this).html)
)
);
this.hasImages(false);
} }
body.classList.toggle('html', html);
body.classList.toggle('plain', !html); this.isHtml(true);
this.isHtml(html); this.initView();
return true; return true;
} }
}
viewHtml() {
return this.html() && this.viewBody(true);
} }
viewPlain() { viewPlain() {
return this.viewBody(false); const body = this.body;
if (body) {
body.classList.toggle('html', 0);
body.classList.toggle('plain', 1);
body.innerHTML = plainToHtml(
(this.plain()
? this.plain()
.replace(/-----BEGIN PGP (SIGNED MESSAGE-----(\r?\n[a-z][^\r\n]+)+|SIGNATURE-----[\s\S]*)/, '')
.trim()
: htmlToPlain(body.innerHTML)
)
);
this.isHtml(false);
this.hasImages(false);
this.initView();
return true;
}
} }
swapColors() { initView() {
const cl = this.body?.classList; // init BlockquoteSwitcher
cl && cl.toggle('swapColors'); this.body.querySelectorAll('blockquote').forEach(node => {
if (node.textContent.trim()) {
let h = node.clientHeight || getRealHeight(node);
if (0 === h || 100 < h) {
const el = Element.fromHTML('<details class="sm-bq-switcher"><summary>•••</summary></details>');
node.replaceWith(el);
el.append(node);
}
}
});
}
viewPopupMessage(print) {
const timeStampInUTC = this.dateTimeStampInUTC() || 0,
ccLine = this.cc.toString(),
m = 0 < timeStampInUTC ? new Date(timeStampInUTC * 1000) : null,
win = open(''),
sdoc = win.document;
let subject = encodeHtml(this.subject()),
mode = this.isHtml() ? 'div' : 'pre',
cc = ccLine ? `<div>${encodeHtml(i18n('GLOBAL/CC'))}: ${encodeHtml(ccLine)}</div>` : '',
style = getComputedStyle(doc.querySelector('.messageView')),
prop = property => style.getPropertyValue(property);
sdoc.write(PreviewHTML
.replace('<title>', '<title>'+subject)
// eslint-disable-next-line max-len
.replace('<body>', `<body style="background-color:${prop('background-color')};color:${prop('color')}"><header><h1>${subject}</h1><time>${encodeHtml(m ? m.format('LLL',0,LanguageStore.hourCycle()) : '')}</time><div>${encodeHtml(this.from)}</div><div>${encodeHtml(i18n('GLOBAL/TO'))}: ${encodeHtml(this.to)}</div>${cc}</header><${mode}>${this.bodyAsHTML()}</${mode}>`)
);
sdoc.close();
if (print) {
setTimeout(() => win.print(), 100);
}
} }
/** /**
* @param {boolean=} print = false * @param {boolean=} print = false
*/ */
popupMessage(print) { popupMessage() {
const this.viewPopupMessage();
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
,SettingsUserStore.messageNewWindow() ? 'innerWidth=' + elementById('V-MailMessageView').clientWidth : ''
),
sdoc = win.document,
subject = encodeHtml(this.subject()),
mode = this.isHtml() ? 'div' : 'pre',
to = `<div>${encodeHtml(i18n('GLOBAL/TO'))}: ${encodeHtml(this.to)}</div>`
+ (ccLine ? `<div>${encodeHtml(i18n('GLOBAL/CC'))}: ${encodeHtml(ccLine)}</div>` : '')
+ (bccLine ? `<div>${encodeHtml(i18n('GLOBAL/BCC'))}: ${encodeHtml(bccLine)}</div>` : ''),
style = getComputedStyle(doc.querySelector('.messageView')),
prop = property => style.getPropertyValue(property);
let attachments = '';
this.attachments.forEach(attachment => {
attachments += `<a href="${attachment.linkDownload()}">${attachment.fileName}</a>`;
});
sdoc.write(PreviewHTML
.replace('<title>', '<title>'+subject)
// eslint-disable-next-line max-len
.replace('<body>', `<body style="background-color:${prop('background-color')};color:${prop('color')}"><header><h1>${subject}</h1><time>${encodeHtml(m ? m.format('LLL',0,LanguageStore.hourCycle()) : '')}</time><div>${encodeHtml(this.from)}</div>${to}</header><${mode}>${this.bodyAsHTML()}</${mode}>`)
.replace('</body>', `<div id="attachments">${attachments}</div></body>`)
);
sdoc.close();
(true === print) && setTimeout(() => win.print(), 100);
} }
printMessage() { printMessage() {
this.popupMessage(true); this.viewPopupMessage(true);
} }
/** /**
* @returns {MessageModel} * @returns {string}
*//* */
clone() { generateUid() {
let self = new MessageModel(); return this.folder + '/' + this.uid;
// Clone message values }
forEachObjectEntry(this, (key, value) => {
if (ko.isObservable(value)) {
ko.isComputed(value) || self[key](value());
} else if (!isFunction(value)) {
self[key] = value;
}
});
self.computeSenderEmail();
return self;
}*/
showExternalImages(regex) { /**
* @param {MessageModel} message
* @returns {MessageModel}
*/
static fromMessageListItem(message) {
let self = new MessageModel();
if (message) {
// Clone message values
forEachObjectEntry(message, (key, value) => {
if (ko.isObservable(value)) {
ko.isComputed(value) || self[key](value());
} else if (!isFunction(value)) {
self[key] = value;
}
});
self.computeSenderEmail();
}
return self;
}
showExternalImages() {
const body = this.body; const body = this.body;
if (body && this.hasImages()) { if (body && this.hasImages()) {
if (regex) { this.hasImages(false);
regex = []; body.rlHasImages = false;
SettingsUserStore.viewImagesWhitelist().trim().split(/[\s\r\n,;]+/g).forEach(rule => {
rule = rule.split('+'); let attr = 'data-x-src',
rule[0] = rule[0].trim(); src, useProxy = !!SettingsGet('UseLocalProxyForExternalImages');
if (rule[0]
&& (!rule.includes('spf') || 'pass' === this.spf[0]?.[0])
&& (!rule.includes('dkim') || 'pass' === this.dkim[0]?.[0])
&& (!rule.includes('dmarc') || 'pass' === this.dmarc[0]?.[0])
) {
regex.push(rule[0].replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'));
}
});
regex = regex.join('|').replace(/\|+/g, '|');
if (regex) {
console.log('whitelist images = '+regex);
regex = new RegExp(regex);
if (this.from[0]?.email.match(regex)) {
regex = null;
}
}
}
let hasImages = false,
isValid = src => {
if (null == regex || (regex && src.match(regex))) {
return true;
}
hasImages = true;
},
attr = 'data-x-src',
src, useProxy = !!SettingsGet('proxyExternalImages');
body.querySelectorAll('img[' + attr + ']').forEach(node => { body.querySelectorAll('img[' + attr + ']').forEach(node => {
src = node.getAttribute(attr); src = node.getAttribute(attr);
if (isValid(src)) { node.src = useProxy ? proxy(src) : src;
node.src = useProxy ? proxy(src) : src;
}
}); });
body.querySelectorAll('[data-x-style-url]').forEach(node => { body.querySelectorAll('[data-x-style-url]').forEach(node => {
JSON.parse(node.dataset.xStyleUrl).forEach(data => { JSON.parse(node.dataset.xStyleUrl).forEach(data =>
if (isValid(data[1])) { node.style[data[0]] = "url('" + (useProxy ? proxy(data[1]) : data[1]) + "')"
node.style[data[0]] = "url('" + (useProxy ? proxy(data[1]) : data[1]) + "')" );
}
});
}); });
this.hasImages(hasImages);
} }
} }
@ -596,146 +418,10 @@ export class MessageModel extends AbstractModel {
clone.querySelectorAll('.sm-bq-switcher').forEach( clone.querySelectorAll('.sm-bq-switcher').forEach(
node => node.replaceWith(node.lastElementChild) node => node.replaceWith(node.lastElementChild)
); );
return (clone.querySelector('.mail-body') || clone).innerHTML; return clone.innerHTML;
} }
let result = msgHtml(this); let result = cleanHtml(this.html(), this.attachments())
return result.html || plainToHtml(this.plain()); return result.html || plainToHtml(this.plain());
} }
async decrypt() {
const msg = this;
if (msg.pgpEncrypted() && !msg.pgpDecrypted()) {
return await msg.pgpDecrypt().then(()=>msg.pgpDecrypted());
}
if (msg.smimeEncrypted() && !msg.smimeDecrypted()) {
return await msg.smimeDecrypt().then(()=>msg.smimeDecrypted());
}
return true;
}
/*
async decrypted() {
return msg.pgpDecrypted() || msg.smimeDecrypted());
}
*/
async pgpDecrypt() {
const oMessage = this,
data = oMessage.pgpEncrypted();
delete data.error;
await PgpUserStore.decrypt(oMessage).then(result => {
if (!result) {
// TODO: translate
throw Error('Decryption failed, canceled or not possible');
}
oMessage.pgpDecrypted(true);
if (result.data) {
MimeToMessage(result.data, oMessage);
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
if (result.signatures?.length) {
oMessage.pgpSigned({
signatures: result.signatures,
success: !!result.signatures.length
});
}
}
})
.catch(e => {
data.error = e.message;
})
.finally(() => {
oMessage.pgpEncrypted(data);
});
}
pgpVerify(/*self, event*/) {
const oMessage = this;
PgpUserStore.verify(oMessage).then(result => {
if (result) {
oMessage.pgpSigned(result);
} else {
alert('Verification failed or no valid public key found');
}
/*
if (result?.success) {
i18n('CRYPTO/GOOD_SIGNATURE', {
USER: validKey.user + ' (' + validKey.id + ')'
});
message.getText()
} else {
const keyIds = arrayLength(signingKeyIds) ? signingKeyIds : null,
additional = keyIds
? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ')
: '';
i18n('CRYPTO/ERROR', {
TYPE: 'OpenPGP',
ERROR: 'message'
}) + (additional ? ' (' + additional + ')' : '');
}
*/
});
}
async smimeDecrypt() {
const message = this;
const addresses = message.from.concat(message.to, message.cc, message.bcc).map(item => item.email),
identity = IdentityUserStore.find(item => addresses.includes(item.email)),
data = message.smimeEncrypted(); // { partId: "1" }
if (data && identity) {
delete data.error;
let pass, params = { ...data }; // clone
params.folder = message.folder;
params.uid = message.uid;
// params.bodyPart = params.bodyPart?.raw;
params.certificate = identity.smimeCertificate();
params.privateKey = identity.smimeKey();
if (identity.smimeKeyEncrypted()) {
pass = await Passphrases.ask(identity,
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email}),
'CRYPTO/DECRYPT'
);
if (!pass) {
return;
}
params.passphrase = pass?.password;
}
await Remote.post('SMimeDecryptMessage', null, params).then(response => {
if (response?.Result?.data) {
message.smimeDecrypted(true);
MimeToMessage(response.Result.data, message);
message.html() ? message.viewHtml() : message.viewPlain();
pass && pass.remember && Passphrases.handle(identity, pass.password);
if ('signed' in response.Result) {
message.smimeSigned(response.Result.signed);
}
}
}).catch(e => {
data.error = e.message
})
.finally(() => {
message.smimeEncrypted(data);
});
}
}
smimeVerify(/*self, event*/) {
const message = this,
data = message.smimeSigned(); // { partId: "1", micAlg: "pgp-sha256" }
if (data) {
const params = { ...data }; // clone
params.folder = message.folder;
params.uid = message.uid;
params.bodyPart = data.bodyPart?.raw;
params.sigPart = data.sigPart?.bodyRaw;
Remote.post('SMimeVerifyMessage', null, params).then(response => {
if (response?.Result) {
if (response.Result.body) {
MimeToMessage(response.Result.body, message);
message.html() ? message.viewHtml() : message.viewPlain();
}
data.success = response.Result.success;
message.smimeSigned(data);
}
});
}
}
} }

View file

@ -1,6 +1,9 @@
import { AbstractCollectionModel } from 'Model/AbstractCollection'; import { AbstractCollectionModel } from 'Model/AbstractCollection';
import { MessageModel } from 'Model/Message'; import { MessageModel } from 'Model/Message';
import { MessageUserStore } from 'Stores/User/Message';
import {
MessageFlagsCache
} from 'Common/Cache';
'use strict'; 'use strict';
@ -9,16 +12,17 @@ export class MessageCollectionModel extends AbstractCollectionModel
/* /*
constructor() { constructor() {
super(); super();
this.filtered this.Filtered
this.folder this.Folder
this.folderHash
this.folderInfo
this.totalEmails this.totalEmails
this.totalThreads this.unreadEmails
this.threadUid this.threadUid
this.newMessages this.newMessages
this.offset this.offset
this.limit this.limit
this.search this.search
this.limited
} }
*/ */
@ -26,17 +30,14 @@ export class MessageCollectionModel extends AbstractCollectionModel
* @param {?Object} json * @param {?Object} json
* @returns {MessageCollectionModel} * @returns {MessageCollectionModel}
*/ */
static reviveFromJson(object/*, cached*/) { static reviveFromJson(object, cached) {
let msg = MessageUserStore.message();
return super.reviveFromJson(object, message => { return super.reviveFromJson(object, message => {
// If message is currently viewed, use that. message = MessageModel.reviveFromJson(message);
if (msg && msg.hash === message.hash) { if (message) {
msg.revivePropertiesFromJson(message); message.deleted(false);
message = msg; cached ? MessageFlagsCache.initMessage(message) : MessageFlagsCache.store(message);
} else { return message;
message = MessageModel.reviveFromJson(message);
} }
return message;
}); });
} }
} }

View file

@ -1,13 +0,0 @@
import ko from 'ko';
import { AbstractModel } from 'Knoin/AbstractModel';
export class MimeHeaderModel extends AbstractModel
{
constructor() {
super();
this.name = '';
this.value = '';
this.parameters = ko.observableArray();
}
}

View file

@ -1,34 +0,0 @@
//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-----';
}
}

View file

@ -1,38 +0,0 @@
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);
}
}

View file

@ -1,4 +1,4 @@
import { Notifications } from 'Common/Enums'; import { Notification } from 'Common/Enums';
import { isArray, pInt, pString } from 'Common/Utils'; import { isArray, pInt, pString } from 'Common/Utils';
import { serverRequest } from 'Common/Links'; import { serverRequest } from 'Common/Links';
import { getNotification } from 'Common/Translator'; import { getNotification } from 'Common/Translator';
@ -8,18 +8,19 @@ let iJsonErrorCount = 0;
const getURL = (add = '') => serverRequest('Json') + pString(add), const getURL = (add = '') => serverRequest('Json') + pString(add),
checkResponseError = data => { checkResponseError = data => {
const err = data ? data.code : null; const err = data ? data.ErrorCode : null;
if (Notifications.InvalidToken === err) { if (Notification.InvalidToken === err) {
console.error(getNotification(err) + ` (${data.messageAdditional})`); console.error(getNotification(err));
// alert(getNotification(err)); // alert(getNotification(err));
setTimeout(rl.logoutReload, 5000); rl.logoutReload();
} else if ([ } else if ([
Notifications.AuthError, Notification.AuthError,
Notifications.ConnectionError, Notification.ConnectionError,
Notifications.DomainNotAllowed, Notification.DomainNotAllowed,
Notifications.AccountNotAllowed, Notification.AccountNotAllowed,
Notifications.MailServerError, Notification.MailServerError,
Notifications.UnknownError Notification.UnknownNotification,
Notification.UnknownError
].includes(err) ].includes(err)
) { ) {
if (7 < ++iJsonErrorCount) { if (7 < ++iJsonErrorCount) {
@ -31,11 +32,10 @@ checkResponseError = data => {
oRequests = {}, oRequests = {},
abort = (sAction, sReason, bClearOnly) => { abort = (sAction, sReason, bClearOnly) => {
let controller = oRequests[sAction]; if (oRequests[sAction]) {
oRequests[sAction] = null; bClearOnly || oRequests[sAction].abort(sReason || 'AbortError');
if (controller) { oRequests[sAction] = null;
clearTimeout(controller.timeoutId); delete oRequests[sAction];
bClearOnly || controller.abort(new DOMException(sAction, sReason || 'AbortError'));
} }
}, },
@ -48,18 +48,15 @@ fetchJSON = (action, sUrl, params, timeout, jsonCallback) => {
} }
} }
// Don't abort, read https://github.com/the-djmaze/snappymail/issues/487 // Don't abort, read https://github.com/the-djmaze/snappymail/issues/487
// abort(action, 0, 1); // abort(action);
const controller = new AbortController(), const controller = new AbortController(),
signal = controller.signal; signal = controller.signal;
oRequests[action] = controller; oRequests[action] = controller;
// Currently there is no way to combine multiple signals, so AbortSignal.timeout() not possible // Currently there is no way to combine multiple signals, so AbortSignal.timeout() not possible
controller.timeoutId = timeout && setTimeout(() => abort(action, 'TimeoutError'), timeout); timeout && setTimeout(() => abort(action, 'TimeoutError'), timeout);
return rl.fetchJSON(sUrl, {signal: signal}, params).then(data => { return rl.fetchJSON(sUrl, {signal: signal}, params).then(jsonCallback).catch(err => {
abort(action, 0, 1);
return jsonCallback ? jsonCallback(data) : Promise.resolve(data);
}).catch(err => {
clearTimeout(controller.timeoutId);
err.aborted = signal.aborted; err.aborted = signal.aborted;
err.reason = signal.reason;
return Promise.reject(err); return Promise.reject(err);
}); });
}; };
@ -68,14 +65,14 @@ class FetchError extends Error
{ {
constructor(code, message) { constructor(code, message) {
super(message); super(message);
this.code = code || Notifications.JsonFalse; this.code = code || Notification.JsonFalse;
} }
} }
export class AbstractFetchRemote export class AbstractFetchRemote
{ {
abort(sAction, sReason) { abort(sAction) {
abort(sAction, sReason); abort(sAction);
return this; return this;
} }
@ -131,10 +128,14 @@ export class AbstractFetchRemote
fetchJSON(sAction, getURL(sGetAdd), fetchJSON(sAction, getURL(sGetAdd),
sGetAdd ? null : (params || {}), sGetAdd ? null : (params || {}),
pInt(iTimeout ?? 30000), undefined === iTimeout ? 30000 : pInt(iTimeout),
async data => { data => {
let iError = 0; let iError = 0;
if (data) { if (sAction && oRequests[sAction]) {
abort(sAction, 0, 1);
}
if (!iError && data) {
/* /*
if (sAction !== data.Action) { if (sAction !== data.Action) {
console.log(sAction + ' !== ' + data.Action); console.log(sAction + ' !== ' + data.Action);
@ -144,14 +145,10 @@ export class AbstractFetchRemote
iJsonErrorCount = 0; iJsonErrorCount = 0;
} else { } else {
checkResponseError(data); checkResponseError(data);
iError = data.code || Notifications.UnknownError iError = data.ErrorCode || Notification.UnknownError
} }
} }
if (111 === iError && rl.app.ask && await rl.app.ask.cryptkey()) {
return this.request(sAction, fCallback, params, iTimeout, sGetAdd);
}
fCallback && fCallback( fCallback && fCallback(
iError, iError,
data, data,
@ -167,12 +164,19 @@ export class AbstractFetchRemote
.catch(err => { .catch(err => {
console.error({fetchError:err}); console.error({fetchError:err});
fCallback && fCallback( fCallback && fCallback(
'TimeoutError' == err.name ? 3 : (err.name == 'AbortError' ? 2 : 1), 'TimeoutError' == err.reason ? 3 : (err.name == 'AbortError' ? 2 : 1),
err err
); );
}); });
} }
/**
* @param {?Function} fCallback
*/
getPublicKey(fCallback) {
this.request('GetPublicKey', fCallback);
}
setTrigger(trigger, value) { setTrigger(trigger, value) {
if (trigger) { if (trigger) {
value = !!value; value = !!value;
@ -189,15 +193,11 @@ export class AbstractFetchRemote
post(action, fTrigger, params, timeOut) { post(action, fTrigger, params, timeOut) {
this.setTrigger(fTrigger, true); this.setTrigger(fTrigger, true);
return fetchJSON(action, getURL(), params || {}, pInt(timeOut, 30000), return fetchJSON(action, getURL(), params || {}, pInt(timeOut, 30000),
async data => { data => {
abort(action, 0, 1); abort(action, 0, 1);
if (!data) { if (!data) {
return Promise.reject(new FetchError(Notifications.JsonParse)); return Promise.reject(new FetchError(Notification.JsonParse));
}
if (111 === data?.code && rl.app.ask && await rl.app.ask.cryptkey()) {
return this.post(action, fTrigger, params, timeOut);
} }
/* /*
let isCached = false, type = ''; let isCached = false, type = '';
@ -222,8 +222,8 @@ export class AbstractFetchRemote
if (!data.Result || action !== data.Action) { if (!data.Result || action !== data.Action) {
checkResponseError(data); checkResponseError(data);
return Promise.reject(new FetchError( return Promise.reject(new FetchError(
data ? data.code : 0, data ? data.ErrorCode : 0,
data ? (data.messageAdditional || data.message) : '' data ? (data.ErrorMessageAdditional || data.ErrorMessage) : ''
)); ));
} }

View file

@ -9,11 +9,52 @@ import { SUB_QUERY_PREFIX } from 'Common/Links';
import { AppUserStore } from 'Stores/User/App'; import { AppUserStore } from 'Stores/User/App';
import { SettingsUserStore } from 'Stores/User/Settings'; import { SettingsUserStore } from 'Stores/User/Settings';
import { FolderUserStore } from 'Stores/User/Folder';
import { AbstractFetchRemote } from 'Remote/AbstractFetch'; import { AbstractFetchRemote } from 'Remote/AbstractFetch';
class RemoteUserFetch extends AbstractFetchRemote { class RemoteUserFetch extends AbstractFetchRemote {
/**
* @param {Function} fCallback
* @param {object} params
* @param {boolean=} bSilent = false
*/
messageList(fCallback, params, bSilent = false) {
const
// folder = getFolderFromCacheList(params.folder.fullName),
folder = getFolderFromCacheList(params.folder),
folderHash = folder?.hash || '';
params = Object.assign({
offset: 0,
limit: SettingsUserStore.messagesPerPage(),
search: '',
uidNext: folder?.uidNext || 0, // Used to check for new messages
sort: FolderUserStore.sortMode()
}, params);
if (AppUserStore.threadsAllowed() && SettingsUserStore.useThreads()) {
params.useThreads = 1;
} else {
params.threadUid = 0;
}
let sGetAdd = '';
if (folderHash) {
params.hash = folderHash + '-' + SettingsGet('AccountHash');
sGetAdd = 'MessageList/' + SUB_QUERY_PREFIX + '/' + b64EncodeJSONSafe(params);
params = {};
}
bSilent || this.abort('MessageList');
this.request('MessageList',
fCallback,
params,
60000, // 60 seconds before aborting
sGetAdd
);
}
/** /**
* @param {?Function} fCallback * @param {?Function} fCallback
* @param {string} sFolderFullName * @param {string} sFolderFullName
@ -36,7 +77,7 @@ class RemoteUserFetch extends AbstractFetchRemote {
sFolderFullName, sFolderFullName,
iUid, iUid,
AppUserStore.threadsAllowed() && SettingsUserStore.useThreads() ? 1 : 0, AppUserStore.threadsAllowed() && SettingsUserStore.useThreads() ? 1 : 0,
SettingsGet('accountHash') SettingsGet('AccountHash')
]) ])
); );
@ -64,6 +105,16 @@ class RemoteUserFetch extends AbstractFetchRemote {
[key]: value [key]: value
}); });
} }
/*
folderMove(sPrevFolderFullName, sNewFolderFullName, bSubscribe) {
return this.post('FolderMove', FolderUserStore.foldersRenaming, {
Folder: sPrevFolderFullName,
NewFolder: sNewFolderFullName,
Subscribe: bSubscribe ? 1 : 0
});
}
*/
} }
export default new RemoteUserFetch(); export default new RemoteUserFetch();

View file

@ -2,7 +2,7 @@ import ko from 'ko';
import { pString } from 'Common/Utils'; import { pString } from 'Common/Utils';
import { settings } from 'Common/Links'; import { settings } from 'Common/Links';
import { createElement/*, elementById*/, fireEvent } from 'Common/Globals'; import { createElement, elementById } from 'Common/Globals';
import { AbstractScreen } from 'Knoin/AbstractScreen'; import { AbstractScreen } from 'Knoin/AbstractScreen';
import { i18nToNodes } from 'Common/Translator'; import { i18nToNodes } from 'Common/Translator';
@ -25,42 +25,35 @@ export class AbstractSettingsScreen extends AbstractScreen {
let settingsScreen = null, let settingsScreen = null,
viewModelDom = null, viewModelDom = null,
RoutedSettingsViewModel = VIEW_MODELS.find( RoutedSettingsViewModel = VIEW_MODELS.find(
SettingsViewModel => subName === SettingsViewModel.route SettingsViewModel => subName === SettingsViewModel.__rlSettingsData.route
); );
if (RoutedSettingsViewModel) { if (RoutedSettingsViewModel) {
// const vmPlace = elementById('V-SettingsPane') || elementById('V-AdminPane); const vmPlace = elementById('rl-settings-subscreen');
const vmPlace = this.viewModels[1].__vm.viewModelDom, if (RoutedSettingsViewModel.__vm) {
SettingsViewModelClass = RoutedSettingsViewModel.vmc; settingsScreen = RoutedSettingsViewModel.__vm;
if (SettingsViewModelClass.__vm) {
settingsScreen = SettingsViewModelClass.__vm;
viewModelDom = settingsScreen.viewModelDom;
} else if (vmPlace) { } else if (vmPlace) {
viewModelDom = createElement('div',{ viewModelDom = createElement('div',{
id: 'V-Settings-' + SettingsViewModelClass.name.replace(/(User|Admin)Settings/,''), id: 'V-Settings-' + RoutedSettingsViewModel.name.replace(/(User|Admin)Settings/,''),
hidden: '' hidden: ''
}) })
vmPlace.append(viewModelDom); vmPlace.append(viewModelDom);
settingsScreen = new SettingsViewModelClass(); settingsScreen = new RoutedSettingsViewModel();
settingsScreen.viewModelDom = viewModelDom; settingsScreen.viewModelDom = viewModelDom;
settingsScreen.viewModelTemplateID = RoutedSettingsViewModel.template;
SettingsViewModelClass.__vm = settingsScreen; RoutedSettingsViewModel.__dom = viewModelDom;
RoutedSettingsViewModel.__vm = settingsScreen;
fireEvent('rl-view-model.create', settingsScreen);
ko.applyBindingAccessorsToNode( ko.applyBindingAccessorsToNode(
viewModelDom, viewModelDom,
{ {
template: () => ({ name: RoutedSettingsViewModel.template }) template: () => ({ name: RoutedSettingsViewModel.__rlSettingsData.template })
}, },
settingsScreen settingsScreen
); );
settingsScreen.onBuild?.(viewModelDom); settingsScreen.onBuild?.(viewModelDom);
fireEvent('rl-view-model', settingsScreen);
} else { } else {
console.log('Cannot find sub settings view model position: SettingsSubScreen'); console.log('Cannot find sub settings view model position: SettingsSubScreen');
} }
@ -75,13 +68,13 @@ export class AbstractSettingsScreen extends AbstractScreen {
// show // show
settingsScreen.beforeShow?.(); settingsScreen.beforeShow?.();
i18nToNodes(viewModelDom); i18nToNodes(settingsScreen.viewModelDom);
viewModelDom.hidden = false; settingsScreen.viewModelDom.hidden = false;
settingsScreen.onShow?.(); settingsScreen.onShow?.();
this.menu.forEach(item => { this.menu.forEach(item => {
item.selected( item.selected(
item.route === RoutedSettingsViewModel.route item.route === RoutedSettingsViewModel.__rlSettingsData.route
); );
}); });
@ -103,21 +96,19 @@ export class AbstractSettingsScreen extends AbstractScreen {
} }
onBuild() { onBuild() {
// TODO: issue on account switch VIEW_MODELS.forEach(SettingsViewModel => this.menu.push(SettingsViewModel.__rlSettingsData));
// When current domain has sieve but the new has not, or current has not and the new has
// SettingsViewModel.disabled() || this.menu.push()
VIEW_MODELS.forEach(SettingsViewModel => this.menu.push(SettingsViewModel));
} }
routes() { routes() {
const DefaultViewModel = VIEW_MODELS.find( const DefaultViewModel = VIEW_MODELS.find(
SettingsViewModel => SettingsViewModel.isDefault SettingsViewModel => SettingsViewModel.__rlSettingsData.isDefault
), ),
defaultRoute = DefaultViewModel?.route || 'general', defaultRoute =
DefaultViewModel ? DefaultViewModel.__rlSettingsData.route : 'general',
rules = { rules = {
subname: /^(.*)$/, subname: /^(.*)$/,
normalize_: (rquest, vals) => { normalize_: (rquest, vals) => {
vals.subname = pString(vals.subname ?? defaultRoute); vals.subname = null == vals.subname ? defaultRoute : pString(vals.subname);
return [vals.subname]; return [vals.subname];
} }
}; };
@ -140,12 +131,13 @@ export class AbstractSettingsScreen extends AbstractScreen {
*/ */
export function settingsAddViewModel(SettingsViewModelClass, template, labelName, route, isDefault = false) { export function settingsAddViewModel(SettingsViewModelClass, template, labelName, route, isDefault = false) {
let name = SettingsViewModelClass.name.replace(/(User|Admin)Settings/, ''); let name = SettingsViewModelClass.name.replace(/(User|Admin)Settings/, '');
VIEW_MODELS.push({ SettingsViewModelClass.__rlSettingsData = {
vmc: SettingsViewModelClass,
label: labelName || 'SETTINGS_LABELS/' + name.toUpperCase(), label: labelName || 'SETTINGS_LABELS/' + name.toUpperCase(),
route: route || name.toLowerCase(), route: route || name.toLowerCase(),
selected: ko.observable(false), selected: ko.observable(false),
template: template || SettingsViewModelClass.name, template: template || SettingsViewModelClass.name,
isDefault: !!isDefault isDefault: !!isDefault
}); };
VIEW_MODELS.push(SettingsViewModelClass);
} }

View file

@ -1,14 +1,17 @@
import { ScopeMessageList } from 'Common/Enums'; import { Scope } from 'Common/Enums';
import { doc, createElement, Settings } from 'Common/Globals'; import { Layout, ClientSideKeyNameMessageListSize } from 'Common/EnumsUser';
import { doc, createElement, leftPanelDisabled, Settings, elementById } from 'Common/Globals';
import { pString, pInt } from 'Common/Utils'; import { pString, pInt } from 'Common/Utils';
import { moveAction } from 'Common/UtilsUser'; import { setLayoutResizer, moveAction } from 'Common/UtilsUser';
import { getFolderFromHashMap, getFolderInboxName } from 'Common/Cache'; import { getFolderFromCacheList, getFolderFullName, getFolderInboxName } from 'Common/Cache';
import { i18n, initOnStartOrLangChange } from 'Common/Translator'; import { i18n, initOnStartOrLangChange } from 'Common/Translator';
import { SettingsUserStore } from 'Stores/User/Settings';
import { AppUserStore } from 'Stores/User/App'; import { AppUserStore } from 'Stores/User/App';
import { AccountUserStore } from 'Stores/User/Account'; import { AccountUserStore } from 'Stores/User/Account';
import { FolderUserStore } from 'Stores/User/Folder'; import { FolderUserStore } from 'Stores/User/Folder';
import { MessagelistUserStore } from 'Stores/User/Messagelist'; import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { ThemeStore } from 'Stores/Theme';
import { SystemDropDownUserView } from 'View/User/SystemDropDown'; import { SystemDropDownUserView } from 'View/User/SystemDropDown';
import { MailFolderList } from 'View/User/MailBox/FolderList'; import { MailFolderList } from 'View/User/MailBox/FolderList';
@ -56,8 +59,11 @@ export class MailBoxUserScreen extends AbstractScreen {
*/ */
onShow() { onShow() {
this.updateWindowTitle(); this.updateWindowTitle();
AppUserStore.focusedState('none'); AppUserStore.focusedState('none');
AppUserStore.focusedState(ScopeMessageList); AppUserStore.focusedState(Scope.MessageList);
ThemeStore.isMobile() && leftPanelDisabled(true);
} }
/** /**
@ -67,8 +73,7 @@ export class MailBoxUserScreen extends AbstractScreen {
* @returns {void} * @returns {void}
*/ */
onRoute(folderHash, page, search, messageUid) { onRoute(folderHash, page, search, messageUid) {
// Only works when FolderUserStore.folderList() is loaded const folder = getFolderFromCacheList(getFolderFullName(folderHash.replace(/~([\d]+)$/, '')));
const folder = getFolderFromHashMap(folderHash.replace(/~([\d]+)$/, ''));
if (folder) { if (folder) {
FolderUserStore.currentFolder(folder); FolderUserStore.currentFolder(folder);
MessagelistUserStore.page(1 > page ? 1 : page); MessagelistUserStore.page(1 > page ? 1 : page);
@ -108,8 +113,26 @@ export class MailBoxUserScreen extends AbstractScreen {
* @returns {void} * @returns {void}
*/ */
onBuild() { onBuild() {
setTimeout(() => {
// initMailboxLayoutResizer
const top = elementById('V-MailMessageList'),
bottom = elementById('V-MailMessageView'),
fToggle = () => {
let layout = SettingsUserStore.layout();
setLayoutResizer(top, bottom, ClientSideKeyNameMessageListSize,
(ThemeStore.isMobile() || Layout.NoPreview === layout)
? 0
: (Layout.SidePreview === layout ? 'Width' : 'Height')
);
};
if (top && bottom) {
fToggle();
addEventListener('rl-layout', fToggle);
}
}, 1);
doc.addEventListener('click', event => doc.addEventListener('click', event =>
event.target.closest('#rl-right') && moveAction(0) event.target.closest('#rl-right') && moveAction(false)
); );
} }

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