Compare commits

..

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

1834 changed files with 45877 additions and 119764 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
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
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
# apcu
RUN set -eux; \
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
pecl install apcu; \
docker-php-ext-enable apcu; \
docker-php-source delete; \
apk del .build-dependencies;
# gd
RUN set -eux; \
apk add --no-cache freetype libjpeg-turbo libpng; \
apk add --no-cache --virtual .deps freetype-dev libjpeg-turbo-dev libpng-dev; \
docker-php-ext-configure gd --with-freetype --with-jpeg; \
docker-php-ext-install gd; \
apk del .deps
# gmagick
# RUN set -eux; \
# apk add --no-cache graphicsmagick libgomp; \
# apk add --no-cache --virtual .deps graphicsmagick-dev libtool; \
# apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
# pecl install gmagick-2.0.6RC1; \
# docker-php-ext-enable gmagick; \
# docker-php-source delete; \
# apk del .build-dependencies; \
# apk del .deps
# gnupg
RUN set -eux; \
apk add --no-cache gnupg gpgme; \
apk add --no-cache --virtual .deps gpgme-dev; \
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
pecl install gnupg; \
docker-php-ext-enable gnupg; \
docker-php-source delete; \
apk del .build-dependencies; \
apk del .deps
# imagick
RUN set -eux; \
apk add --no-cache imagemagick libgomp; \
apk add --no-cache --virtual .deps imagemagick-dev; \
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
echo | pecl install imagick; \
docker-php-ext-enable imagick; \
docker-php-source delete; \
apk del .build-dependencies; \
apk del .deps
# intl
RUN set -eux; \
apk add --no-cache icu-libs; \
apk add --no-cache --virtual .deps icu-dev; \
docker-php-ext-configure intl; \
docker-php-ext-install intl; \
apk del .deps
# ldap
RUN set -eux; \
apk add --no-cache libldap; \
apk add --no-cache --virtual .deps openldap-dev; \
docker-php-ext-configure ldap; \
docker-php-ext-install ldap; \
apk del .deps
# mysql
RUN docker-php-ext-install pdo_mysql
# 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
RUN php -m && \
docker-php-ext-configure ldap --with-libdir=lib/$(uname -m)-linux-gnu/ && \
docker-php-ext-configure intl && \
docker-php-ext-configure gd --with-freetype --with-jpeg && \
docker-php-ext-install ldap opcache pdo_mysql pdo_pgsql zip intl gd && \
php -m
# 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
COPY --chown=www-data:www-data --from=builder /snappymail /snappymail
# Use a custom snappymail data folder
RUN mv -v /snappymail/data /var/lib/snappymail;
# Setup configs
COPY --chown=root:root .docker/release/files /
RUN set -eux; \
chown www-data:www-data /snappymail/include.php; \
chmod 440 /snappymail/include.php; \
chmod +x /entrypoint.sh; \
# Disable the built-in php-fpm configs, since we're using our own config
mv -v /usr/local/etc/php-fpm.d/docker.conf /usr/local/etc/php-fpm.d/docker.conf.disabled; \
mv -v /usr/local/etc/php-fpm.d/www.conf /usr/local/etc/php-fpm.d/www.conf.disabled; \
mv -v /usr/local/etc/php-fpm.d/zz-docker.conf /usr/local/etc/php-fpm.d/zz-docker.conf.disabled;
WORKDIR /tmp
COPY ${FILES_ZIP} .
RUN mkdir /snappymail && \
unzip -q ${FILES_ZIP} -d /snappymail && \
find /snappymail -type d -exec chmod 755 {} \; && \
find /snappymail -type f -exec chmod 644 {} \; && \
rm -rf ${FILES_ZIP}
USER root
WORKDIR /snappymail
VOLUME /var/lib/snappymail
# Install other content
COPY files /
RUN chmod +x /entrypoint.sh && chmod +x /logrotate-loop.sh
VOLUME /snappymail/data
EXPOSE 8888
EXPOSE 9000
ENTRYPOINT []
CMD ["/entrypoint.sh"]

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

