Compare commits
No commits in common. "master" and "v2.24.4" have entirely different histories.
|
|
@ -1,192 +1,51 @@
|
||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
FROM alpine:3.24.3 AS builder
|
|
||||||
RUN apk add --no-cache php85 php85-json php-phar php-zip
|
|
||||||
RUN apk add --no-cache npm
|
|
||||||
RUN npm install -g gulp yarn
|
|
||||||
WORKDIR /source
|
|
||||||
COPY package.json yarn.lock ./
|
|
||||||
RUN yarn install
|
|
||||||
COPY . .
|
|
||||||
# Patch release.php with hotfix from: https://github.com/xgbstar1/snappymail-docker/blob/main/Dockerfile, so that release.php doesn't fail with error
|
|
||||||
RUN sed -i 's_^if.*rename.*snappymail.v.0.0.0.*$_if (!!system("mv snappymail/v/0.0.0 snappymail/v/{$package->version}")) {_' cli/release.php || true
|
|
||||||
RUN php release.php
|
|
||||||
RUN set -eux; \
|
|
||||||
VERSION=$( ls build/dist/releases/webmail ); \
|
|
||||||
ls -al build/dist/releases/webmail/$VERSION/snappymail-$VERSION.tar.gz; \
|
|
||||||
mkdir -p /snappymail; \
|
|
||||||
tar -zxvf build/dist/releases/webmail/$VERSION/snappymail-$VERSION.tar.gz -C /snappymail; \
|
|
||||||
find /snappymail -type d -exec chmod 550 {} \; ; \
|
|
||||||
find /snappymail -type f -exec chmod 440 {} \; ; \
|
|
||||||
find /snappymail/data -type d -exec chmod 750 {} \; ; \
|
|
||||||
# Remove unneeded files
|
|
||||||
rm -v /snappymail/README.md /snappymail/_include.php
|
|
||||||
|
|
||||||
# Inspired by the original Rainloop dockerfile from youtous on GitLab
|
# Inspired by the original Rainloop dockerfile from youtous on GitLab
|
||||||
FROM php:8.5-fpm-alpine AS final
|
FROM php:8.1-fpm-bullseye
|
||||||
|
|
||||||
LABEL org.label-schema.description="SnappyMail webmail client image using nginx, php-fpm on Alpine"
|
ARG FILES_ZIP
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
LABEL org.label-schema.description="SnappyMail webmail client image using nginx, php-fpm based on Debian Buster"
|
||||||
|
|
||||||
|
ENV UID=991 GID=991 UPLOAD_MAX_SIZE=25M LOG_TO_STDERR=true MEMORY_LIMIT=128M SECURE_COOKIES=true
|
||||||
|
ENV fpm.pool.clear_env=false
|
||||||
|
|
||||||
# Install dependencies such as nginx
|
# Install dependencies such as nginx
|
||||||
RUN apk add --no-cache ca-certificates nginx supervisor bash
|
RUN mkdir -p /usr/share/man/man1/ /usr/share/man/man3/ /usr/share/man/man7/ && \
|
||||||
|
apt-get update -q --fix-missing && \
|
||||||
|
apt-get -y upgrade && \
|
||||||
|
apt-get install --no-install-recommends -y \
|
||||||
|
apt-transport-https gnupg openssl wget curl ca-certificates nginx supervisor sudo \
|
||||||
|
unzip libzip-dev libxml2-dev libldb-dev libldap2-dev \
|
||||||
|
sqlite3 libsqlite3-dev libsqlite3-0 libpq-dev postgresql-client mariadb-client logrotate \
|
||||||
|
zip mlocate libpcre3-dev libicu-dev \
|
||||||
|
build-essential chrpath libssl-dev \
|
||||||
|
libxft-dev libfreetype6 libfreetype6-dev \
|
||||||
|
libpng-dev libjpeg62-turbo-dev \
|
||||||
|
libfontconfig1 libfontconfig1-dev \
|
||||||
|
&& \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install PHP extensions
|
# Install PHP extensions
|
||||||
# apcu
|
RUN php -m && \
|
||||||
RUN set -eux; \
|
docker-php-ext-configure ldap --with-libdir=lib/$(uname -m)-linux-gnu/ && \
|
||||||
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
docker-php-ext-configure intl && \
|
||||||
pecl install apcu; \
|
docker-php-ext-configure gd --with-freetype --with-jpeg && \
|
||||||
docker-php-ext-enable apcu; \
|
docker-php-ext-install ldap opcache pdo_mysql pdo_pgsql zip intl gd && \
|
||||||
docker-php-source delete; \
|
php -m
|
||||||
apk del .build-dependencies;
|
|
||||||
|
|
||||||
# gd
|
|
||||||
RUN set -eux; \
|
|
||||||
apk add --no-cache freetype libjpeg-turbo libpng; \
|
|
||||||
apk add --no-cache --virtual .deps freetype-dev libjpeg-turbo-dev libpng-dev; \
|
|
||||||
docker-php-ext-configure gd --with-freetype --with-jpeg; \
|
|
||||||
docker-php-ext-install gd; \
|
|
||||||
apk del .deps
|
|
||||||
|
|
||||||
# gmagick
|
|
||||||
# RUN set -eux; \
|
|
||||||
# apk add --no-cache graphicsmagick libgomp; \
|
|
||||||
# apk add --no-cache --virtual .deps graphicsmagick-dev libtool; \
|
|
||||||
# apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
|
||||||
# pecl install gmagick-2.0.6RC1; \
|
|
||||||
# docker-php-ext-enable gmagick; \
|
|
||||||
# docker-php-source delete; \
|
|
||||||
# apk del .build-dependencies; \
|
|
||||||
# apk del .deps
|
|
||||||
|
|
||||||
# gnupg
|
|
||||||
RUN set -eux; \
|
|
||||||
apk add --no-cache gnupg gpgme; \
|
|
||||||
apk add --no-cache --virtual .deps gpgme-dev; \
|
|
||||||
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
|
||||||
pecl install gnupg; \
|
|
||||||
docker-php-ext-enable gnupg; \
|
|
||||||
docker-php-source delete; \
|
|
||||||
apk del .build-dependencies; \
|
|
||||||
apk del .deps
|
|
||||||
|
|
||||||
# imagick
|
|
||||||
RUN set -eux; \
|
|
||||||
apk add --no-cache imagemagick libgomp; \
|
|
||||||
apk add --no-cache --virtual .deps imagemagick-dev; \
|
|
||||||
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
|
||||||
echo | pecl install imagick; \
|
|
||||||
docker-php-ext-enable imagick; \
|
|
||||||
docker-php-source delete; \
|
|
||||||
apk del .build-dependencies; \
|
|
||||||
apk del .deps
|
|
||||||
|
|
||||||
# intl
|
|
||||||
RUN set -eux; \
|
|
||||||
apk add --no-cache icu-libs; \
|
|
||||||
apk add --no-cache --virtual .deps icu-dev; \
|
|
||||||
docker-php-ext-configure intl; \
|
|
||||||
docker-php-ext-install intl; \
|
|
||||||
apk del .deps
|
|
||||||
|
|
||||||
# ldap
|
|
||||||
RUN set -eux; \
|
|
||||||
apk add --no-cache libldap; \
|
|
||||||
apk add --no-cache --virtual .deps openldap-dev; \
|
|
||||||
docker-php-ext-configure ldap; \
|
|
||||||
docker-php-ext-install ldap; \
|
|
||||||
apk del .deps
|
|
||||||
|
|
||||||
# mysql
|
|
||||||
RUN docker-php-ext-install pdo_mysql
|
|
||||||
|
|
||||||
# postgres
|
|
||||||
RUN set -eux; \
|
|
||||||
apk add --no-cache postgresql-libs; \
|
|
||||||
apk add --no-cache --virtual .deps postgresql-dev; \
|
|
||||||
docker-php-ext-install pdo_pgsql; \
|
|
||||||
apk del .deps
|
|
||||||
|
|
||||||
# redis
|
|
||||||
RUN set -eux; \
|
|
||||||
apk add --no-cache liblzf zstd-libs; \
|
|
||||||
apk add --no-cache --virtual .deps zstd-dev; \
|
|
||||||
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
|
||||||
pecl install igbinary; \
|
|
||||||
docker-php-ext-enable igbinary; \
|
|
||||||
pecl install --configureoptions 'enable-redis-igbinary="yes" enable-redis-lzf="yes" enable-redis-zstd="yes"' redis; \
|
|
||||||
docker-php-ext-enable redis; \
|
|
||||||
docker-php-source delete; \
|
|
||||||
apk del .build-dependencies; \
|
|
||||||
apk del .deps
|
|
||||||
|
|
||||||
# sqlite
|
|
||||||
RUN set -eux; \
|
|
||||||
apk add --no-cache sqlite-libs; \
|
|
||||||
apk add --no-cache --virtual .deps sqlite-dev; \
|
|
||||||
docker-php-ext-install pdo_sqlite; \
|
|
||||||
apk del .deps
|
|
||||||
|
|
||||||
# tidy
|
|
||||||
RUN set -eux; \
|
|
||||||
apk add --no-cache tidyhtml; \
|
|
||||||
apk add --no-cache --virtual .deps tidyhtml-dev; \
|
|
||||||
docker-php-ext-install tidy; \
|
|
||||||
apk del .deps
|
|
||||||
|
|
||||||
# uuid
|
|
||||||
RUN set -eux; \
|
|
||||||
apk add --no-cache libuuid; \
|
|
||||||
apk add --no-cache --virtual .deps util-linux-dev; \
|
|
||||||
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
|
||||||
pecl install uuid; \
|
|
||||||
docker-php-ext-enable uuid; \
|
|
||||||
docker-php-source delete; \
|
|
||||||
apk del .build-dependencies; \
|
|
||||||
apk del .deps
|
|
||||||
|
|
||||||
# xxtea - Manually install php8 compatible version from https://github.com/xxtea/xxtea-pecl master branch
|
|
||||||
RUN set -eux; \
|
|
||||||
apk add --no-cache --virtual .build-dependencies $PHPIZE_DEPS; \
|
|
||||||
wget -q https://github.com/xxtea/xxtea-pecl/tarball/3f5888a29045e12301254151737c5dab4523a1c1 -O xxtea.tar; \
|
|
||||||
echo '9cbfd9c27255767deb26ddedf69e738d401d88ac9762d82c8510f9768842ca18 xxtea.tar' | sha256sum -c -; \
|
|
||||||
tar -C /usr/src -xvf xxtea.tar; \
|
|
||||||
cd /usr/src/xxtea-xxtea-pecl-3f5888a; \
|
|
||||||
phpize; \
|
|
||||||
./configure --with-php-config=/usr/local/bin/php-config --enable-xxtea=yes; \
|
|
||||||
make install; \
|
|
||||||
docker-php-ext-enable xxtea; \
|
|
||||||
cd -; \
|
|
||||||
rm -fv xxtea.tar; \
|
|
||||||
rm -rfv /usr/src/xxtea*; \
|
|
||||||
apk del .build-dependencies;
|
|
||||||
|
|
||||||
# zip
|
|
||||||
RUN set -eux; \
|
|
||||||
apk add --no-cache libzip; \
|
|
||||||
apk add --no-cache --virtual .deps libzip-dev; \
|
|
||||||
docker-php-ext-install zip; \
|
|
||||||
apk del .deps
|
|
||||||
|
|
||||||
# Install snappymail
|
# Install snappymail
|
||||||
# The 'www-data' user/group in alpine is 82:82. The 'nginx' user/group in alpine is 101:101, and is part of www-data group
|
WORKDIR /tmp
|
||||||
COPY --chown=www-data:www-data --from=builder /snappymail /snappymail
|
COPY ${FILES_ZIP} .
|
||||||
# Use a custom snappymail data folder
|
RUN mkdir /snappymail && \
|
||||||
RUN mv -v /snappymail/data /var/lib/snappymail;
|
unzip -q ${FILES_ZIP} -d /snappymail && \
|
||||||
# Setup configs
|
find /snappymail -type d -exec chmod 755 {} \; && \
|
||||||
COPY --chown=root:root .docker/release/files /
|
find /snappymail -type f -exec chmod 644 {} \; && \
|
||||||
RUN set -eux; \
|
rm -rf ${FILES_ZIP}
|
||||||
chown www-data:www-data /snappymail/include.php; \
|
|
||||||
chmod 440 /snappymail/include.php; \
|
|
||||||
chmod +x /entrypoint.sh; \
|
|
||||||
# Disable the built-in php-fpm configs, since we're using our own config
|
|
||||||
mv -v /usr/local/etc/php-fpm.d/docker.conf /usr/local/etc/php-fpm.d/docker.conf.disabled; \
|
|
||||||
mv -v /usr/local/etc/php-fpm.d/www.conf /usr/local/etc/php-fpm.d/www.conf.disabled; \
|
|
||||||
mv -v /usr/local/etc/php-fpm.d/zz-docker.conf /usr/local/etc/php-fpm.d/zz-docker.conf.disabled;
|
|
||||||
|
|
||||||
USER root
|
# Install other content
|
||||||
WORKDIR /snappymail
|
COPY files /
|
||||||
VOLUME /var/lib/snappymail
|
RUN chmod +x /entrypoint.sh && chmod +x /logrotate-loop.sh
|
||||||
|
VOLUME /snappymail/data
|
||||||
EXPOSE 8888
|
EXPOSE 8888
|
||||||
EXPOSE 9000
|
|
||||||
ENTRYPOINT []
|
|
||||||
CMD ["/entrypoint.sh"]
|
CMD ["/entrypoint.sh"]
|
||||||
|
|
|
||||||
96
.docker/release/files/entrypoint.sh
Executable file → Normal file
|
|
@ -1,26 +1,23 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
|
||||||
|
|
||||||
DEBUG=${DEBUG:-}
|
# Create not root user
|
||||||
if [ "$DEBUG" = 'true' ]; then
|
groupadd --gid "$GID" php-cli -f
|
||||||
set -x
|
adduser --uid "$UID" --disabled-password --gid "$GID" --shell /bin/bash --home /home/php-cli php-cli --force --gecos ""
|
||||||
fi
|
|
||||||
UPLOAD_MAX_SIZE=${UPLOAD_MAX_SIZE:-25M}
|
|
||||||
MEMORY_LIMIT=${MEMORY_LIMIT:-128M}
|
|
||||||
SECURE_COOKIES=${SECURE_COOKIES:-true}
|
|
||||||
|
|
||||||
# Disable listening on ipv6 for ipv4-only environments, or nginx will fail to start
|
|
||||||
if ! ip a | grep 'inet6 ' > /dev/null; then
|
|
||||||
echo "[INFO] ipv6 is disabled. Configuring nginx to not listen on ipv6"
|
|
||||||
sed -i '/listen \[::\]:/d' /etc/nginx/nginx.conf
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Set attachment size limit
|
# Set attachment size limit
|
||||||
sed -i "s/<UPLOAD_MAX_SIZE>/$UPLOAD_MAX_SIZE/g" /usr/local/etc/php-fpm.d/php-fpm.conf /etc/nginx/nginx.conf
|
sed -i "s/<UPLOAD_MAX_SIZE>/$UPLOAD_MAX_SIZE/g" /usr/local/etc/php-fpm.d/php-fpm.conf /etc/nginx/nginx.conf
|
||||||
sed -i "s/<MEMORY_LIMIT>/$MEMORY_LIMIT/g" /usr/local/etc/php-fpm.d/php-fpm.conf
|
sed -i "s/<MEMORY_LIMIT>/$MEMORY_LIMIT/g" /usr/local/etc/php-fpm.d/php-fpm.conf
|
||||||
|
|
||||||
|
# Set log output to STDERR if wanted (LOG_TO_STDERR=true)
|
||||||
|
if [ "$LOG_TO_STDERR" = true ]; then
|
||||||
|
echo "[INFO] Logging to stderr activated"
|
||||||
|
sed -i "s/.*error_log.*$/error_log \/dev\/stderr warn;/" /etc/nginx/nginx.conf
|
||||||
|
sed -i "s/.*error_log.*$/php_admin_value[error_log] = \/dev\/stderr/" /usr/local/etc/php-fpm.d/php-fpm.conf
|
||||||
|
fi
|
||||||
|
|
||||||
# Secure cookies
|
# Secure cookies
|
||||||
if [ "${SECURE_COOKIES}" = 'true' ]; then
|
if [ "${SECURE_COOKIES}" = true ]; then
|
||||||
echo "[INFO] Secure cookies activated"
|
echo "[INFO] Secure cookies activated"
|
||||||
{
|
{
|
||||||
echo 'session.cookie_httponly = On';
|
echo 'session.cookie_httponly = On';
|
||||||
|
|
@ -29,58 +26,43 @@ if [ "${SECURE_COOKIES}" = 'true' ]; then
|
||||||
} > /usr/local/etc/php/conf.d/cookies.ini;
|
} > /usr/local/etc/php/conf.d/cookies.ini;
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[INFO] Snappymail version: $( ls /snappymail/snappymail/v )"
|
# Copy snappymail default config if absent
|
||||||
|
SNAPPYMAIL_CONFIG_FILE=/snappymail/data/_data_/_default_/configs/application.ini
|
||||||
# Set permissions on snappymail data
|
|
||||||
echo "[INFO] Setting permissions on /var/lib/snappymail"
|
|
||||||
chown -R www-data:www-data /var/lib/snappymail/
|
|
||||||
chmod 550 /var/lib/snappymail/
|
|
||||||
find /var/lib/snappymail/ -type d -exec chmod 750 {} \;
|
|
||||||
|
|
||||||
# Create snappymail default config if absent
|
|
||||||
SNAPPYMAIL_CONFIG_FILE=/var/lib/snappymail/_data_/_default_/configs/application.ini
|
|
||||||
if [ ! -f "$SNAPPYMAIL_CONFIG_FILE" ]; then
|
if [ ! -f "$SNAPPYMAIL_CONFIG_FILE" ]; then
|
||||||
echo "[INFO] Creating default Snappymail configuration: $SNAPPYMAIL_CONFIG_FILE"
|
echo "[INFO] Creating default Snappymail configuration"
|
||||||
# Run snappymail and exit. This populates the snappymail data directory and generates the config file
|
mkdir -p $(dirname $SNAPPYMAIL_CONFIG_FILE)
|
||||||
# On error, print php exception and exit
|
cp /usr/local/include/application.ini $SNAPPYMAIL_CONFIG_FILE
|
||||||
EXITCODE=
|
|
||||||
su - www-data -s /bin/sh -c 'php /snappymail/index.php' > /tmp/out || EXITCODE=$?
|
|
||||||
if [ -n "$EXITCODE" ]; then
|
|
||||||
cat /tmp/out
|
|
||||||
exit "$EXITCODE"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[INFO] Overriding values in snappymail configuration: $SNAPPYMAIL_CONFIG_FILE"
|
|
||||||
# Enable output of snappymail logs
|
# Enable output of snappymail logs
|
||||||
sed '/^\; Enable logging/{
|
if [ "${LOG_TO_STDERR}" = true ]; then
|
||||||
N
|
sed -z 's/\; Enable logging\nenable = Off/\; Enable logging\nenable = On/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||||
s/enable = Off/enable = On/
|
sed 's/^filename = .*/filename = "errors.log"/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||||
}' -i $SNAPPYMAIL_CONFIG_FILE
|
sed 's/^write_on_error_only = .*/write_on_error_only = Off/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||||
# Redirect snappymail logs to stderr /stdout
|
sed 's/^write_on_php_error_only = .*/write_on_php_error_only = On/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||||
sed 's/^filename = .*/filename = "stderr"/' -i $SNAPPYMAIL_CONFIG_FILE
|
else
|
||||||
sed 's/^write_on_error_only = .*/write_on_error_only = Off/' -i $SNAPPYMAIL_CONFIG_FILE
|
sed -z 's/\; Enable logging\nenable = On/\; Enable logging\nenable = Off/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||||
sed 's/^write_on_php_error_only = .*/write_on_php_error_only = On/' -i $SNAPPYMAIL_CONFIG_FILE
|
fi
|
||||||
# Always enable snappymail Auth logging
|
# Always enable snappymail Auth logging
|
||||||
sed 's/^auth_logging = .*/auth_logging = On/' -i $SNAPPYMAIL_CONFIG_FILE
|
sed 's/^auth_logging = .*/auth_logging = On/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||||
sed 's/^auth_logging_filename = .*/auth_logging_filename = "auth.log"/' -i $SNAPPYMAIL_CONFIG_FILE
|
sed 's/^auth_logging_filename = .*/auth_logging_filename = "auth.log"/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||||
sed 's/^auth_logging_format = .*/auth_logging_format = "[{date:Y-m-d H:i:s}] Auth failed: ip={request:ip} user={imap:login} host={imap:host} port={imap:port}"/' -i $SNAPPYMAIL_CONFIG_FILE
|
sed 's/^auth_logging_format = .*/auth_logging_format = "[{date:Y-m-d H:i:s}] Auth failed: ip={request:ip} user={imap:login} host={imap:host} port={imap:port}"/' -i $SNAPPYMAIL_CONFIG_FILE
|
||||||
sed 's/^auth_syslog = .*/auth_syslog = Off/' -i $SNAPPYMAIL_CONFIG_FILE
|
# Redirect snappymail logs to stderr /stdout
|
||||||
|
mkdir -p /snappymail/data/_data_/_default_/logs/
|
||||||
|
# empty logs
|
||||||
|
cp /dev/null /snappymail/data/_data_/_default_/logs/errors.log
|
||||||
|
cp /dev/null /snappymail/data/_data_/_default_/logs/auth.log
|
||||||
|
chown -R php-cli:php-cli /snappymail/data/
|
||||||
|
|
||||||
(
|
# Fix permissions
|
||||||
while ! nc -vz -w 1 127.0.0.1 8888 > /dev/null 2>&1; do echo "[INFO] Checking whether nginx is alive"; sleep 1; done
|
chown -R $UID:$GID /snappymail/data /var/log /var/lib/nginx
|
||||||
while ! nc -vz -w 1 127.0.0.1 9000 > /dev/null 2>&1; do echo "[INFO] Checking whether php-fpm is alive"; sleep 1; done
|
chmod o+w /dev/stdout
|
||||||
# Create snappymail admin password if absent
|
chmod o+w /dev/stderr
|
||||||
SNAPPYMAIL_ADMIN_PASSWORD_FILE=/var/lib/snappymail/_data_/_default_/admin_password.txt
|
|
||||||
if [ ! -f "$SNAPPYMAIL_ADMIN_PASSWORD_FILE" ]; then
|
|
||||||
echo "[INFO] Creating Snappymail admin password file: $SNAPPYMAIL_ADMIN_PASSWORD_FILE"
|
|
||||||
wget -T 1 -qO- 'http://127.0.0.1:8888/?/AdminAppData/0/12345/' > /dev/null
|
|
||||||
echo "[INFO] Snappymail Admin Panel ready at http://localhost:8888/?admin. Login using password in $SNAPPYMAIL_ADMIN_PASSWORD_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
wget -T 1 -qO- 'http://127.0.0.1:8888/' > /dev/null
|
|
||||||
echo "[INFO] Snappymail ready at http://localhost:8888/"
|
# Touch supervisord PID file in order to fix permissions
|
||||||
) &
|
touch /run/supervisord.pid
|
||||||
|
chown php-cli:php-cli /run/supervisord.pid
|
||||||
|
|
||||||
# RUN !
|
# RUN !
|
||||||
exec /usr/bin/supervisord -c /supervisor.conf --pidfile /run/supervisord.pid
|
exec sudo -u php-cli -g php-cli /usr/bin/supervisord -c '/supervisor.conf' --pidfile '/run/supervisord.pid'
|
||||||
|
|
|
||||||
5
.docker/release/files/etc/logrotate.d/snappymail
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/snappymail/data/_data_/_default_/logs/* {
|
||||||
|
size 10M
|
||||||
|
rotate 0
|
||||||
|
missingok
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ http {
|
||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
|
|
||||||
access_log off;
|
access_log off;
|
||||||
error_log /dev/stderr error;
|
error_log /tmp/ngx_error.log error;
|
||||||
|
|
||||||
sendfile on;
|
sendfile on;
|
||||||
keepalive_timeout 15;
|
keepalive_timeout 15;
|
||||||
|
|
@ -52,8 +52,6 @@ http {
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 8888;
|
listen 8888;
|
||||||
listen [::]:8888;
|
|
||||||
#listen [::]:8888 ipv6only=off;
|
|
||||||
root /snappymail;
|
root /snappymail;
|
||||||
index index.php;
|
index index.php;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
@ -97,7 +95,7 @@ http {
|
||||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||||
fastcgi_param HTTP_PROXY "";
|
fastcgi_param HTTP_PROXY "";
|
||||||
fastcgi_index index.php;
|
fastcgi_index index.php;
|
||||||
fastcgi_pass 127.0.0.1:9000;
|
fastcgi_pass unix:/tmp/php-fpm.sock;
|
||||||
fastcgi_intercept_errors on;
|
fastcgi_intercept_errors on;
|
||||||
fastcgi_request_buffering off;
|
fastcgi_request_buffering off;
|
||||||
fastcgi_param REMOTE_ADDR $http_x_real_ip;
|
fastcgi_param REMOTE_ADDR $http_x_real_ip;
|
||||||
|
|
|
||||||
113
.docker/release/files/logrotate-loop.sh
Normal 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}
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<?php
|
|
||||||
define('APP_DATA_FOLDER_PATH', '/var/lib/snappymail/');
|
|
||||||
?>
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon=true
|
nodaemon=true
|
||||||
user=root
|
|
||||||
logfile=/dev/null
|
|
||||||
logfile_maxbytes=0
|
|
||||||
|
|
||||||
[program:nginx]
|
[program:nginx]
|
||||||
command=nginx -c /etc/nginx/nginx.conf -g 'daemon off;'
|
command=nginx -c /etc/nginx/nginx.conf -g 'daemon off;'
|
||||||
process_name=%(program_name)s_%(process_num)02d
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
user=root
|
user=php-cli
|
||||||
numprocs=1
|
numprocs=1
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=false
|
autorestart=false
|
||||||
|
|
@ -20,7 +17,7 @@ stderr_logfile_maxbytes=0
|
||||||
[program:php-fpm]
|
[program:php-fpm]
|
||||||
command=php-fpm -F
|
command=php-fpm -F
|
||||||
process_name=%(program_name)s_%(process_num)02d
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
user=root
|
user=php-cli
|
||||||
numprocs=1
|
numprocs=1
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=false
|
autorestart=false
|
||||||
|
|
@ -30,10 +27,33 @@ stdout_logfile_maxbytes=0
|
||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; reads snappymail logs
|
||||||
|
[program:snappymail-auth]
|
||||||
|
command=tail -f /snappymail/data/_data_/_default_/logs/auth.log
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:snappymail-errors]
|
||||||
|
command=tail -f /snappymail/data/_data_/_default_/logs/errors.log
|
||||||
|
# everything is an error
|
||||||
|
stdout_logfile=/dev/stderr
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
redirect_stderr=true
|
||||||
|
|
||||||
|
[program:logrotate]
|
||||||
|
command=/logrotate-loop.sh
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
[eventlistener:subprocess-stopped]
|
[eventlistener:subprocess-stopped]
|
||||||
command=php /listener.php
|
command=php /listener.php
|
||||||
process_name=%(program_name)s_%(process_num)02d
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
user=root
|
user=php-cli
|
||||||
numprocs=1
|
numprocs=1
|
||||||
events=PROCESS_STATE_EXITED,PROCESS_STATE_STOPPED,PROCESS_STATE_FATAL
|
events=PROCESS_STATE_EXITED,PROCESS_STATE_STOPPED,PROCESS_STATE_FATAL
|
||||||
autostart=true
|
autostart=true
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,15 @@
|
||||||
[global]
|
[global]
|
||||||
daemonize = no
|
daemonize = no
|
||||||
error_log = /dev/stderr
|
|
||||||
log_buffering = no
|
|
||||||
|
|
||||||
[default]
|
[default]
|
||||||
listen = 9000
|
listen = /tmp/php-fpm.sock
|
||||||
user = www-data
|
|
||||||
listen.owner = www-data
|
|
||||||
listen.group = www-data
|
|
||||||
pm = ondemand
|
pm = ondemand
|
||||||
pm.max_children = 30
|
pm.max_children = 30
|
||||||
pm.process_idle_timeout = 10s
|
pm.process_idle_timeout = 10s
|
||||||
pm.max_requests = 500
|
pm.max_requests = 500
|
||||||
catch_workers_output = yes
|
catch_workers_output = yes
|
||||||
decorate_workers_output = no
|
|
||||||
chdir = /
|
chdir = /
|
||||||
pm.status_path = /status
|
php_admin_value[error_log] = /tmp/php_error.log
|
||||||
php_admin_value[log_errors] = On
|
php_admin_value[log_errors] = On
|
||||||
php_admin_value[expose_php] = Off
|
php_admin_value[expose_php] = Off
|
||||||
php_admin_value[display_errors] = Off
|
php_admin_value[display_errors] = Off
|
||||||
|
|
|
||||||
309
.docker/release/files/usr/local/include/application.ini
Normal 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"
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
/.git
|
|
||||||
/node_modules
|
|
||||||
11
.eslintrc.js
|
|
@ -3,7 +3,7 @@ module.exports = {
|
||||||
// extends: ['eslint:recommended', 'plugin:prettier/recommended'],
|
// extends: ['eslint:recommended', 'plugin:prettier/recommended'],
|
||||||
extends: ['eslint:recommended'],
|
extends: ['eslint:recommended'],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 11,
|
ecmaVersion: 6,
|
||||||
sourceType: 'module'
|
sourceType: 'module'
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
|
|
@ -35,17 +35,10 @@ module.exports = {
|
||||||
// vendors/bootstrap/bootstrap.native.js
|
// vendors/bootstrap/bootstrap.native.js
|
||||||
'BSN': "readonly",
|
'BSN': "readonly",
|
||||||
// Mailvelope
|
// Mailvelope
|
||||||
'mailvelope': "readonly",
|
'mailvelope': "readonly"
|
||||||
// Punycode
|
|
||||||
'IDN': "readonly",
|
|
||||||
// Turndown
|
|
||||||
'TurndownService': "readonly",
|
|
||||||
// Marked
|
|
||||||
'marked': "readonly"
|
|
||||||
},
|
},
|
||||||
// http://eslint.org/docs/rules/
|
// http://eslint.org/docs/rules/
|
||||||
rules: {
|
rules: {
|
||||||
'no-cond-assign': 0,
|
|
||||||
// plugins
|
// plugins
|
||||||
'no-mixed-spaces-and-tabs': 'off',
|
'no-mixed-spaces-and-tabs': 'off',
|
||||||
'max-len': [
|
'max-len': [
|
||||||
|
|
|
||||||
2
.github/FUNDING.yml
vendored
|
|
@ -1,2 +1,2 @@
|
||||||
github: the-djmaze
|
community_bridge: SnappyMail
|
||||||
custom: ["https://www.paypal.me/thedjmaze", "https://snappymail.eu"]
|
custom: ["https://www.paypal.me/thedjmaze", "https://snappymail.eu"]
|
||||||
|
|
|
||||||
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -28,11 +28,9 @@ If applicable, add screenshots to help explain your problem.
|
||||||
- IMAP daemon: [e.g. courier, dovecot]
|
- IMAP daemon: [e.g. courier, dovecot]
|
||||||
- PHP version:
|
- PHP version:
|
||||||
- SnappyMail Version:
|
- SnappyMail Version:
|
||||||
- Mode: [e.g. standalone, nextcloud, cyberpanel, docker]
|
|
||||||
|
|
||||||
**Debug/logging information**
|
**[Debug/logging information](https://github.com/the-djmaze/snappymail/wiki/FAQ#how-do-i-enable-logging)**
|
||||||
[Read here how to log](https://github.com/the-djmaze/snappymail/wiki/FAQ#how-do-i-enable-logging)
|
Place them here (few lines) or as attachments (many lines)
|
||||||
- [ ] I've placed them here (few lines) or as attachments (many lines)
|
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|
|
||||||
93
.github/workflows/docker-pr.yml
vendored
|
|
@ -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
|
|
||||||
117
.github/workflows/docker.yml
vendored
|
|
@ -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
|
|
@ -17,7 +17,6 @@
|
||||||
/data
|
/data
|
||||||
/MULTIPLY
|
/MULTIPLY
|
||||||
/include.php
|
/include.php
|
||||||
/issues
|
|
||||||
.idea/
|
.idea/
|
||||||
.env
|
.env
|
||||||
/test
|
/test
|
||||||
|
|
@ -25,8 +24,6 @@
|
||||||
/vendors/knockout/spec
|
/vendors/knockout/spec
|
||||||
/vendors/openpgp-5
|
/vendors/openpgp-5
|
||||||
!/vendors/openpgp-5/dist
|
!/vendors/openpgp-5/dist
|
||||||
/vendors/squire2
|
|
||||||
!/vendors/squire2/dist
|
|
||||||
/vendors/vanillaqr.js/
|
/vendors/vanillaqr.js/
|
||||||
/integrations/nextcloud/rainloop
|
/integrations/nextcloud/rainloop
|
||||||
/integrations/owncloud/rainloop
|
/integrations/owncloud/rainloop
|
||||||
|
|
|
||||||
4
.gitmodules
vendored
|
|
@ -1,7 +1,3 @@
|
||||||
[submodule "vendors/openpgp-5"]
|
[submodule "vendors/openpgp-5"]
|
||||||
path = vendors/openpgp-5
|
path = vendors/openpgp-5
|
||||||
url = git@github.com:the-djmaze/openpgpjs.git
|
url = git@github.com:the-djmaze/openpgpjs.git
|
||||||
[submodule "vendors/squire2"]
|
|
||||||
path = vendors/squire2
|
|
||||||
url = git@github.com:the-djmaze/Squire.git
|
|
||||||
branch = snappymail
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
<FilesMatch "index\.php">
|
|
||||||
AcceptPathInfo On
|
|
||||||
</FilesMatch>
|
|
||||||
|
|
||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
# Redirect cPanel
|
# Redirect cPanel
|
||||||
RewriteRule cpsess.* https://%{HTTP_HOST}/ [L,R=301]
|
RewriteRule cpsess.* https://%{HTTP_HOST}/ [L,R=301]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
|
||||||
RewriteRule ^(.+)$ index.php/$1 [L,QSA]
|
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
<IfModule mod_expires.c>
|
<IfModule mod_expires.c>
|
||||||
|
|
@ -34,6 +26,7 @@
|
||||||
# Header set Strict-Transport-Security "max-age=31536000"
|
# Header set Strict-Transport-Security "max-age=31536000"
|
||||||
Header set imagetoolbar "no"
|
Header set imagetoolbar "no"
|
||||||
# Header set X-Content-Type-Options "nosniff"
|
# Header set X-Content-Type-Options "nosniff"
|
||||||
|
# Header set X-Frame-Options "DENY"
|
||||||
# Header set X-XSS-Protection "1; mode=block"
|
# Header set X-XSS-Protection "1; mode=block"
|
||||||
Header set Service-Worker-Allowed "/"
|
Header set Service-Worker-Allowed "/"
|
||||||
|
|
||||||
|
|
|
||||||
1897
CHANGELOG.md
|
|
@ -29,7 +29,7 @@
|
||||||
**Editing HTML Template Files**
|
**Editing HTML Template Files**
|
||||||
|
|
||||||
1. Edit data/\_data_/\_default_/configs/application.ini
|
1. Edit data/\_data_/\_default_/configs/application.ini
|
||||||
2. Set `[cache] system_data` to Off
|
2. Set 'cache_system_data' to Off
|
||||||
|
|
||||||
**Release**
|
**Release**
|
||||||
|
|
||||||
|
|
|
||||||
62
README.md
|
|
@ -5,10 +5,6 @@
|
||||||
<br>
|
<br>
|
||||||
<h1>SnappyMail</h1>
|
<h1>SnappyMail</h1>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
[](https://github.com/the-djmaze/snappymail/actions/workflows/docker.yml)
|
|
||||||
[](https://hub.docker.com/r/djmaze/snappymail/tags)
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Simple, modern, lightweight & fast web-based email client.
|
Simple, modern, lightweight & fast web-based email client.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -30,7 +26,7 @@ For more information about the product, check [snappymail.eu](https://snappymail
|
||||||
|
|
||||||
Information about installing the product, check the [wiki page](https://github.com/the-djmaze/snappymail/wiki/Installation-instructions).
|
Information about installing the product, check the [wiki page](https://github.com/the-djmaze/snappymail/wiki/Installation-instructions).
|
||||||
|
|
||||||
And don't forget to read the whole [Wiki](https://github.com/the-djmaze/snappymail/wiki).
|
And don't forget to read the [RainLoop documentation](https://www.rainloop.net/docs/).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
@ -38,7 +34,7 @@ And don't forget to read the whole [Wiki](https://github.com/the-djmaze/snappyma
|
||||||
**GNU AFFERO GENERAL PUBLIC LICENSE Version 3 (AGPL)**.
|
**GNU AFFERO GENERAL PUBLIC LICENSE Version 3 (AGPL)**.
|
||||||
http://www.gnu.org/licenses/agpl-3.0.html
|
http://www.gnu.org/licenses/agpl-3.0.html
|
||||||
|
|
||||||
Copyright (c) 2020 - 2024 SnappyMail
|
Copyright (c) 2020 - 2022 SnappyMail
|
||||||
Copyright (c) 2013 - 2022 RainLoop
|
Copyright (c) 2013 - 2022 RainLoop
|
||||||
|
|
||||||
## Modifications
|
## Modifications
|
||||||
|
|
@ -49,7 +45,7 @@ This fork of RainLoop has the following changes:
|
||||||
* Admin uses password_hash/password_verify
|
* Admin uses password_hash/password_verify
|
||||||
* Auth failed attempts written to syslog
|
* Auth failed attempts written to syslog
|
||||||
* Added Fail2ban instructions
|
* Added Fail2ban instructions
|
||||||
* ES2020
|
* ES2018
|
||||||
* PHP 7.4+ required
|
* PHP 7.4+ required
|
||||||
* PHP mbstring extension required
|
* PHP mbstring extension required
|
||||||
* PHP replaced pclZip with PharData and ZipArchive
|
* PHP replaced pclZip with PharData and ZipArchive
|
||||||
|
|
@ -66,7 +62,7 @@ This fork of RainLoop has the following changes:
|
||||||
* CRLF => LF line endings
|
* CRLF => LF line endings
|
||||||
* Embed boot.js and boot.css into index.html
|
* Embed boot.js and boot.css into index.html
|
||||||
* Removal of old JavaScript code (things are native these days)
|
* Removal of old JavaScript code (things are native these days)
|
||||||
* Added modified [Squire](https://github.com/the-djmaze/Squire/tree/snappymail) HTML editor as replacement for CKEditor
|
* Added modified [Squire](https://github.com/neilj/Squire) HTML editor as replacement for CKEditor
|
||||||
* Updated [Sabre/VObject](https://github.com/sabre-io/vobject)
|
* Updated [Sabre/VObject](https://github.com/sabre-io/vobject)
|
||||||
* Split Admin specific JavaScript code from User code
|
* Split Admin specific JavaScript code from User code
|
||||||
* Split Sieve specific JavaScript code from User code
|
* Split Sieve specific JavaScript code from User code
|
||||||
|
|
@ -83,7 +79,12 @@ This fork of RainLoop has the following changes:
|
||||||
* Added [Fetch Metadata Request Headers](https://www.w3.org/TR/fetch-metadata/) checks
|
* Added [Fetch Metadata Request Headers](https://www.w3.org/TR/fetch-metadata/) checks
|
||||||
* Reduced excessive DOM size
|
* Reduced excessive DOM size
|
||||||
* Support [Kolab groupware](https://kolab.org/)
|
* Support [Kolab groupware](https://kolab.org/)
|
||||||
* Support many more [IMAP RFC's](https://snappymail.eu/comparison#IMAP)
|
* Support IMAP RFC 2971 ID extension
|
||||||
|
* Support IMAP RFC 5258 LIST-EXTENDED
|
||||||
|
* Support IMAP RFC 5464 METADATA
|
||||||
|
* Support IMAP RFC 5819 LIST-STATUS
|
||||||
|
* Support IMAP RFC 7628 SASL OAUTHBEARER aka XOAUTH2
|
||||||
|
* Support IMAP4rev2 RFC 9051
|
||||||
* Support Sodium and OpenSSL for encryption
|
* Support Sodium and OpenSSL for encryption
|
||||||
* Much better PGP support
|
* Much better PGP support
|
||||||
|
|
||||||
|
|
@ -140,28 +141,28 @@ RainLoop 1.17 vs SnappyMail
|
||||||
|
|
||||||
|js/* |RainLoop |Snappy |
|
|js/* |RainLoop |Snappy |
|
||||||
|--------------- |--------: |--------: |
|
|--------------- |--------: |--------: |
|
||||||
|admin.js |2.170.153 | 84.925 |
|
|admin.js |2.170.153 | 82.190 |
|
||||||
|app.js |4.207.787 | 447.263 |
|
|app.js |4.207.787 | 412.127 |
|
||||||
|boot.js | 868.735 | 4.343 |
|
|boot.js | 868.735 | 2.427 |
|
||||||
|libs.js | 658.812 | 233.728 |
|
|libs.js | 658.812 | 191.117 |
|
||||||
|sieve.js | 0 | 91.418 |
|
|sieve.js | 0 | 86.018 |
|
||||||
|polyfills.js | 334.608 | 0 |
|
|polyfills.js | 334.608 | 0 |
|
||||||
|serviceworker.js | 0 | 285 |
|
|serviceworker.js | 0 | 285 |
|
||||||
|TOTAL |8.240.095 | 861.962 |
|
|TOTAL |8.240.095 | 774.164 |
|
||||||
|
|
||||||
|js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|
|js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|
||||||
|--------------- |--------: |--------: |------: |------: |--------: |--------: |
|
|--------------- |--------: |--------: |------: |------: |--------: |--------: |
|
||||||
|admin.min.js | 256.831 | 41.719 | 73.606 | 14.022 | 60.877 | 12.567 |
|
|admin.min.js | 256.831 | 40.879 | 73.606 | 13.468 | 60.877 | 12.077 |
|
||||||
|app.min.js | 515.367 | 202.101 |139.456 | 68.505 |110.485 | 58.481 |
|
|app.min.js | 515.367 | 189.931 |139.456 | 63.444 |110.485 | 54.381 |
|
||||||
|boot.min.js | 84.659 | 2.231 | 26.998 | 1.271 | 23.643 | 1.067 |
|
|boot.min.js | 84.659 | 1.518 | 26.998 | 919 | 23.643 | 747 |
|
||||||
|libs.min.js | 584.772 | 110.646 |180.901 | 39.518 |155.182 | 35.207 |
|
|libs.min.js | 584.772 | 90.689 |180.901 | 33.597 |155.182 | 30.118 |
|
||||||
|sieve.min.js | 0 | 45.504 | 0 | 11.131 | 0 | 9.917 |
|
|sieve.min.js | 0 | 41.909 | 0 | 10.476 | 0 | 9.461 |
|
||||||
|polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 |
|
|polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 |
|
||||||
|TOTAL user |1.217.635 | 314.978 |358.761 |109.294 |299.485 | 94.755 |
|
|TOTAL user |1.217.635 | 282.138 |358.761 | 97.960 |299.485 | 85.246 |
|
||||||
|TOTAL user+sieve |1.217.635 | 360.482 |358.761 |120.425 |299.485 |104.672 |
|
|TOTAL user+sieve |1.217.635 | 324.047 |358.761 |108.436 |299.485 | 94.707 |
|
||||||
|TOTAL admin | 959.099 | 154.596 |292.911 | 54.811 |249.877 | 48.841 |
|
|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
|
### CSS changes
|
||||||
|
|
||||||
|
|
@ -188,12 +189,12 @@ For a user it is around 66% smaller and faster than traditional RainLoop.
|
||||||
|
|
||||||
|css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli |
|
|css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli |
|
||||||
|------------ |-------: |------: |------: |------: |--------: |
|
|------------ |-------: |------: |------: |------: |--------: |
|
||||||
|app.css | 340.331 | 85.073 | 46.946 | 17.792 | 15.210 |
|
|app.css | 340.331 | 82.646 | 46.946 | 17.143 | 14.703 |
|
||||||
|app.min.css | 274.947 | 68.272 | 39.647 | 15.615 | 13.636 |
|
|app.min.css | 274.947 | 66.615 | 39.647 | 15.115 | 13.255 |
|
||||||
|boot.css | | 1.326 | | 664 | 545 |
|
|boot.css | | 1.326 | | 664 | 545 |
|
||||||
|boot.min.css | | 1.071 | | 590 | 474 |
|
|boot.min.css | | 1.071 | | 590 | 474 |
|
||||||
|admin.css | | 30.880 | | 7.045 | 6.127 |
|
|admin.css | | 30.277 | | 6.901 | 5.999 |
|
||||||
|admin.min.css | | 24.959 | | 6.368 | 5.615 |
|
|admin.min.css | | 24.524 | | 6.238 | 5.503 |
|
||||||
|
|
||||||
### PGP
|
### PGP
|
||||||
RainLoop uses the old OpenPGP.js v2
|
RainLoop uses the old OpenPGP.js v2
|
||||||
|
|
@ -207,7 +208,7 @@ See https://github.com/the-djmaze/openpgpjs for development
|
||||||
|
|
||||||
|OpenPGP |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|
|OpenPGP |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|
||||||
|--------------- |--------: |--------: |------: |-------: |--------: |--------: |
|
|--------------- |--------: |--------: |------: |-------: |--------: |--------: |
|
||||||
|openpgp.min.js | 330.742 | 546.165 |102.388 | 169.207 | 84.241 | 138.688 |
|
|openpgp.min.js | 330.742 | 539.988 |102.388 | 167.371 | 84.241 | 137.413 |
|
||||||
|openpgp.worker | 1.499 | | 824 | | 695 | |
|
|openpgp.worker | 1.499 | | 824 | | 695 | |
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -217,6 +218,7 @@ The [Squire](https://github.com/neilj/Squire) implementation is not 100% compati
|
||||||
Still TODO:
|
Still TODO:
|
||||||
|
|
||||||
* support for tables (really needed?!?)
|
* support for tables (really needed?!?)
|
||||||
|
* support BIDI (really needed?!?)
|
||||||
|
|
||||||
| | normal | min | gzip | min gzip |
|
| | normal | min | gzip | min gzip |
|
||||||
|-------- |-------: |-------: |------: |--------: |
|
|-------- |-------: |-------: |------: |--------: |
|
||||||
|
|
@ -224,3 +226,5 @@ Still TODO:
|
||||||
|ckeditor | ? | 520.035 | ? | 155.916 |
|
|ckeditor | ? | 520.035 | ? | 155.916 |
|
||||||
|
|
||||||
CKEditor including the 7 asset requests (css,language,plugins,icons) is 633.46 KB / 180.47 KB (gzip).
|
CKEditor including the 7 asset requests (css,language,plugins,icons) is 633.46 KB / 180.47 KB (gzip).
|
||||||
|
|
||||||
|
To use the old CKEditor, you must install the plugin.
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,15 @@ Currently due to the fast development only the latest version receives security
|
||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| -------- | --------- |
|
| -------- | --------- |
|
||||||
| 2.34.x | ✔ |
|
| 2.13.x | ✔ |
|
||||||
| < 2.34.0 | ❌ |
|
| < 2.13.0 | ❌ |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Please report security issues or vulnerabilities as an encrypted email to [security@snappymail.eu](mailto:security@snappymail.eu).
|
Please report security issues or vulnerabilities as an encrypted email to [security@snappymail.eu](mailto:security@snappymail.eu).
|
||||||
Your report should be detailed enough with clear steps to reproduce and classify the found vulnerability.
|
Your report should be detailed enough with clear steps to reproduce and classify the found vulnerability.
|
||||||
|
|
||||||
You can find the PGP public key below and on the major public keyservers like [keys.openpgp.org](https://keys.openpgp.org).
|
You can find the PGP public key below and on the major public keyservers like [pgp.key-server.io](https://pgp.key-server.io).
|
||||||
```
|
```
|
||||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
Comment: Type: 255-bit EdDSA
|
Comment: Type: 255-bit EdDSA
|
||||||
|
|
@ -36,5 +36,5 @@ YMXQVq4SJt7UpWABAORudfJxsBqCRKtPlZMgGTJLjcOkyFJ9C2Fx7DeN0J4I
|
||||||
## Publishing and Credits
|
## Publishing and Credits
|
||||||
|
|
||||||
I will analyze and fix the reported issue as fast as possible.
|
I will analyze and fix the reported issue as fast as possible.
|
||||||
Together with the reporter, I plan the disclosure of the found and fixed vulnerability.
|
Together with the reporter I plan the disclosure of the found and fixed vulnerability.
|
||||||
Credits to the reporter are granted and can be included in all public communication if desired.
|
Credits to the reporter are granted and can be included in all public communication if desired.
|
||||||
|
|
|
||||||
10
_include.php
|
|
@ -4,6 +4,16 @@
|
||||||
|
|
||||||
//header('Strict-Transport-Security: max-age=31536000');
|
//header('Strict-Transport-Security: max-age=31536000');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uncomment to use gzip compressed output
|
||||||
|
*/
|
||||||
|
//define('USE_GZIP', 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uncomment to use brotli compressed output
|
||||||
|
*/
|
||||||
|
//define('USE_BROTLI', 1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uncomment to enable multiple domain installation.
|
* Uncomment to enable multiple domain installation.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
50
assets/.htaccess
Normal file
|
|
@ -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>
|
||||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 992 B After Width: | Height: | Size: 992 B |
BIN
assets/css/images/empty-contact.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 866 B After Width: | Height: | Size: 866 B |
|
Before Width: | Height: | Size: 335 B After Width: | Height: | Size: 335 B |
|
Before Width: | Height: | Size: 992 B After Width: | Height: | Size: 992 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
|
@ -11,16 +11,13 @@ $nc_destination = "{$destPath}snappymail-{$package->version}-nextcloud.tar";
|
||||||
$nc_tar = new PharData($nc_destination);
|
$nc_tar = new PharData($nc_destination);
|
||||||
$hashes = [];
|
$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/@");
|
$nc_tar->buildFromDirectory('./integrations/nextcloud', "@integrations/nextcloud/snappymail/@");
|
||||||
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('integrations/nextcloud/snappymail'));
|
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('integrations/nextcloud/snappymail'));
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
if (is_file($file)) {
|
if (is_file($file)) {
|
||||||
$name = str_replace('\\', '/', $file);
|
$name = str_replace('\\', '/', $file);
|
||||||
$name = str_replace('integrations/nextcloud/snappymail/', '', $name);
|
$name = str_replace('integrations/nextcloud/snappymail/', '', $name);
|
||||||
|
$name = str_replace('.htaccess', '_htaccess', $name);
|
||||||
$hashes[$name] = hash_file('sha512', $file);
|
$hashes[$name] = hash_file('sha512', $file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -29,12 +26,11 @@ $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('snappymai
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
if (is_file($file)) {
|
if (is_file($file)) {
|
||||||
$newFile = str_replace('\\', '/', $file);
|
$newFile = str_replace('\\', '/', $file);
|
||||||
$newName = str_replace('/.htaccess', '/_htaccess', $newFile);
|
// $newFile = str_replace("'snappymail/v/'.", '', $newFile);
|
||||||
$nc_tar->addFile($file, "snappymail/app/{$newName}");
|
$nc_tar->addFile($file, "snappymail/app/{$newFile}");
|
||||||
$hashes["app/{$newFile}"] = hash_file('sha512', $file);
|
$hashes["app/{$newFile}"] = hash_file('sha512', $file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
$nc_tar->addFile('data/.htaccess');
|
$nc_tar->addFile('data/.htaccess');
|
||||||
$nc_tar->addFromString('data/VERSION', $package->version);
|
$nc_tar->addFromString('data/VERSION', $package->version);
|
||||||
|
|
@ -62,6 +58,7 @@ $hashes['app/serviceworker.js'] = hash('sha512', $data);
|
||||||
|
|
||||||
spl_autoload_register(function($name){
|
spl_autoload_register(function($name){
|
||||||
$file = __DIR__ . '/' . str_replace('\\', '/', $name) . '.php';
|
$file = __DIR__ . '/' . str_replace('\\', '/', $name) . '.php';
|
||||||
|
echo "{$file}\n";
|
||||||
require $file;
|
require $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";
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
define('ROOT_DIR', dirname(__DIR__));
|
||||||
define('PLUGINS_DEST_DIR', __DIR__ . '/dist/releases/plugins');
|
define('PLUGINS_DEST_DIR', __DIR__ . '/dist/releases/plugins');
|
||||||
|
|
||||||
is_dir(PLUGINS_DEST_DIR) || mkdir(PLUGINS_DEST_DIR, 0777, true);
|
is_dir(PLUGINS_DEST_DIR) || mkdir(PLUGINS_DEST_DIR, 0777, true);
|
||||||
|
|
@ -28,46 +29,10 @@ $keys = [
|
||||||
'url',
|
'url',
|
||||||
'version'
|
'version'
|
||||||
];
|
];
|
||||||
/*
|
|
||||||
$released = [
|
|
||||||
'add-x-originating-ip-header',
|
|
||||||
'avatars',
|
|
||||||
'backup',
|
|
||||||
'black-list',
|
|
||||||
'change-password',
|
|
||||||
'change-password-froxlor',
|
|
||||||
'change-password-hestia',
|
|
||||||
'change-password-hmailserver',
|
|
||||||
'change-password-ispconfig',
|
|
||||||
'change-password-poppassd',
|
|
||||||
'custom-login-mapping',
|
|
||||||
'imap-contacts-suggestions',
|
|
||||||
'kolab',
|
|
||||||
'ldap-contacts-suggestions',
|
|
||||||
'ldap-identities',
|
|
||||||
'ldap-login-mapping',
|
|
||||||
'ldap-mail-accounts',
|
|
||||||
'login-external',
|
|
||||||
'login-external-sso',
|
|
||||||
'login-override',
|
|
||||||
'login-register',
|
|
||||||
'login-remote',
|
|
||||||
'mailbox-detect',
|
|
||||||
'nextcloud',
|
|
||||||
'override-smtp-credentials',
|
|
||||||
'set-remote-addr',
|
|
||||||
'smtp-use-from-adr-account',
|
|
||||||
'snowfall-on-login-screen',
|
|
||||||
'two-factor-auth',
|
|
||||||
'view-ics',
|
|
||||||
'white-list'
|
|
||||||
];
|
|
||||||
*/
|
|
||||||
foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
|
foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
|
||||||
if (is_file("{$dir}/index.php") && !strpos($dir, '.bak')) {
|
if (is_file("{$dir}/index.php") && !strpos($dir, '.bak')) {
|
||||||
require "{$dir}/index.php";
|
require "{$dir}/index.php";
|
||||||
$name = basename($dir);
|
$name = basename($dir);
|
||||||
// if (!in_array($name, $released)) continue;
|
|
||||||
$class = new ReflectionClass(str_replace('-', '', $name) . 'Plugin');
|
$class = new ReflectionClass(str_replace('-', '', $name) . 'Plugin');
|
||||||
$manifest_item = [];
|
$manifest_item = [];
|
||||||
foreach ($class->getConstants() as $key => $value) {
|
foreach ($class->getConstants() as $key => $value) {
|
||||||
|
|
@ -106,7 +71,6 @@ foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
|
||||||
$tar->compress(Phar::GZ);
|
$tar->compress(Phar::GZ);
|
||||||
unlink($tar_destination);
|
unlink($tar_destination);
|
||||||
rename("{$tar_destination}.gz", $tgz_destination);
|
rename("{$tar_destination}.gz", $tgz_destination);
|
||||||
/*
|
|
||||||
if (Phar::canWrite()) {
|
if (Phar::canWrite()) {
|
||||||
$phar_destination = PLUGINS_DEST_DIR . "/{$name}.phar";
|
$phar_destination = PLUGINS_DEST_DIR . "/{$name}.phar";
|
||||||
@unlink($phar_destination);
|
@unlink($phar_destination);
|
||||||
|
|
@ -116,7 +80,6 @@ foreach (glob(ROOT_DIR . '/plugins/*', GLOB_NOSORT | GLOB_ONLYDIR) as $dir) {
|
||||||
unlink($phar_destination);
|
unlink($phar_destination);
|
||||||
rename("{$phar_destination}.gz", $phar_destination);
|
rename("{$phar_destination}.gz", $phar_destination);
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
if (isset($options['sign'])) {
|
if (isset($options['sign'])) {
|
||||||
passthru('gpg --local-user 1016E47079145542F8BA133548208BA13290F3EB --armor --detach-sign '.escapeshellarg($tgz_destination), $return_var);
|
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')));
|
$manifest_item['pgp_sig'] = trim(preg_replace('/-----(BEGIN|END) PGP SIGNATURE-----/', '', file_get_contents($tgz_destination.'.asc')));
|
||||||
|
|
|
||||||
280
cli/release.php
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,10 +3,8 @@ import ko from 'ko';
|
||||||
import { logoutLink } from 'Common/Links';
|
import { logoutLink } from 'Common/Links';
|
||||||
import { i18nToNodes, initOnStartOrLangChange } from 'Common/Translator';
|
import { i18nToNodes, initOnStartOrLangChange } from 'Common/Translator';
|
||||||
|
|
||||||
import { arePopupsVisible } from 'Knoin/Knoin';
|
|
||||||
|
|
||||||
import { LanguageStore } from 'Stores/Language';
|
import { LanguageStore } from 'Stores/Language';
|
||||||
import { initThemes } from 'Stores/Theme';
|
import { ThemeStore } from 'Stores/Theme';
|
||||||
|
|
||||||
import { SelectComponent } from 'Component/Select';
|
import { SelectComponent } from 'Component/Select';
|
||||||
import { CheckboxComponent } from 'Component/Checkbox';
|
import { CheckboxComponent } from 'Component/Checkbox';
|
||||||
|
|
@ -20,7 +18,6 @@ export class AbstractApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
logoutReload(url) {
|
logoutReload(url) {
|
||||||
arePopupsVisible(false);
|
|
||||||
url = url || logoutLink();
|
url = url || logoutLink();
|
||||||
if (location.href !== url) {
|
if (location.href !== url) {
|
||||||
setTimeout(() => location.href = url, 100);
|
setTimeout(() => location.href = url, 100);
|
||||||
|
|
@ -49,7 +46,7 @@ export class AbstractApp {
|
||||||
initOnStartOrLangChange();
|
initOnStartOrLangChange();
|
||||||
|
|
||||||
LanguageStore.populate();
|
LanguageStore.populate();
|
||||||
initThemes();
|
ThemeStore.populate();
|
||||||
|
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'External/ko';
|
import 'External/ko';
|
||||||
|
|
||||||
import { SettingsGet, SettingsAdmin } from 'Common/Globals';
|
import { Settings, SettingsGet } from 'Common/Globals';
|
||||||
import { initThemes } from 'Stores/Theme';
|
import { ThemeStore } from 'Stores/Theme';
|
||||||
|
|
||||||
import Remote from 'Remote/Admin/Fetch';
|
import Remote from 'Remote/Admin/Fetch';
|
||||||
|
|
||||||
|
|
@ -11,8 +11,6 @@ import { LoginAdminScreen } from 'Screen/Admin/Login';
|
||||||
import { startScreens } from 'Knoin/Knoin';
|
import { startScreens } from 'Knoin/Knoin';
|
||||||
import { AbstractApp } from 'App/Abstract';
|
import { AbstractApp } from 'App/Abstract';
|
||||||
|
|
||||||
import { AskPopupView } from 'View/Popup/Ask';
|
|
||||||
|
|
||||||
export class AdminApp extends AbstractApp {
|
export class AdminApp extends AbstractApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(Remote);
|
super(Remote);
|
||||||
|
|
@ -20,33 +18,19 @@ export class AdminApp extends AbstractApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh() {
|
refresh() {
|
||||||
initThemes();
|
ThemeStore.populate();
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
// if (!Settings.app('adminAllowed')) {
|
if (!Settings.app('adminAllowed')) {
|
||||||
if (!SettingsAdmin('allowed')) {
|
|
||||||
rl.route.root();
|
rl.route.root();
|
||||||
setTimeout(() => location.href = '/', 1);
|
setTimeout(() => location.href = '/', 1);
|
||||||
} else if (SettingsGet('Auth')) {
|
} else if (SettingsGet('Auth')) {
|
||||||
this.weakPassword(SettingsGet('weakPassword'));
|
this.weakPassword(SettingsGet('WeakPassword'));
|
||||||
startScreens([SettingsAdminScreen]);
|
startScreens([SettingsAdminScreen]);
|
||||||
} else {
|
} else {
|
||||||
startScreens([LoginAdminScreen]);
|
startScreens([LoginAdminScreen]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AskPopupView.credentials = function(sAskDesc, btnText) {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
this.showModal([
|
|
||||||
sAskDesc,
|
|
||||||
view => resolve({username:view.username(), password:view.passphrase()}),
|
|
||||||
() => resolve(null),
|
|
||||||
true,
|
|
||||||
3,
|
|
||||||
btnText
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
|
||||||
184
dev/App/User.js
|
|
@ -1,8 +1,8 @@
|
||||||
import 'External/User/ko';
|
import 'External/User/ko';
|
||||||
|
|
||||||
import { SMAudio } from 'Common/Audio';
|
import { SMAudio } from 'Common/Audio';
|
||||||
import { arrayLength } from 'Common/Utils';
|
import { isArray, pString } from 'Common/Utils';
|
||||||
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility, loadAccountsAndIdentities } from 'Common/UtilsUser';
|
import { mailToHelper, setLayoutResizer, dropdownsDetectVisibility } from 'Common/UtilsUser';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FolderType,
|
FolderType,
|
||||||
|
|
@ -27,22 +27,25 @@ import {
|
||||||
getFolderFromCacheList
|
getFolderFromCacheList
|
||||||
} from 'Common/Cache';
|
} from 'Common/Cache';
|
||||||
|
|
||||||
import { i18n, reloadTime, getErrorMessage } from 'Common/Translator';
|
import { i18n, reloadTime } from 'Common/Translator';
|
||||||
|
|
||||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||||
import { NotificationUserStore } from 'Stores/User/Notification';
|
import { NotificationUserStore } from 'Stores/User/Notification';
|
||||||
import { AccountUserStore } from 'Stores/User/Account';
|
import { AccountUserStore } from 'Stores/User/Account';
|
||||||
import { ContactUserStore } from 'Stores/User/Contact';
|
import { ContactUserStore } from 'Stores/User/Contact';
|
||||||
|
import { IdentityUserStore } from 'Stores/User/Identity';
|
||||||
import { FolderUserStore } from 'Stores/User/Folder';
|
import { FolderUserStore } from 'Stores/User/Folder';
|
||||||
import { PgpUserStore } from 'Stores/User/Pgp';
|
import { PgpUserStore } from 'Stores/User/Pgp';
|
||||||
import { SMimeUserStore } from 'Stores/User/SMime';
|
|
||||||
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
||||||
import { ThemeStore, initThemes } from 'Stores/Theme';
|
import { ThemeStore, changeTheme } from 'Stores/Theme';
|
||||||
import { LanguageStore } from 'Stores/Language';
|
import { LanguageStore } from 'Stores/Language';
|
||||||
import { MessageUserStore } from 'Stores/User/Message';
|
import { MessageUserStore } from 'Stores/User/Message';
|
||||||
|
|
||||||
import Remote from 'Remote/User/Fetch';
|
import Remote from 'Remote/User/Fetch';
|
||||||
|
|
||||||
|
import { AccountModel } from 'Model/Account';
|
||||||
|
import { IdentityModel } from 'Model/Identity';
|
||||||
|
|
||||||
import { LoginUserScreen } from 'Screen/User/Login';
|
import { LoginUserScreen } from 'Screen/User/Login';
|
||||||
import { MailBoxUserScreen } from 'Screen/User/MailBox';
|
import { MailBoxUserScreen } from 'Screen/User/MailBox';
|
||||||
import { SettingsUserScreen } from 'Screen/User/Settings';
|
import { SettingsUserScreen } from 'Screen/User/Settings';
|
||||||
|
|
@ -58,7 +61,9 @@ import { AskPopupView } from 'View/Popup/Ask';
|
||||||
import {
|
import {
|
||||||
folderInformation,
|
folderInformation,
|
||||||
folderInformationMultiply,
|
folderInformationMultiply,
|
||||||
setRefreshFoldersInterval
|
refreshFoldersInterval,
|
||||||
|
messagesMoveHelper,
|
||||||
|
messagesDeleteHelper
|
||||||
} from 'Common/Folders';
|
} from 'Common/Folders';
|
||||||
import { loadFolders } from 'Model/FolderCollection';
|
import { loadFolders } from 'Model/FolderCollection';
|
||||||
|
|
||||||
|
|
@ -74,7 +79,7 @@ export class AppUser extends AbstractApp {
|
||||||
(currentTime > (lastTime + interval + 1000))
|
(currentTime > (lastTime + interval + 1000))
|
||||||
&& Remote.request('Version',
|
&& Remote.request('Version',
|
||||||
iError => (100 < iError) && location.reload(),
|
iError => (100 < iError) && location.reload(),
|
||||||
{ version: Settings.app('version') }
|
{ Version: Settings.app('version') }
|
||||||
);
|
);
|
||||||
lastTime = currentTime;
|
lastTime = currentTime;
|
||||||
}, interval);
|
}, interval);
|
||||||
|
|
@ -85,11 +90,6 @@ export class AppUser extends AbstractApp {
|
||||||
addEventListener('click', dropdownsDetectVisibility);
|
addEventListener('click', dropdownsDetectVisibility);
|
||||||
|
|
||||||
this.folderList = FolderUserStore.folderList;
|
this.folderList = FolderUserStore.folderList;
|
||||||
this.messageList = MessagelistUserStore;
|
|
||||||
|
|
||||||
this.ask = AskPopupView;
|
|
||||||
|
|
||||||
this.loadAccountsAndIdentities = loadAccountsAndIdentities;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -114,7 +114,7 @@ export class AppUser extends AbstractApp {
|
||||||
case FolderType.Trash:
|
case FolderType.Trash:
|
||||||
oMoveFolder = getFolderFromCacheList(FolderUserStore.trashFolder());
|
oMoveFolder = getFolderFromCacheList(FolderUserStore.trashFolder());
|
||||||
nSetSystemFoldersNotification = iFolderType;
|
nSetSystemFoldersNotification = iFolderType;
|
||||||
bDelete = bDelete/* || UNUSED_OPTION_VALUE === FolderUserStore.trashFolder()*/
|
bDelete = bDelete || UNUSED_OPTION_VALUE === FolderUserStore.trashFolder()
|
||||||
|| sFromFolderFullName === FolderUserStore.spamFolder()
|
|| sFromFolderFullName === FolderUserStore.spamFolder()
|
||||||
|| sFromFolderFullName === FolderUserStore.trashFolder();
|
|| sFromFolderFullName === FolderUserStore.trashFolder();
|
||||||
break;
|
break;
|
||||||
|
|
@ -126,20 +126,58 @@ export class AppUser extends AbstractApp {
|
||||||
// no default
|
// no default
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bDelete) {
|
if (!oMoveFolder && !bDelete) {
|
||||||
|
showScreenPopup(FolderSystemPopupView, [nSetSystemFoldersNotification]);
|
||||||
|
} else if (bDelete) {
|
||||||
showScreenPopup(AskPopupView, [
|
showScreenPopup(AskPopupView, [
|
||||||
i18n('POPUPS_ASK/DESC_WANT_DELETE_MESSAGES'),
|
i18n('POPUPS_ASK/DESC_WANT_DELETE_MESSAGES'),
|
||||||
() => {
|
() => {
|
||||||
MessagelistUserStore.moveMessages(sFromFolderFullName, oUids);
|
messagesDeleteHelper(sFromFolderFullName, oUids);
|
||||||
|
MessagelistUserStore.removeMessagesFromList(sFromFolderFullName, oUids);
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
} else if (oMoveFolder) {
|
} else if (oMoveFolder) {
|
||||||
MessagelistUserStore.moveMessages(sFromFolderFullName, oUids, oMoveFolder.fullName);
|
messagesMoveHelper(sFromFolderFullName, oMoveFolder.fullName, oUids);
|
||||||
} else {
|
MessagelistUserStore.removeMessagesFromList(sFromFolderFullName, oUids, oMoveFolder.fullName);
|
||||||
showScreenPopup(FolderSystemPopupView, [nSetSystemFoldersNotification]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountsAndIdentities() {
|
||||||
|
AccountUserStore.loading(true);
|
||||||
|
IdentityUserStore.loading(true);
|
||||||
|
|
||||||
|
Remote.request('AccountsAndIdentities', (iError, oData) => {
|
||||||
|
AccountUserStore.loading(false);
|
||||||
|
IdentityUserStore.loading(false);
|
||||||
|
|
||||||
|
if (!iError) {
|
||||||
|
let items = oData.Result.Accounts;
|
||||||
|
AccountUserStore(isArray(items)
|
||||||
|
? items.map(oValue => new AccountModel(oValue.email, oValue.name))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
AccountUserStore.unshift(new AccountModel(SettingsGet('MainEmail'), '', false));
|
||||||
|
|
||||||
|
items = oData.Result.Identities;
|
||||||
|
IdentityUserStore(isArray(items)
|
||||||
|
? items.map(identityData => {
|
||||||
|
const identity = new IdentityModel(
|
||||||
|
pString(identityData.Id),
|
||||||
|
pString(identityData.Email)
|
||||||
|
);
|
||||||
|
identity.name(pString(identityData.Name));
|
||||||
|
identity.replyTo(pString(identityData.ReplyTo));
|
||||||
|
identity.bcc(pString(identityData.Bcc));
|
||||||
|
identity.signature(pString(identityData.Signature));
|
||||||
|
identity.signatureInsertBefore(!!identityData.SignatureInsertBefore);
|
||||||
|
return identity;
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} folder
|
* @param {string} folder
|
||||||
* @param {Array=} list = []
|
* @param {Array=} list = []
|
||||||
|
|
@ -149,26 +187,25 @@ export class AppUser extends AbstractApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
Remote.request('Logout', (iError, data) =>
|
Remote.request('Logout', () => rl.logoutReload(Settings.app('customLogoutLink')));
|
||||||
iError ? alert('Logout error: ' + getErrorMessage(iError, data))
|
|
||||||
: rl.logoutReload(Settings.app('customLogoutLink'))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstart() {
|
bootstart() {
|
||||||
super.bootstart();
|
super.bootstart();
|
||||||
|
|
||||||
|
addEventListener('resize', () => leftPanelDisabled(ThemeStore.isMobile() || 1000 > innerWidth));
|
||||||
addEventListener('beforeunload', event => {
|
addEventListener('beforeunload', event => {
|
||||||
if (arePopupsVisible() || (!SettingsUserStore.usePreviewPane() && MessageUserStore.message())) {
|
if (arePopupsVisible() || (ThemeStore.isMobile() && MessageUserStore.message())) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return event.returnValue = i18n('POPUPS_ASK/EXIT_ARE_YOU_SURE');
|
return event.returnValue = "Are you sure you want to exit?";
|
||||||
}
|
}
|
||||||
}, {capture: true});
|
}, {capture: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh() {
|
refresh() {
|
||||||
initThemes();
|
ThemeStore.populate();
|
||||||
LanguageStore.language(SettingsGet('language'));
|
LanguageStore.language(SettingsGet('Language'));
|
||||||
|
changeTheme(SettingsGet('Theme'));
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,17 +221,25 @@ export class AppUser extends AbstractApp {
|
||||||
SettingsUserStore.init();
|
SettingsUserStore.init();
|
||||||
ContactUserStore.init();
|
ContactUserStore.init();
|
||||||
|
|
||||||
loadFolders((success, error) => {
|
loadFolders(value => {
|
||||||
try {
|
try {
|
||||||
if (success) {
|
if (value) {
|
||||||
startScreens([
|
startScreens([
|
||||||
MailBoxUserScreen,
|
MailBoxUserScreen,
|
||||||
SettingsUserScreen
|
SettingsUserScreen
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setRefreshFoldersInterval(SettingsGet('CheckMailInterval'));
|
setInterval(() => {
|
||||||
|
const cF = FolderUserStore.currentFolderFullName(),
|
||||||
|
iF = getFolderInboxName();
|
||||||
|
folderInformation(iF);
|
||||||
|
iF === cF || folderInformation(cF);
|
||||||
|
folderInformationMultiply();
|
||||||
|
}, refreshFoldersInterval);
|
||||||
|
|
||||||
loadAccountsAndIdentities();
|
ContactUserStore.init();
|
||||||
|
|
||||||
|
this.accountsAndIdentities();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const cF = FolderUserStore.currentFolderFullName();
|
const cF = FolderUserStore.currentFolderFullName();
|
||||||
|
|
@ -211,29 +256,38 @@ export class AppUser extends AbstractApp {
|
||||||
// initLeftSideLayoutResizer
|
// initLeftSideLayoutResizer
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const left = elementById('rl-left'),
|
const left = elementById('rl-left'),
|
||||||
|
right = elementById('rl-right'),
|
||||||
fToggle = () =>
|
fToggle = () =>
|
||||||
setLayoutResizer(left, ClientSideKeyNameFolderListSize,
|
setLayoutResizer(left, right, ClientSideKeyNameFolderListSize,
|
||||||
(ThemeStore.isMobile() || leftPanelDisabled()) ? 0 : 'Width');
|
(ThemeStore.isMobile() || leftPanelDisabled()) ? 0 : 'Width');
|
||||||
if (left) {
|
if (left && right) {
|
||||||
fToggle();
|
fToggle();
|
||||||
leftPanelDisabled.subscribe(fToggle);
|
leftPanelDisabled.subscribe(fToggle);
|
||||||
}
|
}
|
||||||
}, 1);
|
}, 1);
|
||||||
|
|
||||||
setInterval(reloadTime, 60000);
|
setInterval(reloadTime(), 60000);
|
||||||
|
|
||||||
PgpUserStore.init();
|
PgpUserStore.init();
|
||||||
SMimeUserStore.loadCertificates();
|
|
||||||
|
|
||||||
setTimeout(() => mailToHelper(SettingsGet('mailToEmail')), 500);
|
// When auto-login is active
|
||||||
|
try {
|
||||||
|
navigator.registerProtocolHandler?.(
|
||||||
|
'mailto',
|
||||||
|
location.protocol + '//' + location.host + location.pathname + '?mailto&to=%s',
|
||||||
|
(SettingsGet('Title') || 'SnappyMail')
|
||||||
|
);
|
||||||
|
} catch (e) {} // eslint-disable-line no-empty
|
||||||
|
|
||||||
|
setTimeout(() => mailToHelper(SettingsGet('MailToEmail')), 500);
|
||||||
} else {
|
} else {
|
||||||
this.logout();
|
this.logout();
|
||||||
alert('Folders error: ' + getErrorMessage(0, error))
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
startScreens([LoginUserScreen]);
|
startScreens([LoginUserScreen]);
|
||||||
}
|
}
|
||||||
|
|
@ -241,62 +295,6 @@ export class AppUser extends AbstractApp {
|
||||||
|
|
||||||
showMessageComposer(params = [])
|
showMessageComposer(params = [])
|
||||||
{
|
{
|
||||||
let msg = params[1];
|
showScreenPopup(ComposePopupView, params);
|
||||||
if (1 == arrayLength(msg)) {
|
|
||||||
msg = msg[0];
|
|
||||||
}
|
|
||||||
if (msg) {
|
|
||||||
msg.decrypt().then((/*success*/)=>showScreenPopup(ComposePopupView, params));
|
|
||||||
} else {
|
|
||||||
showScreenPopup(ComposePopupView, params);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AskPopupView.password = function(sAskDesc, btnText, ask) {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
this.showModal([
|
|
||||||
sAskDesc,
|
|
||||||
view => resolve({
|
|
||||||
password:view.passphrase(),
|
|
||||||
username:/*ask & 2 ? */view.username(),
|
|
||||||
remember:/*ask & 4 ? */view.remember(),
|
|
||||||
rememberPermanent: view.rememberPermanent(),
|
|
||||||
}),
|
|
||||||
() => resolve(null),
|
|
||||||
true,
|
|
||||||
ask || 1,
|
|
||||||
btnText
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
AskPopupView.cryptkey = () => new Promise(resolve => {
|
|
||||||
const fn = () => AskPopupView.showModal([
|
|
||||||
i18n('CRYPTO/ASK_CRYPTKEY_PASS'),
|
|
||||||
view => {
|
|
||||||
let pass = view.passphrase();
|
|
||||||
if (pass) {
|
|
||||||
Remote.post('ResealCryptKey', null, {
|
|
||||||
passphrase: pass
|
|
||||||
}).then(response => {
|
|
||||||
resolve(response?.Result);
|
|
||||||
}).catch(e => {
|
|
||||||
if (111 === e.code) {
|
|
||||||
fn();
|
|
||||||
} else {
|
|
||||||
console.error(e);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
() => resolve(null),
|
|
||||||
true,
|
|
||||||
1,
|
|
||||||
i18n('CRYPTO/DECRYPT')
|
|
||||||
]);
|
|
||||||
fn();
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,6 @@ export const SMAudio = new class {
|
||||||
if ('running' == audioCtx.state && (this.supportedMp3 || this.supportedOgg)) {
|
if ('running' == audioCtx.state && (this.supportedMp3 || this.supportedOgg)) {
|
||||||
notificator = notificator || createNewObject();
|
notificator = notificator || createNewObject();
|
||||||
if (notificator) {
|
if (notificator) {
|
||||||
// SettingsGet('NotificationSound').startsWith('custom@')
|
|
||||||
notificator.src = Links.staticLink('sounds/'
|
notificator.src = Links.staticLink('sounds/'
|
||||||
+ SettingsGet('NotificationSound')
|
+ SettingsGet('NotificationSound')
|
||||||
+ (this.supportedMp3 ? '.mp3' : '.ogg'));
|
+ (this.supportedMp3 ? '.mp3' : '.ogg'));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
let FOLDERS_CACHE = new Map,
|
import { MessageSetAction } from 'Common/EnumsUser';
|
||||||
FOLDERS_HASH_MAP = new Map,
|
import { isArray } from 'Common/Utils';
|
||||||
|
|
||||||
|
let FOLDERS_CACHE = {},
|
||||||
|
FOLDERS_NAME_CACHE = {},
|
||||||
|
MESSAGE_FLAGS_CACHE = {},
|
||||||
inboxFolderName = 'INBOX';
|
inboxFolderName = 'INBOX';
|
||||||
|
|
||||||
export const
|
export const
|
||||||
|
|
@ -7,10 +11,18 @@ export const
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
clearCache = () => {
|
clearCache = () => {
|
||||||
FOLDERS_CACHE.clear();
|
FOLDERS_CACHE = {};
|
||||||
FOLDERS_HASH_MAP.clear();
|
FOLDERS_NAME_CACHE = {};
|
||||||
|
MESSAGE_FLAGS_CACHE = {};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} folderFullName
|
||||||
|
* @param {string} uid
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getMessageKey = (folderFullName, uid) => folderFullName + '#' + uid,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
|
|
@ -22,35 +34,143 @@ export const
|
||||||
setFolderInboxName = name => inboxFolderName = name,
|
setFolderInboxName = name => inboxFolderName = name,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} fullNameHash
|
* @param {string} folderHash
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
getFolderFromHashMap = fullNameHash => getFolderFromCacheList(FOLDERS_HASH_MAP.get(fullNameHash)),
|
getFolderFullName = folderHash => (folderHash && FOLDERS_NAME_CACHE[folderHash]) || '',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param {string} folderHash
|
||||||
|
* @param {string} folderFullName
|
||||||
* @param {?FolderModel} folder
|
* @param {?FolderModel} folder
|
||||||
*/
|
*/
|
||||||
setFolder = folder => {
|
setFolder = folder => {
|
||||||
folder.etag = '';
|
folder.hash = '';
|
||||||
FOLDERS_CACHE.set(folder.fullName, folder);
|
FOLDERS_CACHE[folder.fullName] = folder;
|
||||||
FOLDERS_HASH_MAP.set(folder.fullNameHash, folder.fullName);
|
FOLDERS_NAME_CACHE[folder.fullNameHash] = folder.fullName;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} folderFullName
|
* @param {string} folderFullName
|
||||||
* @param {string} folderETag
|
* @param {string} folderHash
|
||||||
*/
|
*/
|
||||||
setFolderETag = (folderFullName, folderETag) =>
|
setFolderHash = (folderFullName, folderHash) =>
|
||||||
FOLDERS_CACHE.has(folderFullName) && (FOLDERS_CACHE.get(folderFullName).etag = folderETag),
|
FOLDERS_CACHE[folderFullName] && (FOLDERS_CACHE[folderFullName].hash = folderHash),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} folderFullName
|
* @param {string} folderFullName
|
||||||
* @returns {?FolderModel}
|
* @returns {?FolderModel}
|
||||||
*/
|
*/
|
||||||
getFolderFromCacheList = folderFullName =>
|
getFolderFromCacheList = folderFullName =>
|
||||||
FOLDERS_CACHE.get(folderFullName),
|
FOLDERS_CACHE[folderFullName] ? FOLDERS_CACHE[folderFullName] : null,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} folderFullName
|
* @param {string} folderFullName
|
||||||
*/
|
*/
|
||||||
removeFolderFromCacheList = folderFullName => FOLDERS_CACHE.delete(folderFullName);
|
removeFolderFromCacheList = folderFullName => delete FOLDERS_CACHE[folderFullName];
|
||||||
|
|
||||||
|
export class MessageFlagsCache
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param {string} folderFullName
|
||||||
|
* @param {string} uid
|
||||||
|
* @param {string} flag
|
||||||
|
* @returns {bool}
|
||||||
|
*/
|
||||||
|
static hasFlag(folderFullName, uid, flag) {
|
||||||
|
return MESSAGE_FLAGS_CACHE[folderFullName]?.[uid]?.includes(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} folderFullName
|
||||||
|
* @param {string} uid
|
||||||
|
* @returns {?Array}
|
||||||
|
*/
|
||||||
|
static getFor(folderFullName, uid) {
|
||||||
|
return MESSAGE_FLAGS_CACHE[folderFullName]?.[uid];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} folderFullName
|
||||||
|
* @param {string} uid
|
||||||
|
* @param {Array} flagsCache
|
||||||
|
*/
|
||||||
|
static setFor(folderFullName, uid, flags) {
|
||||||
|
if (isArray(flags)) {
|
||||||
|
if (!MESSAGE_FLAGS_CACHE[folderFullName]) {
|
||||||
|
MESSAGE_FLAGS_CACHE[folderFullName] = {};
|
||||||
|
}
|
||||||
|
MESSAGE_FLAGS_CACHE[folderFullName][uid] = flags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} folderFullName
|
||||||
|
*/
|
||||||
|
static clearFolder(folderFullName) {
|
||||||
|
MESSAGE_FLAGS_CACHE[folderFullName] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {(MessageModel|null)} message
|
||||||
|
*/
|
||||||
|
static initMessage(message) {
|
||||||
|
if (message) {
|
||||||
|
const uid = message.uid,
|
||||||
|
flags = this.getFor(message.folder, uid),
|
||||||
|
thread = message.threads();
|
||||||
|
|
||||||
|
isArray(flags) && message.flags(flags);
|
||||||
|
|
||||||
|
if (thread.length) {
|
||||||
|
message.hasUnseenSubMessage(!!thread.find(iSubUid =>
|
||||||
|
(uid !== iSubUid) && !this.hasFlag(message.folder, iSubUid, '\\seen')
|
||||||
|
));
|
||||||
|
message.hasFlaggedSubMessage(!!thread.find(iSubUid =>
|
||||||
|
(uid !== iSubUid) && this.hasFlag(message.folder, iSubUid, '\\flagged')
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {(MessageModel|null)} message
|
||||||
|
*/
|
||||||
|
static store(message) {
|
||||||
|
message && this.setFor(message.folder, message.uid, message.flags());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} folder
|
||||||
|
* @param {string} uid
|
||||||
|
* @param {number} setAction
|
||||||
|
*/
|
||||||
|
static storeBySetAction(folder, uid, setAction) {
|
||||||
|
let flags = this.getFor(folder, uid) || [];
|
||||||
|
const
|
||||||
|
unread = flags.includes('\\seen') ? 0 : 1,
|
||||||
|
add = item => flags.includes(item) || flags.push(item),
|
||||||
|
remove = item => flags = flags.filter(flag => flag != item);
|
||||||
|
|
||||||
|
switch (setAction) {
|
||||||
|
case MessageSetAction.SetSeen:
|
||||||
|
add('\\seen');
|
||||||
|
break;
|
||||||
|
case MessageSetAction.UnsetSeen:
|
||||||
|
remove('\\seen');
|
||||||
|
break;
|
||||||
|
case MessageSetAction.SetFlag:
|
||||||
|
add('\\flagged');
|
||||||
|
break;
|
||||||
|
case MessageSetAction.UnsetFlag:
|
||||||
|
remove('\\flagged');
|
||||||
|
break;
|
||||||
|
// no default
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setFor(folder, uid, flags);
|
||||||
|
|
||||||
|
return unread;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@ export const
|
||||||
/**
|
/**
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
ScopeMessageList = 'MessageList',
|
Scope = {
|
||||||
ScopeFolderList = 'FolderList',
|
MessageList: 'MessageList',
|
||||||
ScopeMessageView = 'MessageView',
|
FolderList: 'FolderList',
|
||||||
ScopeSettings = 'Settings',
|
MessageView: 'MessageView',
|
||||||
|
Settings: 'Settings'
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @enum {number}
|
* @enum {number}
|
||||||
|
|
@ -37,7 +39,7 @@ SaveSettingStatus = {
|
||||||
/**
|
/**
|
||||||
* @enum {number}
|
* @enum {number}
|
||||||
*/
|
*/
|
||||||
Notifications = {
|
Notification = {
|
||||||
RequestError: 1,
|
RequestError: 1,
|
||||||
RequestAborted: 2,
|
RequestAborted: 2,
|
||||||
RequestTimeout: 3,
|
RequestTimeout: 3,
|
||||||
|
|
@ -50,7 +52,6 @@ Notifications = {
|
||||||
ConnectionError: 104,
|
ConnectionError: 104,
|
||||||
DomainNotAllowed: 109,
|
DomainNotAllowed: 109,
|
||||||
AccountNotAllowed: 110,
|
AccountNotAllowed: 110,
|
||||||
CryptKeyError: 111,
|
|
||||||
|
|
||||||
ContactsSyncError: 140,
|
ContactsSyncError: 140,
|
||||||
|
|
||||||
|
|
@ -96,6 +97,7 @@ Notifications = {
|
||||||
JsonParse: 952,
|
JsonParse: 952,
|
||||||
// JsonTimeout: 953,
|
// JsonTimeout: 953,
|
||||||
|
|
||||||
|
UnknownNotification: 998,
|
||||||
UnknownError: 999,
|
UnknownError: 999,
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,12 @@ export const FolderType = {
|
||||||
FILES : 25;
|
FILES : 25;
|
||||||
JOURNAL : 26;
|
JOURNAL : 26;
|
||||||
*/
|
*/
|
||||||
},
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
FolderMetadataKeys = {
|
export const FolderMetadataKeys = {
|
||||||
// RFC 5464
|
// RFC 5464
|
||||||
Comment: '/private/comment',
|
Comment: '/private/comment',
|
||||||
CommentShared: '/shared/comment',
|
CommentShared: '/shared/comment',
|
||||||
|
|
@ -39,12 +39,12 @@ FolderMetadataKeys = {
|
||||||
// Kolab
|
// Kolab
|
||||||
KolabFolderType: '/private/vendor/kolab/folder-type',
|
KolabFolderType: '/private/vendor/kolab/folder-type',
|
||||||
KolabFolderTypeShared: '/shared/vendor/kolab/folder-type'
|
KolabFolderTypeShared: '/shared/vendor/kolab/folder-type'
|
||||||
},
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
ComposeType = {
|
export const ComposeType = {
|
||||||
Empty: 0,
|
Empty: 0,
|
||||||
Reply: 1,
|
Reply: 1,
|
||||||
ReplyAll: 2,
|
ReplyAll: 2,
|
||||||
|
|
@ -52,34 +52,51 @@ ComposeType = {
|
||||||
ForwardAsAttachment: 4,
|
ForwardAsAttachment: 4,
|
||||||
Draft: 5,
|
Draft: 5,
|
||||||
EditAsNew: 6
|
EditAsNew: 6
|
||||||
},
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @enum {number}
|
* @enum {number}
|
||||||
*/
|
*/
|
||||||
ClientSideKeyNameExpandedFolders = 3,
|
export const
|
||||||
ClientSideKeyNameFolderListSize = 4,
|
ClientSideKeyNameExpandedFolders = 3,
|
||||||
ClientSideKeyNameMessageListSize = 5,
|
ClientSideKeyNameFolderListSize = 4,
|
||||||
ClientSideKeyNameLastSignMe = 7,
|
ClientSideKeyNameMessageListSize = 5,
|
||||||
ClientSideKeyNameMessageHeaderFullInfo = 9,
|
ClientSideKeyNameLastSignMe = 7,
|
||||||
ClientSideKeyNameMessageAttachmentControls = 10,
|
ClientSideKeyNameMessageHeaderFullInfo = 9,
|
||||||
|
ClientSideKeyNameMessageAttachmentControls = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @enum {number}
|
* @enum {number}
|
||||||
*/
|
*/
|
||||||
MessageSetAction = {
|
export const MessageSetAction = {
|
||||||
SetSeen: 0,
|
SetSeen: 0,
|
||||||
UnsetSeen: 1,
|
UnsetSeen: 1,
|
||||||
SetFlag: 2,
|
SetFlag: 2,
|
||||||
UnsetFlag: 3,
|
UnsetFlag: 3
|
||||||
SetDeleted: 4,
|
};
|
||||||
UnsetDeleted: 5
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @enum {number}
|
* @enum {number}
|
||||||
*/
|
*/
|
||||||
//LayoutNoView = 0,
|
export const MessagePriority = {
|
||||||
LayoutSideView = 1,
|
Low: 5,
|
||||||
LayoutBottomView = 2
|
Normal: 3,
|
||||||
;
|
High: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
export const EditorDefaultType = {
|
||||||
|
Html: 'Html',
|
||||||
|
Plain: 'Plain'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @enum {number}
|
||||||
|
*/
|
||||||
|
export const Layout = {
|
||||||
|
NoPreview: 0,
|
||||||
|
SidePreview: 1,
|
||||||
|
BottomPreview: 2
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
/* eslint key-spacing: 0 */
|
/* eslint key-spacing: 0 */
|
||||||
/* eslint quote-props: 0 */
|
/* eslint quote-props: 0 */
|
||||||
|
|
||||||
import { arrayLength, pInt } from 'Common/Utils';
|
import { arrayLength } from 'Common/Utils';
|
||||||
|
|
||||||
export const RFC822 = 'message/rfc822';
|
|
||||||
|
|
||||||
const
|
const
|
||||||
cache = {},
|
cache = {},
|
||||||
|
|
@ -14,8 +12,8 @@ const
|
||||||
lowerCase = text => text.toLowerCase().trim(),
|
lowerCase = text => text.toLowerCase().trim(),
|
||||||
|
|
||||||
exts = {
|
exts = {
|
||||||
eml: RFC822,
|
eml: 'message/rfc822',
|
||||||
mime: RFC822,
|
mime: 'message/rfc822',
|
||||||
vcard: 'text/vcard',
|
vcard: 'text/vcard',
|
||||||
vcf: 'text/vcard',
|
vcf: 'text/vcard',
|
||||||
htm: 'text/html',
|
htm: 'text/html',
|
||||||
|
|
@ -30,8 +28,6 @@ const
|
||||||
p7c: app+'pkcs7-mime',
|
p7c: app+'pkcs7-mime',
|
||||||
p7m: app+'pkcs7-mime',
|
p7m: app+'pkcs7-mime',
|
||||||
p7s: app+'pkcs7-signature',
|
p7s: app+'pkcs7-signature',
|
||||||
p12: app+'pkcs12',
|
|
||||||
pfx: app+'x-pkcs12',
|
|
||||||
torrent: app+'x-bittorrent',
|
torrent: app+'x-bittorrent',
|
||||||
|
|
||||||
// scripts
|
// scripts
|
||||||
|
|
@ -120,8 +116,7 @@ export const FileType = {
|
||||||
Spreadsheet: 'spreadsheet',
|
Spreadsheet: 'spreadsheet',
|
||||||
Presentation: 'presentation',
|
Presentation: 'presentation',
|
||||||
Certificate: 'certificate',
|
Certificate: 'certificate',
|
||||||
Archive: 'archive',
|
Archive: 'archive'
|
||||||
Calendar: 'calendar'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FileInfo = {
|
export const FileInfo = {
|
||||||
|
|
@ -138,7 +133,7 @@ export const FileInfo = {
|
||||||
getContentType: fileName => {
|
getContentType: fileName => {
|
||||||
fileName = lowerCase(fileName);
|
fileName = lowerCase(fileName);
|
||||||
if ('winmail.dat' === fileName) {
|
if ('winmail.dat' === fileName) {
|
||||||
return app + 'vnd.ms-tnef';
|
return app + 'ms-tnef';
|
||||||
}
|
}
|
||||||
let ext = fileName.split('.').pop();
|
let ext = fileName.split('.').pop();
|
||||||
if (/^(txt|text|def|list|in|ini|log|sql|cfg|conf)$/.test(ext))
|
if (/^(txt|text|def|list|in|ini|log|sql|cfg|conf)$/.test(ext))
|
||||||
|
|
@ -166,7 +161,7 @@ export const FileInfo = {
|
||||||
*/
|
*/
|
||||||
getType: (ext, mimeType) => {
|
getType: (ext, mimeType) => {
|
||||||
ext = lowerCase(ext);
|
ext = lowerCase(ext);
|
||||||
mimeType = lowerCase(mimeType).replace('csv/plain', 'text/csv').replace('x-','');
|
mimeType = lowerCase(mimeType).replace('csv/plain', 'text/csv');
|
||||||
|
|
||||||
let key = ext + mimeType;
|
let key = ext + mimeType;
|
||||||
if (cache[key]) {
|
if (cache[key]) {
|
||||||
|
|
@ -175,7 +170,7 @@ export const FileInfo = {
|
||||||
|
|
||||||
let result = FileType.Unknown;
|
let result = FileType.Unknown;
|
||||||
const mimeTypeParts = mimeType.split('/'),
|
const mimeTypeParts = mimeType.split('/'),
|
||||||
type = mimeTypeParts[1].replace('-compressed',''),
|
type = mimeTypeParts[1].replace('x-','').replace('-compressed',''),
|
||||||
match = str => mimeType.includes(str),
|
match = str => mimeType.includes(str),
|
||||||
archive = /^(zip|7z|tar|rar|gzip|bzip|bzip2)$/;
|
archive = /^(zip|7z|tar|rar|gzip|bzip|bzip2)$/;
|
||||||
|
|
||||||
|
|
@ -192,12 +187,9 @@ export const FileInfo = {
|
||||||
case ['php', 'js', 'css', 'xml', 'html'].includes(ext) || 'text/html' == mimeType:
|
case ['php', 'js', 'css', 'xml', 'html'].includes(ext) || 'text/html' == mimeType:
|
||||||
result = FileType.Code;
|
result = FileType.Code;
|
||||||
break;
|
break;
|
||||||
case 'eml' == ext || ['message/delivery-status', RFC822].includes(mimeType):
|
case 'eml' == ext || ['message/delivery-status', 'message/rfc822'].includes(mimeType):
|
||||||
result = FileType.Eml;
|
result = FileType.Eml;
|
||||||
break;
|
break;
|
||||||
case 'ics' == ext || mimeType == 'text/calendar':
|
|
||||||
result = FileType.Calendar;
|
|
||||||
break;
|
|
||||||
case 'text' == mimeTypeParts[0] || 'txt' == ext || 'log' == ext:
|
case 'text' == mimeTypeParts[0] || 'txt' == ext || 'log' == ext:
|
||||||
result = FileType.Text;
|
result = FileType.Text;
|
||||||
break;
|
break;
|
||||||
|
|
@ -207,8 +199,9 @@ export const FileInfo = {
|
||||||
case 'pdf' == type || 'pdf' == ext:
|
case 'pdf' == type || 'pdf' == ext:
|
||||||
result = FileType.Pdf;
|
result = FileType.Pdf;
|
||||||
break;
|
break;
|
||||||
case [app+'pgp-signature', app+'pgp-keys', exts.p7m, exts.p7s, exts.p12, exts.pfx].includes(mimeType)
|
case [app+'pgp-signature', app+'pgp-keys'].includes(mimeType)
|
||||||
|| ['asc', 'pem', 'ppk', 'p7s', 'p7m', 'p12', 'pfx'].includes(ext):
|
|| ['asc', 'pem', 'ppk'].includes(ext)
|
||||||
|
|| [app+'pkcs7-signature'].includes(mimeType) || 'p7s' == ext:
|
||||||
result = FileType.Certificate;
|
result = FileType.Certificate;
|
||||||
break;
|
break;
|
||||||
case match(msOffice+'.wordprocessingml') || match(openDoc+'.text') || match('vnd.ms-word')
|
case match(msOffice+'.wordprocessingml') || match(openDoc+'.text') || match('vnd.ms-word')
|
||||||
|
|
@ -247,7 +240,6 @@ export const FileInfo = {
|
||||||
case FileType.Certificate:
|
case FileType.Certificate:
|
||||||
case FileType.Spreadsheet:
|
case FileType.Spreadsheet:
|
||||||
case FileType.Presentation:
|
case FileType.Presentation:
|
||||||
case FileType.Calendar:
|
|
||||||
return result + '-' + fileType;
|
return result + '-' + fileType;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -274,8 +266,8 @@ export const FileInfo = {
|
||||||
},
|
},
|
||||||
|
|
||||||
friendlySize: bytes => {
|
friendlySize: bytes => {
|
||||||
bytes = pInt(bytes);
|
bytes = parseInt(bytes, 10) || 0;
|
||||||
let i = bytes ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0;
|
let i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
return (bytes / Math.pow(1024, i)).toFixed(2>i ? 0 : 1) + ' ' + sizes[i];
|
return (bytes / Math.pow(1024, i)).toFixed(2>i ? 0 : 1) + ' ' + sizes[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,24 @@
|
||||||
import { RFC822 } from 'Common/File';
|
import { isArray, arrayLength } from 'Common/Utils';
|
||||||
import { getFolderInboxName, getFolderFromCacheList } from 'Common/Cache';
|
import {
|
||||||
import { baseCollator } from 'Common/Translator';
|
MessageFlagsCache,
|
||||||
import { SettingsGet } from 'Common/Globals';
|
setFolderHash,
|
||||||
import { isArray, arrayLength, pInt } from 'Common/Utils';
|
getFolderInboxName,
|
||||||
|
getFolderFromCacheList
|
||||||
|
} from 'Common/Cache';
|
||||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||||
import { FolderUserStore } from 'Stores/User/Folder';
|
import { FolderUserStore } from 'Stores/User/Folder';
|
||||||
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
||||||
|
import { getNotification } from 'Common/Translator';
|
||||||
|
import { Settings, } from 'Common/Globals';
|
||||||
|
import { serverRequest } from 'Common/Links';
|
||||||
|
|
||||||
import Remote from 'Remote/User/Fetch';
|
import Remote from 'Remote/User/Fetch';
|
||||||
|
|
||||||
let refreshInterval,
|
|
||||||
// Default every 15 minutes
|
|
||||||
refreshFoldersInterval = 900000;
|
|
||||||
|
|
||||||
export const
|
export const
|
||||||
|
|
||||||
setRefreshFoldersInterval = minutes => {
|
|
||||||
refreshFoldersInterval = Math.max(1, pInt(SettingsGet('minRefreshInterval')), pInt(minutes)) * 60000;
|
|
||||||
clearInterval(refreshInterval);
|
|
||||||
refreshInterval = setInterval(() => {
|
|
||||||
const cF = FolderUserStore.currentFolderFullName(),
|
|
||||||
iF = getFolderInboxName();
|
|
||||||
folderInformation(iF);
|
|
||||||
iF === cF || folderInformation(cF);
|
|
||||||
folderInformationMultiply();
|
|
||||||
}, refreshFoldersInterval);
|
|
||||||
},
|
|
||||||
|
|
||||||
sortFolders = folders => {
|
sortFolders = folders => {
|
||||||
try {
|
try {
|
||||||
let collator = baseCollator(true);
|
let collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
|
||||||
folders.sort((a, b) =>
|
folders.sort((a, b) =>
|
||||||
a.isInbox() ? -1 : (b.isInbox() ? 1 : collator.compare(a.fullName, b.fullName))
|
a.isInbox() ? -1 : (b.isInbox() ? 1 : collator.compare(a.fullName, b.fullName))
|
||||||
);
|
);
|
||||||
|
|
@ -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=} aDisabled
|
||||||
* @param {Array=} aHeaderLines
|
* @param {Array=} aHeaderLines
|
||||||
|
|
@ -50,14 +70,15 @@ folderListOptionsBuilder = (
|
||||||
aDisabled,
|
aDisabled,
|
||||||
aHeaderLines,
|
aHeaderLines,
|
||||||
fRenameCallback,
|
fRenameCallback,
|
||||||
fDisableCallback
|
fDisableCallback,
|
||||||
|
bNoSelectSelectable,
|
||||||
|
aList = FolderUserStore.folderList()
|
||||||
) => {
|
) => {
|
||||||
const
|
const
|
||||||
aResult = [],
|
aResult = [],
|
||||||
sDeepPrefix = '\u00A0\u00A0\u00A0',
|
sDeepPrefix = '\u00A0\u00A0\u00A0',
|
||||||
// FolderSystemPopupView should always be true
|
// FolderSystemPopupView should always be true
|
||||||
showUnsubscribed = fRenameCallback ? !SettingsUserStore.hideUnsubscribed() : true,
|
showUnsubscribed = fRenameCallback ? !SettingsUserStore.hideUnsubscribed() : true,
|
||||||
isDisabled = fDisableCallback || (item => !item.selectable() || aDisabled.includes(item.fullName)),
|
|
||||||
|
|
||||||
foldersWalk = folders => {
|
foldersWalk = folders => {
|
||||||
folders.forEach(oItem => {
|
folders.forEach(oItem => {
|
||||||
|
|
@ -68,7 +89,10 @@ folderListOptionsBuilder = (
|
||||||
sDeepPrefix.repeat(oItem.deep) +
|
sDeepPrefix.repeat(oItem.deep) +
|
||||||
fRenameCallback(oItem),
|
fRenameCallback(oItem),
|
||||||
system: false,
|
system: false,
|
||||||
disabled: isDisabled(oItem)
|
disabled: !bNoSelectSelectable && (
|
||||||
|
!oItem.selectable() ||
|
||||||
|
aDisabled.includes(oItem.fullName) ||
|
||||||
|
fDisableCallback(oItem))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
foldersWalk(oItem.subFolders());
|
foldersWalk(oItem.subFolders());
|
||||||
|
|
@ -89,64 +113,61 @@ folderListOptionsBuilder = (
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
foldersWalk(FolderUserStore.folderList());
|
foldersWalk(aList);
|
||||||
|
|
||||||
return aResult;
|
return aResult;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Every 5 minutes
|
||||||
|
refreshFoldersInterval = 300000,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} folder
|
* @param {string} folder
|
||||||
* @param {Array=} list = []
|
* @param {Array=} list = []
|
||||||
*/
|
*/
|
||||||
folderInformation = (folder, list) => {
|
folderInformation = (folder, list) => {
|
||||||
if (folder?.trim()) {
|
if (folder?.trim()) {
|
||||||
let count = 1;
|
fetchFolderInformation(
|
||||||
const uids = [];
|
(iError, data) => {
|
||||||
|
|
||||||
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) => {
|
|
||||||
if (!iError && data.Result) {
|
if (!iError && data.Result) {
|
||||||
const result = data.Result,
|
const result = data.Result,
|
||||||
folderFromCache = getFolderFromCacheList(result.name);
|
folderFromCache = getFolderFromCacheList(result.Folder);
|
||||||
if (folderFromCache) {
|
if (folderFromCache) {
|
||||||
const oldHash = folderFromCache.etag,
|
const oldHash = folderFromCache.hash,
|
||||||
unreadCountChange = (folderFromCache.unreadEmails() !== result.unreadEmails);
|
unreadCountChange = (folderFromCache.unreadEmails() !== result.unreadEmails);
|
||||||
|
|
||||||
// folderFromCache.revivePropertiesFromJson(result);
|
// folderFromCache.revivePropertiesFromJson(result);
|
||||||
folderFromCache.expires = Date.now();
|
folderFromCache.expires = Date.now();
|
||||||
folderFromCache.uidNext = result.uidNext;
|
folderFromCache.uidNext = result.UidNext;
|
||||||
folderFromCache.etag = result.etag;
|
folderFromCache.hash = result.Hash;
|
||||||
folderFromCache.totalEmails(result.totalEmails);
|
folderFromCache.totalEmails(result.totalEmails);
|
||||||
folderFromCache.unreadEmails(result.unreadEmails);
|
folderFromCache.unreadEmails(result.unreadEmails);
|
||||||
|
|
||||||
|
unreadCountChange && MessageFlagsCache.clearFolder(folderFromCache.fullName);
|
||||||
|
|
||||||
|
if (result.MessagesFlags.length) {
|
||||||
|
result.MessagesFlags.forEach(message =>
|
||||||
|
MessageFlagsCache.setFor(folderFromCache.fullName, message.Uid.toString(), message.Flags)
|
||||||
|
);
|
||||||
|
|
||||||
|
MessagelistUserStore.reloadFlagsAndCachedMessage();
|
||||||
|
}
|
||||||
|
|
||||||
MessagelistUserStore.notifyNewMessages(folderFromCache.fullName, result.newMessages);
|
MessagelistUserStore.notifyNewMessages(folderFromCache.fullName, result.newMessages);
|
||||||
|
|
||||||
if (!oldHash || unreadCountChange || result.etag !== oldHash) {
|
if (!oldHash || unreadCountChange || result.Hash !== oldHash) {
|
||||||
if (folderFromCache.fullName === FolderUserStore.currentFolderFullName()) {
|
if (folderFromCache.fullName === FolderUserStore.currentFolderFullName()) {
|
||||||
MessagelistUserStore.reload();
|
MessagelistUserStore.reload();
|
||||||
/*
|
|
||||||
} else if (getFolderInboxName() === folderFromCache.fullName) {
|
} else if (getFolderInboxName() === folderFromCache.fullName) {
|
||||||
// Remote.messageList(null, {folder: getFolderFromCacheList(getFolderInboxName())}, true);
|
Remote.messageList(null, {Folder: getFolderInboxName()}, true);
|
||||||
Remote.messageList(null, {folder: getFolderInboxName()}, true);
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, {
|
},
|
||||||
folder: folder,
|
folder,
|
||||||
flagsUids: uids,
|
list
|
||||||
uidNext: getFolderFromCacheList(folder)?.uidNext || 0 // Used to check for new messages
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -160,18 +181,21 @@ folderInformationMultiply = (boot = false) => {
|
||||||
if (!iError && arrayLength(oData.Result)) {
|
if (!iError && arrayLength(oData.Result)) {
|
||||||
const utc = Date.now();
|
const utc = Date.now();
|
||||||
oData.Result.forEach(item => {
|
oData.Result.forEach(item => {
|
||||||
const folder = getFolderFromCacheList(item.name);
|
const folder = getFolderFromCacheList(item.Folder);
|
||||||
|
|
||||||
if (folder) {
|
if (folder) {
|
||||||
const oldHash = folder.etag,
|
const oldHash = folder.hash,
|
||||||
unreadCountChange = folder.unreadEmails() !== item.unreadEmails;
|
unreadCountChange = folder.unreadEmails() !== item.unreadEmails;
|
||||||
|
|
||||||
// folder.revivePropertiesFromJson(item);
|
// folder.revivePropertiesFromJson(item);
|
||||||
folder.expires = utc;
|
folder.expires = utc;
|
||||||
folder.etag = item.etag;
|
folder.hash = item.Hash;
|
||||||
folder.totalEmails(item.totalEmails);
|
folder.totalEmails(item.totalEmails);
|
||||||
folder.unreadEmails(item.unreadEmails);
|
folder.unreadEmails(item.unreadEmails);
|
||||||
|
|
||||||
if (!oldHash || item.etag !== oldHash) {
|
unreadCountChange && MessageFlagsCache.clearFolder(folder.fullName);
|
||||||
|
|
||||||
|
if (!oldHash || item.Hash !== oldHash) {
|
||||||
if (folder.fullName === FolderUserStore.currentFolderFullName()) {
|
if (folder.fullName === FolderUserStore.currentFolderFullName()) {
|
||||||
MessagelistUserStore.reload();
|
MessagelistUserStore.reload();
|
||||||
}
|
}
|
||||||
|
|
@ -186,26 +210,104 @@ folderInformationMultiply = (boot = false) => {
|
||||||
boot && setTimeout(() => folderInformationMultiply(true), 2000);
|
boot && setTimeout(() => folderInformationMultiply(true), 2000);
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
folders: folders
|
Folders: folders
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
dropFilesInFolder = (sFolderFullName, files) => {
|
moveOrDeleteResponseHelper = (iError, oData) => {
|
||||||
let count = files.length;
|
if (iError) {
|
||||||
for (const file of files) {
|
setFolderHash(FolderUserStore.currentFolderFullName(), '');
|
||||||
if (RFC822 === file.type) {
|
alert(getNotification(iError));
|
||||||
let data = new FormData;
|
} else if (FolderUserStore.currentFolder()) {
|
||||||
data.append('folder', sFolderFullName);
|
if (2 === arrayLength(oData.Result)) {
|
||||||
data.append('appendFile', file);
|
setFolderHash(oData.Result[0], oData.Result[1]);
|
||||||
Remote.request('FolderAppend', (iError, data)=>{
|
|
||||||
iError && console.error(data.message);
|
|
||||||
0 == --count
|
|
||||||
&& FolderUserStore.currentFolderFullName() == sFolderFullName
|
|
||||||
&& MessagelistUserStore.reload(true, true);
|
|
||||||
}, data);
|
|
||||||
} else {
|
} else {
|
||||||
--count;
|
setFolderHash(FolderUserStore.currentFolderFullName(), '');
|
||||||
|
}
|
||||||
|
MessagelistUserStore.reload(!MessagelistUserStore.length);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
messagesMoveHelper = (fromFolderFullName, toFolderFullName, uidsForMove) => {
|
||||||
|
const
|
||||||
|
sSpamFolder = FolderUserStore.spamFolder(),
|
||||||
|
isSpam = sSpamFolder === toFolderFullName,
|
||||||
|
isHam = !isSpam && sSpamFolder === fromFolderFullName && getFolderInboxName() === toFolderFullName;
|
||||||
|
|
||||||
|
Remote.abort('MessageList').request('MessageMove',
|
||||||
|
moveOrDeleteResponseHelper,
|
||||||
|
{
|
||||||
|
FromFolder: fromFolderFullName,
|
||||||
|
ToFolder: toFolderFullName,
|
||||||
|
Uids: [...uidsForMove].join(','),
|
||||||
|
MarkAsRead: (isSpam || FolderUserStore.trashFolder() === toFolderFullName) ? 1 : 0,
|
||||||
|
Learning: isSpam ? 'SPAM' : isHam ? 'HAM' : ''
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
messagesDeleteHelper = (sFromFolderFullName, aUidForRemove) => {
|
||||||
|
Remote.abort('MessageList').request('MessageDelete',
|
||||||
|
moveOrDeleteResponseHelper,
|
||||||
|
{
|
||||||
|
Folder: sFromFolderFullName,
|
||||||
|
Uids: [...aUidForRemove].join(',')
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} sFromFolderFullName
|
||||||
|
* @param {Set} oUids
|
||||||
|
* @param {string} sToFolderFullName
|
||||||
|
* @param {boolean=} bCopy = false
|
||||||
|
*/
|
||||||
|
moveMessagesToFolder = (sFromFolderFullName, oUids, sToFolderFullName, bCopy) => {
|
||||||
|
if (sFromFolderFullName !== sToFolderFullName && oUids?.size) {
|
||||||
|
const oFromFolder = getFolderFromCacheList(sFromFolderFullName),
|
||||||
|
oToFolder = getFolderFromCacheList(sToFolderFullName);
|
||||||
|
|
||||||
|
if (oFromFolder && oToFolder) {
|
||||||
|
bCopy
|
||||||
|
? Remote.request('MessageCopy', null, {
|
||||||
|
FromFolder: oFromFolder.fullName,
|
||||||
|
ToFolder: oToFolder.fullName,
|
||||||
|
Uids: [...oUids].join(',')
|
||||||
|
})
|
||||||
|
: messagesMoveHelper(oFromFolder.fullName, oToFolder.fullName, oUids);
|
||||||
|
|
||||||
|
MessagelistUserStore.removeMessagesFromList(oFromFolder.fullName, oUids, oToFolder.fullName, bCopy);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
dropFilesInFolder = (sFolderFullName, files) => {
|
||||||
|
let count = 0,
|
||||||
|
fn = () => 0 == --count
|
||||||
|
&& FolderUserStore.currentFolderFullName() == sFolderFullName
|
||||||
|
&& MessagelistUserStore.reload(true, true);
|
||||||
|
for (const file of files) {
|
||||||
|
if ('message/rfc822' === file.type) {
|
||||||
|
++count;
|
||||||
|
let data = new FormData;
|
||||||
|
data.append('Folder', sFolderFullName);
|
||||||
|
data.append('AppendFile', file);
|
||||||
|
data.XToken = Settings.app('token');
|
||||||
|
fetch(serverRequest('Append'), {
|
||||||
|
method: 'POST',
|
||||||
|
mode: 'same-origin',
|
||||||
|
cache: 'no-cache',
|
||||||
|
redirect: 'error',
|
||||||
|
referrerPolicy: 'no-referrer',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
.then(fn)
|
||||||
|
.catch(fn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
import { doc, appEl, $htmlCL } from 'Common/Globals';
|
import { doc, elementById, $htmlCL } from 'Common/Globals';
|
||||||
|
|
||||||
// Fullscreen must be on app, else other popups fail
|
// Fullscreen must be on app, else other popups fail
|
||||||
export const
|
export const
|
||||||
appFullscreen = () => (doc.fullscreenElement || doc.webkitFullscreenElement) === appEl,
|
app = elementById('rl-app'),
|
||||||
|
appFullscreen = () => (doc.fullscreenElement || doc.webkitFullscreenElement) === app,
|
||||||
exitFullscreen = () => appFullscreen() && (doc.exitFullscreen || doc.webkitExitFullscreen).call(doc),
|
exitFullscreen = () => appFullscreen() && (doc.exitFullscreen || doc.webkitExitFullscreen).call(doc),
|
||||||
isFullscreen = ko.observable(false),
|
isFullscreen = ko.observable(false),
|
||||||
toggleFullscreen = () => isFullscreen() ? exitFullscreen() : appEl.requestFullscreen();
|
toggleFullscreen = () => isFullscreen() ? exitFullscreen() : app.requestFullscreen();
|
||||||
|
|
||||||
if (appEl) {
|
if (app) {
|
||||||
let event = 'fullscreenchange';
|
let event = 'fullscreenchange';
|
||||||
if (!appEl.requestFullscreen && appEl.webkitRequestFullscreen) {
|
if (!app.requestFullscreen && app.webkitRequestFullscreen) {
|
||||||
appEl.requestFullscreen = appEl.webkitRequestFullscreen;
|
app.requestFullscreen = app.webkitRequestFullscreen;
|
||||||
event = 'webkit'+event;
|
event = 'webkit'+event;
|
||||||
}
|
}
|
||||||
if (appEl.requestFullscreen) {
|
if (app.requestFullscreen) {
|
||||||
doc.addEventListener(event, () => {
|
doc.addEventListener(event, () => {
|
||||||
isFullscreen(appFullscreen());
|
isFullscreen(appFullscreen());
|
||||||
$htmlCL.toggle('rl-fullscreen', appFullscreen());
|
$htmlCL.toggle('rl-fullscreen', appFullscreen());
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,8 @@ export const
|
||||||
|
|
||||||
elementById = id => doc.getElementById(id),
|
elementById = id => doc.getElementById(id),
|
||||||
|
|
||||||
appEl = elementById('rl-app'),
|
|
||||||
|
|
||||||
Settings = rl.settings,
|
Settings = rl.settings,
|
||||||
SettingsGet = Settings.get,
|
SettingsGet = Settings.get,
|
||||||
SettingsAdmin = name => (SettingsGet('Admin') || {})[name],
|
|
||||||
SettingsCapa = name => name && !!(SettingsGet('Capa') || {})[name],
|
SettingsCapa = name => name && !!(SettingsGet('Capa') || {})[name],
|
||||||
|
|
||||||
dropdowns = [],
|
dropdowns = [],
|
||||||
|
|
@ -34,11 +31,6 @@ export const
|
||||||
new CustomEvent(name, {detail:detail, cancelable: !!cancelable})
|
new CustomEvent(name, {detail:detail, cancelable: !!cancelable})
|
||||||
),
|
),
|
||||||
|
|
||||||
stopEvent = event => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
},
|
|
||||||
|
|
||||||
formFieldFocused = () => doc.activeElement?.matches('input,textarea'),
|
formFieldFocused = () => doc.activeElement?.matches('input,textarea'),
|
||||||
|
|
||||||
addShortcut = (...args) => shortcuts.add(...args),
|
addShortcut = (...args) => shortcuts.add(...args),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
import { createElement } from 'Common/Globals';
|
import { createElement } from 'Common/Globals';
|
||||||
import { forEachObjectEntry, isArray, pInt } from 'Common/Utils';
|
import { forEachObjectEntry, pInt } from 'Common/Utils';
|
||||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||||
|
|
||||||
const
|
const
|
||||||
tmpl = createElement('template'),
|
tpl = createElement('template'),
|
||||||
|
|
||||||
turndown = new TurndownService(),
|
|
||||||
|
|
||||||
htmlre = /[&<>"']/g,
|
htmlre = /[&<>"']/g,
|
||||||
httpre = /^(https?:)?\/\//i,
|
httpre = /^(https?:)?\/\//i,
|
||||||
htmlmap = {
|
htmlmap = {
|
||||||
|
|
@ -17,52 +14,13 @@ const
|
||||||
"'": '''
|
"'": '''
|
||||||
},
|
},
|
||||||
|
|
||||||
keepTagContent = 'form,button,data', // font
|
|
||||||
|
|
||||||
allowedTags = [
|
|
||||||
// Structural Elements:
|
|
||||||
'blockquote','br','div','figcaption','figure','h1','h2','h3','h4','h5','h6','hgroup','hr','p','wbr',
|
|
||||||
'article','aside','header','footer','main','section',
|
|
||||||
'details','summary','nav',
|
|
||||||
// List Elements
|
|
||||||
'dd','dl','dt','li','ol','ul',
|
|
||||||
// Text Formatting Elements
|
|
||||||
'a','abbr','address','b','bdi','bdo','cite','code','del','dfn',
|
|
||||||
'em','i','ins','kbd','mark','pre','q','rp','rt','ruby','s','samp','small',
|
|
||||||
'span','strong','sub','sup','time','u','var',
|
|
||||||
// Deprecated by HTML Standard
|
|
||||||
'acronym','big','center','dir','font','marquee',
|
|
||||||
'nobr','plaintext','rb','rtc','strike','tt',
|
|
||||||
// Media Elements
|
|
||||||
'img',//'picture','source',
|
|
||||||
// Table Elements
|
|
||||||
'caption','col','colgroup','table','tbody','td','tfoot','th','thead','tr',
|
|
||||||
// Disallowed but converted later
|
|
||||||
'style','xmp'
|
|
||||||
].join(','),
|
|
||||||
|
|
||||||
nonEmptyTags = [
|
|
||||||
'A','B','EM','I','SPAN','STRONG'
|
|
||||||
],
|
|
||||||
|
|
||||||
blockquoteSwitcher = () => {
|
|
||||||
SettingsUserStore.collapseBlockquotes() &&
|
|
||||||
// tmpl.content.querySelectorAll('blockquote').forEach(node => {
|
|
||||||
[...tmpl.content.querySelectorAll('blockquote')].reverse().forEach(node => {
|
|
||||||
const el = createElement('details', {class:'sm-bq-switcher'});
|
|
||||||
el.innerHTML = '<summary>•••</summary>';
|
|
||||||
node.replaceWith(el);
|
|
||||||
el.append(node);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
replaceWithChildren = node => node.replaceWith(...[...node.childNodes]),
|
replaceWithChildren = node => node.replaceWith(...[...node.childNodes]),
|
||||||
|
|
||||||
urlRegExp = /https?:\/\/[^\p{C}\p{Z}]+[^\p{C}\p{Z}.]/gu,
|
url = /(^|\s|\n|\/?>)(https?:\/\/[-A-Z0-9+&#/%?=()~_|!:,.;]*[-A-Z0-9+&#/%=~()_|])/gi,
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
email = /(^|\r|\n|\p{C}\p{Z})((?:[^"(),.:;<>@[\]\\\p{C}\p{Z}]+(?:\.[^"(),.:;<>@[\]\\\p{C}\p{Z}]+)*|"(?:\\?[^"\\\p{C}\p{Z}])*")@[^@\p{C}\p{Z}]+[^@\p{C}\p{Z}.])/gui,
|
email = /(^|\s|\n|\/?>)((?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x21\x23-\x5b\x5d-\x7f]|\\[\x21\x23-\x5b\x5d-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x21-\x5a\x53-\x7f]|\\[\x21\x23-\x5b\x5d-\x7f])+)\]))/gi,
|
||||||
// rfc3966
|
// rfc3966
|
||||||
tel = /(tel:(\+[0-9().-]+|[0-9*#().-]+(;phone-context=\+[0-9+().-]+)?))/g,
|
tel = /(^|\s|\n|\/?>)(tel:(\+[0-9().-]+|[0-9*#().-]+(;phone-context=\+[0-9+().-]+)?))/g,
|
||||||
|
|
||||||
// Strip tracking
|
// Strip tracking
|
||||||
/** TODO: implement other url strippers like from
|
/** TODO: implement other url strippers like from
|
||||||
|
|
@ -73,18 +31,15 @@ const
|
||||||
* https://github.com/M66B/FairEmail/blob/master/app/src/main/java/eu/faircode/email/UriHelper.java
|
* https://github.com/M66B/FairEmail/blob/master/app/src/main/java/eu/faircode/email/UriHelper.java
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
stripParams = /^(utm_|ec_|fbclid|mc_eid|mkt_tok|_hsenc|vero_id|oly_enc_id|oly_anon_id|__s|Referrer|mailing|elq|bch|trc|ref|correlation_id|pd_|pf_|email_hash)$/i,
|
stripParams = /^(utm_|ec_|fbclid|mc_eid|mkt_tok|_hsenc|vero_id|oly_enc_id|oly_anon_id|__s|Referrer|mailing|elq|bch|trc|ref|correlation_id|pd_|pf_)/i,
|
||||||
urlGetParam = (url, name) => new URL(url).searchParams.get(name) || url,
|
urlGetParam = (url, name) => new URL(url).searchParams.get(name) || url,
|
||||||
base64Url = data => atob(data.replace(/_/g,'/').replace(/-/g,'+')),
|
base64Url = data => atob(data.replace(/_/g,'/').replace(/-/g,'+')),
|
||||||
decode = decodeURIComponent,
|
|
||||||
stripTracking = url => {
|
stripTracking = url => {
|
||||||
try {
|
try {
|
||||||
let nurl = url
|
let nurl = url
|
||||||
// Copernica
|
|
||||||
.replace(/^.+\/(https%253A[^/?&]+).*$/i, (...m) => decode(decode(m[1])))
|
|
||||||
.replace(/tracking\.(printabout\.nl[^?]+)\?.*/i, (...m) => m[1])
|
.replace(/tracking\.(printabout\.nl[^?]+)\?.*/i, (...m) => m[1])
|
||||||
.replace(/(zalando\.nl[^?]+)\?.*/i, (...m) => m[1])
|
.replace(/(zalando\.nl[^?]+)\?.*/i, (...m) => m[1])
|
||||||
.replace(/^.+(awstrack\.me|redditmail\.com)\/.+(https:%2F%2F[^/]+).*/i, (...m) => decode(m[2]))
|
.replace(/^.+(awstrack\.me|redditmail\.com)\/.+(https:%2F%2F[^/]+).*/i, (...m) => decodeURIComponent(m[2]))
|
||||||
.replace(/^.+(www\.google|safelinks\.protection\.outlook\.com|mailchimp\.com).+url=.+$/i,
|
.replace(/^.+(www\.google|safelinks\.protection\.outlook\.com|mailchimp\.com).+url=.+$/i,
|
||||||
() => urlGetParam(url, 'url'))
|
() => urlGetParam(url, 'url'))
|
||||||
.replace(/^.+click\.godaddy\.com.+$/i, () => urlGetParam(url, 'redir'))
|
.replace(/^.+click\.godaddy\.com.+$/i, () => urlGetParam(url, 'redir'))
|
||||||
|
|
@ -119,97 +74,6 @@ const
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
},
|
|
||||||
|
|
||||||
cleanCSS = source =>
|
|
||||||
source.trim()
|
|
||||||
.replace(/;\s*-[^;]+/g, '')
|
|
||||||
.replace(/^\s*-[^;]+(;|$)/g, '')
|
|
||||||
.replace(/white-space[^;]+/g, '')
|
|
||||||
// Drop Microsoft Office style properties
|
|
||||||
// .replace(/mso-[^:;]+:[^;]+/gi, '')
|
|
||||||
,
|
|
||||||
|
|
||||||
/*
|
|
||||||
Parses given css string, and returns css object
|
|
||||||
keys as selectors and values are css rules
|
|
||||||
eliminates all css comments before parsing
|
|
||||||
|
|
||||||
@param source css string to be parsed
|
|
||||||
|
|
||||||
@return object css
|
|
||||||
*/
|
|
||||||
parseCSS = source => {
|
|
||||||
const css = [];
|
|
||||||
css.toString = () => css.reduce(
|
|
||||||
(ret, tmp) =>
|
|
||||||
ret + tmp.selector + ' {\n'
|
|
||||||
+ (tmp.type === 'media' ? tmp.subStyles.toString() : tmp.rules)
|
|
||||||
+ '}\n'
|
|
||||||
,
|
|
||||||
''
|
|
||||||
);
|
|
||||||
/**
|
|
||||||
* Given css array, parses it and then for every selector,
|
|
||||||
* prepends namespace to prevent css collision issues
|
|
||||||
*/
|
|
||||||
css.applyNamespace = namespace => css.forEach(obj => {
|
|
||||||
if (obj.type === 'media') {
|
|
||||||
obj.subStyles.applyNamespace(namespace);
|
|
||||||
} else {
|
|
||||||
obj.selector = obj.selector.split(',').map(selector =>
|
|
||||||
(namespace + ' .mail-body ' + selector.replace(/\./g, '.msg-'))
|
|
||||||
.replace(/\sbody/gi, '')
|
|
||||||
).join(',');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (source) {
|
|
||||||
source = source
|
|
||||||
// strip comments
|
|
||||||
.replace(/\/\*[\s\S]*?\*\//gi, '')
|
|
||||||
// strip MS Word comments
|
|
||||||
.replace(/<!--[\s\S]*?-->/gi, '')
|
|
||||||
// strip HTML, as < is no CSS combinator anyway
|
|
||||||
.replace(/<[\s\S]*/gi, '');
|
|
||||||
// .replace(/<\/?[a-z][\s\S]*?>/gi, '');
|
|
||||||
|
|
||||||
// unified regex to match css & media queries together
|
|
||||||
let unified = /(?:(\s*?@(?:media)[\s\S]*?){([\s\S]*?)}\s*?})|(?:([\s\S]*?){([\s\S]*?)})/gi,
|
|
||||||
arr;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
arr = unified.exec(source);
|
|
||||||
if (arr === null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let selector = arr[arr[2] === undefined ? 3 : 1].split('\r\n').join('\n').trim()
|
|
||||||
// Never have more than a single line break in a row
|
|
||||||
.replace(/\n+/, "\n")
|
|
||||||
// Remove :root and html
|
|
||||||
.split(/\s+/g).map(item => item.replace(/^(:root|html)$/, '')).join(' ').trim();
|
|
||||||
|
|
||||||
// determine the type
|
|
||||||
if (selector.includes('@media')) {
|
|
||||||
// we have a media query
|
|
||||||
css.push({
|
|
||||||
selector: selector,
|
|
||||||
type: 'media',
|
|
||||||
subStyles: parseCSS(arr[2] + '\n}') //recursively parse media query inner css
|
|
||||||
});
|
|
||||||
} else if (selector && !selector.includes('@')) {
|
|
||||||
// we have standard css
|
|
||||||
// ignores @import, @keyframe, @font-face statements
|
|
||||||
css.push({
|
|
||||||
selector: selector,
|
|
||||||
rules: cleanCSS(arr[4])
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return css;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const
|
export const
|
||||||
|
|
@ -225,32 +89,28 @@ export const
|
||||||
* @param {string} text
|
* @param {string} text
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
cleanHtml = (html, oAttachments, msgId) => {
|
cleanHtml = (html, oAttachments) => {
|
||||||
let aColor;
|
|
||||||
const
|
const
|
||||||
debug = false, // Config()->Get('debug', 'enable', false);
|
debug = false, // Config()->Get('debug', 'enable', false);
|
||||||
detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'),
|
detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'),
|
||||||
|
|
||||||
bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()),
|
|
||||||
minFontSize = parseInt(SettingsUserStore.minFontSize()),
|
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
hasExternals: false,
|
hasExternals: false
|
||||||
tracking: false,
|
|
||||||
linkedData: []
|
|
||||||
},
|
},
|
||||||
|
|
||||||
isMsg = !!msgId,
|
findAttachmentByCid = cid => oAttachments.findByCid(cid),
|
||||||
|
findLocationByCid = cid => {
|
||||||
findAttachmentByCid = cId => oAttachments.findByCid(cId),
|
const attachment = findAttachmentByCid(cid);
|
||||||
findLocationByCid = cId => {
|
|
||||||
const attachment = findAttachmentByCid(cId);
|
|
||||||
return attachment?.contentLocation ? attachment : 0;
|
return attachment?.contentLocation ? attachment : 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
// convert body attributes to CSS
|
// convert body attributes to CSS
|
||||||
tasks = {
|
tasks = {
|
||||||
link: value => aColor = value,
|
link: value => {
|
||||||
|
if (/^#[a-fA-Z0-9]{3,6}$/.test(value)) {
|
||||||
|
tpl.content.querySelectorAll('a').forEach(node => node.style.color || (node.style.color = value))
|
||||||
|
}
|
||||||
|
},
|
||||||
text: (value, node) => node.style.color = value,
|
text: (value, node) => node.style.color = value,
|
||||||
topmargin: (value, node) => node.style.marginTop = pInt(value) + 'px',
|
topmargin: (value, node) => node.style.marginTop = pInt(value) + 'px',
|
||||||
leftmargin: (value, node) => node.style.marginLeft = pInt(value) + 'px',
|
leftmargin: (value, node) => node.style.marginLeft = pInt(value) + 'px',
|
||||||
|
|
@ -258,15 +118,6 @@ export const
|
||||||
rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px'
|
rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px'
|
||||||
},
|
},
|
||||||
allowedAttributes = [
|
allowedAttributes = [
|
||||||
// SnappyMail
|
|
||||||
// 'data-x-href-broken',
|
|
||||||
// 'data-x-href-tracking',
|
|
||||||
'data-x-src',
|
|
||||||
// 'data-x-src-hidden',
|
|
||||||
// 'data-x-src-tracking',
|
|
||||||
// 'data-x-src-broken',
|
|
||||||
// 'data-x-style-url',
|
|
||||||
// 'data-x-style-broken-urls',
|
|
||||||
// defaults
|
// defaults
|
||||||
'name',
|
'name',
|
||||||
'dir', 'lang', 'style', 'title',
|
'dir', 'lang', 'style', 'title',
|
||||||
|
|
@ -294,112 +145,42 @@ export const
|
||||||
'abbr', 'scope',
|
'abbr', 'scope',
|
||||||
// td
|
// td
|
||||||
'colspan', 'rowspan', 'headers'
|
'colspan', 'rowspan', 'headers'
|
||||||
// others
|
],
|
||||||
//'class', 'id', 'target'
|
disallowedTags = [
|
||||||
|
'HEAD','STYLE','SVG','SCRIPT','TITLE','LINK','BASE','META',
|
||||||
|
'INPUT','OUTPUT','SELECT','BUTTON','TEXTAREA',
|
||||||
|
'BGSOUND','KEYGEN','SOURCE','OBJECT','EMBED','APPLET','IFRAME','FRAME','FRAMESET','VIDEO','AUDIO','AREA','MAP'
|
||||||
|
],
|
||||||
|
nonEmptyTags = [
|
||||||
|
'A','B','EM','I','SPAN','STRONG'
|
||||||
];
|
];
|
||||||
|
|
||||||
if (SettingsUserStore.allowStyles()) {
|
tpl.innerHTML = html
|
||||||
allowedAttributes.push('class');
|
|
||||||
} else {
|
|
||||||
msgId = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl.innerHTML = html
|
|
||||||
// .replace(/<pre[^>]*>[\s\S]*?<\/pre>/gi, pre => pre.replace(/\n/g, '\n<br>'))
|
// .replace(/<pre[^>]*>[\s\S]*?<\/pre>/gi, pre => pre.replace(/\n/g, '\n<br>'))
|
||||||
|
.replace(/<!doctype[^>]*>/gi, '')
|
||||||
|
.replace(/<\?xml[^>]*\?>/gi, '')
|
||||||
// Not supported by <template> element
|
// Not supported by <template> element
|
||||||
// .replace(/<!doctype[^>]*>/gi, '')
|
|
||||||
// .replace(/<\?xml[^>]*\?>/gi, '')
|
|
||||||
// https://github.com/the-djmaze/snappymail/issues/1860
|
|
||||||
.replace(/<(\/?)x-html(\s[^>]*)?>/gi, '')
|
|
||||||
.replace(/<(\/?)head(\s[^>]*)?>/gi, '')
|
|
||||||
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
|
.replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>')
|
||||||
// .replace(/<\/?(html|head)[^>]*>/gi, '')
|
.replace(/<\/?(html|head)[^>]*>/gi, '')
|
||||||
// Fix Reddit https://github.com/the-djmaze/snappymail/issues/540
|
// Fix Reddit https://github.com/the-djmaze/snappymail/issues/540
|
||||||
.replace(/<span class="preview-text"[\s\S]+?<\/span>/, '')
|
.replace(/<span class="preview-text"[\s\S]+?<\/span>/, '')
|
||||||
// https://github.com/the-djmaze/snappymail/issues/900
|
|
||||||
.replace(/\u2028/g,' ')
|
|
||||||
// https://github.com/the-djmaze/snappymail/issues/1415
|
|
||||||
.replace(/<br>\s*<\/p>/gi,'</p>')
|
|
||||||
.trim();
|
.trim();
|
||||||
html = '';
|
html = '';
|
||||||
|
|
||||||
// Strip all comments
|
// \MailSo\Base\HtmlUtils::ClearComments()
|
||||||
const nodeIterator = document.createNodeIterator(tmpl.content, NodeFilter.SHOW_COMMENT);
|
// https://github.com/the-djmaze/snappymail/issues/187
|
||||||
|
const nodeIterator = document.createNodeIterator(tpl.content, NodeFilter.SHOW_COMMENT);
|
||||||
while (nodeIterator.nextNode()) {
|
while (nodeIterator.nextNode()) {
|
||||||
nodeIterator.referenceNode.remove();
|
nodeIterator.referenceNode.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
tpl.content.querySelectorAll('*').forEach(oElement => {
|
||||||
* Basic support for Linked Data (Structured Email)
|
|
||||||
* https://json-ld.org/
|
|
||||||
* https://structured.email/
|
|
||||||
**/
|
|
||||||
tmpl.content.querySelectorAll('script[type="application/ld+json"]').forEach(oElement => {
|
|
||||||
// Could be array of objects or single object
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(oElement.textContent);
|
|
||||||
(isArray(data) ? data : [data]).forEach(entry => result.linkedData.push(entry));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e, oElement.textContent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// https://github.com/the-djmaze/snappymail/issues/1125
|
|
||||||
tmpl.content.querySelectorAll(keepTagContent).forEach(oElement => replaceWithChildren(oElement));
|
|
||||||
|
|
||||||
tmpl.content.querySelectorAll(
|
|
||||||
':not('+allowedTags+'),a:empty,span:empty'
|
|
||||||
+ (0 < bqLevel ? ',' + (new Array(1 + bqLevel).fill('blockquote').join(' ')) : '')
|
|
||||||
).forEach(oElement => oElement.remove());
|
|
||||||
/* // Is this slower or faster?
|
|
||||||
).forEach(oElement => {
|
|
||||||
if (!node || !node.contains(oElement)) {
|
|
||||||
oElement.remove();
|
|
||||||
node = oElement;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
|
|
||||||
// https://github.com/the-djmaze/snappymail/issues/1641
|
|
||||||
let body = tmpl.content.querySelector('.mail-body');
|
|
||||||
[...tmpl.content.querySelectorAll('.mail-body + .mail-body')]
|
|
||||||
.forEach(oElement => body.append(...oElement.childNodes));
|
|
||||||
/*
|
|
||||||
.forEach(oElement => {
|
|
||||||
let bq = createElement('blockquote');
|
|
||||||
bq.append(...oElement.childNodes);
|
|
||||||
body.replaceWith(bq);
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
|
|
||||||
[...tmpl.content.querySelectorAll('*')].forEach(oElement => {
|
|
||||||
const name = oElement.tagName,
|
const name = oElement.tagName,
|
||||||
oStyle = oElement.style;
|
oStyle = oElement.style;
|
||||||
|
|
||||||
if ('STYLE' === name) {
|
|
||||||
let css = msgId ? parseCSS(oElement.textContent) : [];
|
|
||||||
if (css.length) {
|
|
||||||
css.applyNamespace(msgId);
|
|
||||||
css = css.toString();
|
|
||||||
if (SettingsUserStore.removeColors()) {
|
|
||||||
css = css.replace(/(background-)?color:[^};]+;?/g, '');
|
|
||||||
}
|
|
||||||
oElement.textContent = css;
|
|
||||||
} else {
|
|
||||||
oElement.remove();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('XMP' === name) {
|
|
||||||
const pre = createElement('pre');
|
|
||||||
pre.innerHTML = encodeHtml(oElement.innerHTML);
|
|
||||||
oElement.replaceWith(pre);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// \MailSo\Base\HtmlUtils::ClearTags()
|
// \MailSo\Base\HtmlUtils::ClearTags()
|
||||||
if ('none' == oStyle.display
|
if (disallowedTags.includes(name)
|
||||||
|
|| 'none' == oStyle.display
|
||||||
|| 'hidden' == oStyle.visibility
|
|| 'hidden' == oStyle.visibility
|
||||||
// || (oStyle.lineHeight && 1 > parseFloat(oStyle.lineHeight)
|
// || (oStyle.lineHeight && 1 > parseFloat(oStyle.lineHeight)
|
||||||
// || (oStyle.maxHeight && 1 > parseFloat(oStyle.maxHeight)
|
// || (oStyle.maxHeight && 1 > parseFloat(oStyle.maxHeight)
|
||||||
|
|
@ -409,31 +190,36 @@ export const
|
||||||
oElement.remove();
|
oElement.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
// Idea to allow CSS
|
||||||
|
if ('STYLE' === name) {
|
||||||
|
msgId = '#rl-msg-061eb4d647771be4185943ce91f0039d';
|
||||||
|
oElement.textContent = oElement.textContent
|
||||||
|
.replace(/[^{}]+{/g, m => msgId + ' ' + m.replace(',', ', '+msgId+' '))
|
||||||
|
.replace(/(background-)color:[^};]+/g, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*/
|
||||||
const aAttrsForRemove = [],
|
const aAttrsForRemove = [],
|
||||||
className = oElement.className,
|
|
||||||
hasAttribute = name => oElement.hasAttribute(name),
|
hasAttribute = name => oElement.hasAttribute(name),
|
||||||
getAttribute = name => hasAttribute(name) ? oElement.getAttribute(name).trim() : '',
|
getAttribute = name => hasAttribute(name) ? oElement.getAttribute(name).trim() : '',
|
||||||
setAttribute = (name, value) => oElement.setAttribute(name, value),
|
setAttribute = (name, value) => oElement.setAttribute(name, value),
|
||||||
delAttribute = name => {
|
delAttribute = name => oElement.removeAttribute(name);
|
||||||
let value = getAttribute(name);
|
|
||||||
oElement.removeAttribute(name);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
if ('mail-body' === className) {
|
if ('mail-body' === oElement.className) {
|
||||||
forEachObjectEntry(tasks, (name, cb) =>
|
forEachObjectEntry(tasks, (name, cb) => {
|
||||||
hasAttribute(name) && cb(delAttribute(name), oElement)
|
if (hasAttribute(name)) {
|
||||||
);
|
cb(getAttribute(name), oElement);
|
||||||
} else if (msgId && className) {
|
delAttribute(name);
|
||||||
oElement.className = className.replace(/(^|\s+)/g, '$1msg-');
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oElement.hasAttributes()) {
|
if (oElement.hasAttributes()) {
|
||||||
let i = oElement.attributes.length;
|
let i = oElement.attributes.length;
|
||||||
while (i--) {
|
while (i--) {
|
||||||
let sAttrName = oElement.attributes[i].name.toLowerCase();
|
let sAttrName = oElement.attributes[i].name.toLowerCase();
|
||||||
if (!allowedAttributes.includes(sAttrName) && ('class' !== sAttrName || 'mail-body' !== className)) {
|
if (!allowedAttributes.includes(sAttrName)) {
|
||||||
delAttribute(sAttrName);
|
delAttribute(sAttrName);
|
||||||
aAttrsForRemove.push(sAttrName);
|
aAttrsForRemove.push(sAttrName);
|
||||||
}
|
}
|
||||||
|
|
@ -445,21 +231,25 @@ export const
|
||||||
// if ('TABLE' === name || 'TD' === name || 'TH' === name) {
|
// if ('TABLE' === name || 'TD' === name || 'TH' === name) {
|
||||||
if (!oStyle.backgroundImage) {
|
if (!oStyle.backgroundImage) {
|
||||||
if ('TD' !== name && 'TH' !== name) {
|
if ('TD' !== name && 'TH' !== name) {
|
||||||
['width','height'].forEach(key => {
|
|
||||||
if (hasAttribute(key)) {
|
|
||||||
value = delAttribute(key);
|
|
||||||
oStyle[key] || (oStyle[key] = value.includes('%') ? value : value + 'px');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Make width responsive
|
// Make width responsive
|
||||||
value = oStyle.width;
|
if (hasAttribute('width')) {
|
||||||
if (100 < parseInt(value,10) && !oStyle.maxWidth) {
|
value = getAttribute('width');
|
||||||
|
oStyle.width = value.includes('%') ? value : value + 'px';
|
||||||
|
delAttribute('width');
|
||||||
|
}
|
||||||
|
value = oStyle.removeProperty('width');
|
||||||
|
if (value) {
|
||||||
oStyle.maxWidth = value;
|
oStyle.maxWidth = value;
|
||||||
oStyle.width = '100%';
|
oStyle.width = '100%';
|
||||||
}
|
}
|
||||||
// Make height responsive
|
// Make height responsive
|
||||||
|
if (hasAttribute('height')) {
|
||||||
|
value = getAttribute('height');
|
||||||
|
oStyle.height = value.includes('%') ? value : value + 'px';
|
||||||
|
delAttribute('height');
|
||||||
|
}
|
||||||
value = oStyle.removeProperty('height');
|
value = oStyle.removeProperty('height');
|
||||||
if (value && !oStyle.maxHeight) {
|
if (value) {
|
||||||
oStyle.maxHeight = value;
|
oStyle.maxHeight = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -468,23 +258,18 @@ export const
|
||||||
if ('A' === name) {
|
if ('A' === name) {
|
||||||
value = oElement.href;
|
value = oElement.href;
|
||||||
if (!/^([a-z]+):/i.test(value)) {
|
if (!/^([a-z]+):/i.test(value)) {
|
||||||
setAttribute('data-x-href-broken', value);
|
setAttribute('data-x-broken-href', value);
|
||||||
delAttribute('href');
|
delAttribute('href');
|
||||||
} else {
|
} else {
|
||||||
oElement.href = stripTracking(value);
|
oElement.href = stripTracking(value);
|
||||||
if (oElement.href != value) {
|
|
||||||
result.tracking = true;
|
|
||||||
setAttribute('data-x-href-tracking', value);
|
|
||||||
}
|
|
||||||
setAttribute('target', '_blank');
|
setAttribute('target', '_blank');
|
||||||
// setAttribute('rel', 'external nofollow noopener noreferrer');
|
// setAttribute('rel', 'external nofollow noopener noreferrer');
|
||||||
}
|
}
|
||||||
setAttribute('tabindex', '-1');
|
setAttribute('tabindex', '-1');
|
||||||
aColor && !oElement.style.color && (oElement.style.color = aColor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (['CENTER','FORM'].includes(name)) {
|
// if (['CENTER','FORM'].includes(name)) {
|
||||||
if (nonEmptyTags.includes(name) && ('' == oElement.textContent.trim())) {
|
if ('FORM' === name || 'O:P' === name || (nonEmptyTags.includes(name) && ('' == oElement.textContent.trim()))) {
|
||||||
('A' !== name || !oElement.querySelector('IMG')) && replaceWithChildren(oElement);
|
('A' !== name || !oElement.querySelector('IMG')) && replaceWithChildren(oElement);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -497,85 +282,81 @@ export const
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let skipStyle = false;
|
let skipStyle = false;
|
||||||
if (isMsg) {
|
if (hasAttribute('src')) {
|
||||||
value = isMsg && delAttribute('src');
|
value = stripTracking(getAttribute('src'));
|
||||||
if (value) {
|
delAttribute('src');
|
||||||
if ('IMG' === name) {
|
|
||||||
oElement.loading = 'lazy';
|
if ('IMG' === name) {
|
||||||
let attachment;
|
oElement.loading = 'lazy';
|
||||||
if (value.startsWith('cid:'))
|
let attachment;
|
||||||
{
|
if (value.startsWith('cid:'))
|
||||||
setAttribute('data-x-src', value);
|
{
|
||||||
value = value.slice(4);
|
value = value.slice(4);
|
||||||
attachment = findAttachmentByCid(value);
|
setAttribute('data-x-src-cid', value);
|
||||||
if (attachment?.download) {
|
attachment = findAttachmentByCid(value);
|
||||||
oElement.src = attachment.linkPreview();
|
if (attachment?.download) {
|
||||||
// oElement.title || (oElement.title = '+attachment.fileName);
|
oElement.src = attachment.linkPreview();
|
||||||
attachment.isInline(true);
|
oElement.title += ' ('+attachment.fileName+')';
|
||||||
attachment.isLinked(true);
|
attachment.isInline(true);
|
||||||
}
|
attachment.isLinked(true);
|
||||||
}
|
}
|
||||||
else if ((attachment = findLocationByCid(value)))
|
}
|
||||||
{
|
else if ((attachment = findLocationByCid(value)))
|
||||||
if (attachment.download) {
|
{
|
||||||
oElement.src = attachment.linkPreview();
|
if (attachment.download) {
|
||||||
attachment.isLinked(true);
|
oElement.src = attachment.linkPreview();
|
||||||
}
|
attachment.isLinked(true);
|
||||||
}
|
|
||||||
else if (detectHiddenImages
|
|
||||||
&& ((oStyle.maxHeight && 3 > pInt(oStyle.maxHeight)) // TODO: issue with 'in'
|
|
||||||
|| (oStyle.maxWidth && 3 > pInt(oStyle.maxWidth)) // TODO: issue with 'in'
|
|
||||||
|| (oStyle.width && 2 > pInt(oStyle.width))
|
|
||||||
|| [
|
|
||||||
'email.microsoftemail.com/open',
|
|
||||||
'github.com/notifications/beacon/',
|
|
||||||
'/track/open', // mandrillapp.com list-manage.com
|
|
||||||
'google-analytics.com'
|
|
||||||
].filter(uri => value.toLowerCase().includes(uri)).length
|
|
||||||
)) {
|
|
||||||
skipStyle = true;
|
|
||||||
oStyle.display = 'none';
|
|
||||||
// setAttribute('style', 'display:none');
|
|
||||||
setAttribute('data-x-src-hidden', value);
|
|
||||||
// result.tracking = true;
|
|
||||||
}
|
|
||||||
else if (httpre.test(value))
|
|
||||||
{
|
|
||||||
let src = stripTracking(value);
|
|
||||||
if (src != value) {
|
|
||||||
result.tracking = true;
|
|
||||||
setAttribute('data-x-src-tracking', value);
|
|
||||||
}
|
|
||||||
setAttribute('data-x-src', src);
|
|
||||||
result.hasExternals = true;
|
|
||||||
oElement.alt || (oElement.alt = src.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
|
|
||||||
}
|
|
||||||
else if (value.startsWith('data:image/'))
|
|
||||||
{
|
|
||||||
oElement.src = value;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
setAttribute('data-x-src-broken', value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (detectHiddenImages
|
||||||
|
&& ((oStyle.maxHeight && 3 > pInt(oStyle.maxHeight)) // TODO: issue with 'in'
|
||||||
|
|| (oStyle.maxWidth && 3 > pInt(oStyle.maxWidth)) // TODO: issue with 'in'
|
||||||
|
|| [
|
||||||
|
'email.microsoftemail.com/open',
|
||||||
|
'github.com/notifications/beacon/',
|
||||||
|
'/track/open', // mandrillapp.com list-manage.com
|
||||||
|
'google-analytics.com'
|
||||||
|
].filter(uri => value.toLowerCase().includes(uri)).length
|
||||||
|
)) {
|
||||||
|
skipStyle = true;
|
||||||
|
oStyle.display = 'none';
|
||||||
|
// setAttribute('style', 'display:none');
|
||||||
|
setAttribute('data-x-src-hidden', value);
|
||||||
|
}
|
||||||
|
else if (httpre.test(value))
|
||||||
|
{
|
||||||
|
setAttribute('data-x-src', value);
|
||||||
|
result.hasExternals = true;
|
||||||
|
oElement.alt || (oElement.alt = value.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
|
||||||
|
}
|
||||||
|
else if (value.startsWith('data:image/'))
|
||||||
|
{
|
||||||
|
oElement.src = value;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
setAttribute('data-x-src-broken', value);
|
setAttribute('data-x-src-broken', value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
setAttribute('data-x-src-broken', value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAttribute('background')) {
|
if (hasAttribute('background')) {
|
||||||
oStyle.backgroundImage = 'url("' + delAttribute('background') + '")';
|
oStyle.backgroundImage = 'url("' + getAttribute('background') + '")';
|
||||||
|
delAttribute('background');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAttribute('bgcolor')) {
|
if (hasAttribute('bgcolor')) {
|
||||||
oStyle.backgroundColor = delAttribute('bgcolor');
|
oStyle.backgroundColor = getAttribute('bgcolor');
|
||||||
|
delAttribute('bgcolor');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAttribute('color')) {
|
if (hasAttribute('color')) {
|
||||||
oStyle.color = delAttribute('color');
|
oStyle.color = getAttribute('color');
|
||||||
|
delAttribute('color');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skipStyle) {
|
if (!skipStyle) {
|
||||||
|
|
@ -627,10 +408,8 @@ export const
|
||||||
setAttribute('data-x-style-broken-urls', JSON.stringify(urls_broken));
|
setAttribute('data-x-style-broken-urls', JSON.stringify(urls_broken));
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/the-djmaze/snappymail/issues/1082
|
if (11 > pInt(oStyle.fontSize)) {
|
||||||
if (minFontSize > pInt(oStyle.fontSize)) {
|
oStyle.removeProperty('font-size');
|
||||||
oStyle.fontSize = minFontSize + "px";
|
|
||||||
// oStyle.removeProperty('font-size');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removes background and color
|
// Removes background and color
|
||||||
|
|
@ -642,7 +421,8 @@ export const
|
||||||
oStyle.removeProperty('color');
|
oStyle.removeProperty('color');
|
||||||
}
|
}
|
||||||
|
|
||||||
oStyle.cssText && (oStyle.cssText = cleanCSS(oStyle.cssText));
|
// Drop Microsoft Office style properties
|
||||||
|
// oStyle.cssText = oStyle.cssText.replace(/mso-[^:;]+:[^;]+/gi, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debug && aAttrsForRemove.length) {
|
if (debug && aAttrsForRemove.length) {
|
||||||
|
|
@ -650,10 +430,8 @@ export const
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
isMsg && blockquoteSwitcher();
|
// return tpl.content.firstChild;
|
||||||
|
result.html = tpl.innerHTML.trim();
|
||||||
// return tmpl.content.firstChild;
|
|
||||||
result.html = tmpl.innerHTML.trim();
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -662,12 +440,9 @@ export const
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
htmlToPlain = html => {
|
htmlToPlain = html => {
|
||||||
if (SettingsUserStore.markdown()) {
|
|
||||||
return htmlToMarkdown(html);
|
|
||||||
}
|
|
||||||
const
|
const
|
||||||
hr = '⎯'.repeat(64),
|
hr = '⎯'.repeat(64),
|
||||||
forEach = (selector, fn) => tmpl.content.querySelectorAll(selector).forEach(fn),
|
forEach = (selector, fn) => tpl.content.querySelectorAll(selector).forEach(fn),
|
||||||
blockquotes = node => {
|
blockquotes = node => {
|
||||||
let bq;
|
let bq;
|
||||||
while ((bq = node.querySelector('blockquote'))) {
|
while ((bq = node.querySelector('blockquote'))) {
|
||||||
|
|
@ -685,8 +460,6 @@ export const
|
||||||
html = html
|
html = html
|
||||||
.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gim, (...args) =>
|
.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gim, (...args) =>
|
||||||
1 < args.length ? args[1].toString().replace(/\n/g, '<br>') : '')
|
1 < args.length ? args[1].toString().replace(/\n/g, '<br>') : '')
|
||||||
// Remove line duplication
|
|
||||||
.replace(/<br><\/div>/gi, '</div>')
|
|
||||||
.replace(/\r?\n/g, '')
|
.replace(/\r?\n/g, '')
|
||||||
.replace(/\s+/gm, ' ');
|
.replace(/\s+/gm, ' ');
|
||||||
|
|
||||||
|
|
@ -697,12 +470,10 @@ export const
|
||||||
html = html.replace(/\n*<\/(div|tr)(\s[\s\S]*?)?>\n*/gi, '\n');
|
html = html.replace(/\n*<\/(div|tr)(\s[\s\S]*?)?>\n*/gi, '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl.innerHTML = html
|
tpl.innerHTML = html
|
||||||
.replace(/<t[dh](\s[\s\S]*?)?>/gi, '\t')
|
.replace(/<t[dh](\s[\s\S]*?)?>/gi, '\t')
|
||||||
.replace(/<\/tr(\s[\s\S]*?)?>/gi, '\n');
|
.replace(/<\/tr(\s[\s\S]*?)?>/gi, '\n');
|
||||||
|
|
||||||
forEach('style', node => node.remove());
|
|
||||||
|
|
||||||
// lines
|
// lines
|
||||||
forEach('hr', node => node.replaceWith(`\n\n${hr}\n\n`));
|
forEach('hr', node => node.replaceWith(`\n\n${hr}\n\n`));
|
||||||
|
|
||||||
|
|
@ -739,9 +510,7 @@ export const
|
||||||
forEach('a', a => {
|
forEach('a', a => {
|
||||||
let txt = a.textContent, href = a.href;
|
let txt = a.textContent, href = a.href;
|
||||||
return a.replaceWith(
|
return a.replaceWith(
|
||||||
txt.replace(/[\s()-]+/g, '').includes(href.replace(/^[a-z]:/, '').replace(/[\s()-]+/g, ''))
|
txt.trim() == href || href.includes('mailto:') ? txt : txt + ' ' + href + ' '
|
||||||
? txt
|
|
||||||
: txt + ' ' + href + ' '
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -751,21 +520,16 @@ export const
|
||||||
forEach('i,em', i => i.replaceWith(`*${i.textContent}*`));
|
forEach('i,em', i => i.replaceWith(`*${i.textContent}*`));
|
||||||
|
|
||||||
// Convert line-breaks
|
// Convert line-breaks
|
||||||
tmpl.innerHTML = tmpl.innerHTML
|
tpl.innerHTML = tpl.innerHTML
|
||||||
.replace(/\n{3,}/gm, '\n\n')
|
.replace(/\n{3,}/gm, '\n\n')
|
||||||
.replace(/\n<br[^>]*>/g, '\n')
|
.replace(/\n<br[^>]*>/g, '\n')
|
||||||
.replace(/<br[^>]*>\n/g, '\n');
|
.replace(/<br[^>]*>\n/g, '\n');
|
||||||
forEach('br', br => br.replaceWith('\n'));
|
forEach('br', br => br.replaceWith('\n'));
|
||||||
|
|
||||||
// Blockquotes must be last
|
// Blockquotes must be last
|
||||||
blockquotes(tmpl.content);
|
blockquotes(tpl.content);
|
||||||
|
|
||||||
return (tmpl.content.textContent || '').trim();
|
return (tpl.content.textContent || '').trim();
|
||||||
},
|
|
||||||
|
|
||||||
htmlToMarkdown = html => {
|
|
||||||
tmpl.innerHTML = html;
|
|
||||||
return turndown.turndown(tmpl.content);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -776,9 +540,7 @@ export const
|
||||||
plainToHtml = plain => {
|
plainToHtml = plain => {
|
||||||
plain = plain.toString()
|
plain = plain.toString()
|
||||||
.replace(/\r/g, '')
|
.replace(/\r/g, '')
|
||||||
.replace(/^>[> ]>+/gm, ([match]) => (match ? match.replace(/[ ]+/g, '') : match))
|
.replace(/^>[> ]>+/gm, ([match]) => (match ? match.replace(/[ ]+/g, '') : match));
|
||||||
// https://github.com/the-djmaze/snappymail/issues/900
|
|
||||||
.replace(/\u2028/g,' ');
|
|
||||||
|
|
||||||
let bIn = false,
|
let bIn = false,
|
||||||
bDo = true,
|
bDo = true,
|
||||||
|
|
@ -819,28 +581,200 @@ export const
|
||||||
aText = aNextText;
|
aText = aNextText;
|
||||||
} while (bDo);
|
} while (bDo);
|
||||||
|
|
||||||
tmpl.innerHTML = aText.join('\n')
|
return aText.join('\n')
|
||||||
// .replace(/~~~\/blockquote~~~\n~~~blockquote~~~/g, '\n')
|
// .replace(/~~~\/blockquote~~~\n~~~blockquote~~~/g, '\n')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(urlRegExp, (...m) => {
|
.replace(url, (...m) => {
|
||||||
m[0] = stripTracking(m[0]);
|
m[2] = stripTracking(m[2]);
|
||||||
return `<a href="${m[0]}" target="_blank">${m[0]}</a>`;
|
return `${m[1]}<a href="${m[2]}" target="_blank">${m[2]}</a>`;
|
||||||
})
|
})
|
||||||
.replace(email, '$1<a href="mailto:$2">$2</a>')
|
.replace(email, '$1<a href="mailto:$2">$2</a>')
|
||||||
.replace(tel, '<a href="$1">$1</a>')
|
.replace(tel, '$1<a href="$2">$2</a>')
|
||||||
.replace(/~~~blockquote~~~\s*/g, '<blockquote>')
|
.replace(/~~~blockquote~~~\s*/g, '<blockquote>')
|
||||||
.replace(/\s*~~~\/blockquote~~~/g, '</blockquote>')
|
.replace(/\s*~~~\/blockquote~~~/g, '</blockquote>')
|
||||||
.replace(/\n/g, '<br>');
|
.replace(/\n/g, '<br>');
|
||||||
blockquoteSwitcher();
|
},
|
||||||
return tmpl.innerHTML.trim();
|
|
||||||
};
|
WYSIWYGS = ko.observableArray();
|
||||||
|
|
||||||
|
WYSIWYGS.push(['Squire', (owner, container, onReady)=>{
|
||||||
|
let squire = new SquireUI(container);
|
||||||
|
setTimeout(()=>onReady(squire), 1);
|
||||||
|
/*
|
||||||
|
squire.on('blur', () => owner.blurTrigger());
|
||||||
|
squire.on('focus', () => clearTimeout(owner.blurTimer));
|
||||||
|
squire.on('mode', () => {
|
||||||
|
owner.blurTrigger();
|
||||||
|
owner.onModeChange?.(!owner.isPlain());
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
}]);
|
||||||
|
|
||||||
|
rl.registerWYSIWYG = (name, construct) => WYSIWYGS.push([name, construct]);
|
||||||
|
|
||||||
|
export class HtmlEditor {
|
||||||
|
/**
|
||||||
|
* @param {Object} element
|
||||||
|
* @param {Function=} onBlur
|
||||||
|
* @param {Function=} onReady
|
||||||
|
* @param {Function=} onModeChange
|
||||||
|
*/
|
||||||
|
constructor(element, onBlur = null, onReady = null, onModeChange = null) {
|
||||||
|
this.blurTimer = 0;
|
||||||
|
|
||||||
|
this.onBlur = onBlur;
|
||||||
|
this.onModeChange = onModeChange;
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
onReady = onReady ? [onReady] : [];
|
||||||
|
this.onReady = fn => onReady.push(fn);
|
||||||
|
// TODO: make 'which' user configurable
|
||||||
|
// const which = 'CKEditor4',
|
||||||
|
// wysiwyg = WYSIWYGS.find(item => which == item[0]) || WYSIWYGS.find(item => 'Squire' == item[0]);
|
||||||
|
const wysiwyg = WYSIWYGS.find(item => 'Squire' == item[0]);
|
||||||
|
wysiwyg[1](this, element, editor => {
|
||||||
|
this.editor = editor;
|
||||||
|
editor.on('blur', () => this.blurTrigger());
|
||||||
|
editor.on('focus', () => clearTimeout(this.blurTimer));
|
||||||
|
editor.on('mode', () => {
|
||||||
|
this.blurTrigger();
|
||||||
|
this.onModeChange?.(!this.isPlain());
|
||||||
|
});
|
||||||
|
this.onReady = fn => fn();
|
||||||
|
onReady.forEach(fn => fn());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blurTrigger() {
|
||||||
|
if (this.onBlur) {
|
||||||
|
clearTimeout(this.blurTimer);
|
||||||
|
this.blurTimer = setTimeout(() => this.onBlur?.(), 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isHtml() {
|
||||||
|
return this.editor ? !this.isPlain() : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isPlain() {
|
||||||
|
return this.editor ? 'plain' === this.editor.mode : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
clearCachedSignature() {
|
||||||
|
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||||
|
clearCache: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} signature
|
||||||
|
* @param {bool} html
|
||||||
|
* @param {bool} insertBefore
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
setSignature(signature, html, insertBefore = false) {
|
||||||
|
this.onReady(() => this.editor.execCommand('insertSignature', {
|
||||||
|
isHtml: html,
|
||||||
|
insertBefore: insertBefore,
|
||||||
|
signature: signature
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean=} wrapIsHtml = false
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getData() {
|
||||||
|
let result = '';
|
||||||
|
if (this.editor) {
|
||||||
|
try {
|
||||||
|
if (this.isPlain() && this.editor.plugins.plain && this.editor.__plain) {
|
||||||
|
result = this.editor.__plain.getRawData();
|
||||||
|
} else {
|
||||||
|
result = this.editor.getData();
|
||||||
|
}
|
||||||
|
} catch (e) {} // eslint-disable-line no-empty
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getDataWithHtmlMark() {
|
||||||
|
return (this.isHtml() ? ':HTML:' : '') + this.getData();
|
||||||
|
}
|
||||||
|
|
||||||
|
modeWysiwyg() {
|
||||||
|
this.onReady(() => this.editor.setMode('wysiwyg'));
|
||||||
|
}
|
||||||
|
modePlain() {
|
||||||
|
this.onReady(() => this.editor.setMode('plain'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setHtmlOrPlain(text) {
|
||||||
|
text.startsWith(':HTML:')
|
||||||
|
? this.setHtml(text.slice(6))
|
||||||
|
: this.setPlain(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(mode, data) {
|
||||||
|
this.onReady(() => {
|
||||||
|
const editor = this.editor;
|
||||||
|
this.clearCachedSignature();
|
||||||
|
try {
|
||||||
|
editor.setMode(mode);
|
||||||
|
if (this.isPlain() && editor.plugins.plain && editor.__plain) {
|
||||||
|
editor.__plain.setRawData(data);
|
||||||
|
} else {
|
||||||
|
editor.setData(data);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setHtml(html) {
|
||||||
|
this.setData('wysiwyg', html/*.replace(/<p[^>]*><\/p>/gi, '')*/);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlain(txt) {
|
||||||
|
this.setData('plain', txt);
|
||||||
|
}
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
this.onReady(() => this.editor.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFocus() {
|
||||||
|
try {
|
||||||
|
return !!this.editor?.focusManager.hasFocus;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blur() {
|
||||||
|
this.onReady(() => this.editor.focusManager.blur(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.onReady(() => this.isPlain() ? this.setPlain('') : this.setHtml(''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rl.Utils = {
|
rl.Utils = {
|
||||||
cleanHtml: cleanHtml,
|
|
||||||
htmlToPlain: htmlToPlain,
|
htmlToPlain: htmlToPlain,
|
||||||
plainToHtml: plainToHtml,
|
plainToHtml: plainToHtml
|
||||||
htmlToMarkdown: htmlToMarkdown
|
|
||||||
// markdownToHtml: md => marked.parse(md)
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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(''));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { pInt } from 'Common/Utils';
|
import { pInt } from 'Common/Utils';
|
||||||
import { doc, Settings, SettingsAdmin } from 'Common/Globals';
|
import { Settings } from 'Common/Globals';
|
||||||
|
|
||||||
const
|
const
|
||||||
BASE = doc.location.pathname.replace(/\/+$/,'') + '/',
|
|
||||||
HASH_PREFIX = '#/',
|
HASH_PREFIX = '#/',
|
||||||
|
SERVER_PREFIX = './?',
|
||||||
|
|
||||||
adminPath = () => rl.adminArea() && !SettingsAdmin('host'),
|
adminPath = () => rl.adminArea() && !Settings.app('adminHostUse'),
|
||||||
|
|
||||||
prefix = () => BASE + '?' + (adminPath() ? SettingsAdmin('path') : '');
|
prefix = () => SERVER_PREFIX + (adminPath() ? Settings.app('adminPath') : '');
|
||||||
|
|
||||||
export const
|
export const
|
||||||
SUB_QUERY_PREFIX = '&q[]=',
|
SUB_QUERY_PREFIX = '&q[]=',
|
||||||
|
|
@ -21,7 +21,7 @@ export const
|
||||||
/**
|
/**
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
logoutLink = () => adminPath() ? prefix() : BASE,
|
logoutLink = () => adminPath() ? prefix() : './',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
|
|
@ -30,22 +30,22 @@ export const
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
serverRequestRaw = (type, hash) =>
|
serverRequestRaw = (type, hash) =>
|
||||||
BASE + '?/Raw/' + SUB_QUERY_PREFIX + '/'
|
SERVER_PREFIX + '/Raw/' + SUB_QUERY_PREFIX + '/'
|
||||||
+ '0/' // Settings.get('accountHash') ?
|
+ '0/' // Settings.get('AccountHash') ?
|
||||||
+ (type
|
+ (type
|
||||||
? type + '/' + (hash ? SUB_QUERY_PREFIX + '/' + hash : '')
|
? type + '/' + (hash ? SUB_QUERY_PREFIX + '/' + hash : '')
|
||||||
: ''),
|
: ''),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} download
|
* @param {string} download
|
||||||
|
* @param {string=} customSpecSuffix
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
attachmentDownload = (download) =>
|
attachmentDownload = (download, customSpecSuffix) =>
|
||||||
serverRequestRaw('Download', download),
|
serverRequestRaw('Download', download, customSpecSuffix),
|
||||||
|
|
||||||
proxy = url =>
|
proxy = url =>
|
||||||
BASE + '?/ProxyExternal/'
|
SERVER_PREFIX + '/ProxyExternal/'
|
||||||
// + btoa(JSON.stringify([token,url]).replace(/ /g, '%20')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
|
|
||||||
+ btoa(url.replace(/ /g, '%20')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
|
+ btoa(url.replace(/ /g, '%20')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
|
||||||
// + b64EncodeJSONSafe(url.replace(/ /g, '%20')),
|
// + b64EncodeJSONSafe(url.replace(/ /g, '%20')),
|
||||||
|
|
||||||
|
|
@ -55,16 +55,13 @@ export const
|
||||||
*/
|
*/
|
||||||
serverRequest = type => prefix() + '/' + type + '/' + SUB_QUERY_PREFIX + '/0/',
|
serverRequest = type => prefix() + '/' + type + '/' + SUB_QUERY_PREFIX + '/0/',
|
||||||
|
|
||||||
// Is '?/Css/0/Admin' needed?
|
|
||||||
cssLink = theme => BASE + '?/Css/0/User/-/' + encodeURI(theme) + '/-/' + Date.now() + '/Hash/-/Json/',
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} lang
|
* @param {string} lang
|
||||||
* @param {boolean} isAdmin
|
* @param {boolean} isAdmin
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
langLink = (lang, isAdmin) =>
|
langLink = (lang, isAdmin) =>
|
||||||
BASE + '?/Lang/0/' + (isAdmin ? 'Admin' : 'App')
|
SERVER_PREFIX + '/Lang/0/' + (isAdmin ? 'Admin' : 'App')
|
||||||
+ '/' + encodeURI(lang)
|
+ '/' + encodeURI(lang)
|
||||||
+ '/' + Settings.app('version') + '/',
|
+ '/' + Settings.app('version') + '/',
|
||||||
|
|
||||||
|
|
@ -79,10 +76,6 @@ export const
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
themePreviewLink = theme => {
|
themePreviewLink = theme => {
|
||||||
if (theme.endsWith('@nextcloud')) {
|
|
||||||
theme = theme.slice(0, theme.length - 10).trim();
|
|
||||||
return parent.OC.webroot + '/themes/' + encodeURI(theme) + '/snappymail/preview.png';
|
|
||||||
}
|
|
||||||
let path = 'webVersionPath';
|
let path = 'webVersionPath';
|
||||||
if (theme.endsWith('@custom')) {
|
if (theme.endsWith('@custom')) {
|
||||||
theme = theme.slice(0, theme.length - 7).trim();
|
theme = theme.slice(0, theme.length - 7).trim();
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { koComputable } from 'External/ko';
|
||||||
oCallbacks:
|
oCallbacks:
|
||||||
ItemSelect
|
ItemSelect
|
||||||
MiddleClick
|
MiddleClick
|
||||||
canSelect
|
AutoSelect
|
||||||
ItemGetUid
|
ItemGetUid
|
||||||
UpOrDown
|
UpOrDown
|
||||||
*/
|
*/
|
||||||
|
|
@ -21,13 +21,15 @@ export class Selector {
|
||||||
* @param {koProperty} koFocusedItem
|
* @param {koProperty} koFocusedItem
|
||||||
* @param {string} sItemSelector
|
* @param {string} sItemSelector
|
||||||
* @param {string} sItemCheckedSelector
|
* @param {string} sItemCheckedSelector
|
||||||
|
* @param {string} sItemFocusedSelector
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
koList,
|
koList,
|
||||||
koSelectedItem,
|
koSelectedItem,
|
||||||
koFocusedItem,
|
koFocusedItem,
|
||||||
sItemSelector,
|
sItemSelector,
|
||||||
sItemCheckedSelector
|
sItemCheckedSelector,
|
||||||
|
sItemFocusedSelector
|
||||||
) {
|
) {
|
||||||
koFocusedItem = (koFocusedItem || ko.observable(null)).extend({ toggleSubscribeProperty: [this, 'focused'] });
|
koFocusedItem = (koFocusedItem || ko.observable(null)).extend({ toggleSubscribeProperty: [this, 'focused'] });
|
||||||
koSelectedItem = (koSelectedItem || ko.observable(null)).extend({ toggleSubscribeProperty: [null, 'selected'] });
|
koSelectedItem = (koSelectedItem || ko.observable(null)).extend({ toggleSubscribeProperty: [null, 'selected'] });
|
||||||
|
|
@ -44,7 +46,7 @@ export class Selector {
|
||||||
|
|
||||||
this.sItemSelector = sItemSelector;
|
this.sItemSelector = sItemSelector;
|
||||||
this.sItemCheckedSelector = sItemCheckedSelector;
|
this.sItemCheckedSelector = sItemCheckedSelector;
|
||||||
this.sItemFocusedSelector = sItemSelector + '.focused';
|
this.sItemFocusedSelector = sItemFocusedSelector;
|
||||||
|
|
||||||
this.sLastUid = '';
|
this.sLastUid = '';
|
||||||
this.oCallbacks = {};
|
this.oCallbacks = {};
|
||||||
|
|
@ -63,8 +65,8 @@ export class Selector {
|
||||||
this.listChecked.subscribe(items => {
|
this.listChecked.subscribe(items => {
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
koSelectedItem() ? koSelectedItem(null) : koSelectedItem.valueHasMutated?.();
|
koSelectedItem() ? koSelectedItem(null) : koSelectedItem.valueHasMutated?.();
|
||||||
} else {
|
} else if (this.autoSelect()) {
|
||||||
this.autoSelect();
|
koSelectedItem(koFocusedItem());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -72,7 +74,7 @@ export class Selector {
|
||||||
|
|
||||||
koSelectedItem.subscribe(item => {
|
koSelectedItem.subscribe(item => {
|
||||||
if (item) {
|
if (item) {
|
||||||
// koList.forEach(subItem => subItem.checked(false));
|
koList.forEach(subItem => subItem.checked(false));
|
||||||
selectedItemUseCallback && itemSelectedThrottle(item);
|
selectedItemUseCallback && itemSelectedThrottle(item);
|
||||||
} else {
|
} else {
|
||||||
selectedItemUseCallback && itemSelected();
|
selectedItemUseCallback && itemSelected();
|
||||||
|
|
@ -85,7 +87,8 @@ export class Selector {
|
||||||
* Below code is used to keep checked/focused/selected states when array is refreshed.
|
* Below code is used to keep checked/focused/selected states when array is refreshed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let aCheckedCache = [],
|
let aCache = [],
|
||||||
|
aCheckedCache = [],
|
||||||
mFocused = null,
|
mFocused = null,
|
||||||
mSelected = null;
|
mSelected = null;
|
||||||
|
|
||||||
|
|
@ -96,6 +99,7 @@ export class Selector {
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const uid = this.getItemUid(item);
|
const uid = this.getItemUid(item);
|
||||||
if (uid) {
|
if (uid) {
|
||||||
|
aCache.push(uid);
|
||||||
item.checked() && aCheckedCache.push(uid);
|
item.checked() && aCheckedCache.push(uid);
|
||||||
if (!mFocused && item.focused()) {
|
if (!mFocused && item.focused()) {
|
||||||
mFocused = uid;
|
mFocused = uid;
|
||||||
|
|
@ -114,12 +118,12 @@ export class Selector {
|
||||||
koList.subscribe(aItems => {
|
koList.subscribe(aItems => {
|
||||||
selectedItemUseCallback = false;
|
selectedItemUseCallback = false;
|
||||||
|
|
||||||
this.unselect();
|
koFocusedItem(null);
|
||||||
|
koSelectedItem(null);
|
||||||
|
|
||||||
if (isArray(aItems)) {
|
if (isArray(aItems)) {
|
||||||
let temp,
|
let temp,
|
||||||
isChecked,
|
isChecked;
|
||||||
next = this.iFocusedNextHelper || this.iSelectNextHelper;
|
|
||||||
|
|
||||||
aItems.forEach(item => {
|
aItems.forEach(item => {
|
||||||
const uid = this.getItemUid(item);
|
const uid = this.getItemUid(item);
|
||||||
|
|
@ -144,10 +148,24 @@ export class Selector {
|
||||||
|
|
||||||
selectedItemUseCallback = true;
|
selectedItemUseCallback = true;
|
||||||
|
|
||||||
if (next && aItems.length && !koFocusedItem()) {
|
if (
|
||||||
temp = aItems[-1 === next ? aItems.length - 1 : 0];
|
(this.iSelectNextHelper || this.iFocusedNextHelper) &&
|
||||||
|
aItems.length &&
|
||||||
|
!koFocusedItem()
|
||||||
|
) {
|
||||||
|
temp = null;
|
||||||
|
if (this.iFocusedNextHelper) {
|
||||||
|
temp = aItems[-1 === this.iFocusedNextHelper ? aItems.length - 1 : 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!temp && this.iSelectNextHelper) {
|
||||||
|
temp = aItems[-1 === this.iSelectNextHelper ? aItems.length - 1 : 0];
|
||||||
|
}
|
||||||
|
|
||||||
if (temp) {
|
if (temp) {
|
||||||
this.iSelectNextHelper && koSelectedItem(temp);
|
if (this.iSelectNextHelper) {
|
||||||
|
koSelectedItem(temp);
|
||||||
|
}
|
||||||
|
|
||||||
koFocusedItem(temp);
|
koFocusedItem(temp);
|
||||||
|
|
||||||
|
|
@ -159,9 +177,12 @@ export class Selector {
|
||||||
this.iFocusedNextHelper = 0;
|
this.iFocusedNextHelper = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
!isChecked && !koSelectedItem() && this.autoSelect();
|
if (this.autoSelect() && !isChecked && !koSelectedItem()) {
|
||||||
|
koSelectedItem(koFocusedItem());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aCache = [];
|
||||||
aCheckedCache = [];
|
aCheckedCache = [];
|
||||||
mFocused = null;
|
mFocused = null;
|
||||||
mSelected = null;
|
mSelected = null;
|
||||||
|
|
@ -185,11 +206,10 @@ export class Selector {
|
||||||
|
|
||||||
addEventsListeners(contentScrollable, {
|
addEventsListeners(contentScrollable, {
|
||||||
click: event => {
|
click: event => {
|
||||||
const el = event.target.closestWithin(this.sItemSelector, contentScrollable);
|
let el = event.target.closestWithin(this.sItemSelector, contentScrollable);
|
||||||
let item = el && ko.dataFor(el);
|
el && this.actionClick(ko.dataFor(el), event);
|
||||||
el && (this.oCallbacks.click || (()=>1))(event, item) && this.actionClick(item, event);
|
|
||||||
|
|
||||||
item = getItem(this.sItemCheckedSelector);
|
const item = getItem(this.sItemCheckedSelector);
|
||||||
if (item) {
|
if (item) {
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
this.actionClick(item, event);
|
this.actionClick(item, event);
|
||||||
|
|
@ -234,10 +254,8 @@ export class Selector {
|
||||||
/**
|
/**
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
autoSelect(bForce) {
|
autoSelect() {
|
||||||
(bForce || (this.oCallbacks.canSelect || (()=>1))())
|
return (this.oCallbacks.AutoSelect || (()=>1))() && this.focusedItem();
|
||||||
&& this.focusedItem()
|
|
||||||
&& this.selectedItem(this.focusedItem());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -254,11 +272,10 @@ export class Selector {
|
||||||
* @param {boolean=} bForceSelect = false
|
* @param {boolean=} bForceSelect = false
|
||||||
*/
|
*/
|
||||||
newSelectPosition(sEventKey, bShiftKey, bForceSelect) {
|
newSelectPosition(sEventKey, bShiftKey, bForceSelect) {
|
||||||
let result;
|
let isArrow = 'ArrowUp' === sEventKey || 'ArrowDown' === sEventKey,
|
||||||
|
result;
|
||||||
|
|
||||||
const up = 'ArrowUp' === sEventKey,
|
const pageStep = 10,
|
||||||
isArrow = up || 'ArrowDown' === sEventKey,
|
|
||||||
pageStep = 10,
|
|
||||||
list = this.list(),
|
list = this.list(),
|
||||||
listLen = list.length,
|
listLen = list.length,
|
||||||
focused = this.focusedItem();
|
focused = this.focusedItem();
|
||||||
|
|
@ -270,7 +287,8 @@ export class Selector {
|
||||||
} else if (listLen) {
|
} else if (listLen) {
|
||||||
if (focused) {
|
if (focused) {
|
||||||
if (isArrow) {
|
if (isArrow) {
|
||||||
let i = list.indexOf(focused);
|
let i = list.indexOf(focused),
|
||||||
|
up = 'ArrowUp' == sEventKey;
|
||||||
if (bShiftKey) {
|
if (bShiftKey) {
|
||||||
shiftStart = -1 < shiftStart ? shiftStart : i;
|
shiftStart = -1 < shiftStart ? shiftStart : i;
|
||||||
shiftStart == i
|
shiftStart == i
|
||||||
|
|
@ -313,7 +331,9 @@ export class Selector {
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
this.focusedItem(result);
|
this.focusedItem(result);
|
||||||
!this.list.hasChecked() && this.autoSelect(bForceSelect);
|
if ((this.autoSelect() || bForceSelect) && !this.list.hasChecked()) {
|
||||||
|
this.selectedItem(result);
|
||||||
|
}
|
||||||
this.scrollToFocused();
|
this.scrollToFocused();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import ko from 'ko';
|
import ko from 'ko';
|
||||||
import { Notifications, UploadErrorCode } from 'Common/Enums';
|
import { Notification, UploadErrorCode } from 'Common/Enums';
|
||||||
import { langLink } from 'Common/Links';
|
import { langLink } from 'Common/Links';
|
||||||
import { doc, createElement } from 'Common/Globals';
|
import { doc, createElement } from 'Common/Globals';
|
||||||
import { getKeyByValue, forEachObjectEntry } from 'Common/Utils';
|
import { getKeyByValue, forEachObjectEntry } from 'Common/Utils';
|
||||||
|
|
@ -13,7 +13,6 @@ const
|
||||||
if (rl.I18N) {
|
if (rl.I18N) {
|
||||||
I18N_DATA = rl.I18N;
|
I18N_DATA = rl.I18N;
|
||||||
rl.I18N = null;
|
rl.I18N = null;
|
||||||
doc.documentElement.dir = I18N_DATA.LANG_DIR;
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -21,8 +20,8 @@ const
|
||||||
i18nKey = key => key.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(),
|
i18nKey = key => key.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(),
|
||||||
|
|
||||||
getNotificationMessage = code => {
|
getNotificationMessage = code => {
|
||||||
let key = getKeyByValue(Notifications, code);
|
let key = getKeyByValue(Notification, code);
|
||||||
return key ? I18N_DATA.NOTIFICATIONS[key] : '';
|
return key ? I18N_DATA.NOTIFICATIONS[i18nKey(key).replace('_NOTIFICATION', '_ERROR')] : '';
|
||||||
},
|
},
|
||||||
|
|
||||||
fromNow = date => relativeTime(Math.round((date.getTime() - Date.now()) / 1000));
|
fromNow = date => relativeTime(Math.round((date.getTime() - Date.now()) / 1000));
|
||||||
|
|
@ -45,9 +44,9 @@ export const
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Intl.RelativeTimeFormat) {
|
if (Intl.RelativeTimeFormat) {
|
||||||
return (new Intl.RelativeTimeFormat(doc.documentElement.lang)).format(seconds, unit);
|
let rtf = new Intl.RelativeTimeFormat(doc.documentElement.lang);
|
||||||
|
return rtf.format(seconds, unit);
|
||||||
}
|
}
|
||||||
// Safari < 14
|
|
||||||
abs = Math.abs(seconds);
|
abs = Math.abs(seconds);
|
||||||
let rtf = rl.relativeTime.long[unit][0 > seconds ? 'past' : 'future'],
|
let rtf = rl.relativeTime.long[unit][0 > seconds ? 'past' : 'future'],
|
||||||
plural = rl.relativeTime.plural(abs);
|
plural = rl.relativeTime.plural(abs);
|
||||||
|
|
@ -61,7 +60,7 @@ export const
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
i18n = (key, valueList, defaulValue) => {
|
i18n = (key, valueList, defaulValue) => {
|
||||||
let result = defaulValue ?? key;
|
let result = null == defaulValue ? key : defaulValue;
|
||||||
let path = key.split('/');
|
let path = key.split('/');
|
||||||
if (I18N_DATA[path[0]] && path[1]) {
|
if (I18N_DATA[path[0]] && path[1]) {
|
||||||
result = I18N_DATA[path[0]][path[1]] || result;
|
result = I18N_DATA[path[0]][path[1]] || result;
|
||||||
|
|
@ -101,13 +100,14 @@ export const
|
||||||
|
|
||||||
timestampToString = (timeStampInUTC, formatStr) => {
|
timestampToString = (timeStampInUTC, formatStr) => {
|
||||||
const now = Date.now(),
|
const now = Date.now(),
|
||||||
time = 0 < timeStampInUTC ? timeStampInUTC * 1000 : (0 === timeStampInUTC ? now : 0);
|
time = 0 < timeStampInUTC ? Math.min(now, timeStampInUTC * 1000) : (0 === timeStampInUTC ? now : 0);
|
||||||
|
|
||||||
if (31536000000 < time) {
|
if (31536000000 < time) {
|
||||||
const m = new Date(time), h = LanguageStore.hourCycle();
|
const m = new Date(time), h = LanguageStore.hourCycle();
|
||||||
switch (formatStr) {
|
switch (formatStr) {
|
||||||
case 'FROMNOW':
|
case 'FROMNOW':
|
||||||
return fromNow(m);
|
return fromNow(m);
|
||||||
case 'AUTO': {
|
case 'SHORT': {
|
||||||
// 4 hours
|
// 4 hours
|
||||||
if (14400000 >= now - time)
|
if (14400000 >= now - time)
|
||||||
return fromNow(m);
|
return fromNow(m);
|
||||||
|
|
@ -119,9 +119,7 @@ export const
|
||||||
{TIME: m.format('LT',0,h)}
|
{TIME: m.format('LT',0,h)}
|
||||||
)
|
)
|
||||||
: m.format(
|
: m.format(
|
||||||
date.getFullYear() === m.getFullYear()
|
date.getFullYear() === m.getFullYear() ? {day: '2-digit', month: 'short'} : {dateStyle: 'medium'}
|
||||||
? {day: '2-digit', month: 'short', hour: 'numeric', minute: 'numeric'}
|
|
||||||
: {dateStyle: 'medium', timeStyle: 'short'}
|
|
||||||
, 0, h);
|
, 0, h);
|
||||||
}
|
}
|
||||||
case 'FULL':
|
case 'FULL':
|
||||||
|
|
@ -142,7 +140,7 @@ export const
|
||||||
time = Date.parse(element.dateTime) / 1000;
|
time = Date.parse(element.dateTime) / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = element.dataset.timeFormat;
|
let key = element.dataset.momentFormat;
|
||||||
if (key) {
|
if (key) {
|
||||||
element.textContent = timestampToString(time, key);
|
element.textContent = timestampToString(time, key);
|
||||||
if ('FULL' !== key && 'FROMNOW' !== key) {
|
if ('FULL' !== key && 'FROMNOW' !== key) {
|
||||||
|
|
@ -175,7 +173,7 @@ export const
|
||||||
*/
|
*/
|
||||||
getNotification = (code, message = '', defCode = 0) => {
|
getNotification = (code, message = '', defCode = 0) => {
|
||||||
code = pInt(code);
|
code = pInt(code);
|
||||||
if (Notifications.ClientViewError === code && message) {
|
if (Notification.ClientViewError === code && message) {
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,9 +182,6 @@ export const
|
||||||
|| '';
|
|| '';
|
||||||
},
|
},
|
||||||
|
|
||||||
getErrorMessage = (code, data) =>
|
|
||||||
getNotification(code) || data?.messageAdditional || data?.message || data,
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {*} code
|
* @param {*} code
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
|
|
@ -213,13 +208,14 @@ export const
|
||||||
script.remove();
|
script.remove();
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
script.onerror = () => reject(Error('Language '+language+' failed'));
|
script.onerror = () => reject(new Error('Language '+language+' failed'));
|
||||||
script.src = langLink(language, admin);
|
script.src = langLink(language, admin);
|
||||||
// script.async = true;
|
// script.async = true;
|
||||||
doc.head.append(script);
|
doc.head.append(script);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
*
|
||||||
* @param {string} language
|
* @param {string} language
|
||||||
* @param {boolean=} isEng = false
|
* @param {boolean=} isEng = false
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
|
|
@ -229,8 +225,6 @@ export const
|
||||||
'LANGS_NAMES' + (true === isEng ? '_EN' : '') + '/' + language,
|
'LANGS_NAMES' + (true === isEng ? '_EN' : '') + '/' + language,
|
||||||
null,
|
null,
|
||||||
language
|
language
|
||||||
),
|
);
|
||||||
|
|
||||||
baseCollator = numeric => new Intl.Collator(doc.documentElement.lang, {numeric: !!numeric, sensitivity: 'base'});
|
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|
|
||||||
|
|
@ -10,22 +10,19 @@ export const
|
||||||
|
|
||||||
pInt = (value, defaultValue = 0) => {
|
pInt = (value, defaultValue = 0) => {
|
||||||
value = parseInt(value, 10);
|
value = parseInt(value, 10);
|
||||||
return isFinite(value) ? value : defaultValue;
|
return isNaN(value) || !isFinite(value) ? defaultValue : value;
|
||||||
},
|
},
|
||||||
|
|
||||||
defaultOptionsAfterRender = (domItem, item) =>
|
defaultOptionsAfterRender = (domItem, item) =>
|
||||||
item && undefined !== item.disabled && domItem?.classList.toggle('disabled', domItem.disabled = item.disabled),
|
item && undefined !== item.disabled && domItem?.classList.toggle('disabled', domItem.disabled = item.disabled),
|
||||||
|
|
||||||
// unescape(encodeURIComponent()) makes the UTF-16 DOMString to an UTF-8 string
|
// unescape(encodeURIComponent()) makes the UTF-16 DOMString to an UTF-8 string
|
||||||
b64Encode = data => btoa(unescape(encodeURIComponent(data))),
|
b64EncodeJSON = data => btoa(unescape(encodeURIComponent(JSON.stringify(data)))),
|
||||||
/* // Without deprecated 'unescape':
|
/* // Without deprecated 'unescape':
|
||||||
b64Encode = data => btoa(encodeURIComponent(data).replace(
|
b64EncodeJSON = data => btoa(encodeURIComponent(JSON.stringify(data)).replace(
|
||||||
/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode('0x' + p1)
|
/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode('0x' + p1)
|
||||||
)),
|
)),
|
||||||
*/
|
*/
|
||||||
|
|
||||||
b64EncodeJSON = data => b64Encode(JSON.stringify(data)),
|
|
||||||
|
|
||||||
b64EncodeJSONSafe = data => b64EncodeJSON(data).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
|
b64EncodeJSONSafe = data => b64EncodeJSON(data).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
|
||||||
|
|
||||||
getKeyByValue = (o, v) => Object.keys(o).find(key => o[key] === v);
|
getKeyByValue = (o, v) => Object.keys(o).find(key => o[key] === v);
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,26 @@
|
||||||
import { Notifications } from 'Common/Enums';
|
import { MessageFlagsCache } from 'Common/Cache';
|
||||||
|
import { Notification } from 'Common/Enums';
|
||||||
import { MessageSetAction, ComposeType/*, FolderType*/ } from 'Common/EnumsUser';
|
import { MessageSetAction, ComposeType/*, FolderType*/ } from 'Common/EnumsUser';
|
||||||
import { doc, createElement, elementById, dropdowns, dropdownVisibility, SettingsGet, leftPanelDisabled } from 'Common/Globals';
|
import { doc, createElement, elementById, dropdowns, dropdownVisibility, SettingsGet, leftPanelDisabled } from 'Common/Globals';
|
||||||
import { plainToHtml } from 'Common/Html';
|
import { plainToHtml } from 'Common/Html';
|
||||||
import { getNotification } from 'Common/Translator';
|
import { getNotification } from 'Common/Translator';
|
||||||
import { EmailCollectionModel } from 'Model/EmailCollection';
|
import { EmailModel } from 'Model/Email';
|
||||||
|
import { MessageModel } from 'Model/Message';
|
||||||
import { MessageUserStore } from 'Stores/User/Message';
|
import { MessageUserStore } from 'Stores/User/Message';
|
||||||
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
||||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||||
import * as Local from 'Storage/Client';
|
import * as Local from 'Storage/Client';
|
||||||
import { ThemeStore } from 'Stores/Theme';
|
import { ThemeStore } from 'Stores/Theme';
|
||||||
import Remote from 'Remote/User/Fetch';
|
import Remote from 'Remote/User/Fetch';
|
||||||
import { attachmentDownload } from 'Common/Links';
|
|
||||||
|
|
||||||
import { AccountModel } from 'Model/Account';
|
|
||||||
import { IdentityModel } from 'Model/Identity';
|
|
||||||
import { AccountUserStore } from 'Stores/User/Account';
|
|
||||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
|
||||||
import { isArray } from 'Common/Utils';
|
|
||||||
|
|
||||||
import { showScreenPopup } from 'Knoin/Knoin';
|
|
||||||
import { IdentityPopupView } from 'View/Popup/Identity';
|
|
||||||
|
|
||||||
export const
|
export const
|
||||||
|
|
||||||
// 1 = move, 2 = copy
|
moveAction = ko.observable(false),
|
||||||
moveAction = ko.observable(0),
|
|
||||||
|
|
||||||
dropdownsDetectVisibility = (() =>
|
dropdownsDetectVisibility = (() =>
|
||||||
dropdownVisibility(!!dropdowns.find(item => item.classList.contains('show')))
|
dropdownVisibility(!!dropdowns.find(item => item.classList.contains('show')))
|
||||||
).debounce(50),
|
).debounce(50),
|
||||||
|
|
||||||
|
|
||||||
editIdentity = Identity => showScreenPopup(IdentityPopupView, [Identity]),
|
|
||||||
|
|
||||||
loadAccountsAndIdentities = () => {
|
|
||||||
AccountUserStore.loading(true);
|
|
||||||
IdentityUserStore.loading(true);
|
|
||||||
|
|
||||||
Remote.request('AccountsAndIdentities', (iError, oData) => {
|
|
||||||
AccountUserStore.loading(false);
|
|
||||||
IdentityUserStore.loading(false);
|
|
||||||
|
|
||||||
if (!iError) {
|
|
||||||
let items = oData.Result.Accounts;
|
|
||||||
AccountUserStore(isArray(items)
|
|
||||||
? items.map(oValue => new AccountModel(oValue.email, oValue.name))
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
AccountUserStore.unshift(new AccountModel(SettingsGet('mainEmail'), '', false));
|
|
||||||
|
|
||||||
items = oData.Result.Identities;
|
|
||||||
IdentityUserStore(isArray(items)
|
|
||||||
? items.map(identityData => IdentityModel.reviveFromJson(identityData))
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
|
|
||||||
// Invoke "Update Identity" pop up right after login
|
|
||||||
// https://github.com/the-djmaze/snappymail/issues/1689
|
|
||||||
const main = IdentityUserStore.main();
|
|
||||||
main && !main.exists() && setTimeout(()=>editIdentity(main), 1000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} link
|
* @param {string} link
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
|
|
@ -84,29 +42,6 @@ download = (link, name = "") => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
downloadZip = (name, hashes, onError, fTrigger, folder) => {
|
|
||||||
if (hashes.length) {
|
|
||||||
let params = {
|
|
||||||
target: 'zip',
|
|
||||||
filename: name,
|
|
||||||
hashes: hashes
|
|
||||||
};
|
|
||||||
if (!onError) {
|
|
||||||
onError = () => alert('Download failed');
|
|
||||||
}
|
|
||||||
if (folder) {
|
|
||||||
params.folder = folder;
|
|
||||||
// params.uids = uids;
|
|
||||||
}
|
|
||||||
Remote.post('AttachmentsActions', fTrigger || null, params)
|
|
||||||
.then(result => {
|
|
||||||
let hash = result?.Result?.fileHash;
|
|
||||||
hash ? download(attachmentDownload(hash), hash+'.zip') : onError();
|
|
||||||
})
|
|
||||||
.catch(onError);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {function}
|
* @returns {function}
|
||||||
*/
|
*/
|
||||||
|
|
@ -115,15 +50,14 @@ computedPaginatorHelper = (koCurrentPage, koPageCount) => {
|
||||||
const currentPage = koCurrentPage(),
|
const currentPage = koCurrentPage(),
|
||||||
pageCount = koPageCount(),
|
pageCount = koPageCount(),
|
||||||
result = [],
|
result = [],
|
||||||
lang = doc.documentElement.lang,
|
|
||||||
fAdd = (index, push = true, customName = '') => {
|
fAdd = (index, push = true, customName = '') => {
|
||||||
const name = index.toLocaleString(lang),
|
const data = {
|
||||||
data = {
|
current: index === currentPage,
|
||||||
current: index === currentPage,
|
name: customName ? customName.toString() : index.toString(),
|
||||||
name: customName || name,
|
custom: !!customName,
|
||||||
title: customName ? name : '',
|
title: customName ? index.toString() : '',
|
||||||
value: index
|
value: index.toString()
|
||||||
};
|
};
|
||||||
|
|
||||||
push ? result.push(data) : result.unshift(data);
|
push ? result.push(data) : result.unshift(data);
|
||||||
};
|
};
|
||||||
|
|
@ -132,7 +66,7 @@ computedPaginatorHelper = (koCurrentPage, koPageCount) => {
|
||||||
next = 0,
|
next = 0,
|
||||||
limit = 2;
|
limit = 2;
|
||||||
|
|
||||||
if (1 < pageCount) {
|
if (1 < pageCount || (0 < pageCount && pageCount < currentPage)) {
|
||||||
if (pageCount < currentPage) {
|
if (pageCount < currentPage) {
|
||||||
fAdd(pageCount);
|
fAdd(pageCount);
|
||||||
prev = pageCount;
|
prev = pageCount;
|
||||||
|
|
@ -199,15 +133,29 @@ mailToHelper = mailToUrl => {
|
||||||
mailToUrl = mailToUrl.slice(7).split('?');
|
mailToUrl = mailToUrl.slice(7).split('?');
|
||||||
|
|
||||||
const
|
const
|
||||||
email = decodeURIComponent(mailToUrl[0]),
|
email = mailToUrl[0],
|
||||||
params = new URLSearchParams(mailToUrl[1]),
|
params = new URLSearchParams(mailToUrl[1]),
|
||||||
to = params.get('to'),
|
toEmailModel = value => null != value ? EmailModel.parseEmailLine(value) : null;
|
||||||
toEmailModel = value => EmailCollectionModel.fromString(value);
|
|
||||||
|
|
||||||
showMessageComposer([
|
showMessageComposer([
|
||||||
ComposeType.Empty,
|
ComposeType.Empty,
|
||||||
null,
|
null,
|
||||||
toEmailModel(to ? email + ',' + to : email),
|
params.get('to')
|
||||||
|
? Object.values(
|
||||||
|
toEmailModel(email + ',' + params.get('to')).reduce((result, value) => {
|
||||||
|
if (value) {
|
||||||
|
if (result[value.email]) {
|
||||||
|
if (!result[value.email].name) {
|
||||||
|
result[value.email] = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result[value.email] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, {})
|
||||||
|
)
|
||||||
|
: EmailModel.parseEmailLine(email),
|
||||||
toEmailModel(params.get('cc')),
|
toEmailModel(params.get('cc')),
|
||||||
toEmailModel(params.get('bcc')),
|
toEmailModel(params.get('bcc')),
|
||||||
params.get('subject'),
|
params.get('subject'),
|
||||||
|
|
@ -225,24 +173,37 @@ showMessageComposer = (params = []) =>
|
||||||
rl.app.showMessageComposer(params);
|
rl.app.showMessageComposer(params);
|
||||||
},
|
},
|
||||||
|
|
||||||
setLayoutResizer = (source, sClientSideKeyName, mode) =>
|
setLayoutResizer = (source, target, sClientSideKeyName, mode) =>
|
||||||
{
|
{
|
||||||
if (source.layoutResizer && source.layoutResizer.mode != mode) {
|
if (source.layoutResizer && source.layoutResizer.mode != mode) {
|
||||||
|
target.removeAttribute('style');
|
||||||
source.removeAttribute('style');
|
source.removeAttribute('style');
|
||||||
}
|
}
|
||||||
source.observer?.disconnect();
|
source.observer?.disconnect();
|
||||||
// source.classList.toggle('resizable', mode);
|
// source.classList.toggle('resizable', mode);
|
||||||
if (mode) {
|
if (mode) {
|
||||||
const length = Local.get(sClientSideKeyName + mode) || SettingsGet('Resizer' + sClientSideKeyName + mode);
|
const length = Local.get(sClientSideKeyName + mode) || SettingsGet('Resizer' + sClientSideKeyName + mode),
|
||||||
|
setTargetPos = mode => {
|
||||||
|
let value;
|
||||||
|
if ('Width' == mode) {
|
||||||
|
value = source.offsetWidth;
|
||||||
|
target.style.left = value + 'px';
|
||||||
|
} else {
|
||||||
|
value = source.offsetHeight;
|
||||||
|
target.style.top = (4 + source.offsetTop + value) + 'px';
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
if (length) {
|
if (length) {
|
||||||
source.style[mode.toLowerCase()] = length + 'px';
|
source.style[mode.toLowerCase()] = length + 'px';
|
||||||
|
setTargetPos(mode);
|
||||||
}
|
}
|
||||||
if (!source.layoutResizer) {
|
if (!source.layoutResizer) {
|
||||||
const resizer = createElement('div', {'class':'resizer'}),
|
const resizer = createElement('div', {'class':'resizer'}),
|
||||||
save = (data => Remote.saveSettings(0, data)).debounce(500),
|
save = (data => Remote.saveSettings(0, data)).debounce(500),
|
||||||
size = {},
|
size = {},
|
||||||
store = () => {
|
store = () => {
|
||||||
const value = ('Width' == resizer.mode) ? source.offsetWidth : source.offsetHeight,
|
const value = setTargetPos(resizer.mode),
|
||||||
prop = resizer.key + resizer.mode;
|
prop = resizer.key + resizer.mode;
|
||||||
(value == Local.get(prop)) || Local.set(prop, value);
|
(value == Local.get(prop)) || Local.set(prop, value);
|
||||||
(value == SettingsGet('Resizer' + prop)) || save({['Resizer' + prop]: value});
|
(value == SettingsGet('Resizer' + prop)) || save({['Resizer' + prop]: value});
|
||||||
|
|
@ -289,86 +250,114 @@ setLayoutResizer = (source, sClientSideKeyName, mode) =>
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
viewMessage = (oMessage, popup) => {
|
|
||||||
if (popup) {
|
|
||||||
oMessage.popupMessage();
|
|
||||||
} else {
|
|
||||||
MessageUserStore.error('');
|
|
||||||
let id = 'rl-msg-' + oMessage.hash,
|
|
||||||
body = oMessage.body || elementById(id);
|
|
||||||
if (!body) {
|
|
||||||
body = createElement('div',{
|
|
||||||
id:id,
|
|
||||||
hidden:'',
|
|
||||||
class:'b-text-part'
|
|
||||||
+ (oMessage.pgpSigned() ? ' openpgp-signed' : '')
|
|
||||||
+ (oMessage.pgpEncrypted() ? ' openpgp-encrypted' : '')
|
|
||||||
+ (oMessage.smimeSigned() ? ' smime-signed' : '')
|
|
||||||
+ (oMessage.smimeEncrypted() ? ' smime-encrypted' : '')
|
|
||||||
});
|
|
||||||
MessageUserStore.purgeCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
body.message = oMessage;
|
|
||||||
oMessage.body = body;
|
|
||||||
|
|
||||||
if (!SettingsUserStore.viewHTML() || !oMessage.viewHtml()) {
|
|
||||||
oMessage.viewPlain();
|
|
||||||
}
|
|
||||||
|
|
||||||
MessageUserStore.bodiesDom().append(body);
|
|
||||||
|
|
||||||
MessageUserStore.loading(false);
|
|
||||||
oMessage.body.hidden = false;
|
|
||||||
|
|
||||||
if (oMessage.isUnseen() && SettingsUserStore.messageReadAuto()) {
|
|
||||||
MessageUserStore.MessageSeenTimer = setTimeout(
|
|
||||||
() => MessagelistUserStore.setAction(oMessage.folder, MessageSetAction.SetSeen, [oMessage]),
|
|
||||||
SettingsUserStore.messageReadDelay() * 1000 // seconds
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
populateMessageBody = (oMessage, popup) => {
|
populateMessageBody = (oMessage, popup) => {
|
||||||
if (oMessage) {
|
if (oMessage) {
|
||||||
popup || MessageUserStore.message(oMessage);
|
popup || MessageUserStore.message(oMessage);
|
||||||
if (oMessage.body) {
|
popup || MessageUserStore.loading(true);
|
||||||
viewMessage(oMessage, popup);
|
Remote.message((iError, oData/*, bCached*/) => {
|
||||||
} else {
|
if (iError) {
|
||||||
popup || MessageUserStore.loading(true);
|
if (Notification.RequestAborted !== iError && !popup) {
|
||||||
Remote.message((iError, oData/*, bCached*/) => {
|
MessageUserStore.message(null);
|
||||||
if (iError) {
|
MessageUserStore.error(getNotification(iError));
|
||||||
if (Notifications.RequestAborted !== iError && !popup) {
|
}
|
||||||
MessageUserStore.message(null);
|
} else {
|
||||||
MessageUserStore.error(getNotification(iError));
|
let json = oData?.Result;
|
||||||
|
|
||||||
|
if (
|
||||||
|
json &&
|
||||||
|
MessageModel.validJson(json) &&
|
||||||
|
oMessage.folder === json.Folder
|
||||||
|
) {
|
||||||
|
const threads = oMessage.threads(),
|
||||||
|
isNew = !popup && oMessage.uid != json.Uid && threads.includes(json.Uid),
|
||||||
|
messagesDom = MessageUserStore.bodiesDom();
|
||||||
|
if (isNew) {
|
||||||
|
oMessage = MessageModel.reviveFromJson(json);
|
||||||
|
if (oMessage) {
|
||||||
|
oMessage.threads(threads);
|
||||||
|
MessageFlagsCache.initMessage(oMessage);
|
||||||
|
|
||||||
|
// Set clone
|
||||||
|
oMessage = MessageModel.fromMessageListItem(oMessage);
|
||||||
|
}
|
||||||
|
MessageUserStore.message(oMessage);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
let json = oData?.Result;
|
if (oMessage && oMessage.uid == json.Uid) {
|
||||||
if (json
|
popup || MessageUserStore.error('');
|
||||||
&& ((
|
|
||||||
oMessage.hash && oMessage.hash === json.hash
|
|
||||||
) || (
|
|
||||||
!oMessage.hash
|
|
||||||
&& oMessage.folder === json.folder
|
|
||||||
&& oMessage.uid == json.uid)
|
|
||||||
)
|
|
||||||
&& oMessage.revivePropertiesFromJson(json)
|
|
||||||
) {
|
|
||||||
/*
|
/*
|
||||||
if (bCached) {
|
if (bCached) {
|
||||||
delete json.flags;
|
delete json.Flags;
|
||||||
}
|
}
|
||||||
oMessage.body.remove();
|
|
||||||
*/
|
*/
|
||||||
viewMessage(oMessage, popup);
|
isNew || oMessage.revivePropertiesFromJson(json);
|
||||||
|
|
||||||
|
if (messagesDom) {
|
||||||
|
let id = 'rl-msg-' + oMessage.hash.replace(/[^a-zA-Z0-9]/g, ''),
|
||||||
|
body = elementById(id);
|
||||||
|
if (body) {
|
||||||
|
oMessage.body = body;
|
||||||
|
oMessage.isHtml(body.classList.contains('html'));
|
||||||
|
oMessage.hasImages(body.rlHasImages);
|
||||||
|
} else {
|
||||||
|
body = Element.fromHTML('<div id="' + id + '" hidden="" class="b-text-part '
|
||||||
|
+ (oMessage.pgpSigned() ? ' openpgp-signed' : '')
|
||||||
|
+ (oMessage.pgpEncrypted() ? ' openpgp-encrypted' : '')
|
||||||
|
+ '">'
|
||||||
|
+ '</div>');
|
||||||
|
oMessage.body = body;
|
||||||
|
if (!SettingsUserStore.viewHTML() || !oMessage.viewHtml()) {
|
||||||
|
oMessage.viewPlain();
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageUserStore.purgeMessageBodyCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesDom.append(body);
|
||||||
|
|
||||||
|
popup || (oMessage.body.hidden = false);
|
||||||
|
popup && oMessage.viewPopupMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageFlagsCache.initMessage(oMessage);
|
||||||
|
if (oMessage.isUnseen()) {
|
||||||
|
MessageUserStore.MessageSeenTimer = setTimeout(
|
||||||
|
() => MessagelistUserStore.setAction(oMessage.folder, MessageSetAction.SetSeen, [oMessage]),
|
||||||
|
SettingsUserStore.messageReadDelay() * 1000 // seconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
let selectedMessage = MessagelistUserStore.selectedMessage();
|
||||||
|
if (
|
||||||
|
selectedMessage &&
|
||||||
|
(oMessage.folder !== selectedMessage.folder || oMessage.uid != selectedMessage.uid)
|
||||||
|
) {
|
||||||
|
MessagelistUserStore.selectedMessage(null);
|
||||||
|
if (1 === MessagelistUserStore.length) {
|
||||||
|
MessagelistUserStore.focusedMessage(null);
|
||||||
|
}
|
||||||
|
} else if (!selectedMessage) {
|
||||||
|
selectedMessage = MessagelistUserStore.find(
|
||||||
|
subMessage =>
|
||||||
|
subMessage &&
|
||||||
|
subMessage.folder === oMessage.folder &&
|
||||||
|
subMessage.uid == oMessage.uid
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedMessage) {
|
||||||
|
MessagelistUserStore.selectedMessage(selectedMessage);
|
||||||
|
MessagelistUserStore.focusedMessage(selectedMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
popup || MessageUserStore.loading(false);
|
}
|
||||||
}, oMessage.folder, oMessage.uid);
|
popup || MessageUserStore.loading(false);
|
||||||
}
|
}, oMessage.folder, oMessage.uid);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
leftPanelDisabled.subscribe(value => value && moveAction(0));
|
leftPanelDisabled.subscribe(value => value && moveAction(false));
|
||||||
moveAction.subscribe(value => value && leftPanelDisabled(false));
|
moveAction.subscribe(value => value && leftPanelDisabled(false));
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
export class CheckboxComponent {
|
export class CheckboxComponent {
|
||||||
constructor(params = {}) {
|
constructor(params = {}) {
|
||||||
this.name = params.name;
|
|
||||||
|
|
||||||
this.value = ko.isObservable(params.value) ? params.value
|
this.value = ko.isObservable(params.value) ? params.value
|
||||||
: ko.observable(!!params.value);
|
: ko.observable(!!params.value);
|
||||||
|
|
||||||
this.enable = ko.isObservable(params.enable) ? params.enable
|
this.enable = ko.isObservable(params.enable) ? params.enable
|
||||||
: ko.observable(params.enable ?? 1);
|
: ko.observable(undefined === params.enable || !!params.enable);
|
||||||
|
|
||||||
this.label = params.label;
|
this.label = params.label;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,8 @@
|
||||||
import { doc, createElement, addEventsListeners } from 'Common/Globals';
|
import { doc, createElement, addEventsListeners } from 'Common/Globals';
|
||||||
import { EmailModel } from 'Model/Email';
|
import { EmailModel } from 'Model/Email';
|
||||||
import { addressparser } from 'Mime/Address';
|
|
||||||
|
|
||||||
const contentType = 'snappymail/emailaddress',
|
const contentType = 'snappymail/emailaddress',
|
||||||
getAddressKey = li => li?.emailaddress?.key,
|
getAddressKey = li => li?.emailaddress?.key;
|
||||||
|
|
||||||
parseEmailLine = line => addressparser(line).map(item =>
|
|
||||||
(item.name || item.email)
|
|
||||||
? new EmailModel(item.email, item.name) : null
|
|
||||||
).filter(v => v),
|
|
||||||
splitEmailLine = line => {
|
|
||||||
const result = [];
|
|
||||||
let exists = false;
|
|
||||||
addressparser(line).forEach(item => {
|
|
||||||
const address = (item.name || item.email)
|
|
||||||
? new EmailModel(item.email, item.name)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (address?.email) {
|
|
||||||
exists = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(address ? address.toLine() : item.name);
|
|
||||||
});
|
|
||||||
return exists ? result : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
let dragAddress, datalist;
|
let dragAddress, datalist;
|
||||||
|
|
||||||
|
|
@ -166,9 +144,7 @@ export class EmailAddressesComponent {
|
||||||
|
|
||||||
_parseInput(force) {
|
_parseInput(force) {
|
||||||
let val = this.input.value;
|
let val = this.input.value;
|
||||||
if ((force || val.includes(',') || val.includes(';')
|
if ((force || val.includes(',') || val.includes(';')) && this._parseValue(val)) {
|
||||||
|| (val.charAt(val.length-1)===' ' && this._simpleEmailMatch(val)))
|
|
||||||
&& this._parseValue(val)) {
|
|
||||||
this.input.value = '';
|
this.input.value = '';
|
||||||
}
|
}
|
||||||
this._resizeInput();
|
this._resizeInput();
|
||||||
|
|
@ -178,8 +154,8 @@ export class EmailAddressesComponent {
|
||||||
if (val) {
|
if (val) {
|
||||||
const self = this,
|
const self = this,
|
||||||
v = val.trim(),
|
v = val.trim(),
|
||||||
hook = (v && [',', ';', '\n'].includes(v.slice(-1))) ? splitEmailLine(val) : null,
|
hook = (v && [',', ';', '\n'].includes(v.slice(-1))) ? EmailModel.splitEmailLine(val) : null,
|
||||||
values = (hook || [val]).map(value => parseEmailLine(value))
|
values = (hook || [val]).map(value => EmailModel.parseEmailLine(value))
|
||||||
.flat(Infinity)
|
.flat(Infinity)
|
||||||
.map(item => (item.toLine ? [item.toLine(), item] : [item, null]));
|
.map(item => (item.toLine ? [item.toLine(), item] : [item, null]));
|
||||||
|
|
||||||
|
|
@ -287,13 +263,6 @@ export class EmailAddressesComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_simpleEmailMatch(value) {
|
|
||||||
// A very SIMPLE test to check if the value might be an email
|
|
||||||
const val = value.trim();
|
|
||||||
return /^[^@]*<[^\s@]{1,128}@[^\s@]{1,256}\.[\w]{2,32}>$/g.test(val)
|
|
||||||
|| /^[^\s@]{1,128}@[^\s@]{1,256}\.[\w]{2,32}$/g.test(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderTags() {
|
_renderTags() {
|
||||||
let self = this;
|
let self = this;
|
||||||
[...self.ul.children].forEach(node => node !== self.inputCont && node.remove());
|
[...self.ul.children].forEach(node => node !== self.inputCont && node.remove());
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export class JCard {
|
||||||
if (input) {
|
if (input) {
|
||||||
// read from jCard
|
// read from jCard
|
||||||
if (typeof input !== 'object') {
|
if (typeof input !== 'object') {
|
||||||
throw Error('error reading vcard')
|
throw new Error('error reading vcard')
|
||||||
}
|
}
|
||||||
this.parseFromJCard(input)
|
this.parseFromJCard(input)
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +87,7 @@ export class JCard {
|
||||||
arg = new VCardProperty(String(arg), value, params, type);
|
arg = new VCardProperty(String(arg), value, params, type);
|
||||||
}
|
}
|
||||||
if (!(arg instanceof VCardProperty)) {
|
if (!(arg instanceof VCardProperty)) {
|
||||||
throw Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty');
|
throw new Error('invalid argument of VCard.set(), expects string arguments or a VCardProperty');
|
||||||
}
|
}
|
||||||
let field = arg.getField();
|
let field = arg.getField();
|
||||||
this.props.set(field, [arg]);
|
this.props.set(field, [arg]);
|
||||||
|
|
@ -101,7 +101,7 @@ export class JCard {
|
||||||
arg = new VCardProperty(String(arg), value, params, type);
|
arg = new VCardProperty(String(arg), value, params, type);
|
||||||
}
|
}
|
||||||
if (!(arg instanceof VCardProperty)) {
|
if (!(arg instanceof VCardProperty)) {
|
||||||
throw Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty');
|
throw new Error('invalid argument of VCard.add(), expects string arguments or a VCardProperty');
|
||||||
}
|
}
|
||||||
// VCardProperty arguments
|
// VCardProperty arguments
|
||||||
let field = arg.getField();
|
let field = arg.getField();
|
||||||
|
|
@ -124,15 +124,15 @@ export class JCard {
|
||||||
// VCardProperty argument
|
// VCardProperty argument
|
||||||
else if (arg instanceof VCardProperty) {
|
else if (arg instanceof VCardProperty) {
|
||||||
let propArray = this.props.get(arg.getField());
|
let propArray = this.props.get(arg.getField());
|
||||||
if (!propArray?.includes(arg))
|
if (!(propArray === null || propArray === void 0 ? void 0 : propArray.includes(arg)))
|
||||||
throw Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg));
|
throw new Error("Attempted to remove VCardProperty VCard does not have: ".concat(arg));
|
||||||
propArray.splice(propArray.indexOf(arg), 1);
|
propArray.splice(propArray.indexOf(arg), 1);
|
||||||
if (propArray.length === 0)
|
if (propArray.length === 0)
|
||||||
this.props.delete(arg.getField());
|
this.props.delete(arg.getField());
|
||||||
}
|
}
|
||||||
// incorrect arguments
|
// incorrect arguments
|
||||||
else
|
else
|
||||||
throw Error('invalid argument of VCard.remove(), expects ' +
|
throw new Error('invalid argument of VCard.remove(), expects ' +
|
||||||
'string and optional param filter or a VCardProperty');
|
'string and optional param filter or a VCardProperty');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,7 +202,7 @@ export class JCard {
|
||||||
parseFullName(options) {
|
parseFullName(options) {
|
||||||
let n = this.getOne('n');
|
let n = this.getOne('n');
|
||||||
if (n === undefined) {
|
if (n === undefined) {
|
||||||
throw Error('\'fn\' VCardProperty not present in card, cannot parse full name');
|
throw new Error('\'fn\' VCardProperty not present in card, cannot parse full name');
|
||||||
}
|
}
|
||||||
let fnString = '';
|
let fnString = '';
|
||||||
// Position in n -> position in fn
|
// Position in n -> position in fn
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export class VCardProperty {
|
||||||
}
|
}
|
||||||
// invalid property
|
// invalid property
|
||||||
else {
|
else {
|
||||||
throw Error('invalid Property constructor');
|
throw new Error('invalid Property constructor');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
382
dev/External/SquireUI.js
vendored
|
|
@ -3,6 +3,10 @@
|
||||||
(doc => {
|
(doc => {
|
||||||
|
|
||||||
const
|
const
|
||||||
|
removeElements = 'HEAD,LINK,META,NOSCRIPT,SCRIPT,TEMPLATE,TITLE',
|
||||||
|
allowedElements = 'A,B,BLOCKQUOTE,BR,DIV,FONT,H1,H2,H3,H4,H5,H6,HR,IMG,LI,OL,P,SPAN,STRONG,TABLE,TD,TH,TR,U,UL',
|
||||||
|
allowedAttributes = 'abbr,align,background,bgcolor,border,cellpadding,cellspacing,class,color,colspan,dir,face,frame,height,href,hspace,id,lang,rowspan,rules,scope,size,src,style,target,type,usemap,valign,vspace,width'.split(','),
|
||||||
|
|
||||||
i18n = (str, def) => rl.i18n(str) || def,
|
i18n = (str, def) => rl.i18n(str) || def,
|
||||||
|
|
||||||
ctrlKey = shortcuts.getMetaKey() + ' + ',
|
ctrlKey = shortcuts.getMetaKey() + ' + ',
|
||||||
|
|
@ -10,43 +14,65 @@ const
|
||||||
createElement = name => doc.createElement(name),
|
createElement = name => doc.createElement(name),
|
||||||
|
|
||||||
tpl = createElement('template'),
|
tpl = createElement('template'),
|
||||||
|
clr = createElement('input'),
|
||||||
|
|
||||||
trimLines = html => html.trim().replace(/^(<div>\s*<br\s*\/?>\s*<\/div>)+/, '').trim(),
|
trimLines = html => html.trim().replace(/^(<div>\s*<br\s*\/?>\s*<\/div>)+/, '').trim(),
|
||||||
htmlToPlain = html => rl.Utils.htmlToPlain(html).trim(),
|
htmlToPlain = html => rl.Utils.htmlToPlain(html).trim(),
|
||||||
plainToHtml = text => rl.Utils.plainToHtml(text),
|
plainToHtml = text => rl.Utils.plainToHtml(text),
|
||||||
|
|
||||||
forEachObjectValue = (obj, fn) => Object.values(obj).forEach(fn),
|
getFragmentOfChildren = parent => {
|
||||||
|
let frag = doc.createDocumentFragment();
|
||||||
|
frag.append(...parent.childNodes);
|
||||||
|
return frag;
|
||||||
|
},
|
||||||
|
|
||||||
SquireDefaultConfig = {
|
SquireDefaultConfig = {
|
||||||
/*
|
/*
|
||||||
|
blockTag: 'P',
|
||||||
|
undo: {
|
||||||
|
documentSizeThreshold: -1, // -1 means no threshold
|
||||||
|
undoLimit: -1 // -1 means no limit
|
||||||
|
},
|
||||||
addLinks: true // allow_smart_html_links
|
addLinks: true // allow_smart_html_links
|
||||||
*/
|
*/
|
||||||
sanitizeToDOMFragment: (html, isPaste/*, squire*/) => {
|
sanitizeToDOMFragment: (html, isPaste/*, squire*/) => {
|
||||||
html = (html||'')
|
tpl.innerHTML = (html||'')
|
||||||
.replace(/<\/?(BODY|HTML)[^>]*>/gi,'')
|
.replace(/<\/?(BODY|HTML)[^>]*>/gi,'')
|
||||||
.replace(/<!--[^>]+-->/g,'')
|
.replace(/<!--[^>]+-->/g,'')
|
||||||
.replace(/<span[^>]*>\s*<\/span>/gi,'')
|
.replace(/<span[^>]*>\s*<\/span>/gi,'')
|
||||||
.trim();
|
.trim();
|
||||||
tpl.innerHTML = isPaste ? rl.Utils.cleanHtml(html).html : html;
|
tpl.querySelectorAll('a:empty,span:empty').forEach(el => el.remove());
|
||||||
|
if (isPaste) {
|
||||||
|
tpl.querySelectorAll(removeElements).forEach(el => el.remove());
|
||||||
|
tpl.querySelectorAll('*').forEach(el => {
|
||||||
|
if (!el.matches(allowedElements)) {
|
||||||
|
el.replaceWith(getFragmentOfChildren(el));
|
||||||
|
} else if (el.hasAttributes()) {
|
||||||
|
[...el.attributes].forEach(attr => {
|
||||||
|
let name = attr.name.toLowerCase();
|
||||||
|
if (!allowedAttributes.includes(name)) {
|
||||||
|
el.removeAttribute(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
return tpl.content;
|
return tpl.content;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
clr.type = "color";
|
||||||
|
clr.style.display = 'none';
|
||||||
|
doc.body.append(clr);
|
||||||
|
|
||||||
class SquireUI
|
class SquireUI
|
||||||
{
|
{
|
||||||
constructor(container) {
|
constructor(container) {
|
||||||
const
|
const
|
||||||
clr = createElement('input'),
|
doClr = name => () => {
|
||||||
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';
|
|
||||||
|
|
||||||
clr.value = '';
|
clr.value = '';
|
||||||
clr.onchange = () => squire.setStyle({[name]:clr.value});
|
clr.onchange = () => squire.setStyle({[name]:clr.value});
|
||||||
// Chrome 110+ https://github.com/the-djmaze/snappymail/issues/1199
|
clr.click();
|
||||||
// clr.oninput = () => squire.setStyle({[name]:clr.value});
|
|
||||||
setTimeout(()=>clr.click(),1);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
actions = {
|
actions = {
|
||||||
|
|
@ -58,7 +84,8 @@ class SquireUI
|
||||||
[i18n('SETTINGS_GENERAL/EDITOR_HTML'),'wysiwyg'],
|
[i18n('SETTINGS_GENERAL/EDITOR_HTML'),'wysiwyg'],
|
||||||
[i18n('SETTINGS_GENERAL/EDITOR_PLAIN'),'plain']
|
[i18n('SETTINGS_GENERAL/EDITOR_PLAIN'),'plain']
|
||||||
],
|
],
|
||||||
cmd: s => this.setMode('plain' == s.value ? 'plain' : 'wysiwyg')
|
cmd: s => this.setMode('plain' == s.value ? 'plain' : 'wysiwyg'),
|
||||||
|
hint: i18n('EDITOR/TEXT_SWITCHER_PLAIN_TEXT', 'Plain')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
font: {
|
font: {
|
||||||
|
|
@ -84,45 +111,20 @@ class SquireUI
|
||||||
cmd: s => squire.setStyle({ fontFamily: s.value })
|
cmd: s => squire.setStyle({ fontFamily: s.value })
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
select: [[i18n('GLOBAL/DEFAULT'),''],'11px','13px','16px','20px','24px','30px'],
|
select: ['11px','13px','16px','20px','24px','30px'],
|
||||||
defaultValueIndex: 0,
|
|
||||||
cmd: s => squire.setStyle({ fontSize: s.value })
|
cmd: s => squire.setStyle({ fontSize: s.value })
|
||||||
// TODO: maybe consider using https://developer.mozilla.org/en-US/docs/Web/CSS/font-size#values
|
|
||||||
// example:
|
|
||||||
// select: ['','xx-small', 'x-small',' small',' medium', 'large', 'x-large', 'xx-large', 'xxx-large'],
|
|
||||||
// defaultValueIndex: 0,
|
|
||||||
},
|
|
||||||
// dir: {
|
|
||||||
// select: [
|
|
||||||
// [i18n('EDITOR/DIR_LTR', 'LTR'),'ltr'],
|
|
||||||
// [i18n('EDITOR/DIR_RTL', 'RTL'),'rtl'],
|
|
||||||
// [i18n('EDITOR/DIR_AUTO', 'Auto'),'auto'],
|
|
||||||
// ['',''],
|
|
||||||
// ],
|
|
||||||
// cmd: s => {
|
|
||||||
// squire.setAttribute('dir', s.value || null);
|
|
||||||
// // squire.setStyle({ 'unicode-bidi': 'plaintext' });
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
},
|
|
||||||
dir: {
|
|
||||||
dir_ltr: {
|
|
||||||
html: '⁋',
|
|
||||||
cmd: () => squire.setTextDirection('ltr')
|
|
||||||
},
|
|
||||||
dir_rtl: {
|
|
||||||
html: '¶',
|
|
||||||
cmd: () => squire.setTextDirection('rtl')
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
textColor: {
|
textColor: {
|
||||||
html: 'A<sub>▾</sub>',
|
html: 'A<sub>▾</sub>',
|
||||||
cmd: doClr('color')
|
cmd: doClr('color'),
|
||||||
|
hint: 'Text color'
|
||||||
},
|
},
|
||||||
backgroundColor: {
|
backgroundColor: {
|
||||||
html: '🎨', /* ▧ */
|
html: '🎨', /* ▧ */
|
||||||
cmd: doClr('backgroundColor')
|
cmd: doClr('backgroundColor'),
|
||||||
|
hint: 'Background color'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
inline: {
|
inline: {
|
||||||
|
|
@ -130,37 +132,37 @@ class SquireUI
|
||||||
html: 'B',
|
html: 'B',
|
||||||
cmd: () => this.doAction('bold'),
|
cmd: () => this.doAction('bold'),
|
||||||
key: 'B',
|
key: 'B',
|
||||||
matches: 'B,STRONG'
|
hint: 'Bold'
|
||||||
},
|
},
|
||||||
italic: {
|
italic: {
|
||||||
html: 'I',
|
html: 'I',
|
||||||
cmd: () => this.doAction('italic'),
|
cmd: () => this.doAction('italic'),
|
||||||
key: 'I',
|
key: 'I',
|
||||||
matches: 'I'
|
hint: 'Italic'
|
||||||
},
|
},
|
||||||
underline: {
|
underline: {
|
||||||
html: '<u>U</u>',
|
html: '<u>U</u>',
|
||||||
cmd: () => this.doAction('underline'),
|
cmd: () => this.doAction('underline'),
|
||||||
key: 'U',
|
key: 'U',
|
||||||
matches: 'U'
|
hint: 'Underline'
|
||||||
},
|
},
|
||||||
strike: {
|
strike: {
|
||||||
html: '<s>S</s>',
|
html: '<s>S</s>',
|
||||||
cmd: () => this.doAction('strikethrough'),
|
cmd: () => this.doAction('strikethrough'),
|
||||||
key: 'Shift + 7',
|
key: 'Shift + 7',
|
||||||
matches: 'S'
|
hint: 'Strikethrough'
|
||||||
},
|
},
|
||||||
sub: {
|
sub: {
|
||||||
html: 'Xₙ',
|
html: 'Xₙ',
|
||||||
cmd: () => this.doAction('subscript'),
|
cmd: () => this.doAction('subscript'),
|
||||||
key: 'Shift + 5',
|
key: 'Shift + 5',
|
||||||
matches: 'SUB'
|
hint: 'Subscript'
|
||||||
},
|
},
|
||||||
sup: {
|
sup: {
|
||||||
html: 'Xⁿ',
|
html: 'Xⁿ',
|
||||||
cmd: () => this.doAction('superscript'),
|
cmd: () => this.doAction('superscript'),
|
||||||
key: 'Shift + 6',
|
key: 'Shift + 6',
|
||||||
matches: 'SUP'
|
hint: 'Superscript'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
block: {
|
block: {
|
||||||
|
|
@ -168,13 +170,13 @@ class SquireUI
|
||||||
html: '#',
|
html: '#',
|
||||||
cmd: () => this.doList('OL'),
|
cmd: () => this.doList('OL'),
|
||||||
key: 'Shift + 8',
|
key: 'Shift + 8',
|
||||||
matches: 'OL'
|
hint: 'Ordered list'
|
||||||
},
|
},
|
||||||
ul: {
|
ul: {
|
||||||
html: '⋮',
|
html: '⋮',
|
||||||
cmd: () => this.doList('UL'),
|
cmd: () => this.doList('UL'),
|
||||||
key: 'Shift + 9',
|
key: 'Shift + 9',
|
||||||
matches: 'UL'
|
hint: 'Unordered list'
|
||||||
},
|
},
|
||||||
quote: {
|
quote: {
|
||||||
html: '"',
|
html: '"',
|
||||||
|
|
@ -182,17 +184,19 @@ class SquireUI
|
||||||
let parent = squire.getSelectionClosest('UL,OL,BLOCKQUOTE')?.nodeName;
|
let parent = squire.getSelectionClosest('UL,OL,BLOCKQUOTE')?.nodeName;
|
||||||
('BLOCKQUOTE' == parent) ? squire.decreaseQuoteLevel() : squire.increaseQuoteLevel();
|
('BLOCKQUOTE' == parent) ? squire.decreaseQuoteLevel() : squire.increaseQuoteLevel();
|
||||||
},
|
},
|
||||||
matches: 'BLOCKQUOTE'
|
hint: 'Blockquote'
|
||||||
},
|
},
|
||||||
indentDecrease: {
|
indentDecrease: {
|
||||||
html: '⇤',
|
html: '⇤',
|
||||||
cmd: () => squire.changeIndentationLevel('decrease'),
|
cmd: () => squire.changeIndentationLevel('decrease'),
|
||||||
key: ']'
|
key: ']',
|
||||||
|
hint: 'Decrease indent'
|
||||||
},
|
},
|
||||||
indentIncrease: {
|
indentIncrease: {
|
||||||
html: '⇥',
|
html: '⇥',
|
||||||
cmd: () => squire.changeIndentationLevel('increase'),
|
cmd: () => squire.changeIndentationLevel('increase'),
|
||||||
key: '['
|
key: '[',
|
||||||
|
hint: 'Increase indent'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
targets: {
|
targets: {
|
||||||
|
|
@ -205,21 +209,21 @@ class SquireUI
|
||||||
url.length ? squire.makeLink(url) : (node && squire.removeLink());
|
url.length ? squire.makeLink(url) : (node && squire.removeLink());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
matches: 'A'
|
hint: 'Link'
|
||||||
},
|
},
|
||||||
imageUrl: {
|
imageUrl: {
|
||||||
html: '🖼️',
|
html: '🖼️',
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
let node = squire.getSelectionClosest('IMG'),
|
let node = squire.getSelectionClosest('IMG'),
|
||||||
src = prompt("Image", node?.src || "https://");
|
src = prompt("Image", node?.src || "https://");
|
||||||
src?.length ? squire.insertImage(src) : node?.remove();
|
src.length ? squire.insertImage(src) : (node && squire.detach(node));
|
||||||
},
|
},
|
||||||
matches: 'IMG'
|
hint: 'Image URL'
|
||||||
},
|
},
|
||||||
imageUpload: {
|
imageUpload: {
|
||||||
html: '📂️',
|
html: '📂️',
|
||||||
cmd: () => browseImage.click(),
|
cmd: () => browseImage.click(),
|
||||||
matches: 'IMG'
|
hint: 'Image select',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/*
|
/*
|
||||||
|
|
@ -231,26 +235,19 @@ class SquireUI
|
||||||
undo: {
|
undo: {
|
||||||
html: '↶',
|
html: '↶',
|
||||||
cmd: () => squire.undo(),
|
cmd: () => squire.undo(),
|
||||||
key: 'Z'
|
key: 'Z',
|
||||||
|
hint: 'Undo'
|
||||||
},
|
},
|
||||||
redo: {
|
redo: {
|
||||||
html: '↷',
|
html: '↷',
|
||||||
cmd: () => squire.redo(),
|
cmd: () => squire.redo(),
|
||||||
key: 'Y'
|
key: 'Y',
|
||||||
|
hint: 'Redo'
|
||||||
},
|
},
|
||||||
source: {
|
source: {
|
||||||
html: '👁',
|
html: '👁',
|
||||||
cmd: btn => {
|
cmd: () => this.setMode('source' == this.mode ? 'wysiwyg' : 'source'),
|
||||||
this.setMode('source' == this.mode ? 'wysiwyg' : 'source');
|
hint: i18n('EDITOR/TEXT_SWITCHER_SOURCE', 'Source')
|
||||||
btn.classList.toggle('active', 'source' == this.mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
clear: {
|
|
||||||
removeStyle: {
|
|
||||||
html: '⎚',
|
|
||||||
cmd: () => squire.setStyle()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -261,30 +258,6 @@ class SquireUI
|
||||||
browseImage = createElement('input'),
|
browseImage = createElement('input'),
|
||||||
squire = new Squire(wysiwyg, SquireDefaultConfig);
|
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.type = 'file';
|
||||||
browseImage.accept = 'image/*';
|
browseImage.accept = 'image/*';
|
||||||
browseImage.style.display = 'none';
|
browseImage.style.display = 'none';
|
||||||
|
|
@ -298,18 +271,25 @@ class SquireUI
|
||||||
|
|
||||||
plain.className = 'squire-plain';
|
plain.className = 'squire-plain';
|
||||||
wysiwyg.className = 'squire-wysiwyg';
|
wysiwyg.className = 'squire-wysiwyg';
|
||||||
wysiwyg.dir = 'auto';
|
|
||||||
this.mode = ''; // 'plain' | 'wysiwyg'
|
this.mode = ''; // 'plain' | 'wysiwyg'
|
||||||
|
this.__plain = {
|
||||||
|
getRawData: () => this.plain.value,
|
||||||
|
setRawData: plain => this.plain.value = plain
|
||||||
|
};
|
||||||
|
|
||||||
this.container = container;
|
this.container = container;
|
||||||
this.squire = squire;
|
this.squire = squire;
|
||||||
this.plain = plain;
|
this.plain = plain;
|
||||||
this.wysiwyg = wysiwyg;
|
this.wysiwyg = wysiwyg;
|
||||||
|
|
||||||
dispatchEvent(new CustomEvent('squire-toolbar', {detail:{squire:this,actions:actions}}));
|
|
||||||
|
|
||||||
toolbar.className = 'squire-toolbar btn-toolbar';
|
toolbar.className = 'squire-toolbar btn-toolbar';
|
||||||
let group, action/*, touchTap*/;
|
let group, action/*, touchTap*/;
|
||||||
for (group in actions) {
|
for (group in actions) {
|
||||||
|
/*
|
||||||
|
if ('bidi' == group && !rl.settings.app('allowHtmlEditorBitiButtons')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
*/
|
||||||
let toolgroup = createElement('div');
|
let toolgroup = createElement('div');
|
||||||
toolgroup.className = 'btn-group';
|
toolgroup.className = 'btn-group';
|
||||||
toolgroup.id = 'squire-toolgroup-'+group;
|
toolgroup.id = 'squire-toolgroup-'+group;
|
||||||
|
|
@ -330,7 +310,6 @@ class SquireUI
|
||||||
input.append(option);
|
input.append(option);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
input.add(new Option(i18n('GLOBAL/DEFAULT'), ''));
|
|
||||||
Object.entries(cfg.select).forEach(([label, options]) => {
|
Object.entries(cfg.select).forEach(([label, options]) => {
|
||||||
let group = createElement('optgroup');
|
let group = createElement('optgroup');
|
||||||
group.label = label;
|
group.label = label;
|
||||||
|
|
@ -339,7 +318,7 @@ class SquireUI
|
||||||
option.style[action] = value;
|
option.style[action] = value;
|
||||||
group.append(option);
|
group.append(option);
|
||||||
});
|
});
|
||||||
input.add(group);
|
input.append(group);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ev = 'input';
|
ev = 'input';
|
||||||
|
|
@ -363,7 +342,6 @@ class SquireUI
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
input.addEventListener(ev, () => cfg.cmd(input));
|
input.addEventListener(ev, () => cfg.cmd(input));
|
||||||
cfg.hint = i18n('EDITOR/' + action.toUpperCase());
|
|
||||||
if (cfg.hint) {
|
if (cfg.hint) {
|
||||||
input.title = cfg.key ? cfg.hint + ' (' + ctrlKey + cfg.key + ')' : cfg.hint;
|
input.title = cfg.key ? cfg.hint + ' (' + ctrlKey + cfg.key + ')' : cfg.hint;
|
||||||
} else if (cfg.key) {
|
} else if (cfg.key) {
|
||||||
|
|
@ -381,178 +359,36 @@ class SquireUI
|
||||||
|
|
||||||
let changes = actions.changes;
|
let changes = actions.changes;
|
||||||
changes.undo.input.disabled = changes.redo.input.disabled = true;
|
changes.undo.input.disabled = changes.redo.input.disabled = true;
|
||||||
squire.addEventListener('undoStateChange', e => {
|
squire.addEventListener('undoStateChange', state => {
|
||||||
changes.undo.input.disabled = !e.detail.canUndo;
|
changes.undo.input.disabled = !state.canUndo;
|
||||||
changes.redo.input.disabled = !e.detail.canRedo;
|
changes.redo.input.disabled = !state.canRedo;
|
||||||
});
|
});
|
||||||
|
|
||||||
squire.addEventListener('pasteImage', e => {
|
|
||||||
const items = e.detail.clipboardData.items;
|
|
||||||
let l = items.length;
|
|
||||||
while (l--) {
|
|
||||||
const item = items[l];
|
|
||||||
if (/^image\/(png|jpeg|webp)/.test(item.type)) {
|
|
||||||
let reader = new FileReader();
|
|
||||||
reader.onload = event => {
|
|
||||||
let img = createElement("img"),
|
|
||||||
canvas = createElement("canvas"),
|
|
||||||
ctx = canvas.getContext('2d');
|
|
||||||
img.onload = ()=>{
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
let width = img.width, height = img.height;
|
|
||||||
if (width > height) {
|
|
||||||
// Landscape
|
|
||||||
if (width > 1024) {
|
|
||||||
height = height * 1024 / width;
|
|
||||||
width = 1024;
|
|
||||||
}
|
|
||||||
} else if (height > 1024) {
|
|
||||||
// Portrait
|
|
||||||
width = width * 1024 / height;
|
|
||||||
height = 1024;
|
|
||||||
}
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
ctx.drawImage(img, 0, 0, width, height);
|
|
||||||
squire.insertHTML('<img alt="" style="width:100%;max-width:'+width+'px" src="'+canvas.toDataURL()+'">', true);
|
|
||||||
};
|
|
||||||
img.src = event.target.result;
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(item.getAsFile());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.font.fontSize.input.selectedIndex = actions.font.fontSize.defaultValueIndex;
|
|
||||||
|
|
||||||
// squire.addEventListener('focus', () => shortcuts.off());
|
// squire.addEventListener('focus', () => shortcuts.off());
|
||||||
// squire.addEventListener('blur', () => shortcuts.on());
|
// squire.addEventListener('blur', () => shortcuts.on());
|
||||||
|
|
||||||
container.append(toolbar, wysiwyg, plain);
|
container.append(toolbar, wysiwyg, plain);
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} fontName
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
const normalizeFontName = (fontName) => fontName.trim().replace(/(^["']*|["']*$)/g, '').trim().toLowerCase();
|
|
||||||
|
|
||||||
/** @type {string[]} - lower cased array of available font families*/
|
|
||||||
const fontFamiliesLowerCase = Object.values(actions.font.fontFamily.input.options).map(option => option.value.toLowerCase());
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A theme might have CSS like div.squire-wysiwyg[contenteditable="true"] {
|
|
||||||
* font-family: 'Times New Roman', Times, serif; }
|
|
||||||
* so let's find the best match squire.getRoot()'s font
|
|
||||||
* it will also help to properly handle generic font names like 'sans-serif'
|
|
||||||
* @type {number}
|
|
||||||
*/
|
|
||||||
let defaultFontFamilyIndex = 0;
|
|
||||||
const squireRootFonts = getComputedStyle(squire.getRoot()).fontFamily.split(',').map(normalizeFontName);
|
|
||||||
fontFamiliesLowerCase.some((family, index) => {
|
|
||||||
const matchFound = family.split(',').some(availableFontName => {
|
|
||||||
const normalizedFontName = normalizeFontName(availableFontName);
|
|
||||||
return squireRootFonts.some(squireFontName => squireFontName === normalizedFontName);
|
|
||||||
});
|
|
||||||
if (matchFound) {
|
|
||||||
defaultFontFamilyIndex = index;
|
|
||||||
}
|
|
||||||
return matchFound;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instead of comparing whole 'font-family' strings,
|
|
||||||
* we are going to look for individual font names, because we might be
|
|
||||||
* editing a Draft started in another email client for example
|
|
||||||
*
|
|
||||||
* @type {Object.<string,number>}
|
|
||||||
*/
|
|
||||||
const fontNamesMap = {};
|
|
||||||
/**
|
|
||||||
* @param {string} fontFamily
|
|
||||||
* @param {number} index
|
|
||||||
*/
|
|
||||||
const processFontFamilyString = (fontFamily, index) => {
|
|
||||||
fontFamily.split(',').forEach(fontName => {
|
|
||||||
const key = normalizeFontName(fontName);
|
|
||||||
if (fontNamesMap[key] === undefined) {
|
|
||||||
fontNamesMap[key] = index;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// first deal with the default font family
|
|
||||||
processFontFamilyString(fontFamiliesLowerCase[defaultFontFamilyIndex], defaultFontFamilyIndex);
|
|
||||||
// and now with the rest of the font families
|
|
||||||
fontFamiliesLowerCase.forEach((fontFamily, index) => {
|
|
||||||
if (index !== defaultFontFamilyIndex) {
|
|
||||||
processFontFamilyString(fontFamily, index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// -----
|
|
||||||
|
|
||||||
squire.addEventListener('pathChange', () => {
|
|
||||||
|
|
||||||
const squireRoot = squire.getRoot();
|
|
||||||
let range = squire.getSelection(),
|
|
||||||
collapsed = range.collapsed,
|
|
||||||
elm = collapsed ? range.endContainer : range?.commonAncestorContainer;
|
|
||||||
if (elm && !(elm instanceof Element)) {
|
|
||||||
elm = elm.parentElement;
|
|
||||||
}
|
|
||||||
forEachObjectValue(actions, entries => {
|
|
||||||
forEachObjectValue(entries, cfg => {
|
|
||||||
// Check if selection has a matching parent or contains a matching element
|
|
||||||
cfg.matches && cfg.input.classList.toggle('active', !!(elm && (
|
|
||||||
(!collapsed && [...elm.querySelectorAll(cfg.matches)].some(node => range.intersectsNode(node)))
|
|
||||||
|| elm.closestWithin(cfg.matches, squireRoot)
|
|
||||||
)));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (elm) {
|
|
||||||
// try to find font-family and/or font-size and set "select" elements' values
|
|
||||||
|
|
||||||
let sizeSelectedIndex = actions.font.fontSize.defaultValueIndex;
|
|
||||||
let familySelectedIndex = defaultFontFamilyIndex;
|
|
||||||
|
|
||||||
let familyFound = false;
|
|
||||||
let sizeFound = false;
|
|
||||||
do {
|
|
||||||
if (!familyFound && elm.style.fontFamily) {
|
|
||||||
familyFound = true;
|
|
||||||
familySelectedIndex = -1; // show empty select if we don't know the font
|
|
||||||
const fontNames = elm.style.fontFamily.split(',');
|
|
||||||
for (let i = 0; i < fontNames.length; i++) {
|
|
||||||
const index = fontNamesMap[normalizeFontName(fontNames[i])];
|
|
||||||
if (index !== undefined) {
|
|
||||||
familySelectedIndex = index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sizeFound && elm.style.fontSize) {
|
|
||||||
sizeFound = true;
|
|
||||||
// -1 is ok because it will just show a black <select>
|
|
||||||
sizeSelectedIndex = actions.font.fontSize.select.indexOf(elm.style.fontSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
elm = elm.parentElement;
|
|
||||||
} while ((!familyFound || !sizeFound) && elm && elm !== squireRoot);
|
|
||||||
|
|
||||||
actions.font.fontFamily.input.selectedIndex = familySelectedIndex;
|
|
||||||
actions.font.fontSize.input.selectedIndex = sizeSelectedIndex;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
/*
|
/*
|
||||||
squire.addEventListener('cursor', e => {
|
squire.addEventListener('dragover', );
|
||||||
console.dir({cursor:e.detail.range});
|
squire.addEventListener('drop', );
|
||||||
});
|
squire.addEventListener('pathChange', );
|
||||||
squire.addEventListener('select', e => {
|
squire.addEventListener('cursor', );
|
||||||
console.dir({select:e.detail.range});
|
squire.addEventListener('select', );
|
||||||
});
|
squire.addEventListener('input', );
|
||||||
|
squire.addEventListener('willPaste', );
|
||||||
|
squire.addEventListener( 'keydown keyup', monitorShiftKey )
|
||||||
|
squire.addEventListener( 'keydown', onKey )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// CKEditor gimmicks used by HtmlEditor
|
||||||
|
this.plugins = {
|
||||||
|
plain: true
|
||||||
|
};
|
||||||
|
this.focusManager = {
|
||||||
|
hasFocus: () => squire._isFocused,
|
||||||
|
blur: () => squire.blur()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
doAction(name) {
|
doAction(name) {
|
||||||
|
|
@ -565,11 +401,11 @@ class SquireUI
|
||||||
fn = {UL:'makeUnorderedList',OL:'makeOrderedList'};
|
fn = {UL:'makeUnorderedList',OL:'makeOrderedList'};
|
||||||
(parent == type) ? this.squire.removeList() : this.squire[fn[type]]();
|
(parent == type) ? this.squire.removeList() : this.squire[fn[type]]();
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
testPresenceinSelection(format, validation) {
|
testPresenceinSelection(format, validation) {
|
||||||
return validation.test(this.squire.getPath()) || this.squire.hasFormat(format);
|
return validation.test(this.squire.getPath()) || this.squire.hasFormat(format);
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
setMode(mode) {
|
setMode(mode) {
|
||||||
if (this.mode != mode) {
|
if (this.mode != mode) {
|
||||||
let cl = this.container.classList, source = 'source' == this.mode;
|
let cl = this.container.classList, source = 'source' == this.mode;
|
||||||
|
|
@ -590,6 +426,7 @@ class SquireUI
|
||||||
this.modeSelect.selectedIndex = 'plain' == this.mode ? 1 : 0;
|
this.modeSelect.selectedIndex = 'plain' == this.mode ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CKeditor gimmicks used by HtmlEditor
|
||||||
on(type, fn) {
|
on(type, fn) {
|
||||||
if ('mode' == type) {
|
if ('mode' == type) {
|
||||||
this.onModeChange = fn;
|
this.onModeChange = fn;
|
||||||
|
|
@ -632,7 +469,6 @@ class SquireUI
|
||||||
// Move cursor above signature
|
// Move cursor above signature
|
||||||
div.before(br);
|
div.before(br);
|
||||||
div.before(br.cloneNode());
|
div.before(br.cloneNode());
|
||||||
// squire._docWasChanged();
|
|
||||||
}
|
}
|
||||||
this._prev_txt_sig = signature;
|
this._prev_txt_sig = signature;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -656,18 +492,6 @@ class SquireUI
|
||||||
squire.setSelection( range );
|
squire.setSelection( range );
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlainData() {
|
|
||||||
return this.plain.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPlainData(text) {
|
|
||||||
this.plain.value = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
blur() {
|
|
||||||
this.squire.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
focus() {
|
focus() {
|
||||||
if ('plain' == this.mode) {
|
if ('plain' == this.mode) {
|
||||||
this.plain.focus();
|
this.plain.focus();
|
||||||
|
|
|
||||||
35
dev/External/User/ko.js
vendored
|
|
@ -1,12 +1,12 @@
|
||||||
import 'External/ko';
|
import 'External/ko';
|
||||||
import ko from 'ko';
|
import ko from 'ko';
|
||||||
import { RFC822 } from 'Common/File';
|
import { HtmlEditor } from 'Common/Html';
|
||||||
import { HtmlEditor } from 'Common/HtmlEditor';
|
|
||||||
import { timeToNode } from 'Common/Translator';
|
import { timeToNode } from 'Common/Translator';
|
||||||
import { doc, elementById, addEventsListeners, dropdowns, leftPanelDisabled } from 'Common/Globals';
|
import { doc, elementById, addEventsListeners, dropdowns, leftPanelDisabled } from 'Common/Globals';
|
||||||
|
import { dropdownsDetectVisibility } from 'Common/UtilsUser';
|
||||||
import { EmailAddressesComponent } from 'Component/EmailAddresses';
|
import { EmailAddressesComponent } from 'Component/EmailAddresses';
|
||||||
import { ThemeStore } from 'Stores/Theme';
|
import { ThemeStore } from 'Stores/Theme';
|
||||||
import { dropFilesInFolder } from 'Common/Folders';
|
import { moveMessagesToFolder, dropFilesInFolder } from 'Common/Folders';
|
||||||
import { setExpandedFolder } from 'Model/FolderCollection';
|
import { setExpandedFolder } from 'Model/FolderCollection';
|
||||||
import { FolderUserStore } from 'Stores/User/Folder';
|
import { FolderUserStore } from 'Stores/User/Folder';
|
||||||
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
import { MessagelistUserStore } from 'Stores/User/Messagelist';
|
||||||
|
|
@ -44,7 +44,7 @@ const rlContentType = 'snappymail/action',
|
||||||
let files = false;
|
let files = false;
|
||||||
// if (e.dataTransfer.types.includes('Files'))
|
// if (e.dataTransfer.types.includes('Files'))
|
||||||
for (const item of e.dataTransfer.items) {
|
for (const item of e.dataTransfer.items) {
|
||||||
files |= 'file' === item.kind && RFC822 === item.type;
|
files |= 'file' === item.kind && 'message/rfc822' === item.type;
|
||||||
}
|
}
|
||||||
if (files || dragMessages()) {
|
if (files || dragMessages()) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -63,9 +63,7 @@ const rlContentType = 'snappymail/action',
|
||||||
dragDrop = (e, element, folder, dragData) => {
|
dragDrop = (e, element, folder, dragData) => {
|
||||||
dragStop(e, element);
|
dragStop(e, element);
|
||||||
if (dragMessages() && 'copyMove' == e.dataTransfer.effectAllowed) {
|
if (dragMessages() && 'copyMove' == e.dataTransfer.effectAllowed) {
|
||||||
MessagelistUserStore.moveMessages(
|
moveMessagesToFolder(FolderUserStore.currentFolderFullName(), dragData.data, folder.fullName, e.ctrlKey);
|
||||||
FolderUserStore.currentFolderFullName(), dragData.data, folder.fullName, e.ctrlKey
|
|
||||||
);
|
|
||||||
} else if (e.dataTransfer.types.includes('Files')) {
|
} else if (e.dataTransfer.types.includes('Files')) {
|
||||||
dropFilesInFolder(folder.fullName, e.dataTransfer.files);
|
dropFilesInFolder(folder.fullName, e.dataTransfer.files);
|
||||||
}
|
}
|
||||||
|
|
@ -91,19 +89,19 @@ Object.assign(ko.bindingHandlers, {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (ko.isObservable(fValue)) {
|
if (ko.isObservable(fValue)) {
|
||||||
editor = new HtmlEditor(element, fOnReady, fUpdateKoValue, fUpdateKoValue);
|
editor = new HtmlEditor(element, fUpdateKoValue, fOnReady, fUpdateKoValue);
|
||||||
|
|
||||||
fValue.__fetchEditorValue = fUpdateKoValue;
|
fValue.__fetchEditorValue = fUpdateKoValue;
|
||||||
|
|
||||||
fValue.subscribe(fUpdateEditorValue);
|
fValue.subscribe(fUpdateEditorValue);
|
||||||
|
|
||||||
// ko.addDisposeCallback(element, () => {
|
// ko.utils.domNodeDisposal.addDisposeCallback(element, () => {
|
||||||
// });
|
// });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
time: {
|
moment: {
|
||||||
init: ttn,
|
init: ttn,
|
||||||
update: ttn
|
update: ttn
|
||||||
},
|
},
|
||||||
|
|
@ -194,6 +192,10 @@ Object.assign(ko.bindingHandlers, {
|
||||||
};
|
};
|
||||||
addEventsListeners(element, {
|
addEventsListeners(element, {
|
||||||
dragstart: e => {
|
dragstart: e => {
|
||||||
|
dragData = {
|
||||||
|
action: 'sortable',
|
||||||
|
element: element
|
||||||
|
};
|
||||||
setDragAction(e, 'sortable', 'move', element, element);
|
setDragAction(e, 'sortable', 'move', element, element);
|
||||||
element.style.opacity = 0.25;
|
element.style.opacity = 0.25;
|
||||||
},
|
},
|
||||||
|
|
@ -243,5 +245,18 @@ Object.assign(ko.bindingHandlers, {
|
||||||
dropdowns.push(element);
|
dropdowns.push(element);
|
||||||
element.ddBtn = new BSN.Dropdown(element.querySelector('.dropdown-toggle'));
|
element.ddBtn = new BSN.Dropdown(element.querySelector('.dropdown-toggle'));
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openDropdownTrigger: {
|
||||||
|
update: (element, fValueAccessor) => {
|
||||||
|
if (ko.unwrap(fValueAccessor())) {
|
||||||
|
const el = element.ddBtn;
|
||||||
|
el.open || el.toggle();
|
||||||
|
// el.focus();
|
||||||
|
|
||||||
|
dropdownsDetectVisibility();
|
||||||
|
fValueAccessor()(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
76
dev/External/ko.js
vendored
|
|
@ -15,7 +15,6 @@ export const
|
||||||
* based solely on the values of other observables in the application
|
* based solely on the values of other observables in the application
|
||||||
*/
|
*/
|
||||||
koComputable = fn => ko.computed(fn, {'pure':true}),
|
koComputable = fn => ko.computed(fn, {'pure':true}),
|
||||||
// koObservable = value => ko.observable(value),
|
|
||||||
|
|
||||||
addObservablesTo = (target, observables) =>
|
addObservablesTo = (target, observables) =>
|
||||||
forEachObjectEntry(observables, (key, value) =>
|
forEachObjectEntry(observables, (key, value) =>
|
||||||
|
|
@ -29,22 +28,6 @@ export const
|
||||||
|
|
||||||
dispose = disposable => isFunction(disposable?.dispose) && disposable.dispose(),
|
dispose = disposable => isFunction(disposable?.dispose) && disposable.dispose(),
|
||||||
|
|
||||||
onEvent = (element, event, fn) => {
|
|
||||||
element.addEventListener(event, fn);
|
|
||||||
ko.addDisposeCallback(element, () => element.removeEventListener(event, fn));
|
|
||||||
},
|
|
||||||
|
|
||||||
onKey = (key, element, fValueAccessor, fAllBindings, model) => {
|
|
||||||
let fn = event => {
|
|
||||||
if (key == event.key) {
|
|
||||||
// stopEvent(event);
|
|
||||||
// element.dispatchEvent(new Event('change'));
|
|
||||||
fValueAccessor().call(model);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
onEvent(element, 'keydown', fn);
|
|
||||||
},
|
|
||||||
|
|
||||||
// With this we don't need delegateRunOnDestroy
|
// With this we don't need delegateRunOnDestroy
|
||||||
koArrayWithDestroy = data => {
|
koArrayWithDestroy = data => {
|
||||||
data = ko.observableArray(data);
|
data = ko.observableArray(data);
|
||||||
|
|
@ -67,31 +50,46 @@ Object.assign(ko.bindingHandlers, {
|
||||||
},
|
},
|
||||||
update: (element, fValueAccessor) => {
|
update: (element, fValueAccessor) => {
|
||||||
let value = ko.unwrap(fValueAccessor());
|
let value = ko.unwrap(fValueAccessor());
|
||||||
errorTip(element, isFunction(value) ? value() : value);
|
value = isFunction(value) ? value() : value;
|
||||||
|
errorTip(element, value);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onEnter: {
|
onEnter: {
|
||||||
init: (element, fValueAccessor, fAllBindings, model) =>
|
init: (element, fValueAccessor, fAllBindings, viewModel) => {
|
||||||
onKey('Enter', element, fValueAccessor, fAllBindings, model)
|
let fn = event => {
|
||||||
|
if ('Enter' == event.key) {
|
||||||
|
element.dispatchEvent(new Event('change'));
|
||||||
|
fValueAccessor().call(viewModel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
element.addEventListener('keydown', fn);
|
||||||
|
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keydown', fn));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onEsc: {
|
onEsc: {
|
||||||
init: (element, fValueAccessor, fAllBindings, model) =>
|
init: (element, fValueAccessor, fAllBindings, viewModel) => {
|
||||||
onKey('Escape', element, fValueAccessor, fAllBindings, model)
|
let fn = event => {
|
||||||
|
if ('Escape' == event.key) {
|
||||||
|
element.dispatchEvent(new Event('change'));
|
||||||
|
fValueAccessor().call(viewModel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
element.addEventListener('keyup', fn);
|
||||||
|
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keyup', fn));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onSpace: {
|
onSpace: {
|
||||||
init: (element, fValueAccessor, fAllBindings, model) =>
|
init: (element, fValueAccessor, fAllBindings, viewModel) => {
|
||||||
onKey(' ', element, fValueAccessor, fAllBindings, model)
|
let fn = event => {
|
||||||
},
|
if (' ' == event.key) {
|
||||||
|
fValueAccessor().call(viewModel, event);
|
||||||
toggle: {
|
}
|
||||||
init: (element, fValueAccessor) => {
|
};
|
||||||
let observable = fValueAccessor(),
|
element.addEventListener('keyup', fn);
|
||||||
fn = () => observable(!observable());
|
ko.utils.domNodeDisposal.addDisposeCallback(element, () => element.removeEventListener('keyup', fn));
|
||||||
onEvent(element, 'click', fn);
|
|
||||||
onEvent(element, 'keydown', event => ' ' == event.key && fn());
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -102,12 +100,16 @@ Object.assign(ko.bindingHandlers, {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
title: {
|
||||||
|
update: (element, fValueAccessor) => element.title = ko.unwrap(fValueAccessor())
|
||||||
|
},
|
||||||
|
|
||||||
command: {
|
command: {
|
||||||
init: (element, fValueAccessor, fAllBindings, viewModel, bindingContext) => {
|
init: (element, fValueAccessor, fAllBindings, viewModel, bindingContext) => {
|
||||||
const command = fValueAccessor();
|
const command = fValueAccessor();
|
||||||
|
|
||||||
if (!command || !command.canExecute) {
|
if (!command || !command.canExecute) {
|
||||||
throw Error('Value should be a command');
|
throw new Error('Value should be a command');
|
||||||
}
|
}
|
||||||
|
|
||||||
ko.bindingHandlers['FORM'==element.nodeName ? 'submit' : 'click'].init(
|
ko.bindingHandlers['FORM'==element.nodeName ? 'submit' : 'click'].init(
|
||||||
|
|
@ -119,8 +121,10 @@ Object.assign(ko.bindingHandlers, {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
update: (element, fValueAccessor) => {
|
update: (element, fValueAccessor) => {
|
||||||
|
const cl = element.classList;
|
||||||
|
|
||||||
let disabled = !fValueAccessor().canExecute();
|
let disabled = !fValueAccessor().canExecute();
|
||||||
element.classList.toggle('disabled', disabled);
|
cl.toggle('disabled', disabled);
|
||||||
|
|
||||||
if (element.matches('INPUT,TEXTAREA,BUTTON')) {
|
if (element.matches('INPUT,TEXTAREA,BUTTON')) {
|
||||||
element.disabled = disabled;
|
element.disabled = disabled;
|
||||||
|
|
@ -176,6 +180,6 @@ ko.extenders.falseTimeout = (target, option) => {
|
||||||
|
|
||||||
// functions
|
// functions
|
||||||
|
|
||||||
ko.observable.fn.askDeleteHelper = function(prop='askDelete') {
|
ko.observable.fn.askDeleteHelper = function() {
|
||||||
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, prop] });
|
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, 'askDelete'] });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
46
dev/Html/PreviewMessage.html
Normal 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>
|
||||||
|
|
@ -5,13 +5,9 @@ function typeCast(curValue, newValue) {
|
||||||
if (null != curValue) {
|
if (null != curValue) {
|
||||||
switch (typeof curValue)
|
switch (typeof curValue)
|
||||||
{
|
{
|
||||||
case 'boolean':
|
case 'boolean': return 0 != newValue && !!newValue;
|
||||||
return 0 != newValue && !!newValue;
|
case 'number': return isFinite(newValue) ? parseFloat(newValue) : 0;
|
||||||
case 'number':
|
case 'string': return null != newValue ? '' + newValue : '';
|
||||||
newValue = parseFloat(newValue);
|
|
||||||
return isFinite(newValue) ? newValue : 0;
|
|
||||||
case 'string':
|
|
||||||
return null != newValue ? '' + newValue : '';
|
|
||||||
case 'object':
|
case 'object':
|
||||||
if (curValue.constructor.reviveFromJson) {
|
if (curValue.constructor.reviveFromJson) {
|
||||||
return curValue.constructor.reviveFromJson(newValue);
|
return curValue.constructor.reviveFromJson(newValue);
|
||||||
|
|
@ -27,10 +23,10 @@ export class AbstractModel {
|
||||||
constructor() {
|
constructor() {
|
||||||
/*
|
/*
|
||||||
if (new.target === AbstractModel) {
|
if (new.target === AbstractModel) {
|
||||||
throw Error("Can't instantiate AbstractModel!");
|
throw new Error("Can't instantiate AbstractModel!");
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
Object.defineProperty(this, 'disposables', {value: []});
|
this.disposables = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
addObservables(observables) {
|
addObservables(observables) {
|
||||||
|
|
@ -84,11 +80,13 @@ export class AbstractModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
revivePropertiesFromJson(json) {
|
revivePropertiesFromJson(json) {
|
||||||
const model = this.constructor,
|
let model = this.constructor;
|
||||||
valid = model.validJson(json);
|
if (!model.validJson(json)) {
|
||||||
valid && forEachObjectEntry(json, (key, value) => {
|
return false;
|
||||||
|
}
|
||||||
|
forEachObjectEntry(json, (key, value) => {
|
||||||
if ('@' !== key[0]) try {
|
if ('@' !== key[0]) try {
|
||||||
// key = key[0].toLowerCase() + key.slice(1);
|
key = key[0].toLowerCase() + key.slice(1);
|
||||||
switch (typeof this[key])
|
switch (typeof this[key])
|
||||||
{
|
{
|
||||||
case 'function':
|
case 'function':
|
||||||
|
|
@ -105,11 +103,9 @@ export class AbstractModel {
|
||||||
this[key] = typeCast(this[key], value);
|
this[key] = typeCast(this[key], value);
|
||||||
break;
|
break;
|
||||||
case 'undefined':
|
case 'undefined':
|
||||||
console.log(`Undefined ${model.name}.${key} set`);
|
|
||||||
this[key] = value;
|
this[key] = value;
|
||||||
break;
|
// fall through
|
||||||
// default:
|
default:
|
||||||
// console.log((typeof this[key])+` ${model.name}.${key} not revived`);
|
|
||||||
// console.log((typeof this[key])+' '+(model.name)+'.'+key+' not revived');
|
// console.log((typeof this[key])+' '+(model.name)+'.'+key+' not revived');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -117,7 +113,7 @@ export class AbstractModel {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return valid;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import ko from 'ko';
|
import ko from 'ko';
|
||||||
|
|
||||||
import { addObservablesTo, addComputablesTo, addSubscribablesTo } from 'External/ko';
|
import { addObservablesTo, addComputablesTo, addSubscribablesTo } from 'External/ko';
|
||||||
import { keyScope, addShortcut, SettingsGet, toggleLeftPanel, elementById } from 'Common/Globals';
|
import { keyScope, addShortcut, SettingsGet, leftPanelDisabled, toggleLeftPanel, elementById } from 'Common/Globals';
|
||||||
import { ViewTypePopup, showScreenPopup } from 'Knoin/Knoin';
|
import { ViewTypePopup, showScreenPopup } from 'Knoin/Knoin';
|
||||||
|
|
||||||
import { SaveSettingStatus } from 'Common/Enums';
|
import { SaveSettingStatus } from 'Common/Enums';
|
||||||
|
|
@ -59,10 +59,10 @@ export class AbstractViewPopup extends AbstractView
|
||||||
super('Popups' + name, ViewTypePopup);
|
super('Popups' + name, ViewTypePopup);
|
||||||
this.keyScope.scope = name;
|
this.keyScope.scope = name;
|
||||||
this.modalVisible = ko.observable(false).extend({ rateLimit: 0 });
|
this.modalVisible = ko.observable(false).extend({ rateLimit: 0 });
|
||||||
this.close = () => this.modalVisible(false);
|
|
||||||
this.tryToClose = () => (false === this.onClose()) || this.close();
|
|
||||||
addShortcut('escape,close', '', name, () => {
|
addShortcut('escape,close', '', name, () => {
|
||||||
this.modalVisible() && this.tryToClose();
|
if (this.modalVisible() && false !== this.onClose()) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
// return true; Issue with supported modal close
|
// return true; Issue with supported modal close
|
||||||
});
|
});
|
||||||
|
|
@ -78,6 +78,8 @@ export class AbstractViewPopup extends AbstractView
|
||||||
afterShow() {} // Happens after showModal() animation transitionend
|
afterShow() {} // Happens after showModal() animation transitionend
|
||||||
onHide() {} // Happens before animation transitionend
|
onHide() {} // Happens before animation transitionend
|
||||||
afterHide() {} // Happens after animation transitionend
|
afterHide() {} // Happens after animation transitionend
|
||||||
|
|
||||||
|
close() {}
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,6 +96,7 @@ export class AbstractViewLeft extends AbstractView
|
||||||
constructor(templateID)
|
constructor(templateID)
|
||||||
{
|
{
|
||||||
super(templateID, 'left');
|
super(templateID, 'left');
|
||||||
|
this.leftPanelDisabled = leftPanelDisabled;
|
||||||
this.toggleLeftPanel = toggleLeftPanel;
|
this.toggleLeftPanel = toggleLeftPanel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,11 +118,6 @@ export class AbstractViewSettings
|
||||||
onHide() {}
|
onHide() {}
|
||||||
viewModelDom
|
viewModelDom
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* When this[name] does not exists, create as observable with value of SettingsGet(name)
|
|
||||||
* When this[name+'Trigger'] does not exists, create as observable
|
|
||||||
* Subscribe to this[name], and handle saving the setting
|
|
||||||
*/
|
|
||||||
addSetting(name, valueCb)
|
addSetting(name, valueCb)
|
||||||
{
|
{
|
||||||
let prop = name[0].toLowerCase() + name.slice(1),
|
let prop = name[0].toLowerCase() + name.slice(1),
|
||||||
|
|
@ -135,7 +133,6 @@ export class AbstractViewSettings
|
||||||
rl.app.Remote.saveSetting(name, value,
|
rl.app.Remote.saveSetting(name, value,
|
||||||
iError => {
|
iError => {
|
||||||
this[trigger](iError ? SaveSettingStatus.Failed : SaveSettingStatus.Success);
|
this[trigger](iError ? SaveSettingStatus.Failed : SaveSettingStatus.Success);
|
||||||
// iError || Settings.set(name, value);
|
|
||||||
setTimeout(() => this[trigger](SaveSettingStatus.Idle), 1000);
|
setTimeout(() => this[trigger](SaveSettingStatus.Idle), 1000);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -143,10 +140,6 @@ export class AbstractViewSettings
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Foreach name if this[name] does not exists, create as observable with value of SettingsGet(name)
|
|
||||||
* Subscribe to this[name], for saving the setting
|
|
||||||
*/
|
|
||||||
addSettings(names)
|
addSettings(names)
|
||||||
{
|
{
|
||||||
names.forEach(name => {
|
names.forEach(name => {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
import ko from 'ko';
|
import ko from 'ko';
|
||||||
import { koComputable } from 'External/ko';
|
import { koComputable } from 'External/ko';
|
||||||
import { doc, $htmlCL, elementById, createElement, fireEvent } from 'Common/Globals';
|
import { doc, $htmlCL, elementById, fireEvent } from 'Common/Globals';
|
||||||
import { forEachObjectEntry } from 'Common/Utils';
|
import { forEachObjectEntry } from 'Common/Utils';
|
||||||
import { i18nToNodes } from 'Common/Translator';
|
import { i18nToNodes } from 'Common/Translator';
|
||||||
|
|
||||||
import { leftPanelDisabled } from 'Common/Globals';
|
|
||||||
import { ThemeStore } from 'Stores/Theme';
|
|
||||||
|
|
||||||
let
|
let
|
||||||
currentScreen = null,
|
currentScreen = null,
|
||||||
defaultScreenName = '';
|
defaultScreenName = '';
|
||||||
|
|
@ -25,13 +22,13 @@ const
|
||||||
screen = screenName => (screenName && SCREENS.get(screenName)) || null,
|
screen = screenName => (screenName && SCREENS.get(screenName)) || null,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the extended AbstractView model
|
|
||||||
* @param {Function} ViewModelClass
|
* @param {Function} ViewModelClass
|
||||||
* @param {Object=} vmScreen
|
* @param {Object=} vmScreen
|
||||||
* @returns {*}
|
* @returns {*}
|
||||||
*/
|
*/
|
||||||
buildViewModel = (ViewModelClass, vmScreen) => {
|
buildViewModel = (ViewModelClass, vmScreen) => {
|
||||||
if (ViewModelClass && !ViewModelClass.__vm) {
|
if (ViewModelClass && !ViewModelClass.__builded) {
|
||||||
|
let vmDom = null;
|
||||||
const
|
const
|
||||||
vm = new ViewModelClass(vmScreen),
|
vm = new ViewModelClass(vmScreen),
|
||||||
id = vm.viewModelTemplateID,
|
id = vm.viewModelTemplateID,
|
||||||
|
|
@ -39,30 +36,35 @@ const
|
||||||
dialog = ViewTypePopup === vm.viewType,
|
dialog = ViewTypePopup === vm.viewType,
|
||||||
vmPlace = doc.getElementById(position);
|
vmPlace = doc.getElementById(position);
|
||||||
|
|
||||||
if (vmPlace) {
|
ViewModelClass.__builded = true;
|
||||||
ViewModelClass.__vm = vm;
|
ViewModelClass.__vm = vm;
|
||||||
|
|
||||||
let vmDom = dialog
|
if (vmPlace) {
|
||||||
? createElement('dialog',{id:'V-'+id})
|
vmDom = Element.fromHTML(dialog
|
||||||
: createElement('div',{id:'V-'+id,hidden:''})
|
? '<dialog id="V-'+ id + '"></dialog>'
|
||||||
|
: '<div id="V-'+ id + '" hidden=""></div>');
|
||||||
vmPlace.append(vmDom);
|
vmPlace.append(vmDom);
|
||||||
|
|
||||||
vm.viewModelDom = vmDom;
|
vm.viewModelDom = ViewModelClass.__dom = vmDom;
|
||||||
|
|
||||||
if (dialog) {
|
if (dialog) {
|
||||||
|
vm.close = () => hideScreenPopup(ViewModelClass);
|
||||||
|
|
||||||
// Firefox < 98 / Safari < 15.4 HTMLDialogElement not defined
|
// Firefox < 98 / Safari < 15.4 HTMLDialogElement not defined
|
||||||
if (!vmDom.showModal) {
|
if (!vmDom.showModal) {
|
||||||
vmDom.className = 'polyfill';
|
vmDom.className = 'polyfill';
|
||||||
vmDom.showModal = () => {
|
vmDom.showModal = () => {
|
||||||
vmDom.backdrop ||
|
vmDom.backdrop ||
|
||||||
vmDom.before(vmDom.backdrop = createElement('div',{class:'dialog-backdrop'}));
|
vmDom.before(vmDom.backdrop = Element.fromHTML('<div class="dialog-backdrop"></div>'));
|
||||||
vmDom.setAttribute('open','');
|
vmDom.setAttribute('open','');
|
||||||
vmDom.open = true;
|
vmDom.open = true;
|
||||||
|
vmDom.returnValue = null;
|
||||||
vmDom.backdrop.hidden = false;
|
vmDom.backdrop.hidden = false;
|
||||||
};
|
};
|
||||||
vmDom.close = () => {
|
vmDom.close = v => {
|
||||||
// if (vmDom.dispatchEvent(new CustomEvent('cancel', {cancelable:true}))) {
|
// if (vmDom.dispatchEvent(new CustomEvent('cancel', {cancelable:true}))) {
|
||||||
vmDom.backdrop.hidden = true;
|
vmDom.backdrop.hidden = true;
|
||||||
|
vmDom.returnValue = v;
|
||||||
vmDom.removeAttribute('open', null);
|
vmDom.removeAttribute('open', null);
|
||||||
vmDom.open = false;
|
vmDom.open = false;
|
||||||
// vmDom.dispatchEvent(new CustomEvent('close'));
|
// vmDom.dispatchEvent(new CustomEvent('close'));
|
||||||
|
|
@ -74,17 +76,14 @@ const
|
||||||
// vmDom.addEventListener('close', () => vm.modalVisible(false));
|
// vmDom.addEventListener('close', () => vm.modalVisible(false));
|
||||||
|
|
||||||
// show/hide popup/modal
|
// show/hide popup/modal
|
||||||
// transitionend is called for each property, so we only listen to `opacity`
|
|
||||||
// as defined in CSS by `dialog:not(.animate)`
|
|
||||||
const endShowHide = e => {
|
const endShowHide = e => {
|
||||||
if (e.target === vmDom && 'opacity' === e.propertyName) {
|
if (e.target === vmDom) {
|
||||||
if (vmDom.classList.contains('animate')) {
|
if (vmDom.classList.contains('animate')) {
|
||||||
|
autofocus(vmDom);
|
||||||
vm.afterShow?.();
|
vm.afterShow?.();
|
||||||
fireEvent('rl-vm-visible', vm);
|
|
||||||
} else {
|
} else {
|
||||||
vmDom.close();
|
vmDom.close();
|
||||||
vm.afterHide?.();
|
vm.afterHide?.();
|
||||||
// fireEvent('rl-vm-hidden', vm);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -99,7 +98,6 @@ const
|
||||||
vmDom.backdrop.style.zIndex = 3000 + (visiblePopups.size * 2);
|
vmDom.backdrop.style.zIndex = 3000 + (visiblePopups.size * 2);
|
||||||
}
|
}
|
||||||
vm.keyScope.set();
|
vm.keyScope.set();
|
||||||
setTimeout(()=>autofocus(vmDom),1);
|
|
||||||
requestAnimationFrame(() => { // wait just before the next paint
|
requestAnimationFrame(() => { // wait just before the next paint
|
||||||
vmDom.offsetHeight; // force a reflow
|
vmDom.offsetHeight; // force a reflow
|
||||||
vmDom.classList.add('animate'); // trigger the transitions
|
vmDom.classList.add('animate'); // trigger the transitions
|
||||||
|
|
@ -140,9 +138,10 @@ const
|
||||||
screen.viewModels.forEach(ViewModelClass => {
|
screen.viewModels.forEach(ViewModelClass => {
|
||||||
if (
|
if (
|
||||||
ViewModelClass.__vm &&
|
ViewModelClass.__vm &&
|
||||||
|
ViewModelClass.__dom &&
|
||||||
ViewTypePopup !== ViewModelClass.__vm.viewType
|
ViewTypePopup !== ViewModelClass.__vm.viewType
|
||||||
) {
|
) {
|
||||||
fn(ViewModelClass.__vm, ViewModelClass.__vm.viewModelDom);
|
fn(ViewModelClass.__vm, ViewModelClass.__dom);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -152,69 +151,85 @@ const
|
||||||
forEachViewModel(screenToHide, (vm, dom) => {
|
forEachViewModel(screenToHide, (vm, dom) => {
|
||||||
dom.hidden = true;
|
dom.hidden = true;
|
||||||
vm.onHide?.();
|
vm.onHide?.();
|
||||||
destroy && dom.remove();
|
destroy && vm.viewModelDom.remove();
|
||||||
});
|
});
|
||||||
ThemeStore.isMobile() && leftPanelDisabled(true);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Function} ViewModelClassToHide
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
hideScreenPopup = ViewModelClassToHide =>
|
||||||
|
ViewModelClassToHide?.__vm && ViewModelClassToHide.__dom
|
||||||
|
&& ViewModelClassToHide.__vm.modalVisible(false),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} screenName
|
* @param {string} screenName
|
||||||
* @param {string} subPart
|
* @param {string} subPart
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
screenOnRoute = (screenName, subPart) => {
|
screenOnRoute = (screenName, subPart) => {
|
||||||
screenName = screenName || defaultScreenName;
|
let vmScreen = null,
|
||||||
if (screenName && fireEvent('sm-show-screen', screenName + (subPart ? '/' + subPart : ''), 1)) {
|
isSameScreen = false;
|
||||||
|
|
||||||
|
if (null == screenName || '' == screenName) {
|
||||||
|
screenName = defaultScreenName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fireEvent('sm-show-screen', screenName, 1)) {
|
||||||
|
|
||||||
// Close all popups
|
// Close all popups
|
||||||
for (let vm of visiblePopups) {
|
for (let vm of visiblePopups) {
|
||||||
vm.tryToClose();
|
(false === vm.onClose()) || vm.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
let vmScreen = screen(screenName);
|
if (screenName) {
|
||||||
if (!vmScreen) {
|
vmScreen = screen(screenName);
|
||||||
vmScreen = screen(defaultScreenName);
|
if (!vmScreen) {
|
||||||
if (vmScreen) {
|
vmScreen = screen(defaultScreenName);
|
||||||
subPart = screenName + '/' + subPart;
|
if (vmScreen) {
|
||||||
screenName = defaultScreenName;
|
subPart = screenName + '/' + subPart;
|
||||||
}
|
screenName = defaultScreenName;
|
||||||
}
|
|
||||||
|
|
||||||
if (vmScreen?.__started) {
|
|
||||||
let isSameScreen = currentScreen && vmScreen === currentScreen;
|
|
||||||
|
|
||||||
if (!vmScreen.__builded) {
|
|
||||||
vmScreen.__builded = true;
|
|
||||||
|
|
||||||
vmScreen.viewModels.forEach(ViewModelClass =>
|
|
||||||
buildViewModel(ViewModelClass, vmScreen)
|
|
||||||
);
|
|
||||||
|
|
||||||
vmScreen.onBuild?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
// hide screen
|
|
||||||
currentScreen && !isSameScreen && hideScreen(currentScreen);
|
|
||||||
// --
|
|
||||||
|
|
||||||
currentScreen = vmScreen;
|
|
||||||
|
|
||||||
// show screen
|
|
||||||
if (!isSameScreen) {
|
|
||||||
vmScreen.onShow?.();
|
|
||||||
|
|
||||||
forEachViewModel(vmScreen, (vm, dom) => {
|
|
||||||
vm.beforeShow?.();
|
|
||||||
i18nToNodes(dom);
|
|
||||||
dom.hidden = false;
|
|
||||||
vm.onShow?.();
|
|
||||||
autofocus(dom);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// --
|
}
|
||||||
|
|
||||||
vmScreen.__cross?.parse(subPart);
|
if (vmScreen?.__started) {
|
||||||
}, 1);
|
isSameScreen = currentScreen && vmScreen === currentScreen;
|
||||||
|
|
||||||
|
if (!vmScreen.__builded) {
|
||||||
|
vmScreen.__builded = true;
|
||||||
|
|
||||||
|
vmScreen.viewModels.forEach(ViewModelClass =>
|
||||||
|
buildViewModel(ViewModelClass, vmScreen)
|
||||||
|
);
|
||||||
|
|
||||||
|
vmScreen.onBuild?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
// hide screen
|
||||||
|
currentScreen && !isSameScreen && hideScreen(currentScreen);
|
||||||
|
// --
|
||||||
|
|
||||||
|
currentScreen = vmScreen;
|
||||||
|
|
||||||
|
// show screen
|
||||||
|
if (!isSameScreen) {
|
||||||
|
vmScreen.onShow?.();
|
||||||
|
|
||||||
|
forEachViewModel(vmScreen, (vm, dom) => {
|
||||||
|
vm.beforeShow?.();
|
||||||
|
i18nToNodes(dom);
|
||||||
|
dom.hidden = false;
|
||||||
|
vm.onShow?.();
|
||||||
|
autofocus(dom);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// --
|
||||||
|
|
||||||
|
vmScreen.__cross?.parse(subPart);
|
||||||
|
}, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -229,11 +244,15 @@ export const
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
showScreenPopup = (ViewModelClassToShow, params = []) => {
|
showScreenPopup = (ViewModelClassToShow, params = []) => {
|
||||||
const vm = buildViewModel(ViewModelClassToShow);
|
const vm = buildViewModel(ViewModelClassToShow) && ViewModelClassToShow.__dom && ViewModelClassToShow.__vm;
|
||||||
|
|
||||||
if (vm) {
|
if (vm) {
|
||||||
params = params || [];
|
params = params || [];
|
||||||
|
|
||||||
vm.beforeShow?.(...params);
|
vm.beforeShow?.(...params);
|
||||||
|
|
||||||
vm.modalVisible(true);
|
vm.modalVisible(true);
|
||||||
|
|
||||||
vm.onShow?.(...params);
|
vm.onShow?.(...params);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -266,7 +285,7 @@ export const
|
||||||
});
|
});
|
||||||
|
|
||||||
const cross = new Crossroads();
|
const cross = new Crossroads();
|
||||||
cross.addRoute(/^([^/]*)\/?(.*)$/, screenOnRoute);
|
cross.addRoute(/^([a-zA-Z0-9-]*)\/?(.*)$/, screenOnRoute);
|
||||||
|
|
||||||
hasher.add(cross.parse.bind(cross));
|
hasher.add(cross.parse.bind(cross));
|
||||||
hasher.init();
|
hasher.init();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
import { decodeEncodedWords, BDecode, BEncode, QPDecode, decodeText } from 'Mime/Encoding';
|
|
||||||
import { addressparser } from 'Mime/Address';
|
const
|
||||||
|
// RFC2045
|
||||||
|
QPDecodeParams = [/=([0-9A-F]{2})/g, (...args) => String.fromCharCode(parseInt(args[1], 16))],
|
||||||
|
QPDecode = data => data.replace(/=\r?\n/g, '').replace(...QPDecodeParams),
|
||||||
|
decodeText = (charset, data) => {
|
||||||
|
try {
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings
|
||||||
|
return new TextDecoder(charset).decode(Uint8Array.from(data, c => c.charCodeAt(0)));
|
||||||
|
} catch (e) {
|
||||||
|
console.error({charset:charset,error:e});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export function ParseMime(text)
|
export function ParseMime(text)
|
||||||
{
|
{
|
||||||
|
|
@ -15,52 +26,7 @@ export function ParseMime(text)
|
||||||
this.bodyEnd = 0;
|
this.bodyEnd = 0;
|
||||||
this.boundary = '';
|
this.boundary = '';
|
||||||
this.bodyText = '';
|
this.bodyText = '';
|
||||||
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6
|
this.headers = {};
|
||||||
// https://datatracker.ietf.org/doc/html/rfc4021
|
|
||||||
this.headers = {
|
|
||||||
// Required
|
|
||||||
date = null,
|
|
||||||
from = [], // mailbox-list
|
|
||||||
// Optional
|
|
||||||
sender = [], // mailbox MUST occur with multi-address
|
|
||||||
'reply-to' = [], // address-list
|
|
||||||
to = [], // address-list
|
|
||||||
cc = [], // address-list
|
|
||||||
bcc = [], // address-list
|
|
||||||
'message-id' = '', // msg-id SHOULD be present
|
|
||||||
'in-reply-to' = '', // 1*msg-id SHOULD occur in some replies
|
|
||||||
references = '', // 1*msg-id SHOULD occur in some replies
|
|
||||||
subject = '', // unstructured
|
|
||||||
// Optional unlimited
|
|
||||||
comments = [], // unstructured
|
|
||||||
keywords = [], // phrase *("," phrase)
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6.6
|
|
||||||
'resent-date' = [],
|
|
||||||
'resent-from' = [],
|
|
||||||
'resent-sender' = [],
|
|
||||||
'resent-to' = [],
|
|
||||||
'resent-cc' = [],
|
|
||||||
'resent-bcc' = [],
|
|
||||||
'resent-msg-id' = [],
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc2822#section-3.6.7
|
|
||||||
trace = [],
|
|
||||||
'return-path' = '', // angle-addr
|
|
||||||
received = [],
|
|
||||||
// optional others outside RFC2822
|
|
||||||
'mime-version' = '', // RFC2045
|
|
||||||
'content-transfer-encoding' = '',
|
|
||||||
'content-type' = '',
|
|
||||||
'delivered-to' = [], // RFC9228 addr-spec
|
|
||||||
'authentication-results' = '', // dkim, spf, dmarc
|
|
||||||
'dkim-signature' = '',
|
|
||||||
'x-rspamd-queue-id' = '',
|
|
||||||
'x-rspamd-action' = '',
|
|
||||||
'x-spamd-bar' = '',
|
|
||||||
'x-rspamd-server' = '',
|
|
||||||
'x-spamd-result' = '',
|
|
||||||
'x-remote-address' = '',
|
|
||||||
// etc.
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -83,25 +49,25 @@ export function ParseMime(text)
|
||||||
get body() {
|
get body() {
|
||||||
let body = this.bodyRaw,
|
let body = this.bodyRaw,
|
||||||
charset = this.header('content-type')?.params.charset,
|
charset = this.header('content-type')?.params.charset,
|
||||||
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
|
encoding = this.headerValue('content-transfer-encoding');
|
||||||
if ('quoted-printable' == encoding) {
|
if ('quoted-printable' == encoding) {
|
||||||
body = QPDecode(body);
|
body = QPDecode(body);
|
||||||
} else if ('base64' == encoding) {
|
} else if ('base64' == encoding) {
|
||||||
body = BDecode(body.replace(/\r?\n/g, ''));
|
body = atob(body.replace(/\r?\n/g, ''));
|
||||||
}
|
}
|
||||||
return decodeText(charset, body);
|
return decodeText(charset, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
get dataUrl() {
|
get dataUrl() {
|
||||||
let body = this.bodyRaw,
|
let body = this.bodyRaw,
|
||||||
encoding = this.headerValue('content-transfer-encoding')?.toLowerCase();
|
encoding = this.headerValue('content-transfer-encoding');
|
||||||
if ('base64' == encoding) {
|
if ('base64' == encoding) {
|
||||||
body = body.replace(/\r?\n/g, '');
|
body = body.replace(/\r?\n/g, '');
|
||||||
} else {
|
} else {
|
||||||
if ('quoted-printable' == encoding) {
|
if ('quoted-printable' == encoding) {
|
||||||
body = QPDecode(body);
|
body = QPDecode(body);
|
||||||
}
|
}
|
||||||
body = BEncode(body);
|
body = btoa(body);
|
||||||
}
|
}
|
||||||
return 'data:' + this.headerValue('content-type') + ';base64,' + body;
|
return 'data:' + this.headerValue('content-type') + ';base64,' + body;
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +78,7 @@ export function ParseMime(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
getByContentType(type) {
|
getByContentType(type) {
|
||||||
if (type == this.headerValue('content-type')?.toLowerCase()) {
|
if (type == this.headerValue('content-type')) {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
let i = 0, p = this.parts, part;
|
let i = 0, p = this.parts, part;
|
||||||
|
|
@ -124,9 +90,6 @@ export function ParseMime(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mailbox-list or address-list
|
|
||||||
const lists = ['from','reply-to','to','cc','bcc'];
|
|
||||||
|
|
||||||
const ParsePart = (mimePart, start_pos = 0, id = '') =>
|
const ParsePart = (mimePart, start_pos = 0, id = '') =>
|
||||||
{
|
{
|
||||||
let part = new MimePart,
|
let part = new MimePart,
|
||||||
|
|
@ -148,19 +111,11 @@ export function ParseMime(text)
|
||||||
[...header.matchAll(/;\s*([^;=]+)=\s*"?([^;"]+)"?/g)].forEach(param =>
|
[...header.matchAll(/;\s*([^;=]+)=\s*"?([^;"]+)"?/g)].forEach(param =>
|
||||||
params[param[1].trim().toLowerCase()] = param[2].trim()
|
params[param[1].trim().toLowerCase()] = param[2].trim()
|
||||||
);
|
);
|
||||||
let field = match[1].trim().toLowerCase();
|
// encoded-word = "=?" charset "?" encoding "?" encoded-text "?="
|
||||||
if (lists.includes(field)) {
|
match[2] = match[2].trim().replace(/=\?([^?]+)\?(B|Q)\?(.+?)\?=/g, (m, charset, encoding, text) =>
|
||||||
match[2] = addressparser(match[2]);
|
decodeText(charset, 'B' == encoding ? atob(text) : QPDecode(text))
|
||||||
} else if ('keywords' === field) {
|
);
|
||||||
match[2] = match[2].split(',').forEach(entry => decodeEncodedWords(entry.trim()));
|
headers[match[1].trim().toLowerCase()] = {
|
||||||
match[2] = (headers[field]?.value || []).concat(match[2]);
|
|
||||||
} else {
|
|
||||||
match[2] = decodeEncodedWords(match[2].trim());
|
|
||||||
if ('comments' === field) {
|
|
||||||
match[2] = (headers[field]?.value || []).push(match[2]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
headers[field] = {
|
|
||||||
value: match[2],
|
value: match[2],
|
||||||
params: params
|
params: params
|
||||||
};
|
};
|
||||||
|
|
@ -175,7 +130,7 @@ export function ParseMime(text)
|
||||||
let boundary = headers['content-type']?.params.boundary;
|
let boundary = headers['content-type']?.params.boundary;
|
||||||
if (boundary) {
|
if (boundary) {
|
||||||
part.boundary = boundary;
|
part.boundary = boundary;
|
||||||
let regex = new RegExp('(?:^|\r?\n)--' + RegExp.escape(boundary) + '(?:--)?(?:\r?\n|$)', 'g'),
|
let regex = new RegExp('(?:^|\r?\n)--' + boundary + '(?:--)?(?:\r?\n|$)', 'g'),
|
||||||
body = mimePart.slice(head.length),
|
body = mimePart.slice(head.length),
|
||||||
bodies = body.split(regex),
|
bodies = body.split(regex),
|
||||||
pos = part.bodyStart;
|
pos = part.bodyStart;
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,38 @@
|
||||||
|
|
||||||
import { ParseMime } from 'Mime/Parser';
|
import { ParseMime } from 'Mime/Parser';
|
||||||
import { AttachmentModel } from 'Model/Attachment';
|
import { AttachmentModel } from 'Model/Attachment';
|
||||||
|
import { EmailModel } from 'Model/Email';
|
||||||
import { FileInfo } from 'Common/File';
|
import { FileInfo } from 'Common/File';
|
||||||
import { BEGIN_PGP_MESSAGE } from 'Stores/User/Pgp';
|
import { BEGIN_PGP_MESSAGE } from 'Stores/User/Pgp';
|
||||||
|
|
||||||
import { EmailModel } from 'Model/Email';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string data
|
* @param string data
|
||||||
* @param MessageModel message
|
* @param MessageModel message
|
||||||
*/
|
*/
|
||||||
export function MimeToMessage(data, message)
|
export function MimeToMessage(data, message)
|
||||||
{
|
{
|
||||||
|
let signed;
|
||||||
const struct = ParseMime(data);
|
const struct = ParseMime(data);
|
||||||
if (struct.headers) {
|
if (struct.headers) {
|
||||||
let html = struct.getByContentType('text/html'),
|
let html = struct.getByContentType('text/html');
|
||||||
subject = struct.headerValue('subject');
|
|
||||||
html = html ? html.body : '';
|
html = html ? html.body : '';
|
||||||
|
|
||||||
// Content-Type: ...; protected-headers="v1"
|
if (struct.headers.subject) {
|
||||||
subject && message.subject(subject);
|
message.subject(struct.headers.subject.value);
|
||||||
|
}
|
||||||
// EmailCollectionModel
|
|
||||||
['from','to'].forEach(name => {
|
['from','to'].forEach(name => {
|
||||||
const items = message[name];
|
if (struct.headers[name] && !message[name].length) {
|
||||||
struct.headerValue(name)?.forEach(item => {
|
let mail = new EmailModel;
|
||||||
item = new EmailModel(item.email, item.name);
|
mail.parse(struct.headers[name].value);
|
||||||
// Make them unique
|
message[name].push(mail);
|
||||||
if (item.email && item.name || !items.find(address => address.email == item.email)) {
|
}
|
||||||
items.push(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
struct.forEach(part => {
|
struct.forEach(part => {
|
||||||
let cd = part.header('content-disposition'),
|
let cd = part.header('content-disposition'),
|
||||||
cId = part.header('content-id'),
|
cid = part.header('content-id'),
|
||||||
type = part.header('content-type');
|
type = part.header('content-type');
|
||||||
if (cId || cd) {
|
if (cid || cd) {
|
||||||
// if (cd && 'attachment' === cd.value) {
|
// if (cd && 'attachment' === cd.value) {
|
||||||
let attachment = new AttachmentModel;
|
let attachment = new AttachmentModel;
|
||||||
attachment.mimeType = type.value;
|
attachment.mimeType = type.value;
|
||||||
|
|
@ -45,15 +40,17 @@ export function MimeToMessage(data, message)
|
||||||
attachment.fileNameExt = attachment.fileName.replace(/^.+(\.[a-z]+)$/, '$1');
|
attachment.fileNameExt = attachment.fileName.replace(/^.+(\.[a-z]+)$/, '$1');
|
||||||
attachment.fileType = FileInfo.getType('', type.value);
|
attachment.fileType = FileInfo.getType('', type.value);
|
||||||
attachment.url = part.dataUrl;
|
attachment.url = part.dataUrl;
|
||||||
attachment.estimatedSize = part.body.length;
|
attachment.friendlySize = FileInfo.friendlySize(part.body.length);
|
||||||
/*
|
/*
|
||||||
|
attachment.isThumbnail = false;
|
||||||
attachment.contentLocation = '';
|
attachment.contentLocation = '';
|
||||||
|
attachment.download = '';
|
||||||
attachment.folder = '';
|
attachment.folder = '';
|
||||||
attachment.uid = '';
|
attachment.uid = '';
|
||||||
attachment.mimeIndex = part.id;
|
attachment.mimeIndex = part.id;
|
||||||
*/
|
*/
|
||||||
attachment.cId = cId ? cId.value : '';
|
attachment.cid = cid ? cid.value : '';
|
||||||
if (cId && html) {
|
if (cid && html) {
|
||||||
let cid = 'cid:' + attachment.contentId(),
|
let cid = 'cid:' + attachment.contentId(),
|
||||||
found = html.includes(cid);
|
found = html.includes(cid);
|
||||||
attachment.isInline(found);
|
attachment.isInline(found);
|
||||||
|
|
@ -65,28 +62,12 @@ export function MimeToMessage(data, message)
|
||||||
} else {
|
} else {
|
||||||
message.attachments.push(attachment);
|
message.attachments.push(attachment);
|
||||||
}
|
}
|
||||||
} else if ('multipart/signed' === type.value) {
|
} else if ('multipart/signed' === type.value && 'application/pgp-signature' === type.params.protocol) {
|
||||||
let protocol = type.params.protocol;
|
signed = {
|
||||||
if ('application/pgp-signature' === protocol) {
|
MicAlg: type.micalg,
|
||||||
message.pgpSigned({
|
BodyPart: part.parts[0],
|
||||||
micAlg: type.micalg,
|
SigPart: part.parts[1]
|
||||||
bodyPart: part.parts[0],
|
};
|
||||||
sigPart: part.parts[1]
|
|
||||||
});
|
|
||||||
} else if ('application/pkcs7-signature' === protocol.replace('x-')) {
|
|
||||||
message.smimeSigned({
|
|
||||||
micAlg: type.micalg,
|
|
||||||
bodyPart: part,
|
|
||||||
sigPart: part.parts[1], // For importing
|
|
||||||
detached: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if ('application/pkcs7-mime' === type.value /*&& 'signed-data' === type.params['smime-type']=*/) {
|
|
||||||
message.smimeSigned({
|
|
||||||
micAlg: type.micalg,
|
|
||||||
bodyPart: part,
|
|
||||||
detached: false
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -97,7 +78,10 @@ export function MimeToMessage(data, message)
|
||||||
message.plain(data);
|
message.plain(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.plain().includes(BEGIN_PGP_MESSAGE)) {
|
if (!signed && message.plain().includes(BEGIN_PGP_MESSAGE)) {
|
||||||
message.pgpSigned(true);
|
signed = true;
|
||||||
}
|
}
|
||||||
|
message.pgpSigned(signed);
|
||||||
|
|
||||||
|
// TODO: Verify instantly?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export class AbstractCollectionModel extends Array
|
||||||
constructor() {
|
constructor() {
|
||||||
/*
|
/*
|
||||||
if (new.target === AbstractCollectionModel) {
|
if (new.target === AbstractCollectionModel) {
|
||||||
throw Error("Can't instantiate AbstractCollectionModel!");
|
throw new Error("Can't instantiate AbstractCollectionModel!");
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
super();
|
super();
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,6 @@ export class AccountModel extends AbstractModel {
|
||||||
&& setTimeout(()=>this.fetchUnread(), (Math.ceil(Math.random() * 10)) * 3000);
|
&& setTimeout(()=>this.fetchUnread(), (Math.ceil(Math.random() * 10)) * 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
label() {
|
|
||||||
return this.name || IDN.toUnicode(this.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get INBOX unread messages
|
* Get INBOX unread messages
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import ko from 'ko';
|
import ko from 'ko';
|
||||||
|
|
||||||
import { FileInfo, FileType } from 'Common/File';
|
import { FileInfo, FileType } from 'Common/File';
|
||||||
import { stopEvent, SettingsGet, SettingsCapa } from 'Common/Globals';
|
|
||||||
import { b64EncodeJSONSafe } from 'Common/Utils';
|
|
||||||
import {
|
import {
|
||||||
attachmentDownload,
|
attachmentDownload,
|
||||||
serverRequestRaw
|
serverRequestRaw
|
||||||
|
|
@ -20,12 +18,13 @@ export class AttachmentModel extends AbstractModel {
|
||||||
this.checked = ko.observable(true);
|
this.checked = ko.observable(true);
|
||||||
|
|
||||||
this.mimeType = '';
|
this.mimeType = '';
|
||||||
// this.mimeTypeParams = '';
|
|
||||||
this.fileName = '';
|
this.fileName = '';
|
||||||
this.fileNameExt = '';
|
this.fileNameExt = '';
|
||||||
this.fileType = FileType.Unknown;
|
this.fileType = FileType.Unknown;
|
||||||
this.cId = '';
|
this.isThumbnail = false;
|
||||||
|
this.cid = '';
|
||||||
this.contentLocation = '';
|
this.contentLocation = '';
|
||||||
|
this.download = '';
|
||||||
this.folder = '';
|
this.folder = '';
|
||||||
this.uid = '';
|
this.uid = '';
|
||||||
this.url = '';
|
this.url = '';
|
||||||
|
|
@ -53,7 +52,7 @@ export class AttachmentModel extends AbstractModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleChecked(self, event) {
|
toggleChecked(self, event) {
|
||||||
stopEvent(event);
|
event.stopPropagation();
|
||||||
self.checked(!self.checked());
|
self.checked(!self.checked());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,7 +61,7 @@ export class AttachmentModel extends AbstractModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
contentId() {
|
contentId() {
|
||||||
return this.cId.replace(/^<+|>+$/g, '');
|
return this.cid.replace(/^<+|>+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -125,20 +124,6 @@ export class AttachmentModel extends AbstractModel {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get download() {
|
|
||||||
return b64EncodeJSONSafe(this.url ? {
|
|
||||||
fileName: this.fileName,
|
|
||||||
data: this.url.replace(/^.+,/, '')
|
|
||||||
} : {
|
|
||||||
folder: this.folder,
|
|
||||||
uid: this.uid,
|
|
||||||
mimeIndex: this.mimeIndex,
|
|
||||||
mimeType: this.mimeType,
|
|
||||||
fileName: this.fileName,
|
|
||||||
accountHash: SettingsGet('accountHash')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
|
|
@ -157,7 +142,7 @@ export class AttachmentModel extends AbstractModel {
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
hasThumbnail() {
|
hasThumbnail() {
|
||||||
return SettingsCapa('AttachmentThumbnails') && this.isImage() && !this.isLinked();
|
return this.isThumbnail && !this.isLinked();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -166,7 +151,7 @@ export class AttachmentModel extends AbstractModel {
|
||||||
thumbnailStyle() {
|
thumbnailStyle() {
|
||||||
return this.hasThumbnail()
|
return this.hasThumbnail()
|
||||||
? 'background:url(' + serverRequestRaw('ViewThumbnail', this.download) + ')'
|
? 'background:url(' + serverRequestRaw('ViewThumbnail', this.download) + ')'
|
||||||
: null;
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { baseCollator } from 'Common/Translator';
|
|
||||||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||||
import { AttachmentModel } from 'Model/Attachment';
|
import { AttachmentModel } from 'Model/Attachment';
|
||||||
|
|
||||||
|
|
@ -11,32 +10,22 @@ export class AttachmentCollectionModel extends AbstractCollectionModel
|
||||||
* @returns {AttachmentCollectionModel}
|
* @returns {AttachmentCollectionModel}
|
||||||
*/
|
*/
|
||||||
static reviveFromJson(items) {
|
static reviveFromJson(items) {
|
||||||
const attachments = super.reviveFromJson(items, attachment => AttachmentModel.reviveFromJson(attachment));
|
return super.reviveFromJson(items, attachment => AttachmentModel.reviveFromJson(attachment));
|
||||||
let collator = baseCollator(true);
|
|
||||||
attachments.sort((a, b) => {
|
|
||||||
if (a.isInline()) {
|
|
||||||
if (!b.isInline()) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
} else if (!b.isInline()) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return collator.compare(a.fileName, b.fileName);
|
|
||||||
});
|
|
||||||
/*
|
/*
|
||||||
|
const attachments = super.reviveFromJson(items, attachment => AttachmentModel.reviveFromJson(attachment));
|
||||||
if (attachments) {
|
if (attachments) {
|
||||||
attachments.InlineCount = attachments.reduce((accumulator, a) => accumulator + (a.isInline ? 1 : 0), 0);
|
attachments.InlineCount = attachments.reduce((accumulator, a) => accumulator + (a.isInline ? 1 : 0), 0);
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
return attachments;
|
return attachments;
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} cId
|
* @param {string} cid
|
||||||
* @returns {*}
|
* @returns {*}
|
||||||
*/
|
*/
|
||||||
findByCid(cId) {
|
findByCid(cid) {
|
||||||
cId = cId.replace(/^<+|>+$/g, '');
|
cid = cid.replace(/^<+|>+$/g, '');
|
||||||
return this.find(item => cId === item.contentId());
|
return this.find(item => cid === item.contentId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,16 @@ export class ComposeAttachmentModel extends AbstractModel {
|
||||||
* @param {?number=} size = null
|
* @param {?number=} size = null
|
||||||
* @param {boolean=} isInline = false
|
* @param {boolean=} isInline = false
|
||||||
* @param {boolean=} isLinked = false
|
* @param {boolean=} isLinked = false
|
||||||
* @param {string=} cId = ''
|
* @param {string=} CID = ''
|
||||||
* @param {string=} contentLocation = ''
|
* @param {string=} contentLocation = ''
|
||||||
*/
|
*/
|
||||||
constructor(id, fileName, size = null, isInline = false, isLinked = false, cId = '', contentLocation = '') {
|
constructor(id, fileName, size = null, isInline = false, isLinked = false, CID = '', contentLocation = '') {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.isInline = !!isInline;
|
this.isInline = !!isInline;
|
||||||
this.isLinked = !!isLinked;
|
this.isLinked = !!isLinked;
|
||||||
this.cId = cId;
|
this.CID = CID;
|
||||||
this.contentLocation = contentLocation;
|
this.contentLocation = contentLocation;
|
||||||
this.fromMessage = false;
|
this.fromMessage = false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,6 @@ export class ContactModel extends AbstractModel {
|
||||||
focused: false,
|
focused: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
checked: false,
|
checked: false,
|
||||||
sendToAll: true,
|
|
||||||
|
|
||||||
deleted: false,
|
deleted: false,
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
|
|
@ -135,6 +134,22 @@ export class ContactModel extends AbstractModel {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Array|null}
|
||||||
|
*/
|
||||||
|
getNameAndEmailHelper() {
|
||||||
|
let name = (this.givenName() + ' ' + this.surName()).trim(),
|
||||||
|
email = this.email()[0]?.value();
|
||||||
|
/*
|
||||||
|
// this.jCard.getOne('fn')?.notEmpty() ||
|
||||||
|
this.jCard.parseFullName({set:true});
|
||||||
|
// let name = this.jCard.getOne('nickname'),
|
||||||
|
let name = this.jCard.getOne('fn'),
|
||||||
|
email = this.jCard.getOne('email');
|
||||||
|
*/
|
||||||
|
return email ? [email, name] : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @static
|
* @static
|
||||||
* @param {jCard} json
|
* @param {jCard} json
|
||||||
|
|
@ -194,15 +209,19 @@ export class ContactModel extends AbstractModel {
|
||||||
return contact;
|
return contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
generateUid() {
|
||||||
|
return '' + this.id;
|
||||||
|
}
|
||||||
|
|
||||||
addEmail() {
|
addEmail() {
|
||||||
// home, work
|
// home, work
|
||||||
this.email.push({
|
this.email.push({
|
||||||
value: ko.observable('')
|
value: ko.observable('')
|
||||||
// type: prop.params.type
|
// type: prop.params.type
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.sendToAllDisplayStatus())
|
|
||||||
document.getElementById('send-to-all').style.display = 'block';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addTel() {
|
addTel() {
|
||||||
|
|
@ -245,7 +264,7 @@ export class ContactModel extends AbstractModel {
|
||||||
this.namePrefix(),
|
this.namePrefix(),
|
||||||
this.nameSuffix()
|
this.nameSuffix()
|
||||||
]/*, params, group*/);
|
]/*, params, group*/);
|
||||||
jCard.parseFullName({set:true});
|
// jCard.parseFullName({set:true});
|
||||||
|
|
||||||
['nickname', 'note', 'title'].forEach(field =>
|
['nickname', 'note', 'title'].forEach(field =>
|
||||||
this[field]() ? jCard.set(field, this[field]()/*, params, group*/) : jCard.remove(field)
|
this[field]() ? jCard.set(field, this[field]()/*, params, group*/) : jCard.remove(field)
|
||||||
|
|
@ -285,7 +304,7 @@ export class ContactModel extends AbstractModel {
|
||||||
// jCard.set('rev', '2022-05-21T10:59:52Z')
|
// jCard.set('rev', '2022-05-21T10:59:52Z')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uid: this.id,
|
Uid: this.id,
|
||||||
jCard: JSON.stringify(jCard)
|
jCard: JSON.stringify(jCard)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -299,9 +318,4 @@ export class ContactModel extends AbstractModel {
|
||||||
+ (this.checked() ? ' checked' : '')
|
+ (this.checked() ? ' checked' : '')
|
||||||
+ (this.focused() ? ' focused' : '');
|
+ (this.focused() ? ' focused' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
sendToAllDisplayStatus() {
|
|
||||||
return this.email.length > 1
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,269 @@ import { AbstractModel } from 'Knoin/AbstractModel';
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses structured e-mail addresses from an address field
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* "Name <address@domain>"
|
||||||
|
*
|
||||||
|
* will be converted to
|
||||||
|
*
|
||||||
|
* [{name: "Name", address: "address@domain"}]
|
||||||
|
*
|
||||||
|
* @param {String} str Address field
|
||||||
|
* @return {Array} An array of address objects
|
||||||
|
*/
|
||||||
|
function addressparser(str) {
|
||||||
|
var tokenizer = new Tokenizer(str);
|
||||||
|
var tokens = tokenizer.tokenize();
|
||||||
|
var addresses = [];
|
||||||
|
var address = [];
|
||||||
|
var parsedAddresses = [];
|
||||||
|
|
||||||
|
tokens.forEach(token => {
|
||||||
|
if (token.type === 'operator' && (token.value === ',' || token.value === ';')) {
|
||||||
|
address.length && addresses.push(address);
|
||||||
|
address = [];
|
||||||
|
} else {
|
||||||
|
address.push(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
address.length && addresses.push(address);
|
||||||
|
|
||||||
|
addresses.forEach(address => {
|
||||||
|
address = _handleAddress(address);
|
||||||
|
if (address.length) {
|
||||||
|
parsedAddresses = parsedAddresses.concat(address);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsedAddresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts tokens for a single address into an address object
|
||||||
|
*
|
||||||
|
* @param {Array} tokens Tokens object
|
||||||
|
* @return {Object} Address object
|
||||||
|
*/
|
||||||
|
function _handleAddress(tokens) {
|
||||||
|
var isGroup = false;
|
||||||
|
var state = 'text';
|
||||||
|
var address = void 0;
|
||||||
|
var addresses = [];
|
||||||
|
var data = {
|
||||||
|
address: [],
|
||||||
|
comment: [],
|
||||||
|
group: [],
|
||||||
|
text: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter out <addresses>, (comments) and regular text
|
||||||
|
tokens.forEach(token => {
|
||||||
|
if (token.type === 'operator') {
|
||||||
|
switch (token.value) {
|
||||||
|
case '<':
|
||||||
|
state = 'address';
|
||||||
|
break;
|
||||||
|
case '(':
|
||||||
|
state = 'comment';
|
||||||
|
break;
|
||||||
|
case ':':
|
||||||
|
state = 'group';
|
||||||
|
isGroup = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
state = 'text';
|
||||||
|
}
|
||||||
|
} else if (token.value) {
|
||||||
|
data[state].push(token.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there is no text but a comment, replace the two
|
||||||
|
if (!data.text.length && data.comment.length) {
|
||||||
|
data.text = data.comment;
|
||||||
|
data.comment = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
||||||
|
data.text = data.text.join(' ');
|
||||||
|
addresses.push({
|
||||||
|
name: data.text || address && address.name,
|
||||||
|
group: data.group.length ? addressparser(data.group.join(',')) : []
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If no address was found, try to detect one from regular text
|
||||||
|
if (!data.address.length && data.text.length) {
|
||||||
|
var i = data.text.length;
|
||||||
|
while (i--) {
|
||||||
|
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
||||||
|
data.address = data.text.splice(i, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// still no address
|
||||||
|
if (!data.address.length) {
|
||||||
|
i = data.text.length;
|
||||||
|
while (i--) {
|
||||||
|
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, address => {
|
||||||
|
if (!data.address.length) {
|
||||||
|
data.address = [address.trim()];
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return address.trim();
|
||||||
|
});
|
||||||
|
if (data.address.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's still is no text but a comment exixts, replace the two
|
||||||
|
if (!data.text.length && data.comment.length) {
|
||||||
|
data.text = data.comment;
|
||||||
|
data.comment = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only the first address occurence, push others to regular text
|
||||||
|
if (data.address.length > 1) {
|
||||||
|
data.text = data.text.concat(data.address.splice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join values with spaces
|
||||||
|
data.text = data.text.join(' ');
|
||||||
|
data.address = data.address.join(' ');
|
||||||
|
|
||||||
|
if (!data.address && isGroup) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
address = {
|
||||||
|
address: data.address || data.text || '',
|
||||||
|
name: data.text || data.address || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
if (address.address === address.name) {
|
||||||
|
if ((address.address || '').match(/@/)) {
|
||||||
|
address.name = '';
|
||||||
|
} else {
|
||||||
|
address.address = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addresses.push(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Operator tokens and which tokens are expected to end the sequence
|
||||||
|
*/
|
||||||
|
var OPERATORS = {
|
||||||
|
'"': '"',
|
||||||
|
'(': ')',
|
||||||
|
'<': '>',
|
||||||
|
',': '',
|
||||||
|
// Groups are ended by semicolons
|
||||||
|
':': ';',
|
||||||
|
// Semicolons are not a legal delimiter per the RFC2822 grammar other
|
||||||
|
// than for terminating a group, but they are also not valid for any
|
||||||
|
// other use in this context. Given that some mail clients have
|
||||||
|
// historically allowed the semicolon as a delimiter equivalent to the
|
||||||
|
// comma in their UI, it makes sense to treat them the same as a comma
|
||||||
|
// when used outside of a group.
|
||||||
|
';': ''
|
||||||
|
};
|
||||||
|
|
||||||
|
class Tokenizer
|
||||||
|
{
|
||||||
|
constructor(str) {
|
||||||
|
this.str = (str || '').toString();
|
||||||
|
this.operatorCurrent = '';
|
||||||
|
this.operatorExpecting = '';
|
||||||
|
this.node = null;
|
||||||
|
this.escaped = false;
|
||||||
|
this.list = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenize() {
|
||||||
|
var list = [];
|
||||||
|
[...this.str].forEach(c => this.checkChar(c));
|
||||||
|
|
||||||
|
this.list.forEach(node => {
|
||||||
|
node.value = (node.value || '').toString().trim();
|
||||||
|
node.value && list.push(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkChar(chr) {
|
||||||
|
if ((chr in OPERATORS || chr === '\\') && this.escaped) {
|
||||||
|
this.escaped = false;
|
||||||
|
} else if (this.operatorExpecting && chr === this.operatorExpecting) {
|
||||||
|
this.node = {
|
||||||
|
type: 'operator',
|
||||||
|
value: chr
|
||||||
|
};
|
||||||
|
this.list.push(this.node);
|
||||||
|
this.node = null;
|
||||||
|
this.operatorExpecting = '';
|
||||||
|
this.escaped = false;
|
||||||
|
return;
|
||||||
|
} else if (!this.operatorExpecting && chr in OPERATORS) {
|
||||||
|
this.node = {
|
||||||
|
type: 'operator',
|
||||||
|
value: chr
|
||||||
|
};
|
||||||
|
this.list.push(this.node);
|
||||||
|
this.node = null;
|
||||||
|
this.operatorExpecting = OPERATORS[chr];
|
||||||
|
this.escaped = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.escaped && chr === '\\') {
|
||||||
|
this.escaped = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.node) {
|
||||||
|
this.node = {
|
||||||
|
type: 'text',
|
||||||
|
value: ''
|
||||||
|
};
|
||||||
|
this.list.push(this.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.escaped && chr !== '\\') {
|
||||||
|
this.node.value += '\\';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.node.value += chr;
|
||||||
|
this.escaped = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class EmailModel extends AbstractModel {
|
export class EmailModel extends AbstractModel {
|
||||||
/**
|
/**
|
||||||
* @param {string=} email = ''
|
* @param {string=} email = ''
|
||||||
* @param {string=} name = ''
|
* @param {string=} name = ''
|
||||||
* @param {string=} dkimStatus = 'none'
|
* @param {string=} dkimStatus = 'none'
|
||||||
*/
|
*/
|
||||||
constructor(email, name, dkimStatus = 'none') {
|
constructor(email = '', name = '', dkimStatus = 'none') {
|
||||||
super();
|
super();
|
||||||
this.email = email || '';
|
this.email = email;
|
||||||
this.name = name || '';
|
this.name = name;
|
||||||
this.dkimStatus = dkimStatus;
|
this.dkimStatus = dkimStatus;
|
||||||
this.cleanup();
|
|
||||||
|
this.clearDuplicateName();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,34 +276,60 @@ export class EmailModel extends AbstractModel {
|
||||||
*/
|
*/
|
||||||
static reviveFromJson(json) {
|
static reviveFromJson(json) {
|
||||||
const email = super.reviveFromJson(json);
|
const email = super.reviveFromJson(json);
|
||||||
email?.cleanup();
|
email?.clearDuplicateName();
|
||||||
return email?.valid() ? email : null;
|
return email;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
valid() {
|
|
||||||
return this.name || this.email;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
cleanup() {
|
clear() {
|
||||||
|
this.email = '';
|
||||||
|
this.name = '';
|
||||||
|
|
||||||
|
this.dkimStatus = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
validate() {
|
||||||
|
return this.name || this.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} withoutName = false
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
hash(withoutName = false) {
|
||||||
|
return '#' + (withoutName ? '' : this.name) + '#' + this.email + '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
clearDuplicateName() {
|
||||||
if (this.name === this.email) {
|
if (this.name === this.email) {
|
||||||
this.name = '';
|
this.name = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} query
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
search(query) {
|
||||||
|
return (this.name + ' ' + this.email).toLowerCase().includes(query.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {boolean} friendlyView = false
|
* @param {boolean} friendlyView = false
|
||||||
* @param {boolean} wrapWithLink = false
|
* @param {boolean} wrapWithLink = false
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
toLine(friendlyView, wrapWithLink) {
|
toLine(friendlyView, wrapWithLink) {
|
||||||
let name = this.name,
|
let result = this.email,
|
||||||
result = this.email,
|
name = this.name,
|
||||||
toLink = text =>
|
toLink = text =>
|
||||||
'<a href="mailto:'
|
'<a href="mailto:'
|
||||||
+ encodeHtml(result) + (name ? '?to=' + encodeURIComponent('"' + name + '" <' + result + '>') : '')
|
+ encodeHtml(result) + (name ? '?to=' + encodeURIComponent('"' + name + '" <' + result + '>') : '')
|
||||||
|
|
@ -71,11 +348,47 @@ export class EmailModel extends AbstractModel {
|
||||||
result = toLink();
|
result = toLink();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result || name;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
get domain() {
|
static splitEmailLine(line) {
|
||||||
return this.email.replace(/^.+@([^@]+)$/, '$1');
|
const result = [];
|
||||||
// return this.email.slice(this.email.lastIndexOf('@'))
|
let exists = false;
|
||||||
|
addressparser(line).forEach(item => {
|
||||||
|
const address = item.address
|
||||||
|
? new EmailModel(item.address.replace(/^[<]+(.*)[>]+$/g, '$1'), item.name || '')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (address?.email) {
|
||||||
|
exists = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(address ? address.toLine() : item.name);
|
||||||
|
});
|
||||||
|
return exists ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static parseEmailLine(line) {
|
||||||
|
return addressparser(line).map(item =>
|
||||||
|
item.address ? new EmailModel(item.address.replace(/^[<]+(.*)[>]+$/g, '$1'), item.name || '') : null
|
||||||
|
).filter(v => v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} emailAddress
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
parse(emailAddress) {
|
||||||
|
emailAddress = emailAddress.trim();
|
||||||
|
if (emailAddress) {
|
||||||
|
const result = addressparser(emailAddress);
|
||||||
|
if (result.length) {
|
||||||
|
this.name = result[0].name || '';
|
||||||
|
this.email = result[0].address || '';
|
||||||
|
this.clearDuplicateName();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||||
import { EmailModel } from 'Model/Email';
|
import { EmailModel } from 'Model/Email';
|
||||||
import { forEachObjectValue } from 'Common/Utils';
|
|
||||||
import { addressparser } from 'Mime/Address';
|
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
@ -15,16 +13,6 @@ export class EmailCollectionModel extends AbstractCollectionModel
|
||||||
return super.reviveFromJson(items, email => EmailModel.reviveFromJson(email));
|
return super.reviveFromJson(items, email => EmailModel.reviveFromJson(email));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} text
|
|
||||||
* @returns {EmailCollectionModel}
|
|
||||||
*/
|
|
||||||
static fromString(str) {
|
|
||||||
let list = new this();
|
|
||||||
list.fromString(str);
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {boolean=} friendlyView = false
|
* @param {boolean=} friendlyView = false
|
||||||
* @param {boolean=} wrapWithLink = false
|
* @param {boolean=} wrapWithLink = false
|
||||||
|
|
@ -33,43 +21,4 @@ export class EmailCollectionModel extends AbstractCollectionModel
|
||||||
toString(friendlyView, wrapWithLink) {
|
toString(friendlyView, wrapWithLink) {
|
||||||
return this.map(email => email.toLine(friendlyView, wrapWithLink)).join(', ');
|
return this.map(email => email.toLine(friendlyView, wrapWithLink)).join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} text
|
|
||||||
*/
|
|
||||||
fromString(str) {
|
|
||||||
if (str) {
|
|
||||||
let items = {}, key;
|
|
||||||
addressparser(str).forEach(item => {
|
|
||||||
item = new EmailModel(item.email, item.name);
|
|
||||||
// Make them unique
|
|
||||||
key = item.email || item.name;
|
|
||||||
if (key && (item.name || !items[key])) {
|
|
||||||
items[key] = item;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
forEachObjectValue(items, item => this.push(item));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {array} [{name: "Name", email: "address@domain"}]
|
|
||||||
*/
|
|
||||||
/*
|
|
||||||
static fromArray(addresses) {
|
|
||||||
let list = new this();
|
|
||||||
list.fromArray(addresses);
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
fromArray(addresses) {
|
|
||||||
addresses.forEach(item => {
|
|
||||||
item = new EmailModel(item.email, item.name);
|
|
||||||
// Make them unique
|
|
||||||
if (item.email && item.name || !this.find(address => address.email == item.email)) {
|
|
||||||
this.push(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||||
import { UNUSED_OPTION_VALUE } from 'Common/Consts';
|
import { UNUSED_OPTION_VALUE } from 'Common/Consts';
|
||||||
import { isArray, getKeyByValue, forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
|
import { isArray, getKeyByValue, forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
|
||||||
import { ClientSideKeyNameExpandedFolders, FolderType, FolderMetadataKeys } from 'Common/EnumsUser';
|
import { ClientSideKeyNameExpandedFolders, FolderType, FolderMetadataKeys } from 'Common/EnumsUser';
|
||||||
import { clearCache, getFolderFromCacheList, setFolder, setFolderInboxName } from 'Common/Cache';
|
import { getFolderFromCacheList, setFolder, setFolderInboxName } from 'Common/Cache';
|
||||||
import { Settings, SettingsGet, fireEvent } from 'Common/Globals';
|
import { Settings, SettingsGet, fireEvent } from 'Common/Globals';
|
||||||
|
|
||||||
import * as Local from 'Storage/Client';
|
import * as Local from 'Storage/Client';
|
||||||
|
|
@ -20,17 +20,10 @@ import { AbstractModel } from 'Knoin/AbstractModel';
|
||||||
|
|
||||||
import { /*koComputable,*/ addObservablesTo } from 'External/ko';
|
import { /*koComputable,*/ addObservablesTo } from 'External/ko';
|
||||||
|
|
||||||
import { mailBox } from 'Common/Links';
|
//import { mailBox } from 'Common/Links';
|
||||||
|
|
||||||
import Remote from 'Remote/User/Fetch';
|
import Remote from 'Remote/User/Fetch';
|
||||||
|
|
||||||
import { FileInfo } from 'Common/File';
|
|
||||||
|
|
||||||
import { FolderPopupView } from 'View/Popup/Folder';
|
|
||||||
import { showScreenPopup } from 'Knoin/Knoin';
|
|
||||||
|
|
||||||
import { isAllowedKeyword } from 'Stores/User/Folder';
|
|
||||||
|
|
||||||
const
|
const
|
||||||
// isPosNumeric = value => null != value && /^[0-9]*$/.test(value.toString()),
|
// isPosNumeric = value => null != value && /^[0-9]*$/.test(value.toString()),
|
||||||
|
|
||||||
|
|
@ -98,13 +91,12 @@ export const
|
||||||
Remote.abort('Folders')
|
Remote.abort('Folders')
|
||||||
.post('Folders', FolderUserStore.foldersLoading)
|
.post('Folders', FolderUserStore.foldersLoading)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
clearCache();
|
|
||||||
FolderCollectionModel.reviveFromJson(data.Result)?.storeIt();
|
FolderCollectionModel.reviveFromJson(data.Result)?.storeIt();
|
||||||
fCallback?.(true);
|
fCallback?.(true);
|
||||||
// Repeat every 15 minutes?
|
// Repeat every 15 minutes?
|
||||||
// this.foldersTimeout = setTimeout(loadFolders, 900000);
|
// this.foldersTimeout = setTimeout(loadFolders, 900000);
|
||||||
})
|
})
|
||||||
.catch(e => fCallback && setTimeout(fCallback, 1, false, e));
|
.catch(() => fCallback && setTimeout(fCallback, 1, false));
|
||||||
};
|
};
|
||||||
|
|
||||||
export class FolderCollectionModel extends AbstractCollectionModel
|
export class FolderCollectionModel extends AbstractCollectionModel
|
||||||
|
|
@ -117,8 +109,6 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
||||||
this.namespace;
|
this.namespace;
|
||||||
this.optimized
|
this.optimized
|
||||||
this.capabilities
|
this.capabilities
|
||||||
this.allow; // allow adding
|
|
||||||
// this.exist;
|
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -134,11 +124,11 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = super.reviveFromJson(object, oFolder => {
|
const result = super.reviveFromJson(object, oFolder => {
|
||||||
let oCacheFolder = getFolderFromCacheList(oFolder.fullName);
|
let oCacheFolder = getFolderFromCacheList(oFolder.FullName);
|
||||||
if (oCacheFolder) {
|
if (oCacheFolder) {
|
||||||
// oCacheFolder.revivePropertiesFromJson(oFolder);
|
// oCacheFolder.revivePropertiesFromJson(oFolder);
|
||||||
if (oFolder.etag) {
|
if (oFolder.Hash) {
|
||||||
oCacheFolder.etag = oFolder.etag;
|
oCacheFolder.hash = oFolder.Hash;
|
||||||
}
|
}
|
||||||
if (null != oFolder.totalEmails) {
|
if (null != oFolder.totalEmails) {
|
||||||
oCacheFolder.totalEmails(oFolder.totalEmails);
|
oCacheFolder.totalEmails(oFolder.totalEmails);
|
||||||
|
|
@ -177,32 +167,32 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Flags
|
// Flags
|
||||||
if (oFolder.attributes.includes('\\sentmail')) {
|
if (oFolder.Flags.includes('\\sentmail')) {
|
||||||
role = 'sent';
|
role = 'sent';
|
||||||
}
|
}
|
||||||
if (oFolder.attributes.includes('\\spam')) {
|
if (oFolder.Flags.includes('\\spam')) {
|
||||||
role = 'junk';
|
role = 'junk';
|
||||||
}
|
}
|
||||||
if (oFolder.attributes.includes('\\bin')) {
|
if (oFolder.Flags.includes('\\bin')) {
|
||||||
role = 'trash';
|
role = 'trash';
|
||||||
}
|
}
|
||||||
if (oFolder.attributes.includes('\\important')) {
|
if (oFolder.Flags.includes('\\important')) {
|
||||||
role = 'important';
|
role = 'important';
|
||||||
}
|
}
|
||||||
if (oFolder.attributes.includes('\\starred')) {
|
if (oFolder.Flags.includes('\\starred')) {
|
||||||
role = 'flagged';
|
role = 'flagged';
|
||||||
}
|
}
|
||||||
if (oFolder.attributes.includes('\\all') || oFolder.flags.includes('\\allmail')) {
|
if (oFolder.Flags.includes('\\all') || oFolder.Flags.includes('\\allmail')) {
|
||||||
role = 'all';
|
role = 'all';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
if (role) {
|
if (role) {
|
||||||
role = role[0].toUpperCase() + role.slice(1);
|
role = role[0].toUpperCase() + role.slice(1);
|
||||||
SystemFolders[role] || (SystemFolders[role] = oFolder.fullName);
|
SystemFolders[role] || (SystemFolders[role] = oFolder.FullName);
|
||||||
}
|
}
|
||||||
|
|
||||||
oCacheFolder.type(FolderType[getKeyByValue(SystemFolders, oFolder.fullName)] || 0);
|
oCacheFolder.type(FolderType[getKeyByValue(SystemFolders, oFolder.FullName)] || 0);
|
||||||
|
|
||||||
oCacheFolder.collapsed(!expandedFolders
|
oCacheFolder.collapsed(!expandedFolders
|
||||||
|| !isArray(expandedFolders)
|
|| !isArray(expandedFolders)
|
||||||
|
|
@ -231,13 +221,14 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
||||||
name = parents.pop(),
|
name = parents.pop(),
|
||||||
pfolder = getFolderFromCacheList(parentName);
|
pfolder = getFolderFromCacheList(parentName);
|
||||||
if (!pfolder) {
|
if (!pfolder) {
|
||||||
console.log('Create nonexistent folder ' + parentName);
|
|
||||||
pfolder = FolderModel.reviveFromJson({
|
pfolder = FolderModel.reviveFromJson({
|
||||||
'@Object': 'Object/Folder',
|
'@Object': 'Object/Folder',
|
||||||
name: name,
|
Name: name,
|
||||||
fullName: parentName,
|
FullName: parentName,
|
||||||
delimiter: delimiter,
|
Delimiter: delimiter,
|
||||||
attributes: ['\\nonexistent']
|
Exists: false,
|
||||||
|
isSubscribed: false,
|
||||||
|
Flags: ['\\nonexistent']
|
||||||
});
|
});
|
||||||
setFolder(pfolder);
|
setFolder(pfolder);
|
||||||
result.splice(i, 0, pfolder);
|
result.splice(i, 0, pfolder);
|
||||||
|
|
@ -260,10 +251,6 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
visible() {
|
|
||||||
return this.filter(folder => folder.visible());
|
|
||||||
}
|
|
||||||
|
|
||||||
storeIt() {
|
storeIt() {
|
||||||
FolderUserStore.displaySpecSetting(Settings.app('folderSpecLimit') < this.CountRec);
|
FolderUserStore.displaySpecSetting(Settings.app('folderSpecLimit') < this.CountRec);
|
||||||
|
|
||||||
|
|
@ -283,9 +270,11 @@ export class FolderCollectionModel extends AbstractCollectionModel
|
||||||
FolderUserStore.namespace = this.namespace;
|
FolderUserStore.namespace = this.namespace;
|
||||||
|
|
||||||
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
|
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
|
||||||
AppUserStore.threadsAllowed(!!this.capabilities.some(capa => capa.startsWith('THREAD=')));
|
AppUserStore.threadsAllowed(!!(
|
||||||
|
Settings.app('useImapThread') && this.capabilities.some(capa => capa.startsWith('THREAD='))
|
||||||
|
));
|
||||||
|
|
||||||
// FolderUserStore.optimized(!!this.optimized);
|
// FolderUserStore.folderListOptimized(!!this.optimized);
|
||||||
FolderUserStore.quotaUsage(this.quotaUsage);
|
FolderUserStore.quotaUsage(this.quotaUsage);
|
||||||
FolderUserStore.quotaLimit(this.quotaLimit);
|
FolderUserStore.quotaLimit(this.quotaLimit);
|
||||||
FolderUserStore.capabilities(this.capabilities);
|
FolderUserStore.capabilities(this.capabilities);
|
||||||
|
|
@ -306,7 +295,6 @@ export class FolderModel extends AbstractModel {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.fullName = '';
|
this.fullName = '';
|
||||||
this.parentName = '';
|
|
||||||
this.delimiter = '';
|
this.delimiter = '';
|
||||||
this.deep = 0;
|
this.deep = 0;
|
||||||
this.expires = 0;
|
this.expires = 0;
|
||||||
|
|
@ -314,10 +302,9 @@ export class FolderModel extends AbstractModel {
|
||||||
|
|
||||||
this.exists = true;
|
this.exists = true;
|
||||||
|
|
||||||
this.etag = '';
|
this.hash = '';
|
||||||
this.id = 0;
|
// this.id = null;
|
||||||
this.uidNext = 0;
|
this.uidNext = null;
|
||||||
this.size = 0;
|
|
||||||
|
|
||||||
addObservablesTo(this, {
|
addObservablesTo(this, {
|
||||||
name: '',
|
name: '',
|
||||||
|
|
@ -327,10 +314,12 @@ export class FolderModel extends AbstractModel {
|
||||||
|
|
||||||
focused: false,
|
focused: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
|
editing: false,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
checkable: false, // Check for new messages
|
checkable: false, // Check for new messages
|
||||||
askDelete: false,
|
askDelete: false,
|
||||||
|
|
||||||
|
nameForEdit: '',
|
||||||
errorMsg: '',
|
errorMsg: '',
|
||||||
|
|
||||||
totalEmails: 0,
|
totalEmails: 0,
|
||||||
|
|
@ -343,13 +332,13 @@ export class FolderModel extends AbstractModel {
|
||||||
tagsAllowed: false
|
tagsAllowed: false
|
||||||
});
|
});
|
||||||
|
|
||||||
this.attributes = ko.observableArray();
|
this.flags = ko.observableArray();
|
||||||
// For messages
|
|
||||||
this.permanentFlags = ko.observableArray();
|
this.permanentFlags = ko.observableArray();
|
||||||
|
|
||||||
this.addSubscribables({
|
this.addSubscribables({
|
||||||
kolabType: sValue => this.metadata[FolderMetadataKeys.KolabFolderType] = sValue,
|
kolabType: sValue => this.metadata[FolderMetadataKeys.KolabFolderType] = sValue,
|
||||||
permanentFlags: aValue => this.tagsAllowed(aValue.includes('\\*')),
|
permanentFlags: aValue => this.tagsAllowed(aValue.includes('\\*')),
|
||||||
|
editing: value => value && this.nameForEdit(this.name()),
|
||||||
unreadEmails: unread => FolderType.Inbox === this.type() && fireEvent('mailbox.inbox-unread-count', unread)
|
unreadEmails: unread => FolderType.Inbox === this.type() && fireEvent('mailbox.inbox-unread-count', unread)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -371,19 +360,20 @@ export class FolderModel extends AbstractModel {
|
||||||
.extend({ notify: 'always' });
|
.extend({ notify: 'always' });
|
||||||
*/
|
*/
|
||||||
/*
|
/*
|
||||||
// https://www.rfc-editor.org/rfc/rfc8621.html#section-2
|
https://www.rfc-editor.org/rfc/rfc8621.html#section-2
|
||||||
this.myRights = {
|
"myRights": {
|
||||||
'mayAddItems': true,
|
"mayAddItems": true,
|
||||||
'mayCreateChild': true,
|
"mayRename": false,
|
||||||
'mayDelete': true,
|
"maySubmit": true,
|
||||||
'mayReadItems': true,
|
"mayDelete": false,
|
||||||
'mayRemoveItems': true,
|
"maySetKeywords": true,
|
||||||
'mayRename': true,
|
"mayRemoveItems": true,
|
||||||
'maySetKeywords': true,
|
"mayCreateChild": true,
|
||||||
'maySetSeen': true,
|
"maySetSeen": true,
|
||||||
'maySubmit': true
|
"mayReadItems": true
|
||||||
};
|
},
|
||||||
*/
|
*/
|
||||||
|
|
||||||
this.addComputables({
|
this.addComputables({
|
||||||
|
|
||||||
isInbox: () => FolderType.Inbox === this.type(),
|
isInbox: () => FolderType.Inbox === this.type(),
|
||||||
|
|
@ -391,10 +381,7 @@ export class FolderModel extends AbstractModel {
|
||||||
isFlagged: () => FolderUserStore.currentFolder() === this
|
isFlagged: () => FolderUserStore.currentFolder() === this
|
||||||
&& MessagelistUserStore.listSearch().includes('flagged'),
|
&& MessagelistUserStore.listSearch().includes('flagged'),
|
||||||
|
|
||||||
// isSubscribed: () => this.attributes().includes('\\subscribed'),
|
|
||||||
|
|
||||||
hasVisibleSubfolders: () => !!this.subFolders().find(folder => folder.visible()),
|
hasVisibleSubfolders: () => !!this.subFolders().find(folder => folder.visible()),
|
||||||
visibleSubfolders: () => this.subFolders().visible(),
|
|
||||||
|
|
||||||
hasSubscriptions: () => this.isSubscribed() | !!this.subFolders().find(
|
hasSubscriptions: () => this.isSubscribed() | !!this.subFolders().find(
|
||||||
oFolder => {
|
oFolder => {
|
||||||
|
|
@ -403,6 +390,8 @@ export class FolderModel extends AbstractModel {
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|
||||||
|
canBeEdited: () => !this.type() && this.exists/* && this.selectable()*/,
|
||||||
|
|
||||||
isSystemFolder: () => this.type()
|
isSystemFolder: () => this.type()
|
||||||
| (FolderUserStore.allowKolab() && !!this.kolabType() & !SettingsUserStore.unhideKolabFolders()),
|
| (FolderUserStore.allowKolab() && !!this.kolabType() & !SettingsUserStore.unhideKolabFolders()),
|
||||||
|
|
||||||
|
|
@ -413,8 +402,6 @@ export class FolderModel extends AbstractModel {
|
||||||
canBeSubscribed: () => this.selectable()
|
canBeSubscribed: () => this.selectable()
|
||||||
&& !(this.isSystemFolder() | !SettingsUserStore.hideUnsubscribed()),
|
&& !(this.isSystemFolder() | !SettingsUserStore.hideUnsubscribed()),
|
||||||
|
|
||||||
optionalTags: () => this.permanentFlags.filter(isAllowedKeyword),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Folder is visible when:
|
* Folder is visible when:
|
||||||
* - hasVisibleSubfolders()
|
* - hasVisibleSubfolders()
|
||||||
|
|
@ -456,47 +443,30 @@ export class FolderModel extends AbstractModel {
|
||||||
return name;
|
return name;
|
||||||
},
|
},
|
||||||
|
|
||||||
nameInfo: () => {
|
manageFolderSystemName: () => {
|
||||||
if (this.isSystemFolder()) {
|
if (this.isSystemFolder()) {
|
||||||
translateTrigger();
|
translateTrigger();
|
||||||
let suffix = getSystemFolderName(this.type(), getKolabFolderName(this.kolabType()));
|
let suffix = getSystemFolderName(this.type(), getKolabFolderName(this.kolabType()));
|
||||||
if (this.name() !== suffix && 'inbox' !== suffix.toLowerCase()) {
|
if (this.name() !== suffix && 'inbox' !== suffix.toLowerCase()) {
|
||||||
return ' (' + suffix + ')';
|
return '(' + suffix + ')';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
|
|
||||||
friendlySize: () => FileInfo.friendlySize(this.size),
|
hasSubscribedUnreadMessagesSubfolders: () =>
|
||||||
|
!!this.subFolders().find(
|
||||||
detailedName: () => this.name() + ' ' + this.nameInfo(),
|
folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
|
||||||
|
)
|
||||||
icon: () => {
|
/*
|
||||||
switch (this.type())
|
!!this.subFolders().filter(
|
||||||
{
|
folder => folder.unreadCount() | folder.hasSubscribedUnreadMessagesSubfolders()
|
||||||
case 1: return '📥'; // FolderType.Inbox
|
).length
|
||||||
case 2: return '📧'; // FolderType.Sent icon-paper-plane
|
*/
|
||||||
case 3: return '🗎'; // FolderType.Drafts
|
// ,href: () => this.canBeSelected() && mailBox(this.fullNameHash)
|
||||||
case 4: return '⚠'; // FolderType.Junk
|
|
||||||
case 5: return '🗑'; // FolderType.Trash
|
|
||||||
case 6: return '🗄'; // FolderType.Archive
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
|
|
||||||
hasUnreadInSub: () =>
|
|
||||||
this.subFolders().some(
|
|
||||||
folder => folder.unreadEmails() | folder.hasUnreadInSub()
|
|
||||||
),
|
|
||||||
|
|
||||||
href: () => this.canBeSelected() && mailBox(this.fullNameHash)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
edit() {
|
|
||||||
showScreenPopup(FolderPopupView, [this]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For url safe '/#/mailbox/...' path
|
* For url safe '/#/mailbox/...' path
|
||||||
*/
|
*/
|
||||||
|
|
@ -514,7 +484,6 @@ export class FolderModel extends AbstractModel {
|
||||||
const folder = super.reviveFromJson(json);
|
const folder = super.reviveFromJson(json);
|
||||||
if (folder) {
|
if (folder) {
|
||||||
const path = folder.fullName.split(folder.delimiter),
|
const path = folder.fullName.split(folder.delimiter),
|
||||||
attr = name => folder.attributes.includes(name),
|
|
||||||
type = (folder.metadata[FolderMetadataKeys.KolabFolderType]
|
type = (folder.metadata[FolderMetadataKeys.KolabFolderType]
|
||||||
|| folder.metadata[FolderMetadataKeys.KolabFolderTypeShared]
|
|| folder.metadata[FolderMetadataKeys.KolabFolderTypeShared]
|
||||||
|| ''
|
|| ''
|
||||||
|
|
@ -524,12 +493,6 @@ export class FolderModel extends AbstractModel {
|
||||||
path.pop();
|
path.pop();
|
||||||
folder.parentName = path.join(folder.delimiter);
|
folder.parentName = path.join(folder.delimiter);
|
||||||
|
|
||||||
folder.isSubscribed(attr('\\subscribed'));
|
|
||||||
folder.exists = !attr('\\nonexistent');
|
|
||||||
folder.subFolders.allow = !attr('\\noinferiors');
|
|
||||||
// folder.subFolders.exist = attr('\\haschildren') || !attr('\\hasnochildren');
|
|
||||||
folder.selectable(folder.exists && !attr('\\noselect'));
|
|
||||||
|
|
||||||
type && 'mail' != type && folder.kolabType(type);
|
type && 'mail' != type && folder.kolabType(type);
|
||||||
}
|
}
|
||||||
return folder;
|
return folder;
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,36 @@
|
||||||
//import { AbstractModel } from 'Knoin/AbstractModel';
|
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||||
import { EmailModel } from 'Model/Email';
|
import { addObservablesTo } from 'External/ko';
|
||||||
import { addObservablesTo, addComputablesTo } from 'External/ko';
|
|
||||||
|
|
||||||
export class IdentityModel extends EmailModel /*AbstractModel*/ {
|
export class IdentityModel extends AbstractModel {
|
||||||
constructor() {
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @param {string} email
|
||||||
|
*/
|
||||||
|
constructor(id, email) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
addObservablesTo(this, {
|
addObservablesTo(this, {
|
||||||
id: '',
|
id: id || '',
|
||||||
label: '',
|
email: email,
|
||||||
// email: '',
|
name: '',
|
||||||
// name: '',
|
|
||||||
|
|
||||||
replyTo: '',
|
replyTo: '',
|
||||||
bcc: '',
|
bcc: '',
|
||||||
sentFolder: '',
|
|
||||||
|
|
||||||
signature: '',
|
signature: '',
|
||||||
signatureInsertBefore: false,
|
signatureInsertBefore: false,
|
||||||
|
|
||||||
pgpSign: false,
|
askDelete: false
|
||||||
pgpEncrypt: false,
|
|
||||||
|
|
||||||
smimeKey: '',
|
|
||||||
smimeCertificate: '',
|
|
||||||
smimeCertificateChain: '',
|
|
||||||
|
|
||||||
askDelete: false,
|
|
||||||
|
|
||||||
exists: false
|
|
||||||
});
|
|
||||||
|
|
||||||
addComputablesTo(this, {
|
|
||||||
smimeKeyEncrypted: () => this.smimeKey().includes('-----BEGIN ENCRYPTED PRIVATE KEY-----'),
|
|
||||||
smimeKeyValid: () => /^-----BEGIN (ENCRYPTED |RSA )?PRIVATE KEY-----/.test(this.smimeKey()),
|
|
||||||
smimeCertificateValid: () => /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificate()),
|
|
||||||
smimeCertificateChainValid: () => !this.smimeCertificateChain()
|
|
||||||
|| /^-----BEGIN CERTIFICATE-----/.test(this.smimeCertificateChain())
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
toString() {
|
formattedName() {
|
||||||
const name = this.name,
|
const name = this.name(),
|
||||||
email = this.email,
|
email = this.email();
|
||||||
label = this.label();
|
|
||||||
return (name ? `${name} ` : '') + `<${email}>` + (label ? ` (${label})` : '');
|
return name ? name + ' <' + email + '>' : email;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,37 @@
|
||||||
import ko from 'ko';
|
import ko from 'ko';
|
||||||
|
|
||||||
|
import { MessagePriority } from 'Common/EnumsUser';
|
||||||
import { i18n } from 'Common/Translator';
|
import { i18n } from 'Common/Translator';
|
||||||
|
|
||||||
import { doc, elementById, SettingsGet } from 'Common/Globals';
|
import { doc, SettingsGet } from 'Common/Globals';
|
||||||
import { encodeHtml, plainToHtml, htmlToPlain, cleanHtml } from 'Common/Html';
|
import { encodeHtml, plainToHtml, htmlToPlain, cleanHtml } from 'Common/Html';
|
||||||
import { forEachObjectEntry, b64EncodeJSONSafe } from 'Common/Utils';
|
import { isFunction, forEachObjectEntry } from 'Common/Utils';
|
||||||
import { serverRequestRaw, proxy } from 'Common/Links';
|
import { serverRequestRaw, proxy } from 'Common/Links';
|
||||||
import { addObservablesTo, addComputablesTo } from 'External/ko';
|
import { addObservablesTo, addComputablesTo } from 'External/ko';
|
||||||
|
|
||||||
import { FolderUserStore } from 'Stores/User/Folder';
|
import { FolderUserStore, isAllowedKeyword } from 'Stores/User/Folder';
|
||||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||||
|
|
||||||
import { FileInfo, RFC822 } from 'Common/File';
|
import { FileInfo } from 'Common/File';
|
||||||
import { AttachmentCollectionModel } from 'Model/AttachmentCollection';
|
import { AttachmentCollectionModel } from 'Model/AttachmentCollection';
|
||||||
import { EmailCollectionModel } from 'Model/EmailCollection';
|
import { EmailCollectionModel } from 'Model/EmailCollection';
|
||||||
import { MimeHeaderCollectionModel } from 'Model/MimeHeaderCollection';
|
|
||||||
//import { MimeHeaderAutocryptModel } from 'Model/MimeHeaderAutocrypt';
|
|
||||||
import { AbstractModel } from 'Knoin/AbstractModel';
|
import { AbstractModel } from 'Knoin/AbstractModel';
|
||||||
|
|
||||||
|
import PreviewHTML from 'Html/PreviewMessage.html';
|
||||||
|
|
||||||
import { LanguageStore } from 'Stores/Language';
|
import { LanguageStore } from 'Stores/Language';
|
||||||
|
|
||||||
|
//import { MessageFlagsCache } from 'Common/Cache';
|
||||||
import Remote from 'Remote/User/Fetch';
|
import Remote from 'Remote/User/Fetch';
|
||||||
|
|
||||||
import { MimeToMessage } from 'Mime/Utils';
|
|
||||||
|
|
||||||
import { PgpUserStore } from 'Stores/User/Pgp';
|
|
||||||
import { IdentityUserStore } from 'Stores/User/Identity';
|
|
||||||
import { Passphrases } from 'Storage/Passphrases';
|
|
||||||
|
|
||||||
const
|
const
|
||||||
PreviewHTML = `<html>
|
hcont = Element.fromHTML('<div area="hidden" style="position:absolute;left:-200vw;width:max(50vw,400px)"></div>'),
|
||||||
<head>
|
getRealHeight = el => {
|
||||||
<meta charset="utf-8">
|
hcont.innerHTML = el.outerHTML;
|
||||||
<title></title>
|
const result = hcont.clientHeight;
|
||||||
<style>
|
hcont.innerHTML = '';
|
||||||
html, body {
|
return result;
|
||||||
margin: 0;
|
},
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
background: rgba(125,128,128,0.3);
|
|
||||||
border-bottom: 1px solid #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
header h1 {
|
|
||||||
font-size: 120%;
|
|
||||||
}
|
|
||||||
|
|
||||||
header * {
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
header time {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-left: 2px solid rgba(125,128,128,0.5);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 0 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
word-break: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
body > * {
|
|
||||||
padding: 0.5em 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#attachments > * {
|
|
||||||
border: 1px solid rgba(125,128,128,0.5);
|
|
||||||
padding: 0.25em;
|
|
||||||
margin-right: 1em;
|
|
||||||
}
|
|
||||||
#attachments > *::before {
|
|
||||||
content: '📎 ';
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body></body>
|
|
||||||
</html>`,
|
|
||||||
|
|
||||||
msgHtml = msg => cleanHtml(msg.html(), msg.attachments(), '#rl-msg-' + msg.hash),
|
|
||||||
|
|
||||||
toggleTag = (message, keyword) => {
|
toggleTag = (message, keyword) => {
|
||||||
const lower = keyword.toLowerCase(),
|
const lower = keyword.toLowerCase(),
|
||||||
|
|
@ -94,12 +40,13 @@ body > * {
|
||||||
Remote.request('MessageSetKeyword', iError => {
|
Remote.request('MessageSetKeyword', iError => {
|
||||||
if (!iError) {
|
if (!iError) {
|
||||||
isSet ? flags.remove(lower) : flags.push(lower);
|
isSet ? flags.remove(lower) : flags.push(lower);
|
||||||
|
// MessageFlagsCache.setFor(message.folder, message.uid, flags());
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
folder: message.folder,
|
Folder: message.folder,
|
||||||
uids: message.uid,
|
Uids: message.uid,
|
||||||
keyword: keyword,
|
Keyword: keyword,
|
||||||
setAction: isSet ? 0 : 1
|
SetAction: isSet ? 0 : 1
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -113,55 +60,47 @@ body > * {
|
||||||
unic[email.email] || localEmails.has(email.email) || localEmails.set(email.email, email)
|
unic[email.email] || localEmails.has(email.email) || localEmails.set(email.email, email)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
doc.body.append(hcont);
|
||||||
|
|
||||||
export class MessageModel extends AbstractModel {
|
export class MessageModel extends AbstractModel {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
Object.assign(this, {
|
this.folder = '';
|
||||||
folder: '',
|
this.uid = 0;
|
||||||
uid: 0,
|
this.hash = '';
|
||||||
hash: '',
|
this.requestHash = '';
|
||||||
from: new EmailCollectionModel,
|
this.from = new EmailCollectionModel;
|
||||||
to: new EmailCollectionModel,
|
this.to = new EmailCollectionModel;
|
||||||
cc: new EmailCollectionModel,
|
this.cc = new EmailCollectionModel;
|
||||||
bcc: new EmailCollectionModel,
|
this.bcc = new EmailCollectionModel;
|
||||||
sender: new EmailCollectionModel,
|
this.replyTo = new EmailCollectionModel;
|
||||||
replyTo: new EmailCollectionModel,
|
this.deliveredTo = new EmailCollectionModel;
|
||||||
deliveredTo: new EmailCollectionModel,
|
this.body = null;
|
||||||
body: null,
|
this.draftInfo = [];
|
||||||
draftInfo: [],
|
this.dkim = [];
|
||||||
dkim: [],
|
this.spf = [];
|
||||||
spf: [],
|
this.dmarc = [];
|
||||||
dmarc: [],
|
this.messageId = '';
|
||||||
messageId: '',
|
this.inReplyTo = '';
|
||||||
inReplyTo: '',
|
this.references = '';
|
||||||
references: '',
|
|
||||||
// autocrypt: ko.observableArray(),
|
|
||||||
hasVirus: null, // or boolean when scanned
|
|
||||||
priority: 3, // Normal
|
|
||||||
senderEmailsString: '',
|
|
||||||
senderClearEmailsString: '',
|
|
||||||
isSpam: false,
|
|
||||||
spamScore: 0,
|
|
||||||
spamResult: '',
|
|
||||||
size: 0,
|
|
||||||
readReceipt: '',
|
|
||||||
preview: null,
|
|
||||||
|
|
||||||
attachments: ko.observableArray(new AttachmentCollectionModel),
|
|
||||||
threads: ko.observableArray(),
|
|
||||||
threadUnseen: ko.observableArray(),
|
|
||||||
unsubsribeLinks: ko.observableArray(),
|
|
||||||
flags: ko.observableArray(),
|
|
||||||
headers: ko.observableArray(new MimeHeaderCollectionModel)
|
|
||||||
});
|
|
||||||
|
|
||||||
addObservablesTo(this, {
|
addObservablesTo(this, {
|
||||||
subject: '',
|
subject: '',
|
||||||
plain: '',
|
plain: '',
|
||||||
html: '',
|
html: '',
|
||||||
dateTimestamp: 0,
|
size: 0,
|
||||||
dateTimestampSource: 0,
|
spamScore: 0,
|
||||||
|
spamResult: '',
|
||||||
|
isSpam: false,
|
||||||
|
hasVirus: null, // or boolean when scanned
|
||||||
|
dateTimeStampInUTC: 0,
|
||||||
|
priority: MessagePriority.Normal,
|
||||||
|
|
||||||
|
senderEmailsString: '',
|
||||||
|
senderClearEmailsString: '',
|
||||||
|
|
||||||
|
deleted: false,
|
||||||
|
|
||||||
// Also used by Selector
|
// Also used by Selector
|
||||||
focused: false,
|
focused: false,
|
||||||
|
|
@ -171,99 +110,52 @@ export class MessageModel extends AbstractModel {
|
||||||
isHtml: false,
|
isHtml: false,
|
||||||
hasImages: false,
|
hasImages: false,
|
||||||
hasExternals: false,
|
hasExternals: false,
|
||||||
hasTracking: false,
|
|
||||||
|
|
||||||
encrypted: false,
|
|
||||||
|
|
||||||
pgpSigned: null,
|
pgpSigned: null,
|
||||||
|
pgpVerified: null,
|
||||||
|
|
||||||
|
encrypted: false,
|
||||||
pgpEncrypted: null,
|
pgpEncrypted: null,
|
||||||
pgpDecrypted: false,
|
pgpDecrypted: false,
|
||||||
|
|
||||||
smimeSigned: null,
|
readReceipt: '',
|
||||||
smimeEncrypted: null,
|
|
||||||
smimeDecrypted: false,
|
|
||||||
|
|
||||||
// rfc8621
|
hasUnseenSubMessage: false,
|
||||||
id: '',
|
hasFlaggedSubMessage: false
|
||||||
// threadId: ''
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Basic support for Linked Data (Structured Email)
|
|
||||||
* https://json-ld.org/
|
|
||||||
* https://structured.email/
|
|
||||||
**/
|
|
||||||
linkedData: []
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.attachments = ko.observableArray(new AttachmentCollectionModel);
|
||||||
|
this.threads = ko.observableArray();
|
||||||
|
this.unsubsribeLinks = ko.observableArray();
|
||||||
|
this.flags = ko.observableArray();
|
||||||
|
|
||||||
addComputablesTo(this, {
|
addComputablesTo(this, {
|
||||||
attachmentIconClass: () =>
|
attachmentIconClass: () =>
|
||||||
this.encrypted() ? 'icon-lock' : FileInfo.getAttachmentsIconClass(this.attachments()),
|
this.encrypted() ? 'icon-lock' : FileInfo.getAttachmentsIconClass(this.attachments()),
|
||||||
threadsLen: () => rl.app.messageList.threadUid() ? 0 : this.threads().length,
|
threadsLen: () => this.threads().length,
|
||||||
threadUnseenLen: () => rl.app.messageList.threadUid() ? 0 : this.threadUnseen().length,
|
|
||||||
|
|
||||||
threadsLenText: () => {
|
|
||||||
const unseenLen = this.threadUnseenLen();
|
|
||||||
return this.threadsLen() + (unseenLen > 0 ? '/' + unseenLen : '');
|
|
||||||
},
|
|
||||||
|
|
||||||
isUnseen: () => !this.flags().includes('\\seen'),
|
isUnseen: () => !this.flags().includes('\\seen'),
|
||||||
isFlagged: () => this.flags().includes('\\flagged'),
|
isFlagged: () => this.flags().includes('\\flagged'),
|
||||||
isDeleted: () => this.flags().includes('\\deleted'),
|
|
||||||
// isJunk: () => this.flags().includes('$junk') && !this.flags().includes('$nonjunk'),
|
// isJunk: () => this.flags().includes('$junk') && !this.flags().includes('$nonjunk'),
|
||||||
// isPhishing: () => this.flags().includes('$phishing'),
|
// isPhishing: () => this.flags().includes('$phishing'),
|
||||||
|
|
||||||
tagOptions: () => {
|
tagOptions: () => {
|
||||||
const tagOptions = [];
|
const tagOptions = [];
|
||||||
FolderUserStore.currentFolder().optionalTags().forEach(value => {
|
FolderUserStore.currentFolder().permanentFlags.forEach(value => {
|
||||||
let lower = value.toLowerCase();
|
if (isAllowedKeyword(value)) {
|
||||||
tagOptions.push({
|
let lower = value.toLowerCase();
|
||||||
css: 'msgflag-' + lower,
|
tagOptions.push({
|
||||||
value: value,
|
css: 'msgflag-' + lower,
|
||||||
checked: this.flags().includes(lower),
|
value: value,
|
||||||
label: i18n('MESSAGE_TAGS/'+lower, 0, value),
|
checked: this.flags().includes(lower),
|
||||||
toggle: (/*obj*/) => toggleTag(this, value)
|
label: i18n('MESSAGE_TAGS/'+lower, 0, value),
|
||||||
});
|
toggle: (/*obj*/) => toggleTag(this, value)
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return tagOptions
|
return tagOptions
|
||||||
},
|
|
||||||
|
|
||||||
whitelistOptions: () => {
|
|
||||||
let options = [];
|
|
||||||
if ('match' === SettingsUserStore.viewImages()) {
|
|
||||||
let from = this.from[0],
|
|
||||||
list = SettingsUserStore.viewImagesWhitelist(),
|
|
||||||
counts = {};
|
|
||||||
this.html().match(/src=["'][^"']+/g)?.forEach(m => {
|
|
||||||
m = m.replace(/^.+(:\/\/[^/]+).+$/, '$1');
|
|
||||||
if (counts[m]) {
|
|
||||||
++counts[m];
|
|
||||||
} else {
|
|
||||||
counts[m] = 1;
|
|
||||||
options.push(m);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
options = options.filter(txt => !list.includes(txt)).sort((a,b) => (counts[a] < counts[b])
|
|
||||||
? 1
|
|
||||||
: (counts[a] > counts[b] ? -1 : a.localeCompare(b))
|
|
||||||
);
|
|
||||||
from && options.unshift(from.email);
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
this.smimeSigned.subscribe(value =>
|
|
||||||
value?.body && MimeToMessage(value.body, this)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
get requestHash() {
|
|
||||||
return b64EncodeJSONSafe({
|
|
||||||
folder: this.folder,
|
|
||||||
uid: this.uid,
|
|
||||||
mimeType: RFC822,
|
|
||||||
fileName: (this.subject() || 'message') + '-' + this.hash + '.eml',
|
|
||||||
accountHash: SettingsGet('accountHash')
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,23 +164,23 @@ export class MessageModel extends AbstractModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
spamStatus() {
|
spamStatus() {
|
||||||
let spam = this.spamResult;
|
let spam = this.spamResult();
|
||||||
return spam ? i18n(this.isSpam ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
|
return spam ? i18n(this.isSpam() ? 'GLOBAL/SPAM' : 'GLOBAL/NOT_SPAM') + ': ' + spam : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
friendlySize() {
|
friendlySize() {
|
||||||
return FileInfo.friendlySize(this.size);
|
return FileInfo.friendlySize(this.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
computeSenderEmail() {
|
computeSenderEmail() {
|
||||||
const list = this[
|
const list = this[
|
||||||
[FolderUserStore.sentFolder(), FolderUserStore.draftsFolder()].includes(this.folder) ? 'to' : 'from'
|
[FolderUserStore.sentFolder(), FolderUserStore.draftsFolder()].includes(this.folder) ? 'to' : 'from'
|
||||||
];
|
];
|
||||||
this.senderEmailsString = list.toString(true);
|
this.senderEmailsString(list.toString(true));
|
||||||
this.senderClearEmailsString = list.map(email => email?.email).filter(email => email).join(', ');
|
this.senderClearEmailsString(list.map(email => email?.email).filter(email => email).join(', '));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -296,67 +188,14 @@ export class MessageModel extends AbstractModel {
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
revivePropertiesFromJson(json) {
|
revivePropertiesFromJson(json) {
|
||||||
|
if ('Priority' in json && ![MessagePriority.High, MessagePriority.Low].includes(json.Priority)) {
|
||||||
|
json.Priority = MessagePriority.Normal;
|
||||||
|
}
|
||||||
if (super.revivePropertiesFromJson(json)) {
|
if (super.revivePropertiesFromJson(json)) {
|
||||||
// this.foundCIDs = isArray(json.FoundCIDs) ? json.FoundCIDs : [];
|
// this.foundCIDs = isArray(json.FoundCIDs) ? json.FoundCIDs : [];
|
||||||
// this.attachments(AttachmentCollectionModel.reviveFromJson(json.attachments, this.foundCIDs));
|
// this.attachments(AttachmentCollectionModel.reviveFromJson(json.Attachments, this.foundCIDs));
|
||||||
// this.headers(MimeHeaderCollectionModel.reviveFromJson(json.headers));
|
|
||||||
|
|
||||||
this.computeSenderEmail();
|
this.computeSenderEmail();
|
||||||
|
|
||||||
let value, headers = this.headers();
|
|
||||||
/* // These could be by Envelope or MIME
|
|
||||||
this.messageId = headers.valueByName('Message-Id');
|
|
||||||
this.subject(headers.valueByName('Subject'));
|
|
||||||
this.sender = EmailCollectionModel.fromString(headers.valueByName('Sender'));
|
|
||||||
this.from = EmailCollectionModel.fromArray(headers.valueByName('From'));
|
|
||||||
this.replyTo = EmailCollectionModel.fromArray(headers.valueByName('Reply-To'));
|
|
||||||
this.to = EmailCollectionModel.fromArray(headers.valueByName('To'));
|
|
||||||
this.cc = EmailCollectionModel.fromArray(headers.valueByName('Cc'));
|
|
||||||
this.bcc = EmailCollectionModel.fromArray(headers.valueByName('Bcc'));
|
|
||||||
this.inReplyTo = headers.valueByName('In-Reply-To');
|
|
||||||
|
|
||||||
this.deliveredTo = EmailCollectionModel.fromString(headers.valueByName('Delivered-To'));
|
|
||||||
*/
|
|
||||||
// Priority
|
|
||||||
value = headers.valueByName('X-MSMail-Priority')
|
|
||||||
|| headers.valueByName('Importance')
|
|
||||||
|| headers.valueByName('X-Priority');
|
|
||||||
if (value) {
|
|
||||||
if (/[h12]/.test(value[0])) {
|
|
||||||
this.priority = 1;
|
|
||||||
} else if (/[l45]/.test(value[0])) {
|
|
||||||
this.priority = 5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unsubscribe links
|
|
||||||
if (value = headers.valueByName('List-Unsubscribe')) {
|
|
||||||
this.unsubsribeLinks(value.split(',').map(
|
|
||||||
link => link.replace(/^[ <>]+|[ <>]+$/g, '')
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (headers.valueByName('X-Virus')) {
|
|
||||||
this.hasVirus = true;
|
|
||||||
}
|
|
||||||
if (value = headers.valueByName('X-Virus-Status')) {
|
|
||||||
if (value.includes('infected')) {
|
|
||||||
this.hasVirus = true;
|
|
||||||
} else if (value.includes('clean')) {
|
|
||||||
this.hasVirus = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
if (value = headers.valueByName('X-Virus-Scanned')) {
|
|
||||||
this.virusScanned(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://autocrypt.org/level1.html#the-autocrypt-header
|
|
||||||
headers.valuesByName('Autocrypt').forEach(value => {
|
|
||||||
this.autocrypt.push(new MimeHeaderAutocryptModel(value));
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -366,13 +205,16 @@ export class MessageModel extends AbstractModel {
|
||||||
lineAsCss(flags=1) {
|
lineAsCss(flags=1) {
|
||||||
let classes = [];
|
let classes = [];
|
||||||
forEachObjectEntry({
|
forEachObjectEntry({
|
||||||
|
deleted: this.deleted(),
|
||||||
selected: this.selected(),
|
selected: this.selected(),
|
||||||
checked: this.checked(),
|
checked: this.checked(),
|
||||||
unseen: this.isUnseen(),
|
unseen: this.isUnseen(),
|
||||||
focused: this.focused(),
|
focused: this.focused(),
|
||||||
priorityHigh: this.priority === 1,
|
priorityHigh: this.priority() === MessagePriority.High,
|
||||||
withAttachments: !!this.attachments().length,
|
withAttachments: !!this.attachments().length,
|
||||||
// hasChildrenMessage: 1 < this.threadsLen()
|
// hasChildrenMessage: 1 < this.threadsLen(),
|
||||||
|
hasUnseenSubMessage: this.hasUnseenSubMessage(),
|
||||||
|
hasFlaggedSubMessage: this.hasFlaggedSubMessage()
|
||||||
}, (key, value) => value && classes.push(key));
|
}, (key, value) => value && classes.push(key));
|
||||||
flags && this.flags().forEach(value => classes.push('msgflag-'+value));
|
flags && this.flags().forEach(value => classes.push('msgflag-'+value));
|
||||||
return classes.join(' ');
|
return classes.join(' ');
|
||||||
|
|
@ -429,161 +271,141 @@ export class MessageModel extends AbstractModel {
|
||||||
return [[...toResult.values()], [...ccResult.values()]];
|
return [[...toResult.values()], [...ccResult.values()]];
|
||||||
}
|
}
|
||||||
|
|
||||||
viewBody(html) {
|
viewHtml() {
|
||||||
const body = this.body;
|
const body = this.body;
|
||||||
if (body) {
|
if (body && this.html()) {
|
||||||
if (html) {
|
let result = cleanHtml(this.html(), this.attachments());
|
||||||
let result = msgHtml(this);
|
this.hasExternals(result.hasExternals);
|
||||||
this.hasExternals(result.hasExternals);
|
this.hasImages(body.rlHasImages = !!result.hasExternals);
|
||||||
this.hasImages(!!result.hasExternals);
|
|
||||||
this.hasTracking(!!result.tracking);
|
body.innerHTML = result.html;
|
||||||
this.linkedData(result.linkedData);
|
|
||||||
body.innerHTML = result.html;
|
body.classList.toggle('html', 1);
|
||||||
if (!this.isSpam && FolderUserStore.spamFolder() != this.folder) {
|
body.classList.toggle('plain', 0);
|
||||||
if ('always' === SettingsUserStore.viewImages()) {
|
|
||||||
this.showExternalImages();
|
if (SettingsUserStore.showImages()) {
|
||||||
}
|
this.showExternalImages();
|
||||||
if ('match' === SettingsUserStore.viewImages()) {
|
|
||||||
this.showExternalImages(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
body.innerHTML = plainToHtml(
|
|
||||||
(this.plain()
|
|
||||||
? this.plain()
|
|
||||||
.replace(/-----BEGIN PGP (SIGNED MESSAGE-----(\r?\n[^\r\n]+)+|SIGNATURE-----[\s\S]*)/sg, '')
|
|
||||||
.trim()
|
|
||||||
: htmlToPlain(body.innerHTML || msgHtml(this).html)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
this.hasImages(false);
|
|
||||||
}
|
}
|
||||||
body.classList.toggle('html', html);
|
|
||||||
body.classList.toggle('plain', !html);
|
this.isHtml(true);
|
||||||
this.isHtml(html);
|
this.initView();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
viewHtml() {
|
|
||||||
return this.html() && this.viewBody(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
viewPlain() {
|
viewPlain() {
|
||||||
return this.viewBody(false);
|
const body = this.body;
|
||||||
|
if (body) {
|
||||||
|
body.classList.toggle('html', 0);
|
||||||
|
body.classList.toggle('plain', 1);
|
||||||
|
body.innerHTML = plainToHtml(
|
||||||
|
(this.plain()
|
||||||
|
? this.plain()
|
||||||
|
.replace(/-----BEGIN PGP (SIGNED MESSAGE-----(\r?\n[a-z][^\r\n]+)+|SIGNATURE-----[\s\S]*)/, '')
|
||||||
|
.trim()
|
||||||
|
: htmlToPlain(body.innerHTML)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.isHtml(false);
|
||||||
|
this.hasImages(false);
|
||||||
|
this.initView();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
swapColors() {
|
initView() {
|
||||||
const cl = this.body?.classList;
|
// init BlockquoteSwitcher
|
||||||
cl && cl.toggle('swapColors');
|
this.body.querySelectorAll('blockquote').forEach(node => {
|
||||||
|
if (node.textContent.trim()) {
|
||||||
|
let h = node.clientHeight || getRealHeight(node);
|
||||||
|
if (0 === h || 100 < h) {
|
||||||
|
const el = Element.fromHTML('<details class="sm-bq-switcher"><summary>•••</summary></details>');
|
||||||
|
node.replaceWith(el);
|
||||||
|
el.append(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
viewPopupMessage(print) {
|
||||||
|
const timeStampInUTC = this.dateTimeStampInUTC() || 0,
|
||||||
|
ccLine = this.cc.toString(),
|
||||||
|
m = 0 < timeStampInUTC ? new Date(timeStampInUTC * 1000) : null,
|
||||||
|
win = open(''),
|
||||||
|
sdoc = win.document;
|
||||||
|
let subject = encodeHtml(this.subject()),
|
||||||
|
mode = this.isHtml() ? 'div' : 'pre',
|
||||||
|
cc = ccLine ? `<div>${encodeHtml(i18n('GLOBAL/CC'))}: ${encodeHtml(ccLine)}</div>` : '',
|
||||||
|
style = getComputedStyle(doc.querySelector('.messageView')),
|
||||||
|
prop = property => style.getPropertyValue(property);
|
||||||
|
sdoc.write(PreviewHTML
|
||||||
|
.replace('<title>', '<title>'+subject)
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
.replace('<body>', `<body style="background-color:${prop('background-color')};color:${prop('color')}"><header><h1>${subject}</h1><time>${encodeHtml(m ? m.format('LLL',0,LanguageStore.hourCycle()) : '')}</time><div>${encodeHtml(this.from)}</div><div>${encodeHtml(i18n('GLOBAL/TO'))}: ${encodeHtml(this.to)}</div>${cc}</header><${mode}>${this.bodyAsHTML()}</${mode}>`)
|
||||||
|
);
|
||||||
|
sdoc.close();
|
||||||
|
|
||||||
|
if (print) {
|
||||||
|
setTimeout(() => win.print(), 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {boolean=} print = false
|
* @param {boolean=} print = false
|
||||||
*/
|
*/
|
||||||
popupMessage(print) {
|
popupMessage() {
|
||||||
const
|
this.viewPopupMessage();
|
||||||
timeStampInUTC = this.dateTimestamp() || 0,
|
|
||||||
ccLine = this.cc.toString(),
|
|
||||||
bccLine = this.bcc.toString(),
|
|
||||||
m = 0 < timeStampInUTC ? new Date(timeStampInUTC * 1000) : null,
|
|
||||||
win = open('', 'sm-msg-'+this.requestHash
|
|
||||||
,SettingsUserStore.messageNewWindow() ? 'innerWidth=' + elementById('V-MailMessageView').clientWidth : ''
|
|
||||||
),
|
|
||||||
sdoc = win.document,
|
|
||||||
subject = encodeHtml(this.subject()),
|
|
||||||
mode = this.isHtml() ? 'div' : 'pre',
|
|
||||||
to = `<div>${encodeHtml(i18n('GLOBAL/TO'))}: ${encodeHtml(this.to)}</div>`
|
|
||||||
+ (ccLine ? `<div>${encodeHtml(i18n('GLOBAL/CC'))}: ${encodeHtml(ccLine)}</div>` : '')
|
|
||||||
+ (bccLine ? `<div>${encodeHtml(i18n('GLOBAL/BCC'))}: ${encodeHtml(bccLine)}</div>` : ''),
|
|
||||||
style = getComputedStyle(doc.querySelector('.messageView')),
|
|
||||||
prop = property => style.getPropertyValue(property);
|
|
||||||
let attachments = '';
|
|
||||||
this.attachments.forEach(attachment => {
|
|
||||||
attachments += `<a href="${attachment.linkDownload()}">${attachment.fileName}</a>`;
|
|
||||||
});
|
|
||||||
sdoc.write(PreviewHTML
|
|
||||||
.replace('<title>', '<title>'+subject)
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
.replace('<body>', `<body style="background-color:${prop('background-color')};color:${prop('color')}"><header><h1>${subject}</h1><time>${encodeHtml(m ? m.format('LLL',0,LanguageStore.hourCycle()) : '')}</time><div>${encodeHtml(this.from)}</div>${to}</header><${mode}>${this.bodyAsHTML()}</${mode}>`)
|
|
||||||
.replace('</body>', `<div id="attachments">${attachments}</div></body>`)
|
|
||||||
);
|
|
||||||
sdoc.close();
|
|
||||||
(true === print) && setTimeout(() => win.print(), 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
printMessage() {
|
printMessage() {
|
||||||
this.popupMessage(true);
|
this.viewPopupMessage(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {MessageModel}
|
* @returns {string}
|
||||||
*//*
|
*/
|
||||||
clone() {
|
generateUid() {
|
||||||
let self = new MessageModel();
|
return this.folder + '/' + this.uid;
|
||||||
// Clone message values
|
}
|
||||||
forEachObjectEntry(this, (key, value) => {
|
|
||||||
if (ko.isObservable(value)) {
|
|
||||||
ko.isComputed(value) || self[key](value());
|
|
||||||
} else if (!isFunction(value)) {
|
|
||||||
self[key] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
self.computeSenderEmail();
|
|
||||||
return self;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
showExternalImages(regex) {
|
/**
|
||||||
|
* @param {MessageModel} message
|
||||||
|
* @returns {MessageModel}
|
||||||
|
*/
|
||||||
|
static fromMessageListItem(message) {
|
||||||
|
let self = new MessageModel();
|
||||||
|
if (message) {
|
||||||
|
// Clone message values
|
||||||
|
forEachObjectEntry(message, (key, value) => {
|
||||||
|
if (ko.isObservable(value)) {
|
||||||
|
ko.isComputed(value) || self[key](value());
|
||||||
|
} else if (!isFunction(value)) {
|
||||||
|
self[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.computeSenderEmail();
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
showExternalImages() {
|
||||||
const body = this.body;
|
const body = this.body;
|
||||||
if (body && this.hasImages()) {
|
if (body && this.hasImages()) {
|
||||||
if (regex) {
|
this.hasImages(false);
|
||||||
regex = [];
|
body.rlHasImages = false;
|
||||||
SettingsUserStore.viewImagesWhitelist().trim().split(/[\s\r\n,;]+/g).forEach(rule => {
|
|
||||||
rule = rule.split('+');
|
let attr = 'data-x-src',
|
||||||
rule[0] = rule[0].trim();
|
src, useProxy = !!SettingsGet('UseLocalProxyForExternalImages');
|
||||||
if (rule[0]
|
|
||||||
&& (!rule.includes('spf') || 'pass' === this.spf[0]?.[0])
|
|
||||||
&& (!rule.includes('dkim') || 'pass' === this.dkim[0]?.[0])
|
|
||||||
&& (!rule.includes('dmarc') || 'pass' === this.dmarc[0]?.[0])
|
|
||||||
) {
|
|
||||||
regex.push(rule[0].replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
regex = regex.join('|').replace(/\|+/g, '|');
|
|
||||||
if (regex) {
|
|
||||||
console.log('whitelist images = '+regex);
|
|
||||||
regex = new RegExp(regex);
|
|
||||||
if (this.from[0]?.email.match(regex)) {
|
|
||||||
regex = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let hasImages = false,
|
|
||||||
isValid = src => {
|
|
||||||
if (null == regex || (regex && src.match(regex))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
hasImages = true;
|
|
||||||
},
|
|
||||||
attr = 'data-x-src',
|
|
||||||
src, useProxy = !!SettingsGet('proxyExternalImages');
|
|
||||||
body.querySelectorAll('img[' + attr + ']').forEach(node => {
|
body.querySelectorAll('img[' + attr + ']').forEach(node => {
|
||||||
src = node.getAttribute(attr);
|
src = node.getAttribute(attr);
|
||||||
if (isValid(src)) {
|
node.src = useProxy ? proxy(src) : src;
|
||||||
node.src = useProxy ? proxy(src) : src;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
body.querySelectorAll('[data-x-style-url]').forEach(node => {
|
body.querySelectorAll('[data-x-style-url]').forEach(node => {
|
||||||
JSON.parse(node.dataset.xStyleUrl).forEach(data => {
|
JSON.parse(node.dataset.xStyleUrl).forEach(data =>
|
||||||
if (isValid(data[1])) {
|
node.style[data[0]] = "url('" + (useProxy ? proxy(data[1]) : data[1]) + "')"
|
||||||
node.style[data[0]] = "url('" + (useProxy ? proxy(data[1]) : data[1]) + "')"
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.hasImages(hasImages);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -596,146 +418,10 @@ export class MessageModel extends AbstractModel {
|
||||||
clone.querySelectorAll('.sm-bq-switcher').forEach(
|
clone.querySelectorAll('.sm-bq-switcher').forEach(
|
||||||
node => node.replaceWith(node.lastElementChild)
|
node => node.replaceWith(node.lastElementChild)
|
||||||
);
|
);
|
||||||
return (clone.querySelector('.mail-body') || clone).innerHTML;
|
return clone.innerHTML;
|
||||||
}
|
}
|
||||||
let result = msgHtml(this);
|
let result = cleanHtml(this.html(), this.attachments())
|
||||||
return result.html || plainToHtml(this.plain());
|
return result.html || plainToHtml(this.plain());
|
||||||
}
|
}
|
||||||
|
|
||||||
async decrypt() {
|
|
||||||
const msg = this;
|
|
||||||
if (msg.pgpEncrypted() && !msg.pgpDecrypted()) {
|
|
||||||
return await msg.pgpDecrypt().then(()=>msg.pgpDecrypted());
|
|
||||||
}
|
|
||||||
if (msg.smimeEncrypted() && !msg.smimeDecrypted()) {
|
|
||||||
return await msg.smimeDecrypt().then(()=>msg.smimeDecrypted());
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
async decrypted() {
|
|
||||||
return msg.pgpDecrypted() || msg.smimeDecrypted());
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
async pgpDecrypt() {
|
|
||||||
const oMessage = this,
|
|
||||||
data = oMessage.pgpEncrypted();
|
|
||||||
delete data.error;
|
|
||||||
await PgpUserStore.decrypt(oMessage).then(result => {
|
|
||||||
if (!result) {
|
|
||||||
// TODO: translate
|
|
||||||
throw Error('Decryption failed, canceled or not possible');
|
|
||||||
}
|
|
||||||
oMessage.pgpDecrypted(true);
|
|
||||||
if (result.data) {
|
|
||||||
MimeToMessage(result.data, oMessage);
|
|
||||||
oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain();
|
|
||||||
if (result.signatures?.length) {
|
|
||||||
oMessage.pgpSigned({
|
|
||||||
signatures: result.signatures,
|
|
||||||
success: !!result.signatures.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
data.error = e.message;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
oMessage.pgpEncrypted(data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pgpVerify(/*self, event*/) {
|
|
||||||
const oMessage = this;
|
|
||||||
PgpUserStore.verify(oMessage).then(result => {
|
|
||||||
if (result) {
|
|
||||||
oMessage.pgpSigned(result);
|
|
||||||
} else {
|
|
||||||
alert('Verification failed or no valid public key found');
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
if (result?.success) {
|
|
||||||
i18n('CRYPTO/GOOD_SIGNATURE', {
|
|
||||||
USER: validKey.user + ' (' + validKey.id + ')'
|
|
||||||
});
|
|
||||||
message.getText()
|
|
||||||
} else {
|
|
||||||
const keyIds = arrayLength(signingKeyIds) ? signingKeyIds : null,
|
|
||||||
additional = keyIds
|
|
||||||
? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
i18n('CRYPTO/ERROR', {
|
|
||||||
TYPE: 'OpenPGP',
|
|
||||||
ERROR: 'message'
|
|
||||||
}) + (additional ? ' (' + additional + ')' : '');
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async smimeDecrypt() {
|
|
||||||
const message = this;
|
|
||||||
const addresses = message.from.concat(message.to, message.cc, message.bcc).map(item => item.email),
|
|
||||||
identity = IdentityUserStore.find(item => addresses.includes(item.email)),
|
|
||||||
data = message.smimeEncrypted(); // { partId: "1" }
|
|
||||||
if (data && identity) {
|
|
||||||
delete data.error;
|
|
||||||
let pass, params = { ...data }; // clone
|
|
||||||
params.folder = message.folder;
|
|
||||||
params.uid = message.uid;
|
|
||||||
// params.bodyPart = params.bodyPart?.raw;
|
|
||||||
params.certificate = identity.smimeCertificate();
|
|
||||||
params.privateKey = identity.smimeKey();
|
|
||||||
if (identity.smimeKeyEncrypted()) {
|
|
||||||
pass = await Passphrases.ask(identity,
|
|
||||||
i18n('SMIME/PRIVATE_KEY_OF', {EMAIL: identity.email}),
|
|
||||||
'CRYPTO/DECRYPT'
|
|
||||||
);
|
|
||||||
if (!pass) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
params.passphrase = pass?.password;
|
|
||||||
}
|
|
||||||
await Remote.post('SMimeDecryptMessage', null, params).then(response => {
|
|
||||||
if (response?.Result?.data) {
|
|
||||||
message.smimeDecrypted(true);
|
|
||||||
MimeToMessage(response.Result.data, message);
|
|
||||||
message.html() ? message.viewHtml() : message.viewPlain();
|
|
||||||
pass && pass.remember && Passphrases.handle(identity, pass.password);
|
|
||||||
if ('signed' in response.Result) {
|
|
||||||
message.smimeSigned(response.Result.signed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).catch(e => {
|
|
||||||
data.error = e.message
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
message.smimeEncrypted(data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
smimeVerify(/*self, event*/) {
|
|
||||||
const message = this,
|
|
||||||
data = message.smimeSigned(); // { partId: "1", micAlg: "pgp-sha256" }
|
|
||||||
if (data) {
|
|
||||||
const params = { ...data }; // clone
|
|
||||||
params.folder = message.folder;
|
|
||||||
params.uid = message.uid;
|
|
||||||
params.bodyPart = data.bodyPart?.raw;
|
|
||||||
params.sigPart = data.sigPart?.bodyRaw;
|
|
||||||
Remote.post('SMimeVerifyMessage', null, params).then(response => {
|
|
||||||
if (response?.Result) {
|
|
||||||
if (response.Result.body) {
|
|
||||||
MimeToMessage(response.Result.body, message);
|
|
||||||
message.html() ? message.viewHtml() : message.viewPlain();
|
|
||||||
}
|
|
||||||
data.success = response.Result.success;
|
|
||||||
message.smimeSigned(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
import { AbstractCollectionModel } from 'Model/AbstractCollection';
|
||||||
import { MessageModel } from 'Model/Message';
|
import { MessageModel } from 'Model/Message';
|
||||||
import { MessageUserStore } from 'Stores/User/Message';
|
|
||||||
|
import {
|
||||||
|
MessageFlagsCache
|
||||||
|
} from 'Common/Cache';
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
@ -9,16 +12,17 @@ export class MessageCollectionModel extends AbstractCollectionModel
|
||||||
/*
|
/*
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.filtered
|
this.Filtered
|
||||||
this.folder
|
this.Folder
|
||||||
|
this.folderHash
|
||||||
|
this.folderInfo
|
||||||
this.totalEmails
|
this.totalEmails
|
||||||
this.totalThreads
|
this.unreadEmails
|
||||||
this.threadUid
|
this.threadUid
|
||||||
this.newMessages
|
this.newMessages
|
||||||
this.offset
|
this.offset
|
||||||
this.limit
|
this.limit
|
||||||
this.search
|
this.search
|
||||||
this.limited
|
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -26,17 +30,14 @@ export class MessageCollectionModel extends AbstractCollectionModel
|
||||||
* @param {?Object} json
|
* @param {?Object} json
|
||||||
* @returns {MessageCollectionModel}
|
* @returns {MessageCollectionModel}
|
||||||
*/
|
*/
|
||||||
static reviveFromJson(object/*, cached*/) {
|
static reviveFromJson(object, cached) {
|
||||||
let msg = MessageUserStore.message();
|
|
||||||
return super.reviveFromJson(object, message => {
|
return super.reviveFromJson(object, message => {
|
||||||
// If message is currently viewed, use that.
|
message = MessageModel.reviveFromJson(message);
|
||||||
if (msg && msg.hash === message.hash) {
|
if (message) {
|
||||||
msg.revivePropertiesFromJson(message);
|
message.deleted(false);
|
||||||
message = msg;
|
cached ? MessageFlagsCache.initMessage(message) : MessageFlagsCache.store(message);
|
||||||
} else {
|
return message;
|
||||||
message = MessageModel.reviveFromJson(message);
|
|
||||||
}
|
}
|
||||||
return message;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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-----';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Notifications } from 'Common/Enums';
|
import { Notification } from 'Common/Enums';
|
||||||
import { isArray, pInt, pString } from 'Common/Utils';
|
import { isArray, pInt, pString } from 'Common/Utils';
|
||||||
import { serverRequest } from 'Common/Links';
|
import { serverRequest } from 'Common/Links';
|
||||||
import { getNotification } from 'Common/Translator';
|
import { getNotification } from 'Common/Translator';
|
||||||
|
|
@ -8,18 +8,19 @@ let iJsonErrorCount = 0;
|
||||||
const getURL = (add = '') => serverRequest('Json') + pString(add),
|
const getURL = (add = '') => serverRequest('Json') + pString(add),
|
||||||
|
|
||||||
checkResponseError = data => {
|
checkResponseError = data => {
|
||||||
const err = data ? data.code : null;
|
const err = data ? data.ErrorCode : null;
|
||||||
if (Notifications.InvalidToken === err) {
|
if (Notification.InvalidToken === err) {
|
||||||
console.error(getNotification(err) + ` (${data.messageAdditional})`);
|
console.error(getNotification(err));
|
||||||
// alert(getNotification(err));
|
// alert(getNotification(err));
|
||||||
setTimeout(rl.logoutReload, 5000);
|
rl.logoutReload();
|
||||||
} else if ([
|
} else if ([
|
||||||
Notifications.AuthError,
|
Notification.AuthError,
|
||||||
Notifications.ConnectionError,
|
Notification.ConnectionError,
|
||||||
Notifications.DomainNotAllowed,
|
Notification.DomainNotAllowed,
|
||||||
Notifications.AccountNotAllowed,
|
Notification.AccountNotAllowed,
|
||||||
Notifications.MailServerError,
|
Notification.MailServerError,
|
||||||
Notifications.UnknownError
|
Notification.UnknownNotification,
|
||||||
|
Notification.UnknownError
|
||||||
].includes(err)
|
].includes(err)
|
||||||
) {
|
) {
|
||||||
if (7 < ++iJsonErrorCount) {
|
if (7 < ++iJsonErrorCount) {
|
||||||
|
|
@ -31,11 +32,10 @@ checkResponseError = data => {
|
||||||
oRequests = {},
|
oRequests = {},
|
||||||
|
|
||||||
abort = (sAction, sReason, bClearOnly) => {
|
abort = (sAction, sReason, bClearOnly) => {
|
||||||
let controller = oRequests[sAction];
|
if (oRequests[sAction]) {
|
||||||
oRequests[sAction] = null;
|
bClearOnly || oRequests[sAction].abort(sReason || 'AbortError');
|
||||||
if (controller) {
|
oRequests[sAction] = null;
|
||||||
clearTimeout(controller.timeoutId);
|
delete oRequests[sAction];
|
||||||
bClearOnly || controller.abort(new DOMException(sAction, sReason || 'AbortError'));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -48,18 +48,15 @@ fetchJSON = (action, sUrl, params, timeout, jsonCallback) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Don't abort, read https://github.com/the-djmaze/snappymail/issues/487
|
// Don't abort, read https://github.com/the-djmaze/snappymail/issues/487
|
||||||
// abort(action, 0, 1);
|
// abort(action);
|
||||||
const controller = new AbortController(),
|
const controller = new AbortController(),
|
||||||
signal = controller.signal;
|
signal = controller.signal;
|
||||||
oRequests[action] = controller;
|
oRequests[action] = controller;
|
||||||
// Currently there is no way to combine multiple signals, so AbortSignal.timeout() not possible
|
// Currently there is no way to combine multiple signals, so AbortSignal.timeout() not possible
|
||||||
controller.timeoutId = timeout && setTimeout(() => abort(action, 'TimeoutError'), timeout);
|
timeout && setTimeout(() => abort(action, 'TimeoutError'), timeout);
|
||||||
return rl.fetchJSON(sUrl, {signal: signal}, params).then(data => {
|
return rl.fetchJSON(sUrl, {signal: signal}, params).then(jsonCallback).catch(err => {
|
||||||
abort(action, 0, 1);
|
|
||||||
return jsonCallback ? jsonCallback(data) : Promise.resolve(data);
|
|
||||||
}).catch(err => {
|
|
||||||
clearTimeout(controller.timeoutId);
|
|
||||||
err.aborted = signal.aborted;
|
err.aborted = signal.aborted;
|
||||||
|
err.reason = signal.reason;
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -68,14 +65,14 @@ class FetchError extends Error
|
||||||
{
|
{
|
||||||
constructor(code, message) {
|
constructor(code, message) {
|
||||||
super(message);
|
super(message);
|
||||||
this.code = code || Notifications.JsonFalse;
|
this.code = code || Notification.JsonFalse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AbstractFetchRemote
|
export class AbstractFetchRemote
|
||||||
{
|
{
|
||||||
abort(sAction, sReason) {
|
abort(sAction) {
|
||||||
abort(sAction, sReason);
|
abort(sAction);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,10 +128,19 @@ export class AbstractFetchRemote
|
||||||
|
|
||||||
fetchJSON(sAction, getURL(sGetAdd),
|
fetchJSON(sAction, getURL(sGetAdd),
|
||||||
sGetAdd ? null : (params || {}),
|
sGetAdd ? null : (params || {}),
|
||||||
pInt(iTimeout ?? 30000),
|
undefined === iTimeout ? 30000 : pInt(iTimeout),
|
||||||
async data => {
|
data => {
|
||||||
|
let cached = false;
|
||||||
|
if (data?.Time) {
|
||||||
|
cached = pInt(data.Time) > Date.now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
let iError = 0;
|
let iError = 0;
|
||||||
if (data) {
|
if (sAction && oRequests[sAction]) {
|
||||||
|
abort(sAction, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iError && data) {
|
||||||
/*
|
/*
|
||||||
if (sAction !== data.Action) {
|
if (sAction !== data.Action) {
|
||||||
console.log(sAction + ' !== ' + data.Action);
|
console.log(sAction + ' !== ' + data.Action);
|
||||||
|
|
@ -144,35 +150,35 @@ export class AbstractFetchRemote
|
||||||
iJsonErrorCount = 0;
|
iJsonErrorCount = 0;
|
||||||
} else {
|
} else {
|
||||||
checkResponseError(data);
|
checkResponseError(data);
|
||||||
iError = data.code || Notifications.UnknownError
|
iError = data.ErrorCode || Notification.UnknownError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (111 === iError && rl.app.ask && await rl.app.ask.cryptkey()) {
|
|
||||||
return this.request(sAction, fCallback, params, iTimeout, sGetAdd);
|
|
||||||
}
|
|
||||||
|
|
||||||
fCallback && fCallback(
|
fCallback && fCallback(
|
||||||
iError,
|
iError,
|
||||||
data,
|
data,
|
||||||
/**
|
cached,
|
||||||
* Responses like "304 Not Modified" are returned as "200 OK"
|
sAction,
|
||||||
* This is an attempt to detect if the request comes from cache.
|
params
|
||||||
* But when client has wrong date/time, it will fail.
|
|
||||||
*/
|
|
||||||
data?.epoch && data.epoch < Math.floor(start / 1000) - 60
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error({fetchError:err});
|
console.error({fetchError:err});
|
||||||
fCallback && fCallback(
|
fCallback && fCallback(
|
||||||
'TimeoutError' == err.name ? 3 : (err.name == 'AbortError' ? 2 : 1),
|
'TimeoutError' == err.reason ? 3 : (err.name == 'AbortError' ? 2 : 1),
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {?Function} fCallback
|
||||||
|
*/
|
||||||
|
getPublicKey(fCallback) {
|
||||||
|
this.request('GetPublicKey', fCallback);
|
||||||
|
}
|
||||||
|
|
||||||
setTrigger(trigger, value) {
|
setTrigger(trigger, value) {
|
||||||
if (trigger) {
|
if (trigger) {
|
||||||
value = !!value;
|
value = !!value;
|
||||||
|
|
@ -189,20 +195,16 @@ export class AbstractFetchRemote
|
||||||
post(action, fTrigger, params, timeOut) {
|
post(action, fTrigger, params, timeOut) {
|
||||||
this.setTrigger(fTrigger, true);
|
this.setTrigger(fTrigger, true);
|
||||||
return fetchJSON(action, getURL(), params || {}, pInt(timeOut, 30000),
|
return fetchJSON(action, getURL(), params || {}, pInt(timeOut, 30000),
|
||||||
async data => {
|
data => {
|
||||||
abort(action, 0, 1);
|
abort(action, 0, 1);
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return Promise.reject(new FetchError(Notifications.JsonParse));
|
return Promise.reject(new FetchError(Notification.JsonParse));
|
||||||
}
|
|
||||||
|
|
||||||
if (111 === data?.code && rl.app.ask && await rl.app.ask.cryptkey()) {
|
|
||||||
return this.post(action, fTrigger, params, timeOut);
|
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
let isCached = false, type = '';
|
let isCached = false, type = '';
|
||||||
if (data?.epoch) {
|
if (data?.Time) {
|
||||||
isCached = data.epoch > microtime() - start;
|
isCached = pInt(data.Time) > microtime() - start;
|
||||||
}
|
}
|
||||||
// backward capability
|
// backward capability
|
||||||
switch (true) {
|
switch (true) {
|
||||||
|
|
@ -222,8 +224,8 @@ export class AbstractFetchRemote
|
||||||
if (!data.Result || action !== data.Action) {
|
if (!data.Result || action !== data.Action) {
|
||||||
checkResponseError(data);
|
checkResponseError(data);
|
||||||
return Promise.reject(new FetchError(
|
return Promise.reject(new FetchError(
|
||||||
data ? data.code : 0,
|
data ? data.ErrorCode : 0,
|
||||||
data ? (data.messageAdditional || data.message) : ''
|
data ? (data.ErrorMessageAdditional || data.ErrorMessage) : ''
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,57 @@ import { SUB_QUERY_PREFIX } from 'Common/Links';
|
||||||
|
|
||||||
import { AppUserStore } from 'Stores/User/App';
|
import { AppUserStore } from 'Stores/User/App';
|
||||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||||
|
import { FolderUserStore } from 'Stores/User/Folder';
|
||||||
|
|
||||||
import { AbstractFetchRemote } from 'Remote/AbstractFetch';
|
import { AbstractFetchRemote } from 'Remote/AbstractFetch';
|
||||||
|
|
||||||
class RemoteUserFetch extends AbstractFetchRemote {
|
class RemoteUserFetch extends AbstractFetchRemote {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Function} fCallback
|
||||||
|
* @param {object} params
|
||||||
|
* @param {boolean=} bSilent = false
|
||||||
|
*/
|
||||||
|
messageList(fCallback, params, bSilent = false) {
|
||||||
|
const
|
||||||
|
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 {?Function} fCallback
|
||||||
* @param {string} sFolderFullName
|
* @param {string} sFolderFullName
|
||||||
|
|
@ -36,7 +82,7 @@ class RemoteUserFetch extends AbstractFetchRemote {
|
||||||
sFolderFullName,
|
sFolderFullName,
|
||||||
iUid,
|
iUid,
|
||||||
AppUserStore.threadsAllowed() && SettingsUserStore.useThreads() ? 1 : 0,
|
AppUserStore.threadsAllowed() && SettingsUserStore.useThreads() ? 1 : 0,
|
||||||
SettingsGet('accountHash')
|
SettingsGet('AccountHash')
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -64,6 +110,16 @@ class RemoteUserFetch extends AbstractFetchRemote {
|
||||||
[key]: value
|
[key]: value
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
folderMove(sPrevFolderFullName, sNewFolderFullName, bSubscribe) {
|
||||||
|
return this.post('FolderMove', FolderUserStore.foldersRenaming, {
|
||||||
|
Folder: sPrevFolderFullName,
|
||||||
|
NewFolder: sNewFolderFullName,
|
||||||
|
Subscribe: bSubscribe ? 1 : 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new RemoteUserFetch();
|
export default new RemoteUserFetch();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import ko from 'ko';
|
||||||
|
|
||||||
import { pString } from 'Common/Utils';
|
import { pString } from 'Common/Utils';
|
||||||
import { settings } from 'Common/Links';
|
import { settings } from 'Common/Links';
|
||||||
import { createElement/*, elementById*/, fireEvent } from 'Common/Globals';
|
import { createElement, elementById } from 'Common/Globals';
|
||||||
|
|
||||||
import { AbstractScreen } from 'Knoin/AbstractScreen';
|
import { AbstractScreen } from 'Knoin/AbstractScreen';
|
||||||
import { i18nToNodes } from 'Common/Translator';
|
import { i18nToNodes } from 'Common/Translator';
|
||||||
|
|
@ -25,42 +25,35 @@ export class AbstractSettingsScreen extends AbstractScreen {
|
||||||
let settingsScreen = null,
|
let settingsScreen = null,
|
||||||
viewModelDom = null,
|
viewModelDom = null,
|
||||||
RoutedSettingsViewModel = VIEW_MODELS.find(
|
RoutedSettingsViewModel = VIEW_MODELS.find(
|
||||||
SettingsViewModel => subName === SettingsViewModel.route
|
SettingsViewModel => subName === SettingsViewModel.__rlSettingsData.route
|
||||||
);
|
);
|
||||||
|
|
||||||
if (RoutedSettingsViewModel) {
|
if (RoutedSettingsViewModel) {
|
||||||
// const vmPlace = elementById('V-SettingsPane') || elementById('V-AdminPane);
|
const vmPlace = elementById('rl-settings-subscreen');
|
||||||
const vmPlace = this.viewModels[1].__vm.viewModelDom,
|
if (RoutedSettingsViewModel.__vm) {
|
||||||
SettingsViewModelClass = RoutedSettingsViewModel.vmc;
|
settingsScreen = RoutedSettingsViewModel.__vm;
|
||||||
if (SettingsViewModelClass.__vm) {
|
|
||||||
settingsScreen = SettingsViewModelClass.__vm;
|
|
||||||
viewModelDom = settingsScreen.viewModelDom;
|
|
||||||
} else if (vmPlace) {
|
} else if (vmPlace) {
|
||||||
viewModelDom = createElement('div',{
|
viewModelDom = createElement('div',{
|
||||||
id: 'V-Settings-' + SettingsViewModelClass.name.replace(/(User|Admin)Settings/,''),
|
id: 'V-Settings-' + RoutedSettingsViewModel.name.replace(/(User|Admin)Settings/,''),
|
||||||
hidden: ''
|
hidden: ''
|
||||||
})
|
})
|
||||||
vmPlace.append(viewModelDom);
|
vmPlace.append(viewModelDom);
|
||||||
|
|
||||||
settingsScreen = new SettingsViewModelClass();
|
settingsScreen = new RoutedSettingsViewModel();
|
||||||
settingsScreen.viewModelDom = viewModelDom;
|
settingsScreen.viewModelDom = viewModelDom;
|
||||||
settingsScreen.viewModelTemplateID = RoutedSettingsViewModel.template;
|
|
||||||
|
|
||||||
SettingsViewModelClass.__vm = settingsScreen;
|
RoutedSettingsViewModel.__dom = viewModelDom;
|
||||||
|
RoutedSettingsViewModel.__vm = settingsScreen;
|
||||||
fireEvent('rl-view-model.create', settingsScreen);
|
|
||||||
|
|
||||||
ko.applyBindingAccessorsToNode(
|
ko.applyBindingAccessorsToNode(
|
||||||
viewModelDom,
|
viewModelDom,
|
||||||
{
|
{
|
||||||
template: () => ({ name: RoutedSettingsViewModel.template })
|
template: () => ({ name: RoutedSettingsViewModel.__rlSettingsData.template })
|
||||||
},
|
},
|
||||||
settingsScreen
|
settingsScreen
|
||||||
);
|
);
|
||||||
|
|
||||||
settingsScreen.onBuild?.(viewModelDom);
|
settingsScreen.onBuild?.(viewModelDom);
|
||||||
|
|
||||||
fireEvent('rl-view-model', settingsScreen);
|
|
||||||
} else {
|
} else {
|
||||||
console.log('Cannot find sub settings view model position: SettingsSubScreen');
|
console.log('Cannot find sub settings view model position: SettingsSubScreen');
|
||||||
}
|
}
|
||||||
|
|
@ -75,13 +68,13 @@ export class AbstractSettingsScreen extends AbstractScreen {
|
||||||
|
|
||||||
// show
|
// show
|
||||||
settingsScreen.beforeShow?.();
|
settingsScreen.beforeShow?.();
|
||||||
i18nToNodes(viewModelDom);
|
i18nToNodes(settingsScreen.viewModelDom);
|
||||||
viewModelDom.hidden = false;
|
settingsScreen.viewModelDom.hidden = false;
|
||||||
settingsScreen.onShow?.();
|
settingsScreen.onShow?.();
|
||||||
|
|
||||||
this.menu.forEach(item => {
|
this.menu.forEach(item => {
|
||||||
item.selected(
|
item.selected(
|
||||||
item.route === RoutedSettingsViewModel.route
|
item.route === RoutedSettingsViewModel.__rlSettingsData.route
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -103,21 +96,19 @@ export class AbstractSettingsScreen extends AbstractScreen {
|
||||||
}
|
}
|
||||||
|
|
||||||
onBuild() {
|
onBuild() {
|
||||||
// TODO: issue on account switch
|
VIEW_MODELS.forEach(SettingsViewModel => this.menu.push(SettingsViewModel.__rlSettingsData));
|
||||||
// When current domain has sieve but the new has not, or current has not and the new has
|
|
||||||
// SettingsViewModel.disabled() || this.menu.push()
|
|
||||||
VIEW_MODELS.forEach(SettingsViewModel => this.menu.push(SettingsViewModel));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
routes() {
|
routes() {
|
||||||
const DefaultViewModel = VIEW_MODELS.find(
|
const DefaultViewModel = VIEW_MODELS.find(
|
||||||
SettingsViewModel => SettingsViewModel.isDefault
|
SettingsViewModel => SettingsViewModel.__rlSettingsData.isDefault
|
||||||
),
|
),
|
||||||
defaultRoute = DefaultViewModel?.route || 'general',
|
defaultRoute =
|
||||||
|
DefaultViewModel ? DefaultViewModel.__rlSettingsData.route : 'general',
|
||||||
rules = {
|
rules = {
|
||||||
subname: /^(.*)$/,
|
subname: /^(.*)$/,
|
||||||
normalize_: (rquest, vals) => {
|
normalize_: (rquest, vals) => {
|
||||||
vals.subname = pString(vals.subname ?? defaultRoute);
|
vals.subname = null == vals.subname ? defaultRoute : pString(vals.subname);
|
||||||
return [vals.subname];
|
return [vals.subname];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -140,12 +131,13 @@ export class AbstractSettingsScreen extends AbstractScreen {
|
||||||
*/
|
*/
|
||||||
export function settingsAddViewModel(SettingsViewModelClass, template, labelName, route, isDefault = false) {
|
export function settingsAddViewModel(SettingsViewModelClass, template, labelName, route, isDefault = false) {
|
||||||
let name = SettingsViewModelClass.name.replace(/(User|Admin)Settings/, '');
|
let name = SettingsViewModelClass.name.replace(/(User|Admin)Settings/, '');
|
||||||
VIEW_MODELS.push({
|
SettingsViewModelClass.__rlSettingsData = {
|
||||||
vmc: SettingsViewModelClass,
|
|
||||||
label: labelName || 'SETTINGS_LABELS/' + name.toUpperCase(),
|
label: labelName || 'SETTINGS_LABELS/' + name.toUpperCase(),
|
||||||
route: route || name.toLowerCase(),
|
route: route || name.toLowerCase(),
|
||||||
selected: ko.observable(false),
|
selected: ko.observable(false),
|
||||||
template: template || SettingsViewModelClass.name,
|
template: template || SettingsViewModelClass.name,
|
||||||
isDefault: !!isDefault
|
isDefault: !!isDefault
|
||||||
});
|
};
|
||||||
|
|
||||||
|
VIEW_MODELS.push(SettingsViewModelClass);
|
||||||
}
|
}
|
||||||
|
|
|
||||||