@ -1,26 +1,23 @@
#!/bin/sh
set -eu
DEBUG=${DEBUG:-}
if [ "$DEBUG" = 'true' ]; then
set -x
fi
UPLOAD_MAX_SIZE=${UPLOAD_MAX_SIZE:-25M}
MEMORY_LIMIT=${MEMORY_LIMIT:-128M}
SECURE_COOKIES=${SECURE_COOKIES:-true}
# Create not root user
groupadd --gid "$GID" php-cli -f
adduser --uid "$UID" --disabled-password --gid "$GID" --shell /bin/bash --home /home/php-cli php-cli --force --gecos ""
# Disable listening on ipv6 for ipv4-only environments, or nginx will fail to start
if ! ip a | grep 'inet6 ' > /dev/null; then
echo "[INFO] ipv6 is disabled. Configuring nginx to not listen on ipv6"
sed -i '/listen \[::\]:/d' /etc/nginx/nginx.conf
fi
# Set attachment size limit
sed -i "s/<UPLOAD_MAX_SIZE>/$UPLOAD_MAX_SIZE/g" /usr/local/etc/php-fpm.d/php-fpm.conf /etc/nginx/nginx.conf
sed -i "s/<MEMORY_LIMIT>/$MEMORY_LIMIT/g" /usr/local/etc/php-fpm.d/php-fpm.conf
# Set log output to STDERR if wanted (LOG_TO_STDERR=true)
if [ "$LOG_TO_STDERR" = true ]; then
echo "[INFO] Logging to stderr activated"
sed -i "s/.*error_log.*$/error_log \/dev\/stderr warn;/" /etc/nginx/nginx.conf
sed -i "s/.*error_log.*$/php_admin_value[error_log] = \/dev\/stderr/" /usr/local/etc/php-fpm.d/php-fpm.conf
fi
# Secure cookies
if [ "${SECURE_COOKIES}" = 'true' ]; then
if [ "${SECURE_COOKIES}" = true ]; then
echo "[INFO] Secure cookies activated"
{
echo 'session.cookie_httponly = On';
@ -29,58 +26,43 @@ if [ "${SECURE_COOKIES}" = 'true' ]; then
} > /usr/local/etc/php/conf.d/cookies.ini;
fi
echo "[INFO] Snappymail version: $( ls /snappymail/snappymail/v )"
# Set permissions on snappymail data
echo "[INFO] Setting permissions on /var/lib/snappymail"
chown -R www-data:www-data /var/lib/snappymail/
chmod 550 /var/lib/snappymail/
find /var/lib/snappymail/ -type d -exec chmod 750 {} \;
# Create snappymail default config if absent
SNAPPYMAIL_CONFIG_FILE=/var/lib/snappymail/_data_/_default_/configs/application.ini
# Copy snappymail default config if absent
SNAPPYMAIL_CONFIG_FILE=/snappymail/data/_data_/_default_/configs/application.ini
if [ ! -f "$SNAPPYMAIL_CONFIG_FILE" ]; then
echo "[INFO] Creating default Snappymail configuration: $SNAPPYMAIL_CONFIG_FILE"
# Run snappymail and exit. This populates the snappymail data directory and generates the config file
# On error, print php exception and exit
EXITCODE=
su - www-data -s /bin/sh -c 'php /snappymail/index.php' > /tmp/out || EXITCODE=$?
if [ -n "$EXITCODE" ]; then
cat /tmp/out
exit "$EXITCODE"
fi
echo "[INFO] Creating default Snappymail configuration"
mkdir -p $(dirname $SNAPPYMAIL_CONFIG_FILE)
cp /usr/local/include/application.ini $SNAPPYMAIL_CONFIG_FILE
fi
echo "[INFO] Overriding values in snappymail configuration: $SNAPPYMAIL_CONFIG_FILE"
# Enable output of snappymail logs
sed '/^\; Enable logging/{
N
s/enable = Off/enable = On/
}' -i $SNAPPYMAIL_CONFIG_FILE
# Redirect snappymail logs to stderr /stdout
sed 's/^filename = .*/filename = "stderr"/' -i $SNAPPYMAIL_CONFIG_FILE
sed 's/^write_on_error_only = .*/write_on_error_only = Off/' -i $SNAPPYMAIL_CONFIG_FILE
sed 's/^write_on_php_error_only = .*/write_on_php_error_only = On/' -i $SNAPPYMAIL_CONFIG_FILE
if [ "${LOG_TO_STDERR}" = true ]; then
sed -z 's/\; Enable logging\nenable = Off/\; Enable logging\nenable = On/' -i $SNAPPYMAIL_CONFIG_FILE
sed 's/^filename = .*/filename = "errors.log"/' -i $SNAPPYMAIL_CONFIG_FILE
sed 's/^write_on_error_only = .*/write_on_error_only = Off/' -i $SNAPPYMAIL_CONFIG_FILE
sed 's/^write_on_php_error_only = .*/write_on_php_error_only = On/' -i $SNAPPYMAIL_CONFIG_FILE
else
sed -z 's/\; Enable logging\nenable = On/\; Enable logging\nenable = Off/' -i $SNAPPYMAIL_CONFIG_FILE
fi
# Always enable snappymail Auth logging
sed 's/^auth_logging = .*/auth_logging = On/' -i $SNAPPYMAIL_CONFIG_FILE
sed 's/^auth_logging_filename = .*/auth_logging_filename = "auth.log"/' -i $SNAPPYMAIL_CONFIG_FILE
sed 's/^auth_logging_format = .*/auth_logging_format = "[{date:Y-m-d H:i:s}] Auth failed: ip={request:ip} user={imap:login} host={imap:host} port={imap:port}"/' -i $SNAPPYMAIL_CONFIG_FILE
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/
(
while ! nc -vz -w 1 127.0.0.1 8888 > /dev/null 2>&1; do echo "[INFO] Checking whether nginx is alive"; sleep 1; done
while ! nc -vz -w 1 127.0.0.1 9000 > /dev/null 2>&1; do echo "[INFO] Checking whether php-fpm is alive"; sleep 1; done
# Create snappymail admin password if absent
SNAPPYMAIL_ADMIN_PASSWORD_FILE=/var/lib/snappymail/_data_/_default_/admin_password.txt
if [ ! -f "$SNAPPYMAIL_ADMIN_PASSWORD_FILE" ]; then
echo "[INFO] Creating Snappymail admin password file: $SNAPPYMAIL_ADMIN_PASSWORD_FILE"
wget -T 1 -qO- 'http://127.0.0.1:8888/?/AdminAppData/0/12345/' > /dev/null
echo "[INFO] Snappymail Admin Panel ready at http://localhost:8888/?admin. Login using password in $SNAPPYMAIL_ADMIN_PASSWORD_FILE"
fi
# Fix permissions
chown -R $UID:$GID /snappymail/data /var/log /var/lib/nginx
chmod o+w /dev/stdout
chmod o+w /dev/stderr
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 !
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;
access_log off;
error_log /dev/stderr error;
error_log /tmp/ngx_error.log error;
sendfile on;
keepalive_timeout 15;
@ -52,8 +52,6 @@ http {
server {
listen 8888;
listen [::]:8888;
#listen [::]:8888 ipv6only=off;
root /snappymail;
index index.php;
charset utf-8;
@ -97,7 +95,7 @@ http {
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTP_PROXY "";
fastcgi_index index.php;
fastcgi_pass 127.0.0.1:9000;
fastcgi_pass unix:/tmp/php-fpm.sock;
fastcgi_intercept_errors on;
fastcgi_request_buffering off;
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]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
[program:nginx]
command=nginx -c /etc/nginx/nginx.conf -g 'daemon off;'
process_name=%(program_name)s_%(process_num)02d
user=root
user=php-cli
numprocs=1
autostart=true
autorestart=false
@ -20,7 +17,7 @@ stderr_logfile_maxbytes=0
[program:php-fpm]
command=php-fpm -F
process_name=%(program_name)s_%(process_num)02d
user=root
user=php-cli
numprocs=1
autostart=true
autorestart=false
@ -30,11 +27,34 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; reads snappymail logs
[program:snappymail-auth]
command=tail -f /snappymail/data/_data_/_default_/logs/auth.log
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:snappymail-errors]
command=tail -f /snappymail/data/_data_/_default_/logs/errors.log
# everything is an error
stdout_logfile=/dev/stderr
stdout_logfile_maxbytes=0
redirect_stderr=true
[program:logrotate]
command=/logrotate-loop.sh
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[eventlistener:subprocess-stopped]
command=php /listener.php
process_name=%(program_name)s_%(process_num)02d
user=root
user=php-cli
numprocs=1
events=PROCESS_STATE_EXITED,PROCESS_STATE_STOPPED,PROCESS_STATE_FATAL
autostart=true
autorestart=unexpected
autorestart=unexpected

View file

@ -1,21 +1,15 @@
[global]
daemonize = no
error_log = /dev/stderr
log_buffering = no
[default]
listen = 9000
user = www-data
listen.owner = www-data
listen.group = www-data
listen = /tmp/php-fpm.sock
pm = ondemand
pm.max_children = 30
pm.process_idle_timeout = 10s
pm.max_requests = 500
catch_workers_output = yes
decorate_workers_output = no
chdir = /
pm.status_path = /status
php_admin_value[error_log] = /tmp/php_error.log
php_admin_value[log_errors] = On
php_admin_value[expose_php] = 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.3"
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'],
parserOptions: {
ecmaVersion: 11,
ecmaVersion: 6,
sourceType: 'module'
},
env: {
@ -35,17 +35,10 @@ module.exports = {
// vendors/bootstrap/bootstrap.native.js
'BSN': "readonly",
// Mailvelope
'mailvelope': "readonly",
// Punycode
'IDN': "readonly",
// Turndown
'TurndownService': "readonly",
// Marked
'marked': "readonly"
'mailvelope': "readonly"
},
// http://eslint.org/docs/rules/
rules: {
'no-cond-assign': 0,
// plugins
'no-mixed-spaces-and-tabs': 'off',
'max-len': [

2
.github/FUNDING.yml vendored
View file

@ -1,2 +1,2 @@
github: the-djmaze
community_bridge: SnappyMail
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]
- PHP version:
- SnappyMail Version:
- Mode: [e.g. standalone, nextcloud, cyberpanel, docker]
**Debug/logging information**
[Read here how to log](https://github.com/the-djmaze/snappymail/wiki/FAQ#how-do-i-enable-logging)
- [ ] I've placed them here (few lines) or as attachments (many lines)
**[Debug/logging information](https://github.com/the-djmaze/snappymail/wiki/FAQ#how-do-i-enable-logging)**
Place them here (few lines) or as attachments (many lines)
**Additional context**
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
/MULTIPLY
/include.php
/issues
.idea/
.env
/test
@ -25,8 +24,6 @@
/vendors/knockout/spec
/vendors/openpgp-5
!/vendors/openpgp-5/dist
/vendors/squire2
!/vendors/squire2/dist
/vendors/vanillaqr.js/
/integrations/nextcloud/rainloop
/integrations/owncloud/rainloop

4
.gitmodules vendored
View file

@ -1,7 +1,3 @@
[submodule "vendors/openpgp-5"]
path = vendors/openpgp-5
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>
RewriteEngine On
# Redirect cPanel
RewriteRule cpsess.* https://%{HTTP_HOST}/ [L,R=301]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.+)$ index.php/$1 [L,QSA]
</IfModule>
<IfModule mod_expires.c>
@ -34,6 +26,7 @@
# Header set Strict-Transport-Security "max-age=31536000"
Header set imagetoolbar "no"
# Header set X-Content-Type-Options "nosniff"
# Header set X-Frame-Options "DENY"
# Header set X-XSS-Protection "1; mode=block"
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**
1. Edit data/\_data_/\_default_/configs/application.ini
2. Set `[cache] system_data` to Off
2. Set 'cache_system_data' to Off
**Release**

View file

@ -5,10 +5,6 @@
<br>
<h1>SnappyMail</h1>
<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>
Simple, modern, lightweight &amp; fast web-based email client.
</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).
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
@ -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)**.
http://www.gnu.org/licenses/agpl-3.0.html
Copyright (c) 2020 - 2024 SnappyMail
Copyright (c) 2020 - 2022 SnappyMail
Copyright (c) 2013 - 2022 RainLoop
## Modifications
@ -49,7 +45,7 @@ This fork of RainLoop has the following changes:
* Admin uses password_hash/password_verify
* Auth failed attempts written to syslog
* Added Fail2ban instructions
* ES2020
* ES2018
* PHP 7.4+ required
* PHP mbstring extension required
* PHP replaced pclZip with PharData and ZipArchive
@ -66,7 +62,7 @@ This fork of RainLoop has the following changes:
* CRLF => LF line endings
* Embed boot.js and boot.css into index.html
* 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)
* Split Admin 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
* Reduced excessive DOM size
* 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
* Much better PGP support
@ -140,28 +141,28 @@ RainLoop 1.17 vs SnappyMail
|js/* |RainLoop |Snappy |
|--------------- |--------: |--------: |
|admin.js |2.170.153 | 84.925 |
|app.js |4.207.787 | 447.263 |
|boot.js | 868.735 | 4.343 |
|libs.js | 658.812 | 233.728 |
|sieve.js | 0 | 91.418 |
|admin.js |2.170.153 | 82.190 |
|app.js |4.207.787 | 412.127 |
|boot.js | 868.735 | 2.427 |
|libs.js | 658.812 | 191.117 |
|sieve.js | 0 | 86.018 |
|polyfills.js | 334.608 | 0 |
|serviceworker.js | 0 | 285 |
|TOTAL |8.240.095 | 861.962 |
|TOTAL |8.240.095 | 774.164 |
|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 |
|app.min.js | 515.367 | 202.101 |139.456 | 68.505 |110.485 | 58.481 |
|boot.min.js | 84.659 | 2.231 | 26.998 | 1.271 | 23.643 | 1.067 |
|libs.min.js | 584.772 | 110.646 |180.901 | 39.518 |155.182 | 35.207 |
|sieve.min.js | 0 | 45.504 | 0 | 11.131 | 0 | 9.917 |
|admin.min.js | 256.831 | 40.879 | 73.606 | 13.468 | 60.877 | 12.077 |
|app.min.js | 515.367 | 189.931 |139.456 | 63.444 |110.485 | 54.381 |
|boot.min.js | 84.659 | 1.518 | 26.998 | 919 | 23.643 | 747 |
|libs.min.js | 584.772 | 90.689 |180.901 | 33.597 |155.182 | 30.118 |
|sieve.min.js | 0 | 41.909 | 0 | 10.476 | 0 | 9.461 |
|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+sieve |1.217.635 | 360.482 |358.761 |120.425 |299.485 |104.672 |
|TOTAL admin | 959.099 | 154.596 |292.911 | 54.811 |249.877 | 48.841 |
|TOTAL user |1.217.635 | 282.138 |358.761 | 97.960 |299.485 | 85.246 |
|TOTAL user+sieve |1.217.635 | 324.047 |358.761 |108.436 |299.485 | 94.707 |
|TOTAL admin | 959.099 | 133.086 |292.911 | 47.984 |249.877 | 42.942 |
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
@ -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 |
|------------ |-------: |------: |------: |------: |--------: |
|app.css | 340.331 | 85.073 | 46.946 | 17.792 | 15.210 |
|app.min.css | 274.947 | 68.272 | 39.647 | 15.615 | 13.636 |
|app.css | 340.331 | 82.646 | 46.946 | 17.143 | 14.703 |
|app.min.css | 274.947 | 66.615 | 39.647 | 15.115 | 13.255 |
|boot.css | | 1.326 | | 664 | 545 |
|boot.min.css | | 1.071 | | 590 | 474 |
|admin.css | | 30.880 | | 7.045 | 6.127 |
|admin.min.css | | 24.959 | | 6.368 | 5.615 |
|admin.css | | 30.277 | | 6.901 | 5.999 |
|admin.min.css | | 24.524 | | 6.238 | 5.503 |
### PGP
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.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 | |
@ -217,6 +218,7 @@ The [Squire](https://github.com/neilj/Squire) implementation is not 100% compati
Still TODO:
* support for tables (really needed?!?)
* support BIDI (really needed?!?)
| | normal | min | gzip | min gzip |
|-------- |-------: |-------: |------: |--------: |
@ -224,3 +226,5 @@ Still TODO:
|ckeditor | ? | 520.035 | ? | 155.916 |
CKEditor including the 7 asset requests (css,language,plugins,icons) is 633.46 KB / 180.47 KB (gzip).
To use the old CKEditor, you must install the plugin.

View file

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

View file

@ -4,6 +4,16 @@
//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.
*/

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

View file

Before

Width:  |  Height:  |  Size: 992 B

After

Width:  |  Height:  |  Size: 992 B

Before After
Before After

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

@ -11,16 +11,13 @@ $nc_destination = "{$destPath}snappymail-{$package->version}-nextcloud.tar";
$nc_tar = new PharData($nc_destination);
$hashes = [];
file_put_contents(ROOT_DIR . '/integrations/nextcloud/snappymail/VERSION', $package->version);
$file = ROOT_DIR . '/integrations/nextcloud/snappymail/appinfo/info.xml';
file_put_contents($file, preg_replace('/<version>[^<]*</', "<version>{$package->version}<", file_get_contents($file)));
$nc_tar->buildFromDirectory('./integrations/nextcloud', "@integrations/nextcloud/snappymail/@");
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('integrations/nextcloud/snappymail'));
foreach ($files as $file) {
if (is_file($file)) {
$name = str_replace('\\', '/', $file);
$name = str_replace('integrations/nextcloud/snappymail/', '', $name);
$name = str_replace('.htaccess', '_htaccess', $name);
$hashes[$name] = hash_file('sha512', $file);
}
}
@ -29,12 +26,11 @@ $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('snappymai
foreach ($files as $file) {
if (is_file($file)) {
$newFile = str_replace('\\', '/', $file);
$newName = str_replace('/.htaccess', '/_htaccess', $newFile);
$nc_tar->addFile($file, "snappymail/app/{$newName}");
// $newFile = str_replace("'snappymail/v/'.", '', $newFile);
$nc_tar->addFile($file, "snappymail/app/{$newFile}");
$hashes["app/{$newFile}"] = hash_file('sha512', $file);
}
}
/*
$nc_tar->addFile('data/.htaccess');
$nc_tar->addFromString('data/VERSION', $package->version);
@ -62,6 +58,7 @@ $hashes['app/serviceworker.js'] = hash('sha512', $data);
spl_autoload_register(function($name){
$file = __DIR__ . '/' . str_replace('\\', '/', $name) . '.php';
echo "{$file}\n";
require $file;
});

View file

@ -1,100 +0,0 @@
<?php
echo "\x1b[33;1m === ownCloud === \x1b[0m\n";
$cert_dir = false; // $_SERVER['HOME'].'/.owncloud/certificates';
$nc_destination = "{$destPath}snappymail-{$package->version}-owncloud.tar";
@unlink($nc_destination);
@unlink("{$nc_destination}.gz");
$nc_tar = new PharData($nc_destination);
$hashes = [];
file_put_contents(ROOT_DIR . '/integrations/owncloud/snappymail/VERSION', $package->version);
$file = ROOT_DIR . '/integrations/owncloud/snappymail/appinfo/info.xml';
file_put_contents($file, preg_replace('/<version>[^<]*</', "<version>{$package->version}<", file_get_contents($file)));
$nc_tar->buildFromDirectory('./integrations/owncloud', "@integrations/owncloud/snappymail/@");
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('integrations/owncloud/snappymail'));
foreach ($files as $file) {
if (is_file($file)) {
$name = str_replace('\\', '/', $file);
$name = str_replace('integrations/owncloud/snappymail/', '', $name);
$hashes[$name] = hash_file('sha512', $file);
}
}
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('snappymail/v'), RecursiveIteratorIterator::SELF_FIRST);
foreach ($files as $file) {
if (is_file($file)) {
$newFile = str_replace('\\', '/', $file);
// $newFile = str_replace("'snappymail/v/'.", '', $newFile);
$nc_tar->addFile($file, "snappymail/app/{$newFile}");
$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->addFromString('data/VERSION', $package->version);
$nc_tar->addFile('data/README.md');
$nc_tar->addFile('_include.php', 'snappymail/app/_include.php');
*/
$nc_tar->addFile('.htaccess', 'snappymail/app/_htaccess');
$hashes['app/.htaccess'] = hash_file('sha512', '.htaccess');
$index = file_get_contents('index.php');
$index = str_replace('0.0.0', $package->version, $index);
//$index = str_replace('snappymail/v/', '', $index);
$nc_tar->addFromString('snappymail/app/index.php', $index);
$hashes['app/index.php'] = hash('sha512', $index);
$nc_tar->addFile('README.md', 'snappymail/app/README.md');
$hashes['app/README.md'] = hash_file('sha512', 'README.md');
$nc_tar->addFile('CHANGELOG.md', 'snappymail/CHANGELOG.md');
$hashes['CHANGELOG.md'] = hash_file('sha512', 'CHANGELOG.md');
$data = file_get_contents('dev/serviceworker.js');
$nc_tar->addFromString('snappymail/app/serviceworker.js', $data);
$hashes['app/serviceworker.js'] = hash('sha512', $data);
if ($cert_dir) {
spl_autoload_register(function($name){
$file = __DIR__ . '/' . str_replace('\\', '/', $name) . '.php';
require $file;
});
ksort($hashes);
$cert = file_get_contents($cert_dir.'/snappymail.crt');
$rsa = new \phpseclib\Crypt\RSA();
$rsa->loadKey(file_get_contents($cert_dir.'/snappymail.key'));
$x509 = new \phpseclib\File\X509();
$x509->loadX509($cert);
$x509->setPrivateKey($rsa);
$rsa->setSignatureMode(\phpseclib\Crypt\RSA::SIGNATURE_PSS);
$rsa->setMGFHash('sha512');
$rsa->setSaltLength(0);
$signature = $rsa->sign(json_encode($hashes));
$nc_tar->addFromString('snappymail/appinfo/signature.json', json_encode([
'hashes' => $hashes,
'signature' => base64_encode($signature),
'certificate' => $cert
], JSON_PRETTY_PRINT));
}
$nc_tar->compress(Phar::GZ);
unlink($nc_destination);
$nc_destination .= '.gz';
if ($cert_dir) {
$signature = shell_exec("openssl dgst -sha512 -sign {$cert_dir}/snappymail.key {$nc_destination} | openssl base64");
file_put_contents($nc_destination.'.sig', $signature);
}
echo "{$nc_destination} created\n";

View file

@ -1,4 +1,5 @@
<?php
define('ROOT_DIR', dirname(__DIR__));
define('PLUGINS_DEST_DIR', __DIR__ . '/dist/releases/plugins');
is_dir(PLUGINS_DEST_DIR) || mkdir(PLUGINS_DEST_DIR, 0777, true);
@ -28,46 +29,10 @@ $keys = [
'url',
'version'
];
/*
$released = [
'add-x-originating-ip-header',
'avatars',
'backup',
'black-list',
'change-password',
'change-password-froxlor',
'change-password-hestia',
'change-password-hmailserver',
'change-password-ispconfig',
'change-password-poppassd',
'custom-login-mapping',
'imap-contacts-suggestions',
'kolab',
'ldap-contacts-suggestions',
'ldap-identities',
'ldap-login-mapping',
'ldap-mail-accounts',
'login-external',
'login-external-sso',
'login-override',
'login-register',
'login-remote',
'mailbox-detect',
'nextcloud',
'override-smtp-credentials',
'set-remote-addr',
'smtp-use-from-adr-account',
'snowfall-on-login-screen',
'two-factor-auth',
'view-ics',
'white-list'
];
*/
foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
if (is_file("{$dir}/index.php") && !strpos($dir, '.bak')) {
require "{$dir}/index.php";
$name = basename($dir);
// if (!in_array($name, $released)) continue;
$class = new ReflectionClass(str_replace('-', '', $name) . 'Plugin');
$manifest_item = [];
foreach ($class->getConstants() as $key => $value) {
@ -106,7 +71,6 @@ foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
$tar->compress(Phar::GZ);
unlink($tar_destination);
rename("{$tar_destination}.gz", $tgz_destination);
/*
if (Phar::canWrite()) {
$phar_destination = PLUGINS_DEST_DIR . "/{$name}.phar";
@unlink($phar_destination);
@ -116,7 +80,6 @@ foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
unlink($phar_destination);
rename("{$phar_destination}.gz", $phar_destination);
}
*/
if (isset($options['sign'])) {
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '.escapeshellarg($tgz_destination), $return_var);
$manifest_item['pgp_sig'] = trim(preg_replace('/-----(BEGIN|END) PGP SIGNATURE-----/', '', file_get_contents($tgz_destination.'.asc')));

View file

@ -1,280 +0,0 @@
#!/usr/bin/php
<?php
define('ROOT_DIR', dirname(__DIR__));
chdir(ROOT_DIR);
$options = getopt('', ['aur','docker','plugins','skip-gulp','debian','nextcloud','owncloud','cpanel','sign']);
if (isset($options['plugins'])) {
require(ROOT_DIR . '/build/plugins.php');
}
$gulp = trim(shell_exec('which gulp'));
if (!$gulp) {
exit('gulp not installed, run as root: npm install --global gulp-cli');
}
$package = json_decode(file_get_contents('package.json'));
/**
* Update files that contain version
*/
// cloudron
$file = ROOT_DIR . '/integrations/cloudron/Dockerfile';
file_put_contents($file, preg_replace('/VERSION=[0-9.]+/', "VERSION={$package->version}", file_get_contents($file)));
$file = ROOT_DIR . '/integrations/cloudron/DESCRIPTION.md';
file_put_contents($file, preg_replace('/<upstream>[^<]*</', "<upstream>{$package->version}<", file_get_contents($file)));
// virtualmin
$file = ROOT_DIR . '/integrations/virtualmin/snappymail.pl';
file_put_contents($file, preg_replace('/return \\( "[0-9]+\\.[0-9]+\\.[0-9]+" \\)/', "return ( \"{$package->version}\" )", file_get_contents($file)));
// Arch User Repository
// https://aur.archlinux.org/packages/snappymail/
$options['aur'] = isset($options['aur']);
// Docker build
$options['docker'] = isset($options['docker']);
if ($options['docker'] && !($docker = trim(shell_exec('which docker')))) {
exit('docker not found');
}
if ($options['docker'] && $options['aur']) {
exit('Conflict between docker and aur');
}
$destPath = "build/dist/releases/webmail/{$package->version}/";
is_dir($destPath) || mkdir($destPath, 0777, true);
$zip_destination = "{$destPath}snappymail-{$package->version}.zip";
$tar_destination = "{$destPath}snappymail-{$package->version}.tar";
@unlink($zip_destination);
@unlink($tar_destination);
@unlink("{$tar_destination}.gz");
if (!isset($options['skip-gulp'])) {
echo "\x1b[33;1m === Gulp === \x1b[0m\n";
passthru($gulp, $return_var);
if ($return_var) {
exit("gulp failed with error code {$return_var}\n");
}
$cmddir = escapeshellcmd(ROOT_DIR) . '/snappymail/v/0.0.0/static';
if ($gzip = trim(shell_exec('which gzip'))) {
echo "\x1b[33;1m === Gzip *.js and *.css === \x1b[0m\n";
passthru("{$gzip} -k --best {$cmddir}/js/*.js");
passthru("{$gzip} -k --best {$cmddir}/js/min/*.js");
passthru("{$gzip} -k --best {$cmddir}/css/admin*.css");
passthru("{$gzip} -k --best {$cmddir}/css/app*.css");
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/boot.js.gz');
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/min/boot.min.js.gz');
}
if ($brotli = trim(shell_exec('which brotli'))) {
echo "\x1b[33;1m === Brotli *.js and *.css === \x1b[0m\n";
passthru("{$brotli} -k --best {$cmddir}/js/*.js");
passthru("{$brotli} -k --best {$cmddir}/js/min/*.js");
passthru("{$brotli} -k --best {$cmddir}/css/admin*.css");
passthru("{$brotli} -k --best {$cmddir}/css/app*.css");
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/boot.js.br');
unlink(ROOT_DIR . '/snappymail/v/0.0.0/static/js/min/boot.min.js.br');
}
}
// Temporary rename folder to speed up PharData
//if (!rename('snappymail/v/0.0.0', "snappymail/v/{$package->version}")){
if (!rename('snappymail/v/0.0.0', "snappymail/v/{$package->version}")) {
exit('Failed to temporary rename snappymail/v/0.0.0');
}
register_shutdown_function(function(){
// Rename folder back to original
@rename("snappymail/v/{$GLOBALS['package']->version}", 'snappymail/v/0.0.0');
});
echo "\x1b[33;1m === Zip/Tar === \x1b[0m\n";
$zip = new ZipArchive();
if (!$zip->open($zip_destination, ZIPARCHIVE::CREATE)) {
exit("Failed to create {$zip_destination}");
}
$tar = new PharData($tar_destination);
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('snappymail/v'), RecursiveIteratorIterator::SELF_FIRST);
foreach ($files as $file) {
$file = str_replace('\\', '/', $file);
//echo "{$file}\n";
// Ignore "." and ".." folders
if (!in_array(substr($file, strrpos($file, '/')+1), array('.', '..'))) {
if (is_dir($file)) {
$zip->addEmptyDir($file);
} else if (is_file($file)) {
$zip->addFile($file);
}
}
}
if ($options['docker']) {
$tar->buildFromDirectory('./snappymail/', "@v/{$package->version}@");
} else {
$tar->buildFromDirectory('./', "@snappymail/v/{$package->version}@");
}
//$zip->addFile('cli/upgrade.sh');
//$tar->addFile('cli/upgrade.sh');
$zip->addFile('data/.htaccess');
$tar->addFile('data/.htaccess');
$zip->addFromString('data/VERSION', $package->version);
$tar->addFromString('data/VERSION', $package->version);
$zip->addFile('data/README.md');
$tar->addFile('data/README.md');
if ($options['aur']) {
$data = '<?php
function __get_custom_data_full_path()
{
return \'/var/lib/snappymail\';
}
';
$zip->addFromString('include.php', $data);
$tar->addFromString('include.php', $data);
} else {
$zip->addFile('_include.php');
$tar->addFile('_include.php');
}
$zip->addFile('.htaccess');
$tar->addFile('.htaccess');
$index = file_get_contents('index.php');
$index = str_replace('0.0.0', $package->version, $index);
$zip->addFromString('index.php', $index);
$tar->addFromString('index.php', $index);
$zip->addFile('README.md');
$tar->addFile('README.md');
$zip->close();
$tar->compress(Phar::GZ);
unlink($tar_destination);
$tar_destination .= '.gz';
echo "{$zip_destination} created\n{$tar_destination} created\n";
if (isset($options['nextcloud'])) {
require(ROOT_DIR . '/build/nextcloud.php');
}
if (isset($options['owncloud'])) {
require(ROOT_DIR . '/build/owncloud.php');
}
if (isset($options['cpanel'])) {
require(ROOT_DIR . '/build/cpanel.php');
}
rename("snappymail/v/{$package->version}", 'snappymail/v/0.0.0');
file_put_contents("{$destPath}core.json", '{
"version": "'.$package->version.'",
"file": "../latest.tar.gz",
"warnings": []
}');
// Arch User Repository
if ($options['aur']) {
// extension_loaded('blake2')
if (!function_exists('b2sum') && $b2sum = trim(shell_exec('which b2sum'))) {
function b2sum($file) {
$file = escapeshellarg($file);
exec("b2sum --binary {$file} 2>&1", $output, $exitcode);
$output = explode(' ', implode("\n", $output));
return $output[0];
}
}
$b2sums = function_exists('b2sum') ? [
b2sum($tar_destination),
b2sum(ROOT_DIR . '/build/arch/snappymail.sysusers'),
b2sum(ROOT_DIR . '/build/arch/snappymail.tmpfiles')
] : [];
file_put_contents('build/arch/.SRCINFO', 'pkgbase = snappymail
pkgdesc = modern PHP webmail client
pkgver = '.$package->version.'
pkgrel = 1
url = https://github.com/the-djmaze/snappymail
arch = any
license = AGPL3
makedepends = php
makedepends = nodejs
makedepends = yarn
makedepends = gulp
depends = php-fpm
optdepends = mariadb: storage backend for contacts
optdepends = php-pgsql: storage backend for contacts
optdepends = php-sqlite: storage backend for contacts
source = snappymail-'.$package->version.'.tar.gz::https://github.com/the-djmaze/snappymail/archive/v'.$package->version.'.tar.gz
source = snappymail.sysusers
source = snappymail.tmpfiles
b2sums = '.implode("\n b2sums = ", $b2sums).'
pkgname = snappymail
');
$file = ROOT_DIR . '/build/arch/PKGBUILD';
if (is_file($file)) {
$PKGBUILD = file_get_contents($file);
$PKGBUILD = preg_replace('/pkgver=[0-9.]+/', "pkgver={$package->version}", $PKGBUILD);
$PKGBUILD = preg_replace('/b2sums=\\([^)]+\\)/s', "b2sums=('".implode("'\n '", $b2sums)."')", $PKGBUILD);
file_put_contents($file, $PKGBUILD);
}
}
// Debian Repository
else if (isset($options['debian'])) {
require(ROOT_DIR . '/build/deb.php');
}
// Docker build
else if ($options['docker']) {
echo "\x1b[33;1m === Docker === \x1b[0m\n";
$zip_filename = "snappymail-{$package->version}.zip";
copy($zip_destination, "./.docker/release/{$zip_filename}");
if ($docker) {
passthru("{$docker} build --pull " . ROOT_DIR . "/.docker/release/ --build-arg FILES_ZIP={$zip_filename} -t snappymail:{$package->version}");
} else {
echo "Docker not installed!\n";
}
}
if (isset($options['sign'])) {
echo "\x1b[33;1m === PGP Sign === \x1b[0m\n";
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '.escapeshellarg($tar_destination), $return_var);
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '.escapeshellarg($zip_destination), $return_var);
if (isset($options['nextcloud'])) {
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '
.escapeshellarg("{$destPath}snappymail-{$package->version}-nextcloud.tar.gz"), $return_var);
}
if (isset($options['owncloud'])) {
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '
.escapeshellarg("{$destPath}snappymail-{$package->version}-owncloud.tar.gz"), $return_var);
}
if (isset($options['cpanel'])) {
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '
.escapeshellarg("{$destPath}snappymail-{$package->version}-cpanel.tar.gz"), $return_var);
}
if (isset($options['debian'])) {
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '
. escapeshellarg(ROOT_DIR . "/build/dist/releases/webmail/{$package->version}/" . basename(DEB_DEST_DIR.'.deb')), $return_var);
// https://github.com/the-djmaze/snappymail/issues/185#issuecomment-1059420588
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --digest-algo SHA512 --clearsign --output '
. escapeshellarg(ROOT_DIR . "/build/dist/releases/webmail/{$package->version}/InRelease") . ' '
. escapeshellarg(ROOT_DIR . "/build/dist/releases/webmail/{$package->version}/Release"), $return_var);
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --digest-algo SHA512 -abs --output '
. escapeshellarg(ROOT_DIR . "/build/dist/releases/webmail/{$package->version}/Release.gpg") . ' '
. escapeshellarg(ROOT_DIR . "/build/dist/releases/webmail/{$package->version}/Release"), $return_var);
}
}

View file

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

View file

@ -1,7 +1,7 @@
import 'External/ko';
import { SettingsGet, SettingsAdmin } from 'Common/Globals';
import { initThemes } from 'Stores/Theme';
import { Settings, SettingsGet } from 'Common/Globals';
import { ThemeStore } from 'Stores/Theme';
import Remote from 'Remote/Admin/Fetch';
@ -11,8 +11,6 @@ import { LoginAdminScreen } from 'Screen/Admin/Login';
import { startScreens } from 'Knoin/Knoin';
import { AbstractApp } from 'App/Abstract';
import { AskPopupView } from 'View/Popup/Ask';
export class AdminApp extends AbstractApp {
constructor() {
super(Remote);
@ -20,33 +18,19 @@ export class AdminApp extends AbstractApp {
}
refresh() {
initThemes();
ThemeStore.populate();
this.start();
}
start() {
// if (!Settings.app('adminAllowed')) {
if (!SettingsAdmin('allowed')) {
if (!Settings.app('adminAllowed')) {
rl.route.root();
setTimeout(() => location.href = '/', 1);
} else if (SettingsGet('Auth')) {
this.weakPassword(SettingsGet('weakPassword'));
this.weakPassword(SettingsGet('WeakPassword'));
startScreens([SettingsAdminScreen]);
} else {
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 { SMAudio } from 'Common/Audio';
import { arrayLength } from 'Common/Utils';
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser';
import { isArray, pString } from 'Common/Utils';
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility } from 'Common/UtilsUser';
import {
FolderType,
@ -27,22 +27,25 @@ import {
getFolderFromCacheList
} from 'Common/Cache';
import { i18n, reloadTime, getErrorMessage } from 'Common/Translator';
import { i18n, reloadTime } from 'Common/Translator';
import { SettingsUserStore } from 'Stores/User/Settings';
import { NotificationUserStore } from 'Stores/User/Notification';
import { AccountUserStore } from 'Stores/User/Account';
import { ContactUserStore } from 'Stores/User/Contact';
import { IdentityUserStore } from 'Stores/User/Identity';
import { FolderUserStore } from 'Stores/User/Folder';
import { PgpUserStore } from 'Stores/User/Pgp';
import { SMimeUserStore } from 'Stores/User/SMime';
import { MessagelistUserStore } from 'Stores/User/Messagelist';
import { ThemeStore, initThemes } from 'Stores/Theme';
import { ThemeStore, changeTheme } from 'Stores/Theme';
import { LanguageStore } from 'Stores/Language';
import { MessageUserStore } from 'Stores/User/Message';
import Remote from 'Remote/User/Fetch';
import { AccountModel } from 'Model/Account';
import { IdentityModel } from 'Model/Identity';
import { LoginUserScreen } from 'Screen/User/Login';
import { MailBoxUserScreen } from 'Screen/User/MailBox';
import { SettingsUserScreen } from 'Screen/User/Settings';
@ -58,7 +61,9 @@ import { AskPopupView } from 'View/Popup/Ask';
import {
folderInformation,
folderInformationMultiply,
setRefreshFoldersInterval
refreshFoldersInterval,
messagesMoveHelper,
messagesDeleteHelper
} from 'Common/Folders';
import { loadFolders } from 'Model/FolderCollection';
@ -74,7 +79,7 @@ export class AppUser extends AbstractApp {
(currentTime > (lastTime + interval + 1000))
&& Remote.request('Version',
iError => (100 < iError) && location.reload(),
{ version: Settings.app('version') }
{ Version: Settings.app('version') }
);
lastTime = currentTime;
}, interval);
@ -85,11 +90,6 @@ export class AppUser extends AbstractApp {
addEventListener('click', dropdownsDetectVisibility);
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:
oMoveFolder = getFolderFromCacheList(FolderUserStore.trashFolder());
nSetSystemFoldersNotification = iFolderType;
bDelete = bDelete/* || UNUSED_OPTION_VALUE === FolderUserStore.trashFolder()*/
bDelete = bDelete || UNUSED_OPTION_VALUE === FolderUserStore.trashFolder()
|| sFromFolderFullName === FolderUserStore.spamFolder()
|| sFromFolderFullName === FolderUserStore.trashFolder();
break;
@ -126,20 +126,58 @@ export class AppUser extends AbstractApp {
// no default
}
if (bDelete) {
if (!oMoveFolder && !bDelete) {
showScreenPopup(FolderSystemPopupView, [nSetSystemFoldersNotification]);
} else if (bDelete) {
showScreenPopup(AskPopupView, [
i18n('POPUPS_ASK/DESC_WANT_DELETE_MESSAGES'),
() => {
MessagelistUserStore.moveMessages(sFromFolderFullName, oUids);
messagesDeleteHelper(sFromFolderFullName, oUids);
MessagelistUserStore.removeMessagesFromList(sFromFolderFullName, oUids);
}
]);
} else if (oMoveFolder) {
MessagelistUserStore.moveMessages(sFromFolderFullName, oUids, oMoveFolder.fullName);
} else {
showScreenPopup(FolderSystemPopupView, [nSetSystemFoldersNotification]);
messagesMoveHelper(sFromFolderFullName, oMoveFolder.fullName, oUids);
MessagelistUserStore.removeMessagesFromList(sFromFolderFullName, oUids, oMoveFolder.fullName);
}
}
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 {Array=} list = []
@ -149,26 +187,25 @@ export class AppUser extends AbstractApp {
}
logout() {
Remote.request('Logout', (iError, data) =>
iError ? alert('Logout error: ' + getErrorMessage(iError, data))
: rl.logoutReload(Settings.app('customLogoutLink'))
);
Remote.request('Logout', () => rl.logoutReload(Settings.app('customLogoutLink')));
}
bootstart() {
super.bootstart();
addEventListener('resize', () => leftPanelDisabled(ThemeStore.isMobile() || 1000 > innerWidth));
addEventListener('beforeunload', event => {
if (arePopupsVisible() || (!SettingsUserStore.usePreviewPane() && MessageUserStore.message())) {
if (arePopupsVisible() || (ThemeStore.isMobile() && MessageUserStore.message())) {
event.preventDefault();
return event.returnValue = i18n('POPUPS_ASK/EXIT_ARE_YOU_SURE');
return event.returnValue = "Are you sure you want to exit?";
}
}, {capture: true});
}
refresh() {
initThemes();
LanguageStore.language(SettingsGet('language'));
ThemeStore.populate();
LanguageStore.language(SettingsGet('Language'));
changeTheme(SettingsGet('Theme'));
this.start();
}
@ -184,17 +221,25 @@ export class AppUser extends AbstractApp {
SettingsUserStore.init();
ContactUserStore.init();
loadFolders((success, error) => {
loadFolders(value => {
try {
if (success) {
if (value) {
startScreens([
MailBoxUserScreen,
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(() => {
const cF = FolderUserStore.currentFolderFullName();
@ -211,29 +256,38 @@ export class AppUser extends AbstractApp {
// initLeftSideLayoutResizer
setTimeout(() => {
const left = elementById('rl-left'),
right = elementById('rl-right'),
fToggle = () =>
setLayoutResizer(left, ClientSideKeyNameFolderListSize,
setLayoutResizer(left, right, ClientSideKeyNameFolderListSize,
(ThemeStore.isMobile() || leftPanelDisabled()) ? 0 : 'Width');
if (left) {
if (left && right) {
fToggle();
leftPanelDisabled.subscribe(fToggle);
}
}, 1);
setInterval(reloadTime, 60000);
setInterval(reloadTime(), 60000);
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 {
this.logout();
alert('Folders error: ' + getErrorMessage(0, error))
}
} catch (e) {
console.error(e);
}
});
} else {
startScreens([LoginUserScreen]);
}
@ -241,62 +295,6 @@ export class AppUser extends AbstractApp {
showMessageComposer(params = [])
{
let msg = params[1];
if (1 == arrayLength(msg)) {
msg = msg[0];
}
if (msg) {
msg.decrypt().then((/*success*/)=>showScreenPopup(ComposePopupView, params));
} else {
showScreenPopup(ComposePopupView, params);
}
showScreenPopup(ComposePopupView, params);
}
}
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)) {
notificator = notificator || createNewObject();
if (notificator) {
// SettingsGet('NotificationSound').startsWith('custom@')
notificator.src = Links.staticLink('sounds/'
+ SettingsGet('NotificationSound')
+ (this.supportedMp3 ? '.mp3' : '.ogg'));

View file

@ -1,5 +1,9 @@
let FOLDERS_CACHE = new Map,
FOLDERS_HASH_MAP = new Map,
import { MessageSetAction } from 'Common/EnumsUser';
import { isArray } from 'Common/Utils';
let FOLDERS_CACHE = {},
FOLDERS_NAME_CACHE = {},
MESSAGE_FLAGS_CACHE = {},
inboxFolderName = 'INBOX';
export const
@ -7,10 +11,18 @@ export const
* @returns {void}
*/
clearCache = () => {
FOLDERS_CACHE.clear();
FOLDERS_HASH_MAP.clear();
FOLDERS_CACHE = {};
FOLDERS_NAME_CACHE = {};
MESSAGE_FLAGS_CACHE = {};
},
/**
* @param {string} folderFullName
* @param {string} uid
* @returns {string}
*/
getMessageKey = (folderFullName, uid) => folderFullName + '#' + uid,
/**
* @returns {string}
*/
@ -22,35 +34,143 @@ export const
setFolderInboxName = name => inboxFolderName = name,
/**
* @param {string} fullNameHash
* @param {string} folderHash
* @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
*/
setFolder = folder => {
folder.etag = '';
FOLDERS_CACHE.set(folder.fullName, folder);
FOLDERS_HASH_MAP.set(folder.fullNameHash, folder.fullName);
folder.hash = '';
FOLDERS_CACHE[folder.fullName] = folder;
FOLDERS_NAME_CACHE[folder.fullNameHash] = folder.fullName;
},
/**
* @param {string} folderFullName
* @param {string} folderETag
* @param {string} folderHash
*/
setFolderETag = (folderFullName, folderETag) =>
FOLDERS_CACHE.has(folderFullName) && (FOLDERS_CACHE.get(folderFullName).etag = folderETag),
setFolderHash = (folderFullName, folderHash) =>
FOLDERS_CACHE[folderFullName] && (FOLDERS_CACHE[folderFullName].hash = folderHash),
/**
* @param {string} folderFullName
* @returns {?FolderModel}
*/
getFolderFromCacheList = folderFullName =>
FOLDERS_CACHE.get(folderFullName),
FOLDERS_CACHE[folderFullName] ? FOLDERS_CACHE[folderFullName] : null,
/**
* @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}
*/
ScopeMessageList = 'MessageList',
ScopeFolderList = 'FolderList',
ScopeMessageView = 'MessageView',
ScopeSettings = 'Settings',
Scope = {
MessageList: 'MessageList',
FolderList: 'FolderList',
MessageView: 'MessageView',
Settings: 'Settings'
},
/**
* @enum {number}
@ -37,7 +39,7 @@ SaveSettingStatus = {
/**
* @enum {number}
*/
Notifications = {
Notification = {
RequestError: 1,
RequestAborted: 2,
RequestTimeout: 3,
@ -50,7 +52,6 @@ Notifications = {
ConnectionError: 104,
DomainNotAllowed: 109,
AccountNotAllowed: 110,
CryptKeyError: 111,
ContactsSyncError: 140,
@ -96,6 +97,7 @@ Notifications = {
JsonParse: 952,
// JsonTimeout: 953,
UnknownNotification: 998,
UnknownError: 999,
// Admin

View file

@ -25,12 +25,12 @@ export const FolderType = {
FILES : 25;
JOURNAL : 26;
*/
},
};
/**
* @enum {string}
*/
FolderMetadataKeys = {
export const FolderMetadataKeys = {
// RFC 5464
Comment: '/private/comment',
CommentShared: '/shared/comment',
@ -39,12 +39,12 @@ FolderMetadataKeys = {
// Kolab
KolabFolderType: '/private/vendor/kolab/folder-type',
KolabFolderTypeShared: '/shared/vendor/kolab/folder-type'
},
};
/**
* @enum {string}
*/
ComposeType = {
export const ComposeType = {
Empty: 0,
Reply: 1,
ReplyAll: 2,
@ -52,34 +52,51 @@ ComposeType = {
ForwardAsAttachment: 4,
Draft: 5,
EditAsNew: 6
},
};
/**
* @enum {number}
*/
ClientSideKeyNameExpandedFolders = 3,
ClientSideKeyNameFolderListSize = 4,
ClientSideKeyNameMessageListSize = 5,
ClientSideKeyNameLastSignMe = 7,
ClientSideKeyNameMessageHeaderFullInfo = 9,
ClientSideKeyNameMessageAttachmentControls = 10,
export const
ClientSideKeyNameExpandedFolders = 3,
ClientSideKeyNameFolderListSize = 4,
ClientSideKeyNameMessageListSize = 5,
ClientSideKeyNameLastSignMe = 7,
ClientSideKeyNameMessageHeaderFullInfo = 9,
ClientSideKeyNameMessageAttachmentControls = 10;
/**
* @enum {number}
*/
MessageSetAction = {
export const MessageSetAction = {
SetSeen: 0,
UnsetSeen: 1,
SetFlag: 2,
UnsetFlag: 3,
SetDeleted: 4,
UnsetDeleted: 5
},
UnsetFlag: 3
};
/**
* @enum {number}
*/
//LayoutNoView = 0,
LayoutSideView = 1,
LayoutBottomView = 2
;
export const MessagePriority = {
Low: 5,
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 quote-props: 0 */
import { arrayLength, pInt } from 'Common/Utils';
export const RFC822 = 'message/rfc822';
import { arrayLength } from 'Common/Utils';
const
cache = {},
@ -14,8 +12,8 @@ const
lowerCase = text => text.toLowerCase().trim(),
exts = {
eml: RFC822,
mime: RFC822,
eml: 'message/rfc822',
mime: 'message/rfc822',
vcard: 'text/vcard',
vcf: 'text/vcard',
htm: 'text/html',
@ -30,8 +28,6 @@ const
p7c: app+'pkcs7-mime',
p7m: app+'pkcs7-mime',
p7s: app+'pkcs7-signature',
p12: app+'pkcs12',
pfx: app+'x-pkcs12',
torrent: app+'x-bittorrent',
// scripts
@ -120,8 +116,7 @@ export const FileType = {
Spreadsheet: 'spreadsheet',
Presentation: 'presentation',
Certificate: 'certificate',
Archive: 'archive',
Calendar: 'calendar'
Archive: 'archive'
};
export const FileInfo = {
@ -138,7 +133,7 @@ export const FileInfo = {
getContentType: fileName => {
fileName = lowerCase(fileName);
if ('winmail.dat' === fileName) {
return app + 'vnd.ms-tnef';
return app + 'ms-tnef';
}
let ext = fileName.split('.').pop();
if (/^(txt|text|def|list|in|ini|log|sql|cfg|conf)$/.test(ext))
@ -166,7 +161,7 @@ export const FileInfo = {
*/
getType: (ext, mimeType) => {
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;
if (cache[key]) {
@ -175,7 +170,7 @@ export const FileInfo = {
let result = FileType.Unknown;
const mimeTypeParts = mimeType.split('/'),
type = mimeTypeParts[1].replace('-compressed',''),
type = mimeTypeParts[1].replace('x-','').replace('-compressed',''),
match = str => mimeType.includes(str),
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:
result = FileType.Code;
break;
case 'eml' == ext || ['message/delivery-status', RFC822].includes(mimeType):
case 'eml' == ext || ['message/delivery-status', 'message/rfc822'].includes(mimeType):
result = FileType.Eml;
break;
case 'ics' == ext || mimeType == 'text/calendar':
result = FileType.Calendar;
break;
case 'text' == mimeTypeParts[0] || 'txt' == ext || 'log' == ext:
result = FileType.Text;
break;
@ -207,8 +199,9 @@ export const FileInfo = {
case 'pdf' == type || 'pdf' == ext:
result = FileType.Pdf;
break;
case [app+'pgp-signature', app+'pgp-keys', exts.p7m, exts.p7s, exts.p12, exts.pfx].includes(mimeType)
|| ['asc', 'pem', 'ppk', 'p7s', 'p7m', 'p12', 'pfx'].includes(ext):
case [app+'pgp-signature', app+'pgp-keys'].includes(mimeType)
|| ['asc', 'pem', 'ppk'].includes(ext)
|| [app+'pkcs7-signature'].includes(mimeType) || 'p7s' == ext:
result = FileType.Certificate;
break;
case match(msOffice+'.wordprocessingml') || match(openDoc+'.text') || match('vnd.ms-word')
@ -247,7 +240,6 @@ export const FileInfo = {
case FileType.Certificate:
case FileType.Spreadsheet:
case FileType.Presentation:
case FileType.Calendar:
return result + '-' + fileType;
}
return result;
@ -274,8 +266,8 @@ export const FileInfo = {
},
friendlySize: bytes => {
bytes = pInt(bytes);
let i = bytes ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0;
bytes = parseInt(bytes, 10) || 0;
let i = Math.floor(Math.log(bytes) / Math.log(1024));
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 { getFolderInboxName, getFolderFromCacheList } from 'Common/Cache';
import { baseCollator } from 'Common/Translator';
import { SettingsGet } from 'Common/Globals';
import { isArray, arrayLength, pInt } from 'Common/Utils';
import { isArray, arrayLength } from 'Common/Utils';
import {
MessageFlagsCache,
setFolderHash,
getFolderInboxName,
getFolderFromCacheList
} from 'Common/Cache';
import { SettingsUserStore } from 'Stores/User/Settings';
import { FolderUserStore } from 'Stores/User/Folder';
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';
let refreshInterval,
// Default every 15 minutes
refreshFoldersInterval = 900000;
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 => {
try {
let collator = baseCollator(true);
let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
folders.sort((a, b) =>
a.isInbox() ? -1 : (b.isInbox() ? 1 : collator.compare(a.fullName, b.fullName))
);
@ -38,6 +27,37 @@ sortFolders = folders => {
}
},
/**
* @param {?Function} fCallback
* @param {string} folder
* @param {Array=} list = []
*/
fetchFolderInformation = (fCallback, folder, list = []) => {
let fetch = !arrayLength(list);
const uids = [],
folderFromCache = getFolderFromCacheList(folder);
if (!fetch) {
list.forEach(messageListItem => {
MessageFlagsCache.getFor(folder, messageListItem.uid) || uids.push(messageListItem.uid);
messageListItem.threads.forEach(uid => {
MessageFlagsCache.getFor(folder, uid) || uids.push(uid);
});
});
fetch = uids.length;
}
if (fetch) {
Remote.request('FolderInformation', fCallback, {
Folder: folder,
FlagsUids: uids,
UidNext: folderFromCache?.uidNext || 0 // Used to check for new messages
});
} else if (SettingsUserStore.useThreads()) {
MessagelistUserStore.reloadFlagsAndCachedMessage();
}
},
/**
* @param {Array=} aDisabled
* @param {Array=} aHeaderLines
@ -50,14 +70,15 @@ folderListOptionsBuilder = (
aDisabled,
aHeaderLines,
fRenameCallback,
fDisableCallback
fDisableCallback,
bNoSelectSelectable,
aList = FolderUserStore.folderList()
) => {
const
aResult = [],
sDeepPrefix = '\u00A0\u00A0\u00A0',
// FolderSystemPopupView should always be true
showUnsubscribed = fRenameCallback ? !SettingsUserStore.hideUnsubscribed() : true,
isDisabled = fDisableCallback || (item => !item.selectable() || aDisabled.includes(item.fullName)),
foldersWalk = folders => {
folders.forEach(oItem => {
@ -68,7 +89,10 @@ folderListOptionsBuilder = (
sDeepPrefix.repeat(oItem.deep) +
fRenameCallback(oItem),
system: false,
disabled: isDisabled(oItem)
disabled: !bNoSelectSelectable && (
!oItem.selectable() ||
aDisabled.includes(oItem.fullName) ||
fDisableCallback(oItem))
});
}
foldersWalk(oItem.subFolders());
@ -89,64 +113,61 @@ folderListOptionsBuilder = (
})
);
foldersWalk(FolderUserStore.folderList());
foldersWalk(aList);
return aResult;
},
// Every 5 minutes
refreshFoldersInterval = 300000,
/**
* @param {string} folder
* @param {Array=} list = []
*/
folderInformation = (folder, list) => {
if (folder?.trim()) {
let count = 1;
const uids = [];
if (arrayLength(list)) {
list.forEach(messageListItem => {
uids.push(messageListItem.uid);
messageListItem.threads.forEach(uid => uids.push(uid));
});
count = uids.length;
}
if (count) {
Remote.request('FolderInformation', (iError, data) => {
fetchFolderInformation(
(iError, data) => {
if (!iError && data.Result) {
const result = data.Result,
folderFromCache = getFolderFromCacheList(result.name);
folderFromCache = getFolderFromCacheList(result.Folder);
if (folderFromCache) {
const oldHash = folderFromCache.etag,
const oldHash = folderFromCache.hash,
unreadCountChange = (folderFromCache.unreadEmails() !== result.unreadEmails);
// folderFromCache.revivePropertiesFromJson(result);
// folderFromCache.revivePropertiesFromJson(result);
folderFromCache.expires = Date.now();
folderFromCache.uidNext = result.uidNext;
folderFromCache.etag = result.etag;
folderFromCache.uidNext = result.UidNext;
folderFromCache.hash = result.Hash;
folderFromCache.totalEmails(result.totalEmails);
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);
if (!oldHash || unreadCountChange || result.etag !== oldHash) {
if (!oldHash || unreadCountChange || result.Hash !== oldHash) {
if (folderFromCache.fullName === FolderUserStore.currentFolderFullName()) {
MessagelistUserStore.reload();
/*
} else if (getFolderInboxName() === folderFromCache.fullName) {
// Remote.messageList(null, {folder: getFolderFromCacheList(getFolderInboxName())}, true);
Remote.messageList(null, {folder: getFolderInboxName()}, true);
*/
Remote.messageList(null, {Folder: getFolderInboxName()}, true);
}
}
}
}
}, {
folder: folder,
flagsUids: uids,
uidNext: getFolderFromCacheList(folder)?.uidNext || 0 // Used to check for new messages
});
}
},
folder,
list
);
}
},
@ -160,18 +181,21 @@ folderInformationMultiply = (boot = false) => {
if (!iError && arrayLength(oData.Result)) {
const utc = Date.now();
oData.Result.forEach(item => {
const folder = getFolderFromCacheList(item.name);
const folder = getFolderFromCacheList(item.Folder);
if (folder) {
const oldHash = folder.etag,
const oldHash = folder.hash,
unreadCountChange = folder.unreadEmails() !== item.unreadEmails;
// folder.revivePropertiesFromJson(item);
folder.expires = utc;
folder.etag = item.etag;
folder.hash = item.Hash;
folder.totalEmails(item.totalEmails);
folder.unreadEmails(item.unreadEmails);
if (!oldHash || item.etag !== oldHash) {
unreadCountChange && MessageFlagsCache.clearFolder(folder.fullName);
if (!oldHash || item.Hash !== oldHash) {
if (folder.fullName === FolderUserStore.currentFolderFullName()) {
MessagelistUserStore.reload();
}
@ -186,26 +210,104 @@ folderInformationMultiply = (boot = false) => {
boot && setTimeout(() => folderInformationMultiply(true), 2000);
}
}, {
folders: folders
Folders: folders
});
}
},
dropFilesInFolder = (sFolderFullName, files) => {
let count = files.length;
for (const file of files) {
if (RFC822 === file.type) {
let data = new FormData;
data.append('folder', sFolderFullName);
data.append('appendFile', file);
Remote.request('FolderAppend', (iError, data)=>{
iError && console.error(data.message);
0 == --count
&& FolderUserStore.currentFolderFullName() == sFolderFullName
&& MessagelistUserStore.reload(true, true);
}, data);
moveOrDeleteResponseHelper = (iError, oData) => {
if (iError) {
setFolderHash(FolderUserStore.currentFolderFullName(), '');
alert(getNotification(iError));
} else if (FolderUserStore.currentFolder()) {
if (2 === arrayLength(oData.Result)) {
setFolderHash(oData.Result[0], oData.Result[1]);
} 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
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),
isFullscreen = ko.observable(false),
toggleFullscreen = () => isFullscreen() ? exitFullscreen() : appEl.requestFullscreen();
toggleFullscreen = () => isFullscreen() ? exitFullscreen() : app.requestFullscreen();
if (appEl) {
if (app) {
let event = 'fullscreenchange';
if (!appEl.requestFullscreen && appEl.webkitRequestFullscreen) {
appEl.requestFullscreen = appEl.webkitRequestFullscreen;
if (!app.requestFullscreen && app.webkitRequestFullscreen) {
app.requestFullscreen = app.webkitRequestFullscreen;
event = 'webkit'+event;
}
if (appEl.requestFullscreen) {
if (app.requestFullscreen) {
doc.addEventListener(event, () => {
isFullscreen(appFullscreen());
$htmlCL.toggle('rl-fullscreen', appFullscreen());

View file

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

View file

@ -1,12 +1,9 @@
import { createElement } from 'Common/Globals';
import { forEachObjectEntry, isArray, pInt } from 'Common/Utils';
import { forEachObjectEntry, pInt } from 'Common/Utils';
import { SettingsUserStore } from 'Stores/User/Settings';
const
tmpl = createElement('template'),
turndown = new TurndownService(),
tpl = createElement('template'),
htmlre = /[&<>"']/g,
httpre = /^(https?:)?\/\//i,
htmlmap = {
@ -17,52 +14,13 @@ const
"'": '&#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]),
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
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
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
/** 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
*/
// 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,
base64Url = data => atob(data.replace(/_/g,'/').replace(/-/g,'+')),
decode = decodeURIComponent,
stripTracking = url => {
try {
let nurl = url
// Copernica
.replace(/^.+\/(https%253A[^/?&]+).*$/i, (...m) => decode(decode(m[1])))
.replace(/tracking\.(printabout\.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,
() => urlGetParam(url, 'url'))
.replace(/^.+click\.godaddy\.com.+$/i, () => urlGetParam(url, 'redir'))
@ -119,97 +74,6 @@ const
});
}
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
@ -225,32 +89,28 @@ export const
* @param {string} text
* @returns {string}
*/
cleanHtml = (html, oAttachments, msgId) => {
let aColor;
cleanHtml = (html, oAttachments) => {
const
debug = false, // Config()->Get('debug', 'enable', false);
detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'),
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
minFontSize = parseInt(SettingsUserStore.minFontSize()),
result = {
hasExternals: false,
tracking: false,
linkedData: []
hasExternals: false
},
isMsg = !!msgId,
findAttachmentByCid = cId => oAttachments.findByCid(cId),
findLocationByCid = cId => {
const attachment = findAttachmentByCid(cId);
findAttachmentByCid = cid => oAttachments.findByCid(cid),
findLocationByCid = cid => {
const attachment = findAttachmentByCid(cid);
return attachment?.contentLocation ? attachment : 0;
},
// convert body attributes to CSS
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,
topmargin: (value, node) => node.style.marginTop = 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'
},
allowedAttributes = [
// SnappyMail
// 'data-x-href-broken',
// 'data-x-href-tracking',
'data-x-src',
// 'data-x-src-hidden',
// 'data-x-src-tracking',
// 'data-x-src-broken',
// 'data-x-style-url',
// 'data-x-style-broken-urls',
// defaults
'name',
'dir', 'lang', 'style', 'title',
@ -294,112 +145,42 @@ export const
'abbr', 'scope',
// td
'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()) {
allowedAttributes.push('class');
} else {
msgId = 0;
}
tmpl.innerHTML = html
tpl.innerHTML = html
// .replace(/<pre[^>]*>[\s\S]*?<\/pre>/gi, pre => pre.replace(/\n/g, '\n<br>'))
.replace(/<!doctype[^>]*>/gi, '')
.replace(/<\?xml[^>]*\?>/gi, '')
// Not supported by <template> element
// .replace(/<!doctype[^>]*>/gi, '')
// .replace(/<\?xml[^>]*\?>/gi, '')
// https://github.com/the-djmaze/snappymail/issues/1860
.replace(/<(\/?)x-html(\s[^>]*)?>/gi, '')
.replace(/<(\/?)head(\s[^>]*)?>/gi, '')
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
// .replace(/<\/?(html|head)[^>]*>/gi, '')
.replace(/<\/?(html|head)[^>]*>/gi, '')
// Fix Reddit https://github.com/the-djmaze/snappymail/issues/540
.replace(/<span class="preview-text"[\s\S]+?<\/span>/, '')
// https://github.com/the-djmaze/snappymail/issues/900
.replace(/\u2028/g,' ')
// https://github.com/the-djmaze/snappymail/issues/1415
.replace(/<br>\s*<\/p>/gi,'</p>')
.trim();
html = '';
// Strip all comments
const nodeIterator = document.createNodeIterator(tmpl.content, NodeFilter.SHOW_COMMENT);
// \MailSo\Base\HtmlUtils::ClearComments()
// https://github.com/the-djmaze/snappymail/issues/187
const nodeIterator = document.createNodeIterator(tpl.content, NodeFilter.SHOW_COMMENT);
while (nodeIterator.nextNode()) {
nodeIterator.referenceNode.remove();
}
/**
* Basic support for Linked Data (Structured Email)
* https://json-ld.org/
* https://structured.email/
**/
tmpl.content.querySelectorAll('script[type="application/ld+json"]').forEach(oElement => {
// Could be array of objects or single object
try {
const data = JSON.parse(oElement.textContent);
(isArray(data) ? data : [data]).forEach(entry => result.linkedData.push(entry));
} catch (e) {
console.error(e, oElement.textContent);
}
});
// 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 => {
tpl.content.querySelectorAll('*').forEach(oElement => {
const name = oElement.tagName,
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()
if ('none' == oStyle.display
if (disallowedTags.includes(name)
|| 'none' == oStyle.display
|| 'hidden' == oStyle.visibility
// || (oStyle.lineHeight && 1 > parseFloat(oStyle.lineHeight)
// || (oStyle.maxHeight && 1 > parseFloat(oStyle.maxHeight)
@ -409,31 +190,36 @@ export const
oElement.remove();
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 = [],
className = oElement.className,
hasAttribute = name => oElement.hasAttribute(name),
getAttribute = name => hasAttribute(name) ? oElement.getAttribute(name).trim() : '',
setAttribute = (name, value) => oElement.setAttribute(name, value),
delAttribute = name => {
let value = getAttribute(name);
oElement.removeAttribute(name);
return value;
};
delAttribute = name => oElement.removeAttribute(name);
if ('mail-body' === className) {
forEachObjectEntry(tasks, (name, cb) =>
hasAttribute(name) && cb(delAttribute(name), oElement)
);
} else if (msgId && className) {
oElement.className = className.replace(/(^|\s+)/g, '$1msg-');
if ('mail-body' === oElement.className) {
forEachObjectEntry(tasks, (name, cb) => {
if (hasAttribute(name)) {
cb(getAttribute(name), oElement);
delAttribute(name);
}
});
}
if (oElement.hasAttributes()) {
let i = oElement.attributes.length;
while (i--) {
let sAttrName = oElement.attributes[i].name.toLowerCase();
if (!allowedAttributes.includes(sAttrName) && ('class' !== sAttrName || 'mail-body' !== className)) {
if (!allowedAttributes.includes(sAttrName)) {
delAttribute(sAttrName);
aAttrsForRemove.push(sAttrName);
}
@ -445,21 +231,25 @@ export const
// if ('TABLE' === name || 'TD' === name || 'TH' === name) {
if (!oStyle.backgroundImage) {
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
value = oStyle.width;
if (100 < parseInt(value,10) && !oStyle.maxWidth) {
if (hasAttribute('width')) {
value = getAttribute('width');
oStyle.width = value.includes('%') ? value : value + 'px';
delAttribute('width');
}
value = oStyle.removeProperty('width');
if (value) {
oStyle.maxWidth = value;
oStyle.width = '100%';
}
// Make height responsive
if (hasAttribute('height')) {
value = getAttribute('height');
oStyle.height = value.includes('%') ? value : value + 'px';
delAttribute('height');
}
value = oStyle.removeProperty('height');
if (value && !oStyle.maxHeight) {
if (value) {
oStyle.maxHeight = value;
}
}
@ -468,23 +258,18 @@ export const
if ('A' === name) {
value = oElement.href;
if (!/^([a-z]+):/i.test(value)) {
setAttribute('data-x-href-broken', value);
setAttribute('data-x-broken-href', value);
delAttribute('href');
} else {
oElement.href = stripTracking(value);
if (oElement.href != value) {
result.tracking = true;
setAttribute('data-x-href-tracking', value);
}
setAttribute('target', '_blank');
// setAttribute('rel', 'external nofollow noopener noreferrer');
}
setAttribute('tabindex', '-1');
aColor && !oElement.style.color && (oElement.style.color = aColor);
}
// 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);
return;
}
@ -497,85 +282,81 @@ export const
*/
let skipStyle = false;
if (isMsg) {
value = isMsg && delAttribute('src');
if (value) {
if ('IMG' === name) {
oElement.loading = 'lazy';
let attachment;
if (value.startsWith('cid:'))
{
setAttribute('data-x-src', value);
value = value.slice(4);
attachment = findAttachmentByCid(value);
if (attachment?.download) {
oElement.src = attachment.linkPreview();
// oElement.title || (oElement.title = '+attachment.fileName);
attachment.isInline(true);
attachment.isLinked(true);
}
if (hasAttribute('src')) {
value = stripTracking(getAttribute('src'));
delAttribute('src');
if ('IMG' === name) {
oElement.loading = 'lazy';
let attachment;
if (value.startsWith('cid:'))
{
value = value.slice(4);
setAttribute('data-x-src-cid', value);
attachment = findAttachmentByCid(value);
if (attachment?.download) {
oElement.src = attachment.linkPreview();
oElement.title += ' ('+attachment.fileName+')';
attachment.isInline(true);
attachment.isLinked(true);
}
else if ((attachment = findLocationByCid(value)))
{
if (attachment.download) {
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 ((attachment = findLocationByCid(value)))
{
if (attachment.download) {
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'
|| [
'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
{
setAttribute('data-x-src-broken', value);
}
}
else
{
setAttribute('data-x-src-broken', value);
}
}
if (hasAttribute('background')) {
oStyle.backgroundImage = 'url("' + delAttribute('background') + '")';
oStyle.backgroundImage = 'url("' + getAttribute('background') + '")';
delAttribute('background');
}
if (hasAttribute('bgcolor')) {
oStyle.backgroundColor = delAttribute('bgcolor');
oStyle.backgroundColor = getAttribute('bgcolor');
delAttribute('bgcolor');
}
if (hasAttribute('color')) {
oStyle.color = delAttribute('color');
oStyle.color = getAttribute('color');
delAttribute('color');
}
if (!skipStyle) {
@ -627,10 +408,8 @@ export const
setAttribute('data-x-style-broken-urls', JSON.stringify(urls_broken));
}
// https://github.com/the-djmaze/snappymail/issues/1082
if (minFontSize > pInt(oStyle.fontSize)) {
oStyle.fontSize = minFontSize + "px";
// oStyle.removeProperty('font-size');
if (11 > pInt(oStyle.fontSize)) {
oStyle.removeProperty('font-size');
}
// Removes background and color
@ -642,7 +421,8 @@ export const
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) {
@ -650,10 +430,8 @@ export const
}
});
isMsg && blockquoteSwitcher();
// return tmpl.content.firstChild;
result.html = tmpl.innerHTML.trim();
// return tpl.content.firstChild;
result.html = tpl.innerHTML.trim();
return result;
},
@ -662,12 +440,9 @@ export const
* @returns {string}
*/
htmlToPlain = html => {
if (SettingsUserStore.markdown()) {
return htmlToMarkdown(html);
}
const
hr = '⎯'.repeat(64),
forEach = (selector, fn) => tmpl.content.querySelectorAll(selector).forEach(fn),
forEach = (selector, fn) => tpl.content.querySelectorAll(selector).forEach(fn),
blockquotes = node => {
let bq;
while ((bq = node.querySelector('blockquote'))) {
@ -685,8 +460,6 @@ export const
html = html
.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gim, (...args) =>
1 < args.length ? args[1].toString().replace(/\n/g, '<br>') : '')
// Remove line duplication
.replace(/<br><\/div>/gi, '</div>')
.replace(/\r?\n/g, '')
.replace(/\s+/gm, ' ');
@ -697,12 +470,10 @@ export const
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(/<\/tr(\s[\s\S]*?)?>/gi, '\n');
forEach('style', node => node.remove());
// lines
forEach('hr', node => node.replaceWith(`\n\n${hr}\n\n`));
@ -739,9 +510,7 @@ export const
forEach('a', a => {
let txt = a.textContent, href = a.href;
return a.replaceWith(
txt.replace(/[\s()-]+/g, '').includes(href.replace(/^[a-z]:/, '').replace(/[\s()-]+/g, ''))
? txt
: txt + ' ' + href + ' '
txt.trim() == href || href.includes('mailto:') ? txt : txt + ' ' + href + ' '
);
});
@ -751,21 +520,16 @@ export const
forEach('i,em', i => i.replaceWith(`*${i.textContent}*`));
// Convert line-breaks
tmpl.innerHTML = tmpl.innerHTML
tpl.innerHTML = tpl.innerHTML
.replace(/\n{3,}/gm, '\n\n')
.replace(/\n<br[^>]*>/g, '\n')
.replace(/<br[^>]*>\n/g, '\n');
forEach('br', br => br.replaceWith('\n'));
// Blockquotes must be last
blockquotes(tmpl.content);
blockquotes(tpl.content);
return (tmpl.content.textContent || '').trim();
},
htmlToMarkdown = html => {
tmpl.innerHTML = html;
return turndown.turndown(tmpl.content);
return (tpl.content.textContent || '').trim();
},
/**
@ -776,9 +540,7 @@ export const
plainToHtml = plain => {
plain = plain.toString()
.replace(/\r/g, '')
.replace(/^>[> ]>+/gm, ([match]) => (match ? match.replace(/[ ]+/g, '') : match))
// https://github.com/the-djmaze/snappymail/issues/900
.replace(/\u2028/g,' ');
.replace(/^>[> ]>+/gm, ([match]) => (match ? match.replace(/[ ]+/g, '') : match));
let bIn = false,
bDo = true,
@ -819,28 +581,200 @@ export const
aText = aNextText;
} while (bDo);
tmpl.innerHTML = aText.join('\n')
return aText.join('\n')
// .replace(/~~~\/blockquote~~~\n~~~blockquote~~~/g, '\n')
.replace(/&/g, '&amp;')
.replace(/>/g, '&gt;')
.replace(/</g, '&lt;')
.replace(urlRegExp, (...m) => {
m[0] = stripTracking(m[0]);
return `<a href="${m[0]}" target="_blank">${m[0]}</a>`;
.replace(url, (...m) => {
m[2] = stripTracking(m[2]);
return `${m[1]}<a href="${m[2]}" target="_blank">${m[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(/\s*~~~\/blockquote~~~/g, '</blockquote>')
.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 = {
cleanHtml: cleanHtml,
htmlToPlain: htmlToPlain,
plainToHtml: plainToHtml,
htmlToMarkdown: htmlToMarkdown
// markdownToHtml: md => marked.parse(md)
plainToHtml: plainToHtml
};

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 { doc, Settings, SettingsAdmin } from 'Common/Globals';
import { Settings } from 'Common/Globals';
const
BASE = doc.location.pathname.replace(/\/+$/,'') + '/',
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
SUB_QUERY_PREFIX = '&q[]=',
@ -21,7 +21,7 @@ export const
/**
* @returns {string}
*/
logoutLink = () => adminPath() ? prefix() : BASE,
logoutLink = () => adminPath() ? prefix() : './',
/**
* @param {string} type
@ -30,22 +30,22 @@ export const
* @returns {string}
*/
serverRequestRaw = (type, hash) =>
BASE + '?/Raw/' + SUB_QUERY_PREFIX + '/'
+ '0/' // Settings.get('accountHash') ?
SERVER_PREFIX + '/Raw/' + SUB_QUERY_PREFIX + '/'
+ '0/' // Settings.get('AccountHash') ?
+ (type
? type + '/' + (hash ? SUB_QUERY_PREFIX + '/' + hash : '')
: ''),
/**
* @param {string} download
* @param {string=} customSpecSuffix
* @returns {string}
*/
attachmentDownload = (download) =>
serverRequestRaw('Download', download),
attachmentDownload = (download, customSpecSuffix) =>
serverRequestRaw('Download', download, customSpecSuffix),
proxy = url =>
BASE + '?/ProxyExternal/'
// + btoa(JSON.stringify([token,url]).replace(/ /g, '%20')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
SERVER_PREFIX + '/ProxyExternal/'
+ btoa(url.replace(/ /g, '%20')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
// + b64EncodeJSONSafe(url.replace(/ /g, '%20')),
@ -55,16 +55,13 @@ export const
*/
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 {boolean} isAdmin
* @returns {string}
*/
langLink = (lang, isAdmin) =>
BASE + '?/Lang/0/' + (isAdmin ? 'Admin' : 'App')
SERVER_PREFIX + '/Lang/0/' + (isAdmin ? 'Admin' : 'App')
+ '/' + encodeURI(lang)
+ '/' + Settings.app('version') + '/',
@ -79,10 +76,6 @@ export const
* @returns {string}
*/
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';
if (theme.endsWith('@custom')) {
theme = theme.slice(0, theme.length - 7).trim();

View file

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

View file

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

View file

@ -10,22 +10,19 @@ export const
pInt = (value, defaultValue = 0) => {
value = parseInt(value, 10);
return isFinite(value) ? value : defaultValue;
return isNaN(value) || !isFinite(value) ? defaultValue : value;
},
defaultOptionsAfterRender = (domItem, item) =>
item && undefined !== item.disabled && domItem?.classList.toggle('disabled', domItem.disabled = item.disabled),
// 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':
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)
)),
*/
b64EncodeJSON = data => b64Encode(JSON.stringify(data)),
b64EncodeJSONSafe = data => b64EncodeJSON(data).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
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 { doc, createElement, elementById, dropdowns, dropdownVisibility, SettingsGet, leftPanelDisabled } from 'Common/Globals';
import { plainToHtml } from 'Common/Html';
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 { MessagelistUserStore } from 'Stores/User/Messagelist';
import { SettingsUserStore } from 'Stores/User/Settings';
import * as Local from 'Storage/Client';
import { ThemeStore } from 'Stores/Theme';
import Remote from 'Remote/User/Fetch';
import { attachmentDownload } from 'Common/Links';
import { AccountModel } from 'Model/Account';
import { IdentityModel } from 'Model/Identity';
import { AccountUserStore } from 'Stores/User/Account';
import { IdentityUserStore } from 'Stores/User/Identity';
import { isArray } from 'Common/Utils';
import { showScreenPopup } from 'Knoin/Knoin';
import { IdentityPopupView } from 'View/Popup/Identity';
export const
// 1 = move, 2 = copy
moveAction = ko.observable(0),
moveAction = ko.observable(false),
dropdownsDetectVisibility = (() =>
dropdownVisibility(!!dropdowns.find(item => item.classList.contains('show')))
).debounce(50),
editIdentity = Identity => showScreenPopup(IdentityPopupView, [Identity]),
loadAccountsAndIdentities = () => {
AccountUserStore.loading(true);
IdentityUserStore.loading(true);
Remote.request('AccountsAndIdentities', (iError, oData) => {
AccountUserStore.loading(false);
IdentityUserStore.loading(false);
if (!iError) {
let items = oData.Result.Accounts;
AccountUserStore(isArray(items)
? items.map(oValue => new AccountModel(oValue.email, oValue.name))
: []
);
AccountUserStore.unshift(new AccountModel(SettingsGet('mainEmail'), '', false));
items = oData.Result.Identities;
IdentityUserStore(isArray(items)
? items.map(identityData => IdentityModel.reviveFromJson(identityData))
: []
);
// Invoke "Update Identity" pop up right after login
// https://github.com/the-djmaze/snappymail/issues/1689
const main = IdentityUserStore.main();
main && !main.exists() && setTimeout(()=>editIdentity(main), 1000);
}
});
},
/**
* @param {string} link
* @returns {boolean}
@ -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}
*/
@ -115,15 +50,14 @@ computedPaginatorHelper = (koCurrentPage, koPageCount) => {
const currentPage = koCurrentPage(),
pageCount = koPageCount(),
result = [],
lang = doc.documentElement.lang,
fAdd = (index, push = true, customName = '') => {
const name = index.toLocaleString(lang),
data = {
current: index === currentPage,
name: customName || name,
title: customName ? name : '',
value: index
};
const data = {
current: index === currentPage,
name: customName ? customName.toString() : index.toString(),
custom: !!customName,
title: customName ? index.toString() : '',
value: index.toString()
};
push ? result.push(data) : result.unshift(data);
};
@ -132,7 +66,7 @@ computedPaginatorHelper = (koCurrentPage, koPageCount) => {
next = 0,
limit = 2;
if (1 < pageCount) {
if (1 < pageCount || (0 < pageCount && pageCount < currentPage)) {
if (pageCount < currentPage) {
fAdd(pageCount);
prev = pageCount;
@ -199,15 +133,29 @@ mailToHelper = mailToUrl => {
mailToUrl = mailToUrl.slice(7).split('?');
const
email = decodeURIComponent(mailToUrl[0]),
email = mailToUrl[0],
params = new URLSearchParams(mailToUrl[1]),
to = params.get('to'),
toEmailModel = value => EmailCollectionModel.fromString(value);
toEmailModel = value => null != value ? EmailModel.parseEmailLine(value) : null;
showMessageComposer([
ComposeType.Empty,
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('bcc')),
params.get('subject'),
@ -225,24 +173,37 @@ showMessageComposer = (params = []) =>
rl.app.showMessageComposer(params);
},
setLayoutResizer = (source, sClientSideKeyName, mode) =>
setLayoutResizer = (source, target, sClientSideKeyName, mode) =>
{
if (source.layoutResizer && source.layoutResizer.mode != mode) {
target.removeAttribute('style');
source.removeAttribute('style');
}
source.observer?.disconnect();
// source.classList.toggle('resizable', 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) {
source.style[mode.toLowerCase()] = length + 'px';
setTargetPos(mode);
}
if (!source.layoutResizer) {
const resizer = createElement('div', {'class':'resizer'}),
save = (data => Remote.saveSettings(0, data)).debounce(500),
size = {},
store = () => {
const value = ('Width' == resizer.mode) ? source.offsetWidth : source.offsetHeight,
const value = setTargetPos(resizer.mode),
prop = resizer.key + resizer.mode;
(value == Local.get(prop)) || Local.set(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) => {
if (oMessage) {
popup || MessageUserStore.message(oMessage);
if (oMessage.body) {
viewMessage(oMessage, popup);
} else {
popup || MessageUserStore.loading(true);
Remote.message((iError, oData/*, bCached*/) => {
if (iError) {
if (Notifications.RequestAborted !== iError && !popup) {
MessageUserStore.message(null);
MessageUserStore.error(getNotification(iError));
popup || MessageUserStore.loading(true);
Remote.message((iError, oData/*, bCached*/) => {
if (iError) {
if (Notification.RequestAborted !== iError && !popup) {
MessageUserStore.message(null);
MessageUserStore.error(getNotification(iError));
}
} else {
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 (json
&& ((
oMessage.hash && oMessage.hash === json.hash
) || (
!oMessage.hash
&& oMessage.folder === json.folder
&& oMessage.uid == json.uid)
)
&& oMessage.revivePropertiesFromJson(json)
) {
if (oMessage && oMessage.uid == json.Uid) {
popup || MessageUserStore.error('');
/*
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));

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,10 @@
(doc => {
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,
ctrlKey = shortcuts.getMetaKey() + ' + ',
@ -10,43 +14,65 @@ const
createElement = name => doc.createElement(name),
tpl = createElement('template'),
clr = createElement('input'),
trimLines = html => html.trim().replace(/^(<div>\s*<br\s*\/?>\s*<\/div>)+/, '').trim(),
htmlToPlain = html => rl.Utils.htmlToPlain(html).trim(),
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 = {
/*
blockTag: 'P',
undo: {
documentSizeThreshold: -1, // -1 means no threshold
undoLimit: -1 // -1 means no limit
},
addLinks: true // allow_smart_html_links
*/
sanitizeToDOMFragment: (html, isPaste/*, squire*/) => {
html = (html||'')
tpl.innerHTML = (html||'')
.replace(/<\/?(BODY|HTML)[^>]*>/gi,'')
.replace(/<!--[^>]+-->/g,'')
.replace(/<span[^>]*>\s*<\/span>/gi,'')
.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;
}
};
clr.type = "color";
clr.style.display = 'none';
doc.body.append(clr);
class SquireUI
{
constructor(container) {
const
clr = createElement('input'),
doClr = name => input => {
// https://github.com/the-djmaze/snappymail/issues/826
clr.style.left = (input.offsetLeft + input.parentNode.offsetLeft) + 'px';
clr.style.width = input.offsetWidth + 'px';
doClr = 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);
clr.click();
},
actions = {
@ -58,7 +84,8 @@ class SquireUI
[i18n('SETTINGS_GENERAL/EDITOR_HTML'),'wysiwyg'],
[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: {
@ -84,45 +111,20 @@ class SquireUI
cmd: s => squire.setStyle({ fontFamily: s.value })
},
fontSize: {
select: [[i18n('GLOBAL/DEFAULT'),''],'11px','13px','16px','20px','24px','30px'],
defaultValueIndex: 0,
select: ['11px','13px','16px','20px','24px','30px'],
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: {
textColor: {
html: 'A<sub>▾</sub>',
cmd: doClr('color')
cmd: doClr('color'),
hint: 'Text color'
},
backgroundColor: {
html: '🎨', /* ▧ */
cmd: doClr('backgroundColor')
cmd: doClr('backgroundColor'),
hint: 'Background color'
},
},
inline: {
@ -130,37 +132,37 @@ class SquireUI
html: 'B',
cmd: () => this.doAction('bold'),
key: 'B',
matches: 'B,STRONG'
hint: 'Bold'
},
italic: {
html: 'I',
cmd: () => this.doAction('italic'),
key: 'I',
matches: 'I'
hint: 'Italic'
},
underline: {
html: '<u>U</u>',
cmd: () => this.doAction('underline'),
key: 'U',
matches: 'U'
hint: 'Underline'
},
strike: {
html: '<s>S</s>',
cmd: () => this.doAction('strikethrough'),
key: 'Shift + 7',
matches: 'S'
hint: 'Strikethrough'
},
sub: {
html: 'Xₙ',
cmd: () => this.doAction('subscript'),
key: 'Shift + 5',
matches: 'SUB'
hint: 'Subscript'
},
sup: {
html: 'Xⁿ',
cmd: () => this.doAction('superscript'),
key: 'Shift + 6',
matches: 'SUP'
hint: 'Superscript'
}
},
block: {
@ -168,13 +170,13 @@ class SquireUI
html: '#',
cmd: () => this.doList('OL'),
key: 'Shift + 8',
matches: 'OL'
hint: 'Ordered list'
},
ul: {
html: '⋮',
cmd: () => this.doList('UL'),
key: 'Shift + 9',
matches: 'UL'
hint: 'Unordered list'
},
quote: {
html: '"',
@ -182,17 +184,19 @@ class SquireUI
let parent = squire.getSelectionClosest('UL,OL,BLOCKQUOTE')?.nodeName;
('BLOCKQUOTE' == parent) ? squire.decreaseQuoteLevel() : squire.increaseQuoteLevel();
},
matches: 'BLOCKQUOTE'
hint: 'Blockquote'
},
indentDecrease: {
html: '⇤',
cmd: () => squire.changeIndentationLevel('decrease'),
key: ']'
key: ']',
hint: 'Decrease indent'
},
indentIncrease: {
html: '⇥',
cmd: () => squire.changeIndentationLevel('increase'),
key: '['
key: '[',
hint: 'Increase indent'
}
},
targets: {
@ -205,21 +209,21 @@ class SquireUI
url.length ? squire.makeLink(url) : (node && squire.removeLink());
}
},
matches: 'A'
hint: 'Link'
},
imageUrl: {
html: '🖼️',
cmd: () => {
let node = squire.getSelectionClosest('IMG'),
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: {
html: '📂️',
cmd: () => browseImage.click(),
matches: 'IMG'
hint: 'Image select',
}
},
/*
@ -231,26 +235,19 @@ class SquireUI
undo: {
html: '↶',
cmd: () => squire.undo(),
key: 'Z'
key: 'Z',
hint: 'Undo'
},
redo: {
html: '↷',
cmd: () => squire.redo(),
key: 'Y'
key: 'Y',
hint: 'Redo'
},
source: {
html: '👁',
cmd: btn => {
this.setMode('source' == this.mode ? 'wysiwyg' : 'source');
btn.classList.toggle('active', 'source' == this.mode);
}
}
},
clear: {
removeStyle: {
html: '⎚',
cmd: () => squire.setStyle()
cmd: () => this.setMode('source' == this.mode ? 'wysiwyg' : 'source'),
hint: i18n('EDITOR/TEXT_SWITCHER_SOURCE', 'Source')
}
}
},
@ -261,30 +258,6 @@ class SquireUI
browseImage = createElement('input'),
squire = new Squire(wysiwyg, SquireDefaultConfig);
clr.type = 'color';
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.accept = 'image/*';
browseImage.style.display = 'none';
@ -298,18 +271,25 @@ class SquireUI
plain.className = 'squire-plain';
wysiwyg.className = 'squire-wysiwyg';
wysiwyg.dir = 'auto';
this.mode = ''; // 'plain' | 'wysiwyg'
this.__plain = {
getRawData: () => this.plain.value,
setRawData: plain => this.plain.value = plain
};
this.container = container;
this.squire = squire;
this.plain = plain;
this.wysiwyg = wysiwyg;
dispatchEvent(new CustomEvent('squire-toolbar', {detail:{squire:this,actions:actions}}));
toolbar.className = 'squire-toolbar btn-toolbar';
let group, action/*, touchTap*/;
for (group in actions) {
/*
if ('bidi' == group && !rl.settings.app('allowHtmlEditorBitiButtons')) {
continue;
}
*/
let toolgroup = createElement('div');
toolgroup.className = 'btn-group';
toolgroup.id = 'squire-toolgroup-'+group;
@ -330,7 +310,6 @@ class SquireUI
input.append(option);
});
} else {
input.add(new Option(i18n('GLOBAL/DEFAULT'), ''));
Object.entries(cfg.select).forEach(([label, options]) => {
let group = createElement('optgroup');
group.label = label;
@ -339,7 +318,7 @@ class SquireUI
option.style[action] = value;
group.append(option);
});
input.add(group);
input.append(group);
});
}
ev = 'input';
@ -363,7 +342,6 @@ class SquireUI
*/
}
input.addEventListener(ev, () => cfg.cmd(input));
cfg.hint = i18n('EDITOR/' + action.toUpperCase());
if (cfg.hint) {
input.title = cfg.key ? cfg.hint + ' (' + ctrlKey + cfg.key + ')' : cfg.hint;
} else if (cfg.key) {
@ -381,178 +359,36 @@ class SquireUI
let changes = actions.changes;
changes.undo.input.disabled = changes.redo.input.disabled = true;
squire.addEventListener('undoStateChange', e => {
changes.undo.input.disabled = !e.detail.canUndo;
changes.redo.input.disabled = !e.detail.canRedo;
squire.addEventListener('undoStateChange', state => {
changes.undo.input.disabled = !state.canUndo;
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('blur', () => shortcuts.on());
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 => {
console.dir({cursor:e.detail.range});
});
squire.addEventListener('select', e => {
console.dir({select:e.detail.range});
});
squire.addEventListener('dragover', );
squire.addEventListener('drop', );
squire.addEventListener('pathChange', );
squire.addEventListener('cursor', );
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) {
@ -565,11 +401,11 @@ class SquireUI
fn = {UL:'makeUnorderedList',OL:'makeOrderedList'};
(parent == type) ? this.squire.removeList() : this.squire[fn[type]]();
}
/*
testPresenceinSelection(format, validation) {
return validation.test(this.squire.getPath()) || this.squire.hasFormat(format);
}
*/
setMode(mode) {
if (this.mode != mode) {
let cl = this.container.classList, source = 'source' == this.mode;
@ -590,6 +426,7 @@ class SquireUI
this.modeSelect.selectedIndex = 'plain' == this.mode ? 1 : 0;
}
// CKeditor gimmicks used by HtmlEditor
on(type, fn) {
if ('mode' == type) {
this.onModeChange = fn;
@ -632,7 +469,6 @@ class SquireUI
// Move cursor above signature
div.before(br);
div.before(br.cloneNode());
// squire._docWasChanged();
}
this._prev_txt_sig = signature;
} catch (e) {
@ -656,18 +492,6 @@ class SquireUI
squire.setSelection( range );
}
getPlainData() {
return this.plain.value;
}
setPlainData(text) {
this.plain.value = text;
}
blur() {
this.squire.blur();
}
focus() {
if ('plain' == this.mode) {
this.plain.focus();

View file

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

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

View file

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

View file

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

View file

@ -1,12 +1,9 @@
import ko from '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 { i18nToNodes } from 'Common/Translator';
import { leftPanelDisabled } from 'Common/Globals';
import { ThemeStore } from 'Stores/Theme';
let
currentScreen = null,
defaultScreenName = '';
@ -25,13 +22,13 @@ const
screen = screenName => (screenName && SCREENS.get(screenName)) || null,
/**
* Creates the extended AbstractView model
* @param {Function} ViewModelClass
* @param {Object=} vmScreen
* @returns {*}
*/
buildViewModel = (ViewModelClass, vmScreen) => {
if (ViewModelClass && !ViewModelClass.__vm) {
if (ViewModelClass && !ViewModelClass.__builded) {
let vmDom = null;
const
vm = new ViewModelClass(vmScreen),
id = vm.viewModelTemplateID,
@ -39,30 +36,35 @@ const
dialog = ViewTypePopup === vm.viewType,
vmPlace = doc.getElementById(position);
if (vmPlace) {
ViewModelClass.__vm = vm;
ViewModelClass.__builded = true;
ViewModelClass.__vm = vm;
let vmDom = dialog
? createElement('dialog',{id:'V-'+id})
: createElement('div',{id:'V-'+id,hidden:''})
if (vmPlace) {
vmDom = Element.fromHTML(dialog
? '<dialog id="V-'+ id + '"></dialog>'
: '<div id="V-'+ id + '" hidden=""></div>');
vmPlace.append(vmDom);
vm.viewModelDom = vmDom;
vm.viewModelDom = ViewModelClass.__dom = vmDom;
if (dialog) {
vm.close = () => hideScreenPopup(ViewModelClass);
// Firefox < 98 / Safari < 15.4 HTMLDialogElement not defined
if (!vmDom.showModal) {
vmDom.className = 'polyfill';
vmDom.showModal = () => {
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.open = true;
vmDom.returnValue = null;
vmDom.backdrop.hidden = false;
};
vmDom.close = () => {
vmDom.close = v => {
// if (vmDom.dispatchEvent(new CustomEvent('cancel', {cancelable:true}))) {
vmDom.backdrop.hidden = true;
vmDom.returnValue = v;
vmDom.removeAttribute('open', null);
vmDom.open = false;
// vmDom.dispatchEvent(new CustomEvent('close'));
@ -74,17 +76,14 @@ const
// vmDom.addEventListener('close', () => vm.modalVisible(false));
// show/hide popup/modal
// transitionend is called for each property, so we only listen to `opacity`
// as defined in CSS by `dialog:not(.animate)`
const endShowHide = e => {
if (e.target === vmDom && 'opacity' === e.propertyName) {
if (e.target === vmDom) {
if (vmDom.classList.contains('animate')) {
autofocus(vmDom);
vm.afterShow?.();
fireEvent('rl-vm-visible', vm);
} else {
vmDom.close();
vm.afterHide?.();
// fireEvent('rl-vm-hidden', vm);
}
}
};
@ -99,7 +98,6 @@ const
vmDom.backdrop.style.zIndex = 3000 + (visiblePopups.size * 2);
}
vm.keyScope.set();
setTimeout(()=>autofocus(vmDom),1);
requestAnimationFrame(() => { // wait just before the next paint
vmDom.offsetHeight; // force a reflow
vmDom.classList.add('animate'); // trigger the transitions
@ -140,9 +138,10 @@ const
screen.viewModels.forEach(ViewModelClass => {
if (
ViewModelClass.__vm &&
ViewModelClass.__dom &&
ViewTypePopup !== ViewModelClass.__vm.viewType
) {
fn(ViewModelClass.__vm, ViewModelClass.__vm.viewModelDom);
fn(ViewModelClass.__vm, ViewModelClass.__dom);
}
});
},
@ -152,69 +151,85 @@ const
forEachViewModel(screenToHide, (vm, dom) => {
dom.hidden = true;
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} subPart
* @returns {void}
*/
screenOnRoute = (screenName, subPart) => {
screenName = screenName || defaultScreenName;
if (screenName && fireEvent('sm-show-screen', screenName + (subPart ? '/' + subPart : ''), 1)) {
let vmScreen = null,
isSameScreen = false;
if (null == screenName || '' == screenName) {
screenName = defaultScreenName;
}
if (fireEvent('sm-show-screen', screenName, 1)) {
// Close all popups
for (let vm of visiblePopups) {
vm.tryToClose();
(false === vm.onClose()) || vm.close();
}
let vmScreen = screen(screenName);
if (!vmScreen) {
vmScreen = screen(defaultScreenName);
if (vmScreen) {
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);
});
if (screenName) {
vmScreen = screen(screenName);
if (!vmScreen) {
vmScreen = screen(defaultScreenName);
if (vmScreen) {
subPart = screenName + '/' + subPart;
screenName = defaultScreenName;
}
// --
}
vmScreen.__cross?.parse(subPart);
}, 1);
if (vmScreen?.__started) {
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}
*/
showScreenPopup = (ViewModelClassToShow, params = []) => {
const vm = buildViewModel(ViewModelClassToShow);
const vm = buildViewModel(ViewModelClassToShow) && ViewModelClassToShow.__dom && ViewModelClassToShow.__vm;
if (vm) {
params = params || [];
vm.beforeShow?.(...params);
vm.modalVisible(true);
vm.onShow?.(...params);
}
},
@ -266,7 +285,7 @@ export const
});
const cross = new Crossroads();
cross.addRoute(/^([^/]*)\/?(.*)$/, screenOnRoute);
cross.addRoute(/^([a-zA-Z0-9-]*)\/?(.*)$/, screenOnRoute);
hasher.add(cross.parse.bind(cross));
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)
{
@ -15,52 +26,7 @@ export function ParseMime(text)
this.bodyEnd = 0;
this.boundary = '';
this.bodyText = '';
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6
// https://datatracker.ietf.org/doc/html/rfc4021
this.headers = {
// Required
date = null,
from = [], // mailbox-list
// Optional
sender = [], // mailbox MUST occur with multi-address
'reply-to' = [], // address-list
to = [], // address-list
cc = [], // address-list
bcc = [], // address-list
'message-id' = '', // msg-id SHOULD be present
'in-reply-to' = '', // 1*msg-id SHOULD occur in some replies
references = '', // 1*msg-id SHOULD occur in some replies
subject = '', // unstructured
// Optional unlimited
comments = [], // unstructured
keywords = [], // phrase *("," phrase)
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6.6
'resent-date' = [],
'resent-from' = [],
'resent-sender' = [],
'resent-to' = [],
'resent-cc' = [],
'resent-bcc' = [],
'resent-msg-id' = [],
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6.7
trace = [],
'return-path' = '', // angle-addr
received = [],
// optional others outside RFC2822
'mime-version' = '', // RFC2045
'content-transfer-encoding' = '',
'content-type' = '',
'delivered-to' = [], // RFC9228 addr-spec
'authentication-results' = '', // dkim, spf, dmarc
'dkim-signature' = '',
'x-rspamd-queue-id' = '',
'x-rspamd-action' = '',
'x-spamd-bar' = '',
'x-rspamd-server' = '',
'x-spamd-result' = '',
'x-remote-address' = '',
// etc.
};
this.headers = {};
}
*/
@ -83,25 +49,25 @@ export function ParseMime(text)
get body() {
let body = this.bodyRaw,
charset = this.header('content-type')?.params.charset,
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
encoding = this.headerValue('content-transfer-encoding');
if ('quoted-printable' == encoding) {
body = QPDecode(body);
} else if ('base64' == encoding) {
body = BDecode(body.replace(/\r?\n/g, ''));
body = atob(body.replace(/\r?\n/g, ''));
}
return decodeText(charset, body);
}
get dataUrl() {
let body = this.bodyRaw,
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
encoding = this.headerValue('content-transfer-encoding');
if ('base64' == encoding) {
body = body.replace(/\r?\n/g, '');
} else {
if ('quoted-printable' == encoding) {
body = QPDecode(body);
}
body = BEncode(body);
body = btoa(body);
}
return 'data:' + this.headerValue('content-type') + ';base64,' + body;
}
@ -112,7 +78,7 @@ export function ParseMime(text)
}
getByContentType(type) {
if (type == this.headerValue('content-type')?.toLowerCase()) {
if (type == this.headerValue('content-type')) {
return this;
}
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 = '') =>
{
let part = new MimePart,
@ -148,19 +111,11 @@ export function ParseMime(text)
[...header.matchAll(/;\s*([^;=]+)=\s*"?([^;"]+)"?/g)].forEach(param =>
params[param[1].trim().toLowerCase()] = param[2].trim()
);
let field = match[1].trim().toLowerCase();
if (lists.includes(field)) {
match[2] = addressparser(match[2]);
} else if ('keywords' === field) {
match[2] = match[2].split(',').forEach(entry => decodeEncodedWords(entry.trim()));
match[2] = (headers[field]?.value || []).concat(match[2]);
} else {
match[2] = decodeEncodedWords(match[2].trim());
if ('comments' === field) {
match[2] = (headers[field]?.value || []).push(match[2]);
}
}
headers[field] = {
// encoded-word = "=?" charset "?" encoding "?" encoded-text "?="
match[2] = match[2].trim().replace(/=\?([^?]+)\?(B|Q)\?(.+?)\?=/g, (m, charset, encoding, text) =>
decodeText(charset, 'B' == encoding ? atob(text) : QPDecode(text))
);
headers[match[1].trim().toLowerCase()] = {
value: match[2],
params: params
};
@ -175,7 +130,7 @@ export function ParseMime(text)
let boundary = headers['content-type']?.params.boundary;
if (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),
bodies = body.split(regex),
pos = part.bodyStart;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,16 +10,16 @@ export class ComposeAttachmentModel extends AbstractModel {
* @param {?number=} size = null
* @param {boolean=} isInline = false
* @param {boolean=} isLinked = false
* @param {string=} cId = ''
* @param {string=} CID = ''
* @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();
this.id = id;
this.isInline = !!isInline;
this.isLinked = !!isLinked;
this.cId = cId;
this.CID = CID;
this.contentLocation = contentLocation;
this.fromMessage = false;

View file

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

View file

@ -4,18 +4,269 @@ import { AbstractModel } from 'Knoin/AbstractModel';
'use strict';
/**
* Parses structured e-mail addresses from an address field
*
* Example:
*
* "Name <address@domain>"
*
* will be converted to
*
* [{name: "Name", address: "address@domain"}]
*
* @param {String} str Address field
* @return {Array} An array of address objects
*/
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 {
/**
* @param {string=} email = ''
* @param {string=} name = ''
* @param {string=} dkimStatus = 'none'
*/
constructor(email, name, dkimStatus = 'none') {
constructor(email = '', name = '', dkimStatus = 'none') {
super();
this.email = email || '';
this.name = name || '';
this.email = email;
this.name = name;
this.dkimStatus = dkimStatus;
this.cleanup();
this.clearDuplicateName();
}
/**
@ -25,34 +276,60 @@ export class EmailModel extends AbstractModel {
*/
static reviveFromJson(json) {
const email = super.reviveFromJson(json);
email?.cleanup();
return email?.valid() ? email : null;
}
/**
* @returns {boolean}
*/
valid() {
return this.name || this.email;
email?.clearDuplicateName();
return email;
}
/**
* @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) {
this.name = '';
}
}
/**
* @param {string} query
* @returns {boolean}
*/
search(query) {
return (this.name + ' ' + this.email).toLowerCase().includes(query.toLowerCase());
}
/**
* @param {boolean} friendlyView = false
* @param {boolean} wrapWithLink = false
* @returns {string}
*/
toLine(friendlyView, wrapWithLink) {
let name = this.name,
result = this.email,
let result = this.email,
name = this.name,
toLink = text =>
'<a href="mailto:'
+ encodeHtml(result) + (name ? '?to=' + encodeURIComponent('"' + name + '" <' + result + '>') : '')
@ -71,11 +348,47 @@ export class EmailModel extends AbstractModel {
result = toLink();
}
}
return result || name;
return result;
}
get domain() {
return this.email.replace(/^.+@([^@]+)$/, '$1');
// return this.email.slice(this.email.lastIndexOf('@'))
static splitEmailLine(line) {
const result = [];
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 { EmailModel } from 'Model/Email';
import { forEachObjectValue } from 'Common/Utils';
import { addressparser } from 'Mime/Address';
'use strict';
@ -15,16 +13,6 @@ export class EmailCollectionModel extends AbstractCollectionModel
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=} wrapWithLink = false
@ -33,43 +21,4 @@ export class EmailCollectionModel extends AbstractCollectionModel
toString(friendlyView, wrapWithLink) {
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 { isArray, getKeyByValue, forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
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 * as Local from 'Storage/Client';
@ -20,17 +20,10 @@ import { AbstractModel } from 'Knoin/AbstractModel';
import { /*koComputable,*/ addObservablesTo } from 'External/ko';
import { mailBox } from 'Common/Links';
//import { mailBox } from 'Common/Links';
import Remote from 'Remote/User/Fetch';
import { FileInfo } from 'Common/File';
import { FolderPopupView } from 'View/Popup/Folder';
import { showScreenPopup } from 'Knoin/Knoin';
import { isAllowedKeyword } from 'Stores/User/Folder';
const
// isPosNumeric = value => null != value && /^[0-9]*$/.test(value.toString()),
@ -98,13 +91,12 @@ export const
Remote.abort('Folders')
.post('Folders', FolderUserStore.foldersLoading)
.then(data => {
clearCache();
FolderCollectionModel.reviveFromJson(data.Result)?.storeIt();
fCallback?.(true);
// Repeat every 15 minutes?
// this.foldersTimeout = setTimeout(loadFolders, 900000);
})
.catch(e => fCallback && setTimeout(fCallback, 1, false, e));
.catch(() => fCallback && setTimeout(fCallback, 1, false));
};
export class FolderCollectionModel extends AbstractCollectionModel
@ -117,8 +109,6 @@ export class FolderCollectionModel extends AbstractCollectionModel
this.namespace;
this.optimized
this.capabilities
this.allow; // allow adding
// this.exist;
}
*/
@ -134,11 +124,11 @@ export class FolderCollectionModel extends AbstractCollectionModel
);
const result = super.reviveFromJson(object, oFolder => {
let oCacheFolder = getFolderFromCacheList(oFolder.fullName);
let oCacheFolder = getFolderFromCacheList(oFolder.FullName);
if (oCacheFolder) {
// oCacheFolder.revivePropertiesFromJson(oFolder);
if (oFolder.etag) {
oCacheFolder.etag = oFolder.etag;
if (oFolder.Hash) {
oCacheFolder.hash = oFolder.Hash;
}
if (null != oFolder.totalEmails) {
oCacheFolder.totalEmails(oFolder.totalEmails);
@ -177,32 +167,32 @@ export class FolderCollectionModel extends AbstractCollectionModel
break;
}
// Flags
if (oFolder.attributes.includes('\\sentmail')) {
if (oFolder.Flags.includes('\\sentmail')) {
role = 'sent';
}
if (oFolder.attributes.includes('\\spam')) {
if (oFolder.Flags.includes('\\spam')) {
role = 'junk';
}
if (oFolder.attributes.includes('\\bin')) {
if (oFolder.Flags.includes('\\bin')) {
role = 'trash';
}
if (oFolder.attributes.includes('\\important')) {
if (oFolder.Flags.includes('\\important')) {
role = 'important';
}
if (oFolder.attributes.includes('\\starred')) {
if (oFolder.Flags.includes('\\starred')) {
role = 'flagged';
}
if (oFolder.attributes.includes('\\all') || oFolder.flags.includes('\\allmail')) {
if (oFolder.Flags.includes('\\all') || oFolder.Flags.includes('\\allmail')) {
role = 'all';
}
}
*/
if (role) {
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
|| !isArray(expandedFolders)
@ -231,13 +221,14 @@ export class FolderCollectionModel extends AbstractCollectionModel
name = parents.pop(),
pfolder = getFolderFromCacheList(parentName);
if (!pfolder) {
console.log('Create nonexistent folder ' + parentName);
pfolder = FolderModel.reviveFromJson({
'@Object': 'Object/Folder',
name: name,
fullName: parentName,
delimiter: delimiter,
attributes: ['\\nonexistent']
Name: name,
FullName: parentName,
Delimiter: delimiter,
Exists: false,
isSubscribed: false,
Flags: ['\\nonexistent']
});
setFolder(pfolder);
result.splice(i, 0, pfolder);
@ -260,10 +251,6 @@ export class FolderCollectionModel extends AbstractCollectionModel
return result;
}
visible() {
return this.filter(folder => folder.visible());
}
storeIt() {
FolderUserStore.displaySpecSetting(Settings.app('folderSpecLimit') < this.CountRec);
@ -283,9 +270,11 @@ export class FolderCollectionModel extends AbstractCollectionModel
FolderUserStore.namespace = this.namespace;
// '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.quotaLimit(this.quotaLimit);
FolderUserStore.capabilities(this.capabilities);
@ -306,7 +295,6 @@ export class FolderModel extends AbstractModel {
super();
this.fullName = '';
this.parentName = '';
this.delimiter = '';
this.deep = 0;
this.expires = 0;
@ -314,10 +302,9 @@ export class FolderModel extends AbstractModel {
this.exists = true;
this.etag = '';
this.id = 0;
this.uidNext = 0;
this.size = 0;
this.hash = '';
// this.id = null;
this.uidNext = null;
addObservablesTo(this, {
name: '',
@ -327,10 +314,12 @@ export class FolderModel extends AbstractModel {
focused: false,
selected: false,
editing: false,
isSubscribed: true,
checkable: false, // Check for new messages
askDelete: false,
nameForEdit: '',
errorMsg: '',
totalEmails: 0,
@ -343,13 +332,13 @@ export class FolderModel extends AbstractModel {
tagsAllowed: false
});
this.attributes = ko.observableArray();
// For messages
this.flags = ko.observableArray();
this.permanentFlags = ko.observableArray();
this.addSubscribables({
kolabType: sValue => this.metadata[FolderMetadataKeys.KolabFolderType] = sValue,
permanentFlags: aValue => this.tagsAllowed(aValue.includes('\\*')),
editing: value => value && this.nameForEdit(this.name()),
unreadEmails: unread => FolderType.Inbox === this.type() && fireEvent('mailbox.inbox-unread-count', unread)
});
@ -371,19 +360,20 @@ export class FolderModel extends AbstractModel {
.extend({ notify: 'always' });
*/
/*
// https://www.rfc-editor.org/rfc/rfc8621.html#section-2
this.myRights = {
'mayAddItems': true,
'mayCreateChild': true,
'mayDelete': true,
'mayReadItems': true,
'mayRemoveItems': true,
'mayRename': true,
'maySetKeywords': true,
'maySetSeen': true,
'maySubmit': true
};
https://www.rfc-editor.org/rfc/rfc8621.html#section-2
"myRights": {
"mayAddItems": true,
"mayRename": false,
"maySubmit": true,
"mayDelete": false,
"maySetKeywords": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"maySetSeen": true,
"mayReadItems": true
},
*/
this.addComputables({
isInbox: () => FolderType.Inbox === this.type(),
@ -391,10 +381,7 @@ export class FolderModel extends AbstractModel {
isFlagged: () => FolderUserStore.currentFolder() === this
&& MessagelistUserStore.listSearch().includes('flagged'),
// isSubscribed: () => this.attributes().includes('\\subscribed'),
hasVisibleSubfolders: () => !!this.subFolders().find(folder => folder.visible()),
visibleSubfolders: () => this.subFolders().visible(),
hasSubscriptions: () => this.isSubscribed() | !!this.subFolders().find(
oFolder => {
@ -403,6 +390,8 @@ export class FolderModel extends AbstractModel {
}
),
canBeEdited: () => !this.type() && this.exists/* && this.selectable()*/,
isSystemFolder: () => this.type()
| (FolderUserStore.allowKolab() && !!this.kolabType() & !SettingsUserStore.unhideKolabFolders()),
@ -413,8 +402,6 @@ export class FolderModel extends AbstractModel {
canBeSubscribed: () => this.selectable()
&& !(this.isSystemFolder() | !SettingsUserStore.hideUnsubscribed()),
optionalTags: () => this.permanentFlags.filter(isAllowedKeyword),
/**
* Folder is visible when:
* - hasVisibleSubfolders()
@ -456,47 +443,30 @@ export class FolderModel extends AbstractModel {
return name;
},
nameInfo: () => {
manageFolderSystemName: () => {
if (this.isSystemFolder()) {
translateTrigger();
let suffix = getSystemFolderName(this.type(), getKolabFolderName(this.kolabType()));
if (this.name() !== suffix && 'inbox' !== suffix.toLowerCase()) {
return ' (' + suffix + ')';
return '(' + suffix + ')';
}
}
return '';
},
friendlySize: () => FileInfo.friendlySize(this.size),
detailedName: () => this.name() + ' ' + this.nameInfo(),
icon: () => {
switch (this.type())
{
case 1: return '📥'; // FolderType.Inbox
case 2: return '📧'; // FolderType.Sent icon-paper-plane
case 3: return '🗎'; // FolderType.Drafts
case 4: return '⚠'; // FolderType.Junk
case 5: return '🗑'; // FolderType.Trash
case 6: return '🗄'; // FolderType.Archive
}
return null;
},
hasUnreadInSub: () =>
this.subFolders().some(
folder => folder.unreadEmails() | folder.hasUnreadInSub()
),
href: () => this.canBeSelected() && mailBox(this.fullNameHash)
hasSubscribedUnreadMessagesSubfolders: () =>
!!this.subFolders().find(
folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
)
/*
!!this.subFolders().filter(
folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
).length
*/
// ,href: () => this.canBeSelected() && mailBox(this.fullNameHash)
});
}
edit() {
showScreenPopup(FolderPopupView, [this]);
}
/**
* For url safe '/#/mailbox/...' path
*/
@ -514,7 +484,6 @@ export class FolderModel extends AbstractModel {
const folder = super.reviveFromJson(json);
if (folder) {
const path = folder.fullName.split(folder.delimiter),
attr = name => folder.attributes.includes(name),
type = (folder.metadata[FolderMetadataKeys.KolabFolderType]
|| folder.metadata[FolderMetadataKeys.KolabFolderTypeShared]
|| ''
@ -524,12 +493,6 @@ export class FolderModel extends AbstractModel {
path.pop();
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);
}
return folder;

View file

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

View file

@ -1,91 +1,37 @@
import ko from 'ko';
import { MessagePriority } from 'Common/EnumsUser';
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 { forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
import { isFunction, forEachObjectEntry } from 'Common/Utils';
import { serverRequestRaw, proxy } from 'Common/Links';
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 { FileInfo, RFC822 } from 'Common/File';
import { FileInfo } from 'Common/File';
import { AttachmentCollectionModel } from 'Model/AttachmentCollection';
import { EmailCollectionModel } from 'Model/EmailCollection';
import { MimeHeaderCollectionModel } from 'Model/MimeHeaderCollection';
//import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
import { AbstractModel } from 'Knoin/AbstractModel';
import PreviewHTML from 'Html/PreviewMessage.html';
import { LanguageStore } from 'Stores/Language';
//import { MessageFlagsCache } from 'Common/Cache';
import Remote from 'Remote/User/Fetch';
import { MimeToMessage } from 'Mime/Utils';
import { PgpUserStore } from 'Stores/User/Pgp';
import { IdentityUserStore } from 'Stores/User/Identity';
import { Passphrases } from 'Storage/Passphrases';
const
PreviewHTML = `<html>
<head>
<meta charset="utf-8">
<title></title>
<style>
html, body {
margin: 0;
padding: 0;
}
header {
background: rgba(125,128,128,0.3);
border-bottom: 1px solid #888;
}
header h1 {
font-size: 120%;
}
header * {
margin: 5px 0;
}
header time {
float: right;
}
blockquote {
border-left: 2px solid rgba(125,128,128,0.5);
margin: 0;
padding: 0 0 0 10px;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
word-break: normal;
}
body > * {
padding: 0.5em 1em;
}
#attachments > * {
border: 1px solid rgba(125,128,128,0.5);
padding: 0.25em;
margin-right: 1em;
}
#attachments > *::before {
content: '📎 ';
}
</style>
</head>
<body></body>
</html>`,
msgHtml = msg => cleanHtml(msg.html(), msg.attachments(), '#rl-msg-' + msg.hash),
hcont = Element.fromHTML('<div area="hidden" style="position:absolute;left:-200vw;width:max(50vw,400px)"></div>'),
getRealHeight = el => {
hcont.innerHTML = el.outerHTML;
const result = hcont.clientHeight;
hcont.innerHTML = '';
return result;
},
toggleTag = (message, keyword) => {
const lower = keyword.toLowerCase(),
@ -94,12 +40,13 @@ body > * {
Remote.request('MessageSetKeyword', iError => {
if (!iError) {
isSet ? flags.remove(lower) : flags.push(lower);
// MessageFlagsCache.setFor(message.folder, message.uid, flags());
}
}, {
folder: message.folder,
uids: message.uid,
keyword: keyword,
setAction: isSet ? 0 : 1
Folder: message.folder,
Uids: message.uid,
Keyword: keyword,
SetAction: isSet ? 0 : 1
})
},
@ -113,55 +60,47 @@ body > * {
unic[email.email] || localEmails.has(email.email) || localEmails.set(email.email, email)
);
doc.body.append(hcont);
export class MessageModel extends AbstractModel {
constructor() {
super();
Object.assign(this, {
folder: '',
uid: 0,
hash: '',
from: new EmailCollectionModel,
to: new EmailCollectionModel,
cc: new EmailCollectionModel,
bcc: new EmailCollectionModel,
sender: new EmailCollectionModel,
replyTo: new EmailCollectionModel,
deliveredTo: new EmailCollectionModel,
body: null,
draftInfo: [],
dkim: [],
spf: [],
dmarc: [],
messageId: '',
inReplyTo: '',
references: '',
// autocrypt: ko.observableArray(),
hasVirus: null, // or boolean when scanned
priority: 3, // Normal
senderEmailsString: '',
senderClearEmailsString: '',
isSpam: false,
spamScore: 0,
spamResult: '',
size: 0,
readReceipt: '',
preview: null,
attachments: ko.observableArray(new AttachmentCollectionModel),
threads: ko.observableArray(),
threadUnseen: ko.observableArray(),
unsubsribeLinks: ko.observableArray(),
flags: ko.observableArray(),
headers: ko.observableArray(new MimeHeaderCollectionModel)
});
this.folder = '';
this.uid = 0;
this.hash = '';
this.requestHash = '';
this.from = new EmailCollectionModel;
this.to = new EmailCollectionModel;
this.cc = new EmailCollectionModel;
this.bcc = new EmailCollectionModel;
this.replyTo = new EmailCollectionModel;
this.deliveredTo = new EmailCollectionModel;
this.body = null;
this.draftInfo = [];
this.dkim = [];
this.spf = [];
this.dmarc = [];
this.messageId = '';
this.inReplyTo = '';
this.references = '';
addObservablesTo(this, {
subject: '',
plain: '',
html: '',
dateTimestamp: 0,
dateTimestampSource: 0,
size: 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
focused: false,
@ -171,99 +110,52 @@ export class MessageModel extends AbstractModel {
isHtml: false,
hasImages: false,
hasExternals: false,
hasTracking: false,
encrypted: false,
pgpSigned: null,
pgpVerified: null,
encrypted: false,
pgpEncrypted: null,
pgpDecrypted: false,
smimeSigned: null,
smimeEncrypted: null,
smimeDecrypted: false,
readReceipt: '',
// rfc8621
id: '',
// threadId: ''
/**
* Basic support for Linked Data (Structured Email)
* https://json-ld.org/
* https://structured.email/
**/
linkedData: []
hasUnseenSubMessage: false,
hasFlaggedSubMessage: false
});
this.attachments = ko.observableArray(new AttachmentCollectionModel);
this.threads = ko.observableArray();
this.unsubsribeLinks = ko.observableArray();
this.flags = ko.observableArray();
addComputablesTo(this, {
attachmentIconClass: () =>
this.encrypted() ? 'icon-lock' : FileInfo.getAttachmentsIconClass(this.attachments()),
threadsLen: () => rl.app.messageList.threadUid() ? 0 : this.threads().length,
threadUnseenLen: () => rl.app.messageList.threadUid() ? 0 : this.threadUnseen().length,
threadsLenText: () => {
const unseenLen = this.threadUnseenLen();
return this.threadsLen() + (unseenLen > 0 ? '/' + unseenLen : '');
},
threadsLen: () => this.threads().length,
isUnseen: () => !this.flags().includes('\\seen'),
isFlagged: () => this.flags().includes('\\flagged'),
isDeleted: () => this.flags().includes('\\deleted'),
// isJunk: () => this.flags().includes('$junk') && !this.flags().includes('$nonjunk'),
// isPhishing: () => this.flags().includes('$phishing'),
tagOptions: () => {
const tagOptions = [];
FolderUserStore.currentFolder().optionalTags().forEach(value => {
let lower = value.toLowerCase();
tagOptions.push({
css: 'msgflag-' + lower,
value: value,
checked: this.flags().includes(lower),
label: i18n('MESSAGE_TAGS/'+lower, 0, value),
toggle: (/*obj*/) => toggleTag(this, value)
});
FolderUserStore.currentFolder().permanentFlags.forEach(value => {
if (isAllowedKeyword(value)) {
let lower = value.toLowerCase();
tagOptions.push({
css: 'msgflag-' + lower,
value: value,
checked: this.flags().includes(lower),
label: i18n('MESSAGE_TAGS/'+lower, 0, value),
toggle: (/*obj*/) => toggleTag(this, value)
});
}
});
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() {
let spam = this.spamResult;
return spam ? i18n(this.isSpam ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
let spam = this.spamResult();
return spam ? i18n(this.isSpam() ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
}
/**
* @returns {string}
*/
friendlySize() {
return FileInfo.friendlySize(this.size);
return FileInfo.friendlySize(this.size());
}
computeSenderEmail() {
const list = this[
[FolderUserStore.sentFolder(), FolderUserStore.draftsFolder()].includes(this.folder) ? 'to' : 'from'
];
this.senderEmailsString = list.toString(true);
this.senderClearEmailsString = list.map(email => email?.email).filter(email => email).join(', ');
this.senderEmailsString(list.toString(true));
this.senderClearEmailsString(list.map(email => email?.email).filter(email => email).join(', '));
}
/**
@ -296,67 +188,14 @@ export class MessageModel extends AbstractModel {
* @returns {boolean}
*/
revivePropertiesFromJson(json) {
if ('Priority' in json && ![MessagePriority.High, MessagePriority.Low].includes(json.Priority)) {
json.Priority = MessagePriority.Normal;
}
if (super.revivePropertiesFromJson(json)) {
// this.foundCIDs = isArray(json.FoundCIDs) ? json.FoundCIDs : [];
// this.attachments(AttachmentCollectionModel.reviveFromJson(json.attachments, this.foundCIDs));
// this.headers(MimeHeaderCollectionModel.reviveFromJson(json.headers));
// this.attachments(AttachmentCollectionModel.reviveFromJson(json.Attachments, this.foundCIDs));
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) {
let classes = [];
forEachObjectEntry({
deleted: this.deleted(),
selected: this.selected(),
checked: this.checked(),
unseen: this.isUnseen(),
focused: this.focused(),
priorityHigh: this.priority === 1,
priorityHigh: this.priority() === MessagePriority.High,
withAttachments: !!this.attachments().length,
// hasChildrenMessage: 1 < this.threadsLen()
// hasChildrenMessage: 1 < this.threadsLen(),
hasUnseenSubMessage: this.hasUnseenSubMessage(),
hasFlaggedSubMessage: this.hasFlaggedSubMessage()
}, (key, value) => value && classes.push(key));
flags && this.flags().forEach(value => classes.push('msgflag-'+value));
return classes.join(' ');
@ -429,161 +271,141 @@ export class MessageModel extends AbstractModel {
return [[...toResult.values()], [...ccResult.values()]];
}
viewBody(html) {
viewHtml() {
const body = this.body;
if (body) {
if (html) {
let result = msgHtml(this);
this.hasExternals(result.hasExternals);
this.hasImages(!!result.hasExternals);
this.hasTracking(!!result.tracking);
this.linkedData(result.linkedData);
body.innerHTML = result.html;
if (!this.isSpam && FolderUserStore.spamFolder() != this.folder) {
if ('always' === SettingsUserStore.viewImages()) {
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);
if (body && this.html()) {
let result = cleanHtml(this.html(), this.attachments());
this.hasExternals(result.hasExternals);
this.hasImages(body.rlHasImages = !!result.hasExternals);
body.innerHTML = result.html;
body.classList.toggle('html', 1);
body.classList.toggle('plain', 0);
if (SettingsUserStore.showImages()) {
this.showExternalImages();
}
body.classList.toggle('html', html);
body.classList.toggle('plain', !html);
this.isHtml(html);
this.isHtml(true);
this.initView();
return true;
}
}
viewHtml() {
return this.html() && this.viewBody(true);
}
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() {
const cl = this.body?.classList;
cl && cl.toggle('swapColors');
initView() {
// init BlockquoteSwitcher
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
*/
popupMessage(print) {
const
timeStampInUTC = this.dateTimestamp() || 0,
ccLine = this.cc.toString(),
bccLine = this.bcc.toString(),
m = 0 < timeStampInUTC ? new Date(timeStampInUTC * 1000) : null,
win = open('', 'sm-msg-'+this.requestHash
,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);
popupMessage() {
this.viewPopupMessage();
}
printMessage() {
this.popupMessage(true);
this.viewPopupMessage(true);
}
/**
* @returns {MessageModel}
*//*
clone() {
let self = new MessageModel();
// 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;
}*/
* @returns {string}
*/
generateUid() {
return this.folder + '/' + this.uid;
}
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;
if (body && this.hasImages()) {
if (regex) {
regex = [];
SettingsUserStore.viewImagesWhitelist().trim().split(/[\s\r\n,;]+/g).forEach(rule => {
rule = rule.split('+');
rule[0] = rule[0].trim();
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');
this.hasImages(false);
body.rlHasImages = false;
let attr = 'data-x-src',
src, useProxy = !!SettingsGet('UseLocalProxyForExternalImages');
body.querySelectorAll('img[' + attr + ']').forEach(node => {
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 => {
JSON.parse(node.dataset.xStyleUrl).forEach(data => {
if (isValid(data[1])) {
node.style[data[0]] = "url('" + (useProxy ? proxy(data[1]) : data[1]) + "')"
}
});
JSON.parse(node.dataset.xStyleUrl).forEach(data =>
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(
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());
}
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 { MessageModel } from 'Model/Message';
import { MessageUserStore } from 'Stores/User/Message';
import {
MessageFlagsCache
} from 'Common/Cache';
'use strict';
@ -9,16 +12,17 @@ export class MessageCollectionModel extends AbstractCollectionModel
/*
constructor() {
super();
this.filtered
this.folder
this.Filtered
this.Folder
this.folderHash
this.folderInfo
this.totalEmails
this.totalThreads
this.unreadEmails
this.threadUid
this.newMessages
this.offset
this.limit
this.search
this.limited
}
*/
@ -26,17 +30,14 @@ export class MessageCollectionModel extends AbstractCollectionModel
* @param {?Object} json
* @returns {MessageCollectionModel}
*/
static reviveFromJson(object/*, cached*/) {
let msg = MessageUserStore.message();
static reviveFromJson(object, cached) {
return super.reviveFromJson(object, message => {
// If message is currently viewed, use that.
if (msg && msg.hash === message.hash) {
msg.revivePropertiesFromJson(message);
message = msg;
} else {
message = MessageModel.reviveFromJson(message);
message = MessageModel.reviveFromJson(message);
if (message) {
message.deleted(false);
cached ? MessageFlagsCache.initMessage(message) : MessageFlagsCache.store(message);
return 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 { serverRequest } from 'Common/Links';
import { getNotification } from 'Common/Translator';
@ -8,18 +8,19 @@ let iJsonErrorCount = 0;
const getURL = (add = '') => serverRequest('Json') + pString(add),
checkResponseError = data => {
const err = data ? data.code : null;
if (Notifications.InvalidToken === err) {
console.error(getNotification(err) + ` (${data.messageAdditional})`);
const err = data ? data.ErrorCode : null;
if (Notification.InvalidToken === err) {
console.error(getNotification(err));
// alert(getNotification(err));
setTimeout(rl.logoutReload, 5000);
rl.logoutReload();
} else if ([
Notifications.AuthError,
Notifications.ConnectionError,
Notifications.DomainNotAllowed,
Notifications.AccountNotAllowed,
Notifications.MailServerError,
Notifications.UnknownError
Notification.AuthError,
Notification.ConnectionError,
Notification.DomainNotAllowed,
Notification.AccountNotAllowed,
Notification.MailServerError,
Notification.UnknownNotification,
Notification.UnknownError
].includes(err)
) {
if (7 < ++iJsonErrorCount) {
@ -31,11 +32,10 @@ checkResponseError = data => {
oRequests = {},
abort = (sAction, sReason, bClearOnly) => {
let controller = oRequests[sAction];
oRequests[sAction] = null;
if (controller) {
clearTimeout(controller.timeoutId);
bClearOnly || controller.abort(new DOMException(sAction, sReason || 'AbortError'));
if (oRequests[sAction]) {
bClearOnly || oRequests[sAction].abort(sReason || 'AbortError');
oRequests[sAction] = null;
delete oRequests[sAction];
}
},
@ -48,18 +48,15 @@ fetchJSON = (action, sUrl, params, timeout, jsonCallback) => {
}
}
// Don't abort, read https://github.com/the-djmaze/snappymail/issues/487
// abort(action, 0, 1);
// abort(action);
const controller = new AbortController(),
signal = controller.signal;
oRequests[action] = controller;
// Currently there is no way to combine multiple signals, so AbortSignal.timeout() not possible
controller.timeoutId = timeout && setTimeout(() => abort(action, 'TimeoutError'), timeout);
return rl.fetchJSON(sUrl, {signal: signal}, params).then(data => {
abort(action, 0, 1);
return jsonCallback ? jsonCallback(data) : Promise.resolve(data);
}).catch(err => {
clearTimeout(controller.timeoutId);
timeout && setTimeout(() => abort(action, 'TimeoutError'), timeout);
return rl.fetchJSON(sUrl, {signal: signal}, params).then(jsonCallback).catch(err => {
err.aborted = signal.aborted;
err.reason = signal.reason;
return Promise.reject(err);
});
};
@ -68,14 +65,14 @@ class FetchError extends Error
{
constructor(code, message) {
super(message);
this.code = code || Notifications.JsonFalse;
this.code = code || Notification.JsonFalse;
}
}
export class AbstractFetchRemote
{
abort(sAction, sReason) {
abort(sAction, sReason);
abort(sAction) {
abort(sAction);
return this;
}
@ -131,10 +128,19 @@ export class AbstractFetchRemote
fetchJSON(sAction, getURL(sGetAdd),
sGetAdd ? null : (params || {}),
pInt(iTimeout ?? 30000),
async data => {
undefined === iTimeout ? 30000 : pInt(iTimeout),
data => {
let cached = false;
if (data?.Time) {
cached = pInt(data.Time) > Date.now() - start;
}
let iError = 0;
if (data) {
if (sAction && oRequests[sAction]) {
abort(sAction, 0, 1);
}
if (!iError && data) {
/*
if (sAction !== data.Action) {
console.log(sAction + ' !== ' + data.Action);
@ -144,35 +150,35 @@ export class AbstractFetchRemote
iJsonErrorCount = 0;
} else {
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(
iError,
data,
/**
* Responses like "304 Not Modified" are returned as "200 OK"
* This is an attempt to detect if the request comes from cache.
* But when client has wrong date/time, it will fail.
*/
data?.epoch && data.epoch < Math.floor(start / 1000) - 60
cached,
sAction,
params
);
}
)
.catch(err => {
console.error({fetchError:err});
fCallback && fCallback(
'TimeoutError' == err.name ? 3 : (err.name == 'AbortError' ? 2 : 1),
'TimeoutError' == err.reason ? 3 : (err.name == 'AbortError' ? 2 : 1),
err
);
});
}
/**
* @param {?Function} fCallback
*/
getPublicKey(fCallback) {
this.request('GetPublicKey', fCallback);
}
setTrigger(trigger, value) {
if (trigger) {
value = !!value;
@ -189,20 +195,16 @@ export class AbstractFetchRemote
post(action, fTrigger, params, timeOut) {
this.setTrigger(fTrigger, true);
return fetchJSON(action, getURL(), params || {}, pInt(timeOut, 30000),
async data => {
data => {
abort(action, 0, 1);
if (!data) {
return Promise.reject(new FetchError(Notifications.JsonParse));
}
if (111 === data?.code && rl.app.ask && await rl.app.ask.cryptkey()) {
return this.post(action, fTrigger, params, timeOut);
return Promise.reject(new FetchError(Notification.JsonParse));
}
/*
let isCached = false, type = '';
if (data?.epoch) {
isCached = data.epoch > microtime() - start;
if (data?.Time) {
isCached = pInt(data.Time) > microtime() - start;
}
// backward capability
switch (true) {
@ -222,8 +224,8 @@ export class AbstractFetchRemote
if (!data.Result || action !== data.Action) {
checkResponseError(data);
return Promise.reject(new FetchError(
data ? data.code : 0,
data ? (data.messageAdditional || data.message) : ''
data ? data.ErrorCode : 0,
data ? (data.ErrorMessageAdditional || data.ErrorMessage) : ''
));
}

View file

@ -9,11 +9,57 @@ import { SUB_QUERY_PREFIX } from 'Common/Links';
import { AppUserStore } from 'Stores/User/App';
import { SettingsUserStore } from 'Stores/User/Settings';
import { FolderUserStore } from 'Stores/User/Folder';
import { AbstractFetchRemote } from 'Remote/AbstractFetch';
class RemoteUserFetch extends AbstractFetchRemote {
/**
* @param {Function} fCallback
* @param {object} params
* @param {boolean=} bSilent = false
*/
messageList(fCallback, params, bSilent = false) {
const
sFolderFullName = pString(params.Folder),
folder = getFolderFromCacheList(sFolderFullName),
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(),
Hash: folderHash + SettingsGet('AccountHash')
}, params);
params.Folder = sFolderFullName;
if (AppUserStore.threadsAllowed() && SettingsUserStore.useThreads()) {
params.useThreads = 1;
} else {
params.threadUid = 0;
}
let sGetAdd = '';
if (folderHash && (!params.search || !params.search.includes('is:'))) {
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 {string} sFolderFullName
@ -36,7 +82,7 @@ class RemoteUserFetch extends AbstractFetchRemote {
sFolderFullName,
iUid,
AppUserStore.threadsAllowed() && SettingsUserStore.useThreads() ? 1 : 0,
SettingsGet('accountHash')
SettingsGet('AccountHash')
])
);
@ -64,6 +110,16 @@ class RemoteUserFetch extends AbstractFetchRemote {
[key]: value
});
}
/*
folderMove(sPrevFolderFullName, sNewFolderFullName, bSubscribe) {
return this.post('FolderMove', FolderUserStore.foldersRenaming, {
Folder: sPrevFolderFullName,
NewFolder: sNewFolderFullName,
Subscribe: bSubscribe ? 1 : 0
});
}
*/
}
export default new RemoteUserFetch();

View file

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

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